From 6e4f52f8a2e510273149acbaf629521d1b4aec2e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 16 Oct 2019 16:16:39 +0200 Subject: 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 ++++++++++++++++ lib/pleroma/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 ++++++++--- .../mastodon_api/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 +- test/web/activity_pub/activity_validator_test.exs | 21 ++++++++ test/web/activity_pub/side_effects_test.exs | 32 ++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 2 +- test/web/activity_pub/views/object_view_test.exs | 2 +- test/web/common_api/common_api_test.exs | 11 +++-- .../controllers/notification_controller_test.exs | 2 +- .../controllers/status_controller_test.exs | 16 +++--- .../mastodon_api/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) -- cgit v1.2.3 From 081e8206ab75e336a76b621508b3999170159ec6 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 16 Oct 2019 17:03:21 +0200 Subject: Transmogrifier: Use new ingestion pipeline for Likes. --- lib/pleroma/object/containment.ex | 12 +++++++++ lib/pleroma/web/activity_pub/object_validator.ex | 5 ++-- lib/pleroma/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 -- cgit v1.2.3 From 66452f518faa1f079f02006943b0c2cdc830b47f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 17 Oct 2019 18:36:52 +0200 Subject: ObjectValidator: Rewrite LikeValidator with Ecto. --- lib/pleroma/web/activity_pub/object_validator.ex | 42 +++--------- .../object_validators/like_validator.ex | 69 +++++++++++++++++++ .../activity_pub/object_validators/types/object.ex | 25 +++++++ lib/pleroma/web/common_api/common_api.ex | 10 --- test/web/activity_pub/activity_validator_test.exs | 21 ------ test/web/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) -- cgit v1.2.3 From 203d61b95012fd2cb8a8618f6f51a3748c940cc1 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 17 Oct 2019 19:35:31 +0200 Subject: Transmogrifier: Make proper use of the LikeValidator. --- lib/pleroma/web/activity_pub/object_validator.ex | 10 ++- .../object_validators/like_validator.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 79 +++++++++++++++------- test/web/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" -- cgit v1.2.3 From 4ec299ea9c1cf45c42e98d7b33f33a72f5e7a9c0 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Oct 2019 12:11:25 +0200 Subject: 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 -- cgit v1.2.3 From 15bbc34c079018f1c988fe9d445bec50e85bbeaf Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 18 Oct 2019 12:44:53 +0200 Subject: 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 -- cgit v1.2.3 From f1381d68e740daf4c341359a2b5837bc2bd3a051 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 19 Oct 2019 14:46:14 +0200 Subject: StatusControllerTest: Capture log. --- .../web/mastodon_api/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 -- cgit v1.2.3 From d4270397dcb2aebde8ed14fd89998ab57aaae545 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 22 Oct 2019 13:42:59 +0300 Subject: Marker: added unread_count field --- lib/pleroma/marker.ex | 5 ++- lib/pleroma/web/mastodon_api/views/marker_view.ex | 1 + .../20191021113356_add_unread_to_marker.exs | 49 ++++++++++++++++++++++ .../controllers/marker_controller_test.exs | 7 +++- test/web/mastodon_api/views/marker_view_test.exs | 4 +- 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 priv/repo/migrations/20191021113356_add_unread_to_marker.exs diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 7f87c86c3..c4d554980 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -18,6 +18,7 @@ defmodule Pleroma.Marker do field(:last_read_id, :string, default: "") field(:timeline, :string, default: "") field(:lock_version, :integer, default: 0) + field(:unread_count, :integer, default: 0) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() @@ -38,7 +39,7 @@ def upsert(%User{} = user, attrs) do Multi.insert(multi, timeline, marker, returning: true, - on_conflict: {:replace, [:last_read_id]}, + on_conflict: {:replace, [:last_read_id, :unread_count]}, conflict_target: [:user_id, :timeline] ) end) @@ -55,7 +56,7 @@ defp get_marker(user, timeline) do @doc false defp changeset(marker, attrs) do marker - |> cast(attrs, [:last_read_id]) + |> cast(attrs, [:last_read_id, :unread_count]) |> validate_required([:user_id, :timeline, :last_read_id]) |> validate_inclusion(:timeline, @timelines) end diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 38fbeed5f..1501c2a30 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -10,6 +10,7 @@ def render("markers.json", %{markers: markers}) do Map.put_new(acc, m.timeline, %{ last_read_id: m.last_read_id, version: m.lock_version, + unread_count: m.unread_count, updated_at: NaiveDateTime.to_iso8601(m.updated_at) }) end) diff --git a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs new file mode 100644 index 000000000..32789b7f9 --- /dev/null +++ b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs @@ -0,0 +1,49 @@ +defmodule Pleroma.Repo.Migrations.AddUnreadToMarker do + use Ecto.Migration + import Ecto.Query + alias Pleroma.Repo + alias Pleroma.Notification + + def up do + alter table(:markers) do + add_if_not_exists(:unread_count, :integer, default: 0) + end + + flush() + + update_markers() + end + + def down do + alter table(:markers) do + remove_if_exists(:unread_count, :integer) + end + end + + def update_markers do + from(q in Notification, + select: %{ + timeline: "notifications", + user_id: q.user_id, + unread_count: fragment("COUNT(*) FILTER (WHERE seen = false) as unread_count"), + last_read_id: fragment("(MAX(id) FILTER (WHERE seen = true)::text) as last_read_id ") + }, + group_by: [q.user_id] + ) + |> Repo.all() + |> Enum.reduce(Ecto.Multi.new(), fn attrs, multi -> + marker = + Pleroma.Marker + |> struct(attrs) + |> Ecto.Changeset.change() + + multi + |> Ecto.Multi.insert(attrs[:user_id], marker, + returning: true, + on_conflict: {:replace, [:last_read_id, :unread_count]}, + conflict_target: [:user_id, :timeline] + ) + end) + |> Pleroma.Repo.transaction() + end +end diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 1fcad873d..5e7b4001f 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -15,7 +15,7 @@ test "gets markers with correct scopes", %{conn: conn} do {:ok, %{"notifications" => marker}} = Pleroma.Marker.upsert( user, - %{"notifications" => %{"last_read_id" => "69420"}} + %{"notifications" => %{"last_read_id" => "69420", "unread_count" => 7}} ) response = @@ -28,6 +28,7 @@ test "gets markers with correct scopes", %{conn: conn} do assert response == %{ "notifications" => %{ "last_read_id" => "69420", + "unread_count" => 7, "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), "version" => 0 } @@ -70,7 +71,8 @@ test "creates a marker with correct scopes", %{conn: conn} do "notifications" => %{ "last_read_id" => "69420", "updated_at" => _, - "version" => 0 + "version" => 0, + "unread_count" => 0 } } = response end @@ -98,6 +100,7 @@ test "updates exist marker", %{conn: conn} do assert response == %{ "notifications" => %{ "last_read_id" => "69888", + "unread_count" => 0, "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), "version" => 0 } diff --git a/test/web/mastodon_api/views/marker_view_test.exs b/test/web/mastodon_api/views/marker_view_test.exs index 8a5c89d56..3ce794617 100644 --- a/test/web/mastodon_api/views/marker_view_test.exs +++ b/test/web/mastodon_api/views/marker_view_test.exs @@ -8,17 +8,19 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do import Pleroma.Factory test "returns markers" do - marker1 = insert(:marker, timeline: "notifications", last_read_id: "17") + marker1 = insert(:marker, timeline: "notifications", last_read_id: "17", unread_count: 5) marker2 = insert(:marker, timeline: "home", last_read_id: "42") assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{ "home" => %{ last_read_id: "42", + unread_count: 0, updated_at: NaiveDateTime.to_iso8601(marker2.updated_at), version: 0 }, "notifications" => %{ last_read_id: "17", + unread_count: 5, updated_at: NaiveDateTime.to_iso8601(marker1.updated_at), version: 0 } -- cgit v1.2.3 From c077dc7af5e2a378223a8d2862df1d52877ea245 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 22 Oct 2019 11:52:21 -0500 Subject: Initial doc about storing remote media --- docs/administration/storing_remote_media.md | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/administration/storing_remote_media.md diff --git a/docs/administration/storing_remote_media.md b/docs/administration/storing_remote_media.md new file mode 100644 index 000000000..7edda2753 --- /dev/null +++ b/docs/administration/storing_remote_media.md @@ -0,0 +1,36 @@ +# Storing Remote Media + +Pleroma does not store remote/federated media by default. The best way to achieve this is to change Nginx to keep its reverse proxy cache +forever and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy +as soon as the post is received by your instance. + +## Nginx + +We should be using `proxy_store` here I think??? + +``` + location ~ ^/(media|proxy) { + proxy_cache pleroma_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_http_version 1.1; + proxy_cache_valid 200 206 301 304 1h; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + proxy_ignore_headers Cache-Control; + proxy_hide_header Cache-Control; + proxy_pass http://127.0.0.1:4000; + } +``` + +## Pleroma + +Add to your `prod.secret.exs`: + +``` +config :pleroma, :instance, + rewrite_policy: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] +``` -- cgit v1.2.3 From a1ad8dc34993445033595c8f52e0ee1815e5567d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 22 Oct 2019 14:07:59 -0500 Subject: Leverage nginx proxy cache to store items with a 1 year TTL with no size limit. It does not purge items when they expire, but will only update them if the origin's copy has changed for some reason. If origin is offline/unavailable or gone forever it will still serve the cached copies. --- docs/administration/storing_remote_media.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/administration/storing_remote_media.md b/docs/administration/storing_remote_media.md index 7edda2753..0abb85a77 100644 --- a/docs/administration/storing_remote_media.md +++ b/docs/administration/storing_remote_media.md @@ -6,22 +6,25 @@ as soon as the post is received by your instance. ## Nginx -We should be using `proxy_store` here I think??? - ``` +proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m + inactive=1y use_temp_path=off; + location ~ ^/(media|proxy) { proxy_cache pleroma_media_cache; slice 1m; proxy_cache_key $host$uri$is_args$args$slice_range; proxy_set_header Range $slice_range; proxy_http_version 1.1; - proxy_cache_valid 200 206 301 304 1h; + proxy_cache_valid 206 301 302 304 1h; + proxy_cache_valid 200 1y; proxy_cache_lock on; + proxy_cache_use_stale error timeout invalid_header updating; proxy_ignore_client_abort on; proxy_buffering on; chunked_transfer_encoding on; - proxy_ignore_headers Cache-Control; - proxy_hide_header Cache-Control; + proxy_ignore_headers Cache-Control Expires; + proxy_hide_header Cache-Control Expires; proxy_pass http://127.0.0.1:4000; } ``` -- cgit v1.2.3 From b9d164fb89af65c2aef83c2867c937ea39a9e995 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 22 Oct 2019 14:12:01 -0500 Subject: Formatting --- docs/administration/storing_remote_media.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/administration/storing_remote_media.md b/docs/administration/storing_remote_media.md index 0abb85a77..74d333342 100644 --- a/docs/administration/storing_remote_media.md +++ b/docs/administration/storing_remote_media.md @@ -7,8 +7,8 @@ as soon as the post is received by your instance. ## Nginx ``` -proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2 keys_zone=pleroma_media_cache:10m - inactive=1y use_temp_path=off; + proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2 + keys_zone=pleroma_media_cache:10m inactive=1y use_temp_path=off; location ~ ^/(media|proxy) { proxy_cache pleroma_media_cache; -- cgit v1.2.3 From 47a551837ade9b5c5b7291c83bc3e787e9c6a17d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 22 Oct 2019 15:13:42 -0500 Subject: Remove proxy_cache_lock suggestion --- docs/administration/storing_remote_media.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/administration/storing_remote_media.md b/docs/administration/storing_remote_media.md index 74d333342..619300e7e 100644 --- a/docs/administration/storing_remote_media.md +++ b/docs/administration/storing_remote_media.md @@ -18,7 +18,6 @@ as soon as the post is received by your instance. proxy_http_version 1.1; proxy_cache_valid 206 301 302 304 1h; proxy_cache_valid 200 1y; - proxy_cache_lock on; proxy_cache_use_stale error timeout invalid_header updating; proxy_ignore_client_abort on; proxy_buffering on; -- cgit v1.2.3 From 752d0c683357277f5926b7b7011b3f945a7610d1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 22 Oct 2019 15:14:04 -0500 Subject: Relocate to configuration subdir --- docs/administration/storing_remote_media.md | 38 ----------------------------- docs/configuration/storing_remote_media.md | 38 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 38 deletions(-) delete mode 100644 docs/administration/storing_remote_media.md create mode 100644 docs/configuration/storing_remote_media.md diff --git a/docs/administration/storing_remote_media.md b/docs/administration/storing_remote_media.md deleted file mode 100644 index 619300e7e..000000000 --- a/docs/administration/storing_remote_media.md +++ /dev/null @@ -1,38 +0,0 @@ -# Storing Remote Media - -Pleroma does not store remote/federated media by default. The best way to achieve this is to change Nginx to keep its reverse proxy cache -forever and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy -as soon as the post is received by your instance. - -## Nginx - -``` - proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2 - keys_zone=pleroma_media_cache:10m inactive=1y use_temp_path=off; - - location ~ ^/(media|proxy) { - proxy_cache pleroma_media_cache; - slice 1m; - proxy_cache_key $host$uri$is_args$args$slice_range; - proxy_set_header Range $slice_range; - proxy_http_version 1.1; - proxy_cache_valid 206 301 302 304 1h; - proxy_cache_valid 200 1y; - proxy_cache_use_stale error timeout invalid_header updating; - proxy_ignore_client_abort on; - proxy_buffering on; - chunked_transfer_encoding on; - proxy_ignore_headers Cache-Control Expires; - proxy_hide_header Cache-Control Expires; - proxy_pass http://127.0.0.1:4000; - } -``` - -## Pleroma - -Add to your `prod.secret.exs`: - -``` -config :pleroma, :instance, - rewrite_policy: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] -``` diff --git a/docs/configuration/storing_remote_media.md b/docs/configuration/storing_remote_media.md new file mode 100644 index 000000000..619300e7e --- /dev/null +++ b/docs/configuration/storing_remote_media.md @@ -0,0 +1,38 @@ +# Storing Remote Media + +Pleroma does not store remote/federated media by default. The best way to achieve this is to change Nginx to keep its reverse proxy cache +forever and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy +as soon as the post is received by your instance. + +## Nginx + +``` + proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2 + keys_zone=pleroma_media_cache:10m inactive=1y use_temp_path=off; + + location ~ ^/(media|proxy) { + proxy_cache pleroma_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_http_version 1.1; + proxy_cache_valid 206 301 302 304 1h; + proxy_cache_valid 200 1y; + proxy_cache_use_stale error timeout invalid_header updating; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + proxy_ignore_headers Cache-Control Expires; + proxy_hide_header Cache-Control Expires; + proxy_pass http://127.0.0.1:4000; + } +``` + +## Pleroma + +Add to your `prod.secret.exs`: + +``` +config :pleroma, :instance, + rewrite_policy: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] +``` -- cgit v1.2.3 From 9a4afbd2a0486238bfaf4047d91376d32635514a Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 22 Oct 2019 16:13:22 +0300 Subject: added update unread_count for notifications --- lib/pleroma/marker.ex | 36 ++++++++++++++++++++++++++++++++ lib/pleroma/notification.ex | 50 ++++++++++++++++++++++++++++++--------------- test/notification_test.exs | 7 +++++++ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index c4d554980..4b8198690 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Marker do alias Ecto.Multi alias Pleroma.Repo alias Pleroma.User + alias __MODULE__ @timelines ["notifications"] @@ -46,6 +47,41 @@ def upsert(%User{} = user, attrs) do |> Repo.transaction() end + @spec multi_set_unread_count(Multi.t(), User.t(), String.t()) :: Multi.t() + def multi_set_unread_count(multi, %User{} = user, "notifications") do + multi + |> Multi.run(:counters, fn _repo, _changes -> + query = + from(q in Pleroma.Notification, + where: q.user_id == ^user.id, + select: %{ + timeline: "notifications", + user_id: ^user.id, + unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END ) as unread_count") + } + ) + + {:ok, Repo.one(query)} + end) + |> Multi.insert( + :marker, + fn %{counters: attrs} -> + Marker + |> struct(attrs) + |> Ecto.Changeset.change() + end, + returning: true, + on_conflict: {:replace, [:last_read_id, :unread_count]}, + conflict_target: [:user_id, :timeline] + ) + end + + def set_unread_count(%User{} = user, timeline) do + Multi.new() + |> multi_set_unread_count(user, timeline) + |> Repo.transaction() + end + defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do {:ok, marker} -> %__MODULE__{marker | user: user} diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index e5da1492b..d339fdf64 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -5,7 +5,9 @@ defmodule Pleroma.Notification do use Ecto.Schema + alias Ecto.Multi alias Pleroma.Activity + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination @@ -151,25 +153,23 @@ def for_user_since(user, date) do |> Repo.all() end - def set_read_up_to(%{id: user_id} = _user, id) do + def set_read_up_to(%{id: user_id} = user, id) do query = from( n in Notification, where: n.user_id == ^user_id, where: n.id <= ^id, where: n.seen == false, - update: [ - set: [ - seen: true, - updated_at: ^NaiveDateTime.utc_now() - ] - ], # Ideally we would preload object and activities here # but Ecto does not support preloads in update_all select: n.id ) - {_, notification_ids} = Repo.update_all(query, []) + {:ok, %{ids: {_, notification_ids}}} = + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_unread_count(user, "notifications") + |> Repo.transaction() Notification |> where([n], n.id in ^notification_ids) @@ -186,11 +186,18 @@ def set_read_up_to(%{id: user_id} = _user, id) do |> Repo.all() end + @spec read_one(User.t(), String.t()) :: + {:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil def read_one(%User{} = user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do - notification - |> changeset(%{seen: true}) - |> Repo.update() + Multi.new() + |> Multi.update(:update, changeset(notification, %{seen: true})) + |> Marker.multi_set_unread_count(user, "notifications") + |> Repo.transaction() + |> case do + {:ok, %{update: notification}} -> {:ok, notification} + {:error, :update, changeset, _} -> {:error, changeset} + end end end @@ -243,8 +250,11 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act 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) + notifications = + activity + |> get_notified_from_activity() + |> Enum.map(&create_notification(activity, &1)) + {:ok, notifications} else {:ok, []} @@ -253,8 +263,11 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) when type in ["Like", "Announce", "Follow"] do - users = get_notified_from_activity(activity) - notifications = Enum.map(users, fn user -> create_notification(activity, user) end) + notifications = + activity + |> get_notified_from_activity + |> Enum.map(&create_notification(activity, &1)) + {:ok, notifications} end @@ -263,8 +276,11 @@ def create_notifications(_), do: {:ok, []} # TODO move to sql, too. def create_notification(%Activity{} = activity, %User{} = user) do unless skip?(activity, user) do - notification = %Notification{user_id: user.id, activity: activity} - {:ok, notification} = Repo.insert(notification) + {:ok, %{notification: notification}} = + Multi.new() + |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) + |> Marker.multi_set_unread_count(user, "notifications") + |> Repo.transaction() ["user", "user:notification"] |> Streamer.stream(notification) diff --git a/test/notification_test.exs b/test/notification_test.exs index 96316f8dd..558ac358c 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -310,6 +310,13 @@ test "it sets all notifications as read up to a specified notification ID" do assert n1.seen == true assert n2.seen == true assert n3.seen == false + + assert %Pleroma.Marker{unread_count: 1} = + Pleroma.Repo.get_by( + Pleroma.Marker, + user_id: other_user.id, + timeline: "notifications" + ) end end -- cgit v1.2.3 From 97d5c79aa07bfe836cd676424ce1b5a298c72b60 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 23 Oct 2019 11:52:27 +0200 Subject: Add Pipeline module, test for federation. --- lib/pleroma/web/activity_pub/activity_pub.ex | 19 +----- lib/pleroma/web/activity_pub/pipeline.ex | 41 ++++++++++++ lib/pleroma/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 -- cgit v1.2.3 From 1adafa096653c4538e4162a2dffba982ee6c6d8e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 23 Oct 2019 12:18:05 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/builder.ex | 4 ++-- lib/pleroma/web/activity_pub/object_validator.ex | 4 ++-- lib/pleroma/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 -- cgit v1.2.3 From 25077812bfc8a7a94c3fa953b2924003296470c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 23 Oct 2019 12:25:20 +0200 Subject: 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 -- cgit v1.2.3 From aa64b3108ba6aa4294e541e86da323ba1e1a7243 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 23 Oct 2019 11:54:52 +0300 Subject: fix migrate --- lib/pleroma/marker.ex | 5 +++-- .../migrations/20191021113356_add_unread_to_marker.exs | 18 +++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 4b8198690..098fe3bbd 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -56,8 +56,9 @@ def multi_set_unread_count(multi, %User{} = user, "notifications") do where: q.user_id == ^user.id, select: %{ timeline: "notifications", - user_id: ^user.id, - unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END ) as unread_count") + user_id: type(^user.id, :string), + unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), + last_read_id: type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) } ) diff --git a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs index 32789b7f9..964c7fb98 100644 --- a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs +++ b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs @@ -25,25 +25,21 @@ def update_markers do select: %{ timeline: "notifications", user_id: q.user_id, - unread_count: fragment("COUNT(*) FILTER (WHERE seen = false) as unread_count"), - last_read_id: fragment("(MAX(id) FILTER (WHERE seen = true)::text) as last_read_id ") + unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), + last_read_id: type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) }, group_by: [q.user_id] ) |> Repo.all() - |> Enum.reduce(Ecto.Multi.new(), fn attrs, multi -> - marker = - Pleroma.Marker - |> struct(attrs) - |> Ecto.Changeset.change() - - multi - |> Ecto.Multi.insert(attrs[:user_id], marker, + |> Enum.each(fn attrs -> + Pleroma.Marker + |> struct(attrs) + |> Ecto.Changeset.change() + |> Pleroma.Repo.insert( returning: true, on_conflict: {:replace, [:last_read_id, :unread_count]}, conflict_target: [:user_id, :timeline] ) end) - |> Pleroma.Repo.transaction() end end -- cgit v1.2.3 From d3fb9e02cc0ce7dc462e587e639e117aaef5fbc5 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 23 Oct 2019 22:48:04 +0300 Subject: add tests --- lib/pleroma/marker.ex | 9 +++------ .../migrations/20191021113356_add_unread_to_marker.exs | 3 ++- test/marker_test.exs | 15 +++++++++++++++ test/notification_test.exs | 3 +++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 098fe3bbd..5f6a47f38 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -58,7 +58,8 @@ def multi_set_unread_count(multi, %User{} = user, "notifications") do timeline: "notifications", user_id: type(^user.id, :string), unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) } ) @@ -77,11 +78,7 @@ def multi_set_unread_count(multi, %User{} = user, "notifications") do ) end - def set_unread_count(%User{} = user, timeline) do - Multi.new() - |> multi_set_unread_count(user, timeline) - |> Repo.transaction() - end + def multi_set_unread_count(multi, _, _), do: multi defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do diff --git a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs index 964c7fb98..c15e2ff13 100644 --- a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs +++ b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs @@ -26,7 +26,8 @@ def update_markers do timeline: "notifications", user_id: q.user_id, unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) }, group_by: [q.user_id] ) diff --git a/test/marker_test.exs b/test/marker_test.exs index 04bd67fe6..1900ed08b 100644 --- a/test/marker_test.exs +++ b/test/marker_test.exs @@ -8,6 +8,21 @@ defmodule Pleroma.MarkerTest do import Pleroma.Factory + describe "multi_set_unread_count/3" do + test "returns multi" do + user = insert(:user) + + assert %Ecto.Multi{ + operations: [marker: {:run, _}, counters: {:run, _}] + } = + Marker.multi_set_unread_count( + Ecto.Multi.new(), + user, + "notifications" + ) + end + end + describe "get_markers/2" do test "returns user markers" do user = insert(:user) diff --git a/test/notification_test.exs b/test/notification_test.exs index 558ac358c..1e8a9ca98 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -31,6 +31,9 @@ test "notifies someone when they are directly addressed" do assert notified_ids == [other_user.id, third_user.id] assert notification.activity_id == activity.id assert other_notification.activity_id == activity.id + + assert [%Pleroma.Marker{unread_count: 2}] = + Pleroma.Marker.get_markers(other_user, ["notifications"]) end test "it creates a notification for subscribed users" do -- cgit v1.2.3 From 922e3d082c38ccd108710e21d4bda8e65b551f9c Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 24 Oct 2019 09:50:41 +0300 Subject: add test --- lib/pleroma/marker.ex | 14 +------------- lib/pleroma/notification.ex | 14 ++++++++++++++ test/marker_test.exs | 6 ++++++ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 5f6a47f38..a7ea542dd 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -51,19 +51,7 @@ def upsert(%User{} = user, attrs) do def multi_set_unread_count(multi, %User{} = user, "notifications") do multi |> Multi.run(:counters, fn _repo, _changes -> - query = - from(q in Pleroma.Notification, - where: q.user_id == ^user.id, - select: %{ - timeline: "notifications", - user_id: type(^user.id, :string), - unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) - } - ) - - {:ok, Repo.one(query)} + {:ok, Repo.one(Pleroma.Notification.notifications_info_query(user))} end) |> Multi.insert( :marker, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index af56cc667..373f9b06a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -36,6 +36,20 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end + @spec notifications_info_query(User.t()) :: Ecto.Queryable.t() + def notifications_info_query(user) do + from(q in Pleroma.Notification, + where: q.user_id == ^user.id, + select: %{ + timeline: "notifications", + user_id: type(^user.id, :string), + unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + } + ) + end + def for_user_query(user, opts \\ []) do Notification |> where(user_id: ^user.id) diff --git a/test/marker_test.exs b/test/marker_test.exs index 1900ed08b..5d03db48e 100644 --- a/test/marker_test.exs +++ b/test/marker_test.exs @@ -21,6 +21,12 @@ test "returns multi" do "notifications" ) end + + test "return empty multi" do + user = insert(:user) + multi = Ecto.Multi.new() + assert Marker.multi_set_unread_count(multi, user, "home") == multi + end end describe "get_markers/2" do -- cgit v1.2.3 From 1b82eb6d4102bc2d7acec0a905e7714c95eadc94 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 30 Oct 2019 23:22:38 +0300 Subject: move sql (update_markers) from migrate to mix task --- lib/mix/tasks/pleroma/marker.ex | 36 +++++++++++++++++ .../20191021113356_add_unread_to_marker.exs | 46 ---------------------- .../20191030202008_add_unread_to_marker.exs | 18 +++++++++ 3 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 lib/mix/tasks/pleroma/marker.ex delete mode 100644 priv/repo/migrations/20191021113356_add_unread_to_marker.exs create mode 100644 priv/repo/migrations/20191030202008_add_unread_to_marker.exs diff --git a/lib/mix/tasks/pleroma/marker.ex b/lib/mix/tasks/pleroma/marker.ex new file mode 100644 index 000000000..1d5be11de --- /dev/null +++ b/lib/mix/tasks/pleroma/marker.ex @@ -0,0 +1,36 @@ +defmodule Mix.Tasks.Pleroma.Marker do + use Mix.Task + import Mix.Pleroma + + import Ecto.Query + alias Pleroma.Repo + alias Pleroma.Notification + + def run(["update_markers"]) do + start_pleroma() + + from(q in Notification, + select: %{ + timeline: "notifications", + user_id: q.user_id, + unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + }, + group_by: [q.user_id] + ) + |> Repo.all() + |> Enum.each(fn attrs -> + Pleroma.Marker + |> struct(attrs) + |> Ecto.Changeset.change() + |> Pleroma.Repo.insert( + returning: true, + on_conflict: {:replace, [:last_read_id, :unread_count]}, + conflict_target: [:user_id, :timeline] + ) + end) + + shell_info("Done") + end +end diff --git a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs b/priv/repo/migrations/20191021113356_add_unread_to_marker.exs deleted file mode 100644 index c15e2ff13..000000000 --- a/priv/repo/migrations/20191021113356_add_unread_to_marker.exs +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Pleroma.Repo.Migrations.AddUnreadToMarker do - use Ecto.Migration - import Ecto.Query - alias Pleroma.Repo - alias Pleroma.Notification - - def up do - alter table(:markers) do - add_if_not_exists(:unread_count, :integer, default: 0) - end - - flush() - - update_markers() - end - - def down do - alter table(:markers) do - remove_if_exists(:unread_count, :integer) - end - end - - def update_markers do - from(q in Notification, - select: %{ - timeline: "notifications", - user_id: q.user_id, - unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) - }, - group_by: [q.user_id] - ) - |> Repo.all() - |> Enum.each(fn attrs -> - Pleroma.Marker - |> struct(attrs) - |> Ecto.Changeset.change() - |> Pleroma.Repo.insert( - returning: true, - on_conflict: {:replace, [:last_read_id, :unread_count]}, - conflict_target: [:user_id, :timeline] - ) - end) - end -end diff --git a/priv/repo/migrations/20191030202008_add_unread_to_marker.exs b/priv/repo/migrations/20191030202008_add_unread_to_marker.exs new file mode 100644 index 000000000..f81339c9f --- /dev/null +++ b/priv/repo/migrations/20191030202008_add_unread_to_marker.exs @@ -0,0 +1,18 @@ +defmodule Pleroma.Repo.Migrations.AddUnreadToMarker do + use Ecto.Migration + import Ecto.Query + alias Pleroma.Repo + alias Pleroma.Notification + + def up do + alter table(:markers) do + add_if_not_exists(:unread_count, :integer, default: 0) + end + end + + def down do + alter table(:markers) do + remove_if_exists(:unread_count, :integer) + end + end +end -- cgit v1.2.3 From 209319c8d289564653f73cbf15fb6449d91cf3ca Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 30 Oct 2019 23:49:05 +0300 Subject: update marker api --- lib/pleroma/web/mastodon_api/views/marker_view.ex | 6 ++++-- .../controllers/marker_controller_test.exs | 20 ++++++++++---------- test/web/mastodon_api/views/marker_view_test.exs | 8 ++++---- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 1501c2a30..81545cff0 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -10,8 +10,10 @@ def render("markers.json", %{markers: markers}) do Map.put_new(acc, m.timeline, %{ last_read_id: m.last_read_id, version: m.lock_version, - unread_count: m.unread_count, - updated_at: NaiveDateTime.to_iso8601(m.updated_at) + updated_at: NaiveDateTime.to_iso8601(m.updated_at), + pleroma: %{ + unread_count: m.unread_count + } }) end) end diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 5e7b4001f..e0aacccb4 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -26,13 +26,13 @@ test "gets markers with correct scopes", %{conn: conn} do |> json_response(200) assert response == %{ - "notifications" => %{ - "last_read_id" => "69420", - "unread_count" => 7, - "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0 - } - } + "notifications" => %{ + "last_read_id" => "69420", + "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), + "version" => 0, + "pleroma" => %{ "unread_count" => 7 } + } + } end test "gets markers with missed scopes", %{conn: conn} do @@ -72,7 +72,7 @@ test "creates a marker with correct scopes", %{conn: conn} do "last_read_id" => "69420", "updated_at" => _, "version" => 0, - "unread_count" => 0 + "pleroma" => %{ "unread_count" => 0 } } } = response end @@ -100,9 +100,9 @@ test "updates exist marker", %{conn: conn} do assert response == %{ "notifications" => %{ "last_read_id" => "69888", - "unread_count" => 0, "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0 + "version" => 0, + "pleroma" => %{ "unread_count" => 0 } } } end diff --git a/test/web/mastodon_api/views/marker_view_test.exs b/test/web/mastodon_api/views/marker_view_test.exs index 3ce794617..f172e5023 100644 --- a/test/web/mastodon_api/views/marker_view_test.exs +++ b/test/web/mastodon_api/views/marker_view_test.exs @@ -14,15 +14,15 @@ test "returns markers" do assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{ "home" => %{ last_read_id: "42", - unread_count: 0, updated_at: NaiveDateTime.to_iso8601(marker2.updated_at), - version: 0 + version: 0, + pleroma: %{unread_count: 0} }, "notifications" => %{ last_read_id: "17", - unread_count: 5, updated_at: NaiveDateTime.to_iso8601(marker1.updated_at), - version: 0 + version: 0, + pleroma: %{unread_count: 5} } } end -- cgit v1.2.3 From 58da7f66202f3f2e90d4954649c3e5a0e3a7dc32 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 31 Oct 2019 17:34:17 +0300 Subject: updated docs\changelog --- CHANGELOG.md | 1 + docs/API/differences_in_mastoapi_responses.md | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e5424c6..c14c8c0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Add `pleroma.direct_conversation_id` to the status endpoint (`GET /api/v1/statuses/:id`) - Mastodon API: `pleroma.thread_muted` to the Status entity - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message +- Mastodon API: Add `pleroma.unread_count` to the Marker entity ### Added diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index aca0f5e0e..d18b976b6 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -155,3 +155,9 @@ Has theses additionnal parameters (which are the same as in Pleroma-API): * `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. + +## Markers + +Has these additional fields under the `pleroma` object: + +- `unread_count`: contains number unread notifications -- cgit v1.2.3 From 1b3a942a84b8b612e07e3bf34801137741926911 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 31 Oct 2019 17:36:59 +0300 Subject: fix format --- lib/mix/tasks/pleroma/marker.ex | 10 +++++----- .../controllers/marker_controller_test.exs | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/mix/tasks/pleroma/marker.ex b/lib/mix/tasks/pleroma/marker.ex index 1d5be11de..bebef0d6a 100644 --- a/lib/mix/tasks/pleroma/marker.ex +++ b/lib/mix/tasks/pleroma/marker.ex @@ -1,10 +1,10 @@ defmodule Mix.Tasks.Pleroma.Marker do use Mix.Task import Mix.Pleroma - import Ecto.Query - alias Pleroma.Repo + alias Pleroma.Notification + alias Pleroma.Repo def run(["update_markers"]) do start_pleroma() @@ -15,7 +15,7 @@ def run(["update_markers"]) do user_id: q.user_id, unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) }, group_by: [q.user_id] ) @@ -26,8 +26,8 @@ def run(["update_markers"]) do |> Ecto.Changeset.change() |> Pleroma.Repo.insert( returning: true, - on_conflict: {:replace, [:last_read_id, :unread_count]}, - conflict_target: [:user_id, :timeline] + on_conflict: {:replace, [:last_read_id, :unread_count]}, + conflict_target: [:user_id, :timeline] ) end) diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index e0aacccb4..8bcfcb7e1 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -26,13 +26,13 @@ test "gets markers with correct scopes", %{conn: conn} do |> json_response(200) assert response == %{ - "notifications" => %{ - "last_read_id" => "69420", - "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0, - "pleroma" => %{ "unread_count" => 7 } - } - } + "notifications" => %{ + "last_read_id" => "69420", + "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), + "version" => 0, + "pleroma" => %{"unread_count" => 7} + } + } end test "gets markers with missed scopes", %{conn: conn} do @@ -72,7 +72,7 @@ test "creates a marker with correct scopes", %{conn: conn} do "last_read_id" => "69420", "updated_at" => _, "version" => 0, - "pleroma" => %{ "unread_count" => 0 } + "pleroma" => %{"unread_count" => 0} } } = response end @@ -102,7 +102,7 @@ test "updates exist marker", %{conn: conn} do "last_read_id" => "69888", "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), "version" => 0, - "pleroma" => %{ "unread_count" => 0 } + "pleroma" => %{"unread_count" => 0} } } end -- cgit v1.2.3 From 57995fa8cf26c9d5cd31969b59dbafb9f8c8fdc7 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Sat, 2 Nov 2019 21:19:01 +0300 Subject: fix migrate update migrate --- lib/mix/tasks/pleroma/marker.ex | 36 ---------------------- .../20191030202008_add_unread_to_marker.exs | 32 ++++++++++++++++++- 2 files changed, 31 insertions(+), 37 deletions(-) delete mode 100644 lib/mix/tasks/pleroma/marker.ex diff --git a/lib/mix/tasks/pleroma/marker.ex b/lib/mix/tasks/pleroma/marker.ex deleted file mode 100644 index bebef0d6a..000000000 --- a/lib/mix/tasks/pleroma/marker.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule Mix.Tasks.Pleroma.Marker do - use Mix.Task - import Mix.Pleroma - import Ecto.Query - - alias Pleroma.Notification - alias Pleroma.Repo - - def run(["update_markers"]) do - start_pleroma() - - from(q in Notification, - select: %{ - timeline: "notifications", - user_id: q.user_id, - unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) - }, - group_by: [q.user_id] - ) - |> Repo.all() - |> Enum.each(fn attrs -> - Pleroma.Marker - |> struct(attrs) - |> Ecto.Changeset.change() - |> Pleroma.Repo.insert( - returning: true, - on_conflict: {:replace, [:last_read_id, :unread_count]}, - conflict_target: [:user_id, :timeline] - ) - end) - - shell_info("Done") - end -end diff --git a/priv/repo/migrations/20191030202008_add_unread_to_marker.exs b/priv/repo/migrations/20191030202008_add_unread_to_marker.exs index f81339c9f..2b3abc682 100644 --- a/priv/repo/migrations/20191030202008_add_unread_to_marker.exs +++ b/priv/repo/migrations/20191030202008_add_unread_to_marker.exs @@ -2,12 +2,15 @@ defmodule Pleroma.Repo.Migrations.AddUnreadToMarker do use Ecto.Migration import Ecto.Query alias Pleroma.Repo - alias Pleroma.Notification def up do alter table(:markers) do add_if_not_exists(:unread_count, :integer, default: 0) end + + flush() + + update_markers() end def down do @@ -15,4 +18,31 @@ def down do remove_if_exists(:unread_count, :integer) end end + + def update_markers do + now = NaiveDateTime.utc_now() + + markers_attrs = + from(q in "notifications", + select: %{ + timeline: "notifications", + user_id: q.user_id, + unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + }, + group_by: [q.user_id] + ) + |> Repo.all() + |> Enum.map(fn attrs -> + attrs + |> Map.put_new(:inserted_at, now) + |> Map.put_new(:updated_at, now) + end) + + Repo.insert_all("markers", markers_attrs, + on_conflict: {:replace, [:last_read_id, :unread_count]}, + conflict_target: [:user_id, :timeline] + ) + end end -- cgit v1.2.3 From 3d1b445cbf001f76af614441c241dcc299e76af7 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 Nov 2019 15:02:09 +0100 Subject: Object Validators: Extract common validations. --- lib/pleroma/web/activity_pub/object_validator.ex | 7 +++-- .../object_validators/common_validations.ex | 32 ++++++++++++++++++++++ .../object_validators/like_validator.ex | 26 ++++-------------- lib/pleroma/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 -- cgit v1.2.3 From faced6236b9e2ce9675cf743068f16098b744562 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 Nov 2019 15:02:31 +0100 Subject: 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 -- cgit v1.2.3 From ddbfc995ac40db9bd1da137b03e5acf6d050ddc5 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 11 Nov 2019 17:06:41 +0300 Subject: clean sql query --- lib/pleroma/marker.ex | 2 +- lib/pleroma/notification.ex | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index a7ea542dd..d5ca27bf2 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -56,7 +56,7 @@ def multi_set_unread_count(multi, %User{} = user, "notifications") do |> Multi.insert( :marker, fn %{counters: attrs} -> - Marker + %Marker{timeline: "notifications", user_id: user.id} |> struct(attrs) |> Ecto.Changeset.change() end, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 373f9b06a..158903c4b 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -41,8 +41,6 @@ def notifications_info_query(user) do from(q in Pleroma.Notification, where: q.user_id == ^user.id, select: %{ - timeline: "notifications", - user_id: type(^user.id, :string), unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), last_read_id: type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) -- cgit v1.2.3 From b5b62f42b2864dc8b95c8ba7d650321ebcc332ad Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 12 Nov 2019 15:59:34 +0300 Subject: update Marker.multi_set_unread_count --- lib/pleroma/marker.ex | 7 ++++++- lib/pleroma/notification.ex | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index d5ca27bf2..a32546094 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Marker do import Ecto.Query alias Ecto.Multi + alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User alias __MODULE__ @@ -51,7 +52,11 @@ def upsert(%User{} = user, attrs) do def multi_set_unread_count(multi, %User{} = user, "notifications") do multi |> Multi.run(:counters, fn _repo, _changes -> - {:ok, Repo.one(Pleroma.Notification.notifications_info_query(user))} + {:ok, + %{ + unread_count: Repo.aggregate(Notification.unread_count_query(user), :count, :id), + last_read_id: Repo.one(Notification.last_read_query(user)) + }} end) |> Multi.insert( :marker, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 158903c4b..1cc6a4735 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -36,15 +36,22 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end - @spec notifications_info_query(User.t()) :: Ecto.Queryable.t() - def notifications_info_query(user) do + @spec unread_count_query(User.t()) :: Ecto.Queryable.t() + def unread_count_query(user) do from(q in Pleroma.Notification, where: q.user_id == ^user.id, - select: %{ - unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) - } + where: q.seen == false + ) + end + + @spec last_read_query(User.t()) :: Ecto.Queryable.t() + def last_read_query(user) do + from(q in Pleroma.Notification, + where: q.user_id == ^user.id, + where: q.seen == true, + select: type(q.id, :string), + limit: 1, + order_by: [desc: :id] ) end -- cgit v1.2.3 From b9041c209787dc279d4dc5194d65dff73684cdb9 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 15 Nov 2019 22:10:41 +0300 Subject: added recount unread notifications to markers --- lib/pleroma/marker.ex | 29 ++++++++++++++++++++-- .../mastodon_api/controllers/marker_controller.ex | 8 +++++- test/marker_test.exs | 14 +++++++++++ .../controllers/marker_controller_test.exs | 3 ++- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index a32546094..2d217a0b7 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Marker do alias __MODULE__ @timelines ["notifications"] + @type t :: %__MODULE__{} schema "markers" do field(:last_read_id, :string, default: "") @@ -26,8 +27,18 @@ defmodule Pleroma.Marker do timestamps() end - def get_markers(user, timelines \\ []) do - Repo.all(get_query(user, timelines)) + @doc """ + Gets markers by user and timeline. + + opts: + `recount_unread` - run force recount unread notifications for `true` value + """ + @spec get_markers(User.t(), list(String), map()) :: list(t()) + def get_markers(user, timelines \\ [], opts \\ %{}) do + user + |> get_query(timelines) + |> recount_unread_notifications(opts[:recount_unread]) + |> Repo.all() end def upsert(%User{} = user, attrs) do @@ -99,4 +110,18 @@ defp get_query(user, timelines) do |> by_user_id(user.id) |> by_timeline(timelines) end + + defp recount_unread_notifications(query, true) do + from( + q in query, + left_join: n in "notifications", + on: n.user_id == q.user_id and n.seen == false, + group_by: [:id], + select_merge: %{ + unread_count: fragment("count(?)", n.id) + } + ) + end + + defp recount_unread_notifications(query, _), do: query end diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex index ce025624d..6649ffbda 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -18,7 +18,13 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do # GET /api/v1/markers def index(%{assigns: %{user: user}} = conn, params) do - markers = Pleroma.Marker.get_markers(user, params["timeline"]) + markers = + Pleroma.Marker.get_markers( + user, + params["timeline"], + %{recount_unread: true} + ) + render(conn, "markers.json", %{markers: markers}) end diff --git a/test/marker_test.exs b/test/marker_test.exs index 5d03db48e..7b1d2218a 100644 --- a/test/marker_test.exs +++ b/test/marker_test.exs @@ -36,6 +36,20 @@ test "returns user markers" do insert(:marker, timeline: "home", user: user) assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)] end + + test "returns user markers with recount unread notifications" do + user = insert(:user) + marker = insert(:marker, user: user) + insert(:notification, user: user) + insert(:notification, user: user) + insert(:marker, timeline: "home", user: user) + + assert Marker.get_markers( + user, + ["notifications"], + %{recount_unread: true} + ) == [%Marker{refresh_record(marker) | unread_count: 2}] + end end describe "upsert/2" do diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 8bcfcb7e1..64bf79bb1 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -11,11 +11,12 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do test "gets markers with correct scopes", %{conn: conn} do user = insert(:user) token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) + insert_list(7, :notification, user: user) {:ok, %{"notifications" => marker}} = Pleroma.Marker.upsert( user, - %{"notifications" => %{"last_read_id" => "69420", "unread_count" => 7}} + %{"notifications" => %{"last_read_id" => "69420"}} ) response = -- cgit v1.2.3 From 1993d7096d673d8a8151fedd7bcac909d584d13d Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 5 Dec 2019 12:33:06 +0100 Subject: Validators: Add a type for the datetime used in AP. --- .../object_validators/note_validator.ex | 3 +- .../object_validators/types/date_time.ex | 34 ++++++++++++++++++++++ .../object_validators/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 -- cgit v1.2.3 From d4bafabfd14887e61eb5bc1d877035dcfebbd33f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 9 Dec 2019 10:39:14 +0100 Subject: 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 -- cgit v1.2.3 From cd040691bd28fea1437b8f1c39bb914465e1ff46 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 10 Feb 2020 09:01:45 +0300 Subject: maked `unread_count` as virtual field --- lib/pleroma/marker.ex | 31 ++-- lib/pleroma/notification.ex | 14 +- .../mastodon_api/controllers/marker_controller.ex | 8 +- mix.lock | 192 ++++++++++----------- .../20191030202008_add_unread_to_marker.exs | 48 ------ .../migrations/20200210050658_update_markers.exs | 39 +++++ test/marker_test.exs | 14 +- test/notification_test.exs | 5 +- 8 files changed, 159 insertions(+), 192 deletions(-) delete mode 100644 priv/repo/migrations/20191030202008_add_unread_to_marker.exs create mode 100644 priv/repo/migrations/20200210050658_update_markers.exs diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 2d217a0b7..dab97d8b6 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Marker do field(:last_read_id, :string, default: "") field(:timeline, :string, default: "") field(:lock_version, :integer, default: 0) - field(:unread_count, :integer, default: 0) + field(:unread_count, :integer, default: 0, virtual: true) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() @@ -33,14 +33,15 @@ defmodule Pleroma.Marker do opts: `recount_unread` - run force recount unread notifications for `true` value """ - @spec get_markers(User.t(), list(String), map()) :: list(t()) - def get_markers(user, timelines \\ [], opts \\ %{}) do + @spec get_markers(User.t(), list(String)) :: list(t()) + def get_markers(user, timelines \\ []) do user |> get_query(timelines) - |> recount_unread_notifications(opts[:recount_unread]) + |> unread_count_query() |> Repo.all() end + @spec upsert(User.t(), map()) :: {:ok | :error, any()} def upsert(%User{} = user, attrs) do attrs |> Map.take(@timelines) @@ -52,22 +53,18 @@ def upsert(%User{} = user, attrs) do Multi.insert(multi, timeline, marker, returning: true, - on_conflict: {:replace, [:last_read_id, :unread_count]}, + on_conflict: {:replace, [:last_read_id]}, conflict_target: [:user_id, :timeline] ) end) |> Repo.transaction() end - @spec multi_set_unread_count(Multi.t(), User.t(), String.t()) :: Multi.t() - def multi_set_unread_count(multi, %User{} = user, "notifications") do + @spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t() + def multi_set_last_read_id(multi, %User{} = user, "notifications") do multi |> Multi.run(:counters, fn _repo, _changes -> - {:ok, - %{ - unread_count: Repo.aggregate(Notification.unread_count_query(user), :count, :id), - last_read_id: Repo.one(Notification.last_read_query(user)) - }} + {:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}} end) |> Multi.insert( :marker, @@ -77,12 +74,12 @@ def multi_set_unread_count(multi, %User{} = user, "notifications") do |> Ecto.Changeset.change() end, returning: true, - on_conflict: {:replace, [:last_read_id, :unread_count]}, + on_conflict: {:replace, [:last_read_id]}, conflict_target: [:user_id, :timeline] ) end - def multi_set_unread_count(multi, _, _), do: multi + def multi_set_last_read_id(multi, _, _), do: multi defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do @@ -94,7 +91,7 @@ defp get_marker(user, timeline) do @doc false defp changeset(marker, attrs) do marker - |> cast(attrs, [:last_read_id, :unread_count]) + |> cast(attrs, [:last_read_id]) |> validate_required([:user_id, :timeline, :last_read_id]) |> validate_inclusion(:timeline, @timelines) end @@ -111,7 +108,7 @@ defp get_query(user, timelines) do |> by_timeline(timelines) end - defp recount_unread_notifications(query, true) do + defp unread_count_query(query) do from( q in query, left_join: n in "notifications", @@ -122,6 +119,4 @@ defp recount_unread_notifications(query, true) do } ) end - - defp recount_unread_notifications(query, _), do: query end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 2e4fe2edb..70fd97bfa 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -38,14 +38,6 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end - @spec unread_count_query(User.t()) :: Ecto.Queryable.t() - def unread_count_query(user) do - from(q in Pleroma.Notification, - where: q.user_id == ^user.id, - where: q.seen == false - ) - end - @spec last_read_query(User.t()) :: Ecto.Queryable.t() def last_read_query(user) do from(q in Pleroma.Notification, @@ -229,7 +221,7 @@ def set_read_up_to(%{id: user_id} = user, id) do {:ok, %{ids: {_, notification_ids}}} = Multi.new() |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) - |> Marker.multi_set_unread_count(user, "notifications") + |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() Notification @@ -253,7 +245,7 @@ def read_one(%User{} = user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do Multi.new() |> Multi.update(:update, changeset(notification, %{seen: true})) - |> Marker.multi_set_unread_count(user, "notifications") + |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() |> case do {:ok, %{update: notification}} -> {:ok, notification} @@ -340,7 +332,7 @@ def create_notification(%Activity{} = activity, %User{} = user) do {:ok, %{notification: notification}} = Multi.new() |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) - |> Marker.multi_set_unread_count(user, "notifications") + |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() ["user", "user:notification"] diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex index 6649ffbda..ce025624d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -18,13 +18,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do # GET /api/v1/markers def index(%{assigns: %{user: user}} = conn, params) do - markers = - Pleroma.Marker.get_markers( - user, - params["timeline"], - %{recount_unread: true} - ) - + markers = Pleroma.Marker.get_markers(user, params["timeline"]) render(conn, "markers.json", %{markers: markers}) end diff --git a/mix.lock b/mix.lock index b8a35a795..1893e7e41 100644 --- a/mix.lock +++ b/mix.lock @@ -1,112 +1,112 @@ %{ - "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm"}, + "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, - "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm"}, - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, - "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "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"}, - "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"}, + "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, + "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, + "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "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"]}, - "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, - "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"}, - "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, - "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, - "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"}, - "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, + "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"}, + "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, + "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, + "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, + "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, + "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, + "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "48e513299cd28b12c77266c0ed5b1c844368e5c1823724994ae84834f43d6bbe"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, - "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm"}, - "db_connection": {:hex, :db_connection, "2.2.0", "e923e88887cd60f9891fd324ac5e0290954511d090553c415fbf54be4c57ee63", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, - "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm"}, - "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm"}, - "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm"}, - "ecto": {:hex, :ecto, "3.3.1", "82ab74298065bf0c64ca299f6c6785e68ea5d6b980883ee80b044499df35aba1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, - "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"}, - "ecto_sql": {:hex, :ecto_sql, "3.3.2", "92804e0de69bb63e621273c3492252cb08a29475c05d40eeb6f41ad2d483cfd3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 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.0", "6f93a2062adb43637edad0ea7357db2702a4b80dd9683482fe00f5134e97f4c1", [:mix], [], "hexpm"}, - "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm"}, + "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, + "db_connection": {:hex, :db_connection, "2.2.0", "e923e88887cd60f9891fd324ac5e0290954511d090553c415fbf54be4c57ee63", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "bdf196feedfa6b83071e808b2b086fb113f8a1c4c7761f6eff6fe4b96aba0086"}, + "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.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, + "ecto": {:hex, :ecto, "3.3.1", "82ab74298065bf0c64ca299f6c6785e68ea5d6b980883ee80b044499df35aba1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "e6c614dfe3bcff2d575ce16d815dbd43f4ee1844599a83de1eea81976a31c174"}, + "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.2", "92804e0de69bb63e621273c3492252cb08a29475c05d40eeb6f41ad2d483cfd3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 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", "b82d89d4e6a9f7f7f04783b07e8b0af968e0be2f01ee4b39047fe727c5c07471"}, + "esshd": {:hex, :esshd, "0.1.0", "6f93a2062adb43637edad0ea7357db2702a4b80dd9683482fe00f5134e97f4c1", [:mix], [], "hexpm", "98d0f3c6f4b8a0333170df770c6fe772b3d04564fb514c1a09504cf5ab2f48a5"}, + "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"}, - "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"}, - "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "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"}, + "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, + "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, + "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "ex_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": {:git, "https://github.com/slashmili/ex_syslogger.git", "f3963399047af17e038897c69e20d552e6899e1d", [tag: "1.4.0"]}, - "excoveralls": {:hex, :excoveralls, "0.11.2", "0c6f2c8db7683b0caa9d490fb8125709c54580b4255ffa7ad35f3264b075a643", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, - "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "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"}, - "floki": {:hex, :floki, "0.23.1", "e100306ce7d8841d70a559748e5091542e2cfc67ffb3ade92b89a8435034dab1", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm"}, - "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm"}, - "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.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm"}, - "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"}, - "html_entities": {:hex, :html_entities, "0.5.0", "40f5c5b9cbe23073b48a4e69c67b6c11974f623a76165e2b92d098c0e88ccb1d", [:mix], [], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.11.2", "0c6f2c8db7683b0caa9d490fb8125709c54580b4255ffa7ad35f3264b075a643", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e11a4490976aabeed3eb9dc70ec94a4f2d11fed5c9d4b5dc5d89bfa0a215abb5"}, + "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"}, + "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, + "floki": {:hex, :floki, "0.23.1", "e100306ce7d8841d70a559748e5091542e2cfc67ffb3ade92b89a8435034dab1", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "39b431b6330206cadee418e793177401ebedf2e86abc945ddd545aedb37dfc19"}, + "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, + "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm", "8453e2289d94c3199396eb517d65d6715ef26bcae0ee83eb5ff7a84445458d76"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm", "5cacd405e72b2609a7e1f891bddb80c53d0b3b7b0036d1648e7382ca108c41c8"}, + "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm", "f7d97341e536f95b96eef2988d6d4230f7262cf239cda0e2e63123ee0b717222"}, + "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.0", "40f5c5b9cbe23073b48a4e69c67b6c11974f623a76165e2b92d098c0e88ccb1d", [:mix], [], "hexpm", "8e9186e1873bea1067895f6a542b59df6c9fcf3b516ba272eeff3ea0c7b755cd"}, "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.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, - "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, - "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"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, + "httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "89149056039084024a284cd703b2d1900d584958dba432132cb21ef35aed7487"}, + "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.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "eb02df7d5526df13063397e051b926b7006d5986d66f399eefc474f560cdad6a"}, + "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm", "6429c4fee52b2dda7861ee19a4f09c8c1ffa213bee3a1ec187828fde95d447ed"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm", "1feaf05ee886815ad047cad7ede17d6910710986148ae09cf73eee2989717b81"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, - "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, - "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm"}, - "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"}, + "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"}, + "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, + "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, "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"}, - "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, - "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm"}, - "phoenix": {:hex, :phoenix, "1.4.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, - "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"}, - "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"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [: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.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, - "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, - "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"}, - "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm"}, - "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"}, - "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, - "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"}, - "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"}, - "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"}, - "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, + "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.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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", "256ad7a140efadc3f0290470369da5bd3de985ec7c706eba07c2641b228974be"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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", "fe15d9fee5b82f5e64800502011ffe530650d42e1710ae9b14bc4c9be38bf303"}, + "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8b01b3d6d39731ab18aa548d928b5796166d2500755f553725cfe967bafba7d9"}, + "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.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, + "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, + "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, + "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, + "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, + "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, + "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"}, + "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm", "6de553ba9ac0668d3728b699d5065543f3e40c854154017461ee8c09038752da"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, "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"}, - "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, - "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm"}, - "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"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"}, + "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": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]}, - "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm"}, - "tesla": {:hex, :tesla, "1.3.0", "f35d72f029e608f9cdc6f6d6fcc7c66cf6d6512a70cfef9206b21b8bd0203a30", [: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, "~> 0.4", [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"}, - "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"}, - "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "tzdata": {:hex, :tzdata, "0.5.22", "f2ba9105117ee0360eae2eca389783ef7db36d533899b2e84559404dbc77ebb8", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, - "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm"}, - "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"}, + "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "tesla": {:hex, :tesla, "1.3.0", "f35d72f029e608f9cdc6f6d6fcc7c66cf6d6512a70cfef9206b21b8bd0203a30", [: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, "~> 0.4", [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", "93a7cacc5ca47997759cfa1d3ab25501d291e490908006d5be56f37f89d96693"}, + "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"}, + "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "db9fbfb5ac707bc4f85a297758406340bf0358b4af737a88113c1a9eee120ac7"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, + "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, + "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/priv/repo/migrations/20191030202008_add_unread_to_marker.exs b/priv/repo/migrations/20191030202008_add_unread_to_marker.exs deleted file mode 100644 index 2b3abc682..000000000 --- a/priv/repo/migrations/20191030202008_add_unread_to_marker.exs +++ /dev/null @@ -1,48 +0,0 @@ -defmodule Pleroma.Repo.Migrations.AddUnreadToMarker do - use Ecto.Migration - import Ecto.Query - alias Pleroma.Repo - - def up do - alter table(:markers) do - add_if_not_exists(:unread_count, :integer, default: 0) - end - - flush() - - update_markers() - end - - def down do - alter table(:markers) do - remove_if_exists(:unread_count, :integer) - end - end - - def update_markers do - now = NaiveDateTime.utc_now() - - markers_attrs = - from(q in "notifications", - select: %{ - timeline: "notifications", - user_id: q.user_id, - unread_count: fragment("SUM( CASE WHEN seen = false THEN 1 ELSE 0 END )"), - last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) - }, - group_by: [q.user_id] - ) - |> Repo.all() - |> Enum.map(fn attrs -> - attrs - |> Map.put_new(:inserted_at, now) - |> Map.put_new(:updated_at, now) - end) - - Repo.insert_all("markers", markers_attrs, - on_conflict: {:replace, [:last_read_id, :unread_count]}, - conflict_target: [:user_id, :timeline] - ) - end -end diff --git a/priv/repo/migrations/20200210050658_update_markers.exs b/priv/repo/migrations/20200210050658_update_markers.exs new file mode 100644 index 000000000..b280e156c --- /dev/null +++ b/priv/repo/migrations/20200210050658_update_markers.exs @@ -0,0 +1,39 @@ +defmodule Pleroma.Repo.Migrations.UpdateMarkers do + use Ecto.Migration + import Ecto.Query + alias Pleroma.Repo + + def up do + update_markers() + end + + def down do + :ok + end + + defp update_markers do + now = NaiveDateTime.utc_now() + + markers_attrs = + from(q in "notifications", + select: %{ + timeline: "notifications", + user_id: q.user_id, + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + }, + group_by: [q.user_id] + ) + |> Repo.all() + |> Enum.map(fn attrs -> + attrs + |> Map.put_new(:inserted_at, now) + |> Map.put_new(:updated_at, now) + end) + + Repo.insert_all("markers", markers_attrs, + on_conflict: {:replace, [:last_read_id, :unread_count]}, + conflict_target: [:user_id, :timeline] + ) + end +end diff --git a/test/marker_test.exs b/test/marker_test.exs index 7b1d2218a..54c710691 100644 --- a/test/marker_test.exs +++ b/test/marker_test.exs @@ -15,7 +15,7 @@ test "returns multi" do assert %Ecto.Multi{ operations: [marker: {:run, _}, counters: {:run, _}] } = - Marker.multi_set_unread_count( + Marker.multi_set_last_read_id( Ecto.Multi.new(), user, "notifications" @@ -25,19 +25,12 @@ test "returns multi" do test "return empty multi" do user = insert(:user) multi = Ecto.Multi.new() - assert Marker.multi_set_unread_count(multi, user, "home") == multi + assert Marker.multi_set_last_read_id(multi, user, "home") == multi end end describe "get_markers/2" do test "returns user markers" do - user = insert(:user) - marker = insert(:marker, user: user) - insert(:marker, timeline: "home", user: user) - assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)] - end - - test "returns user markers with recount unread notifications" do user = insert(:user) marker = insert(:marker, user: user) insert(:notification, user: user) @@ -46,8 +39,7 @@ test "returns user markers with recount unread notifications" do assert Marker.get_markers( user, - ["notifications"], - %{recount_unread: true} + ["notifications"] ) == [%Marker{refresh_record(marker) | unread_count: 2}] end end diff --git a/test/notification_test.exs b/test/notification_test.exs index c9b352097..49a79b2d3 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -338,12 +338,15 @@ test "it sets all notifications as read up to a specified notification ID" do assert n2.seen == true assert n3.seen == false - assert %Pleroma.Marker{unread_count: 1} = + assert %Pleroma.Marker{} = + m = Pleroma.Repo.get_by( Pleroma.Marker, user_id: other_user.id, timeline: "notifications" ) + + assert m.last_read_id == to_string(n2.id) end end -- cgit v1.2.3 From 3830cb538bd3aaee3fc48bc97b57230a558b98cf Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 10 Feb 2020 09:14:15 +0300 Subject: removed a comments --- lib/pleroma/marker.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index dab97d8b6..ff5f60351 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -27,12 +27,7 @@ defmodule Pleroma.Marker do timestamps() end - @doc """ - Gets markers by user and timeline. - - opts: - `recount_unread` - run force recount unread notifications for `true` value - """ + @doc "Gets markers by user and timeline." @spec get_markers(User.t(), list(String)) :: list(t()) def get_markers(user, timelines \\ []) do user -- cgit v1.2.3 From 241a3d744ae4e9d040247ad0aeb6287156acf920 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 11 Feb 2020 13:53:24 +0400 Subject: Add ActivityExpirationPolicy --- config/config.exs | 2 ++ lib/pleroma/web/activity_pub/mrf.ex | 7 ++-- .../activity_pub/mrf/activity_expiration_policy.ex | 35 ++++++++++++++++++++ .../mrf/activity_expiration_policy_test.exs | 38 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex create mode 100644 test/web/activity_pub/mrf/activity_expiration_policy_test.exs diff --git a/config/config.exs b/config/config.exs index 41c1ff637..d5b298c16 100644 --- a/config/config.exs +++ b/config/config.exs @@ -361,6 +361,8 @@ config :pleroma, :mrf_subchain, match_actor: %{} +config :pleroma, :mrf_activity_expiration, days: 365 + config :pleroma, :mrf_vocabulary, accept: [], reject: [] diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 263ed11af..b6e737de5 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -8,11 +8,8 @@ defmodule Pleroma.Web.ActivityPub.MRF do def filter(policies, %{} = object) do policies |> Enum.reduce({:ok, object}, fn - policy, {:ok, object} -> - policy.filter(object) - - _, error -> - error + policy, {:ok, object} -> policy.filter(object) + _, error -> error end) end diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex new file mode 100644 index 000000000..1b8860161 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do + @moduledoc "Adds expiration to all local activities" + @behaviour Pleroma.Web.ActivityPub.MRF + + @impl true + def filter(%{"id" => id} = activity) do + activity = + if String.starts_with?(id, Pleroma.Web.Endpoint.url()) do + maybe_add_expiration(activity) + else + activity + end + + {:ok, activity} + end + + @impl true + def describe, do: {:ok, %{}} + + defp maybe_add_expiration(activity) do + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: days) + + with %{"expires_at" => existing_expires_at} <- activity, + :lt <- NaiveDateTime.compare(existing_expires_at, expires_at) do + activity + else + _ -> Map.put(activity, "expires_at", expires_at) + end + end +end diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs new file mode 100644 index 000000000..2e65048c0 --- /dev/null +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do + use ExUnit.Case, async: true + alias Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy + + @id Pleroma.Web.Endpoint.url() <> "/activities/cofe" + + test "adds `expires_at` property" do + assert {:ok, %{"expires_at" => expires_at}} = ActivityExpirationPolicy.filter(%{"id" => @id}) + + assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 + end + + test "keeps existing `expires_at` if it less than the config setting" do + expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: 1) + + assert {:ok, %{"expires_at" => ^expires_at}} = + ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => expires_at}) + end + + test "owerwrites existing `expires_at` if it greater than the config setting" do + too_distant_future = NaiveDateTime.utc_now() |> Timex.shift(years: 2) + + assert {:ok, %{"expires_at" => expires_at}} = + ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => too_distant_future}) + + assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 + end + + test "ignores remote activities" do + assert {:ok, activity} = ActivityExpirationPolicy.filter(%{"id" => "https://example.com/123"}) + + refute Map.has_key?(activity, "expires_at") + end +end -- cgit v1.2.3 From 4d459b0e9906b2ebc0280b36c92007b2e680671f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 12 Feb 2020 22:51:26 +0400 Subject: Move ActivityExpiration creation from CommonApi.post/2 to ActivityPub.insert/4 --- lib/pleroma/web/activity_pub/activity_pub.ex | 17 ++++++++++++++--- lib/pleroma/web/common_api/activity_draft.ex | 9 ++++++++- lib/pleroma/web/common_api/common_api.ex | 12 +----------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5c436941a..408f6c966 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1,10 +1,11 @@ # 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.ActivityPub do alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics + alias Pleroma.ActivityExpiration alias Pleroma.Config alias Pleroma.Conversation alias Pleroma.Conversation.Participation @@ -135,12 +136,14 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when {:containment, :ok} <- {:containment, Containment.contain_child(map)}, {:ok, map, object} <- insert_full_object(map) do {:ok, activity} = - Repo.insert(%Activity{ + %Activity{ data: map, local: local, actor: map["actor"], recipients: recipients - }) + } + |> Repo.insert() + |> maybe_create_activity_expiration() # Splice in the child object if we have one. activity = @@ -180,6 +183,14 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when end end + defp maybe_create_activity_expiration({:ok, %{data: %{"expires_at" => expires_at}} = activity}) do + with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do + {:ok, activity} + end + end + + defp maybe_create_activity_expiration(result), do: result + defp create_or_bump_conversation(activity, actor) do with {:ok, conversation} <- Conversation.create_or_bump_for(activity), %User{} = user <- User.get_cached_by_ap_id(actor), diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index f7da81b34..7a83cad9c 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -193,6 +193,13 @@ defp preview?(draft) do defp changes(draft) do direct? = draft.visibility == "direct" + additional = %{"cc" => draft.cc, "directMessage" => direct?} + + additional = + case draft.expires_at do + %NaiveDateTime{} = expires_at -> Map.put(additional, "expires_at", expires_at) + _ -> additional + end changes = %{ @@ -200,7 +207,7 @@ defp changes(draft) do actor: draft.user, context: draft.context, object: draft.object, - additional: %{"cc" => draft.cc, "directMessage" => direct?} + additional: additional } |> Utils.maybe_add_list_data(draft.user, draft.visibility) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2a348dcf6..03921de27 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -277,20 +277,10 @@ def listen(user, %{"title" => _} = data) do def post(user, %{"status" => _} = data) do with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do - draft.changes - |> ActivityPub.create(draft.preview?) - |> maybe_create_activity_expiration(draft.expires_at) + ActivityPub.create(draft.changes, draft.preview?) end end - defp maybe_create_activity_expiration({:ok, activity}, %NaiveDateTime{} = expires_at) do - with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do - {:ok, activity} - end - end - - defp maybe_create_activity_expiration(result, _), do: result - # Updates the emojis for a user based on their profile def update(user) do emoji = emoji_from_profile(user) -- cgit v1.2.3 From e2d358f1fb0babbdd2a318bad863e27afecbb3d1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 15:19:23 +0400 Subject: Fix typo --- test/web/activity_pub/mrf/activity_expiration_policy_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 2e65048c0..2f2f90b44 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -21,7 +21,7 @@ test "keeps existing `expires_at` if it less than the config setting" do ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => expires_at}) end - test "owerwrites existing `expires_at` if it greater than the config setting" do + test "overwrites existing `expires_at` if it greater than the config setting" do too_distant_future = NaiveDateTime.utc_now() |> Timex.shift(years: 2) assert {:ok, %{"expires_at" => expires_at}} = -- cgit v1.2.3 From 57878f870879995f53227bb7a24b810531dd4217 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 15:50:31 +0400 Subject: Improve readability --- lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 1b8860161..5d823f2c7 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -7,9 +7,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @behaviour Pleroma.Web.ActivityPub.MRF @impl true - def filter(%{"id" => id} = activity) do + def filter(activity) do activity = - if String.starts_with?(id, Pleroma.Web.Endpoint.url()) do + if local?(activity) do maybe_add_expiration(activity) else activity @@ -21,6 +21,10 @@ def filter(%{"id" => id} = activity) do @impl true def describe, do: {:ok, %{}} + defp local?(%{"id" => id}) do + String.starts_with?(id, Pleroma.Web.Endpoint.url()) + end + defp maybe_add_expiration(activity) do days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: days) -- cgit v1.2.3 From 3732b0ba729bb7443e338b5f6bcc7e018983aa4c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 16:39:02 +0400 Subject: Update CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 150fd27cd..e4a641a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Rate limiter is now disabled for localhost/socket (unless remoteip plug is enabled) - Logger: default log level changed from `warn` to `info`. - Config mix task `migrate_to_db` truncates `config` table before migrating the config file. +- MFR policy to set global expiration for every local activity +
API Changes -- cgit v1.2.3 From 0ddcd67d32eb40cb6cb2a3dfee4c55e930e7f37c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 14 Feb 2020 16:53:53 +0400 Subject: Update `cheatsheet.md` and `config/description.exs` --- config/description.exs | 15 +++++++++++++++ docs/configuration/cheatsheet.md | 9 +++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index e5bac9b3f..d86a4ccca 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1346,6 +1346,21 @@ } ] }, + %{ + group: :pleroma, + key: :mrf_activity_expiration, + label: "MRF Activity Expiration Policy", + type: :group, + description: "Adds expiration to all local activities", + children: [ + %{ + key: :days, + type: :integer, + description: "Default global expiration time for all local activities (in days)", + suggestions: [90, 365] + } + ] + }, %{ group: :pleroma, key: :mrf_subchain, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2bd935983..bd03aec66 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -33,7 +33,7 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `rewrite_policy`: Message Rewrite Policy, either one or a list. Here are the ones available by default: * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. - * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). + * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certain instances (See [`:mrf_simple`](#mrf_simple)). * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). @@ -43,7 +43,8 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). -* `public`: Makes the client API in authentificated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). +* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). @@ -142,6 +143,10 @@ config :pleroma, :mrf_user_allowlist, * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines * `:reject` rejects the message entirely +#### :mrf_activity_expiration + +* `days`: Default global expiration time for all local activities (in days) + ### :activitypub * ``unfollow_blocked``: Whether blocks result in people getting unfollowed * ``outgoing_blocks``: Whether to federate blocks to other instances -- cgit v1.2.3 From 514c899275a32e6ef63305f9424c50344d41b12e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 11 Feb 2020 10:12:57 +0300 Subject: 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 | 154 +++- lib/pleroma/http/request.ex | 23 + lib/pleroma/http/request_builder.ex | 105 +-- 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 +- .../activity_pub/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 ++ test/reverse_proxy/reverse_proxy_test.exs | 385 +++++++++ test/reverse_proxy_test.exs | 344 -------- test/support/http_request_mock.ex | 94 +- test/user_invite_token_test.exs | 4 - test/web/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 +- 64 files changed, 3917 insertions(+), 691 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 create mode 100644 test/reverse_proxy/reverse_proxy_test.exs delete mode 100644 test/reverse_proxy_test.exs 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] - @doc """ - Configure a client connection + require Logger - # Returns + alias Pleroma.Config + alias Pleroma.HTTP.Adapter - Tesla.Env.client + @doc """ + 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 + + defp pool_timeout(opts) do + timeout = + Config.get([:pools, opts[:pool], :timeout]) || Config.get([:pools, :default, :timeout]) + + Keyword.merge(opts, timeout: timeout) 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) - - @hackney_options - |> Keyword.merge(adapter_options) - |> Keyword.merge(options) - |> Keyword.merge(proxy: proxy_url) + @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,61 +52,97 @@ 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) - - params = Keyword.get(options, :params, []) - - %{} - |> 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)).() + :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} + :exit, e -> {:error, e} 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 + @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 - def process_request_options(options) do - Keyword.merge(Pleroma.HTTP.Connection.hackney_options([]), options) + 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 - @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 """ - @doc """ - Specify the request method when building a request - - ## Parameters - - - request (Map) - Collected request options - - m (atom) - Request method + alias Pleroma.HTTP.Request + alias Tesla.Multipart - ## Returns - - Map + @doc """ + 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 + """ + @spec method(Request.t(), Request.method()) :: Request.t() + def method(request, m), do: %{request | method: m} - ## Parameters - - - request (Map) - Collected request options - - u (String) - Request URL - - ## Returns - - Map + @doc """ + Specify the request method when building a request """ - @spec url(map(), String.t()) :: map() - def url(request, u) do - Map.put_new(request, :url, u) - end + @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/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs new file mode 100644 index 000000000..1ab3cc4bb --- /dev/null +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -0,0 +1,385 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxyTest do + use Pleroma.Web.ConnCase + import ExUnit.CaptureLog + import Mox + alias Pleroma.ReverseProxy + alias Pleroma.ReverseProxy.ClientMock + + setup_all do + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.ReverseProxy.ClientMock) + :ok + end + + setup :verify_on_exit! + + defp user_agent_mock(user_agent, invokes) do + json = Jason.encode!(%{"user-agent": user_agent}) + + ClientMock + |> expect(:request, fn :get, url, _, _, _ -> + Registry.register(Pleroma.ReverseProxy.ClientMock, url, 0) + + {:ok, 200, + [ + {"content-type", "application/json"}, + {"content-length", byte_size(json) |> to_string()} + ], %{url: url}} + end) + |> 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, client} + + [{_, 1}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) + :done + end + end) + end + + describe "reverse proxy" do + test "do not track successful request", %{conn: conn} do + user_agent_mock("hackney/1.15.1", 2) + url = "/success" + + conn = ReverseProxy.call(conn, url) + + assert conn.status == 200 + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil} + end + end + + describe "user-agent" do + test "don't keep", %{conn: conn} do + user_agent_mock("hackney/1.15.1", 2) + conn = ReverseProxy.call(conn, "/user-agent") + assert json_response(conn, 200) == %{"user-agent" => "hackney/1.15.1"} + end + + test "keep", %{conn: conn} do + user_agent_mock(Pleroma.Application.user_agent(), 2) + conn = ReverseProxy.call(conn, "/user-agent-keep", keep_user_agent: true) + assert json_response(conn, 200) == %{"user-agent" => Pleroma.Application.user_agent()} + end + end + + test "closed connection", %{conn: conn} do + ClientMock + |> expect(:request, fn :get, "/closed", _, _, _ -> {:ok, 200, [], %{}} end) + |> expect(:stream_body, fn _ -> {:error, :closed} end) + |> expect(:close, fn _ -> :ok end) + + conn = ReverseProxy.call(conn, "/closed") + assert conn.halted + 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} = 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) + + assert capture_log(fn -> + ReverseProxy.call(conn, "/huge-file", max_body_length: 4) + end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large" + + assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file") + + assert capture_log(fn -> + ReverseProxy.call(conn, "/huge-file", max_body_length: 4) + end) == "" + end + + test "max_body_length returns error if streaming body more than that option", %{conn: conn} do + stream_mock(3, true) + + assert capture_log(fn -> + ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30) + end) =~ + "[warn] Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large" + end + end + + describe "HEAD requests" do + test "common", %{conn: conn} do + ClientMock + |> expect(:request, fn :head, "/head", _, _, _ -> + {:ok, 200, [{"content-type", "text/html; charset=utf-8"}]} + end) + + conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head") + assert html_response(conn, 200) == "" + end + end + + defp error_mock(status) when is_integer(status) do + ClientMock + |> expect(:request, fn :get, "/status/" <> _, _, _, _ -> + {:error, status} + end) + end + + describe "returns error on" do + test "500", %{conn: conn} do + error_mock(500) + url = "/status/500" + + capture_log(fn -> ReverseProxy.call(conn, url) end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500" + + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} + + {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url) + assert ttl <= 60_000 + end + + test "400", %{conn: conn} do + error_mock(400) + url = "/status/400" + + capture_log(fn -> ReverseProxy.call(conn, url) end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400" + + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} + assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil} + end + + test "403", %{conn: conn} do + error_mock(403) + url = "/status/403" + + capture_log(fn -> + ReverseProxy.call(conn, url, failed_request_ttl: :timer.seconds(120)) + end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to /status/403 failed with HTTP status 403" + + {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url) + assert ttl > 100_000 + end + + test "204", %{conn: conn} do + url = "/status/204" + expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end) + + capture_log(fn -> + conn = ReverseProxy.call(conn, url) + assert conn.resp_body == "Request failed: No Content" + assert conn.halted + end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to \"/status/204\" failed with HTTP status 204" + + assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} + assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil} + end + end + + test "streaming", %{conn: conn} do + stream_mock(21) + 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"] + end + + defp headers_mock(_) do + ClientMock + |> expect(:request, fn :get, "/headers", headers, _, _ -> + 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} = 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}), client} + + [{_, 1}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) + :done + end + end) + + :ok + end + + describe "keep request headers" do + setup [:headers_mock] + + test "header passes", %{conn: conn} do + conn = + Plug.Conn.put_req_header( + conn, + "accept", + "text/html" + ) + |> ReverseProxy.call("/headers") + + %{"headers" => headers} = json_response(conn, 200) + assert headers["Accept"] == "text/html" + end + + test "header is filtered", %{conn: conn} do + conn = + Plug.Conn.put_req_header( + conn, + "accept-language", + "en-US" + ) + |> ReverseProxy.call("/headers") + + %{"headers" => headers} = json_response(conn, 200) + refute headers["Accept-Language"] + end + end + + test "returns 400 on non GET, HEAD requests", %{conn: conn} do + conn = ReverseProxy.call(Map.put(conn, :method, "POST"), "/ip") + assert conn.status == 400 + end + + describe "cache resp headers" do + test "returns headers", %{conn: conn} do + ClientMock + |> expect(:request, fn :get, "/cache/" <> ttl, _, _, _ -> + {:ok, 200, [{"cache-control", "public, max-age=" <> ttl}], %{}} + end) + |> expect(:stream_body, fn _ -> :done end) + + conn = ReverseProxy.call(conn, "/cache/10") + assert {"cache-control", "public, max-age=10"} in conn.resp_headers + end + + test "add cache-control", %{conn: conn} do + ClientMock + |> expect(:request, fn :get, "/cache", _, _, _ -> + {:ok, 200, [{"ETag", "some ETag"}], %{}} + end) + |> expect(:stream_body, fn _ -> :done end) + + conn = ReverseProxy.call(conn, "/cache") + assert {"cache-control", "public"} in conn.resp_headers + end + end + + defp disposition_headers_mock(headers) do + ClientMock + |> expect(:request, fn :get, "/disposition", _, _, _ -> + Registry.register(Pleroma.ReverseProxy.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 + [{_, 0}] -> + Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1)) + {:ok, "", client} + + [{_, 1}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition") + :done + end + end) + end + + describe "response content disposition header" do + test "not atachment", %{conn: conn} do + disposition_headers_mock([ + {"content-type", "image/gif"}, + {"content-length", 0} + ]) + + conn = ReverseProxy.call(conn, "/disposition") + + assert {"content-type", "image/gif"} in conn.resp_headers + end + + test "with content-disposition header", %{conn: conn} do + disposition_headers_mock([ + {"content-disposition", "attachment; filename=\"filename.jpg\""}, + {"content-length", 0} + ]) + + conn = ReverseProxy.call(conn, "/disposition") + + 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/reverse_proxy_test.exs b/test/reverse_proxy_test.exs deleted file mode 100644 index 0672f57db..000000000 --- a/test/reverse_proxy_test.exs +++ /dev/null @@ -1,344 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ReverseProxyTest do - use Pleroma.Web.ConnCase, async: true - import ExUnit.CaptureLog - import Mox - alias Pleroma.ReverseProxy - alias Pleroma.ReverseProxy.ClientMock - - setup_all do - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.ReverseProxy.ClientMock) - :ok - end - - setup :verify_on_exit! - - defp user_agent_mock(user_agent, invokes) do - json = Jason.encode!(%{"user-agent": user_agent}) - - ClientMock - |> expect(:request, fn :get, url, _, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, url, 0) - - {:ok, 200, - [ - {"content-type", "application/json"}, - {"content-length", byte_size(json) |> to_string()} - ], %{url: url}} - end) - |> expect(:stream_body, invokes, fn %{url: url} -> - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do - [{_, 0}] -> - Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) - {:ok, json} - - [{_, 1}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) - :done - end - end) - end - - describe "reverse proxy" do - test "do not track successful request", %{conn: conn} do - user_agent_mock("hackney/1.15.1", 2) - url = "/success" - - conn = ReverseProxy.call(conn, url) - - assert conn.status == 200 - assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil} - end - end - - describe "user-agent" do - test "don't keep", %{conn: conn} do - user_agent_mock("hackney/1.15.1", 2) - conn = ReverseProxy.call(conn, "/user-agent") - assert json_response(conn, 200) == %{"user-agent" => "hackney/1.15.1"} - end - - test "keep", %{conn: conn} do - user_agent_mock(Pleroma.Application.user_agent(), 2) - conn = ReverseProxy.call(conn, "/user-agent-keep", keep_user_agent: true) - assert json_response(conn, 200) == %{"user-agent" => Pleroma.Application.user_agent()} - end - end - - test "closed connection", %{conn: conn} do - ClientMock - |> expect(:request, fn :get, "/closed", _, _, _ -> {:ok, 200, [], %{}} end) - |> expect(:stream_body, fn _ -> {:error, :closed} end) - |> expect(:close, fn _ -> :ok end) - - conn = ReverseProxy.call(conn, "/closed") - assert conn.halted - 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) - - assert capture_log(fn -> - ReverseProxy.call(conn, "/huge-file", max_body_length: 4) - end) =~ - "[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large" - - assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file") - - assert capture_log(fn -> - ReverseProxy.call(conn, "/huge-file", max_body_length: 4) - 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) - - assert capture_log(fn -> - ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30) - end) =~ - "[warn] Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large" - end - end - - describe "HEAD requests" do - test "common", %{conn: conn} do - ClientMock - |> expect(:request, fn :head, "/head", _, _, _ -> - {:ok, 200, [{"content-type", "text/html; charset=utf-8"}]} - end) - - conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head") - assert html_response(conn, 200) == "" - end - end - - defp error_mock(status) when is_integer(status) do - ClientMock - |> expect(:request, fn :get, "/status/" <> _, _, _, _ -> - {:error, status} - end) - end - - describe "returns error on" do - test "500", %{conn: conn} do - error_mock(500) - url = "/status/500" - - capture_log(fn -> ReverseProxy.call(conn, url) end) =~ - "[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500" - - assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} - - {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url) - assert ttl <= 60_000 - end - - test "400", %{conn: conn} do - error_mock(400) - url = "/status/400" - - capture_log(fn -> ReverseProxy.call(conn, url) end) =~ - "[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400" - - assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} - assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil} - end - - test "403", %{conn: conn} do - error_mock(403) - url = "/status/403" - - capture_log(fn -> - ReverseProxy.call(conn, url, failed_request_ttl: :timer.seconds(120)) - end) =~ - "[error] Elixir.Pleroma.ReverseProxy: request to /status/403 failed with HTTP status 403" - - {:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url) - assert ttl > 100_000 - end - - test "204", %{conn: conn} do - url = "/status/204" - expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end) - - capture_log(fn -> - conn = ReverseProxy.call(conn, url) - assert conn.resp_body == "Request failed: No Content" - assert conn.halted - end) =~ - "[error] Elixir.Pleroma.ReverseProxy: request to \"/status/204\" failed with HTTP status 204" - - assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true} - assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil} - end - end - - test "streaming", %{conn: conn} do - stream_mock(21) - 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"] - end - - defp headers_mock(_) do - ClientMock - |> expect(:request, fn :get, "/headers", headers, _, _ -> - 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} -> - 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})} - - [{_, 1}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) - :done - end - end) - - :ok - end - - describe "keep request headers" do - setup [:headers_mock] - - test "header passes", %{conn: conn} do - conn = - Plug.Conn.put_req_header( - conn, - "accept", - "text/html" - ) - |> ReverseProxy.call("/headers") - - %{"headers" => headers} = json_response(conn, 200) - assert headers["Accept"] == "text/html" - end - - test "header is filtered", %{conn: conn} do - conn = - Plug.Conn.put_req_header( - conn, - "accept-language", - "en-US" - ) - |> ReverseProxy.call("/headers") - - %{"headers" => headers} = json_response(conn, 200) - refute headers["Accept-Language"] - end - end - - test "returns 400 on non GET, HEAD requests", %{conn: conn} do - conn = ReverseProxy.call(Map.put(conn, :method, "POST"), "/ip") - assert conn.status == 400 - end - - describe "cache resp headers" do - test "returns headers", %{conn: conn} do - ClientMock - |> expect(:request, fn :get, "/cache/" <> ttl, _, _, _ -> - {:ok, 200, [{"cache-control", "public, max-age=" <> ttl}], %{}} - end) - |> expect(:stream_body, fn _ -> :done end) - - conn = ReverseProxy.call(conn, "/cache/10") - assert {"cache-control", "public, max-age=10"} in conn.resp_headers - end - - test "add cache-control", %{conn: conn} do - ClientMock - |> expect(:request, fn :get, "/cache", _, _, _ -> - {:ok, 200, [{"ETag", "some ETag"}], %{}} - end) - |> expect(:stream_body, fn _ -> :done end) - - conn = ReverseProxy.call(conn, "/cache") - assert {"cache-control", "public"} in conn.resp_headers - end - end - - defp disposition_headers_mock(headers) do - ClientMock - |> expect(:request, fn :get, "/disposition", _, _, _ -> - Registry.register(Pleroma.ReverseProxy.ClientMock, "/disposition", 0) - - {:ok, 200, headers, %{url: "/disposition"}} - end) - |> expect(:stream_body, 2, fn %{url: "/disposition"} -> - case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do - [{_, 0}] -> - Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1)) - {:ok, ""} - - [{_, 1}] -> - Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition") - :done - end - end) - end - - describe "response content disposition header" do - test "not atachment", %{conn: conn} do - disposition_headers_mock([ - {"content-type", "image/gif"}, - {"content-length", 0} - ]) - - conn = ReverseProxy.call(conn, "/disposition") - - assert {"content-type", "image/gif"} in conn.resp_headers - end - - test "with content-disposition header", %{conn: conn} do - disposition_headers_mock([ - {"content-disposition", "attachment; filename=\"filename.jpg\""}, - {"content-length", 0} - ]) - - conn = ReverseProxy.call(conn, "/disposition") - - assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers - 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" -- cgit v1.2.3 From 2a219f5e86bea076b1bc93f1a9205c764d43a380 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Feb 2020 09:12:46 -0600 Subject: 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 -- cgit v1.2.3 From 7d73e7a09a72354acf526652e307149afbf5b1a3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Feb 2020 09:18:09 -0600 Subject: 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) -- cgit v1.2.3 From 138a3c1fe48bbace79c0121d4571db3c2a827860 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Feb 2020 09:30:18 -0600 Subject: 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) -- cgit v1.2.3 From c9db0507f8d49aee9988b0b63477672f5df9c0b2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 19 Feb 2020 12:19:03 +0300 Subject: 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") -- cgit v1.2.3 From 819cd467170cb6dd1334cde0a0c79dbb785a22b6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 20 Feb 2020 22:04:02 +0400 Subject: Auto-expire Create activities only --- .../activity_pub/mrf/activity_expiration_policy.ex | 2 +- test/web/activity_pub/activity_pub_test.exs | 16 ++++++++++ .../mrf/activity_expiration_policy_test.exs | 35 ++++++++++++++++++---- .../cron/purge_expired_activities_worker_test.exs | 30 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 5d823f2c7..274bb9a5c 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @impl true def filter(activity) do activity = - if local?(activity) do + if activity["type"] == "Create" && local?(activity) do maybe_add_expiration(activity) else activity diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index ce68e7d0e..2cd908a87 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1784,4 +1784,20 @@ test "old user must be in the new user's `also_known_as` list" do ActivityPub.move(old_user, new_user) end end + + describe "global activity expiration" do + clear_config([:instance, :rewrite_policy]) + + test "creates an activity expiration for local Create activities" do + Pleroma.Config.put( + [:instance, :rewrite_policy], + Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy + ) + + {:ok, %{id: id_create}} = ActivityBuilder.insert(%{"type" => "Create", "context" => "3hu"}) + {:ok, _follow} = ActivityBuilder.insert(%{"type" => "Follow", "context" => "3hu"}) + + assert [%{activity_id: ^id_create}] = Pleroma.ActivityExpiration |> Repo.all() + end + end end diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 2f2f90b44..0d3bcc457 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -9,7 +9,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do @id Pleroma.Web.Endpoint.url() <> "/activities/cofe" test "adds `expires_at` property" do - assert {:ok, %{"expires_at" => expires_at}} = ActivityExpirationPolicy.filter(%{"id" => @id}) + assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = + ActivityExpirationPolicy.filter(%{"id" => @id, "type" => "Create"}) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 end @@ -17,21 +18,43 @@ test "adds `expires_at` property" do test "keeps existing `expires_at` if it less than the config setting" do expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: 1) - assert {:ok, %{"expires_at" => ^expires_at}} = - ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => expires_at}) + assert {:ok, %{"type" => "Create", "expires_at" => ^expires_at}} = + ActivityExpirationPolicy.filter(%{ + "id" => @id, + "type" => "Create", + "expires_at" => expires_at + }) end test "overwrites existing `expires_at` if it greater than the config setting" do too_distant_future = NaiveDateTime.utc_now() |> Timex.shift(years: 2) - assert {:ok, %{"expires_at" => expires_at}} = - ActivityExpirationPolicy.filter(%{"id" => @id, "expires_at" => too_distant_future}) + assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = + ActivityExpirationPolicy.filter(%{ + "id" => @id, + "type" => "Create", + "expires_at" => too_distant_future + }) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 end test "ignores remote activities" do - assert {:ok, activity} = ActivityExpirationPolicy.filter(%{"id" => "https://example.com/123"}) + assert {:ok, activity} = + ActivityExpirationPolicy.filter(%{ + "id" => "https://example.com/123", + "type" => "Create" + }) + + refute Map.has_key?(activity, "expires_at") + end + + test "ignores non-Create activities" do + assert {:ok, activity} = + ActivityExpirationPolicy.filter(%{ + "id" => "https://example.com/123", + "type" => "Follow" + }) refute Map.has_key?(activity, "expires_at") end diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index c2561683e..c6c7ff388 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -12,6 +12,7 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do import ExUnit.CaptureLog clear_config([ActivityExpiration, :enabled]) + clear_config([:instance, :rewrite_policy]) test "deletes an expiration activity" do Pleroma.Config.put([ActivityExpiration, :enabled], true) @@ -36,6 +37,35 @@ test "deletes an expiration activity" do refute Pleroma.Repo.get(Pleroma.ActivityExpiration, expiration.id) end + test "works with ActivityExpirationPolicy" do + Pleroma.Config.put([ActivityExpiration, :enabled], true) + + Pleroma.Config.put( + [:instance, :rewrite_policy], + Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy + ) + + user = insert(:user) + + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + + {:ok, %{id: id} = activity} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"}) + + past_date = + NaiveDateTime.utc_now() |> Timex.shift(days: -days) |> NaiveDateTime.truncate(:second) + + activity + |> Repo.preload(:expiration) + |> Map.get(:expiration) + |> Ecto.Changeset.change(%{scheduled_at: past_date}) + |> Repo.update!() + + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid) + + assert [%{data: %{"type" => "Delete", "deleted_activity_id" => ^id}}] = + Pleroma.Repo.all(Pleroma.Activity) + end + describe "delete_activity/1" do test "adds log message if activity isn't find" do assert capture_log([level: :error], fn -> -- cgit v1.2.3 From effb4a3d48462060e31db23bfcfd3e7c989d3141 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sat, 21 Sep 2019 03:15:09 +0200 Subject: init.d/pleroma: Add option to attach an elixir console --- installation/init.d/pleroma | 48 +++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/installation/init.d/pleroma b/installation/init.d/pleroma index ed50bb551..384536f7e 100755 --- a/installation/init.d/pleroma +++ b/installation/init.d/pleroma @@ -1,21 +1,45 @@ #!/sbin/openrc-run - -# Requires OpenRC >= 0.35 -directory=/opt/pleroma - -command=/usr/bin/mix -command_args="phx.server" +supervisor=supervise-daemon command_user=pleroma:pleroma command_background=1 - -export PORT=4000 -export MIX_ENV=prod - # Ask process to terminate within 30 seconds, otherwise kill it retry="SIGTERM/30/SIGKILL/5" - pidfile="/var/run/pleroma.pid" +directory=/opt/pleroma +healthcheck_delay=60 +healthcheck_timer=30 + +: ${pleroma_port:-4000} + +# Needs OpenRC >= 0.42 +#respawn_max=0 +#respawn_delay=5 + +# put pleroma_console=YES in /etc/conf.d/pleroma if you want to be able to +# connect to pleroma via an elixir console +if yesno "${pleroma_console}"; then + command=elixir + command_args="--name pleroma@127.0.0.1 --erl '-kernel inet_dist_listen_min 9001 inet_dist_listen_max 9001 inet_dist_use_interface {127,0,0,1}' -S mix phx.server" + + start_post() { + einfo "You can get a console by using this command as pleroma's user:" + einfo "iex --name console@127.0.0.1 --remsh pleroma@127.0.0.1" + } +else + command=/usr/bin/mix + command_args="phx.server" +fi + +export MIX_ENV=prod depend() { - need nginx postgresql + need nginx postgresql +} + +healthcheck() { + # put pleroma_health=YES in /etc/conf.d/pleroma if you want healthchecking + # and make sure you have curl installed + yesno "$pleroma_health" || return 0 + + curl -q "localhost:${pleroma_port}/api/pleroma/healthcheck" } -- cgit v1.2.3 From 3849bbb60d9085bced717fef1f09216d570af287 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 10:15:56 +0300 Subject: 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"}, -- cgit v1.2.3 From a03c420b84d9901be70520d8c027ccb53449990d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 12:32:42 +0300 Subject: 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( -- cgit v1.2.3 From ad8f26c0a4a0a579e93547e78313d3e4ecef6ed5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 12:53:40 +0300 Subject: 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 -- cgit v1.2.3 From 011ede45361096f55dda938078e24574cdf33b2b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 21 Feb 2020 14:42:43 +0400 Subject: Update documentation --- CHANGELOG.md | 2 +- config/description.exs | 4 ++-- docs/configuration/cheatsheet.md | 4 ++-- lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a641a7e..c5558e0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Rate limiter is now disabled for localhost/socket (unless remoteip plug is enabled) - Logger: default log level changed from `warn` to `info`. - Config mix task `migrate_to_db` truncates `config` table before migrating the config file. -- MFR policy to set global expiration for every local activity +- MFR policy to set global expiration for all local Create activities
API Changes diff --git a/config/description.exs b/config/description.exs index d86a4ccca..f0c6e3377 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1351,12 +1351,12 @@ key: :mrf_activity_expiration, label: "MRF Activity Expiration Policy", type: :group, - description: "Adds expiration to all local activities", + description: "Adds expiration to all local Create activities", children: [ %{ key: :days, type: :integer, - description: "Default global expiration time for all local activities (in days)", + description: "Default global expiration time for all local Create activities (in days)", suggestions: [90, 365] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index bd03aec66..f50c8bab7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -43,7 +43,7 @@ You shouldn't edit the base config directly to avoid breakages and merge conflic * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). - * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local Create activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). * `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. @@ -145,7 +145,7 @@ config :pleroma, :mrf_user_allowlist, #### :mrf_activity_expiration -* `days`: Default global expiration time for all local activities (in days) +* `days`: Default global expiration time for all local Create activities (in days) ### :activitypub * ``unfollow_blocked``: Whether blocks result in people getting unfollowed diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 274bb9a5c..a9bdf3b69 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do - @moduledoc "Adds expiration to all local activities" + @moduledoc "Adds expiration to all local Create activities" @behaviour Pleroma.Web.ActivityPub.MRF @impl true -- cgit v1.2.3 From 6806df80ddb1e52aef2b89b923d9a3e2844b5aeb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 14:28:16 +0300 Subject: 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 ] ] -- cgit v1.2.3 From f604f9e47061b9d47c1bb62cc7aaf44fabdf69b3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 14:33:55 +0300 Subject: 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 -- cgit v1.2.3 From d44f9e3b6cfd5a0dae07f6194bfd05360afd6560 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Feb 2020 16:56:55 +0300 Subject: 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) -- cgit v1.2.3 From c05cbc47f9e83a7ba41124475e48cf01ecbb2e56 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 22 Feb 2020 13:14:30 +0000 Subject: Better advice for vacuuming after restoring. --- docs/administration/backup.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/administration/backup.md b/docs/administration/backup.md index 692aa7368..be57bf74a 100644 --- a/docs/administration/backup.md +++ b/docs/administration/backup.md @@ -18,9 +18,8 @@ 6. Run `sudo -Hu postgres pg_restore -d -v -1 ` 7. If you installed a newer Pleroma version, you should run `mix ecto.migrate`[^1]. This task performs database migrations, if there were any. 8. Restart the Pleroma service. -9. After you've restarted Pleroma, you will notice that postgres will take up more cpu resources than usual. A lot in fact. To fix this you must do a VACUUM ANLAYZE. This can also be done while the instance is still running like so: - $ sudo -u postgres psql pleroma_database_name - pleroma=# VACUUM ANALYZE; +9. Run `sudo -Hu postgres vacuumdb --all --analyze-in-stages`. This will quickly generate the statistics so that postgres can properly plan queries. + [^1]: Prefix with `MIX_ENV=prod` to run it using the production config file. ## Remove -- cgit v1.2.3 From 0cf1d4fcd0c15594f663101061670a4555132840 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 22 Feb 2020 19:48:41 +0300 Subject: [#1560] Restricted AP- & OStatus-related routes for non-federating instances. --- lib/pleroma/plugs/static_fe_plug.ex | 5 +- .../web/activity_pub/activity_pub_controller.ex | 2 +- lib/pleroma/web/ostatus/ostatus_controller.ex | 2 + .../controllers/remote_follow_controller.ex | 2 + .../web/twitter_api/controllers/util_controller.ex | 2 + test/web/activity_pub/publisher_test.exs | 4 + test/web/feed/user_controller_test.exs | 133 +++++++++++++-------- test/web/static_fe/static_fe_controller_test.exs | 119 +++++------------- .../twitter_api/remote_follow_controller_test.exs | 6 + test/web/twitter_api/util_controller_test.exs | 37 +++--- 10 files changed, 160 insertions(+), 152 deletions(-) diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex index b3fb3c582..7d69e661c 100644 --- a/lib/pleroma/plugs/static_fe_plug.ex +++ b/lib/pleroma/plugs/static_fe_plug.ex @@ -21,6 +21,9 @@ def call(conn, _) do defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) defp accepts_html?(conn) do - conn |> get_req_header("accept") |> List.first() |> String.contains?("text/html") + conn + |> get_req_header("accept") + |> List.first() + |> String.contains?("text/html") end end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 5059e3984..aee574262 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do when action in [:activity, :object] ) - plug(Pleroma.Web.FederatingPlug when action in [:inbox, :relay]) + plug(Pleroma.Web.FederatingPlug) plug(:set_requester_reachable when action in [:inbox]) plug(:relay_active? when action in [:relay]) diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 01ec7941e..630cd0006 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Metadata.PlayerView alias Pleroma.Web.Router + plug(Pleroma.Web.FederatingPlug) + plug( RateLimiter, [name: :ap_routes, params: ["uuid"]] when action in [:object, :activity] diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index fbf31c7eb..89da760da 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do @status_types ["Article", "Event", "Note", "Video", "Page", "Question"] + plug(Pleroma.Web.FederatingPlug) + # Note: follower can submit the form (with password auth) not being signed in (having no token) plug( OAuthScopesPlug, diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index f08b9d28c..0a77978e3 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -17,6 +17,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.WebFinger + plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) + plug( OAuthScopesPlug, %{scopes: ["follow", "write:follows"]} diff --git a/test/web/activity_pub/publisher_test.exs b/test/web/activity_pub/publisher_test.exs index 015af19ab..c8eed68b6 100644 --- a/test/web/activity_pub/publisher_test.exs +++ b/test/web/activity_pub/publisher_test.exs @@ -23,6 +23,10 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do :ok end + clear_config_all([:instance, :federating]) do + Pleroma.Config.put([:instance, :federating], true) + end + describe "gather_webfinger_links/1" do test "it returns links" do user = insert(:user) diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index 41cc9e07e..fceb2ed43 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -8,66 +8,78 @@ defmodule Pleroma.Web.Feed.UserControllerTest do import Pleroma.Factory import SweetXml + alias Pleroma.Config alias Pleroma.Object alias Pleroma.User - clear_config([:feed]) - - test "gets a feed", %{conn: conn} do - Pleroma.Config.put( - [:feed, :post_title], - %{max_length: 10, omission: "..."} - ) - - activity = insert(:note_activity) - - note = - insert(:note, - data: %{ - "content" => "This is :moominmamma: note ", - "attachment" => [ - %{ - "url" => [%{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"}] - } - ], - "inReplyTo" => activity.data["id"] - } - ) + clear_config_all([:instance, :federating]) do + Config.put([:instance, :federating], true) + end - note_activity = insert(:note_activity, note: note) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) + describe "feed" do + clear_config([:feed]) - note2 = - insert(:note, - user: user, - data: %{"content" => "42 This is :moominmamma: note ", "inReplyTo" => activity.data["id"]} + test "gets a feed", %{conn: conn} do + Config.put( + [:feed, :post_title], + %{max_length: 10, omission: "..."} ) - _note_activity2 = insert(:note_activity, note: note2) - object = Object.normalize(note_activity) + activity = insert(:note_activity) + + note = + insert(:note, + data: %{ + "content" => "This is :moominmamma: note ", + "attachment" => [ + %{ + "url" => [ + %{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"} + ] + } + ], + "inReplyTo" => activity.data["id"] + } + ) + + note_activity = insert(:note_activity, note: note) + user = User.get_cached_by_ap_id(note_activity.data["actor"]) - resp = - conn - |> put_req_header("content-type", "application/atom+xml") - |> get(user_feed_path(conn, :feed, user.nickname)) - |> response(200) + note2 = + insert(:note, + user: user, + data: %{ + "content" => "42 This is :moominmamma: note ", + "inReplyTo" => activity.data["id"] + } + ) - activity_titles = - resp - |> SweetXml.parse() - |> SweetXml.xpath(~x"//entry/title/text()"l) + _note_activity2 = insert(:note_activity, note: note2) + object = Object.normalize(note_activity) - assert activity_titles == ['42 This...', 'This is...'] - assert resp =~ object.data["content"] - end + resp = + conn + |> put_req_header("content-type", "application/atom+xml") + |> get(user_feed_path(conn, :feed, user.nickname)) + |> response(200) - test "returns 404 for a missing feed", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/atom+xml") - |> get(user_feed_path(conn, :feed, "nonexisting")) + activity_titles = + resp + |> SweetXml.parse() + |> SweetXml.xpath(~x"//entry/title/text()"l) - assert response(conn, 404) + assert activity_titles == ['42 This...', 'This is...'] + assert resp =~ object.data["content"] + end + + test "returns 404 for a missing feed", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/atom+xml") + |> get(user_feed_path(conn, :feed, "nonexisting")) + + assert response(conn, 404) + end end describe "feed_redirect" do @@ -248,4 +260,29 @@ test "html format. it returns error when user not found", %{conn: conn} do assert response == %{"error" => "Not found"} end end + + describe "feed_redirect (depending on federation enabled state)" do + setup %{conn: conn} do + user = insert(:user) + conn = put_req_header(conn, "accept", "application/json") + + %{conn: conn, user: user} + end + + clear_config([:instance, :federating]) + + test "renders if instance is federating", %{conn: conn, user: user} do + Config.put([:instance, :federating], true) + + conn = get(conn, "/users/#{user.nickname}") + assert json_response(conn, 200) + end + + test "renders 404 if instance is NOT federating", %{conn: conn, user: user} do + Config.put([:instance, :federating], false) + + conn = get(conn, "/users/#{user.nickname}") + assert json_response(conn, 404) + end + end end diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index 2ce8f9fa3..11facab99 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -1,56 +1,42 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI import Pleroma.Factory clear_config_all([:static_fe, :enabled]) do - Pleroma.Config.put([:static_fe, :enabled], true) + Config.put([:static_fe, :enabled], true) end - describe "user profile page" do - test "just the profile as HTML", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/users/#{user.nickname}") - - assert html_response(conn, 200) =~ user.nickname - end + setup %{conn: conn} do + conn = put_req_header(conn, "accept", "text/html") + user = insert(:user) - test "renders json unless there's an html accept header", %{conn: conn} do - user = insert(:user) + %{conn: conn, user: user} + end - conn = - conn - |> put_req_header("accept", "application/json") - |> get("/users/#{user.nickname}") + describe "user profile html" do + test "just the profile as HTML", %{conn: conn, user: user} do + conn = get(conn, "/users/#{user.nickname}") - assert json_response(conn, 200) + assert html_response(conn, 200) =~ user.nickname end test "404 when user not found", %{conn: conn} do - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/users/limpopo") + conn = get(conn, "/users/limpopo") assert html_response(conn, 404) =~ "not found" end - test "profile does not include private messages", %{conn: conn} do - user = insert(:user) + test "profile does not include private messages", %{conn: conn, user: user} do CommonAPI.post(user, %{"status" => "public"}) CommonAPI.post(user, %{"status" => "private", "visibility" => "private"}) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/users/#{user.nickname}") + conn = get(conn, "/users/#{user.nickname}") html = html_response(conn, 200) @@ -58,14 +44,10 @@ test "profile does not include private messages", %{conn: conn} do refute html =~ ">private<" end - test "pagination", %{conn: conn} do - user = insert(:user) + test "pagination", %{conn: conn, user: user} do Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/users/#{user.nickname}") + conn = get(conn, "/users/#{user.nickname}") html = html_response(conn, 200) @@ -75,15 +57,11 @@ test "pagination", %{conn: conn} do refute html =~ ">test1<" end - test "pagination, page 2", %{conn: conn} do - user = insert(:user) + test "pagination, page 2", %{conn: conn, user: user} do activities = Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end) {:ok, a11} = Enum.at(activities, 11) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/users/#{user.nickname}?max_id=#{a11.id}") + conn = get(conn, "/users/#{user.nickname}?max_id=#{a11.id}") html = html_response(conn, 200) @@ -94,15 +72,11 @@ test "pagination, page 2", %{conn: conn} do end end - describe "notice rendering" do - test "single notice page", %{conn: conn} do - user = insert(:user) + describe "notice html" do + test "single notice page", %{conn: conn, user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "testing a thing!"}) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/notice/#{activity.id}") + conn = get(conn, "/notice/#{activity.id}") html = html_response(conn, 200) assert html =~ "
" @@ -110,8 +84,7 @@ test "single notice page", %{conn: conn} do assert html =~ "testing a thing!" end - test "shows the whole thread", %{conn: conn} do - user = insert(:user) + test "shows the whole thread", %{conn: conn, user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "space: the final frontier"}) CommonAPI.post(user, %{ @@ -119,70 +92,47 @@ test "shows the whole thread", %{conn: conn} do "in_reply_to_status_id" => activity.id }) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/notice/#{activity.id}") + conn = get(conn, "/notice/#{activity.id}") html = html_response(conn, 200) assert html =~ "the final frontier" assert html =~ "voyages" end - test "redirect by AP object ID", %{conn: conn} do - user = insert(:user) - + test "redirect by AP object ID", %{conn: conn, user: user} do {:ok, %Activity{data: %{"object" => object_url}}} = CommonAPI.post(user, %{"status" => "beam me up"}) - conn = - conn - |> put_req_header("accept", "text/html") - |> get(URI.parse(object_url).path) + conn = get(conn, URI.parse(object_url).path) assert html_response(conn, 302) =~ "redirected" end - test "redirect by activity ID", %{conn: conn} do - user = insert(:user) - + test "redirect by activity ID", %{conn: conn, user: user} do {:ok, %Activity{data: %{"id" => id}}} = CommonAPI.post(user, %{"status" => "I'm a doctor, not a devops!"}) - conn = - conn - |> put_req_header("accept", "text/html") - |> get(URI.parse(id).path) + conn = get(conn, URI.parse(id).path) assert html_response(conn, 302) =~ "redirected" end test "404 when notice not found", %{conn: conn} do - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/notice/88c9c317") + conn = get(conn, "/notice/88c9c317") assert html_response(conn, 404) =~ "not found" end - test "404 for private status", %{conn: conn} do - user = insert(:user) - + test "404 for private status", %{conn: conn, user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "don't show me!", "visibility" => "private"}) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/notice/#{activity.id}") + conn = get(conn, "/notice/#{activity.id}") assert html_response(conn, 404) =~ "not found" end - test "302 for remote cached status", %{conn: conn} do - user = insert(:user) - + test "302 for remote cached status", %{conn: conn, user: user} do message = %{ "@context" => "https://www.w3.org/ns/activitystreams", "to" => user.follower_address, @@ -199,10 +149,7 @@ test "302 for remote cached status", %{conn: conn} do assert {:ok, activity} = Transmogrifier.handle_incoming(message) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/notice/#{activity.id}") + conn = get(conn, "/notice/#{activity.id}") assert html_response(conn, 302) =~ "redirected" end diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs index 80a42989d..73062f18f 100644 --- a/test/web/twitter_api/remote_follow_controller_test.exs +++ b/test/web/twitter_api/remote_follow_controller_test.exs @@ -5,8 +5,10 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI + import ExUnit.CaptureLog import Pleroma.Factory @@ -15,6 +17,10 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do :ok end + clear_config_all([:instance, :federating]) do + Config.put([:instance, :federating], true) + end + clear_config([:instance]) clear_config([:frontend_configurations, :pleroma_fe]) clear_config([:user, :deny_follow_blocked]) diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 56633ffce..992cc44a5 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do use Pleroma.Web.ConnCase use Oban.Testing, repo: Pleroma.Repo + alias Pleroma.Config alias Pleroma.Tests.ObanHelpers alias Pleroma.User @@ -178,7 +179,7 @@ test "it updates notification privacy option", %{user: user, conn: conn} do describe "GET /api/statusnet/config" do test "it returns config in xml format", %{conn: conn} do - instance = Pleroma.Config.get(:instance) + instance = Config.get(:instance) response = conn @@ -195,12 +196,12 @@ test "it returns config in xml format", %{conn: conn} do end test "it returns config in json format", %{conn: conn} do - instance = Pleroma.Config.get(:instance) - Pleroma.Config.put([:instance, :managed_config], true) - Pleroma.Config.put([:instance, :registrations_open], false) - Pleroma.Config.put([:instance, :invites_enabled], true) - Pleroma.Config.put([:instance, :public], false) - Pleroma.Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"}) + instance = Config.get(:instance) + Config.put([:instance, :managed_config], true) + Config.put([:instance, :registrations_open], false) + Config.put([:instance, :invites_enabled], true) + Config.put([:instance, :public], false) + Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"}) response = conn @@ -234,7 +235,7 @@ test "it returns config in json format", %{conn: conn} do end test "returns the state of safe_dm_mentions flag", %{conn: conn} do - Pleroma.Config.put([:instance, :safe_dm_mentions], true) + Config.put([:instance, :safe_dm_mentions], true) response = conn @@ -243,7 +244,7 @@ test "returns the state of safe_dm_mentions flag", %{conn: conn} do assert response["site"]["safeDMMentionsEnabled"] == "1" - Pleroma.Config.put([:instance, :safe_dm_mentions], false) + Config.put([:instance, :safe_dm_mentions], false) response = conn @@ -254,8 +255,8 @@ test "returns the state of safe_dm_mentions flag", %{conn: conn} do end test "it returns the managed config", %{conn: conn} do - Pleroma.Config.put([:instance, :managed_config], false) - Pleroma.Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"}) + Config.put([:instance, :managed_config], false) + Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"}) response = conn @@ -264,7 +265,7 @@ test "it returns the managed config", %{conn: conn} do refute response["site"]["pleromafe"] - Pleroma.Config.put([:instance, :managed_config], true) + Config.put([:instance, :managed_config], true) response = conn @@ -287,7 +288,7 @@ test "returns everything in :pleroma, :frontend_configurations", %{conn: conn} d } ] - Pleroma.Config.put(:frontend_configurations, config) + Config.put(:frontend_configurations, config) response = conn @@ -320,7 +321,7 @@ test "returns json with custom emoji with tags", %{conn: conn} do clear_config([:instance, :healthcheck]) test "returns 503 when healthcheck disabled", %{conn: conn} do - Pleroma.Config.put([:instance, :healthcheck], false) + Config.put([:instance, :healthcheck], false) response = conn @@ -331,7 +332,7 @@ test "returns 503 when healthcheck disabled", %{conn: conn} do end test "returns 200 when healthcheck enabled and all ok", %{conn: conn} do - Pleroma.Config.put([:instance, :healthcheck], true) + Config.put([:instance, :healthcheck], true) with_mock Pleroma.Healthcheck, system_info: fn -> %Pleroma.Healthcheck{healthy: true} end do @@ -351,7 +352,7 @@ test "returns 200 when healthcheck enabled and all ok", %{conn: conn} do end test "returns 503 when healthcheck enabled and health is false", %{conn: conn} do - Pleroma.Config.put([:instance, :healthcheck], true) + Config.put([:instance, :healthcheck], true) with_mock Pleroma.Healthcheck, system_info: fn -> %Pleroma.Healthcheck{healthy: false} end do @@ -426,6 +427,10 @@ test "it returns version in json format", %{conn: conn} do end describe "POST /main/ostatus - remote_subscribe/2" do + clear_config([:instance, :federating]) do + Config.put([:instance, :federating], true) + end + test "renders subscribe form", %{conn: conn} do user = insert(:user) -- cgit v1.2.3 From 8efae966b1e87fe448a13d04eae0898c4a102c29 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 24 Feb 2020 19:56:27 +0300 Subject: 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 | 188 +++++++++++++----------- 12 files changed, 385 insertions(+), 305 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 - @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] + @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 - key = compose_key(uri) + @spec remove_conn(atom(), String.t()) :: :ok + def remove_conn(name, key) do + GenServer.cast(name, {:remove_conn, key}) + end - if Enum.count(state.conns) < max_connections do - open_conn(key, uri, state, opts) - else - try_to_open_conn(key, uri, state, opts) - end + @impl true + def handle_cast({:add_conn, key, conn}, state) do + state = put_in(state.conns[key], conn) + + 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,15 +197,17 @@ 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 - nil -> - Logger.debug( - ":gun_up message for alive conn #{inspect(conn_pid)}, but deleted from state" + put_in( + state.conns, + Map.delete(state.conns, key) ) + nil -> + Logger.debug(":gun_up message for conn which is not found in state") + :ok = API.close(conn_pid) state @@ -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,16 +239,18 @@ 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 - nil -> - Logger.debug( - ":gun_down message for alive conn #{inspect(conn_pid)}, but deleted from state" + put_in( + state.conns, + Map.delete(state.conns, key) ) + nil -> + Logger.debug(":gun_down message for conn which is not found in state") + :ok = API.close(conn_pid) state @@ -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() - } - } - - assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + test "crf is equalent, sorting by reference", %{name: name} do + Connections.add_conn(name, "1", %Conn{ + conn_state: :idle, + last_reference: now() - 1 + }) + + 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 + }) + + Connections.add_conn(name, "2", %Conn{ + conn_state: :idle, + crf: 2 + }) - assert [{"1", _unused_conn} | _others] = Connections.get_unused_conns(conns) + 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 + }) + + Connections.add_conn(name, "2", %Conn{ + conn_state: :idle, + crf: 2, + last_reference: now() + }) - assert [{"2", _unused_conn} | _others] = Connections.get_unused_conns(conns) + 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 -- cgit v1.2.3 From 6b012ddd69aec0f85c22ad91dbb76e05f2edaf58 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 25 Feb 2020 19:01:29 +0300 Subject: 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. -- cgit v1.2.3 From f446744a6a72d707504c2ba20ea2326f956b5097 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 26 Feb 2020 20:13:53 +0400 Subject: Allow account registration without an email --- CHANGELOG.md | 1 + lib/pleroma/user.ex | 11 ++++- .../mastodon_api/controllers/account_controller.ex | 16 +++++-- test/user_test.exs | 21 ++++++++- .../controllers/account_controller_test.exs | 52 +++++++++++++++++++++- 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bb7e1c7..a924d4083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Rate limiter is now disabled for localhost/socket (unless remoteip plug is enabled) - Logger: default log level changed from `warn` to `info`. - Config mix task `migrate_to_db` truncates `config` table before migrating the config file. +- Allow account registration without an email
API Changes diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 56e599ecc..5271d8dbe 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.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.User do @@ -530,7 +530,14 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do end def maybe_validate_required_email(changeset, true), do: changeset - def maybe_validate_required_email(changeset, _), do: validate_required(changeset, [:email]) + + def maybe_validate_required_email(changeset, _) do + if Pleroma.Config.get([:instance, :account_activation_required]) do + validate_required(changeset, [:email]) + else + changeset + end + end defp put_ap_id(changeset) do ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)}) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 38d14256f..88c997b9f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.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.MastodonAPI.AccountController do @@ -76,7 +76,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts" def create( %{assigns: %{app: app}} = conn, - %{"username" => nickname, "email" => _, "password" => _, "agreement" => true} = params + %{"username" => nickname, "password" => _, "agreement" => true} = params ) do params = params @@ -93,7 +93,8 @@ def create( |> Map.put("bio", params["bio"] || "") |> Map.put("confirm", params["password"]) - with {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), + with :ok <- validate_email_param(params), + {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do json(conn, %{ token_type: "Bearer", @@ -114,6 +115,15 @@ def create(conn, _) do render_error(conn, :forbidden, "Invalid credentials") end + defp validate_email_param(%{"email" => _}), do: :ok + + defp validate_email_param(_) do + case Pleroma.Config.get([:instance, :account_activation_required]) do + true -> {:error, %{"error" => "Missing parameters"}} + _ -> :ok + end + end + @doc "GET /api/v1/accounts/verify_credentials" def verify_credentials(%{assigns: %{user: user}} = conn, _) do chat_token = Phoenix.Token.sign(conn, "user socket", user.id) diff --git a/test/user_test.exs b/test/user_test.exs index 2fc42a90d..b07fed42b 100644 --- a/test/user_test.exs +++ b/test/user_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.UserTest do @@ -412,7 +412,11 @@ test "it sends a welcome message if it is set" do assert activity.actor == welcome_user.ap_id end - test "it requires an email, name, nickname and password, bio is optional" do + clear_config([:instance, :account_activation_required]) + + test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do + Pleroma.Config.put([:instance, :account_activation_required], true) + @full_user_data |> Map.keys() |> Enum.each(fn key -> @@ -423,6 +427,19 @@ test "it requires an email, name, nickname and password, bio is optional" do end) end + test "it requires an name, nickname and password, bio and email are optional when account_activation_required is disabled" do + Pleroma.Config.put([:instance, :account_activation_required], false) + + @full_user_data + |> Map.keys() + |> Enum.each(fn key -> + params = Map.delete(@full_user_data, key) + changeset = User.register_changeset(%User{}, params) + + assert if key in [:bio, :email], do: changeset.valid?, else: not changeset.valid? + end) + end + test "it restricts certain nicknames" do [restricted_name | _] = Pleroma.Config.get([User, :restricted_nicknames]) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 8625bb9cf..ff7cb88d1 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_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.MastodonAPI.AccountControllerTest do @@ -601,6 +601,8 @@ test "blocking / unblocking a user" do [valid_params: valid_params] end + clear_config([:instance, :account_activation_required]) + test "Account registration via Application", %{conn: conn} do conn = post(conn, "/api/v1/apps", %{ @@ -731,7 +733,7 @@ test "returns bad_request if missing required params", %{ assert json_response(res, 200) [{127, 0, 0, 1}, {127, 0, 0, 2}, {127, 0, 0, 3}, {127, 0, 0, 4}] - |> Stream.zip(valid_params) + |> Stream.zip(Map.delete(valid_params, :email)) |> Enum.each(fn {ip, {attr, _}} -> res = conn @@ -743,6 +745,52 @@ test "returns bad_request if missing required params", %{ end) end + test "returns bad_request if missing email params when :account_activation_required is enabled", + %{conn: conn, valid_params: valid_params} do + Pleroma.Config.put([:instance, :account_activation_required], true) + + app_token = insert(:oauth_token, user: nil) + conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token) + + res = + conn + |> Map.put(:remote_ip, {127, 0, 0, 5}) + |> post("/api/v1/accounts", Map.delete(valid_params, :email)) + + assert json_response(res, 400) == %{"error" => "Missing parameters"} + + res = + conn + |> Map.put(:remote_ip, {127, 0, 0, 6}) + |> post("/api/v1/accounts", Map.put(valid_params, :email, "")) + + assert json_response(res, 400) == %{"error" => "{\"email\":[\"can't be blank\"]}"} + end + + test "allow registration without an email", %{conn: conn, valid_params: valid_params} do + app_token = insert(:oauth_token, user: nil) + conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token) + + res = + conn + |> Map.put(:remote_ip, {127, 0, 0, 7}) + |> post("/api/v1/accounts", Map.delete(valid_params, :email)) + + assert json_response(res, 200) + end + + test "allow registration with an empty email", %{conn: conn, valid_params: valid_params} do + app_token = insert(:oauth_token, user: nil) + conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token) + + res = + conn + |> Map.put(:remote_ip, {127, 0, 0, 8}) + |> post("/api/v1/accounts", Map.put(valid_params, :email, "")) + + assert json_response(res, 200) + end + test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_params} do conn = put_req_header(conn, "authorization", "Bearer " <> "invalid-token") -- cgit v1.2.3 From 3ef2ff3e479e69653537e6bbcc92a29590cab971 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sat, 29 Feb 2020 01:23:36 +0100 Subject: auth_controller.ex: Add admin scope to MastoFE Related: https://git.pleroma.social/pleroma/pleroma/issues/1265 --- lib/pleroma/web/mastodon_api/controllers/auth_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index d9e51de7f..b63d96784 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -86,6 +86,6 @@ defp local_mastodon_root_path(conn) do @spec get_or_make_app() :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} defp get_or_make_app do %{client_name: @local_mastodon_name, redirect_uris: "."} - |> App.get_or_make(["read", "write", "follow", "push"]) + |> App.get_or_make(["read", "write", "follow", "push", "admin"]) end end -- cgit v1.2.3 From 523f73dccd4e8f4028488e37f7333732db1eebd7 Mon Sep 17 00:00:00 2001 From: Phil Hagelberg Date: Sat, 29 Feb 2020 18:53:49 -0800 Subject: Fix static FE plug to handle missing Accept header. --- lib/pleroma/plugs/static_fe_plug.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex index b3fb3c582..a8b22c243 100644 --- a/lib/pleroma/plugs/static_fe_plug.ex +++ b/lib/pleroma/plugs/static_fe_plug.ex @@ -21,6 +21,9 @@ def call(conn, _) do defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) defp accepts_html?(conn) do - conn |> get_req_header("accept") |> List.first() |> String.contains?("text/html") + case get_req_header(conn, "accept") do + [accept | _] -> String.contains?(accept, "text/html") + _ -> false + end end end -- cgit v1.2.3 From 400fbc7629ff0fbf931cd5cc84d8ce170cd38e1d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 18 Feb 2020 18:10:39 +0300 Subject: wait in mix task while pleroma is rebooted --- lib/mix/pleroma.ex | 13 ++++++++++++ lib/pleroma/config/transfer_task.ex | 6 +++++- restarter/lib/pleroma.ex | 40 +++++++++++++++++++++++++++---------- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 73a076a53..d2e443fdc 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -12,6 +12,19 @@ def start_pleroma do end {:ok, _} = Application.ensure_all_started(:pleroma) + + if Pleroma.Config.get(:env) not in [:test, :benchmark] do + pleroma_rebooted?() + end + end + + defp pleroma_rebooted? do + if Restarter.Pleroma.rebooted?() do + :ok + else + Process.sleep(10) + pleroma_rebooted?() + end end def load_pleroma do diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index f037ce8a5..01a3de05f 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -42,7 +42,8 @@ def start_link(_) do @spec load_and_update_env([ConfigDB.t()]) :: :ok | false def load_and_update_env(deleted \\ [], restart_pleroma? \\ true) do - with true <- Pleroma.Config.get(:configurable_from_database), + with {:configurable, true} <- + {:configurable, Pleroma.Config.get(:configurable_from_database)}, true <- Ecto.Adapters.SQL.table_exists?(Repo, "config"), started_applications <- Application.started_applications() do # We need to restart applications for loaded settings take effect @@ -65,12 +66,15 @@ def load_and_update_env(deleted \\ [], restart_pleroma? \\ true) do if :pleroma in applications do List.delete(applications, :pleroma) ++ [:pleroma] else + Restarter.Pleroma.rebooted() applications end Enum.each(applications, &restart(started_applications, &1, Pleroma.Config.get(:env))) :ok + else + {:configurable, false} -> Restarter.Pleroma.rebooted() end end diff --git a/restarter/lib/pleroma.ex b/restarter/lib/pleroma.ex index d7817909d..7f08c637c 100644 --- a/restarter/lib/pleroma.ex +++ b/restarter/lib/pleroma.ex @@ -3,11 +3,21 @@ defmodule Restarter.Pleroma do require Logger + @init_state %{need_reboot: false, rebooted: false, after_boot: false} + def start_link(_) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end - def init(_), do: {:ok, %{need_reboot?: false}} + def init(_), do: {:ok, @init_state} + + def rebooted? do + GenServer.call(__MODULE__, :rebooted?) + end + + def rebooted do + GenServer.cast(__MODULE__, :rebooted) + end def need_reboot? do GenServer.call(__MODULE__, :need_reboot?) @@ -29,41 +39,51 @@ def restart_after_boot(env) do GenServer.cast(__MODULE__, {:after_boot, env}) end + def handle_call(:rebooted?, _from, state) do + {:reply, state[:rebooted], state} + end + def handle_call(:need_reboot?, _from, state) do - {:reply, state[:need_reboot?], state} + {:reply, state[:need_reboot], state} end - def handle_cast(:refresh, _state) do - {:noreply, %{need_reboot?: false}} + def handle_cast(:rebooted, state) do + {:noreply, Map.put(state, :rebooted, true)} end - def handle_cast(:need_reboot, %{need_reboot?: true} = state), do: {:noreply, state} + def handle_cast(:need_reboot, %{need_reboot: true} = state), do: {:noreply, state} def handle_cast(:need_reboot, state) do - {:noreply, Map.put(state, :need_reboot?, true)} + {:noreply, Map.put(state, :need_reboot, true)} + end + + def handle_cast(:refresh, _state) do + {:noreply, @init_state} end def handle_cast({:restart, :test, _}, state) do Logger.warn("pleroma restarted") - {:noreply, Map.put(state, :need_reboot?, false)} + {:noreply, Map.put(state, :need_reboot, false)} end def handle_cast({:restart, _, delay}, state) do Process.sleep(delay) do_restart(:pleroma) - {:noreply, Map.put(state, :need_reboot?, false)} + {:noreply, Map.put(state, :need_reboot, false)} end def handle_cast({:after_boot, _}, %{after_boot: true} = state), do: {:noreply, state} def handle_cast({:after_boot, :test}, state) do Logger.warn("pleroma restarted") - {:noreply, Map.put(state, :after_boot, true)} + state = %{state | after_boot: true, rebooted: true} + {:noreply, state} end def handle_cast({:after_boot, _}, state) do do_restart(:pleroma) - {:noreply, Map.put(state, :after_boot, true)} + state = %{state | after_boot: true, rebooted: true} + {:noreply, state} end defp do_restart(app) do -- cgit v1.2.3 From deb5f5c40e638472ca5a01370ad127fc4c437150 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 2 Mar 2020 04:01:37 +0100 Subject: pleroma_api.md: direct_conversation_id vs. conversation_id Related: https://git.pleroma.social/pleroma/pleroma/issues/1594 --- docs/API/pleroma_api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 761d5c69c..12e63ef9f 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -288,10 +288,11 @@ Pleroma Conversations have the same general structure that Mastodon Conversation 2. Pleroma Conversations statuses can be requested by Conversation id. 3. Pleroma Conversations can be replied to. -Conversations have the additional field "recipients" under the "pleroma" key. This holds a list of all the accounts that will receive a message in this conversation. +Conversations have the additional field `recipients` under the `pleroma` key. This holds a list of all the accounts that will receive a message in this conversation. The status posting endpoint takes an additional parameter, `in_reply_to_conversation_id`, which, when set, will set the visiblity to direct and address only the people who are the recipients of that Conversation. +⚠ Conversation IDs can be found in direct messages with the `pleroma.direct_conversation_id` key, do not confuse it with `pleroma.conversation_id`. ## `GET /api/v1/pleroma/conversations/:id/statuses` ### Timeline for a given conversation -- cgit v1.2.3 From 2622cf1190fe8e6ec9145a8cd2538a56889aa7e2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 2 Mar 2020 09:22:34 +0300 Subject: 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, -- cgit v1.2.3 From cc98d010edc444e260c81ac9f264a27d9afd5daf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 25 Feb 2020 16:21:48 +0300 Subject: relay list shows hosts without accepted follow --- lib/mix/tasks/pleroma/relay.ex | 2 +- lib/pleroma/activity.ex | 7 ++++ lib/pleroma/web/activity_pub/relay.ex | 19 +++++++++-- test/fixtures/relay/accept-follow.json | 15 +++++++++ test/fixtures/relay/relay.json | 20 ++++++++++++ test/support/http_request_mock.ex | 8 +++++ test/tasks/relay_test.exs | 3 ++ .../activity_pub/activity_pub_controller_test.exs | 38 ++++++++++++++++++++++ 8 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/relay/accept-follow.json create mode 100644 test/fixtures/relay/relay.json diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index 7ef5f9678..b0fadeae9 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -35,7 +35,7 @@ def run(["unfollow", target]) do def run(["list"]) do start_pleroma() - with {:ok, list} <- Relay.list() do + with {:ok, list} <- Relay.list(true) do list |> Enum.each(&shell_info(&1)) else {:error, e} -> shell_error("Error while fetching relay subscription list: #{inspect(e)}") diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 397eb6e3f..6ca05f74e 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -308,6 +308,13 @@ def follow_requests_for_actor(%Pleroma.User{ap_id: ap_id}) do |> where([a], fragment("? ->> 'state' = 'pending'", a.data)) end + def following_requests_for_actor(%Pleroma.User{ap_id: ap_id}) do + Queries.by_type("Follow") + |> where([a], fragment("?->>'state' = 'pending'", a.data)) + |> where([a], a.actor == ^ap_id) + |> Repo.all() + end + def restrict_deactivated_users(query) do deactivated_users = from(u in User.Query.build(%{deactivated: true}), select: u.ap_id) diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index bb5542c89..729c23af7 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -60,15 +60,28 @@ def publish(%Activity{data: %{"type" => "Create"}} = activity) do def publish(_), do: {:error, "Not implemented"} - @spec list() :: {:ok, [String.t()]} | {:error, any()} - def list do + @spec list(boolean()) :: {:ok, [String.t()]} | {:error, any()} + def list(with_not_accepted \\ false) do with %User{} = user <- get_actor() do - list = + accepted = user |> User.following() |> Enum.map(fn entry -> URI.parse(entry).host end) |> Enum.uniq() + list = + if with_not_accepted do + without_accept = + user + |> Pleroma.Activity.following_requests_for_actor() + |> Enum.map(fn a -> URI.parse(a.data["object"]).host <> " (no Accept received)" end) + |> Enum.uniq() + + accepted ++ without_accept + else + accepted + end + {:ok, list} else error -> format_error(error) diff --git a/test/fixtures/relay/accept-follow.json b/test/fixtures/relay/accept-follow.json new file mode 100644 index 000000000..1b166f2da --- /dev/null +++ b/test/fixtures/relay/accept-follow.json @@ -0,0 +1,15 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "https://relay.mastodon.host/actor", + "id": "https://relay.mastodon.host/activities/ec477b69-db26-4019-923e-cf809de516ab", + "object": { + "actor": "{{ap_id}}", + "id": "{{activity_id}}", + "object": "https://relay.mastodon.host/actor", + "type": "Follow" + }, + "to": [ + "{{ap_id}}" + ], + "type": "Accept" +} \ No newline at end of file diff --git a/test/fixtures/relay/relay.json b/test/fixtures/relay/relay.json new file mode 100644 index 000000000..77ae7f06c --- /dev/null +++ b/test/fixtures/relay/relay.json @@ -0,0 +1,20 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "endpoints": { + "sharedInbox": "https://relay.mastodon.host/inbox" + }, + "followers": "https://relay.mastodon.host/followers", + "following": "https://relay.mastodon.host/following", + "inbox": "https://relay.mastodon.host/inbox", + "name": "ActivityRelay", + "type": "Application", + "id": "https://relay.mastodon.host/actor", + "publicKey": { + "id": "https://relay.mastodon.host/actor#main-key", + "owner": "https://relay.mastodon.host/actor", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuNYHNYETdsZFsdcTTEQo\nlsTP9yz4ZjOGrQ1EjoBA7NkjBUxxUAPxZbBjWPT9F+L3IbCX1IwI2OrBM/KwDlug\nV41xnjNmxSCUNpxX5IMZtFaAz9/hWu6xkRTs9Bh6XWZxi+db905aOqszb9Mo3H2g\nQJiAYemXwTh2kBO7XlBDbsMhO11Tu8FxcWTMdR54vlGv4RoiVh8dJRa06yyiTs+m\njbj/OJwR06mHHwlKYTVT/587NUb+e9QtCK6t/dqpyZ1o7vKSK5PSldZVjwHt292E\nXVxFOQVXi7JazTwpdPww79ECSe8ThCykOYCNkm3RjsKuLuokp7Vzq1hXIoeBJ7z2\ndU8vbgg/JyazsOsTxkVs2nd2i9/QW2SH+sX9X3357+XLSCh/A8p8fv/GeoN7UCXe\n4DWHFJZDlItNFfymiPbQH+omuju8qrfW9ngk1gFeI2mahXFQVu7x0qsaZYioCIrZ\nwq0zPnUGl9u0tLUXQz+ZkInRrEz+JepDVauy5/3QdzMLG420zCj/ygDrFzpBQIrc\n62Z6URueUBJox0UK71K+usxqOrepgw8haFGMvg3STFo34pNYjoK4oKO+h5qZEDFD\nb1n57t6JWUaBocZbJns9RGASq5gih+iMk2+zPLWp1x64yvuLsYVLPLBHxjCxS6lA\ndWcopZHi7R/OsRz+vTT7420CAwEAAQ==\n-----END PUBLIC KEY-----" + }, + "summary": "ActivityRelay bot", + "preferredUsername": "relay", + "url": "https://relay.mastodon.host/actor" +} \ No newline at end of file diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index d46887865..e72638814 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1277,6 +1277,10 @@ def get("http://example.com/rel_me/error", _, _, _) do {:ok, %Tesla.Env{status: 404, body: ""}} end + def get("https://relay.mastodon.host/actor", _, _, _) do + {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/relay/relay.json")}} + end + def get(url, query, body, headers) do {:error, "Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{ @@ -1289,6 +1293,10 @@ def get(url, query, body, headers) do def post(url, query \\ [], body \\ [], headers \\ []) + def post("https://relay.mastodon.host/inbox", _, _, _) do + {:ok, %Tesla.Env{status: 200, body: ""}} + end + def post("http://example.org/needs_refresh", _, _, _) do {:ok, %Tesla.Env{ diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index 04a1e45d7..43565b7c7 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -38,6 +38,9 @@ test "relay is followed" do assert activity.data["type"] == "Follow" assert activity.data["actor"] == local_user.ap_id assert activity.data["object"] == target_user.ap_id + + :ok = Mix.Tasks.Pleroma.Relay.run(["list"]) + assert_receive {:mix_shell, :info, ["mastodon.example.org (no Accept received)"]} end end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index ba2ce1dd9..0c80e2434 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -341,6 +341,44 @@ test "it clears `unreachable` federation status of the sender", %{conn: conn} do assert "ok" == json_response(conn, 200) assert Instances.reachable?(sender_url) end + + test "accept follow activity", %{conn: conn} do + Pleroma.Config.put([:instance, :federating], true) + relay = Relay.get_actor() + + assert {:ok, %Activity{} = activity} = Relay.follow("https://relay.mastodon.host/actor") + + followed_relay = Pleroma.User.get_by_ap_id("https://relay.mastodon.host/actor") + relay = refresh_record(relay) + + accept = + File.read!("test/fixtures/relay/accept-follow.json") + |> String.replace("{{ap_id}}", relay.ap_id) + |> String.replace("{{activity_id}}", activity.data["id"]) + + assert "ok" == + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", accept) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + + assert Pleroma.FollowingRelationship.following?( + relay, + followed_relay + ) + + Mix.shell(Mix.Shell.Process) + + on_exit(fn -> + Mix.shell(Mix.Shell.IO) + end) + + :ok = Mix.Tasks.Pleroma.Relay.run(["list"]) + assert_receive {:mix_shell, :info, ["relay.mastodon.host"]} + end end describe "/users/:nickname/inbox" do -- cgit v1.2.3 From 137c600cae9869e706d10b06dea04c9249e043da Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 2 Mar 2020 10:01:07 +0300 Subject: 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 -- cgit v1.2.3 From b4367125e9afc92ac27ff12552775f8e765140f1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 2 Mar 2020 21:43:18 +0300 Subject: [#1560] Added tests for non-federating instance bahaviour to ActivityPubControllerTest. --- docs/API/differences_in_mastoapi_responses.md | 2 +- docs/clients.md | 2 +- test/plugs/oauth_plug_test.exs | 2 +- .../activity_pub/activity_pub_controller_test.exs | 91 +++++++++++++++++++++- 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 06de90f71..476a4a2bf 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -180,7 +180,7 @@ Post here request with grant_type=refresh_token to obtain new access token. Retu ## Account Registration `POST /api/v1/accounts` -Has theses additionnal parameters (which are the same as in Pleroma-API): +Has theses additional parameters (which are the same as in Pleroma-API): * `fullname`: optional * `bio`: optional * `captcha_solution`: optional, contains provider-specific captcha solution, diff --git a/docs/clients.md b/docs/clients.md index 8ac9ad3de..1eae0f0c6 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -1,5 +1,5 @@ # Pleroma Clients -Note: Additionnal clients may be working but theses are officially supporting Pleroma. +Note: Additional clients may be working but theses are officially supporting Pleroma. Feel free to contact us to be added to this list! ## Desktop diff --git a/test/plugs/oauth_plug_test.exs b/test/plugs/oauth_plug_test.exs index dea11cdb0..0eef27c1f 100644 --- a/test/plugs/oauth_plug_test.exs +++ b/test/plugs/oauth_plug_test.exs @@ -38,7 +38,7 @@ test "with valid token(downcase), it assigns the user", %{conn: conn} = opts do assert conn.assigns[:user] == opts[:user] end - test "with valid token(downcase) in url parameters, it assings the user", opts do + test "with valid token(downcase) in url parameters, it assigns the user", opts do conn = :get |> build_conn("/?access_token=#{opts[:token]}") diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index ba2ce1dd9..af0417406 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -25,9 +25,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do :ok end - clear_config_all([:instance, :federating], - do: Pleroma.Config.put([:instance, :federating], true) - ) + clear_config_all([:instance, :federating]) do + Pleroma.Config.put([:instance, :federating], true) + end describe "/relay" do clear_config([:instance, :allow_relay]) @@ -1008,7 +1008,7 @@ test "it tracks a signed activity fetch when the json is cached", %{conn: conn} end end - describe "Additionnal ActivityPub C2S endpoints" do + describe "Additional ActivityPub C2S endpoints" do test "/api/ap/whoami", %{conn: conn} do user = insert(:user) @@ -1047,4 +1047,87 @@ test "uploadMedia", %{conn: conn} do assert object["actor"] == user.ap_id end end + + describe "when instance is not federating," do + clear_config([:instance, :federating]) do + Pleroma.Config.put([:instance, :federating], false) + end + + test "returns 404 for GET routes", %{conn: conn} do + user = insert(:user) + conn = put_req_header(conn, "accept", "application/json") + + get_uris = [ + "/users/#{user.nickname}", + "/users/#{user.nickname}/outbox", + "/users/#{user.nickname}/inbox?page=true", + "/users/#{user.nickname}/followers", + "/users/#{user.nickname}/following", + "/internal/fetch", + "/relay", + "/relay/following", + "/relay/followers", + "/api/ap/whoami" + ] + + for get_uri <- get_uris do + conn + |> get(get_uri) + |> json_response(404) + + conn + |> assign(:user, user) + |> get(get_uri) + |> json_response(404) + end + end + + test "returns 404 for activity-related POST routes", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + + post_activity_data = + "test/fixtures/mastodon-post-activity.json" + |> File.read!() + |> Poison.decode!() + + post_activity_uris = [ + "/inbox", + "/relay/inbox", + "/users/#{user.nickname}/inbox", + "/users/#{user.nickname}/outbox" + ] + + for post_activity_uri <- post_activity_uris do + conn + |> post(post_activity_uri, post_activity_data) + |> json_response(404) + + conn + |> assign(:user, user) + |> post(post_activity_uri, post_activity_data) + |> json_response(404) + end + end + + test "returns 404 for media upload attempt", %{conn: conn} do + user = insert(:user) + desc = "Description of the image" + + image = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + conn + |> assign(:user, user) + |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) + |> json_response(404) + end + end end -- cgit v1.2.3 From 85d571fc238c14bedbc0d9a0af2c7c0d76d62c4a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 2 Mar 2020 12:52:41 -0600 Subject: 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"}, -- cgit v1.2.3 From f987d83885eef7cd8d114feefe8870a8c5e841c6 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 2 Mar 2020 13:00:05 -0600 Subject: 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. -- cgit v1.2.3 From bd8624d649643c5a14bb24d8b2f2aed0454fb50d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 2 Mar 2020 22:02:21 +0300 Subject: [#1560] Added tests for non-federating instance bahaviour to OStatusControllerTest. --- test/web/ostatus/ostatus_controller_test.exs | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index 50235dfef..2b7bc662d 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -277,4 +277,33 @@ test "404s when attachment isn't audio or video", %{conn: conn} do |> response(404) end end + + describe "when instance is not federating," do + clear_config([:instance, :federating]) do + Pleroma.Config.put([:instance, :federating], false) + end + + test "returns 404 for GET routes", %{conn: conn} do + conn = put_req_header(conn, "accept", "application/json") + + note_activity = insert(:note_activity, local: true) + [_, activity_uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) + + object = Object.normalize(note_activity) + [_, object_uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, object.data["id"])) + + get_uris = [ + "/activities/#{activity_uuid}", + "/objects/#{object_uuid}", + "/notice/#{note_activity.id}", + "/notice/#{note_activity.id}/embed_player" + ] + + for get_uri <- get_uris do + conn + |> get(get_uri) + |> json_response(404) + end + end + end end -- cgit v1.2.3 From 9b6c7843d669c80bd062214e598d0f5d9738de8e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 3 Mar 2020 04:58:12 +0100 Subject: debian_based_*.md: Use erlang-nox metapackage --- docs/installation/debian_based_en.md | 10 +++------- docs/installation/debian_based_jp.md | 22 +++++++++------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index fe2dbb92d..a900ec61d 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -7,13 +7,9 @@ This guide will assume you are on Debian Stretch. This guide should also work wi * `postgresql` (9.6+, Ubuntu 16.04 comes with 9.5, you can get a newer version from [here](https://www.postgresql.org/download/linux/ubuntu/)) * `postgresql-contrib` (9.6+, same situtation as above) -* `elixir` (1.5+, [install from here, Debian and Ubuntu ship older versions](https://elixir-lang.org/install.html#unix-and-unix-like) or use [asdf](https://github.com/asdf-vm/asdf) as the pleroma user) +* `elixir` (1.8+, [install from here, Debian and Ubuntu ship older versions](https://elixir-lang.org/install.html#unix-and-unix-like) or use [asdf](https://github.com/asdf-vm/asdf) as the pleroma user) * `erlang-dev` -* `erlang-tools` -* `erlang-parsetools` -* `erlang-eldap`, if you want to enable ldap authenticator -* `erlang-ssh` -* `erlang-xmerl` +* `erlang-nox` * `git` * `build-essential` @@ -50,7 +46,7 @@ sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb ```shell sudo apt update -sudo apt install elixir erlang-dev erlang-parsetools erlang-xmerl erlang-tools erlang-ssh +sudo apt install elixir erlang-dev erlang-nox ``` ### Install PleromaBE diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index 7aa0bcc24..a3c4621d8 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -10,21 +10,17 @@ ### 必要なソフトウェア - PostgreSQL 9.6以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください) -- postgresql-contrib 9.6以上 (同上) -- Elixir 1.5 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) - - erlang-dev -- erlang-tools -- erlang-parsetools -- erlang-eldap (LDAP認証を有効化するときのみ必要) -- erlang-ssh -- erlang-xmerl -- git -- build-essential +- `postgresql-contrib` 9.6以上 (同上) +- Elixir 1.8 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) +- `erlang-dev` +- `erlang-nox` +- `git` +- `build-essential` #### このガイドで利用している追加パッケージ -- nginx (おすすめです。他のリバースプロキシを使う場合は、参考となる設定をこのリポジトリから探してください) -- certbot (または何らかのLet's Encrypt向けACMEクライアント) +- `nginx` (おすすめです。他のリバースプロキシを使う場合は、参考となる設定をこのリポジトリから探してください) +- `certbot` (または何らかのLet's Encrypt向けACMEクライアント) ### システムを準備する @@ -51,7 +47,7 @@ sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb * ElixirとErlangをインストールします、 ``` sudo apt update -sudo apt install elixir erlang-dev erlang-parsetools erlang-xmerl erlang-tools erlang-ssh +sudo apt install elixir erlang-dev erlang-nox ``` ### Pleroma BE (バックエンド) をインストールします -- cgit v1.2.3 From 3ecdead31ae65f395104a5fd7fafc847a7b97eca Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 10:33:40 +0300 Subject: 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 -- cgit v1.2.3 From 4c8569d403f47957f7a5d698c595959007c8a95a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 12:19:29 +0300 Subject: otp_version refactor --- lib/pleroma/application.ex | 35 +++++++------ lib/pleroma/otp_version.ex | 74 ++++++++++++---------------- test/fixtures/warnings/otp_version/error | 1 - test/fixtures/warnings/otp_version/undefined | 1 - test/otp_version_test.exs | 42 +++++----------- 5 files changed, 63 insertions(+), 90 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 @@ -defmodule Pleroma.OTPVersion do - @type check_status() :: :undefined | {:error, String.t()} | :ok +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only - require Logger +defmodule Pleroma.OTPVersion do + @type check_status() :: :ok | :undefined | {:error, String.t()} - @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 check_files([]), do: nil + defp get_version_from_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 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 + 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/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 -- cgit v1.2.3 From 097ad10d02598fb6b77f305c10341a13fb57ceee Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:29:51 +0000 Subject: 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), -- cgit v1.2.3 From 2c8d80dc0ad594cfe25ebadd9e7a187c95914b34 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:29:57 +0000 Subject: 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 -> -- cgit v1.2.3 From a3ad028973154dafad910d4d73d7d4d4822627c1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:34:36 +0000 Subject: 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 -- cgit v1.2.3 From df3c59d9280b94cf99571cbbd1b10c334db8e44d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:45:18 +0000 Subject: 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. -- cgit v1.2.3 From d30ff35d94ff7d8bc07f0221323a75b07641ee8d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:46:53 +0000 Subject: 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 -- cgit v1.2.3 From 614e3934f9190ff199df087de34146ad5f34c660 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:50:42 +0000 Subject: 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 -- cgit v1.2.3 From a21a66972f8733de766bc538fe81f2e0ccb57925 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:52:01 +0000 Subject: 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) -- cgit v1.2.3 From 7eb65929924af50146d89192c2cf557e3bdbf07f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:53:31 +0000 Subject: 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, -- cgit v1.2.3 From 151dc4e387cfbb91b7cd85461ce0deb1e5f5fe30 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 09:53:37 +0000 Subject: 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} <- -- cgit v1.2.3 From 28ed4b41d03c6a137d198b8c67fb081c7ebfbbc6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 13:05:28 +0300 Subject: 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) -- cgit v1.2.3 From 24d1ac125c6ae719b3d119f2ec0079dcd74eadc2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 13:24:19 +0300 Subject: 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 -- cgit v1.2.3 From d0e4d3ca3b9d8b8ed00d58e9e1c2a05ab561326c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 14:56:49 +0300 Subject: 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] -- cgit v1.2.3 From 05429730e46b8605544637feebd4c409a4e9ed18 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 15:11:48 +0300 Subject: unnecessary with --- lib/pleroma/http/http.ex | 59 ++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 29 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 - - 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 + 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) + + adapter = Application.get_env(:tesla, :adapter) + client = Tesla.client([Tesla.Middleware.FollowRedirects], adapter) + + pid = Process.whereis(adapter_opts[:pool]) + + pool_alive? = + if adapter == Tesla.Adapter.Gun && pid do + Process.alive?(pid) + 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 @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 -- cgit v1.2.3 From ee8071f0d5a8a53f6a9ae635d6ea57ce8576e21b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 15:12:09 +0300 Subject: 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{ -- cgit v1.2.3 From e605e79df9761cef3d9f93c489dd4618c6b70eda Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 15:44:13 +0300 Subject: 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 -- cgit v1.2.3 From 7d68924e4f7233590457aa7e32a21f082dd0584f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:08:21 +0300 Subject: 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] <- -- cgit v1.2.3 From 8fc00b7cbff86885ec99d01821c403a766202659 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:27:46 +0300 Subject: 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 -- cgit v1.2.3 From 7c0ed9302cb13ab44c1bf18017538315dcd0ce2e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:46:20 +0300 Subject: 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() -- cgit v1.2.3 From 6ebf389d6e6ca5f3e56f9b017531f5f7e301ed3c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 16:51:49 +0300 Subject: 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 -> -- cgit v1.2.3 From aaa879ce75a62e69a458226e65bef31b0f2ed08c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:27:22 +0300 Subject: 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 -- cgit v1.2.3 From 24bf5c4e89e6f97ed3d53157cead48c04015a51b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:27:56 +0300 Subject: 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()} -- cgit v1.2.3 From 3723d723652b747b00fc26054101c15e39a5af18 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:32:59 +0300 Subject: 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 -- cgit v1.2.3 From 1ad34bfdbaee7d98167dc7dc7be8b65fd5e6c5f1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 17:44:04 +0300 Subject: 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) - }" + 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 - 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}" - ) - 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 -- cgit v1.2.3 From 8854770fc4e9079131a0897d5fb6c0ccccf98bc6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 18:01:35 +0300 Subject: 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), -- cgit v1.2.3 From f98ee730f01de528797e38f27964b69a465662c4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 18:53:44 +0300 Subject: adapter renaming to adapter_helper --- lib/pleroma/http/adapter.ex | 41 ----- lib/pleroma/http/adapter/gun.ex | 120 ------------ lib/pleroma/http/adapter/hackney.ex | 41 ----- lib/pleroma/http/adapter_helper.ex | 41 +++++ lib/pleroma/http/adapter_helper/gun.ex | 120 ++++++++++++ lib/pleroma/http/adapter_helper/hackney.ex | 41 +++++ lib/pleroma/http/connection.ex | 8 +- test/http/adapter/gun_test.exs | 286 ----------------------------- test/http/adapter/hackney_test.exs | 54 ------ test/http/adapter_helper/gun_test.exs | 286 +++++++++++++++++++++++++++++ test/http/adapter_helper/hackney_test.exs | 54 ++++++ test/http/adapter_helper_test.exs | 28 +++ test/http/adapter_test.exs | 27 --- 13 files changed, 574 insertions(+), 573 deletions(-) delete mode 100644 lib/pleroma/http/adapter.ex delete mode 100644 lib/pleroma/http/adapter/gun.ex delete mode 100644 lib/pleroma/http/adapter/hackney.ex create mode 100644 lib/pleroma/http/adapter_helper.ex create mode 100644 lib/pleroma/http/adapter_helper/gun.ex create mode 100644 lib/pleroma/http/adapter_helper/hackney.ex delete mode 100644 test/http/adapter/gun_test.exs delete mode 100644 test/http/adapter/hackney_test.exs create mode 100644 test/http/adapter_helper/gun_test.exs create mode 100644 test/http/adapter_helper/hackney_test.exs 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.ex deleted file mode 100644 index a3b84d8f3..000000000 --- a/lib/pleroma/http/adapter.ex +++ /dev/null @@ -1,41 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.HTTP.Adapter do - alias Pleroma.HTTP.Connection - - @type proxy :: - {Connection.host(), pos_integer()} - | {Connection.proxy_type(), pos_integer()} - - @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 - case Connection.parse_proxy(proxy_url) do - {:ok, host, port} -> {host, port} - {: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) -end diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex deleted file mode 100644 index ecf9c5b62..000000000 --- a/lib/pleroma/http/adapter/gun.ex +++ /dev/null @@ -1,120 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 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: 5_000, - domain_lookup_timeout: 5_000, - tls_handshake_timeout: 5_000, - retry: 1, - retry_timeout: 1000, - 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 = 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 - 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: format_host(host)]}, - log_level: :warning - ] - ] - - 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 - 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 - 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/adapter/hackney.ex b/lib/pleroma/http/adapter/hackney.ex deleted file mode 100644 index 00db30083..000000000 --- a/lib/pleroma/http/adapter/hackney.ex +++ /dev/null @@ -1,41 +0,0 @@ -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/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex new file mode 100644 index 000000000..2c13666ec --- /dev/null +++ b/lib/pleroma/http/adapter_helper.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper do + alias Pleroma.HTTP.Connection + + @type proxy :: + {Connection.host(), pos_integer()} + | {Connection.proxy_type(), pos_integer()} + + @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 + case Connection.parse_proxy(proxy_url) do + {:ok, host, port} -> {host, port} + {: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) +end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex new file mode 100644 index 000000000..b3298ec7f --- /dev/null +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -0,0 +1,120 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper.Gun do + @behaviour Pleroma.HTTP.AdapterHelper + + alias Pleroma.HTTP.AdapterHelper + + require Logger + + alias Pleroma.Pool.Connections + + @defaults [ + connect_timeout: 5_000, + domain_lookup_timeout: 5_000, + tls_handshake_timeout: 5_000, + retry: 1, + retry_timeout: 1000, + 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) + |> AdapterHelper.maybe_add_proxy(AdapterHelper.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 = 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 + 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: format_host(host)]}, + log_level: :warning + ] + ] + + 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 + 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 + 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/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex new file mode 100644 index 000000000..a0e161eaa --- /dev/null +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -0,0 +1,41 @@ +defmodule Pleroma.HTTP.AdapterHelper.Hackney do + @behaviour Pleroma.HTTP.AdapterHelper + + @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.AdapterHelper.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 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/gun_test.exs deleted file mode 100644 index a05471ac6..000000000 --- a/test/http/adapter/gun_test.exs +++ /dev/null @@ -1,286 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 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.Gun.Conn - 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 = 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 - level = Application.get_env(:logger, :level) - Logger.configure(level: :debug) - 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 successful. 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 = Conn.open(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 = Conn.open(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 = Conn.open(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 = Conn.open(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 = 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] - - 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 = 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] - - 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 - - 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/hackney_test.exs b/test/http/adapter/hackney_test.exs deleted file mode 100644 index 35cb58125..000000000 --- a/test/http/adapter/hackney_test.exs +++ /dev/null @@ -1,54 +0,0 @@ -# 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_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs new file mode 100644 index 000000000..bc7e3f0e0 --- /dev/null +++ b/test/http/adapter_helper/gun_test.exs @@ -0,0 +1,286 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +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.AdapterHelper.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 = 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 + level = Application.get_env(:logger, :level) + Logger.configure(level: :debug) + 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 successful. 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 = Conn.open(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 = Conn.open(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 = Conn.open(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 = Conn.open(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 = 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] + + 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 = 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] + + 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 + + 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_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs new file mode 100644 index 000000000..82f5a7883 --- /dev/null +++ b/test/http/adapter_helper/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.AdapterHelper.HackneyTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + + alias Pleroma.Config + alias Pleroma.HTTP.AdapterHelper.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_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 -- cgit v1.2.3 From 23f407bf093723344e63eba6a63f5cd58aa7313e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 18:57:16 +0300 Subject: 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 -- cgit v1.2.3 From 884d9710b209cc9981c7de61d4e95fd26cd83820 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 3 Mar 2020 19:24:14 +0300 Subject: refactoring for gun api modules --- config/test.exs | 2 +- lib/pleroma/gun/api.ex | 46 ++++++--- lib/pleroma/gun/api/mock.ex | 154 ----------------------------- 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 +- test/support/gun_mock.ex | 155 ++++++++++++++++++++++++++++++ 13 files changed, 229 insertions(+), 232 deletions(-) delete mode 100644 lib/pleroma/gun/api/mock.ex create mode 100644 test/support/gun_mock.ex 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/api/mock.ex b/lib/pleroma/gun/api/mock.ex deleted file mode 100644 index 6d24b0e69..000000000 --- a/lib/pleroma/gun/api/mock.ex +++ /dev/null @@ -1,154 +0,0 @@ -# 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, _timeout), do: {:ok, :http} - - @impl API - def set_owner(_pid, _owner), do: :ok - - @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 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/test/support/gun_mock.ex b/test/support/gun_mock.ex new file mode 100644 index 000000000..e13afd08c --- /dev/null +++ b/test/support/gun_mock.ex @@ -0,0 +1,155 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 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 -- cgit v1.2.3 From b6fc98d9cd3a32b39606c65cb4f298d280e2537c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 3 Mar 2020 22:22:02 +0300 Subject: [#1560] ActivityPubController federation state restrictions adjustments. Adjusted tests. --- lib/pleroma/plugs/federating_plug.ex | 4 +- .../web/activity_pub/activity_pub_controller.ex | 44 ++++++++++++++++------ lib/pleroma/web/router.ex | 3 ++ .../activity_pub/activity_pub_controller_test.exs | 29 ++------------ 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex index 4dc4e9279..4c5aca3e9 100644 --- a/lib/pleroma/plugs/federating_plug.ex +++ b/lib/pleroma/plugs/federating_plug.ex @@ -10,7 +10,7 @@ def init(options) do end def call(conn, _opts) do - if Pleroma.Config.get([:instance, :federating]) do + if federating?() do conn else conn @@ -20,4 +20,6 @@ def call(conn, _opts) do |> halt() end end + + def federating?, do: Pleroma.Config.get([:instance, :federating]) end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index aee574262..e1984f88f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -18,19 +18,31 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.FederatingPlug alias Pleroma.Web.Federator require Logger action_fallback(:errors) + # Note: some of the following actions (like :update_inbox) may be server-to-server as well + @client_to_server_actions [ + :whoami, + :read_inbox, + :update_outbox, + :upload_media, + :followers, + :following + ] + + plug(FederatingPlug when action not in @client_to_server_actions) + plug( Pleroma.Plugs.Cache, [query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2] when action in [:activity, :object] ) - plug(Pleroma.Web.FederatingPlug) plug(:set_requester_reachable when action in [:inbox]) plug(:relay_active? when action in [:relay]) @@ -255,8 +267,16 @@ def inbox(%{assigns: %{valid_signature: true}} = conn, params) do json(conn, "ok") end - # only accept relayed Creates - def inbox(conn, %{"type" => "Create"} = params) do + # POST /relay/inbox -or- POST /internal/fetch/inbox + def inbox(conn, params) do + if params["type"] == "Create" && FederatingPlug.federating?() do + post_inbox_relayed_create(conn, params) + else + post_inbox_fallback(conn, params) + end + end + + defp post_inbox_relayed_create(conn, params) do Logger.debug( "Signature missing or not from author, relayed Create message, fetching object from source" ) @@ -266,7 +286,7 @@ def inbox(conn, %{"type" => "Create"} = params) do json(conn, "ok") end - def inbox(conn, params) do + defp post_inbox_fallback(conn, params) do headers = Enum.into(conn.req_headers, %{}) if String.contains?(headers["signature"], params["actor"]) do @@ -314,7 +334,7 @@ def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do def whoami(_conn, _params), do: {:error, :not_found} def read_inbox( - %{assigns: %{user: %{nickname: nickname} = user}} = conn, + %{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{"nickname" => nickname, "page" => page?} = params ) when page? in [true, "true"] do @@ -337,7 +357,7 @@ def read_inbox( }) end - def read_inbox(%{assigns: %{user: %{nickname: nickname} = user}} = conn, %{ + def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{ "nickname" => nickname }) do with {:ok, user} <- User.ensure_keys_present(user) do @@ -356,7 +376,7 @@ def read_inbox(%{assigns: %{user: nil}} = conn, %{"nickname" => nickname}) do |> json(err) end - def read_inbox(%{assigns: %{user: %{nickname: as_nickname}}} = conn, %{ + def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ "nickname" => nickname }) do err = @@ -370,7 +390,7 @@ def read_inbox(%{assigns: %{user: %{nickname: as_nickname}}} = conn, %{ |> json(err) end - def handle_user_activity(user, %{"type" => "Create"} = params) do + def handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do object = params["object"] |> Map.merge(Map.take(params, ["to", "cc"])) @@ -386,7 +406,7 @@ def handle_user_activity(user, %{"type" => "Create"} = params) do }) end - def handle_user_activity(user, %{"type" => "Delete"} = params) do + def handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do with %Object{} = object <- Object.normalize(params["object"]), true <- user.is_moderator || user.ap_id == object.data["actor"], {:ok, delete} <- ActivityPub.delete(object) do @@ -396,7 +416,7 @@ def handle_user_activity(user, %{"type" => "Delete"} = params) do end end - def handle_user_activity(user, %{"type" => "Like"} = params) do + def handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do with %Object{} = object <- Object.normalize(params["object"]), {:ok, activity, _object} <- ActivityPub.like(user, object) do {:ok, activity} @@ -434,7 +454,7 @@ def update_outbox( end end - def update_outbox(%{assigns: %{user: user}} = conn, %{"nickname" => nickname} = _) do + def update_outbox(%{assigns: %{user: %User{} = user}} = conn, %{"nickname" => nickname}) do err = dgettext("errors", "can't update outbox of %{nickname} as %{as_nickname}", nickname: nickname, @@ -492,7 +512,7 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do - HTTP Code: 201 Created - HTTP Body: ActivityPub object to be inserted into another's `attachment` field """ - def upload_media(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do with {:ok, object} <- ActivityPub.upload( file, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 980242c68..5f3a06caa 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -541,6 +541,7 @@ defmodule Pleroma.Web.Router do get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end + # Server to Server (S2S) AP interactions pipeline :activitypub do plug(:accepts, ["activity+json", "json"]) plug(Pleroma.Web.Plugs.HTTPSignaturePlug) @@ -554,6 +555,7 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/outbox", ActivityPubController, :outbox) end + # Client to Server (C2S) AP interactions pipeline :activitypub_client do plug(:accepts, ["activity+json", "json"]) plug(:fetch_session) @@ -568,6 +570,7 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.EnsureUserKeyPlug) end + # Note: propagate _any_ updates to `@client_to_server_actions` in `ActivityPubController` scope "/", Pleroma.Web.ActivityPub do pipe_through([:activitypub_client]) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index af0417406..b853474d4 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -775,7 +775,7 @@ test "it returns the followers in a collection", %{conn: conn} do assert result["first"]["orderedItems"] == [user.ap_id] end - test "it returns returns a uri if the user has 'hide_followers' set", %{conn: conn} do + test "it returns a uri if the user has 'hide_followers' set", %{conn: conn} do user = insert(:user) user_two = insert(:user, hide_followers: true) User.follow(user, user_two) @@ -1060,14 +1060,8 @@ test "returns 404 for GET routes", %{conn: conn} do get_uris = [ "/users/#{user.nickname}", "/users/#{user.nickname}/outbox", - "/users/#{user.nickname}/inbox?page=true", - "/users/#{user.nickname}/followers", - "/users/#{user.nickname}/following", "/internal/fetch", - "/relay", - "/relay/following", - "/relay/followers", - "/api/ap/whoami" + "/relay" ] for get_uri <- get_uris do @@ -1098,8 +1092,7 @@ test "returns 404 for activity-related POST routes", %{conn: conn} do post_activity_uris = [ "/inbox", "/relay/inbox", - "/users/#{user.nickname}/inbox", - "/users/#{user.nickname}/outbox" + "/users/#{user.nickname}/inbox" ] for post_activity_uri <- post_activity_uris do @@ -1113,21 +1106,5 @@ test "returns 404 for activity-related POST routes", %{conn: conn} do |> json_response(404) end end - - test "returns 404 for media upload attempt", %{conn: conn} do - user = insert(:user) - desc = "Description of the image" - - image = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - conn - |> assign(:user, user) - |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) - |> json_response(404) - end end end -- cgit v1.2.3 From d9c5ae7c09c7cbf3f4f66e01b7ed69a3d6388916 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 3 Mar 2020 17:16:24 -0600 Subject: 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 -- cgit v1.2.3 From 8d9dee1ba951e81aaa08b4db64b431a7456dae56 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 08:56:36 +0300 Subject: 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 -- cgit v1.2.3 From 6b2fb9160cd945cdd4b1265c793d1f85d559fccb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 09:23:42 +0300 Subject: 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 - defp get_version_from_files([]), do: nil + @spec get_version_from_files([Path.t()]) :: String.t() | nil + def 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 -- cgit v1.2.3 From 22d52f5691d985e7daaa955e97e0722f038f6fae Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 09:41:23 +0300 Subject: 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 -- cgit v1.2.3 From d6bebd4f9c8086dd87c75f3637a5d392a05f2daf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 18:13:24 +0300 Subject: 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 -- cgit v1.2.3 From fe47bcde8c20d7c968a7fb20637b4bccc6389691 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 19:44:03 +0300 Subject: 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"}, -- cgit v1.2.3 From b34bc669b91903a4567f6f527ebe16f9cd7e0ccf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 4 Mar 2020 20:09:18 +0300 Subject: 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 -- cgit v1.2.3 From eb324467d9c5c761a776ffc98347246c61ad02ae Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 09:51:52 +0300 Subject: removing try block in getting gun info --- lib/pleroma/pool/connections.ex | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 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) - - host = - case :inet.ntoa(origin_host) do - {:error, :einval} -> origin_host - ip -> ip - end + %{origin_host: origin_host, origin_scheme: scheme, origin_port: port} = Gun.info(pid) - "#{scheme}:#{host}:#{port}" - rescue - _ -> :error_gun_info - end + 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 -- cgit v1.2.3 From ad22e94f336875141a2e2db786b1f15f65402f3e Mon Sep 17 00:00:00 2001 From: eugenijm Date: Thu, 5 Mar 2020 15:01:45 +0300 Subject: Exclude private and direct statuses visible to the admin when using godmode --- lib/pleroma/web/admin_api/admin_api_controller.ex | 4 ++-- test/web/admin_api/admin_api_controller_test.exs | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index de0755ee5..178627030 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -745,14 +745,14 @@ def report_notes_delete(%{assigns: %{user: user}} = conn, %{ end end - def list_statuses(%{assigns: %{user: admin}} = conn, params) do + def list_statuses(%{assigns: %{user: _admin}} = conn, params) do godmode = params["godmode"] == "true" || params["godmode"] == true local_only = params["local_only"] == "true" || params["local_only"] == true with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true {page, page_size} = page_params(params) activities = - ActivityPub.fetch_statuses(admin, %{ + ActivityPub.fetch_statuses(nil, %{ "godmode" => godmode, "local_only" => local_only, "limit" => page_size, diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 45b22ea24..5c7858c05 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3066,7 +3066,7 @@ test "pleroma restarts", %{conn: conn} do end describe "GET /api/pleroma/admin/statuses" do - test "returns all public, unlisted, and direct statuses", %{conn: conn, admin: admin} do + test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do blocked = insert(:user) user = insert(:user) User.block(admin, blocked) @@ -3085,7 +3085,7 @@ test "returns all public, unlisted, and direct statuses", %{conn: conn, admin: a |> json_response(200) refute "private" in Enum.map(response, & &1["visibility"]) - assert length(response) == 4 + assert length(response) == 3 end test "returns only local statuses with local_only on", %{conn: conn} do @@ -3102,12 +3102,16 @@ test "returns only local statuses with local_only on", %{conn: conn} do assert length(response) == 1 end - test "returns private statuses with godmode on", %{conn: conn} do + test "returns private and direct statuses with godmode on", %{conn: conn, admin: admin} do user = insert(:user) + + {:ok, _} = + CommonAPI.post(user, %{"status" => "@#{admin.nickname}", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) conn = get(conn, "/api/pleroma/admin/statuses?godmode=true") - assert json_response(conn, 200) |> length() == 2 + assert json_response(conn, 200) |> length() == 3 end end -- cgit v1.2.3 From f0753eed0fdddd30e127213c89a118dd2e087dc9 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 17:31:06 +0300 Subject: 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 +++ .../controllers/notification_controller_test.exs | 13 +++++++ .../mastodon_api/views/notification_view_test.exs | 13 +++++++ test/web/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) -- cgit v1.2.3 From 058c9b01ac063f3cca22a653032663916a16a234 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 18:28:04 +0300 Subject: 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) -- cgit v1.2.3 From 931111fd5518cb79449cf79ffe29cb774c55d5ff Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 5 Mar 2020 18:57:45 +0300 Subject: 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 -- cgit v1.2.3 From 40765875d41f181b4ac54a772b4c61d6afc0bc34 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 5 Mar 2020 21:19:21 +0300 Subject: [#1560] Misc. improvements in ActivityPubController federation state restrictions. --- lib/pleroma/plugs/federating_plug.ex | 14 +++++++----- .../web/activity_pub/activity_pub_controller.ex | 25 +++++++++++++++------- .../activity_pub/activity_pub_controller_test.exs | 9 +++++--- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex index 4c5aca3e9..456c1bfb9 100644 --- a/lib/pleroma/plugs/federating_plug.ex +++ b/lib/pleroma/plugs/federating_plug.ex @@ -13,13 +13,17 @@ def call(conn, _opts) do if federating?() do conn else - conn - |> put_status(404) - |> Phoenix.Controller.put_view(Pleroma.Web.ErrorView) - |> Phoenix.Controller.render("404.json") - |> halt() + fail(conn) end end def federating?, do: Pleroma.Config.get([:instance, :federating]) + + def fail(conn) do + conn + |> put_status(404) + |> Phoenix.Controller.put_view(Pleroma.Web.ErrorView) + |> Phoenix.Controller.render("404.json") + |> halt() + end end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index e1984f88f..9beaaf8c9 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -29,6 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do @client_to_server_actions [ :whoami, :read_inbox, + :outbox, :update_outbox, :upload_media, :followers, @@ -140,10 +141,14 @@ defp set_cache_ttl_for(conn, entity) do # GET /relay/following def following(%{assigns: %{relay: true}} = conn, _params) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("following.json", %{user: Relay.get_actor()}) + if FederatingPlug.federating?() do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("following.json", %{user: Relay.get_actor()}) + else + FederatingPlug.fail(conn) + end end def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do @@ -177,10 +182,14 @@ def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d # GET /relay/followers def followers(%{assigns: %{relay: true}} = conn, _params) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("followers.json", %{user: Relay.get_actor()}) + if FederatingPlug.federating?() do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("followers.json", %{user: Relay.get_actor()}) + else + FederatingPlug.fail(conn) + end end def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index b853474d4..9c922e991 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -577,7 +577,7 @@ test "it removes all follower collections but actor's", %{conn: conn} do end end - describe "/users/:nickname/outbox" do + describe "GET /users/:nickname/outbox" do test "it will not bomb when there is no activity", %{conn: conn} do user = insert(:user) @@ -614,7 +614,9 @@ test "it returns an announce activity in a collection", %{conn: conn} do assert response(conn, 200) =~ announce_activity.data["object"] end + end + describe "POST /users/:nickname/outbox" do test "it rejects posts from other users", %{conn: conn} do data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!() user = insert(:user) @@ -1059,9 +1061,10 @@ test "returns 404 for GET routes", %{conn: conn} do get_uris = [ "/users/#{user.nickname}", - "/users/#{user.nickname}/outbox", "/internal/fetch", - "/relay" + "/relay", + "/relay/following", + "/relay/followers" ] for get_uri <- get_uris do -- cgit v1.2.3 From d283c9abc11a2b75f87035e0007bf063c604a667 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Mar 2020 07:53:33 -0600 Subject: Remove emoji support from AdminFE --- priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.30262183.js | Bin 179664 -> 0 bytes priv/static/adminfe/static/js/app.30262183.js.map | Bin 398360 -> 0 bytes priv/static/adminfe/static/js/app.55df3157.js | Bin 0 -> 179675 bytes priv/static/adminfe/static/js/app.55df3157.js.map | Bin 0 -> 398361 bytes 5 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 priv/static/adminfe/static/js/app.30262183.js delete mode 100644 priv/static/adminfe/static/js/app.30262183.js.map create mode 100644 priv/static/adminfe/static/js/app.55df3157.js create mode 100644 priv/static/adminfe/static/js/app.55df3157.js.map diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index b0bdb162d..e2db408c3 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.30262183.js b/priv/static/adminfe/static/js/app.30262183.js deleted file mode 100644 index c872d448f..000000000 Binary files a/priv/static/adminfe/static/js/app.30262183.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.30262183.js.map b/priv/static/adminfe/static/js/app.30262183.js.map deleted file mode 100644 index 3711b8a98..000000000 Binary files a/priv/static/adminfe/static/js/app.30262183.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.55df3157.js b/priv/static/adminfe/static/js/app.55df3157.js new file mode 100644 index 000000000..d1a37af1c Binary files /dev/null and b/priv/static/adminfe/static/js/app.55df3157.js differ diff --git a/priv/static/adminfe/static/js/app.55df3157.js.map b/priv/static/adminfe/static/js/app.55df3157.js.map new file mode 100644 index 000000000..740783b80 Binary files /dev/null and b/priv/static/adminfe/static/js/app.55df3157.js.map differ -- cgit v1.2.3 From 15d36b7f5f96932f4beece55fc299871fa0a97e4 Mon Sep 17 00:00:00 2001 From: feld Date: Fri, 6 Mar 2020 15:56:41 +0000 Subject: Revert "Merge branch 'update/admin-fe-without-emojipack' into 'develop'" This reverts merge request !2274 --- priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.30262183.js | Bin 0 -> 179664 bytes priv/static/adminfe/static/js/app.30262183.js.map | Bin 0 -> 398360 bytes priv/static/adminfe/static/js/app.55df3157.js | Bin 179675 -> 0 bytes priv/static/adminfe/static/js/app.55df3157.js.map | Bin 398361 -> 0 bytes 5 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/static/js/app.30262183.js create mode 100644 priv/static/adminfe/static/js/app.30262183.js.map delete mode 100644 priv/static/adminfe/static/js/app.55df3157.js delete mode 100644 priv/static/adminfe/static/js/app.55df3157.js.map diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index e2db408c3..b0bdb162d 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.30262183.js b/priv/static/adminfe/static/js/app.30262183.js new file mode 100644 index 000000000..c872d448f Binary files /dev/null and b/priv/static/adminfe/static/js/app.30262183.js differ diff --git a/priv/static/adminfe/static/js/app.30262183.js.map b/priv/static/adminfe/static/js/app.30262183.js.map new file mode 100644 index 000000000..3711b8a98 Binary files /dev/null and b/priv/static/adminfe/static/js/app.30262183.js.map differ diff --git a/priv/static/adminfe/static/js/app.55df3157.js b/priv/static/adminfe/static/js/app.55df3157.js deleted file mode 100644 index d1a37af1c..000000000 Binary files a/priv/static/adminfe/static/js/app.55df3157.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.55df3157.js.map b/priv/static/adminfe/static/js/app.55df3157.js.map deleted file mode 100644 index 740783b80..000000000 Binary files a/priv/static/adminfe/static/js/app.55df3157.js.map and /dev/null differ -- cgit v1.2.3 From 56ff02f2ef56465b14c9670b930d154911cc7470 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 6 Mar 2020 20:23:58 +0300 Subject: 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 | 119 +++++++++++++++++++---- test/support/gun_mock.ex | 155 ------------------------------ test/test_helper.exs | 3 + 6 files changed, 168 insertions(+), 230 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 + name = :test_connections + {:ok, pid} = Connections.start_link({name, [checkin_timeout: 150]}) {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) - :ok + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(name) + end) + + {:ok, name: name} end - clear_config([:connections_pool, :retry]) do - Pleroma.Config.put([:connections_pool, :retry], 5) + 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 - setup do - name = :test_connections - adapter = Application.get_env(:tesla, :adapter) - Application.put_env(:tesla, :adapter, Tesla.Adapter.Gun) + defp connect_mock(mock) do + mock + |> expect(:connect, &connect(&1, &2)) + |> expect(:await, &await(&1, &2)) + end - {:ok, pid} = Connections.start_link({name, [max_connections: 2, checkin_timeout: 1_500]}) + defp info_mock(mock), do: expect(mock, :info, &info(&1)) - on_exit(fn -> - Application.put_env(:tesla, :adapter, adapter) + defp start_and_register('gun-not-up.com', _, _), do: {:error, :timeout} - if Process.alive?(pid) do - GenServer.stop(name) + 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 - end) - {:ok, name: name} + 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 -> -- cgit v1.2.3 From c93c3096d5ffb2df1493f2b8e3f0627d9a8c5910 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 6 Mar 2020 21:04:18 +0300 Subject: 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 -- cgit v1.2.3 From 78282dc9839dbd17c4649cd3936bb8f4c8283745 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 6 Mar 2020 21:24:19 +0300 Subject: 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) -- cgit v1.2.3 From 14678a7708fb43e60f2f3b610f15d5090616d85c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 7 Mar 2020 10:12:34 +0300 Subject: 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 -- cgit v1.2.3 From 9f884a263904c8b243507d35b29da712a31fb444 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 7 Mar 2020 11:01:37 +0300 Subject: 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) -- cgit v1.2.3 From 5f42ecc4c74172b1b17c126106fda9da24065b11 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 7 Mar 2020 12:24:39 +0300 Subject: 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) -- cgit v1.2.3 From 86e7709a4d870cac151ed44f8bb0bd7fd054f15d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 8 Mar 2020 23:24:30 +0300 Subject: mix.exs: bump version to development one --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 89b56bc5d..bb86c38d0 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.0.0"), + version: version("2.0.50"), elixir: "~> 1.8", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), -- cgit v1.2.3 From 5fc92deef37dcc4db476520d89dd79e616356e63 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 9 Mar 2020 20:51:44 +0300 Subject: [#1560] Ensured authentication or enabled federation for federation-related routes. New tests + tests refactoring. --- lib/pleroma/plugs/ensure_authenticated_plug.ex | 19 +- lib/pleroma/plugs/federating_plug.ex | 2 +- .../web/activity_pub/activity_pub_controller.ex | 76 +++-- lib/pleroma/web/feed/user_controller.ex | 7 +- lib/pleroma/web/ostatus/ostatus_controller.ex | 10 +- lib/pleroma/web/router.ex | 5 +- test/plugs/ensure_authenticated_plug_test.exs | 66 ++++- test/support/conn_case.ex | 19 ++ .../activity_pub/activity_pub_controller_test.exs | 308 ++++++++++++++------- test/web/feed/user_controller_test.exs | 195 ++----------- .../media_proxy/media_proxy_controller_test.exs | 3 +- test/web/ostatus/ostatus_controller_test.exs | 110 ++++---- 12 files changed, 417 insertions(+), 403 deletions(-) diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 6f9b840a9..054d2297f 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -15,9 +15,24 @@ def call(%{assigns: %{user: %User{}}} = conn, _) do conn end - def call(conn, _) do + def call(conn, options) do + perform = + cond do + options[:if_func] -> options[:if_func].() + options[:unless_func] -> !options[:unless_func].() + true -> true + end + + if perform do + fail(conn) + else + conn + end + end + + def fail(conn) do conn |> render_error(:forbidden, "Invalid credentials.") - |> halt + |> halt() end end diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex index c6d622ce4..7d947339f 100644 --- a/lib/pleroma/plugs/federating_plug.ex +++ b/lib/pleroma/plugs/federating_plug.ex @@ -19,7 +19,7 @@ def call(conn, _opts) do def federating?, do: Pleroma.Config.get([:instance, :federating]) - def fail(conn) do + defp fail(conn) do conn |> put_status(404) |> Phoenix.Controller.put_view(Pleroma.Web.ErrorView) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 525e61360..8b9eb4a2c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Delivery alias Pleroma.Object alias Pleroma.Object.Fetcher + alias Pleroma.Plugs.EnsureAuthenticatedPlug alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor @@ -25,18 +26,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do action_fallback(:errors) - # Note: some of the following actions (like :update_inbox) may be server-to-server as well - @client_to_server_actions [ - :whoami, - :read_inbox, - :outbox, - :update_outbox, - :upload_media, - :followers, - :following - ] + @federating_only_actions [:internal_fetch, :relay, :relay_following, :relay_followers] - plug(FederatingPlug when action not in @client_to_server_actions) + plug(FederatingPlug when action in @federating_only_actions) + + plug( + EnsureAuthenticatedPlug, + [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions + ) + + plug( + EnsureAuthenticatedPlug + when action in [:read_inbox, :update_outbox, :whoami, :upload_media, :following, :followers] + ) plug( Pleroma.Plugs.Cache, @@ -47,7 +49,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do plug(:set_requester_reachable when action in [:inbox]) plug(:relay_active? when action in [:relay]) - def relay_active?(conn, _) do + defp relay_active?(conn, _) do if Pleroma.Config.get([:instance, :allow_relay]) do conn else @@ -140,14 +142,12 @@ defp set_cache_ttl_for(conn, entity) do end # GET /relay/following - def following(%{assigns: %{relay: true}} = conn, _params) do - if FederatingPlug.federating?() do + def relay_following(conn, _params) do + with %{halted: false} = conn <- FederatingPlug.call(conn, []) do conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("following.json", %{user: Relay.get_actor()}) - else - FederatingPlug.fail(conn) end end @@ -181,14 +181,12 @@ def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d end # GET /relay/followers - def followers(%{assigns: %{relay: true}} = conn, _params) do - if FederatingPlug.federating?() do + def relay_followers(conn, _params) do + with %{halted: false} = conn <- FederatingPlug.call(conn, []) do conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("followers.json", %{user: Relay.get_actor()}) - else - FederatingPlug.fail(conn) end end @@ -221,13 +219,16 @@ def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d end end - def outbox(conn, %{"nickname" => nickname, "page" => page?} = params) + def outbox( + %{assigns: %{user: for_user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) when page? in [true, "true"] do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do activities = if params["max_id"] do - ActivityPub.fetch_user_activities(user, nil, %{ + ActivityPub.fetch_user_activities(user, for_user, %{ "max_id" => params["max_id"], # This is a hack because postgres generates inefficient queries when filtering by # 'Answer', poll votes will be hidden by the visibility filter in this case anyway @@ -235,7 +236,7 @@ def outbox(conn, %{"nickname" => nickname, "page" => page?} = params) "limit" => 10 }) else - ActivityPub.fetch_user_activities(user, nil, %{ + ActivityPub.fetch_user_activities(user, for_user, %{ "limit" => 10, "include_poll_votes" => true }) @@ -298,7 +299,8 @@ defp post_inbox_relayed_create(conn, params) do defp post_inbox_fallback(conn, params) do headers = Enum.into(conn.req_headers, %{}) - if String.contains?(headers["signature"], params["actor"]) do + if headers["signature"] && params["actor"] && + String.contains?(headers["signature"], params["actor"]) do Logger.debug( "Signature validation error for: #{params["actor"]}, make sure you are forwarding the HTTP Host header!" ) @@ -306,7 +308,9 @@ defp post_inbox_fallback(conn, params) do Logger.debug(inspect(conn.req_headers)) end - json(conn, dgettext("errors", "error")) + conn + |> put_status(:bad_request) + |> json(dgettext("errors", "error")) end defp represent_service_actor(%User{} = user, conn) do @@ -340,8 +344,6 @@ def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do |> render("user.json", %{user: user}) end - def whoami(_conn, _params), do: {:error, :not_found} - def read_inbox( %{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{"nickname" => nickname, "page" => page?} = params @@ -377,14 +379,6 @@ def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{ end end - def read_inbox(%{assigns: %{user: nil}} = conn, %{"nickname" => nickname}) do - err = dgettext("errors", "can't read inbox of %{nickname}", nickname: nickname) - - conn - |> put_status(:forbidden) - |> json(err) - end - def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ "nickname" => nickname }) do @@ -399,7 +393,7 @@ def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ |> json(err) end - def handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do + defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do object = params["object"] |> Map.merge(Map.take(params, ["to", "cc"])) @@ -415,7 +409,7 @@ def handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do }) end - def handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do + defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do with %Object{} = object <- Object.normalize(params["object"]), true <- user.is_moderator || user.ap_id == object.data["actor"], {:ok, delete} <- ActivityPub.delete(object) do @@ -425,7 +419,7 @@ def handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do end end - def handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do + defp handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do with %Object{} = object <- Object.normalize(params["object"]), {:ok, activity, _object} <- ActivityPub.like(user, object) do {:ok, activity} @@ -434,7 +428,7 @@ def handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do end end - def handle_user_activity(_, _) do + defp handle_user_activity(_, _) do {:error, dgettext("errors", "Unhandled activity type")} end @@ -475,13 +469,13 @@ def update_outbox(%{assigns: %{user: %User{} = user}} = conn, %{"nickname" => ni |> json(err) end - def errors(conn, {:error, :not_found}) do + defp errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> json(dgettext("errors", "Not found")) end - def errors(conn, _e) do + defp errors(conn, _e) do conn |> put_status(:internal_server_error) |> json(dgettext("errors", "error")) diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 59aabb549..9ba602d9f 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -25,7 +25,12 @@ def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname def feed_redirect(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do - ActivityPubController.call(conn, :user) + with %{halted: false} = conn <- + Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn, + unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + ) do + ActivityPubController.call(conn, :user) + end end def feed_redirect(conn, %{"nickname" => nickname}) do diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index e3f42b5c4..6fd3cfce5 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -16,7 +16,9 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Metadata.PlayerView alias Pleroma.Web.Router - plug(Pleroma.Web.FederatingPlug) + plug(Pleroma.Plugs.EnsureAuthenticatedPlug, + unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + ) plug( RateLimiter, @@ -137,13 +139,13 @@ def notice_player(conn, %{"id" => id}) do end end - def errors(conn, {:error, :not_found}) do + defp errors(conn, {:error, :not_found}) do render_error(conn, :not_found, "Not found") end - def errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) + defp errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) - def errors(conn, _) do + defp errors(conn, _) do render_error(conn, :internal_server_error, "Something went wrong") end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5f3a06caa..e4e3ee704 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -570,7 +570,6 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.EnsureUserKeyPlug) end - # Note: propagate _any_ updates to `@client_to_server_actions` in `ActivityPubController` scope "/", Pleroma.Web.ActivityPub do pipe_through([:activitypub_client]) @@ -600,8 +599,8 @@ defmodule Pleroma.Web.Router do post("/inbox", ActivityPubController, :inbox) end - get("/following", ActivityPubController, :following, assigns: %{relay: true}) - get("/followers", ActivityPubController, :followers, assigns: %{relay: true}) + get("/following", ActivityPubController, :relay_following) + get("/followers", ActivityPubController, :relay_followers) end scope "/internal/fetch", Pleroma.Web.ActivityPub do diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs index 18be5edd0..7f3559b83 100644 --- a/test/plugs/ensure_authenticated_plug_test.exs +++ b/test/plugs/ensure_authenticated_plug_test.exs @@ -8,24 +8,62 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlugTest do alias Pleroma.Plugs.EnsureAuthenticatedPlug alias Pleroma.User - test "it halts if no user is assigned", %{conn: conn} do - conn = - conn - |> EnsureAuthenticatedPlug.call(%{}) + describe "without :if_func / :unless_func options" do + test "it halts if user is NOT assigned", %{conn: conn} do + conn = EnsureAuthenticatedPlug.call(conn, %{}) - assert conn.status == 403 - assert conn.halted == true + assert conn.status == 403 + assert conn.halted == true + end + + test "it continues if a user is assigned", %{conn: conn} do + conn = assign(conn, :user, %User{}) + ret_conn = EnsureAuthenticatedPlug.call(conn, %{}) + + assert ret_conn == conn + end end - test "it continues if a user is assigned", %{conn: conn} do - conn = - conn - |> assign(:user, %User{}) + describe "with :if_func / :unless_func options" do + setup do + %{ + true_fn: fn -> true end, + false_fn: fn -> false end + } + end + + test "it continues if a user is assigned", %{conn: conn, true_fn: true_fn, false_fn: false_fn} do + conn = assign(conn, :user, %User{}) + assert EnsureAuthenticatedPlug.call(conn, if_func: true_fn) == conn + assert EnsureAuthenticatedPlug.call(conn, if_func: false_fn) == conn + assert EnsureAuthenticatedPlug.call(conn, unless_func: true_fn) == conn + assert EnsureAuthenticatedPlug.call(conn, unless_func: false_fn) == conn + end + + test "it continues if a user is NOT assigned but :if_func evaluates to `false`", + %{conn: conn, false_fn: false_fn} do + assert EnsureAuthenticatedPlug.call(conn, if_func: false_fn) == conn + end + + test "it continues if a user is NOT assigned but :unless_func evaluates to `true`", + %{conn: conn, true_fn: true_fn} do + assert EnsureAuthenticatedPlug.call(conn, unless_func: true_fn) == conn + end + + test "it halts if a user is NOT assigned and :if_func evaluates to `true`", + %{conn: conn, true_fn: true_fn} do + conn = EnsureAuthenticatedPlug.call(conn, if_func: true_fn) + + assert conn.status == 403 + assert conn.halted == true + end - ret_conn = - conn - |> EnsureAuthenticatedPlug.call(%{}) + test "it halts if a user is NOT assigned and :unless_func evaluates to `false`", + %{conn: conn, false_fn: false_fn} do + conn = EnsureAuthenticatedPlug.call(conn, unless_func: false_fn) - assert ret_conn == conn + assert conn.status == 403 + assert conn.halted == true + end end end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 0f2e81f9e..d6595f971 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -48,6 +48,25 @@ defp oauth_access(scopes, opts \\ []) do %{user: user, token: token, conn: conn} end + + defp ensure_federating_or_authenticated(conn, url, user) do + Pleroma.Config.put([:instance, :federating], false) + + conn + |> get(url) + |> response(403) + + conn + |> assign(:user, user) + |> get(url) + |> response(200) + + Pleroma.Config.put([:instance, :federating], true) + + conn + |> get(url) + |> response(200) + end end end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 04800b7ea..a939d0beb 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do import Pleroma.Factory alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Delivery alias Pleroma.Instances alias Pleroma.Object @@ -25,8 +26,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do :ok end - clear_config_all([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], true) + clear_config([:instance, :federating]) do + Config.put([:instance, :federating], true) end describe "/relay" do @@ -42,12 +43,21 @@ test "with the relay active, it returns the relay user", %{conn: conn} do end test "with the relay disabled, it returns 404", %{conn: conn} do - Pleroma.Config.put([:instance, :allow_relay], false) + Config.put([:instance, :allow_relay], false) conn |> get(activity_pub_path(conn, :relay)) |> json_response(404) - |> assert + end + + test "on non-federating instance, it returns 404", %{conn: conn} do + Config.put([:instance, :federating], false) + user = insert(:user) + + conn + |> assign(:user, user) + |> get(activity_pub_path(conn, :relay)) + |> json_response(404) end end @@ -60,6 +70,16 @@ test "it returns the internal fetch user", %{conn: conn} do assert res["id"] =~ "/fetch" end + + test "on non-federating instance, it returns 404", %{conn: conn} do + Config.put([:instance, :federating], false) + user = insert(:user) + + conn + |> assign(:user, user) + |> get(activity_pub_path(conn, :internal_fetch)) + |> json_response(404) + end end describe "/users/:nickname" do @@ -123,9 +143,34 @@ test "it returns 404 for remote users", %{ assert json_response(conn, 404) end + + test "it returns error when user is not found", %{conn: conn} do + response = + conn + |> put_req_header("accept", "application/json") + |> get("/users/jimm") + |> json_response(404) + + assert response == "Not found" + end + + test "it requires authentication if instance is NOT federating", %{ + conn: conn + } do + user = insert(:user) + + conn = + put_req_header( + conn, + "accept", + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + ) + + ensure_federating_or_authenticated(conn, "/users/#{user.nickname}.json", user) + end end - describe "/object/:uuid" do + describe "/objects/:uuid" do test "it returns a json representation of the object with accept application/json", %{ conn: conn } do @@ -236,6 +281,18 @@ test "cached purged after object deletion", %{conn: conn} do assert "Not found" == json_response(conn2, :not_found) end + + test "it requires authentication if instance is NOT federating", %{ + conn: conn + } do + user = insert(:user) + note = insert(:note) + uuid = String.split(note.data["id"], "/") |> List.last() + + conn = put_req_header(conn, "accept", "application/activity+json") + + ensure_federating_or_authenticated(conn, "/objects/#{uuid}", user) + end end describe "/activities/:uuid" do @@ -307,6 +364,18 @@ test "cached purged after activity deletion", %{conn: conn} do assert "Not found" == json_response(conn2, :not_found) end + + test "it requires authentication if instance is NOT federating", %{ + conn: conn + } do + user = insert(:user) + activity = insert(:note_activity) + uuid = String.split(activity.data["id"], "/") |> List.last() + + conn = put_req_header(conn, "accept", "application/activity+json") + + ensure_federating_or_authenticated(conn, "/activities/#{uuid}", user) + end end describe "/inbox" do @@ -341,6 +410,34 @@ test "it clears `unreachable` federation status of the sender", %{conn: conn} do assert "ok" == json_response(conn, 200) assert Instances.reachable?(sender_url) end + + test "without valid signature, " <> + "it only accepts Create activities and requires enabled federation", + %{conn: conn} do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() + non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() + + conn = put_req_header(conn, "content-type", "application/activity+json") + + Config.put([:instance, :federating], false) + + conn + |> post("/inbox", data) + |> json_response(403) + + conn + |> post("/inbox", non_create_data) + |> json_response(403) + + Config.put([:instance, :federating], true) + + ret_conn = post(conn, "/inbox", data) + assert "ok" == json_response(ret_conn, 200) + + conn + |> post("/inbox", non_create_data) + |> json_response(400) + end end describe "/users/:nickname/inbox" do @@ -479,22 +576,11 @@ test "it accepts messages from actors that are followed by the user", %{ test "it rejects reads from other users", %{conn: conn} do user = insert(:user) - otheruser = insert(:user) - - conn = - conn - |> assign(:user, otheruser) - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/inbox") - - assert json_response(conn, 403) - end - - test "it doesn't crash without an authenticated user", %{conn: conn} do - user = insert(:user) + other_user = insert(:user) conn = conn + |> assign(:user, other_user) |> put_req_header("accept", "application/activity+json") |> get("/users/#{user.nickname}/inbox") @@ -575,14 +661,30 @@ test "it removes all follower collections but actor's", %{conn: conn} do refute recipient.follower_address in activity.data["cc"] refute recipient.follower_address in activity.data["to"] end + + test "it requires authentication", %{conn: conn} do + user = insert(:user) + conn = put_req_header(conn, "accept", "application/activity+json") + + ret_conn = get(conn, "/users/#{user.nickname}/inbox") + assert json_response(ret_conn, 403) + + ret_conn = + conn + |> assign(:user, user) + |> get("/users/#{user.nickname}/inbox") + + assert json_response(ret_conn, 200) + end end describe "GET /users/:nickname/outbox" do - test "it will not bomb when there is no activity", %{conn: conn} do + test "it returns 200 even if there're no activities", %{conn: conn} do user = insert(:user) conn = conn + |> assign(:user, user) |> put_req_header("accept", "application/activity+json") |> get("/users/#{user.nickname}/outbox") @@ -597,6 +699,7 @@ test "it returns a note activity in a collection", %{conn: conn} do conn = conn + |> assign(:user, user) |> put_req_header("accept", "application/activity+json") |> get("/users/#{user.nickname}/outbox?page=true") @@ -609,26 +712,38 @@ test "it returns an announce activity in a collection", %{conn: conn} do conn = conn + |> assign(:user, user) |> put_req_header("accept", "application/activity+json") |> get("/users/#{user.nickname}/outbox?page=true") assert response(conn, 200) =~ announce_activity.data["object"] end + + test "it requires authentication if instance is NOT federating", %{ + conn: conn + } do + user = insert(:user) + conn = put_req_header(conn, "accept", "application/activity+json") + + ensure_federating_or_authenticated(conn, "/users/#{user.nickname}/outbox", user) + end end describe "POST /users/:nickname/outbox" do - test "it rejects posts from other users", %{conn: conn} do + test "it rejects posts from other users / unauuthenticated users", %{conn: conn} do data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!() user = insert(:user) - otheruser = insert(:user) + other_user = insert(:user) + conn = put_req_header(conn, "content-type", "application/activity+json") - conn = - conn - |> assign(:user, otheruser) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", data) + conn + |> post("/users/#{user.nickname}/outbox", data) + |> json_response(403) - assert json_response(conn, 403) + conn + |> assign(:user, other_user) + |> post("/users/#{user.nickname}/outbox", data) + |> json_response(403) end test "it inserts an incoming create activity into the database", %{conn: conn} do @@ -743,24 +858,42 @@ test "it returns relay followers", %{conn: conn} do result = conn - |> assign(:relay, true) |> get("/relay/followers") |> json_response(200) assert result["first"]["orderedItems"] == [user.ap_id] end + + test "on non-federating instance, it returns 404", %{conn: conn} do + Config.put([:instance, :federating], false) + user = insert(:user) + + conn + |> assign(:user, user) + |> get("/relay/followers") + |> json_response(404) + end end describe "/relay/following" do test "it returns relay following", %{conn: conn} do result = conn - |> assign(:relay, true) |> get("/relay/following") |> json_response(200) assert result["first"]["orderedItems"] == [] end + + test "on non-federating instance, it returns 404", %{conn: conn} do + Config.put([:instance, :federating], false) + user = insert(:user) + + conn + |> assign(:user, user) + |> get("/relay/following") + |> json_response(404) + end end describe "/users/:nickname/followers" do @@ -771,6 +904,7 @@ test "it returns the followers in a collection", %{conn: conn} do result = conn + |> assign(:user, user_two) |> get("/users/#{user_two.nickname}/followers") |> json_response(200) @@ -784,19 +918,22 @@ test "it returns a uri if the user has 'hide_followers' set", %{conn: conn} do result = conn + |> assign(:user, user) |> get("/users/#{user_two.nickname}/followers") |> json_response(200) assert is_binary(result["first"]) end - test "it returns a 403 error on pages, if the user has 'hide_followers' set and the request is not authenticated", + test "it returns a 403 error on pages, if the user has 'hide_followers' set and the request is from another user", %{conn: conn} do - user = insert(:user, hide_followers: true) + user = insert(:user) + other_user = insert(:user, hide_followers: true) result = conn - |> get("/users/#{user.nickname}/followers?page=1") + |> assign(:user, user) + |> get("/users/#{other_user.nickname}/followers?page=1") assert result.status == 403 assert result.resp_body == "" @@ -828,6 +965,7 @@ test "it works for more than 10 users", %{conn: conn} do result = conn + |> assign(:user, user) |> get("/users/#{user.nickname}/followers") |> json_response(200) @@ -837,12 +975,21 @@ test "it works for more than 10 users", %{conn: conn} do result = conn + |> assign(:user, user) |> get("/users/#{user.nickname}/followers?page=2") |> json_response(200) assert length(result["orderedItems"]) == 5 assert result["totalItems"] == 15 end + + test "returns 403 if requester is not logged in", %{conn: conn} do + user = insert(:user) + + conn + |> get("/users/#{user.nickname}/followers") + |> json_response(403) + end end describe "/users/:nickname/following" do @@ -853,6 +1000,7 @@ test "it returns the following in a collection", %{conn: conn} do result = conn + |> assign(:user, user) |> get("/users/#{user.nickname}/following") |> json_response(200) @@ -860,25 +1008,28 @@ test "it returns the following in a collection", %{conn: conn} do end test "it returns a uri if the user has 'hide_follows' set", %{conn: conn} do - user = insert(:user, hide_follows: true) - user_two = insert(:user) + user = insert(:user) + user_two = insert(:user, hide_follows: true) User.follow(user, user_two) result = conn - |> get("/users/#{user.nickname}/following") + |> assign(:user, user) + |> get("/users/#{user_two.nickname}/following") |> json_response(200) assert is_binary(result["first"]) end - test "it returns a 403 error on pages, if the user has 'hide_follows' set and the request is not authenticated", + test "it returns a 403 error on pages, if the user has 'hide_follows' set and the request is from another user", %{conn: conn} do - user = insert(:user, hide_follows: true) + user = insert(:user) + user_two = insert(:user, hide_follows: true) result = conn - |> get("/users/#{user.nickname}/following?page=1") + |> assign(:user, user) + |> get("/users/#{user_two.nickname}/following?page=1") assert result.status == 403 assert result.resp_body == "" @@ -911,6 +1062,7 @@ test "it works for more than 10 users", %{conn: conn} do result = conn + |> assign(:user, user) |> get("/users/#{user.nickname}/following") |> json_response(200) @@ -920,12 +1072,21 @@ test "it works for more than 10 users", %{conn: conn} do result = conn + |> assign(:user, user) |> get("/users/#{user.nickname}/following?page=2") |> json_response(200) assert length(result["orderedItems"]) == 5 assert result["totalItems"] == 15 end + + test "returns 403 if requester is not logged in", %{conn: conn} do + user = insert(:user) + + conn + |> get("/users/#{user.nickname}/following") + |> json_response(403) + end end describe "delivery tracking" do @@ -1011,7 +1172,7 @@ test "it tracks a signed activity fetch when the json is cached", %{conn: conn} end describe "Additional ActivityPub C2S endpoints" do - test "/api/ap/whoami", %{conn: conn} do + test "GET /api/ap/whoami", %{conn: conn} do user = insert(:user) conn = @@ -1022,12 +1183,16 @@ test "/api/ap/whoami", %{conn: conn} do user = User.get_cached_by_id(user.id) assert UserView.render("user.json", %{user: user}) == json_response(conn, 200) + + conn + |> get("/api/ap/whoami") + |> json_response(403) end clear_config([:media_proxy]) clear_config([Pleroma.Upload]) - test "uploadMedia", %{conn: conn} do + test "POST /api/ap/upload_media", %{conn: conn} do user = insert(:user) desc = "Description of the image" @@ -1047,67 +1212,10 @@ test "uploadMedia", %{conn: conn} do assert object["name"] == desc assert object["type"] == "Document" assert object["actor"] == user.ap_id - end - end - - describe "when instance is not federating," do - clear_config([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], false) - end - - test "returns 404 for GET routes", %{conn: conn} do - user = insert(:user) - conn = put_req_header(conn, "accept", "application/json") - - get_uris = [ - "/users/#{user.nickname}", - "/internal/fetch", - "/relay", - "/relay/following", - "/relay/followers" - ] - - for get_uri <- get_uris do - conn - |> get(get_uri) - |> json_response(404) - conn - |> assign(:user, user) - |> get(get_uri) - |> json_response(404) - end - end - - test "returns 404 for activity-related POST routes", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:valid_signature, true) - |> put_req_header("content-type", "application/activity+json") - - post_activity_data = - "test/fixtures/mastodon-post-activity.json" - |> File.read!() - |> Poison.decode!() - - post_activity_uris = [ - "/inbox", - "/relay/inbox", - "/users/#{user.nickname}/inbox" - ] - - for post_activity_uri <- post_activity_uris do - conn - |> post(post_activity_uri, post_activity_data) - |> json_response(404) - - conn - |> assign(:user, user) - |> post(post_activity_uri, post_activity_data) - |> json_response(404) - end + conn + |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) + |> json_response(403) end end end diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index 00712ab5a..00c50f003 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -12,7 +12,7 @@ defmodule Pleroma.Web.Feed.UserControllerTest do alias Pleroma.Object alias Pleroma.User - clear_config_all([:instance, :federating]) do + clear_config([:instance, :federating]) do Config.put([:instance, :federating], true) end @@ -82,160 +82,9 @@ test "returns 404 for a missing feed", %{conn: conn} do end end + # Note: see ActivityPubControllerTest for JSON format tests describe "feed_redirect" do - test "undefined format. it redirects to feed", %{conn: conn} do - note_activity = insert(:note_activity) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - response = - conn - |> put_req_header("accept", "application/xml") - |> get("/users/#{user.nickname}") - |> response(302) - - assert response == - "You are being redirected." - end - - test "undefined format. it returns error when user not found", %{conn: conn} do - response = - conn - |> put_req_header("accept", "application/xml") - |> get(user_feed_path(conn, :feed, "jimm")) - |> response(404) - - assert response == ~S({"error":"Not found"}) - end - - test "activity+json format. it redirects on actual feed of user", %{conn: conn} do - note_activity = insert(:note_activity) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - response = - conn - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}") - |> json_response(200) - - assert response["endpoints"] == %{ - "oauthAuthorizationEndpoint" => "#{Pleroma.Web.base_url()}/oauth/authorize", - "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps", - "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token", - "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox", - "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media" - } - - assert response["@context"] == [ - "https://www.w3.org/ns/activitystreams", - "http://localhost:4001/schemas/litepub-0.1.jsonld", - %{"@language" => "und"} - ] - - assert Map.take(response, [ - "followers", - "following", - "id", - "inbox", - "manuallyApprovesFollowers", - "name", - "outbox", - "preferredUsername", - "summary", - "tag", - "type", - "url" - ]) == %{ - "followers" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/followers", - "following" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/following", - "id" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}", - "inbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/inbox", - "manuallyApprovesFollowers" => false, - "name" => user.name, - "outbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/outbox", - "preferredUsername" => user.nickname, - "summary" => user.bio, - "tag" => [], - "type" => "Person", - "url" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}" - } - end - - test "activity+json format. it returns error whe use not found", %{conn: conn} do - response = - conn - |> put_req_header("accept", "application/activity+json") - |> get("/users/jimm") - |> json_response(404) - - assert response == "Not found" - end - - test "json format. it redirects on actual feed of user", %{conn: conn} do - note_activity = insert(:note_activity) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - response = - conn - |> put_req_header("accept", "application/json") - |> get("/users/#{user.nickname}") - |> json_response(200) - - assert response["endpoints"] == %{ - "oauthAuthorizationEndpoint" => "#{Pleroma.Web.base_url()}/oauth/authorize", - "oauthRegistrationEndpoint" => "#{Pleroma.Web.base_url()}/api/v1/apps", - "oauthTokenEndpoint" => "#{Pleroma.Web.base_url()}/oauth/token", - "sharedInbox" => "#{Pleroma.Web.base_url()}/inbox", - "uploadMedia" => "#{Pleroma.Web.base_url()}/api/ap/upload_media" - } - - assert response["@context"] == [ - "https://www.w3.org/ns/activitystreams", - "http://localhost:4001/schemas/litepub-0.1.jsonld", - %{"@language" => "und"} - ] - - assert Map.take(response, [ - "followers", - "following", - "id", - "inbox", - "manuallyApprovesFollowers", - "name", - "outbox", - "preferredUsername", - "summary", - "tag", - "type", - "url" - ]) == %{ - "followers" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/followers", - "following" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/following", - "id" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}", - "inbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/inbox", - "manuallyApprovesFollowers" => false, - "name" => user.name, - "outbox" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}/outbox", - "preferredUsername" => user.nickname, - "summary" => user.bio, - "tag" => [], - "type" => "Person", - "url" => "#{Pleroma.Web.base_url()}/users/#{user.nickname}" - } - end - - test "json format. it returns error whe use not found", %{conn: conn} do - response = - conn - |> put_req_header("accept", "application/json") - |> get("/users/jimm") - |> json_response(404) - - assert response == "Not found" - end - - test "html format. it redirects on actual feed of user", %{conn: conn} do + test "with html format, it redirects to user feed", %{conn: conn} do note_activity = insert(:note_activity) user = User.get_cached_by_ap_id(note_activity.data["actor"]) @@ -251,7 +100,7 @@ test "html format. it redirects on actual feed of user", %{conn: conn} do ).resp_body end - test "html format. it returns error when user not found", %{conn: conn} do + test "with html format, it returns error when user is not found", %{conn: conn} do response = conn |> get("/users/jimm") @@ -259,30 +108,30 @@ test "html format. it returns error when user not found", %{conn: conn} do assert response == %{"error" => "Not found"} end - end - describe "feed_redirect (depending on federation enabled state)" do - setup %{conn: conn} do - user = insert(:user) - conn = put_req_header(conn, "accept", "application/json") - - %{conn: conn, user: user} - end - - clear_config([:instance, :federating]) + test "with non-html / non-json format, it redirects to user feed in atom format", %{ + conn: conn + } do + note_activity = insert(:note_activity) + user = User.get_cached_by_ap_id(note_activity.data["actor"]) - test "renders if instance is federating", %{conn: conn, user: user} do - Config.put([:instance, :federating], true) + conn = + conn + |> put_req_header("accept", "application/xml") + |> get("/users/#{user.nickname}") - conn = get(conn, "/users/#{user.nickname}") - assert json_response(conn, 200) + assert conn.status == 302 + assert redirected_to(conn) == "#{Pleroma.Web.base_url()}/users/#{user.nickname}/feed.atom" end - test "renders 404 if instance is NOT federating", %{conn: conn, user: user} do - Config.put([:instance, :federating], false) + test "with non-html / non-json format, it returns error when user is not found", %{conn: conn} do + response = + conn + |> put_req_header("accept", "application/xml") + |> get(user_feed_path(conn, :feed, "jimm")) + |> response(404) - conn = get(conn, "/users/#{user.nickname}") - assert json_response(conn, 404) + assert response == ~S({"error":"Not found"}) end end end diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index f035dfeee..7ac7e4af1 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -52,9 +52,8 @@ test "redirects on valid url when filename invalidated", %{conn: conn} do url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") invalid_url = String.replace(url, "test.png", "test-file.png") response = get(conn, invalid_url) - html = "You are being redirected." assert response.status == 302 - assert response.resp_body == html + assert redirected_to(response) == url end test "it performs ReverseProxy.call when signature valid", %{conn: conn} do diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index 725ab1785..3b84358e4 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do import Pleroma.Factory + alias Pleroma.Config alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -16,22 +17,24 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do :ok end - clear_config_all([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], true) + clear_config([:instance, :federating]) do + Config.put([:instance, :federating], true) end - describe "GET object/2" do + # Note: see ActivityPubControllerTest for JSON format tests + describe "GET /objects/:uuid (text/html)" do + setup %{conn: conn} do + conn = put_req_header(conn, "accept", "text/html") + %{conn: conn} + end + test "redirects to /notice/id for html format", %{conn: conn} do note_activity = insert(:note_activity) object = Object.normalize(note_activity) [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, object.data["id"])) url = "/objects/#{uuid}" - conn = - conn - |> put_req_header("accept", "text/html") - |> get(url) - + conn = get(conn, url) assert redirected_to(conn) == "/notice/#{note_activity.id}" end @@ -45,23 +48,25 @@ test "404s on private objects", %{conn: conn} do |> response(404) end - test "404s on nonexisting objects", %{conn: conn} do + test "404s on non-existing objects", %{conn: conn} do conn |> get("/objects/123") |> response(404) end end - describe "GET activity/2" do + # Note: see ActivityPubControllerTest for JSON format tests + describe "GET /activities/:uuid (text/html)" do + setup %{conn: conn} do + conn = put_req_header(conn, "accept", "text/html") + %{conn: conn} + end + test "redirects to /notice/id for html format", %{conn: conn} do note_activity = insert(:note_activity) [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) - conn = - conn - |> put_req_header("accept", "text/html") - |> get("/activities/#{uuid}") - + conn = get(conn, "/activities/#{uuid}") assert redirected_to(conn) == "/notice/#{note_activity.id}" end @@ -79,19 +84,6 @@ test "404s on nonexistent activities", %{conn: conn} do |> get("/activities/123") |> response(404) end - - test "gets an activity in AS2 format", %{conn: conn} do - note_activity = insert(:note_activity) - [_, uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) - url = "/activities/#{uuid}" - - conn = - conn - |> put_req_header("accept", "application/activity+json") - |> get(url) - - assert json_response(conn, 200) - end end describe "GET notice/2" do @@ -170,7 +162,7 @@ test "404s a private notice", %{conn: conn} do assert response(conn, 404) end - test "404s a nonexisting notice", %{conn: conn} do + test "404s a non-existing notice", %{conn: conn} do url = "/notice/123" conn = @@ -179,10 +171,21 @@ test "404s a nonexisting notice", %{conn: conn} do assert response(conn, 404) end + + test "it requires authentication if instance is NOT federating", %{ + conn: conn + } do + user = insert(:user) + note_activity = insert(:note_activity) + + conn = put_req_header(conn, "accept", "text/html") + + ensure_federating_or_authenticated(conn, "/notice/#{note_activity.id}", user) + end end describe "GET /notice/:id/embed_player" do - test "render embed player", %{conn: conn} do + setup do note_activity = insert(:note_activity) object = Pleroma.Object.normalize(note_activity) @@ -204,9 +207,11 @@ test "render embed player", %{conn: conn} do |> Ecto.Changeset.change(data: object_data) |> Pleroma.Repo.update() - conn = - conn - |> get("/notice/#{note_activity.id}/embed_player") + %{note_activity: note_activity} + end + + test "renders embed player", %{conn: conn, note_activity: note_activity} do + conn = get(conn, "/notice/#{note_activity.id}/embed_player") assert Plug.Conn.get_resp_header(conn, "x-frame-options") == ["ALLOW"] @@ -272,38 +277,19 @@ test "404s when attachment isn't audio or video", %{conn: conn} do |> Ecto.Changeset.change(data: object_data) |> Pleroma.Repo.update() - assert conn - |> get("/notice/#{note_activity.id}/embed_player") - |> response(404) - end - end - - describe "when instance is not federating," do - clear_config([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], false) + conn + |> get("/notice/#{note_activity.id}/embed_player") + |> response(404) end - test "returns 404 for GET routes", %{conn: conn} do - conn = put_req_header(conn, "accept", "application/json") - - note_activity = insert(:note_activity, local: true) - [_, activity_uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, note_activity.data["id"])) - - object = Object.normalize(note_activity) - [_, object_uuid] = hd(Regex.scan(~r/.+\/([\w-]+)$/, object.data["id"])) - - get_uris = [ - "/activities/#{activity_uuid}", - "/objects/#{object_uuid}", - "/notice/#{note_activity.id}", - "/notice/#{note_activity.id}/embed_player" - ] + test "it requires authentication if instance is NOT federating", %{ + conn: conn, + note_activity: note_activity + } do + user = insert(:user) + conn = put_req_header(conn, "accept", "text/html") - for get_uri <- get_uris do - conn - |> get(get_uri) - |> json_response(404) - end + ensure_federating_or_authenticated(conn, "/notice/#{note_activity.id}/embed_player", user) end end end -- cgit v1.2.3 From d9134d4430d4592030af12354aa2231e7c260140 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 10 Mar 2020 11:49:02 +0100 Subject: installation/otp_en.md: Fix pleroma.nginx target [deb] Needs to be backported to stable. Related: https://git.pleroma.social/pleroma/pleroma-support/issues/29 --- docs/installation/otp_en.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 32551f7b6..fb99af699 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -156,8 +156,8 @@ cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/conf.d/pleroma.conf ``` ```sh tab="Debian/Ubuntu" -cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx -ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx +cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.conf +ln -s /etc/nginx/sites-available/pleroma.conf /etc/nginx/sites-enabled/pleroma.conf ``` If your distro does not have either of those you can append `include /etc/nginx/pleroma.conf` to the end of the http section in /etc/nginx/nginx.conf and -- cgit v1.2.3 From 426f5ee48a09dbf321c013db08cc849c8929d86d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 10 Mar 2020 15:31:44 +0300 Subject: tesla adapter can't be changed in adminFE --- lib/pleroma/config/transfer_task.ex | 58 ++++++++++++------------ test/web/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] - - 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)) + # TODO: some problem with prometheus after restart! + reject = [nil, :prometheus] - # 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 -- cgit v1.2.3 From f39e1b9eff859c0795911212c59304f68fca92bc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 10 Mar 2020 15:54:11 +0300 Subject: 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 -- cgit v1.2.3 From 5af798f24659a1558149cf6deddfa55fbc493ac2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 10 Mar 2020 13:08:00 -0500 Subject: Fix enforcement of character limits --- lib/pleroma/web/common_api/utils.ex | 2 +- test/web/common_api/common_api_test.exs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 8746273c4..348fdedf1 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -591,7 +591,7 @@ def validate_character_limit(full_payload, _attachments) do limit = Pleroma.Config.get([:instance, :limit]) length = String.length(full_payload) - if length < limit do + if length <= limit do :ok else {:error, dgettext("errors", "The status is over the character limit")} diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 299d968db..b80523160 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -202,13 +202,15 @@ test "it returns error when status is empty and no attachments" do CommonAPI.post(user, %{"status" => ""}) end - test "it returns error when character limit is exceeded" do + test "it validates character limits are correctly enforced" do Pleroma.Config.put([:instance, :limit], 5) user = insert(:user) assert {:error, "The status is over the character limit"} = CommonAPI.post(user, %{"status" => "foobar"}) + + assert {:ok, activity} = CommonAPI.post(user, %{"status" => "12345"}) end test "it can handle activities that expire" do -- cgit v1.2.3 From a747eb6df9c554c9b17de03f2c1332f6fb9ba164 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 11 Mar 2020 06:35:18 +0100 Subject: static-fe.css: Restore from before a65ee8ea Related: https://git.pleroma.social/pleroma/pleroma/issues/1616 --- priv/static/static/static-fe.css | Bin 0 -> 2629 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 priv/static/static/static-fe.css diff --git a/priv/static/static/static-fe.css b/priv/static/static/static-fe.css new file mode 100644 index 000000000..19c56387b Binary files /dev/null and b/priv/static/static/static-fe.css differ -- cgit v1.2.3 From a06104b9d59f93d3275dff30854543c2e705b4fe Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 11 Mar 2020 08:35:46 +0100 Subject: CLI_tasks/user.md: Fix `pleroma.user new` documentation Closes: https://git.pleroma.social/pleroma/pleroma/issues/1621 [ci skip] --- docs/administration/CLI_tasks/user.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index da8363131..64385ad28 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -5,11 +5,11 @@ ## Create a user ```sh tab="OTP" -./bin/pleroma_ctl user new [] +./bin/pleroma_ctl user new [] ``` ```sh tab="From Source" -mix pleroma.user new [] +mix pleroma.user new [] ``` -- cgit v1.2.3 From 6316726a5fe9d65ee865d281efc2d2652001eb8c Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 11 Mar 2020 08:46:57 +0100 Subject: CLI_tasks: Use manpage formatting conventions - [] for options - <> for mandatory arguments - foo ... when foo can be repeated [ci skip] --- docs/administration/CLI_tasks/database.md | 8 ++++---- docs/administration/CLI_tasks/digest.md | 4 ++-- docs/administration/CLI_tasks/emoji.md | 8 ++++---- docs/administration/CLI_tasks/instance.md | 4 ++-- docs/administration/CLI_tasks/uploads.md | 4 ++-- docs/administration/CLI_tasks/user.md | 12 ++++++------ 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/administration/CLI_tasks/database.md b/docs/administration/CLI_tasks/database.md index 51c7484ba..ff400c8ed 100644 --- a/docs/administration/CLI_tasks/database.md +++ b/docs/administration/CLI_tasks/database.md @@ -10,11 +10,11 @@ Replaces embedded objects with references to them in the `objects` table. Only needs to be ran once if the instance was created before Pleroma 1.0.5. The reason why this is not a migration is because it could significantly increase the database size after being ran, however after this `VACUUM FULL` will be able to reclaim about 20% (really depends on what is in the database, your mileage may vary) of the db size before the migration. ```sh tab="OTP" -./bin/pleroma_ctl database remove_embedded_objects [] +./bin/pleroma_ctl database remove_embedded_objects [option ...] ``` ```sh tab="From Source" -mix pleroma.database remove_embedded_objects [] +mix pleroma.database remove_embedded_objects [option ...] ``` ### Options @@ -28,11 +28,11 @@ This will prune remote posts older than 90 days (configurable with [`config :ple The disk space will only be reclaimed after `VACUUM FULL`. You may run out of disk space during the execution of the task or vacuuming if you don't have about 1/3rds of the database size free. ```sh tab="OTP" -./bin/pleroma_ctl database prune_objects [] +./bin/pleroma_ctl database prune_objects [option ...] ``` ```sh tab="From Source" -mix pleroma.database prune_objects [] +mix pleroma.database prune_objects [option ...] ``` ### Options diff --git a/docs/administration/CLI_tasks/digest.md b/docs/administration/CLI_tasks/digest.md index 1badda8c3..2eb31379e 100644 --- a/docs/administration/CLI_tasks/digest.md +++ b/docs/administration/CLI_tasks/digest.md @@ -5,11 +5,11 @@ ## Send digest email since given date (user registration date by default) ignoring user activity status. ```sh tab="OTP" - ./bin/pleroma_ctl digest test [] + ./bin/pleroma_ctl digest test [since_date] ``` ```sh tab="From Source" -mix pleroma.digest test [] +mix pleroma.digest test [since_date] ``` diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index a3207bc6c..efec8222c 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -5,11 +5,11 @@ ## Lists emoji packs and metadata specified in the manifest ```sh tab="OTP" -./bin/pleroma_ctl emoji ls-packs [] +./bin/pleroma_ctl emoji ls-packs [option ...] ``` ```sh tab="From Source" -mix pleroma.emoji ls-packs [] +mix pleroma.emoji ls-packs [option ...] ``` @@ -19,11 +19,11 @@ mix pleroma.emoji ls-packs [] ## Fetch, verify and install the specified packs from the manifest into `STATIC-DIR/emoji/PACK-NAME` ```sh tab="OTP" -./bin/pleroma_ctl emoji get-packs [] +./bin/pleroma_ctl emoji get-packs [option ...] ``` ```sh tab="From Source" -mix pleroma.emoji get-packs [] +mix pleroma.emoji get-packs [option ...] ``` ### Options diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md index 1a3b268be..52e264bb1 100644 --- a/docs/administration/CLI_tasks/instance.md +++ b/docs/administration/CLI_tasks/instance.md @@ -4,11 +4,11 @@ ## Generate a new configuration file ```sh tab="OTP" - ./bin/pleroma_ctl instance gen [] + ./bin/pleroma_ctl instance gen [option ...] ``` ```sh tab="From Source" -mix pleroma.instance gen [] +mix pleroma.instance gen [option ...] ``` diff --git a/docs/administration/CLI_tasks/uploads.md b/docs/administration/CLI_tasks/uploads.md index e36c94c38..6a15d22f6 100644 --- a/docs/administration/CLI_tasks/uploads.md +++ b/docs/administration/CLI_tasks/uploads.md @@ -4,11 +4,11 @@ ## Migrate uploads from local to remote storage ```sh tab="OTP" - ./bin/pleroma_ctl uploads migrate_local [] + ./bin/pleroma_ctl uploads migrate_local [option ...] ``` ```sh tab="From Source" -mix pleroma.uploads migrate_local [] +mix pleroma.uploads migrate_local [option ...] ``` ### Options diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index 64385ad28..f535dad82 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -5,11 +5,11 @@ ## Create a user ```sh tab="OTP" -./bin/pleroma_ctl user new [] +./bin/pleroma_ctl user new [option ...] ``` ```sh tab="From Source" -mix pleroma.user new [] +mix pleroma.user new [option ...] ``` @@ -33,11 +33,11 @@ mix pleroma.user list ## Generate an invite link ```sh tab="OTP" - ./bin/pleroma_ctl user invite [] + ./bin/pleroma_ctl user invite [option ...] ``` ```sh tab="From Source" -mix pleroma.user invite [] +mix pleroma.user invite [option ...] ``` @@ -137,11 +137,11 @@ mix pleroma.user reset_password ## Set the value of the given user's settings ```sh tab="OTP" - ./bin/pleroma_ctl user set [] + ./bin/pleroma_ctl user set [option ...] ``` ```sh tab="From Source" -mix pleroma.user set [] +mix pleroma.user set [option ...] ``` ### Options -- cgit v1.2.3 From 5b696a8ac1b5a06e60c2143cf88e014b28e14702 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 11 Mar 2020 14:05:56 +0300 Subject: [#1560] Enforced authentication for non-federating instances in StaticFEController. --- lib/pleroma/web/static_fe/static_fe_controller.ex | 20 ++++++++++++-------- test/support/conn_case.ex | 9 +++++++-- test/web/static_fe/static_fe_controller_test.exs | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 5ac75f1c4..5027d5c23 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -17,6 +17,10 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do plug(:put_view, Pleroma.Web.StaticFE.StaticFEView) plug(:assign_id) + plug(Pleroma.Plugs.EnsureAuthenticatedPlug, + unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + ) + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] defp get_title(%Object{data: %{"name" => name}}) when is_binary(name), @@ -33,7 +37,7 @@ defp not_found(conn, message) do |> render("error.html", %{message: message, meta: ""}) end - def get_counts(%Activity{} = activity) do + defp get_counts(%Activity{} = activity) do %Object{data: data} = Object.normalize(activity) %{ @@ -43,9 +47,9 @@ def get_counts(%Activity{} = activity) do } end - def represent(%Activity{} = activity), do: represent(activity, false) + defp represent(%Activity{} = activity), do: represent(activity, false) - def represent(%Activity{object: %Object{data: data}} = activity, selected) do + defp represent(%Activity{object: %Object{data: data}} = activity, selected) do {:ok, user} = User.get_or_fetch(activity.object.data["actor"]) link = @@ -147,17 +151,17 @@ def show(%{assigns: %{activity_id: _}} = conn, _params) do end end - def assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), + defp assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), do: assign(conn, :notice_id, notice_id) - def assign_id(%{path_info: ["users", user_id]} = conn, _opts), + defp assign_id(%{path_info: ["users", user_id]} = conn, _opts), do: assign(conn, :username_or_id, user_id) - def assign_id(%{path_info: ["objects", object_id]} = conn, _opts), + defp assign_id(%{path_info: ["objects", object_id]} = conn, _opts), do: assign(conn, :object_id, object_id) - def assign_id(%{path_info: ["activities", activity_id]} = conn, _opts), + defp assign_id(%{path_info: ["activities", activity_id]} = conn, _opts), do: assign(conn, :activity_id, activity_id) - def assign_id(conn, _opts), do: conn + defp assign_id(conn, _opts), do: conn end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index d6595f971..064874201 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -26,6 +26,8 @@ defmodule Pleroma.Web.ConnCase do use Pleroma.Tests.Helpers import Pleroma.Web.Router.Helpers + alias Pleroma.Config + # The default endpoint for testing @endpoint Pleroma.Web.Endpoint @@ -50,7 +52,10 @@ defp oauth_access(scopes, opts \\ []) do end defp ensure_federating_or_authenticated(conn, url, user) do - Pleroma.Config.put([:instance, :federating], false) + initial_setting = Config.get([:instance, :federating]) + on_exit(fn -> Config.put([:instance, :federating], initial_setting) end) + + Config.put([:instance, :federating], false) conn |> get(url) @@ -61,7 +66,7 @@ defp ensure_federating_or_authenticated(conn, url, user) do |> get(url) |> response(200) - Pleroma.Config.put([:instance, :federating], true) + Config.put([:instance, :federating], true) conn |> get(url) diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index 11facab99..a072cc78f 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -12,6 +12,10 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do Config.put([:static_fe, :enabled], true) end + clear_config([:instance, :federating]) do + Config.put([:instance, :federating], true) + end + setup %{conn: conn} do conn = put_req_header(conn, "accept", "text/html") user = insert(:user) @@ -70,6 +74,10 @@ test "pagination, page 2", %{conn: conn, user: user} do refute html =~ ">test20<" refute html =~ ">test29<" end + + test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do + ensure_federating_or_authenticated(conn, "/users/#{user.nickname}", user) + end end describe "notice html" do @@ -153,5 +161,11 @@ test "302 for remote cached status", %{conn: conn, user: user} do assert html_response(conn, 302) =~ "redirected" end + + test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do + {:ok, activity} = CommonAPI.post(user, %{"status" => "testing a thing!"}) + + ensure_federating_or_authenticated(conn, "/notice/#{activity.id}", user) + end end end -- cgit v1.2.3 From 863ec33ba2a90708d199f18683ffe0c4658c710a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 11 Mar 2020 12:21:44 +0100 Subject: 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 --- lib/pleroma/web/activity_pub/transmogrifier.ex | 5 +-- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- test/fixtures/tesla_mock/funkwhale_audio.json | 44 +++++++++++++++++++++++ test/fixtures/tesla_mock/funkwhale_channel.json | 44 +++++++++++++++++++++++ test/support/http_request_mock.ex | 15 ++++++++ test/web/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(), -- cgit v1.2.3 From 282a93554fbf919ff553d839eeea98abe1f861d4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 11 Mar 2020 16:25:53 +0300 Subject: merging release default config on app start --- lib/mix/tasks/pleroma/docs.ex | 2 +- lib/pleroma/application.ex | 1 + lib/pleroma/config/holder.ex | 38 ++++++++++++++++++----- lib/pleroma/config/loader.ex | 32 +++++++------------ lib/pleroma/config/transfer_task.ex | 2 +- lib/pleroma/docs/json.ex | 2 +- lib/pleroma/web/admin_api/admin_api_controller.ex | 2 +- test/config/holder_test.exs | 16 +++++----- test/config/loader_test.exs | 19 ++---------- test/config/transfer_task_test.exs | 2 +- 10 files changed, 58 insertions(+), 58 deletions(-) diff --git a/lib/mix/tasks/pleroma/docs.ex b/lib/mix/tasks/pleroma/docs.ex index 3c870f876..6088fc71d 100644 --- a/lib/mix/tasks/pleroma/docs.ex +++ b/lib/mix/tasks/pleroma/docs.ex @@ -28,7 +28,7 @@ def run(_) do defp do_run(implementation) do start_pleroma() - with descriptions <- Pleroma.Config.Loader.load("config/description.exs"), + with descriptions <- Pleroma.Config.Loader.read("config/description.exs"), {:ok, file_path} <- Pleroma.Docs.Generator.process( implementation, diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 18854b850..c5b9a98fd 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -31,6 +31,7 @@ def user_agent do # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do + Pleroma.Config.Holder.to_ets() Pleroma.HTML.compile_scrubbers() Pleroma.Config.DeprecationWarnings.warn() Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() diff --git a/lib/pleroma/config/holder.ex b/lib/pleroma/config/holder.ex index f1a339703..88e1db313 100644 --- a/lib/pleroma/config/holder.ex +++ b/lib/pleroma/config/holder.ex @@ -3,14 +3,38 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.Holder do - @config Pleroma.Config.Loader.load_and_merge() + @config Pleroma.Config.Loader.default_config() - @spec config() :: keyword() - def config, do: @config + @spec to_ets() :: true + def to_ets do + :ets.new(:default_config, [:named_table, :protected]) - @spec config(atom()) :: any() - def config(group), do: @config[group] + default_config = + if System.get_env("RELEASE_NAME") do + release_config = + [:code.root_dir(), "releases", System.get_env("RELEASE_VSN"), "releases.exs"] + |> Path.join() + |> Pleroma.Config.Loader.read() - @spec config(atom(), atom()) :: any() - def config(group, key), do: @config[group][key] + Pleroma.Config.Loader.merge(@config, release_config) + else + @config + end + + :ets.insert(:default_config, {:config, default_config}) + end + + @spec default_config() :: keyword() + def default_config, do: from_ets() + + @spec default_config(atom()) :: keyword() + def default_config(group), do: Keyword.get(from_ets(), group) + + @spec default_config(atom(), atom()) :: keyword() + def default_config(group, key), do: get_in(from_ets(), [group, key]) + + defp from_ets do + [{:config, default_config}] = :ets.lookup(:default_config, :config) + default_config + end end diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index df2d18725..b2cb34129 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -13,32 +13,22 @@ defmodule Pleroma.Config.Loader do ] if Code.ensure_loaded?(Config.Reader) do - @spec load(Path.t()) :: keyword() - def load(path), do: Config.Reader.read!(path) - - defp do_merge(conf1, conf2), do: Config.Reader.merge(conf1, conf2) + @reader Config.Reader else # support for Elixir less than 1.9 - @spec load(Path.t()) :: keyword() - def load(path) do - path - |> Mix.Config.eval!() - |> elem(0) - end - - defp do_merge(conf1, conf2), do: Mix.Config.merge(conf1, conf2) + @reader Mix.Config end - @spec load_and_merge() :: keyword() - def load_and_merge do - all_paths = - if Pleroma.Config.get(:release), - do: ["config/config.exs", "config/releases.exs"], - else: ["config/config.exs"] + @spec read(Path.t()) :: keyword() + def read(path), do: @reader.read!(path) + + @spec merge(keyword(), keyword()) :: keyword() + def merge(c1, c2), do: @reader.merge(c1, c2) - all_paths - |> Enum.map(&load(&1)) - |> Enum.reduce([], &do_merge(&2, &1)) + @spec default_config() :: keyword() + def default_config do + "config/config.exs" + |> read() |> filter() end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 435fc7450..7c3449b5e 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -83,7 +83,7 @@ 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 = Pleroma.Config.Holder.default_config(group, key) value = ConfigDB.from_binary(setting.value) merged_value = diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index 6508a7bdb..74f8b2615 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -15,7 +15,7 @@ def process(descriptions) do end def compile do - with config <- Pleroma.Config.Loader.load("config/description.exs") do + with config <- Pleroma.Config.Loader.read("config/description.exs") do config[:pleroma][:config_description] |> Pleroma.Docs.Generator.convert_to_strings() |> Jason.encode!() diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index de0755ee5..47b7d2da3 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -834,7 +834,7 @@ def config_show(conn, _params) do configs = ConfigDB.get_all_as_keyword() merged = - Config.Holder.config() + Config.Holder.default_config() |> ConfigDB.merge(configs) |> Enum.map(fn {group, values} -> Enum.map(values, fn {key, value} -> diff --git a/test/config/holder_test.exs b/test/config/holder_test.exs index 2368d4856..15d48b5c7 100644 --- a/test/config/holder_test.exs +++ b/test/config/holder_test.exs @@ -7,8 +7,8 @@ defmodule Pleroma.Config.HolderTest do alias Pleroma.Config.Holder - test "config/0" do - config = Holder.config() + test "default_config/0" do + config = Holder.default_config() assert config[:pleroma][Pleroma.Uploaders.Local][:uploads] == "test/uploads" assert config[:tesla][:adapter] == Tesla.Mock @@ -20,15 +20,15 @@ test "config/0" do refute config[:phoenix][:serve_endpoints] end - test "config/1" do - pleroma_config = Holder.config(:pleroma) + test "default_config/1" do + pleroma_config = Holder.default_config(:pleroma) assert pleroma_config[Pleroma.Uploaders.Local][:uploads] == "test/uploads" - tesla_config = Holder.config(:tesla) + tesla_config = Holder.default_config(:tesla) assert tesla_config[:adapter] == Tesla.Mock end - test "config/2" do - assert Holder.config(:pleroma, Pleroma.Uploaders.Local) == [uploads: "test/uploads"] - assert Holder.config(:tesla, :adapter) == Tesla.Mock + test "default_config/2" do + assert Holder.default_config(:pleroma, Pleroma.Uploaders.Local) == [uploads: "test/uploads"] + assert Holder.default_config(:tesla, :adapter) == Tesla.Mock end end diff --git a/test/config/loader_test.exs b/test/config/loader_test.exs index 4c93e5d4d..607572f4e 100644 --- a/test/config/loader_test.exs +++ b/test/config/loader_test.exs @@ -7,28 +7,13 @@ defmodule Pleroma.Config.LoaderTest do alias Pleroma.Config.Loader - test "load/1" do - config = Loader.load("test/fixtures/config/temp.secret.exs") + test "read/1" do + config = Loader.read("test/fixtures/config/temp.secret.exs") assert config[:pleroma][:first_setting][:key] == "value" assert config[:pleroma][:first_setting][:key2] == [Pleroma.Repo] assert config[:quack][:level] == :info end - test "load_and_merge/0" do - config = Loader.load_and_merge() - - refute config[:pleroma][Pleroma.Repo] - refute config[:pleroma][Pleroma.Web.Endpoint] - refute config[:pleroma][:env] - refute config[:pleroma][:configurable_from_database] - refute config[:pleroma][:database] - refute config[:phoenix][:serve_endpoints] - - assert config[:pleroma][:ecto_repos] == [Pleroma.Repo] - assert config[:pleroma][Pleroma.Uploaders.Local][:uploads] == "test/uploads" - assert config[:tesla][:adapter] == Tesla.Mock - end - test "filter_group/2" do assert Loader.filter_group(:pleroma, pleroma: [ diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index ce31d1e87..01d04761d 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -70,7 +70,7 @@ test "transfer config values for 1 group and some keys" do assert Application.get_env(:quack, :level) == :info assert Application.get_env(:quack, :meta) == [:none] - default = Pleroma.Config.Holder.config(:quack, :webhook_url) + default = Pleroma.Config.Holder.default_config(:quack, :webhook_url) assert Application.get_env(:quack, :webhook_url) == default on_exit(fn -> -- cgit v1.2.3 From 193d67cde590efd9a75ac11da76657151f58afdd Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 11 Mar 2020 16:43:58 +0300 Subject: compile fix --- lib/pleroma/config/loader.ex | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index b2cb34129..6ca6550bd 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -14,13 +14,19 @@ defmodule Pleroma.Config.Loader do if Code.ensure_loaded?(Config.Reader) do @reader Config.Reader + + def read(path), do: @reader.read!(path) else # support for Elixir less than 1.9 @reader Mix.Config + def read(path) do + path + |> @reader.eval!() + |> elem(0) + end end @spec read(Path.t()) :: keyword() - def read(path), do: @reader.read!(path) @spec merge(keyword(), keyword()) :: keyword() def merge(c1, c2), do: @reader.merge(c1, c2) -- cgit v1.2.3 From fce090c1de543f0bcebf47cfc2a32f99f8ef401f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 11 Mar 2020 17:22:50 +0300 Subject: using Pleroma.Config instead of ets --- lib/pleroma/application.ex | 2 +- lib/pleroma/config/holder.ex | 19 +++++++------------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index c5b9a98fd..33f1705df 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -31,7 +31,7 @@ def user_agent do # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - Pleroma.Config.Holder.to_ets() + Pleroma.Config.Holder.save_default() Pleroma.HTML.compile_scrubbers() Pleroma.Config.DeprecationWarnings.warn() Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() diff --git a/lib/pleroma/config/holder.ex b/lib/pleroma/config/holder.ex index 88e1db313..f037d5d48 100644 --- a/lib/pleroma/config/holder.ex +++ b/lib/pleroma/config/holder.ex @@ -5,10 +5,8 @@ defmodule Pleroma.Config.Holder do @config Pleroma.Config.Loader.default_config() - @spec to_ets() :: true - def to_ets do - :ets.new(:default_config, [:named_table, :protected]) - + @spec save_default() :: :ok + def save_default do default_config = if System.get_env("RELEASE_NAME") do release_config = @@ -21,20 +19,17 @@ def to_ets do @config end - :ets.insert(:default_config, {:config, default_config}) + Pleroma.Config.put(:default_config, default_config) end @spec default_config() :: keyword() - def default_config, do: from_ets() + def default_config, do: get_default() @spec default_config(atom()) :: keyword() - def default_config(group), do: Keyword.get(from_ets(), group) + def default_config(group), do: Keyword.get(get_default(), group) @spec default_config(atom(), atom()) :: keyword() - def default_config(group, key), do: get_in(from_ets(), [group, key]) + def default_config(group, key), do: get_in(get_default(), [group, key]) - defp from_ets do - [{:config, default_config}] = :ets.lookup(:default_config, :config) - default_config - end + defp get_default, do: Pleroma.Config.get(:default_config) end -- cgit v1.2.3 From c3b9fbd3a759d281ef2e81395b78549e43cab63c Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 11 Mar 2020 17:58:25 +0300 Subject: Revert "Set better Cache-Control header for static content" On furher investigation it seems like all that did was cause unintuitive behavior. The emoji request flood that was the reason for introducing it isn't really that big of a deal either, since Plug.Static only needs to read file modification time and size to determine the ETag. Closes #1613 --- lib/pleroma/web/endpoint.ex | 2 +- test/plugs/cache_control_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 118c3ac6f..72cb3ee27 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.Endpoint do plug(Pleroma.Plugs.HTTPSecurityPlug) plug(Pleroma.Plugs.UploadedMedia) - @static_cache_control "public max-age=86400 must-revalidate" + @static_cache_control "public, no-cache" # InstanceStatic needs to be before Plug.Static to be able to override shipped-static files # If you're adding new paths to `only:` you'll need to configure them in InstanceStatic as well diff --git a/test/plugs/cache_control_test.exs b/test/plugs/cache_control_test.exs index 005912ffb..6b567e81d 100644 --- a/test/plugs/cache_control_test.exs +++ b/test/plugs/cache_control_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Web.CacheControlTest do test "Verify Cache-Control header on static assets", %{conn: conn} do conn = get(conn, "/index.html") - assert Conn.get_resp_header(conn, "cache-control") == ["public max-age=86400 must-revalidate"] + assert Conn.get_resp_header(conn, "cache-control") == ["public, no-cache"] end test "Verify Cache-Control header on the API", %{conn: conn} do -- cgit v1.2.3 From 1306b92997dc6e76e5d617d529dbc229d5aee200 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 12 Mar 2020 18:28:54 +0300 Subject: 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 | 94 +++++++++++----------------------- test/http/adapter_helper/gun_test.exs | 12 ++--- test/pool/connections_test.exs | 2 +- 10 files changed, 93 insertions(+), 154 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_pools(_), do: [] + defp http_children(Tesla.Adapter.Gun, _), do: [Pleroma.Pool.Supervisor] - 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 + %{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 conn_key when is_binary(conn_key) <- compose_key_gun_info(conn_pid), - {key, conn} <- find_conn(state.conns, conn_pid, conn_key), + with {_key, conn} <- find_conn(state.conns, conn_pid, 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) - 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 -- cgit v1.2.3 From 98ed0d1c4bd2db354154cc4a1d1e6530eb68f499 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 13 Mar 2020 09:37:57 +0300 Subject: 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 | 17 +++++++---------- lib/pleroma/reverse_proxy/client/tesla.ex | 9 +++++---- lib/pleroma/reverse_proxy/reverse_proxy.ex | 2 +- 7 files changed, 22 insertions(+), 23 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() - - Supervisor.init(children, strategy: :one_for_one) + conns_child = %{ + id: Pool.Connections, + start: + {Pool.Connections, :start_link, [{:gun_connections, Config.get([:connections_pool])}]} + } + + 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] -- cgit v1.2.3 From d1379c4de8ca27fa6d02d20a0029b248efe1d09e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 13 Feb 2020 03:39:47 +0100 Subject: Formatting: Do not use \n and prefer
instead It moves bbcode to bbcode_pleroma as the former is owned by kaniini and transfering ownership wasn't done in a timely manner. Closes: https://git.pleroma.social/pleroma/pleroma/issues/1374 Closes: https://git.pleroma.social/pleroma/pleroma/issues/1375 --- CHANGELOG.md | 4 + lib/pleroma/earmark_renderer.ex | 256 ++++++++++++++++++++++++++ lib/pleroma/web/common_api/utils.ex | 2 +- mix.exs | 2 +- mix.lock | 6 +- test/earmark_renderer_test.ex | 79 ++++++++ test/web/common_api/common_api_utils_test.exs | 28 ++- 7 files changed, 357 insertions(+), 20 deletions(-) create mode 100644 lib/pleroma/earmark_renderer.ex create mode 100644 test/earmark_renderer_test.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 100228c6c..4168086e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ 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] +### Changed +- **Breaking:** BBCode and Markdown formatters will no longer return any `\n` and only use `
` for newlines + ## [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/earmark_renderer.ex b/lib/pleroma/earmark_renderer.ex new file mode 100644 index 000000000..6211a3b4a --- /dev/null +++ b/lib/pleroma/earmark_renderer.ex @@ -0,0 +1,256 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +# +# This file is derived from Earmark, under the following copyright: +# Copyright © 2014 Dave Thomas, The Pragmatic Programmers +# SPDX-License-Identifier: Apache-2.0 +# Upstream: https://github.com/pragdave/earmark/blob/master/lib/earmark/html_renderer.ex +defmodule Pleroma.EarmarkRenderer do + @moduledoc false + + alias Earmark.Block + alias Earmark.Context + alias Earmark.HtmlRenderer + alias Earmark.Options + + import Earmark.Inline, only: [convert: 3] + import Earmark.Helpers.HtmlHelpers + import Earmark.Message, only: [add_messages_from: 2, get_messages: 1, set_messages: 2] + import Earmark.Context, only: [append: 2, set_value: 2] + import Earmark.Options, only: [get_mapper: 1] + + @doc false + def render(blocks, %Context{options: %Options{}} = context) do + messages = get_messages(context) + + {contexts, html} = + get_mapper(context.options).( + blocks, + &render_block(&1, put_in(context.options.messages, [])) + ) + |> Enum.unzip() + + all_messages = + contexts + |> Enum.reduce(messages, fn ctx, messages1 -> messages1 ++ get_messages(ctx) end) + + {put_in(context.options.messages, all_messages), html |> IO.iodata_to_binary()} + end + + ############# + # Paragraph # + ############# + defp render_block(%Block.Para{lnb: lnb, lines: lines, attrs: attrs}, context) do + lines = convert(lines, lnb, context) + add_attrs(lines, "

#{lines.value}

", attrs, [], lnb) + end + + ######## + # Html # + ######## + defp render_block(%Block.Html{html: html}, context) do + {context, html} + end + + defp render_block(%Block.HtmlComment{lines: lines}, context) do + {context, lines} + end + + defp render_block(%Block.HtmlOneline{html: html}, context) do + {context, html} + end + + ######### + # Ruler # + ######### + defp render_block(%Block.Ruler{lnb: lnb, attrs: attrs}, context) do + add_attrs(context, "
", attrs, [], lnb) + end + + ########### + # Heading # + ########### + defp render_block( + %Block.Heading{lnb: lnb, level: level, content: content, attrs: attrs}, + context + ) do + converted = convert(content, lnb, context) + html = "#{converted.value}" + add_attrs(converted, html, attrs, [], lnb) + end + + ############## + # Blockquote # + ############## + + defp render_block(%Block.BlockQuote{lnb: lnb, blocks: blocks, attrs: attrs}, context) do + {context1, body} = render(blocks, context) + html = "
#{body}
" + add_attrs(context1, html, attrs, [], lnb) + end + + ######### + # Table # + ######### + + defp render_block( + %Block.Table{lnb: lnb, header: header, rows: rows, alignments: aligns, attrs: attrs}, + context + ) do + {context1, html} = add_attrs(context, "", attrs, [], lnb) + context2 = set_value(context1, html) + + context3 = + if header do + append(add_trs(append(context2, ""), [header], "th", aligns, lnb), "") + else + # Maybe an error, needed append(context, html) + context2 + end + + context4 = append(add_trs(append(context3, ""), rows, "td", aligns, lnb), "") + + {context4, [context4.value, "
"]} + end + + ######## + # Code # + ######## + + defp render_block( + %Block.Code{lnb: lnb, language: language, attrs: attrs} = block, + %Context{options: options} = context + ) do + class = + if language, do: ~s{ class="#{code_classes(language, options.code_class_prefix)}"}, else: "" + + tag = ~s[
]
+    lines = options.render_code.(block)
+    html = ~s[#{tag}#{lines}
] + add_attrs(context, html, attrs, [], lnb) + end + + ######### + # Lists # + ######### + + defp render_block( + %Block.List{lnb: lnb, type: type, blocks: items, attrs: attrs, start: start}, + context + ) do + {context1, content} = render(items, context) + html = "<#{type}#{start}>#{content}" + add_attrs(context1, html, attrs, [], lnb) + end + + # format a single paragraph list item, and remove the para tags + defp render_block( + %Block.ListItem{lnb: lnb, blocks: blocks, spaced: false, attrs: attrs}, + context + ) + when length(blocks) == 1 do + {context1, content} = render(blocks, context) + content = Regex.replace(~r{}, content, "") + html = "
  • #{content}
  • " + add_attrs(context1, html, attrs, [], lnb) + end + + # format a spaced list item + defp render_block(%Block.ListItem{lnb: lnb, blocks: blocks, attrs: attrs}, context) do + {context1, content} = render(blocks, context) + html = "
  • #{content}
  • " + add_attrs(context1, html, attrs, [], lnb) + end + + ################## + # Footnote Block # + ################## + + defp render_block(%Block.FnList{blocks: footnotes}, context) do + items = + Enum.map(footnotes, fn note -> + blocks = append_footnote_link(note) + %Block.ListItem{attrs: "#fn:#{note.number}", type: :ol, blocks: blocks} + end) + + {context1, html} = render_block(%Block.List{type: :ol, blocks: items}, context) + {context1, Enum.join([~s[
    ], "
    ", html, "
    "])} + end + + ####################################### + # Isolated IALs are rendered as paras # + ####################################### + + defp render_block(%Block.Ial{verbatim: verbatim}, context) do + {context, "

    {:#{verbatim}}

    "} + end + + #################### + # IDDef is ignored # + #################### + + defp render_block(%Block.IdDef{}, context), do: {context, ""} + + ##################################### + # And here are the inline renderers # + ##################################### + + defdelegate br, to: HtmlRenderer + defdelegate codespan(text), to: HtmlRenderer + defdelegate em(text), to: HtmlRenderer + defdelegate strong(text), to: HtmlRenderer + defdelegate strikethrough(text), to: HtmlRenderer + + defdelegate link(url, text), to: HtmlRenderer + defdelegate link(url, text, title), to: HtmlRenderer + + defdelegate image(path, alt, title), to: HtmlRenderer + + defdelegate footnote_link(ref, backref, number), to: HtmlRenderer + + # Table rows + defp add_trs(context, rows, tag, aligns, lnb) do + numbered_rows = + rows + |> Enum.zip(Stream.iterate(lnb, &(&1 + 1))) + + numbered_rows + |> Enum.reduce(context, fn {row, lnb}, ctx -> + append(add_tds(append(ctx, ""), row, tag, aligns, lnb), "") + end) + end + + defp add_tds(context, row, tag, aligns, lnb) do + Enum.reduce(1..length(row), context, add_td_fn(row, tag, aligns, lnb)) + end + + defp add_td_fn(row, tag, aligns, lnb) do + fn n, ctx -> + style = + case Enum.at(aligns, n - 1, :default) do + :default -> "" + align -> " style=\"text-align: #{align}\"" + end + + col = Enum.at(row, n - 1) + converted = convert(col, lnb, set_messages(ctx, [])) + append(add_messages_from(ctx, converted), "<#{tag}#{style}>#{converted.value}") + end + end + + ############################### + # Append Footnote Return Link # + ############################### + + defdelegate append_footnote_link(note), to: HtmlRenderer + defdelegate append_footnote_link(note, fnlink), to: HtmlRenderer + + defdelegate render_code(lines), to: HtmlRenderer + + defp code_classes(language, prefix) do + ["" | String.split(prefix || "")] + |> Enum.map(fn pfx -> "#{pfx}#{language}" end) + |> Enum.join(" ") + end +end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 348fdedf1..635e7cd38 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -331,7 +331,7 @@ def format_input(text, "text/html", options) do def format_input(text, "text/markdown", options) do text |> Formatter.mentions_escape(options) - |> Earmark.as_html!() + |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer}) |> Formatter.linkify(options) |> Formatter.html_escape("text/html") end diff --git a/mix.exs b/mix.exs index bb86c38d0..dd598345c 100644 --- a/mix.exs +++ b/mix.exs @@ -126,7 +126,7 @@ defp deps do {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, {:earmark, "~> 1.3"}, - {:bbcode, "~> 0.1.1"}, + {:bbcode_pleroma, "~> 0.2.0"}, {:ex_machina, "~> 2.3", only: :test}, {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.3", only: :test}, diff --git a/mix.lock b/mix.lock index c8b30a6f9..1b4fbc927 100644 --- a/mix.lock +++ b/mix.lock @@ -3,10 +3,11 @@ "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, - "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, + "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, + "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, + "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, "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"]}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, @@ -110,4 +111,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/earmark_renderer_test.ex b/test/earmark_renderer_test.ex new file mode 100644 index 000000000..220d97d16 --- /dev/null +++ b/test/earmark_renderer_test.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.EarmarkRendererTest do + use ExUnit.Case + + test "Paragraph" do + code = ~s[Hello\n\nWorld!] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    Hello

    World!

    " + end + + test "raw HTML" do + code = ~s[OwO] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    #{code}

    " + end + + test "rulers" do + code = ~s[before\n\n-----\n\nafter] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    before


    after

    " + end + + test "headings" do + code = ~s[# h1\n## h2\n### h3\n] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    h1

    h2

    h3

    ] + end + + test "blockquote" do + code = ~s[> whoms't are you quoting?] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    whoms’t are you quoting?

    " + end + + test "code" do + code = ~s[`mix`] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    mix

    ] + + code = ~s[``mix``] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    mix

    ] + + code = ~s[```\nputs "Hello World"\n```] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[
    puts "Hello World"
    ] + end + + test "lists" do + code = ~s[- one\n- two\n- three\n- four] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "
    • one
    • two
    • three
    • four
    " + + code = ~s[1. one\n2. two\n3. three\n4. four\n] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "
    1. one
    2. two
    3. three
    4. four
    " + end + + test "delegated renderers" do + code = ~s[a
    b] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    #{code}

    " + + code = ~s[*aaaa~*] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    aaaa~

    ] + + code = ~s[**aaaa~**] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    aaaa~

    ] + + # strikethrought + code = ~s[aaaa~] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    aaaa~

    ] + end +end diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index b380d10d8..45fc94522 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -89,8 +89,8 @@ test "works for bare text/html" do assert output == expected - text = "

    hello world!

    \n\n

    second paragraph

    " - expected = "

    hello world!

    \n\n

    second paragraph

    " + text = "

    hello world!


    \n

    second paragraph

    " + expected = "

    hello world!


    \n

    second paragraph

    " {output, [], []} = Utils.format_input(text, "text/html") @@ -99,14 +99,14 @@ test "works for bare text/html" do test "works for bare text/markdown" do text = "**hello world**" - expected = "

    hello world

    \n" + expected = "

    hello world

    " {output, [], []} = Utils.format_input(text, "text/markdown") assert output == expected text = "**hello world**\n\n*another paragraph*" - expected = "

    hello world

    \n

    another paragraph

    \n" + expected = "

    hello world

    another paragraph

    " {output, [], []} = Utils.format_input(text, "text/markdown") @@ -118,7 +118,7 @@ test "works for bare text/markdown" do by someone """ - expected = "

    cool quote

    \n
    \n

    by someone

    \n" + expected = "

    cool quote

    by someone

    " {output, [], []} = Utils.format_input(text, "text/markdown") @@ -134,7 +134,7 @@ test "works for bare text/bbcode" do assert output == expected text = "[b]hello world![/b]\n\nsecond paragraph!" - expected = "hello world!
    \n
    \nsecond paragraph!" + expected = "hello world!

    second paragraph!" {output, [], []} = Utils.format_input(text, "text/bbcode") @@ -143,7 +143,7 @@ test "works for bare text/bbcode" do text = "[b]hello world![/b]\n\nsecond paragraph!" expected = - "hello world!
    \n
    \n<strong>second paragraph!</strong>" + "hello world!

    <strong>second paragraph!</strong>" {output, [], []} = Utils.format_input(text, "text/bbcode") @@ -156,16 +156,14 @@ test "works for text/markdown with mentions" do text = "**hello world**\n\n*another @user__test and @user__test google.com paragraph*" - expected = - ~s(

    hello world

    \n

    another @user__test and @user__test google.com paragraph

    \n) - {output, _, _} = Utils.format_input(text, "text/markdown") - assert output == expected + assert output == + ~s(

    hello world

    another @user__test and @user__test google.com paragraph

    ) end end -- cgit v1.2.3 From fffc382f138442035337e55eb930324d13bbdca8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 13 Mar 2020 19:30:42 +0400 Subject: Fix hashtags WebSocket streaming --- lib/pleroma/activity/ir/topics.ex | 2 +- test/activity/ir/topics_test.exs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index 4acc1a3e0..9e65bedad 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -39,7 +39,7 @@ defp visibility_tags(object, activity) do end end - defp item_creation_tags(tags, %{data: %{"type" => "Create"}} = object, activity) do + defp item_creation_tags(tags, object, %{data: %{"type" => "Create"}} = activity) do tags ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) end diff --git a/test/activity/ir/topics_test.exs b/test/activity/ir/topics_test.exs index e75f83586..44aec1e19 100644 --- a/test/activity/ir/topics_test.exs +++ b/test/activity/ir/topics_test.exs @@ -59,8 +59,8 @@ test "non-local action does not produce public:local topic", %{activity: activit describe "public visibility create events" do setup do activity = %Activity{ - object: %Object{data: %{"type" => "Create", "attachment" => []}}, - data: %{"to" => [Pleroma.Constants.as_public()]} + object: %Object{data: %{"attachment" => []}}, + data: %{"type" => "Create", "to" => [Pleroma.Constants.as_public()]} } {:ok, activity: activity} @@ -98,8 +98,8 @@ test "only converts strinngs to hash tags", %{ describe "public visibility create events with attachments" do setup do activity = %Activity{ - object: %Object{data: %{"type" => "Create", "attachment" => ["foo"]}}, - data: %{"to" => [Pleroma.Constants.as_public()]} + object: %Object{data: %{"attachment" => ["foo"]}}, + data: %{"type" => "Create", "to" => [Pleroma.Constants.as_public()]} } {:ok, activity: activity} -- cgit v1.2.3 From ad31d0726ac1aabfb97ed9746591e315420f17bb Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 11:30:27 -0500 Subject: Do not trust remote Cache-Control headers for mediaproxy --- lib/pleroma/reverse_proxy/reverse_proxy.ex | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index a281a00dc..8db3f78bb 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -7,7 +7,7 @@ defmodule Pleroma.ReverseProxy do @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) + @resp_cache_headers ~w(etag date last-modified) @keep_resp_headers @resp_cache_headers ++ ~w(content-type content-disposition content-encoding content-range) ++ ~w(accept-ranges vary) @@ -34,9 +34,6 @@ defmodule Pleroma.ReverseProxy do * request: `#{inspect(@keep_req_headers)}` * response: `#{inspect(@keep_resp_headers)}` - If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be - set to `#{inspect(@default_cache_control_header)}`. - Options: * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP @@ -297,16 +294,12 @@ defp build_resp_headers(headers, opts) do defp build_resp_cache_headers(headers, _opts) do has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end) - has_cache_control? = List.keymember?(headers, "cache-control", 0) cond do - has_cache? && has_cache_control? -> - headers - has_cache? -> # There's caching header present but no cache-control -- we need to explicitely override it # to public as Plug defaults to "max-age=0, private, must-revalidate" - List.keystore(headers, "cache-control", 0, {"cache-control", "public"}) + List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header}) true -> List.keystore( -- cgit v1.2.3 From e04e16bbc05b035c11b83d5134436d791c512421 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 11:31:55 -0500 Subject: Do not strip Cache-Control headers from media. Trust the Pleroma backend. --- installation/pleroma.nginx | 2 -- 1 file changed, 2 deletions(-) diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx index 7f48b614b..688be3e71 100644 --- a/installation/pleroma.nginx +++ b/installation/pleroma.nginx @@ -90,8 +90,6 @@ server { proxy_ignore_client_abort on; proxy_buffering on; chunked_transfer_encoding on; - proxy_ignore_headers Cache-Control; - proxy_hide_header Cache-Control; proxy_pass http://127.0.0.1:4000; } } -- cgit v1.2.3 From c62195127d93761703954af97e328675ee853805 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 11:46:40 -0500 Subject: Update comment to reflect what the code is actually doing --- lib/pleroma/reverse_proxy/reverse_proxy.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 8db3f78bb..072a3d263 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -297,8 +297,8 @@ defp build_resp_cache_headers(headers, _opts) do cond do has_cache? -> - # There's caching header present but no cache-control -- we need to explicitely override it - # to public as Plug defaults to "max-age=0, private, must-revalidate" + # There's caching header present but no cache-control -- we need to set our own + # as Plug defaults to "max-age=0, private, must-revalidate" List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header}) true -> -- cgit v1.2.3 From 413177c8f0e4b15eb085c4efa26c94d572ee8d88 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 12:02:58 -0500 Subject: Set correct Cache-Control header for local media --- lib/pleroma/plugs/uploaded_media.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index f372829a2..57097baae 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -14,6 +14,8 @@ defmodule Pleroma.Plugs.UploadedMedia do # no slashes @path "media" + @default_cache_control_header "public max-age=86400 must-revalidate" + def init(_opts) do static_plug_opts = [] @@ -58,6 +60,10 @@ defp get_media(conn, {:static_dir, directory}, _, opts) do Map.get(opts, :static_plug_opts) |> Map.put(:at, [@path]) |> Map.put(:from, directory) + |> Map.put(:cache_control_for_etags, @default_cache_control_header) + |> Map.put(:headers, %{ + "cache-control" => @default_cache_control_header + }) conn = Plug.Static.call(conn, static_opts) -- cgit v1.2.3 From 470090471dabdd3863b9082f1c7aba6c84e7e703 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 12:20:33 -0500 Subject: Fix test to use new cache-control settings --- test/reverse_proxy_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/reverse_proxy_test.exs b/test/reverse_proxy_test.exs index 18d70862c..f1690ade9 100644 --- a/test/reverse_proxy_test.exs +++ b/test/reverse_proxy_test.exs @@ -294,7 +294,7 @@ test "add cache-control", %{conn: conn} do |> expect(:stream_body, fn _ -> :done end) conn = ReverseProxy.call(conn, "/cache") - assert {"cache-control", "public"} in conn.resp_headers + assert {"cache-control", "public, max-age=1209600"} in conn.resp_headers end end -- cgit v1.2.3 From db36b48180fda3b0632a5088e45fb0dbf42952c1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 12:23:14 -0500 Subject: Remove test verifying we preserve cache-control headers; we don't --- test/reverse_proxy_test.exs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/reverse_proxy_test.exs b/test/reverse_proxy_test.exs index f1690ade9..87c6aca4e 100644 --- a/test/reverse_proxy_test.exs +++ b/test/reverse_proxy_test.exs @@ -275,17 +275,6 @@ test "returns 400 on non GET, HEAD requests", %{conn: conn} do end describe "cache resp headers" do - test "returns headers", %{conn: conn} do - ClientMock - |> expect(:request, fn :get, "/cache/" <> ttl, _, _, _ -> - {:ok, 200, [{"cache-control", "public, max-age=" <> ttl}], %{}} - end) - |> expect(:stream_body, fn _ -> :done end) - - conn = ReverseProxy.call(conn, "/cache/10") - assert {"cache-control", "public, max-age=10"} in conn.resp_headers - end - test "add cache-control", %{conn: conn} do ClientMock |> expect(:request, fn :get, "/cache", _, _, _ -> -- cgit v1.2.3 From 3b1b183b42019adc9d09b0c1af703b25e313167d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 12:27:50 -0500 Subject: Synchronize cache-control header for local media with the mediaproxy --- lib/pleroma/plugs/uploaded_media.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 57097baae..74427709d 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Plugs.UploadedMedia do # no slashes @path "media" - @default_cache_control_header "public max-age=86400 must-revalidate" + @default_cache_control_header "public, max-age=1209600" def init(_opts) do static_plug_opts = -- cgit v1.2.3 From 7321429a2ea134d7d920d8c977c4ec7bdcafc5e1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 13 Mar 2020 12:42:06 -0500 Subject: Lint --- lib/pleroma/reverse_proxy/reverse_proxy.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 072a3d263..8b713b8f4 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -299,7 +299,12 @@ defp build_resp_cache_headers(headers, _opts) do has_cache? -> # There's caching header present but no cache-control -- we need to set our own # as Plug defaults to "max-age=0, private, must-revalidate" - List.keystore(headers, "cache-control", 0, {"cache-control", @default_cache_control_header}) + List.keystore( + headers, + "cache-control", + 0, + {"cache-control", @default_cache_control_header} + ) true -> List.keystore( -- cgit v1.2.3 From fc4496d4fa45b0389f8476b2c2ee00d647a1dfbe Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 13 Mar 2020 21:15:42 +0300 Subject: rate limiter: disable based on if remote ip was found, not on if the plug was enabled The current rate limiter disable logic won't trigger when the remote ip is not forwarded, only when the remoteip plug is not enabled, which is not the case on most instances since it's enabled by default. This changes the behavior to warn and disable when the remote ip was not forwarded, even if the RemoteIP plug is enabled. Also closes #1620 --- config/test.exs | 2 + lib/pleroma/plugs/rate_limiter/rate_limiter.ex | 27 ++++---- lib/pleroma/plugs/remote_ip.ex | 7 +- test/plugs/rate_limiter_test.exs | 76 ++++++++-------------- .../controllers/account_controller_test.exs | 4 -- 5 files changed, 51 insertions(+), 65 deletions(-) diff --git a/config/test.exs b/config/test.exs index a17886265..b8ea63c94 100644 --- a/config/test.exs +++ b/config/test.exs @@ -92,6 +92,8 @@ config :pleroma, Pleroma.Emails.NewUsersDigestEmail, enabled: true +config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/lib/pleroma/plugs/rate_limiter/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex index c3f6351c8..1529da717 100644 --- a/lib/pleroma/plugs/rate_limiter/rate_limiter.ex +++ b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex @@ -78,7 +78,7 @@ def init(plug_opts) do end def call(conn, plug_opts) do - if disabled?() do + if disabled?(conn) do handle_disabled(conn) else action_settings = action_settings(plug_opts) @@ -87,9 +87,9 @@ def call(conn, plug_opts) do end defp handle_disabled(conn) do - if Config.get(:env) == :prod do - Logger.warn("Rate limiter is disabled for localhost/socket") - end + Logger.warn( + "Rate limiter disabled due to forwarded IP not being found. Please ensure your reverse proxy is providing the X-Forwarded-For header or disable the RemoteIP plug/rate limiter." + ) conn end @@ -109,16 +109,21 @@ defp handle(conn, action_settings) do end end - def disabled? do + def disabled?(conn) do localhost_or_socket = - Config.get([Pleroma.Web.Endpoint, :http, :ip]) - |> Tuple.to_list() - |> Enum.join(".") - |> String.match?(~r/^local|^127.0.0.1/) + case Config.get([Pleroma.Web.Endpoint, :http, :ip]) do + {127, 0, 0, 1} -> true + {0, 0, 0, 0, 0, 0, 0, 1} -> true + {:local, _} -> true + _ -> false + end - remote_ip_disabled = not Config.get([Pleroma.Plugs.RemoteIp, :enabled]) + remote_ip_not_found = + if Map.has_key?(conn.assigns, :remote_ip_found), + do: !conn.assigns.remote_ip_found, + else: false - localhost_or_socket and remote_ip_disabled + localhost_or_socket and remote_ip_not_found end @inspect_bucket_not_found {:error, :not_found} diff --git a/lib/pleroma/plugs/remote_ip.ex b/lib/pleroma/plugs/remote_ip.ex index 2eca4f8f6..0ac9050d0 100644 --- a/lib/pleroma/plugs/remote_ip.ex +++ b/lib/pleroma/plugs/remote_ip.ex @@ -7,6 +7,8 @@ defmodule Pleroma.Plugs.RemoteIp do This is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. """ + import Plug.Conn + @behaviour Plug @headers ~w[ @@ -26,11 +28,12 @@ defmodule Pleroma.Plugs.RemoteIp do def init(_), do: nil - def call(conn, _) do + def call(%{remote_ip: original_remote_ip} = conn, _) do config = Pleroma.Config.get(__MODULE__, []) if Keyword.get(config, :enabled, false) do - RemoteIp.call(conn, remote_ip_opts(config)) + %{remote_ip: new_remote_ip} = conn = RemoteIp.call(conn, remote_ip_opts(config)) + assign(conn, :remote_ip_found, original_remote_ip != new_remote_ip) else conn end diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs index 8023271e4..81e2009c8 100644 --- a/test/plugs/rate_limiter_test.exs +++ b/test/plugs/rate_limiter_test.exs @@ -3,8 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.RateLimiterTest do - use ExUnit.Case, async: true - use Plug.Test + use Pleroma.Web.ConnCase alias Pleroma.Config alias Pleroma.Plugs.RateLimiter @@ -36,63 +35,44 @@ test "config is required for plug to work" do |> RateLimiter.init() |> RateLimiter.action_settings() end + end - test "it is disabled for localhost" do - Config.put([:rate_limit, @limiter_name], {1, 1}) - Config.put([Pleroma.Web.Endpoint, :http, :ip], {127, 0, 0, 1}) - Config.put([Pleroma.Plugs.RemoteIp, :enabled], false) - - assert RateLimiter.disabled?() == true - end + test "it is disabled if it remote ip plug is enabled but no remote ip is found" do + Config.put([Pleroma.Web.Endpoint, :http, :ip], {127, 0, 0, 1}) + assert RateLimiter.disabled?(Plug.Conn.assign(build_conn(), :remote_ip_found, false)) + end - test "it is disabled for socket" do - Config.put([:rate_limit, @limiter_name], {1, 1}) - Config.put([Pleroma.Web.Endpoint, :http, :ip], {:local, "/path/to/pleroma.sock"}) - Config.put([Pleroma.Plugs.RemoteIp, :enabled], false) + test "it restricts based on config values" do + limiter_name = :test_plug_opts + scale = 80 + limit = 5 - assert RateLimiter.disabled?() == true - end + Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8}) + Config.put([:rate_limit, limiter_name], {scale, limit}) - test "it is enabled for socket when remote ip is enabled" do - Config.put([:rate_limit, @limiter_name], {1, 1}) - Config.put([Pleroma.Web.Endpoint, :http, :ip], {:local, "/path/to/pleroma.sock"}) - Config.put([Pleroma.Plugs.RemoteIp, :enabled], true) + plug_opts = RateLimiter.init(name: limiter_name) + conn = conn(:get, "/") - assert RateLimiter.disabled?() == false + for i <- 1..5 do + conn = RateLimiter.call(conn, plug_opts) + assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) + Process.sleep(10) end - test "it restricts based on config values" do - limiter_name = :test_plug_opts - scale = 80 - limit = 5 - - Config.put([Pleroma.Web.Endpoint, :http, :ip], {8, 8, 8, 8}) - Config.put([:rate_limit, limiter_name], {scale, limit}) - - plug_opts = RateLimiter.init(name: limiter_name) - conn = conn(:get, "/") - - for i <- 1..5 do - conn = RateLimiter.call(conn, plug_opts) - assert {^i, _} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) - Process.sleep(10) - end + conn = RateLimiter.call(conn, plug_opts) + assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests) + assert conn.halted - conn = RateLimiter.call(conn, plug_opts) - assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests) - assert conn.halted + Process.sleep(50) - Process.sleep(50) + conn = conn(:get, "/") - conn = conn(:get, "/") + conn = RateLimiter.call(conn, plug_opts) + assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) - conn = RateLimiter.call(conn, plug_opts) - assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) - - refute conn.status == Plug.Conn.Status.code(:too_many_requests) - refute conn.resp_body - refute conn.halted - end + refute conn.status == Plug.Conn.Status.code(:too_many_requests) + refute conn.resp_body + refute conn.halted end describe "options" do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 7f7d8cea3..7efccd9c4 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -756,10 +756,6 @@ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_ end describe "create account by app / rate limit" do - clear_config([Pleroma.Plugs.RemoteIp, :enabled]) do - Pleroma.Config.put([Pleroma.Plugs.RemoteIp, :enabled], true) - end - clear_config([:rate_limit, :app_account_creation]) do Pleroma.Config.put([:rate_limit, :app_account_creation], {10_000, 2}) end -- cgit v1.2.3 From 2966377cb96b88948c1ceae742f70784ca6f5aee Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 12 Mar 2020 15:12:29 -0500 Subject: Update AdminFE --- priv/static/adminfe/chunk-03b0.49362218.css | Bin 9292 -> 0 bytes priv/static/adminfe/chunk-0d8f.650c8e81.css | Bin 0 -> 3433 bytes priv/static/adminfe/chunk-136a.3936457d.css | Bin 0 -> 4946 bytes priv/static/adminfe/chunk-15fa.5a5f973d.css | Bin 0 -> 4748 bytes priv/static/adminfe/chunk-15fa.dc3643e6.css | Bin 4748 -> 0 bytes priv/static/adminfe/chunk-17a5.edcdbe30.css | Bin 3433 -> 0 bytes priv/static/adminfe/chunk-293a.a8b5ee5b.css | Bin 5332 -> 0 bytes priv/static/adminfe/chunk-2b8b.0f1ee211.css | Bin 4946 -> 0 bytes priv/static/adminfe/chunk-453a.bbab87da.css | Bin 1790 -> 0 bytes priv/static/adminfe/chunk-46cf.6dd5bbb7.css | Bin 1071 -> 0 bytes priv/static/adminfe/chunk-46cf.a43e9415.css | Bin 0 -> 1071 bytes priv/static/adminfe/chunk-46ef.d45db7be.css | Bin 0 -> 1790 bytes priv/static/adminfe/chunk-4e46.ad5e9ff3.css | Bin 745 -> 0 bytes priv/static/adminfe/chunk-4e7d.7aace723.css | Bin 0 -> 5332 bytes priv/static/adminfe/chunk-4ffb.dd09fe2e.css | Bin 0 -> 745 bytes priv/static/adminfe/chunk-560d.802cfba1.css | Bin 5731 -> 0 bytes priv/static/adminfe/chunk-6dd6.85f319f7.css | Bin 2044 -> 0 bytes priv/static/adminfe/chunk-876c.90dffac4.css | Bin 0 -> 2044 bytes priv/static/adminfe/chunk-87b3.2affd602.css | Bin 0 -> 9407 bytes priv/static/adminfe/chunk-cf57.4d39576f.css | Bin 0 -> 3221 bytes priv/static/adminfe/chunk-cf58.80435fa1.css | Bin 3143 -> 0 bytes priv/static/adminfe/chunk-e5cf.cba3ae06.css | Bin 0 -> 5731 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.55df3157.js | Bin 179675 -> 0 bytes priv/static/adminfe/static/js/app.55df3157.js.map | Bin 398361 -> 0 bytes priv/static/adminfe/static/js/app.d2c3c6b3.js | Bin 0 -> 181998 bytes priv/static/adminfe/static/js/app.d2c3c6b3.js.map | Bin 0 -> 403968 bytes priv/static/adminfe/static/js/chunk-03b0.7a203856.js | Bin 100666 -> 0 bytes .../adminfe/static/js/chunk-03b0.7a203856.js.map | Bin 348763 -> 0 bytes priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js | Bin 0 -> 33538 bytes .../adminfe/static/js/chunk-0d8f.a85e3222.js.map | Bin 0 -> 116201 bytes priv/static/adminfe/static/js/chunk-136a.142aa42a.js | Bin 0 -> 19553 bytes .../adminfe/static/js/chunk-136a.142aa42a.js.map | Bin 0 -> 69090 bytes priv/static/adminfe/static/js/chunk-15fa.15303f3a.js | Bin 7919 -> 0 bytes .../adminfe/static/js/chunk-15fa.15303f3a.js.map | Bin 17438 -> 0 bytes priv/static/adminfe/static/js/chunk-15fa.34070731.js | Bin 0 -> 7919 bytes .../adminfe/static/js/chunk-15fa.34070731.js.map | Bin 0 -> 17438 bytes priv/static/adminfe/static/js/chunk-17a5.13b13757.js | Bin 33538 -> 0 bytes .../adminfe/static/js/chunk-17a5.13b13757.js.map | Bin 116201 -> 0 bytes priv/static/adminfe/static/js/chunk-293a.a728de01.js | Bin 23332 -> 0 bytes .../adminfe/static/js/chunk-293a.a728de01.js.map | Bin 80400 -> 0 bytes priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js | Bin 19553 -> 0 bytes .../adminfe/static/js/chunk-2b8b.e3daf966.js.map | Bin 69090 -> 0 bytes priv/static/adminfe/static/js/chunk-453a.2fcd7192.js | Bin 7765 -> 0 bytes .../adminfe/static/js/chunk-453a.2fcd7192.js.map | Bin 26170 -> 0 bytes priv/static/adminfe/static/js/chunk-46cf.104380a9.js | Bin 9526 -> 0 bytes .../adminfe/static/js/chunk-46cf.104380a9.js.map | Bin 40123 -> 0 bytes priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js | Bin 0 -> 9526 bytes .../adminfe/static/js/chunk-46cf.3bd3567a.js.map | Bin 0 -> 40123 bytes priv/static/adminfe/static/js/chunk-46ef.215af110.js | Bin 0 -> 7765 bytes .../adminfe/static/js/chunk-46ef.215af110.js.map | Bin 0 -> 26170 bytes priv/static/adminfe/static/js/chunk-4e46.d257e435.js | Bin 2080 -> 0 bytes .../adminfe/static/js/chunk-4e46.d257e435.js.map | Bin 9090 -> 0 bytes priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js | Bin 0 -> 23331 bytes .../adminfe/static/js/chunk-4e7d.a40ad735.js.map | Bin 0 -> 80396 bytes priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js | Bin 0 -> 2080 bytes .../adminfe/static/js/chunk-4ffb.0e8f3772.js.map | Bin 0 -> 9090 bytes priv/static/adminfe/static/js/chunk-560d.a8bb8682.js | Bin 24234 -> 0 bytes .../adminfe/static/js/chunk-560d.a8bb8682.js.map | Bin 92386 -> 0 bytes priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js | Bin 5112 -> 0 bytes .../adminfe/static/js/chunk-6dd6.6c139a9c.js.map | Bin 19744 -> 0 bytes priv/static/adminfe/static/js/chunk-876c.e4ceccca.js | Bin 0 -> 5112 bytes .../adminfe/static/js/chunk-876c.e4ceccca.js.map | Bin 0 -> 19744 bytes priv/static/adminfe/static/js/chunk-87b3.4704cadf.js | Bin 0 -> 103161 bytes .../adminfe/static/js/chunk-87b3.4704cadf.js.map | Bin 0 -> 358274 bytes priv/static/adminfe/static/js/chunk-cf57.42b96339.js | Bin 0 -> 29100 bytes .../adminfe/static/js/chunk-cf57.42b96339.js.map | Bin 0 -> 88026 bytes priv/static/adminfe/static/js/chunk-cf58.e52693b3.js | Bin 27673 -> 0 bytes .../adminfe/static/js/chunk-cf58.e52693b3.js.map | Bin 84422 -> 0 bytes priv/static/adminfe/static/js/chunk-e5cf.501d7902.js | Bin 0 -> 24234 bytes .../adminfe/static/js/chunk-e5cf.501d7902.js.map | Bin 0 -> 92386 bytes priv/static/adminfe/static/js/runtime.ae93ea9f.js | Bin 3969 -> 0 bytes priv/static/adminfe/static/js/runtime.ae93ea9f.js.map | Bin 16759 -> 0 bytes priv/static/adminfe/static/js/runtime.fa19e5d1.js | Bin 0 -> 3969 bytes priv/static/adminfe/static/js/runtime.fa19e5d1.js.map | Bin 0 -> 16759 bytes 75 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 priv/static/adminfe/chunk-03b0.49362218.css create mode 100644 priv/static/adminfe/chunk-0d8f.650c8e81.css create mode 100644 priv/static/adminfe/chunk-136a.3936457d.css create mode 100644 priv/static/adminfe/chunk-15fa.5a5f973d.css delete mode 100644 priv/static/adminfe/chunk-15fa.dc3643e6.css delete mode 100644 priv/static/adminfe/chunk-17a5.edcdbe30.css delete mode 100644 priv/static/adminfe/chunk-293a.a8b5ee5b.css delete mode 100644 priv/static/adminfe/chunk-2b8b.0f1ee211.css delete mode 100644 priv/static/adminfe/chunk-453a.bbab87da.css delete mode 100644 priv/static/adminfe/chunk-46cf.6dd5bbb7.css create mode 100644 priv/static/adminfe/chunk-46cf.a43e9415.css create mode 100644 priv/static/adminfe/chunk-46ef.d45db7be.css delete mode 100644 priv/static/adminfe/chunk-4e46.ad5e9ff3.css create mode 100644 priv/static/adminfe/chunk-4e7d.7aace723.css create mode 100644 priv/static/adminfe/chunk-4ffb.dd09fe2e.css delete mode 100644 priv/static/adminfe/chunk-560d.802cfba1.css delete mode 100644 priv/static/adminfe/chunk-6dd6.85f319f7.css create mode 100644 priv/static/adminfe/chunk-876c.90dffac4.css create mode 100644 priv/static/adminfe/chunk-87b3.2affd602.css create mode 100644 priv/static/adminfe/chunk-cf57.4d39576f.css delete mode 100644 priv/static/adminfe/chunk-cf58.80435fa1.css create mode 100644 priv/static/adminfe/chunk-e5cf.cba3ae06.css delete mode 100644 priv/static/adminfe/static/js/app.55df3157.js delete mode 100644 priv/static/adminfe/static/js/app.55df3157.js.map create mode 100644 priv/static/adminfe/static/js/app.d2c3c6b3.js create mode 100644 priv/static/adminfe/static/js/app.d2c3c6b3.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-03b0.7a203856.js delete mode 100644 priv/static/adminfe/static/js/chunk-03b0.7a203856.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js create mode 100644 priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map create mode 100644 priv/static/adminfe/static/js/chunk-136a.142aa42a.js create mode 100644 priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-15fa.15303f3a.js delete mode 100644 priv/static/adminfe/static/js/chunk-15fa.15303f3a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-15fa.34070731.js create mode 100644 priv/static/adminfe/static/js/chunk-15fa.34070731.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-17a5.13b13757.js delete mode 100644 priv/static/adminfe/static/js/chunk-17a5.13b13757.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-293a.a728de01.js delete mode 100644 priv/static/adminfe/static/js/chunk-293a.a728de01.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js delete mode 100644 priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-453a.2fcd7192.js delete mode 100644 priv/static/adminfe/static/js/chunk-453a.2fcd7192.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-46cf.104380a9.js delete mode 100644 priv/static/adminfe/static/js/chunk-46cf.104380a9.js.map create mode 100644 priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js create mode 100644 priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-46ef.215af110.js create mode 100644 priv/static/adminfe/static/js/chunk-46ef.215af110.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-4e46.d257e435.js delete mode 100644 priv/static/adminfe/static/js/chunk-4e46.d257e435.js.map create mode 100644 priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js create mode 100644 priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map create mode 100644 priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js create mode 100644 priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-560d.a8bb8682.js delete mode 100644 priv/static/adminfe/static/js/chunk-560d.a8bb8682.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js delete mode 100644 priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js.map create mode 100644 priv/static/adminfe/static/js/chunk-876c.e4ceccca.js create mode 100644 priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map create mode 100644 priv/static/adminfe/static/js/chunk-87b3.4704cadf.js create mode 100644 priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map create mode 100644 priv/static/adminfe/static/js/chunk-cf57.42b96339.js create mode 100644 priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-cf58.e52693b3.js delete mode 100644 priv/static/adminfe/static/js/chunk-cf58.e52693b3.js.map create mode 100644 priv/static/adminfe/static/js/chunk-e5cf.501d7902.js create mode 100644 priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.ae93ea9f.js delete mode 100644 priv/static/adminfe/static/js/runtime.ae93ea9f.js.map create mode 100644 priv/static/adminfe/static/js/runtime.fa19e5d1.js create mode 100644 priv/static/adminfe/static/js/runtime.fa19e5d1.js.map diff --git a/priv/static/adminfe/chunk-03b0.49362218.css b/priv/static/adminfe/chunk-03b0.49362218.css deleted file mode 100644 index e43c776aa..000000000 Binary files a/priv/static/adminfe/chunk-03b0.49362218.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0d8f.650c8e81.css b/priv/static/adminfe/chunk-0d8f.650c8e81.css new file mode 100644 index 000000000..0b2a3f669 Binary files /dev/null and b/priv/static/adminfe/chunk-0d8f.650c8e81.css differ diff --git a/priv/static/adminfe/chunk-136a.3936457d.css b/priv/static/adminfe/chunk-136a.3936457d.css new file mode 100644 index 000000000..2857a9d6e Binary files /dev/null and b/priv/static/adminfe/chunk-136a.3936457d.css differ diff --git a/priv/static/adminfe/chunk-15fa.5a5f973d.css b/priv/static/adminfe/chunk-15fa.5a5f973d.css new file mode 100644 index 000000000..30bf7de23 Binary files /dev/null and b/priv/static/adminfe/chunk-15fa.5a5f973d.css differ diff --git a/priv/static/adminfe/chunk-15fa.dc3643e6.css b/priv/static/adminfe/chunk-15fa.dc3643e6.css deleted file mode 100644 index 30bf7de23..000000000 Binary files a/priv/static/adminfe/chunk-15fa.dc3643e6.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-17a5.edcdbe30.css b/priv/static/adminfe/chunk-17a5.edcdbe30.css deleted file mode 100644 index 0b2a3f669..000000000 Binary files a/priv/static/adminfe/chunk-17a5.edcdbe30.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-293a.a8b5ee5b.css b/priv/static/adminfe/chunk-293a.a8b5ee5b.css deleted file mode 100644 index 924633a80..000000000 Binary files a/priv/static/adminfe/chunk-293a.a8b5ee5b.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-2b8b.0f1ee211.css b/priv/static/adminfe/chunk-2b8b.0f1ee211.css deleted file mode 100644 index 2857a9d6e..000000000 Binary files a/priv/static/adminfe/chunk-2b8b.0f1ee211.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-453a.bbab87da.css b/priv/static/adminfe/chunk-453a.bbab87da.css deleted file mode 100644 index d6cc7d182..000000000 Binary files a/priv/static/adminfe/chunk-453a.bbab87da.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-46cf.6dd5bbb7.css b/priv/static/adminfe/chunk-46cf.6dd5bbb7.css deleted file mode 100644 index aa7160528..000000000 Binary files a/priv/static/adminfe/chunk-46cf.6dd5bbb7.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-46cf.a43e9415.css b/priv/static/adminfe/chunk-46cf.a43e9415.css new file mode 100644 index 000000000..aa7160528 Binary files /dev/null and b/priv/static/adminfe/chunk-46cf.a43e9415.css differ diff --git a/priv/static/adminfe/chunk-46ef.d45db7be.css b/priv/static/adminfe/chunk-46ef.d45db7be.css new file mode 100644 index 000000000..d6cc7d182 Binary files /dev/null and b/priv/static/adminfe/chunk-46ef.d45db7be.css differ diff --git a/priv/static/adminfe/chunk-4e46.ad5e9ff3.css b/priv/static/adminfe/chunk-4e46.ad5e9ff3.css deleted file mode 100644 index da819ca09..000000000 Binary files a/priv/static/adminfe/chunk-4e46.ad5e9ff3.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-4e7d.7aace723.css b/priv/static/adminfe/chunk-4e7d.7aace723.css new file mode 100644 index 000000000..9a35b64a0 Binary files /dev/null and b/priv/static/adminfe/chunk-4e7d.7aace723.css differ diff --git a/priv/static/adminfe/chunk-4ffb.dd09fe2e.css b/priv/static/adminfe/chunk-4ffb.dd09fe2e.css new file mode 100644 index 000000000..da819ca09 Binary files /dev/null and b/priv/static/adminfe/chunk-4ffb.dd09fe2e.css differ diff --git a/priv/static/adminfe/chunk-560d.802cfba1.css b/priv/static/adminfe/chunk-560d.802cfba1.css deleted file mode 100644 index a74b42d14..000000000 Binary files a/priv/static/adminfe/chunk-560d.802cfba1.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-6dd6.85f319f7.css b/priv/static/adminfe/chunk-6dd6.85f319f7.css deleted file mode 100644 index c0074e6f7..000000000 Binary files a/priv/static/adminfe/chunk-6dd6.85f319f7.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-876c.90dffac4.css b/priv/static/adminfe/chunk-876c.90dffac4.css new file mode 100644 index 000000000..c0074e6f7 Binary files /dev/null and b/priv/static/adminfe/chunk-876c.90dffac4.css differ diff --git a/priv/static/adminfe/chunk-87b3.2affd602.css b/priv/static/adminfe/chunk-87b3.2affd602.css new file mode 100644 index 000000000..c4fa46d3e Binary files /dev/null and b/priv/static/adminfe/chunk-87b3.2affd602.css differ diff --git a/priv/static/adminfe/chunk-cf57.4d39576f.css b/priv/static/adminfe/chunk-cf57.4d39576f.css new file mode 100644 index 000000000..1190aca24 Binary files /dev/null and b/priv/static/adminfe/chunk-cf57.4d39576f.css differ diff --git a/priv/static/adminfe/chunk-cf58.80435fa1.css b/priv/static/adminfe/chunk-cf58.80435fa1.css deleted file mode 100644 index 8b0f21153..000000000 Binary files a/priv/static/adminfe/chunk-cf58.80435fa1.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-e5cf.cba3ae06.css b/priv/static/adminfe/chunk-e5cf.cba3ae06.css new file mode 100644 index 000000000..a74b42d14 Binary files /dev/null and b/priv/static/adminfe/chunk-e5cf.cba3ae06.css differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index e2db408c3..717b0f32d 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.55df3157.js b/priv/static/adminfe/static/js/app.55df3157.js deleted file mode 100644 index d1a37af1c..000000000 Binary files a/priv/static/adminfe/static/js/app.55df3157.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.55df3157.js.map b/priv/static/adminfe/static/js/app.55df3157.js.map deleted file mode 100644 index 740783b80..000000000 Binary files a/priv/static/adminfe/static/js/app.55df3157.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.d2c3c6b3.js b/priv/static/adminfe/static/js/app.d2c3c6b3.js new file mode 100644 index 000000000..c527207dd Binary files /dev/null and b/priv/static/adminfe/static/js/app.d2c3c6b3.js differ diff --git a/priv/static/adminfe/static/js/app.d2c3c6b3.js.map b/priv/static/adminfe/static/js/app.d2c3c6b3.js.map new file mode 100644 index 000000000..7b2d4dc05 Binary files /dev/null and b/priv/static/adminfe/static/js/app.d2c3c6b3.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-03b0.7a203856.js b/priv/static/adminfe/static/js/chunk-03b0.7a203856.js deleted file mode 100644 index 43ca0e4e6..000000000 Binary files a/priv/static/adminfe/static/js/chunk-03b0.7a203856.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-03b0.7a203856.js.map b/priv/static/adminfe/static/js/chunk-03b0.7a203856.js.map deleted file mode 100644 index 697a106ac..000000000 Binary files a/priv/static/adminfe/static/js/chunk-03b0.7a203856.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js b/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js new file mode 100644 index 000000000..e3b0ae986 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map b/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map new file mode 100644 index 000000000..cf75f3243 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-136a.142aa42a.js b/priv/static/adminfe/static/js/chunk-136a.142aa42a.js new file mode 100644 index 000000000..812089b5f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-136a.142aa42a.js differ diff --git a/priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map b/priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map new file mode 100644 index 000000000..f6b4c84aa Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.15303f3a.js b/priv/static/adminfe/static/js/chunk-15fa.15303f3a.js deleted file mode 100644 index 7d3e0c56e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-15fa.15303f3a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.15303f3a.js.map b/priv/static/adminfe/static/js/chunk-15fa.15303f3a.js.map deleted file mode 100644 index f08d1dbf9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-15fa.15303f3a.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.34070731.js b/priv/static/adminfe/static/js/chunk-15fa.34070731.js new file mode 100644 index 000000000..937908d00 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-15fa.34070731.js differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.34070731.js.map b/priv/static/adminfe/static/js/chunk-15fa.34070731.js.map new file mode 100644 index 000000000..d3830be7c Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-15fa.34070731.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-17a5.13b13757.js b/priv/static/adminfe/static/js/chunk-17a5.13b13757.js deleted file mode 100644 index 80e7a8ac7..000000000 Binary files a/priv/static/adminfe/static/js/chunk-17a5.13b13757.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-17a5.13b13757.js.map b/priv/static/adminfe/static/js/chunk-17a5.13b13757.js.map deleted file mode 100644 index 7da1a0077..000000000 Binary files a/priv/static/adminfe/static/js/chunk-17a5.13b13757.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-293a.a728de01.js b/priv/static/adminfe/static/js/chunk-293a.a728de01.js deleted file mode 100644 index c856e21eb..000000000 Binary files a/priv/static/adminfe/static/js/chunk-293a.a728de01.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-293a.a728de01.js.map b/priv/static/adminfe/static/js/chunk-293a.a728de01.js.map deleted file mode 100644 index 03f61abcb..000000000 Binary files a/priv/static/adminfe/static/js/chunk-293a.a728de01.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js b/priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js deleted file mode 100644 index 4b100db60..000000000 Binary files a/priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js.map b/priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js.map deleted file mode 100644 index a7282eaf4..000000000 Binary files a/priv/static/adminfe/static/js/chunk-2b8b.e3daf966.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-453a.2fcd7192.js b/priv/static/adminfe/static/js/chunk-453a.2fcd7192.js deleted file mode 100644 index b0ee1b6b0..000000000 Binary files a/priv/static/adminfe/static/js/chunk-453a.2fcd7192.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-453a.2fcd7192.js.map b/priv/static/adminfe/static/js/chunk-453a.2fcd7192.js.map deleted file mode 100644 index b43d2f571..000000000 Binary files a/priv/static/adminfe/static/js/chunk-453a.2fcd7192.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46cf.104380a9.js b/priv/static/adminfe/static/js/chunk-46cf.104380a9.js deleted file mode 100644 index 9e1e1520b..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46cf.104380a9.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46cf.104380a9.js.map b/priv/static/adminfe/static/js/chunk-46cf.104380a9.js.map deleted file mode 100644 index b9357ca8f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46cf.104380a9.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js b/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js new file mode 100644 index 000000000..0795a46b6 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js differ diff --git a/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map b/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map new file mode 100644 index 000000000..9993be4aa Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.215af110.js b/priv/static/adminfe/static/js/chunk-46ef.215af110.js new file mode 100644 index 000000000..db11c7488 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-46ef.215af110.js differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.215af110.js.map b/priv/static/adminfe/static/js/chunk-46ef.215af110.js.map new file mode 100644 index 000000000..2da3dbec6 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-46ef.215af110.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-4e46.d257e435.js b/priv/static/adminfe/static/js/chunk-4e46.d257e435.js deleted file mode 100644 index 39c5dcc4e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4e46.d257e435.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4e46.d257e435.js.map b/priv/static/adminfe/static/js/chunk-4e46.d257e435.js.map deleted file mode 100644 index 75d3554ac..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4e46.d257e435.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js b/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js new file mode 100644 index 000000000..ef2379ed9 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js differ diff --git a/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map b/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map new file mode 100644 index 000000000..b349f12eb Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js b/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js new file mode 100644 index 000000000..5a7aa9f59 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js differ diff --git a/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map b/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map new file mode 100644 index 000000000..7c020768c Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-560d.a8bb8682.js b/priv/static/adminfe/static/js/chunk-560d.a8bb8682.js deleted file mode 100644 index 0b03305e9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-560d.a8bb8682.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-560d.a8bb8682.js.map b/priv/static/adminfe/static/js/chunk-560d.a8bb8682.js.map deleted file mode 100644 index bfab1ade9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-560d.a8bb8682.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js b/priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js deleted file mode 100644 index 670016168..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js.map b/priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js.map deleted file mode 100644 index b1438722c..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6dd6.6c139a9c.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js b/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js new file mode 100644 index 000000000..841ceb9dc Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js differ diff --git a/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map b/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map new file mode 100644 index 000000000..88976a4fe Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js b/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js new file mode 100644 index 000000000..9766fd7d2 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map b/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map new file mode 100644 index 000000000..7472fcd92 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-cf57.42b96339.js b/priv/static/adminfe/static/js/chunk-cf57.42b96339.js new file mode 100644 index 000000000..81122f992 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-cf57.42b96339.js differ diff --git a/priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map b/priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map new file mode 100644 index 000000000..7471835b9 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-cf58.e52693b3.js b/priv/static/adminfe/static/js/chunk-cf58.e52693b3.js deleted file mode 100644 index b74c20373..000000000 Binary files a/priv/static/adminfe/static/js/chunk-cf58.e52693b3.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-cf58.e52693b3.js.map b/priv/static/adminfe/static/js/chunk-cf58.e52693b3.js.map deleted file mode 100644 index 0f3f15299..000000000 Binary files a/priv/static/adminfe/static/js/chunk-cf58.e52693b3.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js b/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js new file mode 100644 index 000000000..fe5552943 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js differ diff --git a/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map b/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map new file mode 100644 index 000000000..60676bfe7 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.ae93ea9f.js b/priv/static/adminfe/static/js/runtime.ae93ea9f.js deleted file mode 100644 index ebda2acde..000000000 Binary files a/priv/static/adminfe/static/js/runtime.ae93ea9f.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.ae93ea9f.js.map b/priv/static/adminfe/static/js/runtime.ae93ea9f.js.map deleted file mode 100644 index 6392c981a..000000000 Binary files a/priv/static/adminfe/static/js/runtime.ae93ea9f.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.fa19e5d1.js b/priv/static/adminfe/static/js/runtime.fa19e5d1.js new file mode 100644 index 000000000..b905e42e1 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.fa19e5d1.js differ diff --git a/priv/static/adminfe/static/js/runtime.fa19e5d1.js.map b/priv/static/adminfe/static/js/runtime.fa19e5d1.js.map new file mode 100644 index 000000000..6a2565556 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.fa19e5d1.js.map differ -- cgit v1.2.3 From 6a28c198af415c81596587f765e6c8c9388e9714 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 13 Mar 2020 22:12:33 +0300 Subject: uploaded media plug: do not inject compile-time params on every request --- lib/pleroma/plugs/uploaded_media.ex | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 74427709d..36ff024a7 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -18,7 +18,10 @@ defmodule Pleroma.Plugs.UploadedMedia do def init(_opts) do static_plug_opts = - [] + [ + headers: %{"cache-control" => @default_cache_control_header}, + cache_control_for_etags: @default_cache_control_header + ] |> Keyword.put(:from, "__unconfigured_media_plug") |> Keyword.put(:at, "/__unconfigured_media_plug") |> Plug.Static.init() @@ -60,10 +63,6 @@ defp get_media(conn, {:static_dir, directory}, _, opts) do Map.get(opts, :static_plug_opts) |> Map.put(:at, [@path]) |> Map.put(:from, directory) - |> Map.put(:cache_control_for_etags, @default_cache_control_header) - |> Map.put(:headers, %{ - "cache-control" => @default_cache_control_header - }) conn = Plug.Static.call(conn, static_opts) -- cgit v1.2.3 From 8f7bc07ebcd5c7d4d03ea56b55c6ae4b0dfcb889 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 2 Mar 2020 04:23:29 +0100 Subject: pleroma_api_controller.ex: Improve conversations error reporting Related: https://git.pleroma.social/pleroma/pleroma/issues/1594 --- .../controllers/pleroma_api_controller.ex | 33 ++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 0e160bbfc..dae7f0f2f 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -101,6 +101,11 @@ def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) conn |> put_view(ConversationView) |> render("participation.json", %{participation: participation, for: user}) + else + _error -> + conn + |> put_status(404) + |> json(%{"error" => "Unknown conversation id"}) end end @@ -108,9 +113,9 @@ def conversation_statuses( %{assigns: %{user: user}} = conn, %{"id" => participation_id} = params ) do - participation = Participation.get(participation_id, preload: [:conversation]) - - if user.id == participation.user_id do + with %Participation{} = participation <- + Participation.get(participation_id, preload: [:conversation]), + true <- user.id == participation.user_id do params = params |> Map.put("blocking_user", user) @@ -126,6 +131,11 @@ def conversation_statuses( |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", %{activities: activities, for: user, as: :activity}) + else + _error -> + conn + |> put_status(404) + |> json(%{"error" => "Unknown conversation id"}) end end @@ -133,15 +143,22 @@ def update_conversation( %{assigns: %{user: user}} = conn, %{"id" => participation_id, "recipients" => recipients} ) do - participation = - participation_id - |> Participation.get() - - with true <- user.id == participation.user_id, + with %Participation{} = participation <- Participation.get(participation_id), + true <- user.id == participation.user_id, {:ok, participation} <- Participation.set_recipients(participation, recipients) do conn |> put_view(ConversationView) |> render("participation.json", %{participation: participation, for: user}) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + + _error -> + conn + |> put_status(404) + |> json(%{"error" => "Unknown conversation id"}) end end -- cgit v1.2.3 From e87a32bcd7ee7d6bb5e9b6882a8685b5ee4c180c Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 14 Mar 2020 15:39:58 +0300 Subject: rip out fetch_initial_posts Every time someone tries to use it, it goes mad and tries to scrape the entire fediverse for no visible reason, it's better to just remove it than continue shipping it in it's current state. idea acked by lain and feld on irc Closes #1595 #1422 --- config/config.exs | 4 -- config/description.exs | 19 ------- docs/configuration/cheatsheet.md | 8 --- lib/pleroma/user.ex | 32 +---------- lib/pleroma/web/activity_pub/utils.ex | 39 ------------- lib/pleroma/workers/background_worker.ex | 4 -- ...314123607_config_remove_fetch_initial_posts.exs | 10 ++++ ...00315125756_delete_fetch_initial_posts_jobs.exs | 10 ++++ test/web/activity_pub/utils_test.exs | 65 ---------------------- 9 files changed, 21 insertions(+), 170 deletions(-) create mode 100644 priv/repo/migrations/20200314123607_config_remove_fetch_initial_posts.exs create mode 100644 priv/repo/migrations/20200315125756_delete_fetch_initial_posts_jobs.exs diff --git a/config/config.exs b/config/config.exs index 2cd741213..3357e23e7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -504,10 +504,6 @@ federator_outgoing: 5 ] -config :pleroma, :fetch_initial_posts, - enabled: false, - pages: 5 - config :auto_linker, opts: [ extra: true, diff --git a/config/description.exs b/config/description.exs index 9fdcfcd96..c0e403b2e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2007,25 +2007,6 @@ } ] }, - %{ - group: :pleroma, - key: :fetch_initial_posts, - type: :group, - description: "Fetching initial posts settings", - children: [ - %{ - key: :enabled, - type: :boolean, - description: "Fetch posts when a new user is federated with" - }, - %{ - key: :pages, - type: :integer, - description: "The amount of pages to fetch", - suggestions: [5] - } - ] - }, %{ group: :auto_linker, key: :opts, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 05fd6ceb1..2629385da 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -151,14 +151,6 @@ config :pleroma, :mrf_user_allowlist, * `sign_object_fetches`: Sign object fetches with HTTP signatures * `authorized_fetch_mode`: Require HTTP signatures for AP fetches -### :fetch_initial_posts - -!!! warning - Be careful with this setting, fetching posts may lead to new users being discovered whose posts will then also be fetched. This can lead to serious load on your instance and database. - -* `enabled`: If enabled, when a new user is discovered by your instance, fetch some of their latest posts. -* `pages`: The amount of pages to fetch - ## Pleroma.ScheduledActivity * `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7531757f5..db510d957 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -839,10 +839,6 @@ def get_or_fetch_by_nickname(nickname) do _e -> with [_nick, _domain] <- String.split(nickname, "@"), {:ok, user} <- fetch_by_nickname(nickname) do - if Pleroma.Config.get([:fetch_initial_posts, :enabled]) do - fetch_initial_posts(user) - end - {:ok, user} else _e -> {:error, "not found " <> nickname} @@ -850,11 +846,6 @@ def get_or_fetch_by_nickname(nickname) do end end - @doc "Fetch some posts when the user has just been federated with" - def fetch_initial_posts(user) do - BackgroundWorker.enqueue("fetch_initial_posts", %{"user_id" => user.id}) - end - @spec get_followers_query(User.t(), pos_integer() | nil) :: Ecto.Query.t() def get_followers_query(%User{} = user, nil) do User.Query.build(%{followers: user, deactivated: false}) @@ -1320,16 +1311,6 @@ def perform(:delete, %User{} = user) do Repo.delete(user) end - def perform(:fetch_initial_posts, %User{} = user) do - pages = Pleroma.Config.get!([:fetch_initial_posts, :pages]) - - # Insert all the posts in reverse order, so they're in the right order on the timeline - user.source_data["outbox"] - |> Utils.fetch_ordered_collection(pages) - |> Enum.reverse() - |> Enum.each(&Pleroma.Web.Federator.incoming_ap_doc/1) - end - def perform(:deactivate_async, user, status), do: deactivate(user, status) @spec perform(atom(), User.t(), list()) :: list() | {:error, any()} @@ -1458,18 +1439,7 @@ def get_or_fetch_by_ap_id(ap_id) do if !is_nil(user) and !needs_update?(user) do {:ok, user} else - # Whether to fetch initial posts for the user (if it's a new user & the fetching is enabled) - should_fetch_initial = is_nil(user) and Pleroma.Config.get([:fetch_initial_posts, :enabled]) - - resp = fetch_by_ap_id(ap_id) - - if should_fetch_initial do - with {:ok, %User{} = user} <- resp do - fetch_initial_posts(user) - end - end - - resp + fetch_by_ap_id(ap_id) end end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 2bc958670..15dd2ed45 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -784,45 +784,6 @@ defp build_flag_object(act) when is_map(act) or is_binary(act) do defp build_flag_object(_), do: [] - @doc """ - Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after - the first one to `pages_left` pages. - If the amount of pages is higher than the collection has, it returns whatever was there. - """ - def fetch_ordered_collection(from, pages_left, acc \\ []) do - with {:ok, response} <- Tesla.get(from), - {:ok, collection} <- Jason.decode(response.body) do - case collection["type"] do - "OrderedCollection" -> - # If we've encountered the OrderedCollection and not the page, - # just call the same function on the page address - fetch_ordered_collection(collection["first"], pages_left) - - "OrderedCollectionPage" -> - if pages_left > 0 do - # There are still more pages - if Map.has_key?(collection, "next") do - # There are still more pages, go deeper saving what we have into the accumulator - fetch_ordered_collection( - collection["next"], - pages_left - 1, - acc ++ collection["orderedItems"] - ) - else - # No more pages left, just return whatever we already have - acc ++ collection["orderedItems"] - end - else - # Got the amount of pages needed, add them all to the accumulator - acc ++ collection["orderedItems"] - end - - _ -> - {:error, "Not an OrderedCollection or OrderedCollectionPage"} - end - end - end - #### Report-related helpers def get_reports(params, page, page_size) do params = diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 598df6580..0f8ece2c4 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -10,10 +10,6 @@ defmodule Pleroma.Workers.BackgroundWorker do use Pleroma.Workers.WorkerHelper, queue: "background" @impl Oban.Worker - def perform(%{"op" => "fetch_initial_posts", "user_id" => user_id}, _job) do - user = User.get_cached_by_id(user_id) - User.perform(:fetch_initial_posts, user) - end def perform(%{"op" => "deactivate_user", "user_id" => user_id, "status" => status}, _job) do user = User.get_cached_by_id(user_id) diff --git a/priv/repo/migrations/20200314123607_config_remove_fetch_initial_posts.exs b/priv/repo/migrations/20200314123607_config_remove_fetch_initial_posts.exs new file mode 100644 index 000000000..392f531e8 --- /dev/null +++ b/priv/repo/migrations/20200314123607_config_remove_fetch_initial_posts.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.ConfigRemoveFetchInitialPosts do + use Ecto.Migration + + def change do + execute( + "delete from config where config.key = ':fetch_initial_posts' and config.group = ':pleroma';", + "" + ) + end +end diff --git a/priv/repo/migrations/20200315125756_delete_fetch_initial_posts_jobs.exs b/priv/repo/migrations/20200315125756_delete_fetch_initial_posts_jobs.exs new file mode 100644 index 000000000..5b8e3ab91 --- /dev/null +++ b/priv/repo/migrations/20200315125756_delete_fetch_initial_posts_jobs.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.DeleteFetchInitialPostsJobs do + use Ecto.Migration + + def change do + execute( + "delete from oban_jobs where worker = 'Pleroma.Workers.BackgroundWorker' and args->>'op' = 'fetch_initial_posts';", + "" + ) + end +end diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index e5ab54dd4..e913a5148 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -177,71 +177,6 @@ test "does not adress actor's follower address if the activity is not public", % end end - describe "fetch_ordered_collection" do - import Tesla.Mock - - test "fetches the first OrderedCollectionPage when an OrderedCollection is encountered" do - mock(fn - %{method: :get, url: "http://mastodon.com/outbox"} -> - json(%{"type" => "OrderedCollection", "first" => "http://mastodon.com/outbox?page=true"}) - - %{method: :get, url: "http://mastodon.com/outbox?page=true"} -> - json(%{"type" => "OrderedCollectionPage", "orderedItems" => ["ok"]}) - end) - - assert Utils.fetch_ordered_collection("http://mastodon.com/outbox", 1) == ["ok"] - end - - test "fetches several pages in the right order one after another, but only the specified amount" do - mock(fn - %{method: :get, url: "http://example.com/outbox"} -> - json(%{ - "type" => "OrderedCollectionPage", - "orderedItems" => [0], - "next" => "http://example.com/outbox?page=1" - }) - - %{method: :get, url: "http://example.com/outbox?page=1"} -> - json(%{ - "type" => "OrderedCollectionPage", - "orderedItems" => [1], - "next" => "http://example.com/outbox?page=2" - }) - - %{method: :get, url: "http://example.com/outbox?page=2"} -> - json(%{"type" => "OrderedCollectionPage", "orderedItems" => [2]}) - end) - - assert Utils.fetch_ordered_collection("http://example.com/outbox", 0) == [0] - assert Utils.fetch_ordered_collection("http://example.com/outbox", 1) == [0, 1] - end - - test "returns an error if the url doesn't have an OrderedCollection/Page" do - mock(fn - %{method: :get, url: "http://example.com/not-an-outbox"} -> - json(%{"type" => "NotAnOutbox"}) - end) - - assert {:error, _} = Utils.fetch_ordered_collection("http://example.com/not-an-outbox", 1) - end - - test "returns the what was collected if there are less pages than specified" do - mock(fn - %{method: :get, url: "http://example.com/outbox"} -> - json(%{ - "type" => "OrderedCollectionPage", - "orderedItems" => [0], - "next" => "http://example.com/outbox?page=1" - }) - - %{method: :get, url: "http://example.com/outbox?page=1"} -> - json(%{"type" => "OrderedCollectionPage", "orderedItems" => [1]}) - end) - - assert Utils.fetch_ordered_collection("http://example.com/outbox", 5) == [0, 1] - end - end - test "make_json_ld_header/0" do assert Utils.make_json_ld_header() == %{ "@context" => [ -- cgit v1.2.3 From dca21cd1d6fc0720ed70cce50389a30f8a16952f Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 15 Mar 2020 17:07:08 +0100 Subject: test/earmark_renderer_test.exs: Rename from test/earmark_renderer_test.ex Wasn't in the test suite otherwise --- test/earmark_renderer_test.ex | 79 ------------------------------------------ test/earmark_renderer_test.exs | 79 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 79 deletions(-) delete mode 100644 test/earmark_renderer_test.ex create mode 100644 test/earmark_renderer_test.exs diff --git a/test/earmark_renderer_test.ex b/test/earmark_renderer_test.ex deleted file mode 100644 index 220d97d16..000000000 --- a/test/earmark_renderer_test.ex +++ /dev/null @@ -1,79 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.EarmarkRendererTest do - use ExUnit.Case - - test "Paragraph" do - code = ~s[Hello\n\nWorld!] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "

    Hello

    World!

    " - end - - test "raw HTML" do - code = ~s[OwO] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "

    #{code}

    " - end - - test "rulers" do - code = ~s[before\n\n-----\n\nafter] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "

    before


    after

    " - end - - test "headings" do - code = ~s[# h1\n## h2\n### h3\n] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[

    h1

    h2

    h3

    ] - end - - test "blockquote" do - code = ~s[> whoms't are you quoting?] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "

    whoms’t are you quoting?

    " - end - - test "code" do - code = ~s[`mix`] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[

    mix

    ] - - code = ~s[``mix``] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[

    mix

    ] - - code = ~s[```\nputs "Hello World"\n```] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[
    puts "Hello World"
    ] - end - - test "lists" do - code = ~s[- one\n- two\n- three\n- four] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "
    • one
    • two
    • three
    • four
    " - - code = ~s[1. one\n2. two\n3. three\n4. four\n] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "
    1. one
    2. two
    3. three
    4. four
    " - end - - test "delegated renderers" do - code = ~s[a
    b] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == "

    #{code}

    " - - code = ~s[*aaaa~*] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[

    aaaa~

    ] - - code = ~s[**aaaa~**] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[

    aaaa~

    ] - - # strikethrought - code = ~s[aaaa~] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) - assert result == ~s[

    aaaa~

    ] - end -end diff --git a/test/earmark_renderer_test.exs b/test/earmark_renderer_test.exs new file mode 100644 index 000000000..220d97d16 --- /dev/null +++ b/test/earmark_renderer_test.exs @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.EarmarkRendererTest do + use ExUnit.Case + + test "Paragraph" do + code = ~s[Hello\n\nWorld!] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    Hello

    World!

    " + end + + test "raw HTML" do + code = ~s[OwO] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    #{code}

    " + end + + test "rulers" do + code = ~s[before\n\n-----\n\nafter] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    before


    after

    " + end + + test "headings" do + code = ~s[# h1\n## h2\n### h3\n] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    h1

    h2

    h3

    ] + end + + test "blockquote" do + code = ~s[> whoms't are you quoting?] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    whoms’t are you quoting?

    " + end + + test "code" do + code = ~s[`mix`] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    mix

    ] + + code = ~s[``mix``] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    mix

    ] + + code = ~s[```\nputs "Hello World"\n```] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[
    puts "Hello World"
    ] + end + + test "lists" do + code = ~s[- one\n- two\n- three\n- four] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "
    • one
    • two
    • three
    • four
    " + + code = ~s[1. one\n2. two\n3. three\n4. four\n] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "
    1. one
    2. two
    3. three
    4. four
    " + end + + test "delegated renderers" do + code = ~s[a
    b] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == "

    #{code}

    " + + code = ~s[*aaaa~*] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    aaaa~

    ] + + code = ~s[**aaaa~**] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    aaaa~

    ] + + # strikethrought + code = ~s[aaaa~] + result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + assert result == ~s[

    aaaa~

    ] + end +end -- cgit v1.2.3 From 7c8003c3fcdcab075b9722ab236bf2d1d0e0e8cd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 15 Mar 2020 21:00:12 +0300 Subject: [#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 | 127 ++++++++++++++++++------- lib/pleroma/thread_mute.ex | 37 +++++-- lib/pleroma/user.ex | 50 ++++++++-- lib/pleroma/user_relationship.ex | 9 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 8 +- test/notification_test.exs | 112 ++++++++++++++++++++-- 7 files changed, 291 insertions(+), 62 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 + 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 = - activity - |> get_notified_from_activity() - |> Enum.map(&create_notification(activity, &1)) + Enum.map(potential_receivers, fn user -> + do_send = user in enabled_receivers + create_notification(activity, user, do_send) + end) {:ok, notifications} end - def create_notifications(_), do: {:ok, []} - # 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: {[], []} + + @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 - def get_notified_from_activity(_, _local_only), do: [] + @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 -- cgit v1.2.3 From 0ac6e296549f43e553bdd2350050efcf95d3b6fa Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 15 Mar 2020 15:45:57 +0100 Subject: static_fe: Sanitize HTML in posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note: Seems to have different sanitization with TwitterCard generator giving the following: --- lib/pleroma/web/static_fe/static_fe_controller.ex | 9 ++++++++- test/web/static_fe/static_fe_controller_test.exs | 13 +++++++++++++ 2 files changed, 21 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 5027d5c23..0b77f949c 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -58,10 +58,17 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do _ -> data["url"] || data["external_url"] || data["id"] end + content = + if data["content"] do + Pleroma.HTML.filter_tags(data["content"]) + else + nil + end + %{ user: user, title: get_title(activity.object), - content: data["content"] || nil, + content: content, attachment: data["attachment"], link: link, published: data["published"], diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index a072cc78f..c3d2ae3b4 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -92,6 +92,19 @@ test "single notice page", %{conn: conn, user: user} do assert html =~ "testing a thing!" end + test "filters HTML tags", %{conn: conn} do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => ""}) + + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/notice/#{activity.id}") + + html = html_response(conn, 200) + assert html =~ ~s[<script>alert('xss')</script>] + end + test "shows the whole thread", %{conn: conn, user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "space: the final frontier"}) -- cgit v1.2.3 From acb016397ea82a6365f5ba30ae27d2094cd8ab7e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 15 Mar 2020 15:58:26 +0100 Subject: mix.lock: [minor] last hash appended --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index 1b4fbc927..62e14924a 100644 --- a/mix.lock +++ b/mix.lock @@ -4,10 +4,10 @@ "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, - "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, + "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, + "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, "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"]}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, -- cgit v1.2.3 From 8176ca9e4026d2de4fa0ab385b8c3f77e7f81daf Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 15 Mar 2020 17:00:54 +0100 Subject: static_fe: Sanitize HTML in users --- lib/pleroma/user.ex | 24 ++++++++++++++++++++++ lib/pleroma/web/activity_pub/views/user_view.ex | 7 +------ lib/pleroma/web/admin_api/views/account_view.ex | 4 ++-- lib/pleroma/web/mastodon_api/views/account_view.ex | 19 ++++------------- lib/pleroma/web/static_fe/static_fe_controller.ex | 4 ++-- 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index db510d957..911dde6e2 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.HTML alias Pleroma.Keys alias Pleroma.Notification alias Pleroma.Object @@ -2032,4 +2033,27 @@ def set_invisible(user, invisible) do |> validate_required([:invisible]) |> update_and_set_cache() end + + def sanitize_html(%User{} = user) do + sanitize_html(user, nil) + end + + # User data that mastodon isn't filtering (treated as plaintext): + # - field name + # - display name + def sanitize_html(%User{} = user, filter) do + fields = + user + |> User.fields() + |> Enum.map(fn %{"name" => name, "value" => value} -> + %{ + "name" => name, + "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) + } + end) + + user + |> Map.put(:bio, HTML.filter_tags(user.bio, filter)) + |> Map.put(:fields, fields) + end end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index c0358b678..bc21ac6c7 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -73,6 +73,7 @@ def render("user.json", %{user: user}) do {:ok, _, public_key} = Keys.keys_from_pem(user.keys) public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) public_key = :public_key.pem_encode([public_key]) + user = User.sanitize_html(user) endpoints = render("endpoints.json", %{user: user}) @@ -81,12 +82,6 @@ def render("user.json", %{user: user}) do fields = user |> User.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> - %{ - "name" => Pleroma.HTML.strip_tags(name), - "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } - end) |> Enum.map(&Map.put(&1, "type", "PropertyValue")) %{ diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 619390ef4..1e03849de 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.AdminAPI.AccountView do use Pleroma.Web, :view - alias Pleroma.HTML alias Pleroma.User alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.MediaProxy @@ -26,7 +25,8 @@ def render("index.json", %{users: users}) do def render("show.json", %{user: user}) do avatar = User.avatar_url(user) |> MediaProxy.url() - display_name = HTML.strip_tags(user.name || user.nickname) + display_name = Pleroma.HTML.strip_tags(user.name || user.nickname) + user = User.sanitize_html(user, FastSanitize.Sanitizer.StripTags) %{ "id" => user.id, diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 6dc191250..341dc2c91 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do use Pleroma.Web, :view - alias Pleroma.HTML alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView @@ -67,6 +66,7 @@ def render("relationships.json", %{user: user, targets: targets}) do end defp do_render("show.json", %{user: user} = opts) do + user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) display_name = user.name || user.nickname image = User.avatar_url(user) |> MediaProxy.url() @@ -100,17 +100,6 @@ defp do_render("show.json", %{user: user} = opts) do } end) - fields = - user - |> User.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> - %{ - "name" => name, - "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } - end) - - bio = HTML.filter_tags(user.bio, User.html_filter_policy(opts[:for])) relationship = render("relationship.json", %{user: opts[:for], target: user}) %{ @@ -123,17 +112,17 @@ defp do_render("show.json", %{user: user} = opts) do followers_count: followers_count, following_count: following_count, statuses_count: user.note_count, - note: bio || "", + note: user.bio || "", url: User.profile_url(user), avatar: image, avatar_static: image, header: header, header_static: header, emojis: emojis, - fields: fields, + fields: user.fields, bot: bot, source: %{ - note: HTML.strip_tags((user.bio || "") |> String.replace("
    ", "\n")), + note: Pleroma.HTML.strip_tags((user.bio || "") |> String.replace("
    ", "\n")), sensitive: false, fields: user.raw_fields, pleroma: %{ diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 0b77f949c..7f9464268 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -66,7 +66,7 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do end %{ - user: user, + user: User.sanitize_html(user), title: get_title(activity.object), content: content, attachment: data["attachment"], @@ -120,7 +120,7 @@ def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do next_page_id = List.last(timeline) && List.last(timeline).id render(conn, "profile.html", %{ - user: user, + user: User.sanitize_html(user), timeline: timeline, prev_page_id: prev_page_id, next_page_id: next_page_id, -- cgit v1.2.3 From 26e2076659450361b4fd4252c7a7b838099c442b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 10 Mar 2020 18:11:48 +0300 Subject: fix for feed page pagination --- lib/pleroma/web/controller_helper.ex | 7 +++-- lib/pleroma/web/feed/tag_controller.ex | 4 +-- lib/pleroma/web/feed/user_controller.ex | 4 +-- test/web/feed/tag_controller_test.exs | 52 +++++++++++++++++++++++++-------- test/web/feed/user_controller_test.exs | 15 +++++++++- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index c9a3a2585..ad293cda9 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -87,7 +87,8 @@ def try_render(conn, _, _) do render_error(conn, :not_implemented, "Can't display this activity") end - @spec put_in_if_exist(map(), atom() | String.t(), any) :: map() - def put_in_if_exist(map, _key, nil), do: map - def put_in_if_exist(map, key, value), do: put_in(map, key, value) + @spec put_if_exist(map(), atom() | String.t(), any) :: map() + def put_if_exist(map, _key, nil), do: map + + def put_if_exist(map, key, value), do: Map.put(map, key, value) end diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 75c9ea17e..904047b12 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -9,14 +9,14 @@ defmodule Pleroma.Web.Feed.TagController do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.Feed.FeedView - import Pleroma.Web.ControllerHelper, only: [put_in_if_exist: 3] + import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] def feed(conn, %{"tag" => raw_tag} = params) do {format, tag} = parse_tag(raw_tag) activities = %{"type" => ["Create"], "tag" => tag} - |> put_in_if_exist("max_id", params["max_id"]) + |> put_if_exist("max_id", params["max_id"]) |> ActivityPub.fetch_public_activities() conn diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 9ba602d9f..9ffb3b9be 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.Feed.UserController do alias Pleroma.Web.ActivityPub.ActivityPubController alias Pleroma.Web.Feed.FeedView - import Pleroma.Web.ControllerHelper, only: [put_in_if_exist: 3] + import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] plug(Pleroma.Plugs.SetFormatPlug when action in [:feed_redirect]) @@ -46,7 +46,7 @@ def feed(conn, %{"nickname" => nickname} = params) do "type" => ["Create"], "actor_id" => user.ap_id } - |> put_in_if_exist("max_id", params["max_id"]) + |> put_if_exist("max_id", params["max_id"]) |> ActivityPub.fetch_public_activities() conn diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index 5950605e8..c774bd420 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -8,6 +8,8 @@ defmodule Pleroma.Web.Feed.TagControllerTest do import Pleroma.Factory import SweetXml + alias Pleroma.Object + alias Pleroma.Web.CommonAPI alias Pleroma.Web.Feed.FeedView clear_config([:feed]) @@ -19,9 +21,9 @@ test "gets a feed (ATOM)", %{conn: conn} do ) user = insert(:user) - {:ok, activity1} = Pleroma.Web.CommonAPI.post(user, %{"status" => "yeah #PleromaArt"}) + {:ok, activity1} = CommonAPI.post(user, %{"status" => "yeah #PleromaArt"}) - object = Pleroma.Object.normalize(activity1) + object = Object.normalize(activity1) object_data = Map.put(object.data, "attachment", [ @@ -41,10 +43,9 @@ test "gets a feed (ATOM)", %{conn: conn} do |> Ecto.Changeset.change(data: object_data) |> Pleroma.Repo.update() - {:ok, _activity2} = - Pleroma.Web.CommonAPI.post(user, %{"status" => "42 This is :moominmamma #PleromaArt"}) + {:ok, activity2} = CommonAPI.post(user, %{"status" => "42 This is :moominmamma #PleromaArt"}) - {:ok, _activity3} = Pleroma.Web.CommonAPI.post(user, %{"status" => "This is :moominmamma"}) + {:ok, _activity3} = CommonAPI.post(user, %{"status" => "This is :moominmamma"}) response = conn @@ -63,6 +64,20 @@ test "gets a feed (ATOM)", %{conn: conn} do assert xpath(xml, ~x"//feed/entry/author/name/text()"ls) == [user.nickname, user.nickname] assert xpath(xml, ~x"//feed/entry/author/id/text()"ls) == [user.ap_id, user.ap_id] + + resp = + conn + |> put_req_header("content-type", "application/atom+xml") + |> get("/tags/pleromaart.atom", %{"max_id" => activity2.id}) + |> response(200) + + xml = parse(resp) + + assert xpath(xml, ~x"//feed/title/text()") == '#pleromaart' + + assert xpath(xml, ~x"//feed/entry/title/text()"l) == [ + 'yeah #PleromaArt' + ] end test "gets a feed (RSS)", %{conn: conn} do @@ -72,9 +87,9 @@ test "gets a feed (RSS)", %{conn: conn} do ) user = insert(:user) - {:ok, activity1} = Pleroma.Web.CommonAPI.post(user, %{"status" => "yeah #PleromaArt"}) + {:ok, activity1} = CommonAPI.post(user, %{"status" => "yeah #PleromaArt"}) - object = Pleroma.Object.normalize(activity1) + object = Object.normalize(activity1) object_data = Map.put(object.data, "attachment", [ @@ -94,10 +109,9 @@ test "gets a feed (RSS)", %{conn: conn} do |> Ecto.Changeset.change(data: object_data) |> Pleroma.Repo.update() - {:ok, activity2} = - Pleroma.Web.CommonAPI.post(user, %{"status" => "42 This is :moominmamma #PleromaArt"}) + {:ok, activity2} = CommonAPI.post(user, %{"status" => "42 This is :moominmamma #PleromaArt"}) - {:ok, _activity3} = Pleroma.Web.CommonAPI.post(user, %{"status" => "This is :moominmamma"}) + {:ok, _activity3} = CommonAPI.post(user, %{"status" => "This is :moominmamma"}) response = conn @@ -131,8 +145,8 @@ test "gets a feed (RSS)", %{conn: conn} do "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4" ] - obj1 = Pleroma.Object.normalize(activity1) - obj2 = Pleroma.Object.normalize(activity2) + obj1 = Object.normalize(activity1) + obj2 = Object.normalize(activity2) assert xpath(xml, ~x"//channel/item/description/text()"sl) == [ HtmlEntities.decode(FeedView.activity_content(obj2)), @@ -150,5 +164,19 @@ test "gets a feed (RSS)", %{conn: conn} do assert xpath(xml, ~x"//channel/description/text()"s) == "These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse." + + resp = + conn + |> put_req_header("content-type", "application/atom+xml") + |> get("/tags/pleromaart", %{"max_id" => activity2.id}) + |> response(200) + + xml = parse(resp) + + assert xpath(xml, ~x"//channel/title/text()") == '#pleromaart' + + assert xpath(xml, ~x"//channel/item/title/text()"l) == [ + 'yeah #PleromaArt' + ] end end diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index 00c50f003..fd59ca892 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -54,7 +54,7 @@ test "gets a feed", %{conn: conn} do } ) - _note_activity2 = insert(:note_activity, note: note2) + note_activity2 = insert(:note_activity, note: note2) object = Object.normalize(note_activity) resp = @@ -70,6 +70,19 @@ test "gets a feed", %{conn: conn} do assert activity_titles == ['42 This...', 'This is...'] assert resp =~ object.data["content"] + + resp = + conn + |> put_req_header("content-type", "application/atom+xml") + |> get("/users/#{user.nickname}/feed", %{"max_id" => note_activity2.id}) + |> response(200) + + activity_titles = + resp + |> SweetXml.parse() + |> SweetXml.xpath(~x"//entry/title/text()"l) + + assert activity_titles == ['This is...'] end test "returns 404 for a missing feed", %{conn: conn} do -- cgit v1.2.3 From 91870c8995c154839d611bcce6d038f72ef0665c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 13 Mar 2020 17:41:26 +0300 Subject: adding rss for user feed --- lib/pleroma/web/feed/user_controller.ex | 13 +++- lib/pleroma/web/router.ex | 2 +- .../web/templates/feed/feed/_activity.atom.eex | 50 +++++++++++++++ .../web/templates/feed/feed/_activity.rss.eex | 49 ++++++++++++++ .../web/templates/feed/feed/_activity.xml.eex | 50 --------------- .../web/templates/feed/feed/_author.atom.eex | 17 +++++ .../web/templates/feed/feed/_author.rss.eex | 17 +++++ .../web/templates/feed/feed/_author.xml.eex | 17 ----- lib/pleroma/web/templates/feed/feed/user.atom.eex | 24 +++++++ lib/pleroma/web/templates/feed/feed/user.rss.eex | 20 ++++++ lib/pleroma/web/templates/feed/feed/user.xml.eex | 24 ------- test/web/feed/user_controller_test.exs | 74 ++++++++++++++++++++-- 12 files changed, 259 insertions(+), 98 deletions(-) create mode 100644 lib/pleroma/web/templates/feed/feed/_activity.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_activity.rss.eex delete mode 100644 lib/pleroma/web/templates/feed/feed/_activity.xml.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_author.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/_author.rss.eex delete mode 100644 lib/pleroma/web/templates/feed/feed/_author.xml.eex create mode 100644 lib/pleroma/web/templates/feed/feed/user.atom.eex create mode 100644 lib/pleroma/web/templates/feed/feed/user.rss.eex delete mode 100644 lib/pleroma/web/templates/feed/feed/user.xml.eex diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 9ffb3b9be..e27f85929 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -40,6 +40,15 @@ def feed_redirect(conn, %{"nickname" => nickname}) do end def feed(conn, %{"nickname" => nickname} = params) do + format = get_format(conn) + + format = + if format in ["rss", "atom"] do + format + else + "atom" + end + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do activities = %{ @@ -50,9 +59,9 @@ def feed(conn, %{"nickname" => nickname} = params) do |> ActivityPub.fetch_public_activities() conn - |> put_resp_content_type("application/atom+xml") + |> put_resp_content_type("application/#{format}+xml") |> put_view(FeedView) - |> render("user.xml", + |> render("user.#{format}", user: user, activities: activities, feed_config: Pleroma.Config.get([:feed]) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e4e3ee704..3f36f6c1a 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -513,7 +513,7 @@ defmodule Pleroma.Web.Router do end pipeline :ostatus do - plug(:accepts, ["html", "xml", "atom", "activity+json", "json"]) + plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) plug(Pleroma.Plugs.StaticFEPlug) end diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex new file mode 100644 index 000000000..ac8a75009 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -0,0 +1,50 @@ + + http://activitystrea.ms/schema/1.0/note + http://activitystrea.ms/schema/1.0/post + <%= @data["id"] %> + <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> + <%= activity_content(@object) %> + <%= @data["published"] %> + <%= @data["published"] %> + + <%= activity_context(@activity) %> + + + + <%= if @data["summary"] do %> + <%= @data["summary"] %> + <% end %> + + <%= if @activity.local do %> + + + <% else %> + + <% end %> + + <%= for tag <- @data["tag"] || [] do %> + + <% end %> + + <%= for attachment <- @data["attachment"] || [] do %> + + <% end %> + + <%= if @data["inReplyTo"] do %> + + <% end %> + + <%= for id <- @activity.recipients do %> + <%= if id == Pleroma.Constants.as_public() do %> + + <% else %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + + <% end %> + <% end %> + <% end %> + + <%= for {emoji, file} <- @data["emoji"] || %{} do %> + + <% end %> + diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex new file mode 100644 index 000000000..a4dbed638 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -0,0 +1,49 @@ + + http://activitystrea.ms/schema/1.0/note + http://activitystrea.ms/schema/1.0/post + <%= @data["id"] %> + <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> + <%= activity_content(@object) %> + <%= @data["published"] %> + <%= @data["published"] %> + + <%= activity_context(@activity) %> + + <%= activity_context(@activity) %> + + <%= if @data["summary"] do %> + <%= @data["summary"] %> + <% end %> + + <%= if @activity.local do %> + <%= @data["id"] %> + <% else %> + <%= @data["external_url"] %> + <% end %> + + <%= for tag <- @data["tag"] || [] do %> + + <% end %> + + <%= for attachment <- @data["attachment"] || [] do %> + <%= attachment_href(attachment) %> + <% end %> + + <%= if @data["inReplyTo"] do %> + + <% end %> + + <%= for id <- @activity.recipients do %> + <%= if id == Pleroma.Constants.as_public() do %> + http://activityschema.org/collection/public + <% else %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <%= id %> + <% end %> + <% end %> + <% end %> + + <%= for {emoji, file} <- @data["emoji"] || %{} do %> + <%= file %> + <% end %> + diff --git a/lib/pleroma/web/templates/feed/feed/_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_activity.xml.eex deleted file mode 100644 index ac8a75009..000000000 --- a/lib/pleroma/web/templates/feed/feed/_activity.xml.eex +++ /dev/null @@ -1,50 +0,0 @@ - - http://activitystrea.ms/schema/1.0/note - http://activitystrea.ms/schema/1.0/post - <%= @data["id"] %> - <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> - <%= activity_content(@object) %> - <%= @data["published"] %> - <%= @data["published"] %> - - <%= activity_context(@activity) %> - - - - <%= if @data["summary"] do %> - <%= @data["summary"] %> - <% end %> - - <%= if @activity.local do %> - - - <% else %> - - <% end %> - - <%= for tag <- @data["tag"] || [] do %> - - <% end %> - - <%= for attachment <- @data["attachment"] || [] do %> - - <% end %> - - <%= if @data["inReplyTo"] do %> - - <% end %> - - <%= for id <- @activity.recipients do %> - <%= if id == Pleroma.Constants.as_public() do %> - - <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> - - <% end %> - <% end %> - <% end %> - - <%= for {emoji, file} <- @data["emoji"] || %{} do %> - - <% end %> - diff --git a/lib/pleroma/web/templates/feed/feed/_author.atom.eex b/lib/pleroma/web/templates/feed/feed/_author.atom.eex new file mode 100644 index 000000000..25cbffada --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_author.atom.eex @@ -0,0 +1,17 @@ + + <%= @user.ap_id %> + http://activitystrea.ms/schema/1.0/person + <%= @user.ap_id %> + <%= @user.nickname %> + <%= @user.name %> + <%= escape(@user.bio) %> + <%= escape(@user.bio) %> + <%= @user.nickname %> + + <%= if User.banner_url(@user) do %> + + <% end %> + <%= if @user.local do %> + true + <% end %> + diff --git a/lib/pleroma/web/templates/feed/feed/_author.rss.eex b/lib/pleroma/web/templates/feed/feed/_author.rss.eex new file mode 100644 index 000000000..526aeddcf --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/_author.rss.eex @@ -0,0 +1,17 @@ + + <%= @user.ap_id %> + http://activitystrea.ms/schema/1.0/person + <%= @user.ap_id %> + <%= @user.nickname %> + <%= @user.name %> + <%= escape(@user.bio) %> + <%= escape(@user.bio) %> + <%= @user.nickname %> + <%= User.avatar_url(@user) %> + <%= if User.banner_url(@user) do %> + <%= User.banner_url(@user) %> + <% end %> + <%= if @user.local do %> + true + <% end %> + diff --git a/lib/pleroma/web/templates/feed/feed/_author.xml.eex b/lib/pleroma/web/templates/feed/feed/_author.xml.eex deleted file mode 100644 index 25cbffada..000000000 --- a/lib/pleroma/web/templates/feed/feed/_author.xml.eex +++ /dev/null @@ -1,17 +0,0 @@ - - <%= @user.ap_id %> - http://activitystrea.ms/schema/1.0/person - <%= @user.ap_id %> - <%= @user.nickname %> - <%= @user.name %> - <%= escape(@user.bio) %> - <%= escape(@user.bio) %> - <%= @user.nickname %> - - <%= if User.banner_url(@user) do %> - - <% end %> - <%= if @user.local do %> - true - <% end %> - diff --git a/lib/pleroma/web/templates/feed/feed/user.atom.eex b/lib/pleroma/web/templates/feed/feed/user.atom.eex new file mode 100644 index 000000000..c6acd848f --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/user.atom.eex @@ -0,0 +1,24 @@ + + + + <%= user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %> + <%= @user.nickname <> "'s timeline" %> + <%= most_recent_update(@activities, @user) %> + <%= logo(@user) %> + + + <%= render @view_module, "_author.atom", assigns %> + + <%= if last_activity(@activities) do %> + + <% end %> + + <%= for activity <- @activities do %> + <%= render @view_module, "_activity.atom", Map.merge(assigns, prepare_activity(activity)) %> + <% end %> + diff --git a/lib/pleroma/web/templates/feed/feed/user.rss.eex b/lib/pleroma/web/templates/feed/feed/user.rss.eex new file mode 100644 index 000000000..d69120480 --- /dev/null +++ b/lib/pleroma/web/templates/feed/feed/user.rss.eex @@ -0,0 +1,20 @@ + + + + <%= user_feed_url(@conn, :feed, @user.nickname) <> ".rss" %> + <%= @user.nickname <> "'s timeline" %> + <%= most_recent_update(@activities, @user) %> + <%= logo(@user) %> + <%= '#{user_feed_url(@conn, :feed, @user.nickname)}.rss' %> + + <%= render @view_module, "_author.rss", assigns %> + + <%= if last_activity(@activities) do %> + <%= '#{user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}' %> + <% end %> + + <%= for activity <- @activities do %> + <%= render @view_module, "_activity.rss", Map.merge(assigns, prepare_activity(activity)) %> + <% end %> + + diff --git a/lib/pleroma/web/templates/feed/feed/user.xml.eex b/lib/pleroma/web/templates/feed/feed/user.xml.eex deleted file mode 100644 index d274c08ae..000000000 --- a/lib/pleroma/web/templates/feed/feed/user.xml.eex +++ /dev/null @@ -1,24 +0,0 @@ - - - - <%= user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %> - <%= @user.nickname <> "'s timeline" %> - <%= most_recent_update(@activities, @user) %> - <%= logo(@user) %> - - - <%= render @view_module, "_author.xml", assigns %> - - <%= if last_activity(@activities) do %> - - <% end %> - - <%= for activity <- @activities do %> - <%= render @view_module, "_activity.xml", Map.merge(assigns, prepare_activity(activity)) %> - <% end %> - diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index fd59ca892..e3dfa88f1 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -19,7 +19,7 @@ defmodule Pleroma.Web.Feed.UserControllerTest do describe "feed" do clear_config([:feed]) - test "gets a feed", %{conn: conn} do + test "gets an atom feed", %{conn: conn} do Config.put( [:feed, :post_title], %{max_length: 10, omission: "..."} @@ -59,7 +59,7 @@ test "gets a feed", %{conn: conn} do resp = conn - |> put_req_header("content-type", "application/atom+xml") + |> put_req_header("accept", "application/atom+xml") |> get(user_feed_path(conn, :feed, user.nickname)) |> response(200) @@ -73,7 +73,7 @@ test "gets a feed", %{conn: conn} do resp = conn - |> put_req_header("content-type", "application/atom+xml") + |> put_req_header("accept", "application/atom+xml") |> get("/users/#{user.nickname}/feed", %{"max_id" => note_activity2.id}) |> response(200) @@ -85,10 +85,76 @@ test "gets a feed", %{conn: conn} do assert activity_titles == ['This is...'] end + test "gets a rss feed", %{conn: conn} do + Pleroma.Config.put( + [:feed, :post_title], + %{max_length: 10, omission: "..."} + ) + + activity = insert(:note_activity) + + note = + insert(:note, + data: %{ + "content" => "This is :moominmamma: note ", + "attachment" => [ + %{ + "url" => [ + %{"mediaType" => "image/png", "href" => "https://pleroma.gov/image.png"} + ] + } + ], + "inReplyTo" => activity.data["id"] + } + ) + + note_activity = insert(:note_activity, note: note) + user = User.get_cached_by_ap_id(note_activity.data["actor"]) + + note2 = + insert(:note, + user: user, + data: %{ + "content" => "42 This is :moominmamma: note ", + "inReplyTo" => activity.data["id"] + } + ) + + note_activity2 = insert(:note_activity, note: note2) + object = Object.normalize(note_activity) + + resp = + conn + |> put_req_header("accept", "application/rss+xml") + |> get("/users/#{user.nickname}/feed.rss") + |> response(200) + + activity_titles = + resp + |> SweetXml.parse() + |> SweetXml.xpath(~x"//item/title/text()"l) + + assert activity_titles == ['42 This...', 'This is...'] + assert resp =~ object.data["content"] + + resp = + conn + |> put_req_header("accept", "application/atom+xml") + |> get("/users/#{user.nickname}/feed.rss", %{"max_id" => note_activity2.id}) + |> response(200) + + activity_titles = + resp + |> SweetXml.parse() + |> SweetXml.xpath(~x"//item/title/text()"l) + + assert activity_titles == ['This is...'] + end + test "returns 404 for a missing feed", %{conn: conn} do conn = conn - |> put_req_header("content-type", "application/atom+xml") + |> put_req_header("accept", "application/atom+xml") |> get(user_feed_path(conn, :feed, "nonexisting")) assert response(conn, 404) -- cgit v1.2.3 From 89e4b3ebbd433032a2687712c9c6684902fe4ebe Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 13 Mar 2020 17:58:14 +0300 Subject: fix for content-type header for tag feed --- lib/pleroma/web/feed/tag_controller.ex | 2 +- test/web/feed/tag_controller_test.exs | 22 ++++++++++++---------- test/web/feed/user_controller_test.exs | 4 ++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 904047b12..8133f8480 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -20,7 +20,7 @@ def feed(conn, %{"tag" => raw_tag} = params) do |> ActivityPub.fetch_public_activities() conn - |> put_resp_content_type("application/atom+xml") + |> put_resp_content_type("application/#{format}+xml") |> put_view(FeedView) |> render("tag.#{format}", activities: activities, diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index c774bd420..da1caf049 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -49,7 +49,7 @@ test "gets a feed (ATOM)", %{conn: conn} do response = conn - |> put_req_header("content-type", "application/atom+xml") + |> put_req_header("accept", "application/atom+xml") |> get(tag_feed_path(conn, :feed, "pleromaart.atom")) |> response(200) @@ -65,12 +65,13 @@ test "gets a feed (ATOM)", %{conn: conn} do assert xpath(xml, ~x"//feed/entry/author/name/text()"ls) == [user.nickname, user.nickname] assert xpath(xml, ~x"//feed/entry/author/id/text()"ls) == [user.ap_id, user.ap_id] - resp = + conn = conn - |> put_req_header("content-type", "application/atom+xml") + |> put_req_header("accept", "application/atom+xml") |> get("/tags/pleromaart.atom", %{"max_id" => activity2.id}) - |> response(200) + assert get_resp_header(conn, "content-type") == ["application/atom+xml; charset=utf-8"] + resp = response(conn, 200) xml = parse(resp) assert xpath(xml, ~x"//feed/title/text()") == '#pleromaart' @@ -115,7 +116,7 @@ test "gets a feed (RSS)", %{conn: conn} do response = conn - |> put_req_header("content-type", "application/rss+xml") + |> put_req_header("accept", "application/rss+xml") |> get(tag_feed_path(conn, :feed, "pleromaart.rss")) |> response(200) @@ -155,7 +156,7 @@ test "gets a feed (RSS)", %{conn: conn} do response = conn - |> put_req_header("content-type", "application/atom+xml") + |> put_req_header("accept", "application/rss+xml") |> get(tag_feed_path(conn, :feed, "pleromaart")) |> response(200) @@ -165,12 +166,13 @@ test "gets a feed (RSS)", %{conn: conn} do assert xpath(xml, ~x"//channel/description/text()"s) == "These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse." - resp = + conn = conn - |> put_req_header("content-type", "application/atom+xml") - |> get("/tags/pleromaart", %{"max_id" => activity2.id}) - |> response(200) + |> put_req_header("accept", "application/rss+xml") + |> get("/tags/pleromaart.rss", %{"max_id" => activity2.id}) + assert get_resp_header(conn, "content-type") == ["application/rss+xml; charset=utf-8"] + resp = response(conn, 200) xml = parse(resp) assert xpath(xml, ~x"//channel/title/text()") == '#pleromaart' diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index e3dfa88f1..5c91c33d8 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -19,7 +19,7 @@ defmodule Pleroma.Web.Feed.UserControllerTest do describe "feed" do clear_config([:feed]) - test "gets an atom feed", %{conn: conn} do + test "gets a feed", %{conn: conn} do Config.put( [:feed, :post_title], %{max_length: 10, omission: "..."} @@ -139,7 +139,7 @@ test "gets a rss feed", %{conn: conn} do resp = conn - |> put_req_header("accept", "application/atom+xml") + |> put_req_header("accept", "application/rss+xml") |> get("/users/#{user.nickname}/feed.rss", %{"max_id" => note_activity2.id}) |> response(200) -- cgit v1.2.3 From ba90a6d3e5c1f0a69044ec68748aeed870a085c0 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 14:01:35 +0300 Subject: removing from descriptions.exs deprecated settings --- config/benchmark.exs | 2 -- config/description.exs | 32 -------------------------------- 2 files changed, 34 deletions(-) diff --git a/config/benchmark.exs b/config/benchmark.exs index 84c6782a2..ff59395cf 100644 --- a/config/benchmark.exs +++ b/config/benchmark.exs @@ -61,8 +61,6 @@ config :web_push_encryption, :http_client, Pleroma.Web.WebPushHttpClientMock -config :pleroma_job_queue, disabled: true - config :pleroma, Pleroma.ScheduledActivity, daily_user_limit: 2, total_user_limit: 3, diff --git a/config/description.exs b/config/description.exs index c0e403b2e..732c76734 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1780,25 +1780,6 @@ } ] }, - %{ - group: :pleroma_job_queue, - key: :queues, - type: :group, - description: "[Deprecated] Replaced with `Oban`/`:queues` (keeping the same format)" - }, - %{ - group: :pleroma, - key: Pleroma.Web.Federator.RetryQueue, - type: :group, - description: "[Deprecated] See `Oban` and `:workers` sections for configuration notes", - children: [ - %{ - key: :max_retries, - type: :integer, - description: "[Deprecated] Replaced as `Oban`/`:queues`/`:outgoing_federation` value" - } - ] - }, %{ group: :pleroma, key: Oban, @@ -2577,19 +2558,6 @@ } ] }, - %{ - group: :tesla, - type: :group, - description: "Tesla settings", - children: [ - %{ - key: :adapter, - type: :module, - description: "Tesla adapter", - suggestions: [Tesla.Adapter.Hackney] - } - ] - }, %{ group: :pleroma, key: :chat, -- cgit v1.2.3 From dc2ec84c0fe41e8af3ee5b961fa86c66c483e5b4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 14:19:36 +0300 Subject: warnings fix --- test/plugs/rate_limiter_test.exs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs index 81e2009c8..c6e494c13 100644 --- a/test/plugs/rate_limiter_test.exs +++ b/test/plugs/rate_limiter_test.exs @@ -51,7 +51,7 @@ test "it restricts based on config values" do Config.put([:rate_limit, limiter_name], {scale, limit}) plug_opts = RateLimiter.init(name: limiter_name) - conn = conn(:get, "/") + conn = build_conn(:get, "/") for i <- 1..5 do conn = RateLimiter.call(conn, plug_opts) @@ -65,7 +65,7 @@ test "it restricts based on config values" do Process.sleep(50) - conn = conn(:get, "/") + conn = build_conn(:get, "/") conn = RateLimiter.call(conn, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) @@ -85,7 +85,7 @@ test "`bucket_name` option overrides default bucket name" do base_bucket_name = "#{limiter_name}:group1" plug_opts = RateLimiter.init(name: limiter_name, bucket_name: base_bucket_name) - conn = conn(:get, "/") + conn = build_conn(:get, "/") RateLimiter.call(conn, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn, base_bucket_name, plug_opts) @@ -99,9 +99,9 @@ test "`params` option allows different queries to be tracked independently" do plug_opts = RateLimiter.init(name: limiter_name, params: ["id"]) - conn = conn(:get, "/?id=1") + conn = build_conn(:get, "/?id=1") conn = Plug.Conn.fetch_query_params(conn) - conn_2 = conn(:get, "/?id=2") + conn_2 = build_conn(:get, "/?id=2") RateLimiter.call(conn, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) @@ -120,9 +120,9 @@ test "it supports combination of options modifying bucket name" do id = "100" - conn = conn(:get, "/?id=#{id}") + conn = build_conn(:get, "/?id=#{id}") conn = Plug.Conn.fetch_query_params(conn) - conn_2 = conn(:get, "/?id=#{101}") + conn_2 = build_conn(:get, "/?id=#{101}") RateLimiter.call(conn, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn, base_bucket_name, plug_opts) @@ -138,8 +138,8 @@ test "are restricted based on remote IP" do plug_opts = RateLimiter.init(name: limiter_name) - conn = %{conn(:get, "/") | remote_ip: {127, 0, 0, 2}} - conn_2 = %{conn(:get, "/") | remote_ip: {127, 0, 0, 3}} + conn = %{build_conn(:get, "/") | remote_ip: {127, 0, 0, 2}} + conn_2 = %{build_conn(:get, "/") | remote_ip: {127, 0, 0, 3}} for i <- 1..5 do conn = RateLimiter.call(conn, plug_opts) @@ -179,7 +179,7 @@ test "can have limits separate from unauthenticated connections" do plug_opts = RateLimiter.init(name: limiter_name) user = insert(:user) - conn = conn(:get, "/") |> assign(:user, user) + conn = build_conn(:get, "/") |> assign(:user, user) for i <- 1..5 do conn = RateLimiter.call(conn, plug_opts) @@ -201,10 +201,10 @@ test "different users are counted independently" do plug_opts = RateLimiter.init(name: limiter_name) user = insert(:user) - conn = conn(:get, "/") |> assign(:user, user) + conn = build_conn(:get, "/") |> assign(:user, user) user_2 = insert(:user) - conn_2 = conn(:get, "/") |> assign(:user, user_2) + conn_2 = build_conn(:get, "/") |> assign(:user, user_2) for i <- 1..5 do conn = RateLimiter.call(conn, plug_opts) @@ -230,8 +230,8 @@ test "doesn't crash due to a race condition when multiple requests are made at t opts = RateLimiter.init(name: limiter_name) - conn = conn(:get, "/") - conn_2 = conn(:get, "/") + conn = build_conn(:get, "/") + conn_2 = build_conn(:get, "/") %Task{pid: pid1} = task1 = -- cgit v1.2.3 From 35471205f862fa069c6d87aefc1d827c9fab6e08 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 15:47:25 +0300 Subject: temp fix for `:gun.info` MatchError --- lib/pleroma/pool/connections.ex | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 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) - - host = - case :inet.ntoa(host) do - {:error, :einval} -> host - ip -> ip + # 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 + 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, -- cgit v1.2.3 From bf474ca3c154544b54720ea23c06191e68f32522 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 16:23:49 +0300 Subject: fix --- lib/pleroma/pool/connections.ex | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 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) - - 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 + 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 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, -- cgit v1.2.3 From 7829de6da4b2782a4ae2124ff05787d500bbb990 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Mon, 16 Mar 2020 17:26:28 +0300 Subject: gitlab: create templates for bug reports and release MRs --- .gitlab/issue_templates/Bug.md | 20 ++++++++++++++++++++ .gitlab/merge_request_templates/Release.md | 5 +++++ 2 files changed, 25 insertions(+) create mode 100644 .gitlab/issue_templates/Bug.md create mode 100644 .gitlab/merge_request_templates/Release.md diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md new file mode 100644 index 000000000..66fbc510e --- /dev/null +++ b/.gitlab/issue_templates/Bug.md @@ -0,0 +1,20 @@ + + +### Environment + +* Installation type: + - [ ] OTP + - [ ] From source +* Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): +* Elixir version (`elixir -v` for from source installations, N/A for OTP): +* Operating system: +* PostgreSQL version (`postgres -V`): + + +### Bug description diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md new file mode 100644 index 000000000..237f74e00 --- /dev/null +++ b/.gitlab/merge_request_templates/Release.md @@ -0,0 +1,5 @@ +### Release checklist +* [ ] Bump version in `mix.exs` +* [ ] Compile a changelog +* [ ] Create an MR with an announcement to pleroma.social +* [ ] Tag the release -- cgit v1.2.3 From 8dd01b24d2e64b61992ff9c6895c98f7f7052c6c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Mar 2020 11:44:53 -0500 Subject: Improve documentation of mrf_object_age --- 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 2629385da..4012fe9b1 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -138,7 +138,8 @@ config :pleroma, :mrf_user_allowlist, ``` #### :mrf_object_age -* `threshold`: Required age (in seconds) of a post before actions are taken. +* `threshold`: Required time offset (in seconds) compared to your server clock of an incoming post before actions are taken. + e.g., A value of 900 results in any post with a timestamp older than 15 minutes will be acted upon. * `actions`: A list of actions to apply to the post: * `:delist` removes the post from public timelines * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines -- cgit v1.2.3 From f3791add99014c4e5f1c51c06f8ace84b254cec2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 16 Mar 2020 20:05:21 +0300 Subject: removing with_move parameter --- CHANGELOG.md | 3 +++ docs/API/differences_in_mastoapi_responses.md | 1 - lib/pleroma/notification.ex | 9 --------- lib/pleroma/web/mastodon_api/mastodon_api.ex | 1 - test/notification_test.exs | 8 ++------ test/web/activity_pub/activity_pub_test.exs | 6 ++---- .../mastodon_api/controllers/notification_controller_test.exs | 8 ++------ test/web/mastodon_api/views/notification_view_test.exs | 2 +- 8 files changed, 10 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4168086e2..e3be2ea08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - **Breaking:** BBCode and Markdown formatters will no longer return any `\n` and only use `
    ` for newlines +### Removed +- **Breaking:** removed `with_move` parameter from notifications timeline. + ## [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/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 476a4a2bf..b12d3092c 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -117,7 +117,6 @@ The `type` value is `pleroma:emoji_reaction`. Has these fields: Accepts additional parameters: - `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`. -- `with_move`: boolean, when set to `true` will include Move notifications. `false` by default. ## POST `/api/v1/statuses` diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 60dba3434..3ef3b3f58 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -77,7 +77,6 @@ def for_user_query(user, opts \\ %{}) do |> exclude_notification_muted(user, exclude_notification_muted_opts) |> exclude_blocked(user, exclude_blocked_opts) |> exclude_visibility(opts) - |> exclude_move(opts) end defp exclude_blocked(query, user, opts) do @@ -107,14 +106,6 @@ defp exclude_notification_muted(query, user, opts) do |> where([n, a, o, tm], is_nil(tm.user_id)) end - defp exclude_move(query, %{with_move: true}) do - query - end - - defp exclude_move(query, _opts) do - where(query, [n, a], fragment("?->>'type' != 'Move'", a.data)) - end - @valid_visibilities ~w[direct unlisted public private] defp exclude_visibility(query, %{exclude_visibilities: visibility}) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 3fe2be521..a2dc9bc71 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -72,7 +72,6 @@ defp cast_params(params) do exclude_visibilities: {:array, :string}, reblogs: :boolean, with_muted: :boolean, - with_move: :boolean, account_ap_id: :string } diff --git a/test/notification_test.exs b/test/notification_test.exs index 56a581810..d240ede94 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -667,17 +667,13 @@ test "move activity generates a notification" do Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user) ObanHelpers.perform_all() - assert [] = Notification.for_user(follower) - assert [ %{ activity: %{ data: %{"type" => "Move", "actor" => ^old_ap_id, "target" => ^new_ap_id} } } - ] = Notification.for_user(follower, %{with_move: true}) - - assert [] = Notification.for_user(other_follower) + ] = Notification.for_user(follower) assert [ %{ @@ -685,7 +681,7 @@ test "move activity generates a notification" do data: %{"type" => "Move", "actor" => ^old_ap_id, "target" => ^new_ap_id} } } - ] = Notification.for_user(other_follower, %{with_move: true}) + ] = Notification.for_user(other_follower) end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 3dd3dd04d..d86c8260e 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1955,11 +1955,9 @@ test "create" do activity = %Activity{activity | object: nil} - assert [%Notification{activity: ^activity}] = - Notification.for_user(follower, %{with_move: true}) + assert [%Notification{activity: ^activity}] = Notification.for_user(follower) - assert [%Notification{activity: ^activity}] = - Notification.for_user(follower_move_opted_out, %{with_move: true}) + assert [%Notification{activity: ^activity}] = Notification.for_user(follower_move_opted_out) end test "old user must be in the new user's `also_known_as` list" do diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index d452ddbdd..dbe9a7fd7 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -407,7 +407,7 @@ test "see notifications after muting user with notifications and with_muted para assert length(json_response(conn, 200)) == 1 end - test "see move notifications with `with_move` parameter" do + test "see move notifications" do old_user = insert(:user) new_user = insert(:user, also_known_as: [old_user.ap_id]) %{user: follower, conn: conn} = oauth_access(["read:notifications"]) @@ -416,11 +416,7 @@ test "see move notifications with `with_move` parameter" do Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user) Pleroma.Tests.ObanHelpers.perform_all() - ret_conn = get(conn, "/api/v1/notifications") - - assert json_response(ret_conn, 200) == [] - - conn = get(conn, "/api/v1/notifications", %{"with_move" => "true"}) + conn = get(conn, "/api/v1/notifications") assert length(json_response(conn, 200)) == 1 end diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 4df9c3c03..d04c3022f 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -120,7 +120,7 @@ test "Move notification" do old_user = refresh_record(old_user) new_user = refresh_record(new_user) - [notification] = Notification.for_user(follower, %{with_move: true}) + [notification] = Notification.for_user(follower) expected = %{ id: to_string(notification.id), -- cgit v1.2.3 From d198e7fa2a0c92be4e99c5a765de85096d318bfe Mon Sep 17 00:00:00 2001 From: eugenijm Date: Tue, 28 Jan 2020 09:47:59 +0300 Subject: 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 ++++++++ lib/pleroma/web/admin_api/admin_api_controller.ex | 33 +++++++++++++++++++++++ lib/pleroma/web/router.ex | 1 + test/web/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) -- cgit v1.2.3 From 13cce9c0debbf9a80ed5da26cb34ca563e5e1417 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Fri, 31 Jan 2020 21:07:46 +0300 Subject: 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 +++++++++++++++++++++- lib/pleroma/web/admin_api/admin_api_controller.ex | 34 ++++++--- lib/pleroma/web/admin_api/views/account_view.ex | 40 ++++++++++ .../mastodon_api/controllers/account_controller.ex | 60 +++------------ lib/pleroma/web/router.ex | 3 +- test/web/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 -- cgit v1.2.3 From 74388336852b18d5d5f108a8305f1a038301f7a1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 16 Mar 2020 21:58:10 +0300 Subject: [#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") -- cgit v1.2.3 From b17d8d305f5e9bf25644fd9b3457a965e3a5c001 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Mar 2020 15:39:34 -0500 Subject: 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, -- cgit v1.2.3 From 4705590f76f6a875aa99f32c8b08c20d793470a8 Mon Sep 17 00:00:00 2001 From: Cevado Date: Mon, 16 Mar 2020 22:02:01 -0300 Subject: 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 -- cgit v1.2.3 From d3cf7e19fbe089b3a6d62d6a26f3dfc866a6669d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 17 Mar 2020 13:02:10 +0100 Subject: activity_pub_controller_test.exs: test posting with AP C2S uploaded media --- .../activity_pub/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) -- cgit v1.2.3 From ec3719f5391d6f9945cec2e36287049d72743cd4 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 18 Mar 2020 20:30:31 +0300 Subject: Improved in-test config management functions. --- test/config/transfer_task_test.exs | 4 +-- test/conversation_test.exs | 4 +-- test/plugs/instance_static_test.exs | 4 +-- test/plugs/user_is_admin_plug_test.exs | 8 ++---- test/support/helpers.ex | 30 +++++++++++++++++++++- test/tasks/config_test.exs | 4 +-- test/upload_test.exs | 4 +-- test/uploaders/s3_test.exs | 10 +++----- test/user_test.exs | 4 +-- .../activity_pub/activity_pub_controller_test.exs | 4 +-- .../activity_pub/mrf/object_age_policy_test.exs | 10 +++----- test/web/activity_pub/mrf/simple_policy_test.exs | 22 ++++++++-------- test/web/activity_pub/publisher_test.exs | 4 +-- test/web/activity_pub/transmogrifier_test.exs | 12 +++------ test/web/activity_pub/views/object_view_test.exs | 4 +-- test/web/admin_api/admin_api_controller_test.exs | 29 +++++---------------- test/web/federator_test.exs | 4 +-- test/web/feed/user_controller_test.exs | 4 +-- test/web/instances/instance_test.exs | 4 +-- test/web/instances/instances_test.exs | 4 +-- .../controllers/account_controller_test.exs | 4 +-- .../controllers/status_controller_test.exs | 4 +-- test/web/oauth/ldap_authorization_test.exs | 8 ++---- test/web/oauth/oauth_controller_test.exs | 7 +---- test/web/ostatus/ostatus_controller_test.exs | 4 +-- .../controllers/account_controller_test.exs | 4 +-- .../controllers/emoji_api_controller_test.exs | 4 +-- test/web/static_fe/static_fe_controller_test.exs | 8 ++---- .../twitter_api/remote_follow_controller_test.exs | 4 +-- test/web/twitter_api/twitter_api_test.exs | 16 +++--------- test/web/twitter_api/util_controller_test.exs | 4 +-- test/web/web_finger/web_finger_controller_test.exs | 4 +-- 32 files changed, 89 insertions(+), 155 deletions(-) diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 01d04761d..7bfae67bf 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -10,9 +10,7 @@ defmodule Pleroma.Config.TransferTaskTest do alias Pleroma.Config.TransferTask alias Pleroma.ConfigDB - clear_config(:configurable_from_database) do - Pleroma.Config.put(:configurable_from_database, true) - end + clear_config(:configurable_from_database, true) test "transfer config values from db to env" do refute Application.get_env(:pleroma, :test_key) diff --git a/test/conversation_test.exs b/test/conversation_test.exs index dc0027d04..3c54253e3 100644 --- a/test/conversation_test.exs +++ b/test/conversation_test.exs @@ -11,9 +11,7 @@ defmodule Pleroma.ConversationTest do import Pleroma.Factory - clear_config_all([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], true) - end + clear_config_all([:instance, :federating], true) test "it goes through old direct conversations" do user = insert(:user) diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index 8cd9b5712..2e9d2dc46 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -12,9 +12,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do on_exit(fn -> File.rm_rf(@dir) end) end - clear_config([:instance, :static_dir]) do - Pleroma.Config.put([:instance, :static_dir], @dir) - end + clear_config([:instance, :static_dir], @dir) test "overrides index" do bundled_index = get(build_conn(), "/") diff --git a/test/plugs/user_is_admin_plug_test.exs b/test/plugs/user_is_admin_plug_test.exs index 015d51018..1062d6e70 100644 --- a/test/plugs/user_is_admin_plug_test.exs +++ b/test/plugs/user_is_admin_plug_test.exs @@ -9,9 +9,7 @@ defmodule Pleroma.Plugs.UserIsAdminPlugTest do import Pleroma.Factory describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage]) do - Pleroma.Config.put([:auth, :enforce_oauth_admin_scope_usage], false) - end + clear_config([:auth, :enforce_oauth_admin_scope_usage], false) test "accepts a user that is an admin" do user = insert(:user, is_admin: true) @@ -42,9 +40,7 @@ test "denies when a user isn't set" do end describe "with [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage]) do - Pleroma.Config.put([:auth, :enforce_oauth_admin_scope_usage], true) - end + clear_config([:auth, :enforce_oauth_admin_scope_usage], true) setup do admin_user = insert(:user, is_admin: true) diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 6bf4b019e..c6f7fa5e2 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -26,6 +26,25 @@ defmacro clear_config(config_path, do: yield) do end end + defmacro clear_config(config_path, temp_setting) do + quote do + clear_config(unquote(config_path)) do + Config.put(unquote(config_path), unquote(temp_setting)) + end + end + end + + @doc """ + From _within a test case_, sets config to provided value and restores initial value on exit. + For multi-case setup use `clear_config/2` instead. + """ + def set_config(config_path, temp_setting) do + initial_setting = Config.get(config_path) + Config.put(config_path, temp_setting) + + ExUnit.Callbacks.on_exit(fn -> Config.put(config_path, initial_setting) end) + end + @doc "Stores initial config value and restores it after *all* test examples are executed." defmacro clear_config_all(config_path) do quote do @@ -50,6 +69,14 @@ defmacro clear_config_all(config_path, do: yield) do end end + defmacro clear_config_all(config_path, temp_setting) do + quote do + clear_config_all(unquote(config_path)) do + Config.put(unquote(config_path), unquote(temp_setting)) + end + end + end + defmacro __using__(_opts) do quote do import Pleroma.Tests.Helpers, @@ -57,7 +84,8 @@ defmacro __using__(_opts) do clear_config: 1, clear_config: 2, clear_config_all: 1, - clear_config_all: 2 + clear_config_all: 2, + set_config: 2 ] def to_datetime(naive_datetime) do diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index a6c0de351..b0c2efc98 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -20,9 +20,7 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do :ok end - clear_config_all(:configurable_from_database) do - Pleroma.Config.put(:configurable_from_database, true) - end + clear_config_all(:configurable_from_database, true) test "error if file with custom settings doesn't exist" do Mix.Tasks.Pleroma.Config.migrate_to_db("config/not_existance_config_file.exs") diff --git a/test/upload_test.exs b/test/upload_test.exs index 6ce42b630..6bf7f2417 100644 --- a/test/upload_test.exs +++ b/test/upload_test.exs @@ -250,9 +250,7 @@ test "escapes reserved uri characters" do end describe "Setting a custom base_url for uploaded media" do - clear_config([Pleroma.Upload, :base_url]) do - Pleroma.Config.put([Pleroma.Upload, :base_url], "https://cache.pleroma.social") - end + clear_config([Pleroma.Upload, :base_url], "https://cache.pleroma.social") test "returns a media url with configured base_url" do base_url = Pleroma.Config.get([Pleroma.Upload, :base_url]) diff --git a/test/uploaders/s3_test.exs b/test/uploaders/s3_test.exs index fdc7eff41..96c21c0e5 100644 --- a/test/uploaders/s3_test.exs +++ b/test/uploaders/s3_test.exs @@ -11,12 +11,10 @@ defmodule Pleroma.Uploaders.S3Test do import Mock import ExUnit.CaptureLog - clear_config([Pleroma.Uploaders.S3]) do - Config.put([Pleroma.Uploaders.S3], - bucket: "test_bucket", - public_endpoint: "https://s3.amazonaws.com" - ) - end + clear_config(Pleroma.Uploaders.S3, + bucket: "test_bucket", + public_endpoint: "https://s3.amazonaws.com" + ) describe "get_file/1" do test "it returns path to local folder for files" do diff --git a/test/user_test.exs b/test/user_test.exs index b07fed42b..e0e7a26b8 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -476,9 +476,7 @@ test "it sets the password_hash and ap_id" do email: "email@example.com" } - clear_config([:instance, :account_activation_required]) do - Pleroma.Config.put([:instance, :account_activation_required], true) - end + clear_config([:instance, :account_activation_required], true) test "it creates unconfirmed user" do changeset = User.register_changeset(%User{}, @full_user_data) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index bd8e0b5cc..df0c53458 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -26,9 +26,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do :ok end - clear_config([:instance, :federating]) do - Config.put([:instance, :federating], true) - end + clear_config([:instance, :federating], true) describe "/relay" do clear_config([:instance, :allow_relay]) 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..bdbbb1fc4 100644 --- a/test/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/web/activity_pub/mrf/object_age_policy_test.exs @@ -9,12 +9,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do alias Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy alias Pleroma.Web.ActivityPub.Visibility - clear_config([:mrf_object_age]) do - Config.put(:mrf_object_age, - threshold: 172_800, - actions: [:delist, :strip_followers] - ) - end + clear_config(:mrf_object_age, + threshold: 172_800, + actions: [:delist, :strip_followers] + ) setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index df0f223f8..97aec6622 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -8,18 +8,16 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF.SimplePolicy - clear_config([:mrf_simple]) do - Config.put(:mrf_simple, - media_removal: [], - media_nsfw: [], - federated_timeline_removal: [], - report_removal: [], - reject: [], - accept: [], - avatar_removal: [], - banner_removal: [] - ) - end + clear_config(:mrf_simple, + media_removal: [], + media_nsfw: [], + federated_timeline_removal: [], + report_removal: [], + reject: [], + accept: [], + avatar_removal: [], + banner_removal: [] + ) describe "when :media_removal" do test "is empty" do diff --git a/test/web/activity_pub/publisher_test.exs b/test/web/activity_pub/publisher_test.exs index da26b13f7..ed9c951dd 100644 --- a/test/web/activity_pub/publisher_test.exs +++ b/test/web/activity_pub/publisher_test.exs @@ -23,9 +23,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do :ok end - clear_config_all([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], true) - end + clear_config_all([:instance, :federating], true) describe "gather_webfinger_links/1" do test "it returns links" do diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index efbca82f6..c025b6b78 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1351,9 +1351,7 @@ test "it accepts Move activities" do end describe "`handle_incoming/2`, Mastodon format `replies` handling" do - clear_config([:activitypub, :note_replies_output_limit]) do - Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5) - end + clear_config([:activitypub, :note_replies_output_limit], 5) clear_config([:instance, :federation_incoming_replies_max_depth]) @@ -1394,9 +1392,7 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth end describe "`handle_incoming/2`, Pleroma format `replies` handling" do - clear_config([:activitypub, :note_replies_output_limit]) do - Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5) - end + clear_config([:activitypub, :note_replies_output_limit], 5) clear_config([:instance, :federation_incoming_replies_max_depth]) @@ -2145,9 +2141,7 @@ test "returns object with emoji when object contains map tag" do end describe "set_replies/1" do - clear_config([:activitypub, :note_replies_output_limit]) do - Pleroma.Config.put([:activitypub, :note_replies_output_limit], 2) - end + clear_config([:activitypub, :note_replies_output_limit], 2) test "returns unmodified object if activity doesn't have self-replies" do data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs index 09866e99b..7dda20ec3 100644 --- a/test/web/activity_pub/views/object_view_test.exs +++ b/test/web/activity_pub/views/object_view_test.exs @@ -37,9 +37,7 @@ test "renders a note activity" do end describe "note activity's `replies` collection rendering" do - clear_config([:activitypub, :note_replies_output_limit]) do - Pleroma.Config.put([:activitypub, :note_replies_output_limit], 5) - end + clear_config([:activitypub, :note_replies_output_limit], 5) test "renders `replies` collection for a note activity" do user = insert(:user) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index e4c152fb7..5f3064941 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -43,9 +43,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do end describe "with [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage]) do - Config.put([:auth, :enforce_oauth_admin_scope_usage], true) - end + clear_config([:auth, :enforce_oauth_admin_scope_usage], true) test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope", %{admin: admin} do @@ -93,9 +91,7 @@ test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or bro end describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage]) do - Config.put([:auth, :enforce_oauth_admin_scope_usage], false) - end + clear_config([:auth, :enforce_oauth_admin_scope_usage], false) test "GET /api/pleroma/admin/users/:nickname requires " <> "read:accounts or admin:read:accounts or broader scope", @@ -581,13 +577,8 @@ test "/:right DELETE, can remove from a permission group (multiple)", %{ end describe "POST /api/pleroma/admin/email_invite, with valid config" do - clear_config([:instance, :registrations_open]) do - Config.put([:instance, :registrations_open], false) - end - - clear_config([:instance, :invites_enabled]) do - Config.put([:instance, :invites_enabled], true) - end + clear_config([:instance, :registrations_open], false) + clear_config([:instance, :invites_enabled], true) test "sends invitation and returns 204", %{admin: admin, conn: conn} do recipient_email = "foo@bar.com" @@ -1888,9 +1879,7 @@ test "returns 404 when the status does not exist", %{conn: conn} do end describe "GET /api/pleroma/admin/config" do - clear_config(:configurable_from_database) do - Config.put(:configurable_from_database, true) - end + clear_config(:configurable_from_database, true) test "when configuration from database is off", %{conn: conn} do Config.put(:configurable_from_database, false) @@ -2041,9 +2030,7 @@ test "POST /api/pleroma/admin/config error", %{conn: conn} do end) end - clear_config(:configurable_from_database) do - Config.put(:configurable_from_database, true) - end + clear_config(:configurable_from_database, true) @tag capture_log: true test "create new config setting in db", %{conn: conn} do @@ -3052,9 +3039,7 @@ test "proxy tuple ip", %{conn: conn} do end describe "GET /api/pleroma/admin/restart" do - clear_config(:configurable_from_database) do - Config.put(:configurable_from_database, true) - end + clear_config(:configurable_from_database, true) test "pleroma restarts", %{conn: conn} do capture_log(fn -> diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index d2ee2267c..2b321d263 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -21,9 +21,7 @@ defmodule Pleroma.Web.FederatorTest do :ok end - clear_config_all([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], true) - end + clear_config_all([:instance, :federating], true) clear_config([:instance, :allow_relay]) clear_config([:instance, :rewrite_policy]) diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index 00c50f003..49cfecde3 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -12,9 +12,7 @@ defmodule Pleroma.Web.Feed.UserControllerTest do alias Pleroma.Object alias Pleroma.User - clear_config([:instance, :federating]) do - Config.put([:instance, :federating], true) - end + clear_config([:instance, :federating], true) describe "feed" do clear_config([:feed]) diff --git a/test/web/instances/instance_test.exs b/test/web/instances/instance_test.exs index a3c93b986..ab8e5643b 100644 --- a/test/web/instances/instance_test.exs +++ b/test/web/instances/instance_test.exs @@ -10,9 +10,7 @@ defmodule Pleroma.Instances.InstanceTest do import Pleroma.Factory - clear_config_all([:instance, :federation_reachability_timeout_days]) do - Pleroma.Config.put([:instance, :federation_reachability_timeout_days], 1) - end + clear_config_all([:instance, :federation_reachability_timeout_days], 1) describe "set_reachable/1" do test "clears `unreachable_since` of existing matching Instance record having non-nil `unreachable_since`" do diff --git a/test/web/instances/instances_test.exs b/test/web/instances/instances_test.exs index c5d6abc9c..1d83c1a1c 100644 --- a/test/web/instances/instances_test.exs +++ b/test/web/instances/instances_test.exs @@ -7,9 +7,7 @@ defmodule Pleroma.InstancesTest do use Pleroma.DataCase - clear_config_all([:instance, :federation_reachability_timeout_days]) do - Pleroma.Config.put([:instance, :federation_reachability_timeout_days], 1) - end + clear_config_all([:instance, :federation_reachability_timeout_days], 1) describe "reachable?/1" do test "returns `true` for host / url with unknown reachability status" do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 7efccd9c4..5a78f2968 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -756,9 +756,7 @@ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_ end describe "create account by app / rate limit" do - clear_config([:rate_limit, :app_account_creation]) do - Pleroma.Config.put([:rate_limit, :app_account_creation], {10_000, 2}) - end + clear_config([:rate_limit, :app_account_creation], {10_000, 2}) test "respects rate limit setting", %{conn: conn} do app_token = insert(:oauth_token, user: nil) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index fbf63f608..5259abdcd 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -739,9 +739,7 @@ test "returns 404 error for a wrong id", %{conn: conn} do %{activity: activity} end - clear_config([:instance, :max_pinned_statuses]) do - Config.put([:instance, :max_pinned_statuses], 1) - end + clear_config([:instance, :max_pinned_statuses], 1) test "pin status", %{conn: conn, user: user, activity: activity} do id_str = to_string(activity.id) diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs index c55b0ffc5..b348281c5 100644 --- a/test/web/oauth/ldap_authorization_test.exs +++ b/test/web/oauth/ldap_authorization_test.exs @@ -12,13 +12,9 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do @skip if !Code.ensure_loaded?(:eldap), do: :skip - clear_config_all([:ldap, :enabled]) do - Pleroma.Config.put([:ldap, :enabled], true) - end + clear_config_all([:ldap, :enabled], true) - clear_config_all(Pleroma.Web.Auth.Authenticator) do - Pleroma.Config.put(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator) - end + clear_config_all(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator) @tag @skip test "authorizes the existing user using LDAP credentials" do diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index cff469c28..592612ddf 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -31,12 +31,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do ] end - clear_config([:auth, :oauth_consumer_strategies]) do - Pleroma.Config.put( - [:auth, :oauth_consumer_strategies], - ~w(twitter facebook) - ) - end + clear_config([:auth, :oauth_consumer_strategies], ~w(twitter facebook)) test "GET /oauth/authorize renders auth forms, including OAuth consumer form", %{ app: app, diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index 3b84358e4..6a3dcf2cd 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -17,9 +17,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do :ok end - clear_config([:instance, :federating]) do - Config.put([:instance, :federating], true) - end + clear_config([:instance, :federating], true) # Note: see ActivityPubControllerTest for JSON format tests describe "GET /objects/:uuid (text/html)" do diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index 245cc1579..bc359707d 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -27,9 +27,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do [user: user] end - clear_config([:instance, :account_activation_required]) do - Config.put([:instance, :account_activation_required], true) - end + clear_config([:instance, :account_activation_required], true) test "resend account confirmation email", %{conn: conn, user: user} do conn diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 4b9f5cf9a..146f3f4fe 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -13,9 +13,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do "emoji" ) - clear_config([:auth, :enforce_oauth_admin_scope_usage]) do - Pleroma.Config.put([:auth, :enforce_oauth_admin_scope_usage], false) - end + clear_config([:auth, :enforce_oauth_admin_scope_usage], false) test "shared & non-shared pack information in list_packs is ok" do conn = build_conn() diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index c3d2ae3b4..aabbedb17 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -8,13 +8,9 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do import Pleroma.Factory - clear_config_all([:static_fe, :enabled]) do - Config.put([:static_fe, :enabled], true) - end + clear_config_all([:static_fe, :enabled], true) - clear_config([:instance, :federating]) do - Config.put([:instance, :federating], true) - end + clear_config([:instance, :federating], true) setup %{conn: conn} do conn = put_req_header(conn, "accept", "text/html") diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs index 73062f18f..5c6087527 100644 --- a/test/web/twitter_api/remote_follow_controller_test.exs +++ b/test/web/twitter_api/remote_follow_controller_test.exs @@ -17,9 +17,7 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do :ok end - clear_config_all([:instance, :federating]) do - Config.put([:instance, :federating], true) - end + clear_config_all([:instance, :federating], true) clear_config([:instance]) clear_config([:frontend_configurations, :pleroma_fe]) diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 14eed5f27..0e787715a 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -117,9 +117,7 @@ test "it registers a new user and parses mentions in the bio" do end describe "register with one time token" do - clear_config([:instance, :registrations_open]) do - Pleroma.Config.put([:instance, :registrations_open], false) - end + clear_config([:instance, :registrations_open], false) test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite() @@ -184,9 +182,7 @@ test "returns error on expired token" do end describe "registers with date limited token" do - clear_config([:instance, :registrations_open]) do - Pleroma.Config.put([:instance, :registrations_open], false) - end + clear_config([:instance, :registrations_open], false) setup do data = %{ @@ -246,9 +242,7 @@ test "returns an error on overdue date", %{data: data} do end describe "registers with reusable token" do - clear_config([:instance, :registrations_open]) do - Pleroma.Config.put([:instance, :registrations_open], false) - end + clear_config([:instance, :registrations_open], false) test "returns user on success, after him registration fails" do {:ok, invite} = UserInviteToken.create_invite(%{max_use: 100}) @@ -292,9 +286,7 @@ test "returns user on success, after him registration fails" do end describe "registers with reusable date limited token" do - clear_config([:instance, :registrations_open]) do - Pleroma.Config.put([:instance, :registrations_open], false) - end + clear_config([:instance, :registrations_open], false) test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 9d757b5ef..71ecd1aa7 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -427,9 +427,7 @@ test "it returns version in json format", %{conn: conn} do end describe "POST /main/ostatus - remote_subscribe/2" do - clear_config([:instance, :federating]) do - Config.put([:instance, :federating], true) - end + clear_config([:instance, :federating], true) test "renders subscribe form", %{conn: conn} do user = insert(:user) diff --git a/test/web/web_finger/web_finger_controller_test.exs b/test/web/web_finger/web_finger_controller_test.exs index b65bf5904..fcf14dc1e 100644 --- a/test/web/web_finger/web_finger_controller_test.exs +++ b/test/web/web_finger/web_finger_controller_test.exs @@ -14,9 +14,7 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do :ok end - clear_config_all([:instance, :federating]) do - Pleroma.Config.put([:instance, :federating], true) - end + clear_config_all([:instance, :federating], true) test "GET host-meta" do response = -- cgit v1.2.3 From f9d622d25a744f58fbaf8370ad4435597bb15bf0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 19 Mar 2020 15:08:49 +0100 Subject: WIP --- lib/pleroma/web/activity_pub/transmogrifier.ex | 15 --------------- test/web/activity_pub/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 -- cgit v1.2.3 From 7d275970ab191af539acbc0baec3bc1d0a2558e1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 19 Mar 2020 10:08:11 -0500 Subject: 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) -- cgit v1.2.3 From a8c6933ca023d7487910a0f99aed62fa8c2d45e2 Mon Sep 17 00:00:00 2001 From: stwf Date: Thu, 19 Mar 2020 12:25:36 -0400 Subject: remove federated testing --- .gitlab-ci.yml | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d0d3316a..1b7c03ebb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,19 +62,21 @@ unit-testing: - mix ecto.migrate - mix coveralls --preload-modules -federated-testing: - stage: test - cache: *testing_cache_policy - services: - - name: minibikini/postgres-with-rum:12 - alias: postgres - command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - script: - - mix deps.get - - mix ecto.create - - mix ecto.migrate - - epmd -daemon - - mix test --trace --only federated +# Removed to fix CI issue. In this early state it wasn't adding much value anyway. +# TODO Fix and reinstate federated testing +# federated-testing: +# stage: test +# cache: *testing_cache_policy +# services: +# - name: minibikini/postgres-with-rum:12 +# alias: postgres +# command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] +# script: +# - mix deps.get +# - mix ecto.create +# - mix ecto.migrate +# - epmd -daemon +# - mix test --trace --only federated unit-testing-rum: stage: test -- cgit v1.2.3 From 9b9d67bbec537df6f7c5729e81da6deeaf896bd9 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 19 Mar 2020 18:16:12 +0100 Subject: Fix linting. --- lib/pleroma/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 -- cgit v1.2.3 From c1fd4f665335ba67336bd1b2fab2d9df5e247e08 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 19 Mar 2020 19:10:03 +0100 Subject: transmogrifier.ex: rework fix_attachment for better IR --- lib/pleroma/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"} ] } ] -- cgit v1.2.3 From 98a60df41f8a053005a2a413b552a582a879ecaa Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 18 Mar 2020 17:37:54 +0300 Subject: include_types parameter in /api/v1/notifications --- CHANGELOG.md | 7 ++++ docs/API/differences_in_mastoapi_responses.md | 1 + lib/pleroma/web/mastodon_api/mastodon_api.ex | 22 ++++++++--- lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 1 + .../controllers/notification_controller_test.exs | 45 ++++++++++++++++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3be2ea08..a27200895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Removed - **Breaking:** removed `with_move` parameter from notifications timeline. +### Added +- NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. +
    + API Changes +- Mastodon API: Support for `include_types` in `/api/v1/notifications`. +
    + ## [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/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index b12d3092c..dc8f54d2a 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -117,6 +117,7 @@ The `type` value is `pleroma:emoji_reaction`. Has these fields: Accepts additional parameters: - `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`. +- `include_types`: will include the notifications for activities with the given types. The parameter accepts an array of types (`mention`, `follow`, `reblog`, `favourite`, `move`, `pleroma:emoji_reaction`). Usage example: `GET /api/v1/notifications?include_types[]=mention&include_types[]=reblog`. ## POST `/api/v1/statuses` diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index a2dc9bc71..70da64a7a 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -55,6 +55,7 @@ def get_notifications(user, params \\ %{}) do user |> Notification.for_user_query(options) + |> restrict(:include_types, options) |> restrict(:exclude_types, options) |> restrict(:account_ap_id, options) |> Pagination.fetch_paginated(params) @@ -69,6 +70,7 @@ def get_scheduled_activities(user, params \\ %{}) do defp cast_params(params) do param_types = %{ exclude_types: {:array, :string}, + include_types: {:array, :string}, exclude_visibilities: {:array, :string}, reblogs: :boolean, with_muted: :boolean, @@ -79,14 +81,16 @@ defp cast_params(params) do changeset.changes end + defp restrict(query, :include_types, %{include_types: mastodon_types = [_ | _]}) do + ap_types = convert_and_filter_mastodon_types(mastodon_types) + + where(query, [q, a], fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + end + defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do - ap_types = - mastodon_types - |> Enum.map(&Activity.from_mastodon_notification_type/1) - |> Enum.filter(& &1) + ap_types = convert_and_filter_mastodon_types(mastodon_types) - query - |> where([q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + where(query, [q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) end defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do @@ -94,4 +98,10 @@ defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do end defp restrict(query, _, _), do: query + + defp convert_and_filter_mastodon_types(types) do + types + |> Enum.map(&Activity.from_mastodon_notification_type/1) + |> Enum.filter(& &1) + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 18eb41333..30838b1eb 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -60,6 +60,7 @@ def raw_nodeinfo do "pleroma_explicit_addressing", "shareable_emoji_packs", "multifetch", + "pleroma:api/v1/notifications:include_types_filter", if Config.get([:media_proxy, :enabled]) do "media_proxy" end, diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index dbe9a7fd7..7a0011646 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -304,6 +304,51 @@ test "filters notifications using exclude_types" do assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200) end + test "filters notifications using include_types" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + + {: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, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) + {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) + + mention_notification_id = get_notification_id_by_activity(mention_activity) + favorite_notification_id = get_notification_id_by_activity(favorite_activity) + reblog_notification_id = get_notification_id_by_activity(reblog_activity) + follow_notification_id = get_notification_id_by_activity(follow_activity) + + conn_res = get(conn, "/api/v1/notifications", %{include_types: ["follow"]}) + + assert [%{"id" => ^follow_notification_id}] = json_response(conn_res, 200) + + conn_res = get(conn, "/api/v1/notifications", %{include_types: ["mention"]}) + + assert [%{"id" => ^mention_notification_id}] = json_response(conn_res, 200) + + conn_res = get(conn, "/api/v1/notifications", %{include_types: ["favourite"]}) + + assert [%{"id" => ^favorite_notification_id}] = json_response(conn_res, 200) + + conn_res = get(conn, "/api/v1/notifications", %{include_types: ["reblog"]}) + + assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200) + + result = conn |> get("/api/v1/notifications") |> json_response(200) + + assert length(result) == 4 + + result = + conn + |> get("/api/v1/notifications", %{ + include_types: ["follow", "mention", "favourite", "reblog"] + }) + |> json_response(200) + + assert length(result) == 4 + end + test "destroy multiple" do %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"]) other_user = insert(:user) -- cgit v1.2.3 From fe15f0ba15d02809fa4c21fb646e65d06060f3bb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 20 Mar 2020 13:04:37 +0300 Subject: restrict_unauthenticated setting --- CHANGELOG.md | 1 + config/config.exs | 5 + config/description.exs | 60 ++++++ docs/configuration/cheatsheet.md | 18 ++ lib/pleroma/user.ex | 13 +- lib/pleroma/web/activity_pub/visibility.ex | 14 +- .../mastodon_api/controllers/account_controller.ex | 7 +- .../mastodon_api/controllers/status_controller.ex | 2 +- .../controllers/timeline_controller.ex | 35 ++-- .../controllers/account_controller_test.exs | 213 ++++++++++++++++++++- .../controllers/status_controller_test.exs | 169 ++++++++++++++++ .../controllers/timeline_controller_test.exs | 111 +++++++++-- 12 files changed, 615 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a27200895..15a073c64 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. +- Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses.
    API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. diff --git a/config/config.exs b/config/config.exs index 3357e23e7..2ab939107 100644 --- a/config/config.exs +++ b/config/config.exs @@ -624,6 +624,11 @@ parameters: [gin_fuzzy_search_limit: "500"], prepare: :unnamed +config :pleroma, :restrict_unauthenticated, + timelines: %{local: false, federated: false}, + profiles: %{local: false, remote: false}, + activities: %{local: false, remote: false} + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/description.exs b/config/description.exs index 732c76734..3781fb9cb 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2915,5 +2915,65 @@ suggestions: [2] } ] + }, + %{ + group: :pleroma, + key: :restrict_unauthenticated, + type: :group, + description: + "Disallow viewing timelines, user profiles and statuses for unauthenticated users.", + children: [ + %{ + key: :timelines, + type: :map, + description: "Settings for public and federated timelines.", + children: [ + %{ + key: :local, + type: :boolean, + description: "Disallow view public timeline." + }, + %{ + key: :federated, + type: :boolean, + description: "Disallow view federated timeline." + } + ] + }, + %{ + key: :profiles, + type: :map, + description: "Settings for user profiles.", + children: [ + %{ + key: :local, + type: :boolean, + description: "Disallow view local user profiles." + }, + %{ + key: :remote, + type: :boolean, + description: "Disallow view remote user profiles." + } + ] + }, + %{ + key: :activities, + type: :map, + description: "Settings for statuses.", + children: [ + %{ + key: :local, + type: :boolean, + description: "Disallow view local statuses." + }, + %{ + key: :remote, + type: :boolean, + description: "Disallow view remote statuses." + } + ] + } + ] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 4012fe9b1..d16435e11 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -872,3 +872,21 @@ config :auto_linker, ## :configurable_from_database Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. + + + +## Restrict entities access for unauthenticated users + +### :restrict_unauthenticated + +Restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. + +* `timelines` - public and federated timelines + * `local` - public timeline + * `federated` +* `profiles` - user profiles + * `local` + * `remote` +* `activities` - statuses + * `local` + * `remote` \ No newline at end of file diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 911dde6e2..8693c0b80 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -237,7 +237,18 @@ def visible_for?(user, for_user \\ nil) def visible_for?(%User{invisible: true}, _), do: false - def visible_for?(%User{id: user_id}, %User{id: for_id}) when user_id == for_id, do: true + def visible_for?(%User{id: user_id}, %User{id: user_id}), do: true + + def visible_for?(%User{local: local} = user, nil) do + cfg_key = + if local, + do: :local, + else: :remote + + if Config.get([:restrict_unauthenticated, :profiles, cfg_key]), + do: false, + else: account_status(user) == :active + end def visible_for?(%User{} = user, for_user) do account_status(user) == :active || superuser?(for_user) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 6f226fc92..453a6842e 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -44,6 +44,7 @@ def is_direct?(activity) do def is_list?(%{data: %{"listMessage" => _}}), do: true def is_list?(_), do: false + @spec visible_for_user?(Activity.t(), User.t() | nil) :: boolean() def visible_for_user?(%{actor: ap_id}, %User{ap_id: ap_id}), do: true def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{} = user) do @@ -55,14 +56,21 @@ def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{ def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false - def visible_for_user?(activity, nil) do - is_public?(activity) + def visible_for_user?(%{local: local} = activity, nil) do + cfg_key = + if local, + do: :local, + else: :remote + + if Pleroma.Config.get([:restrict_unauthenticated, :activities, cfg_key]), + do: false, + else: is_public?(activity) end def visible_for_user?(activity, user) do x = [user.ap_id | User.following(user)] y = [activity.actor] ++ activity.data["to"] ++ (activity.data["cc"] || []) - visible_for_user?(activity, nil) || Enum.any?(x, &(&1 in y)) + is_public?(activity) || Enum.any?(x, &(&1 in y)) end def entire_thread_visible_for_user?(%Activity{} = activity, %User{} = user) do diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 88c997b9f..6dbf11ac9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -60,7 +60,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - when action != :create + when action not in [:create, :show, :statuses] ) @relations [:follow, :unfollow] @@ -259,7 +259,8 @@ def show(%{assigns: %{user: for_user}} = conn, %{"id" => nickname_or_id}) do @doc "GET /api/v1/accounts/:id/statuses" def statuses(%{assigns: %{user: reading_user}} = conn, params) do - with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do + with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user), + true <- User.visible_for?(user, reading_user) do params = params |> Map.put("tag", params["tagged"]) @@ -271,6 +272,8 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do |> add_link_headers(activities) |> put_view(StatusView) |> render("index.json", activities: activities, for: reading_user, as: :activity) + else + _e -> render_error(conn, :not_found, "Can't find user") end end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 5c90065f6..37afe6949 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -76,7 +76,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :show]) @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 09e08271b..91f41416d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :public) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) @@ -75,17 +75,30 @@ def direct(%{assigns: %{user: user}} = conn, params) do def public(%{assigns: %{user: user}} = conn, params) do local_only = truthy_param?(params["local"]) - activities = - params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> ActivityPub.fetch_public_activities() + cfg_key = + if local_only do + :local + else + :federated + end - conn - |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", activities: activities, for: user, as: :activity) + restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key]) + + if not (restrict? and is_nil(user)) do + activities = + params + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", local_only) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> ActivityPub.fetch_public_activities() + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", activities: activities, for: user, as: :activity) + else + render_error(conn, :unauthorized, "authorization required for timeline view") + end end def hashtag_fetching(params, user, local_only) do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 7efccd9c4..2182dd28e 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Config alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -46,7 +47,7 @@ test "works by nickname" do end test "works by nickname for remote users" do - Pleroma.Config.put([:instance, :limit_to_local_content], false) + Config.put([:instance, :limit_to_local_content], false) user = insert(:user, nickname: "user@example.com", local: false) conn = @@ -58,7 +59,7 @@ test "works by nickname for remote users" do end test "respects limit_to_local_content == :all for remote user nicknames" do - Pleroma.Config.put([:instance, :limit_to_local_content], :all) + Config.put([:instance, :limit_to_local_content], :all) user = insert(:user, nickname: "user@example.com", local: false) @@ -70,7 +71,7 @@ test "respects limit_to_local_content == :all for remote user nicknames" do end test "respects limit_to_local_content == :unauthenticated for remote user nicknames" do - Pleroma.Config.put([:instance, :limit_to_local_content], :unauthenticated) + Config.put([:instance, :limit_to_local_content], :unauthenticated) user = insert(:user, nickname: "user@example.com", local: false) reading_user = insert(:user) @@ -140,6 +141,106 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do end end + defp local_and_remote_users do + local = insert(:user) + remote = insert(:user, local: false) + {:ok, local: local, remote: remote} + end + + describe "user fetching with restrict unauthenticated profiles for local and remote" do + setup do: local_and_remote_users() + + clear_config([:restrict_unauthenticated, :profiles, :local]) do + Config.put([:restrict_unauthenticated, :profiles, :local], true) + end + + clear_config([:restrict_unauthenticated, :profiles, :remote]) do + Config.put([:restrict_unauthenticated, :profiles, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/accounts/#{local.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/accounts/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + end + + describe "user fetching with restrict unauthenticated profiles for local" do + setup do: local_and_remote_users() + + clear_config([:restrict_unauthenticated, :profiles, :local]) do + Config.put([:restrict_unauthenticated, :profiles, :local], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/accounts/#{local.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/accounts/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + end + + describe "user fetching with restrict unauthenticated profiles for remote" do + setup do: local_and_remote_users() + + clear_config([:restrict_unauthenticated, :profiles, :remote]) do + Config.put([:restrict_unauthenticated, :profiles, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/accounts/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/accounts/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + end + describe "user timelines" do setup do: oauth_access(["read:statuses"]) @@ -293,6 +394,110 @@ test "the user views their own timelines and excludes direct messages", %{ end end + defp local_and_remote_activities(%{local: local, remote: remote}) do + insert(:note_activity, user: local) + insert(:note_activity, user: remote, local: false) + + :ok + end + + describe "statuses with restrict unauthenticated profiles for local and remote" do + setup do: local_and_remote_users() + setup :local_and_remote_activities + + clear_config([:restrict_unauthenticated, :profiles, :local]) do + Config.put([:restrict_unauthenticated, :profiles, :local], true) + end + + clear_config([:restrict_unauthenticated, :profiles, :remote]) do + Config.put([:restrict_unauthenticated, :profiles, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + end + end + + describe "statuses with restrict unauthenticated profiles for local" do + setup do: local_and_remote_users() + setup :local_and_remote_activities + + clear_config([:restrict_unauthenticated, :profiles, :local]) do + Config.put([:restrict_unauthenticated, :profiles, :local], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + end + end + + describe "statuses with restrict unauthenticated profiles for remote" do + setup do: local_and_remote_users() + setup :local_and_remote_activities + + clear_config([:restrict_unauthenticated, :profiles, :remote]) do + Config.put([:restrict_unauthenticated, :profiles, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Can't find user" + } + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + assert length(json_response(res_conn, 200)) == 1 + end + end + describe "followers" do setup do: oauth_access(["read:accounts"]) @@ -757,7 +962,7 @@ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_ describe "create account by app / rate limit" do clear_config([:rate_limit, :app_account_creation]) do - Pleroma.Config.put([:rate_limit, :app_account_creation], {10_000, 2}) + Config.put([:rate_limit, :app_account_creation], {10_000, 2}) end test "respects rate limit setting", %{conn: conn} do diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index fbf63f608..81513a429 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -476,6 +476,103 @@ test "get a status" do assert id == to_string(activity.id) end + defp local_and_remote_activities do + local = insert(:note_activity) + remote = insert(:note_activity, local: false) + {:ok, local: local, remote: remote} + end + + describe "status with restrict unauthenticated activities for local and remote" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :activities, :local]) do + Config.put([:restrict_unauthenticated, :activities, :local], true) + end + + clear_config([:restrict_unauthenticated, :activities, :remote]) do + Config.put([:restrict_unauthenticated, :activities, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/statuses/#{local.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Record not found" + } + + res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Record not found" + } + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + res_conn = get(conn, "/api/v1/statuses/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + end + + describe "status with restrict unauthenticated activities for local" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :activities, :local]) do + Config.put([:restrict_unauthenticated, :activities, :local], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/statuses/#{local.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Record not found" + } + + res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + res_conn = get(conn, "/api/v1/statuses/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + end + + describe "status with restrict unauthenticated activities for remote" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :activities, :remote]) do + Config.put([:restrict_unauthenticated, :activities, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/statuses/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + + assert json_response(res_conn, :not_found) == %{ + "error" => "Record not found" + } + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + res_conn = get(conn, "/api/v1/statuses/#{local.id}") + assert %{"id" => _} = json_response(res_conn, 200) + + res_conn = get(conn, "/api/v1/statuses/#{remote.id}") + assert %{"id" => _} = json_response(res_conn, 200) + end + end + test "getting a status that doesn't exist returns 404" do %{conn: conn} = oauth_access(["read:statuses"]) activity = insert(:note_activity) @@ -514,6 +611,78 @@ test "get statuses by IDs" do assert [%{"id" => ^id1}, %{"id" => ^id2}] = Enum.sort_by(json_response(conn, :ok), & &1["id"]) end + describe "getting statuses by ids with restricted unauthenticated for local and remote" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :activities, :local]) do + Config.put([:restrict_unauthenticated, :activities, :local], true) + end + + clear_config([:restrict_unauthenticated, :activities, :remote]) do + Config.put([:restrict_unauthenticated, :activities, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + + assert json_response(res_conn, 200) == [] + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + + assert length(json_response(res_conn, 200)) == 2 + end + end + + describe "getting statuses by ids with restricted unauthenticated for local" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :activities, :local]) do + Config.put([:restrict_unauthenticated, :activities, :local], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + + remote_id = remote.id + assert [%{"id" => ^remote_id}] = json_response(res_conn, 200) + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + + assert length(json_response(res_conn, 200)) == 2 + end + end + + describe "getting statuses by ids with restricted unauthenticated for remote" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :activities, :remote]) do + Config.put([:restrict_unauthenticated, :activities, :remote], true) + end + + test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do + res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + + local_id = local.id + assert [%{"id" => ^local_id}] = json_response(res_conn, 200) + end + + test "if user is authenticated", %{local: local, remote: remote} do + %{conn: conn} = oauth_access(["read"]) + + res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + + assert length(json_response(res_conn, 200)) == 2 + end + end + describe "deleting a status" do test "when you created it" do %{user: author, conn: conn} = oauth_access(["write:statuses"]) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 2c03b0a75..a15c759d4 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -12,8 +12,6 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do alias Pleroma.User alias Pleroma.Web.CommonAPI - clear_config([:instance, :public]) - setup do mock(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok @@ -80,15 +78,6 @@ test "the public timeline", %{conn: conn} do assert [%{"content" => "test"}] = json_response(conn, :ok) end - test "the public timeline when public is set to false", %{conn: conn} do - Config.put([:instance, :public], false) - - assert %{"error" => "This resource requires authentication."} == - conn - |> get("/api/v1/timelines/public", %{"local" => "False"}) - |> json_response(:forbidden) - end - test "the public timeline includes only public statuses for an authenticated user" do %{user: user, conn: conn} = oauth_access(["read:statuses"]) @@ -102,6 +91,106 @@ test "the public timeline includes only public statuses for an authenticated use end end + defp local_and_remote_activities do + insert(:note_activity) + insert(:note_activity, local: false) + :ok + end + + describe "public with restrict unauthenticated timeline for local and federated timelines" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :timelines, :local]) do + Config.put([:restrict_unauthenticated, :timelines, :local], true) + end + + clear_config([:restrict_unauthenticated, :timelines, :federated]) do + Config.put([:restrict_unauthenticated, :timelines, :federated], true) + end + + test "if user is unauthenticated", %{conn: conn} do + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + + assert json_response(res_conn, :unauthorized) == %{ + "error" => "authorization required for timeline view" + } + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + + assert json_response(res_conn, :unauthorized) == %{ + "error" => "authorization required for timeline view" + } + end + + test "if user is authenticated" do + %{conn: conn} = oauth_access(["read:statuses"]) + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + assert length(json_response(res_conn, 200)) == 2 + end + end + + describe "public with restrict unauthenticated timeline for local" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :timelines, :local]) do + Config.put([:restrict_unauthenticated, :timelines, :local], true) + end + + test "if user is unauthenticated", %{conn: conn} do + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + + assert json_response(res_conn, :unauthorized) == %{ + "error" => "authorization required for timeline view" + } + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + assert length(json_response(res_conn, 200)) == 2 + end + + test "if user is authenticated", %{conn: _conn} do + %{conn: conn} = oauth_access(["read:statuses"]) + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + assert length(json_response(res_conn, 200)) == 2 + end + end + + describe "public with restrict unauthenticated timeline for remote" do + setup do: local_and_remote_activities() + + clear_config([:restrict_unauthenticated, :timelines, :federated]) do + Config.put([:restrict_unauthenticated, :timelines, :federated], true) + end + + test "if user is unauthenticated", %{conn: conn} do + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + + assert json_response(res_conn, :unauthorized) == %{ + "error" => "authorization required for timeline view" + } + end + + test "if user is authenticated", %{conn: _conn} do + %{conn: conn} = oauth_access(["read:statuses"]) + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + assert length(json_response(res_conn, 200)) == 2 + end + end + describe "direct" do test "direct timeline", %{conn: conn} do user_one = insert(:user) -- cgit v1.2.3 From 6c1232b486dcad5a644b4292697d08ebe3000cb3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 20 Mar 2020 15:00:28 +0100 Subject: NotificationController: Fix test. --- test/web/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) -- cgit v1.2.3 From 1c05f539aaea32fe993e5299e656aa44c322e8de Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 20 Mar 2020 18:33:00 +0300 Subject: Improved in-test `clear_config/n` applicability (setup / setup_all / in-test usage). --- test/activity_expiration_test.exs | 2 +- test/activity_test.exs | 2 +- test/captcha_test.exs | 3 +- test/config/transfer_task_test.exs | 2 +- test/conversation_test.exs | 2 +- test/emails/mailer_test.exs | 3 +- test/http/request_builder_test.exs | 4 +- test/object/fetcher_test.exs | 5 +- test/object_test.exs | 4 +- .../admin_secret_authentication_plug_test.exs | 2 +- .../ensure_public_or_authenticated_plug_test.exs | 2 +- test/plugs/http_security_plug_test.exs | 6 +-- test/plugs/instance_static_test.exs | 2 +- test/plugs/oauth_scopes_plug_test.exs | 2 +- test/plugs/rate_limiter_test.exs | 8 ++- test/plugs/remote_ip_test.exs | 3 +- test/plugs/user_enabled_plug_test.exs | 2 +- test/plugs/user_is_admin_plug_test.exs | 4 +- test/repo_test.exs | 2 +- test/scheduled_activity_test.exs | 2 +- test/support/helpers.ex | 58 ++-------------------- test/tasks/config_test.exs | 2 +- test/tasks/robots_txt_test.exs | 2 +- test/upload/filter/anonymize_filename_test.exs | 2 +- test/upload/filter/mogrify_test.exs | 2 +- test/upload/filter_test.exs | 2 +- test/upload_test.exs | 2 +- test/uploaders/s3_test.exs | 9 ++-- test/user_search_test.exs | 2 +- test/user_test.exs | 29 +++++------ .../activity_pub/activity_pub_controller_test.exs | 8 +-- test/web/activity_pub/activity_pub_test.exs | 6 +-- .../activity_pub/mrf/hellthread_policy_test.exs | 2 +- test/web/activity_pub/mrf/keyword_policy_test.exs | 2 +- test/web/activity_pub/mrf/mention_policy_test.exs | 2 +- test/web/activity_pub/mrf/mrf_test.exs | 2 +- .../activity_pub/mrf/object_age_policy_test.exs | 9 ++-- .../activity_pub/mrf/reject_non_public_test.exs | 2 +- test/web/activity_pub/mrf/simple_policy_test.exs | 21 ++++---- test/web/activity_pub/mrf/subchain_policy_test.exs | 3 +- .../mrf/user_allowlist_policy_test.exs | 2 +- .../activity_pub/mrf/vocabulary_policy_test.exs | 4 +- test/web/activity_pub/publisher_test.exs | 2 +- test/web/activity_pub/relay_test.exs | 2 +- .../transmogrifier/follow_handling_test.exs | 2 +- test/web/activity_pub/transmogrifier_test.exs | 16 +++--- test/web/activity_pub/views/object_view_test.exs | 2 +- test/web/admin_api/admin_api_controller_test.exs | 18 +++---- test/web/chat_channel_test.exs | 2 +- test/web/common_api/common_api_test.exs | 6 +-- test/web/federator_test.exs | 9 ++-- test/web/feed/tag_controller_test.exs | 2 +- test/web/feed/user_controller_test.exs | 4 +- test/web/instances/instance_test.exs | 2 +- test/web/instances/instances_test.exs | 2 +- test/web/masto_fe_controller_test.exs | 2 +- .../account_controller/update_credentials_test.exs | 3 +- .../controllers/account_controller_test.exs | 40 +++++---------- .../controllers/media_controller_test.exs | 4 +- .../scheduled_activity_controller_test.exs | 2 +- .../controllers/status_controller_test.exs | 40 +++++---------- .../controllers/timeline_controller_test.exs | 16 ++---- .../media_proxy/media_proxy_controller_test.exs | 4 +- test/web/media_proxy/media_proxy_test.exs | 4 +- test/web/metadata/opengraph_test.exs | 2 +- test/web/metadata/twitter_card_test.exs | 2 +- test/web/node_info_test.exs | 6 +-- test/web/oauth/ldap_authorization_test.exs | 4 +- test/web/oauth/oauth_controller_test.exs | 7 ++- test/web/ostatus/ostatus_controller_test.exs | 2 +- .../controllers/account_controller_test.exs | 2 +- .../controllers/emoji_api_controller_test.exs | 3 +- test/web/plugs/federating_plug_test.exs | 2 +- test/web/rich_media/helpers_test.exs | 2 +- test/web/static_fe/static_fe_controller_test.exs | 5 +- test/web/streamer/streamer_test.exs | 3 +- .../twitter_api/remote_follow_controller_test.exs | 9 ++-- test/web/twitter_api/twitter_api_test.exs | 8 +-- test/web/twitter_api/util_controller_test.exs | 8 +-- test/web/web_finger/web_finger_controller_test.exs | 2 +- .../workers/cron/clear_oauth_token_worker_test.exs | 2 +- test/workers/cron/digest_emails_worker_test.exs | 2 +- .../cron/purge_expired_activities_worker_test.exs | 2 +- test/workers/scheduled_activity_worker_test.exs | 2 +- 84 files changed, 196 insertions(+), 298 deletions(-) diff --git a/test/activity_expiration_test.exs b/test/activity_expiration_test.exs index 4cda5e985..e899d4509 100644 --- a/test/activity_expiration_test.exs +++ b/test/activity_expiration_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.ActivityExpirationTest do alias Pleroma.ActivityExpiration import Pleroma.Factory - clear_config([ActivityExpiration, :enabled]) + setup do: clear_config([ActivityExpiration, :enabled]) test "finds activities due to be deleted only" do activity = insert(:note_activity) diff --git a/test/activity_test.exs b/test/activity_test.exs index 46b55beaa..0c19f481b 100644 --- a/test/activity_test.exs +++ b/test/activity_test.exs @@ -138,7 +138,7 @@ test "when association is not loaded" do } end - clear_config([:instance, :limit_to_local_content]) + setup do: clear_config([:instance, :limit_to_local_content]) test "finds utf8 text in statuses", %{ japanese_activity: japanese_activity, diff --git a/test/captcha_test.exs b/test/captcha_test.exs index 5e29b48b0..ac1d846e8 100644 --- a/test/captcha_test.exs +++ b/test/captcha_test.exs @@ -12,8 +12,7 @@ defmodule Pleroma.CaptchaTest do alias Pleroma.Captcha.Native @ets_options [:ordered_set, :private, :named_table, {:read_concurrency, true}] - - clear_config([Pleroma.Captcha, :enabled]) + setup do: clear_config([Pleroma.Captcha, :enabled]) describe "Kocaptcha" do setup do diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 7bfae67bf..0265a6156 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Config.TransferTaskTest do alias Pleroma.Config.TransferTask alias Pleroma.ConfigDB - clear_config(:configurable_from_database, true) + setup do: clear_config(:configurable_from_database, true) test "transfer config values from db to env" do refute Application.get_env(:pleroma, :test_key) diff --git a/test/conversation_test.exs b/test/conversation_test.exs index 3c54253e3..056a0e920 100644 --- a/test/conversation_test.exs +++ b/test/conversation_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.ConversationTest do import Pleroma.Factory - clear_config_all([:instance, :federating], true) + setup_all do: clear_config([:instance, :federating], true) test "it goes through old direct conversations" do user = insert(:user) diff --git a/test/emails/mailer_test.exs b/test/emails/mailer_test.exs index f30aa6a72..e6e34cba8 100644 --- a/test/emails/mailer_test.exs +++ b/test/emails/mailer_test.exs @@ -14,8 +14,7 @@ defmodule Pleroma.Emails.MailerTest do subject: "Pleroma test email", to: [{"Test User", "user1@example.com"}] } - - clear_config([Pleroma.Emails.Mailer, :enabled]) + setup do: clear_config([Pleroma.Emails.Mailer, :enabled]) test "not send email when mailer is disabled" do Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) diff --git a/test/http/request_builder_test.exs b/test/http/request_builder_test.exs index 11a9314ae..bf3a15ebe 100644 --- a/test/http/request_builder_test.exs +++ b/test/http/request_builder_test.exs @@ -8,8 +8,8 @@ defmodule Pleroma.HTTP.RequestBuilderTest do alias Pleroma.HTTP.RequestBuilder describe "headers/2" do - clear_config([:http, :send_user_agent]) - clear_config([:http, :user_agent]) + setup do: clear_config([:http, :send_user_agent]) + setup do: clear_config([:http, :user_agent]) test "don't send pleroma user agent" do assert RequestBuilder.headers(%{}, []) == %{headers: []} diff --git a/test/object/fetcher_test.exs b/test/object/fetcher_test.exs index 4775ee152..c06e91f12 100644 --- a/test/object/fetcher_test.exs +++ b/test/object/fetcher_test.exs @@ -28,8 +28,7 @@ defmodule Pleroma.Object.FetcherTest do describe "max thread distance restriction" do @ap_id "http://mastodon.example.org/@admin/99541947525187367" - - clear_config([:instance, :federation_incoming_replies_max_depth]) + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) test "it returns thread depth exceeded error if thread depth is exceeded" do Pleroma.Config.put([:instance, :federation_incoming_replies_max_depth], 0) @@ -160,7 +159,7 @@ test "it can refetch pruned objects" do end describe "signed fetches" do - clear_config([:activitypub, :sign_object_fetches]) + setup do: clear_config([:activitypub, :sign_object_fetches]) test_with_mock "it signs fetches when configured to do so", Pleroma.Signature, diff --git a/test/object_test.exs b/test/object_test.exs index 85b2a3f6d..fe583decd 100644 --- a/test/object_test.exs +++ b/test/object_test.exs @@ -74,8 +74,8 @@ test "ensures cache is cleared for the object" do end describe "delete attachments" do - clear_config([Pleroma.Upload]) - clear_config([:instance, :cleanup_attachments]) + setup do: clear_config([Pleroma.Upload]) + setup do: clear_config([:instance, :cleanup_attachments]) test "Disabled via config" do Pleroma.Config.put([Pleroma.Upload, :uploader], Pleroma.Uploaders.Local) diff --git a/test/plugs/admin_secret_authentication_plug_test.exs b/test/plugs/admin_secret_authentication_plug_test.exs index 2e300ac0c..100016c62 100644 --- a/test/plugs/admin_secret_authentication_plug_test.exs +++ b/test/plugs/admin_secret_authentication_plug_test.exs @@ -23,7 +23,7 @@ test "does nothing if a user is assigned", %{conn: conn} do end describe "when secret set it assigns an admin user" do - clear_config([:admin_token]) + setup do: clear_config([:admin_token]) test "with `admin_token` query parameter", %{conn: conn} do Pleroma.Config.put(:admin_token, "password123") diff --git a/test/plugs/ensure_public_or_authenticated_plug_test.exs b/test/plugs/ensure_public_or_authenticated_plug_test.exs index 3fcb4d372..411252274 100644 --- a/test/plugs/ensure_public_or_authenticated_plug_test.exs +++ b/test/plugs/ensure_public_or_authenticated_plug_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlugTest do alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.User - clear_config([:instance, :public]) + setup do: clear_config([:instance, :public]) test "it halts if not public and no user is assigned", %{conn: conn} do Config.put([:instance, :public], false) diff --git a/test/plugs/http_security_plug_test.exs b/test/plugs/http_security_plug_test.exs index 944a9a139..84e4c274f 100644 --- a/test/plugs/http_security_plug_test.exs +++ b/test/plugs/http_security_plug_test.exs @@ -7,9 +7,9 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlugTest do alias Pleroma.Config alias Plug.Conn - clear_config([:http_securiy, :enabled]) - clear_config([:http_security, :sts]) - clear_config([:http_security, :referrer_policy]) + setup do: clear_config([:http_securiy, :enabled]) + setup do: clear_config([:http_security, :sts]) + setup do: clear_config([:http_security, :referrer_policy]) describe "http security enabled" do setup do diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index 2e9d2dc46..b8f070d6a 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -12,7 +12,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do on_exit(fn -> File.rm_rf(@dir) end) end - clear_config([:instance, :static_dir], @dir) + setup do: clear_config([:instance, :static_dir], @dir) test "overrides index" do bundled_index = get(build_conn(), "/") diff --git a/test/plugs/oauth_scopes_plug_test.exs b/test/plugs/oauth_scopes_plug_test.exs index 1b3aa85b6..e79ecf263 100644 --- a/test/plugs/oauth_scopes_plug_test.exs +++ b/test/plugs/oauth_scopes_plug_test.exs @@ -193,7 +193,7 @@ test "filters scopes which directly match or are ancestors of supported scopes" end describe "transform_scopes/2" do - clear_config([:auth, :enforce_oauth_admin_scope_usage]) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage]) setup do {:ok, %{f: &OAuthScopesPlug.transform_scopes/2}} diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs index c6e494c13..0ce9f3a0a 100644 --- a/test/plugs/rate_limiter_test.exs +++ b/test/plugs/rate_limiter_test.exs @@ -12,14 +12,12 @@ defmodule Pleroma.Plugs.RateLimiterTest do import Pleroma.Tests.Helpers, only: [clear_config: 1, clear_config: 2] # Note: each example must work with separate buckets in order to prevent concurrency issues - - clear_config([Pleroma.Web.Endpoint, :http, :ip]) - clear_config(:rate_limit) + setup do: clear_config([Pleroma.Web.Endpoint, :http, :ip]) + setup do: clear_config(:rate_limit) describe "config" do @limiter_name :test_init - - clear_config([Pleroma.Plugs.RemoteIp, :enabled]) + setup do: clear_config([Pleroma.Plugs.RemoteIp, :enabled]) test "config is required for plug to work" do Config.put([:rate_limit, @limiter_name], {1, 1}) diff --git a/test/plugs/remote_ip_test.exs b/test/plugs/remote_ip_test.exs index 9c3737b0b..752ab32e7 100644 --- a/test/plugs/remote_ip_test.exs +++ b/test/plugs/remote_ip_test.exs @@ -9,8 +9,7 @@ defmodule Pleroma.Plugs.RemoteIpTest do alias Pleroma.Plugs.RemoteIp import Pleroma.Tests.Helpers, only: [clear_config: 1, clear_config: 2] - - clear_config(RemoteIp) + setup do: clear_config(RemoteIp) test "disabled" do Pleroma.Config.put(RemoteIp, enabled: false) diff --git a/test/plugs/user_enabled_plug_test.exs b/test/plugs/user_enabled_plug_test.exs index 931513d83..b219d8abf 100644 --- a/test/plugs/user_enabled_plug_test.exs +++ b/test/plugs/user_enabled_plug_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Plugs.UserEnabledPlugTest do alias Pleroma.Plugs.UserEnabledPlug import Pleroma.Factory - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) test "doesn't do anything if the user isn't set", %{conn: conn} do ret_conn = diff --git a/test/plugs/user_is_admin_plug_test.exs b/test/plugs/user_is_admin_plug_test.exs index 1062d6e70..fd6a50e53 100644 --- a/test/plugs/user_is_admin_plug_test.exs +++ b/test/plugs/user_is_admin_plug_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Plugs.UserIsAdminPlugTest do import Pleroma.Factory describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) test "accepts a user that is an admin" do user = insert(:user, is_admin: true) @@ -40,7 +40,7 @@ test "denies when a user isn't set" do end describe "with [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage], true) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) setup do admin_user = insert(:user, is_admin: true) diff --git a/test/repo_test.exs b/test/repo_test.exs index 75e85f974..daffc6542 100644 --- a/test/repo_test.exs +++ b/test/repo_test.exs @@ -67,7 +67,7 @@ test "return error if has not assoc " do :ok end - clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) + setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) test "raises if it detects unapplied migrations" do assert_raise Pleroma.Repo.UnappliedMigrationsError, fn -> diff --git a/test/scheduled_activity_test.exs b/test/scheduled_activity_test.exs index 4369e7e8a..7faa5660d 100644 --- a/test/scheduled_activity_test.exs +++ b/test/scheduled_activity_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.ScheduledActivityTest do alias Pleroma.ScheduledActivity import Pleroma.Factory - clear_config([ScheduledActivity, :enabled]) + setup do: clear_config([ScheduledActivity, :enabled]) setup context do DataCase.ensure_local_uploader(context) diff --git a/test/support/helpers.ex b/test/support/helpers.ex index c6f7fa5e2..e68e9bfd2 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -17,12 +17,10 @@ defmacro clear_config(config_path) do defmacro clear_config(config_path, do: yield) do quote do - setup do - initial_setting = Config.get(unquote(config_path)) - unquote(yield) - on_exit(fn -> Config.put(unquote(config_path), initial_setting) end) - :ok - end + initial_setting = Config.get(unquote(config_path)) + unquote(yield) + on_exit(fn -> Config.put(unquote(config_path), initial_setting) end) + :ok end end @@ -34,58 +32,12 @@ defmacro clear_config(config_path, temp_setting) do end end - @doc """ - From _within a test case_, sets config to provided value and restores initial value on exit. - For multi-case setup use `clear_config/2` instead. - """ - def set_config(config_path, temp_setting) do - initial_setting = Config.get(config_path) - Config.put(config_path, temp_setting) - - ExUnit.Callbacks.on_exit(fn -> Config.put(config_path, initial_setting) end) - end - - @doc "Stores initial config value and restores it after *all* test examples are executed." - defmacro clear_config_all(config_path) do - quote do - clear_config_all(unquote(config_path)) do - end - end - end - - @doc """ - Stores initial config value and restores it after *all* test examples are executed. - Only use if *all* test examples should work with the same stubbed value - (*no* examples set a different value). - """ - defmacro clear_config_all(config_path, do: yield) do - quote do - setup_all do - initial_setting = Config.get(unquote(config_path)) - unquote(yield) - on_exit(fn -> Config.put(unquote(config_path), initial_setting) end) - :ok - end - end - end - - defmacro clear_config_all(config_path, temp_setting) do - quote do - clear_config_all(unquote(config_path)) do - Config.put(unquote(config_path), unquote(temp_setting)) - end - end - end - defmacro __using__(_opts) do quote do import Pleroma.Tests.Helpers, only: [ clear_config: 1, - clear_config: 2, - clear_config_all: 1, - clear_config_all: 2, - set_config: 2 + clear_config: 2 ] def to_datetime(naive_datetime) do diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index b0c2efc98..3dee4f082 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -20,7 +20,7 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do :ok end - clear_config_all(:configurable_from_database, true) + setup_all do: clear_config(:configurable_from_database, true) test "error if file with custom settings doesn't exist" do Mix.Tasks.Pleroma.Config.migrate_to_db("config/not_existance_config_file.exs") diff --git a/test/tasks/robots_txt_test.exs b/test/tasks/robots_txt_test.exs index e03c9c192..7040a0e4e 100644 --- a/test/tasks/robots_txt_test.exs +++ b/test/tasks/robots_txt_test.exs @@ -7,7 +7,7 @@ defmodule Mix.Tasks.Pleroma.RobotsTxtTest do use Pleroma.Tests.Helpers alias Mix.Tasks.Pleroma.RobotsTxt - clear_config([:instance, :static_dir]) + setup do: clear_config([:instance, :static_dir]) test "creates new dir" do path = "test/fixtures/new_dir/" diff --git a/test/upload/filter/anonymize_filename_test.exs b/test/upload/filter/anonymize_filename_test.exs index 330158580..2d5c580f1 100644 --- a/test/upload/filter/anonymize_filename_test.exs +++ b/test/upload/filter/anonymize_filename_test.exs @@ -18,7 +18,7 @@ defmodule Pleroma.Upload.Filter.AnonymizeFilenameTest do %{upload_file: upload_file} end - clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text]) + setup do: clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text]) test "it replaces filename on pre-defined text", %{upload_file: upload_file} do Config.put([Upload.Filter.AnonymizeFilename, :text], "custom-file.png") diff --git a/test/upload/filter/mogrify_test.exs b/test/upload/filter/mogrify_test.exs index 52483d80c..b6a463e8c 100644 --- a/test/upload/filter/mogrify_test.exs +++ b/test/upload/filter/mogrify_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Upload.Filter.MogrifyTest do alias Pleroma.Upload alias Pleroma.Upload.Filter - clear_config([Filter.Mogrify, :args]) + setup do: clear_config([Filter.Mogrify, :args]) test "apply mogrify filter" do Config.put([Filter.Mogrify, :args], [{"tint", "40"}]) diff --git a/test/upload/filter_test.exs b/test/upload/filter_test.exs index 2ffc5247b..352b66402 100644 --- a/test/upload/filter_test.exs +++ b/test/upload/filter_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Upload.FilterTest do alias Pleroma.Config alias Pleroma.Upload.Filter - clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text]) + setup do: clear_config([Pleroma.Upload.Filter.AnonymizeFilename, :text]) test "applies filters" do Config.put([Pleroma.Upload.Filter.AnonymizeFilename, :text], "custom-file.png") diff --git a/test/upload_test.exs b/test/upload_test.exs index 6bf7f2417..060a940bb 100644 --- a/test/upload_test.exs +++ b/test/upload_test.exs @@ -250,7 +250,7 @@ test "escapes reserved uri characters" do end describe "Setting a custom base_url for uploaded media" do - clear_config([Pleroma.Upload, :base_url], "https://cache.pleroma.social") + setup do: clear_config([Pleroma.Upload, :base_url], "https://cache.pleroma.social") test "returns a media url with configured base_url" do base_url = Pleroma.Config.get([Pleroma.Upload, :base_url]) diff --git a/test/uploaders/s3_test.exs b/test/uploaders/s3_test.exs index 96c21c0e5..6950ccb25 100644 --- a/test/uploaders/s3_test.exs +++ b/test/uploaders/s3_test.exs @@ -11,10 +11,11 @@ defmodule Pleroma.Uploaders.S3Test do import Mock import ExUnit.CaptureLog - clear_config(Pleroma.Uploaders.S3, - bucket: "test_bucket", - public_endpoint: "https://s3.amazonaws.com" - ) + setup do: + clear_config(Pleroma.Uploaders.S3, + bucket: "test_bucket", + public_endpoint: "https://s3.amazonaws.com" + ) describe "get_file/1" do test "it returns path to local folder for files" do diff --git a/test/user_search_test.exs b/test/user_search_test.exs index 406cc8fb2..cb847b516 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -15,7 +15,7 @@ defmodule Pleroma.UserSearchTest do end describe "User.search" do - clear_config([:instance, :limit_to_local_content]) + setup do: clear_config([:instance, :limit_to_local_content]) test "excluded invisible users from results" do user = insert(:user, %{nickname: "john t1000"}) diff --git a/test/user_test.exs b/test/user_test.exs index e0e7a26b8..119a36ec1 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -24,7 +24,7 @@ defmodule Pleroma.UserTest do :ok end - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) describe "service actors" do test "returns updated invisible actor" do @@ -297,7 +297,7 @@ test "local users do not automatically follow local locked accounts" do end describe "unfollow/2" do - clear_config([:instance, :external_user_synchronization]) + setup do: clear_config([:instance, :external_user_synchronization]) test "unfollow with syncronizes external user" do Pleroma.Config.put([:instance, :external_user_synchronization], true) @@ -375,10 +375,9 @@ test "fetches correct profile for nickname beginning with number" do password_confirmation: "test", email: "email@example.com" } - - clear_config([:instance, :autofollowed_nicknames]) - clear_config([:instance, :welcome_message]) - clear_config([:instance, :welcome_user_nickname]) + setup do: clear_config([:instance, :autofollowed_nicknames]) + setup do: clear_config([:instance, :welcome_message]) + setup do: clear_config([:instance, :welcome_user_nickname]) test "it autofollows accounts that are set for it" do user = insert(:user) @@ -412,7 +411,7 @@ test "it sends a welcome message if it is set" do assert activity.actor == welcome_user.ap_id end - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do Pleroma.Config.put([:instance, :account_activation_required], true) @@ -475,8 +474,7 @@ test "it sets the password_hash and ap_id" do password_confirmation: "test", email: "email@example.com" } - - clear_config([:instance, :account_activation_required], true) + setup do: clear_config([:instance, :account_activation_required], true) test "it creates unconfirmed user" do changeset = User.register_changeset(%User{}, @full_user_data) @@ -619,9 +617,8 @@ test "returns an ap_followers link for a user" do ap_id: "http...", avatar: %{some: "avatar"} } - - clear_config([:instance, :user_bio_length]) - clear_config([:instance, :user_name_length]) + setup do: clear_config([:instance, :user_bio_length]) + setup do: clear_config([:instance, :user_name_length]) test "it confirms validity" do cs = User.remote_user_creation(@valid_remote) @@ -1114,7 +1111,7 @@ test "hide a user's statuses from timelines and notifications" do [user: user] end - clear_config([:instance, :federating]) + setup do: clear_config([:instance, :federating]) test ".delete_user_activities deletes all create activities", %{user: user} do {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu"}) @@ -1295,7 +1292,7 @@ test "User.delete() plugs any possible zombie objects" do end describe "account_status/1" do - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) test "return confirmation_pending for unconfirm user" do Pleroma.Config.put([:instance, :account_activation_required], true) @@ -1663,7 +1660,7 @@ test "performs update cache if user updated" do end describe "following/followers synchronization" do - clear_config([:instance, :external_user_synchronization]) + setup do: clear_config([:instance, :external_user_synchronization]) test "updates the counters normally on following/getting a follow when disabled" do Pleroma.Config.put([:instance, :external_user_synchronization], false) @@ -1768,7 +1765,7 @@ test "changes email", %{user: user} do [local_user: local_user, remote_user: remote_user] end - clear_config([:instance, :limit_to_local_content]) + setup do: clear_config([:instance, :limit_to_local_content]) test "allows getting remote users by id no matter what :limit_to_local_content is set to", %{ remote_user: remote_user diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index df0c53458..573853afa 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -26,10 +26,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do :ok end - clear_config([:instance, :federating], true) + setup do: clear_config([:instance, :federating], true) describe "/relay" do - clear_config([:instance, :allow_relay]) + setup do: clear_config([:instance, :allow_relay]) test "with the relay active, it returns the relay user", %{conn: conn} do res = @@ -1225,8 +1225,8 @@ test "GET /api/ap/whoami", %{conn: conn} do |> json_response(403) end - clear_config([:media_proxy]) - clear_config([Pleroma.Upload]) + setup do: clear_config([:media_proxy]) + setup do: clear_config([Pleroma.Upload]) test "POST /api/ap/upload_media", %{conn: conn} do user = insert(:user) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index d86c8260e..a43dd34f0 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -27,7 +27,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do :ok end - clear_config([:instance, :federating]) + setup do: clear_config([:instance, :federating]) describe "streaming out participations" do test "it streams them out" do @@ -1396,7 +1396,7 @@ test "creates an undo activity for the last block" do end describe "deletion" do - clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:instance, :rewrite_policy]) test "it reverts deletion on error" do note = insert(:note_activity) @@ -1580,7 +1580,7 @@ test "it filters broken threads" do end describe "update" do - clear_config([:instance, :max_pinned_statuses]) + setup do: clear_config([:instance, :max_pinned_statuses]) test "it creates an update activity with the new user data" do user = insert(:user) diff --git a/test/web/activity_pub/mrf/hellthread_policy_test.exs b/test/web/activity_pub/mrf/hellthread_policy_test.exs index 916b95692..95ef0b168 100644 --- a/test/web/activity_pub/mrf/hellthread_policy_test.exs +++ b/test/web/activity_pub/mrf/hellthread_policy_test.exs @@ -26,7 +26,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do [user: user, message: message] end - clear_config(:mrf_hellthread) + setup do: clear_config(:mrf_hellthread) describe "reject" do test "rejects the message if the recipient count is above reject_threshold", %{ diff --git a/test/web/activity_pub/mrf/keyword_policy_test.exs b/test/web/activity_pub/mrf/keyword_policy_test.exs index 18242a889..fd1f7aec8 100644 --- a/test/web/activity_pub/mrf/keyword_policy_test.exs +++ b/test/web/activity_pub/mrf/keyword_policy_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicyTest do alias Pleroma.Web.ActivityPub.MRF.KeywordPolicy - clear_config(:mrf_keyword) + setup do: clear_config(:mrf_keyword) setup do Pleroma.Config.put([:mrf_keyword], %{reject: [], federated_timeline_removal: [], replace: []}) diff --git a/test/web/activity_pub/mrf/mention_policy_test.exs b/test/web/activity_pub/mrf/mention_policy_test.exs index 08f7be542..aa003bef5 100644 --- a/test/web/activity_pub/mrf/mention_policy_test.exs +++ b/test/web/activity_pub/mrf/mention_policy_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicyTest do alias Pleroma.Web.ActivityPub.MRF.MentionPolicy - clear_config(:mrf_mention) + setup do: clear_config(:mrf_mention) test "pass filter if allow list is empty" do Pleroma.Config.delete([:mrf_mention]) diff --git a/test/web/activity_pub/mrf/mrf_test.exs b/test/web/activity_pub/mrf/mrf_test.exs index 04709df17..c941066f2 100644 --- a/test/web/activity_pub/mrf/mrf_test.exs +++ b/test/web/activity_pub/mrf/mrf_test.exs @@ -60,7 +60,7 @@ test "matches are case-insensitive" do end describe "describe/0" do - clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:instance, :rewrite_policy]) test "it works as expected with noop policy" do expected = %{ 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 bdbbb1fc4..0fbc5f57a 100644 --- a/test/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/web/activity_pub/mrf/object_age_policy_test.exs @@ -9,10 +9,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do alias Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy alias Pleroma.Web.ActivityPub.Visibility - clear_config(:mrf_object_age, - threshold: 172_800, - actions: [:delist, :strip_followers] - ) + setup do: + clear_config(:mrf_object_age, + threshold: 172_800, + actions: [:delist, :strip_followers] + ) setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) 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..abfd32df8 100644 --- a/test/web/activity_pub/mrf/reject_non_public_test.exs +++ b/test/web/activity_pub/mrf/reject_non_public_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublicTest do alias Pleroma.Web.ActivityPub.MRF.RejectNonPublic - clear_config([:mrf_rejectnonpublic]) + setup do: clear_config([:mrf_rejectnonpublic]) describe "public message" do test "it's allowed when address is public" do diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index 97aec6622..5aebbc675 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -8,16 +8,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF.SimplePolicy - clear_config(:mrf_simple, - media_removal: [], - media_nsfw: [], - federated_timeline_removal: [], - report_removal: [], - reject: [], - accept: [], - avatar_removal: [], - banner_removal: [] - ) + setup do: + clear_config(:mrf_simple, + media_removal: [], + media_nsfw: [], + federated_timeline_removal: [], + report_removal: [], + reject: [], + accept: [], + avatar_removal: [], + banner_removal: [] + ) describe "when :media_removal" do test "is empty" do diff --git a/test/web/activity_pub/mrf/subchain_policy_test.exs b/test/web/activity_pub/mrf/subchain_policy_test.exs index 221b8958e..fff66cb7e 100644 --- a/test/web/activity_pub/mrf/subchain_policy_test.exs +++ b/test/web/activity_pub/mrf/subchain_policy_test.exs @@ -13,8 +13,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicyTest do "type" => "Create", "object" => %{"content" => "hi"} } - - clear_config([:mrf_subchain, :match_actor]) + setup do: clear_config([:mrf_subchain, :match_actor]) test "it matches and processes subchains when the actor matches a configured target" do Pleroma.Config.put([:mrf_subchain, :match_actor], %{ diff --git a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs index 87c9e1b29..724bae058 100644 --- a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs +++ b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicyTest do alias Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy - clear_config([:mrf_user_allowlist, :localhost]) + setup do: clear_config([:mrf_user_allowlist, :localhost]) test "pass filter if allow list is empty" do actor = insert(:user) diff --git a/test/web/activity_pub/mrf/vocabulary_policy_test.exs b/test/web/activity_pub/mrf/vocabulary_policy_test.exs index d9207b095..69f22bb77 100644 --- a/test/web/activity_pub/mrf/vocabulary_policy_test.exs +++ b/test/web/activity_pub/mrf/vocabulary_policy_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicyTest do alias Pleroma.Web.ActivityPub.MRF.VocabularyPolicy describe "accept" do - clear_config([:mrf_vocabulary, :accept]) + setup do: clear_config([:mrf_vocabulary, :accept]) test "it accepts based on parent activity type" do Pleroma.Config.put([:mrf_vocabulary, :accept], ["Like"]) @@ -65,7 +65,7 @@ test "it does not accept disallowed parent types" do end describe "reject" do - clear_config([:mrf_vocabulary, :reject]) + setup do: clear_config([:mrf_vocabulary, :reject]) test "it rejects based on parent activity type" do Pleroma.Config.put([:mrf_vocabulary, :reject], ["Like"]) diff --git a/test/web/activity_pub/publisher_test.exs b/test/web/activity_pub/publisher_test.exs index ed9c951dd..801da03c1 100644 --- a/test/web/activity_pub/publisher_test.exs +++ b/test/web/activity_pub/publisher_test.exs @@ -23,7 +23,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do :ok end - clear_config_all([:instance, :federating], true) + setup_all do: clear_config([:instance, :federating], true) describe "gather_webfinger_links/1" do test "it returns links" do diff --git a/test/web/activity_pub/relay_test.exs b/test/web/activity_pub/relay_test.exs index e3115dcd8..040625e4d 100644 --- a/test/web/activity_pub/relay_test.exs +++ b/test/web/activity_pub/relay_test.exs @@ -68,7 +68,7 @@ test "returns activity" do end describe "publish/1" do - clear_config([:instance, :federating]) + setup do: clear_config([:instance, :federating]) test "returns error when activity not `Create` type" do activity = insert(:like_activity) diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index c3d3f9830..967389fae 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -19,7 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do end describe "handle_incoming" do - clear_config([:user, :deny_follow_blocked]) + setup do: clear_config([:user, :deny_follow_blocked]) test "it works for osada follow request" do user = insert(:user) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index c025b6b78..b2cabbd30 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -25,7 +25,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do :ok end - clear_config([:instance, :max_remote_account_fields]) + setup do: clear_config([:instance, :max_remote_account_fields]) describe "handle_incoming" do test "it ignores an incoming notice if we already have it" do @@ -1351,9 +1351,8 @@ test "it accepts Move activities" do end describe "`handle_incoming/2`, Mastodon format `replies` handling" do - clear_config([:activitypub, :note_replies_output_limit], 5) - - clear_config([:instance, :federation_incoming_replies_max_depth]) + setup do: clear_config([:activitypub, :note_replies_output_limit], 5) + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) setup do data = @@ -1392,9 +1391,8 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth end describe "`handle_incoming/2`, Pleroma format `replies` handling" do - clear_config([:activitypub, :note_replies_output_limit], 5) - - clear_config([:instance, :federation_incoming_replies_max_depth]) + setup do: clear_config([:activitypub, :note_replies_output_limit], 5) + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) setup do user = insert(:user) @@ -1878,7 +1876,7 @@ test "returns fixed object" do end describe "fix_in_reply_to/2" do - clear_config([:instance, :federation_incoming_replies_max_depth]) + setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) setup do data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) @@ -2141,7 +2139,7 @@ test "returns object with emoji when object contains map tag" do end describe "set_replies/1" do - clear_config([:activitypub, :note_replies_output_limit], 2) + setup do: clear_config([:activitypub, :note_replies_output_limit], 2) test "returns unmodified object if activity doesn't have self-replies" do data = Poison.decode!(File.read!("test/fixtures/mastodon-post-activity.json")) diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs index 7dda20ec3..de5ffc5b3 100644 --- a/test/web/activity_pub/views/object_view_test.exs +++ b/test/web/activity_pub/views/object_view_test.exs @@ -37,7 +37,7 @@ test "renders a note activity" do end describe "note activity's `replies` collection rendering" do - clear_config([:activitypub, :note_replies_output_limit], 5) + setup do: clear_config([:activitypub, :note_replies_output_limit], 5) test "renders `replies` collection for a note activity" do user = insert(:user) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 5f3064941..0a902585d 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -43,7 +43,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do end describe "with [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage], true) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope", %{admin: admin} do @@ -91,7 +91,7 @@ test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or bro end describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do - clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) test "GET /api/pleroma/admin/users/:nickname requires " <> "read:accounts or admin:read:accounts or broader scope", @@ -577,8 +577,8 @@ test "/:right DELETE, can remove from a permission group (multiple)", %{ end describe "POST /api/pleroma/admin/email_invite, with valid config" do - clear_config([:instance, :registrations_open], false) - clear_config([:instance, :invites_enabled], true) + setup do: clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :invites_enabled], true) test "sends invitation and returns 204", %{admin: admin, conn: conn} do recipient_email = "foo@bar.com" @@ -629,8 +629,8 @@ test "it returns 403 if requested by a non-admin" do end describe "POST /api/pleroma/admin/users/email_invite, with invalid config" do - clear_config([:instance, :registrations_open]) - clear_config([:instance, :invites_enabled]) + setup do: clear_config([:instance, :registrations_open]) + setup do: clear_config([:instance, :invites_enabled]) test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do Config.put([:instance, :registrations_open], false) @@ -1879,7 +1879,7 @@ test "returns 404 when the status does not exist", %{conn: conn} do end describe "GET /api/pleroma/admin/config" do - clear_config(:configurable_from_database, true) + setup do: clear_config(:configurable_from_database, true) test "when configuration from database is off", %{conn: conn} do Config.put(:configurable_from_database, false) @@ -2030,7 +2030,7 @@ test "POST /api/pleroma/admin/config error", %{conn: conn} do end) end - clear_config(:configurable_from_database, true) + setup do: clear_config(:configurable_from_database, true) @tag capture_log: true test "create new config setting in db", %{conn: conn} do @@ -3039,7 +3039,7 @@ test "proxy tuple ip", %{conn: conn} do end describe "GET /api/pleroma/admin/restart" do - clear_config(:configurable_from_database, true) + setup do: clear_config(:configurable_from_database, true) test "pleroma restarts", %{conn: conn} do capture_log(fn -> diff --git a/test/web/chat_channel_test.exs b/test/web/chat_channel_test.exs index 68c24a9f9..f18f3a212 100644 --- a/test/web/chat_channel_test.exs +++ b/test/web/chat_channel_test.exs @@ -21,7 +21,7 @@ test "it broadcasts a message", %{socket: socket} do end describe "message lengths" do - clear_config([:instance, :chat_limit]) + setup do: clear_config([:instance, :chat_limit]) test "it ignores messages of length zero", %{socket: socket} do push(socket, "new_msg", %{"text" => ""}) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index b80523160..0da0bd2e2 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -17,9 +17,9 @@ defmodule Pleroma.Web.CommonAPITest do require Pleroma.Constants - clear_config([:instance, :safe_dm_mentions]) - clear_config([:instance, :limit]) - clear_config([:instance, :max_pinned_statuses]) + setup do: clear_config([:instance, :safe_dm_mentions]) + setup do: clear_config([:instance, :limit]) + setup do: clear_config([:instance, :max_pinned_statuses]) test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do user = insert(:user) diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index 2b321d263..da844c24c 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -21,11 +21,10 @@ defmodule Pleroma.Web.FederatorTest do :ok end - clear_config_all([:instance, :federating], true) - - clear_config([:instance, :allow_relay]) - clear_config([:instance, :rewrite_policy]) - clear_config([:mrf_keyword]) + setup_all do: clear_config([:instance, :federating], true) + setup do: clear_config([:instance, :allow_relay]) + setup do: clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:mrf_keyword]) describe "Publish an activity" do setup do diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index 5950605e8..1ec39ec5d 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Feed.TagControllerTest do alias Pleroma.Web.Feed.FeedView - clear_config([:feed]) + setup do: clear_config([:feed]) test "gets a feed (ATOM)", %{conn: conn} do Pleroma.Config.put( diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index 49cfecde3..3e52eb42b 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -12,10 +12,10 @@ defmodule Pleroma.Web.Feed.UserControllerTest do alias Pleroma.Object alias Pleroma.User - clear_config([:instance, :federating], true) + setup do: clear_config([:instance, :federating], true) describe "feed" do - clear_config([:feed]) + setup do: clear_config([:feed]) test "gets a feed", %{conn: conn} do Config.put( diff --git a/test/web/instances/instance_test.exs b/test/web/instances/instance_test.exs index ab8e5643b..e463200ca 100644 --- a/test/web/instances/instance_test.exs +++ b/test/web/instances/instance_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Instances.InstanceTest do import Pleroma.Factory - clear_config_all([:instance, :federation_reachability_timeout_days], 1) + setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1) describe "set_reachable/1" do test "clears `unreachable_since` of existing matching Instance record having non-nil `unreachable_since`" do diff --git a/test/web/instances/instances_test.exs b/test/web/instances/instances_test.exs index 1d83c1a1c..d2618025c 100644 --- a/test/web/instances/instances_test.exs +++ b/test/web/instances/instances_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.InstancesTest do use Pleroma.DataCase - clear_config_all([:instance, :federation_reachability_timeout_days], 1) + setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1) describe "reachable?/1" do test "returns `true` for host / url with unknown reachability status" do diff --git a/test/web/masto_fe_controller_test.exs b/test/web/masto_fe_controller_test.exs index 9a2d76e0b..1d107d56c 100644 --- a/test/web/masto_fe_controller_test.exs +++ b/test/web/masto_fe_controller_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.MastoFEController do import Pleroma.Factory - clear_config([:instance, :public]) + setup do: clear_config([:instance, :public]) test "put settings", %{conn: conn} do user = insert(:user) 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 cba68859e..43538cb17 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 @@ -9,7 +9,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do use Pleroma.Web.ConnCase import Pleroma.Factory - clear_config([:instance, :max_account_fields]) + + setup do: clear_config([:instance, :max_account_fields]) describe "updating credentials" do setup do: oauth_access(["write:accounts"]) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 59ad0a596..a9fa0ce48 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do import Pleroma.Factory describe "account fetching" do - clear_config([:instance, :limit_to_local_content]) + setup do: clear_config([:instance, :limit_to_local_content]) test "works by id" do user = insert(:user) @@ -150,13 +150,9 @@ defp local_and_remote_users do describe "user fetching with restrict unauthenticated profiles for local and remote" do setup do: local_and_remote_users() - clear_config([:restrict_unauthenticated, :profiles, :local]) do - Config.put([:restrict_unauthenticated, :profiles, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) - clear_config([:restrict_unauthenticated, :profiles, :remote]) do - Config.put([:restrict_unauthenticated, :profiles, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") @@ -186,9 +182,7 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "user fetching with restrict unauthenticated profiles for local" do setup do: local_and_remote_users() - clear_config([:restrict_unauthenticated, :profiles, :local]) do - Config.put([:restrict_unauthenticated, :profiles, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") @@ -215,9 +209,7 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "user fetching with restrict unauthenticated profiles for remote" do setup do: local_and_remote_users() - clear_config([:restrict_unauthenticated, :profiles, :remote]) do - Config.put([:restrict_unauthenticated, :profiles, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") @@ -405,13 +397,9 @@ defp local_and_remote_activities(%{local: local, remote: remote}) do setup do: local_and_remote_users() setup :local_and_remote_activities - clear_config([:restrict_unauthenticated, :profiles, :local]) do - Config.put([:restrict_unauthenticated, :profiles, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) - clear_config([:restrict_unauthenticated, :profiles, :remote]) do - Config.put([:restrict_unauthenticated, :profiles, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") @@ -442,9 +430,7 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: local_and_remote_users() setup :local_and_remote_activities - clear_config([:restrict_unauthenticated, :profiles, :local]) do - Config.put([:restrict_unauthenticated, :profiles, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") @@ -472,9 +458,7 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: local_and_remote_users() setup :local_and_remote_activities - clear_config([:restrict_unauthenticated, :profiles, :remote]) do - Config.put([:restrict_unauthenticated, :profiles, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") @@ -806,7 +790,7 @@ test "blocking / unblocking a user" do [valid_params: valid_params] end - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) test "Account registration via Application", %{conn: conn} do conn = @@ -904,7 +888,7 @@ test "returns bad_request if missing required params", %{ end) end - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) test "returns bad_request if missing email params when :account_activation_required is enabled", %{conn: conn, valid_params: valid_params} do @@ -961,7 +945,7 @@ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_ end describe "create account by app / rate limit" do - clear_config([:rate_limit, :app_account_creation], {10_000, 2}) + setup do: clear_config([:rate_limit, :app_account_creation], {10_000, 2}) test "respects rate limit setting", %{conn: conn} do app_token = insert(:oauth_token, user: nil) diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs index 203fa73b0..6ac4cf63b 100644 --- a/test/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -22,8 +22,8 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do [image: image] end - clear_config([:media_proxy]) - clear_config([Pleroma.Upload]) + setup do: clear_config([:media_proxy]) + setup do: clear_config([Pleroma.Upload]) test "returns uploaded image", %{conn: conn, image: image} do desc = "Description of the image" diff --git a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs index 3cd08c189..f86274d57 100644 --- a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs +++ b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityControllerTest do import Pleroma.Factory import Ecto.Query - clear_config([ScheduledActivity, :enabled]) + setup do: clear_config([ScheduledActivity, :enabled]) test "shows scheduled activities" do %{user: user, conn: conn} = oauth_access(["read:statuses"]) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index beb547780..d59974d50 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -19,9 +19,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do import Pleroma.Factory - clear_config([:instance, :federating]) - clear_config([:instance, :allow_relay]) - clear_config([:rich_media, :enabled]) + setup do: clear_config([:instance, :federating]) + setup do: clear_config([:instance, :allow_relay]) + setup do: clear_config([:rich_media, :enabled]) describe "posting statuses" do setup do: oauth_access(["write:statuses"]) @@ -485,13 +485,9 @@ defp local_and_remote_activities do describe "status with restrict unauthenticated activities for local and remote" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :activities, :local]) do - Config.put([:restrict_unauthenticated, :activities, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) - clear_config([:restrict_unauthenticated, :activities, :remote]) do - Config.put([:restrict_unauthenticated, :activities, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses/#{local.id}") @@ -520,9 +516,7 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "status with restrict unauthenticated activities for local" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :activities, :local]) do - Config.put([:restrict_unauthenticated, :activities, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses/#{local.id}") @@ -548,9 +542,7 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "status with restrict unauthenticated activities for remote" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :activities, :remote]) do - Config.put([:restrict_unauthenticated, :activities, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses/#{local.id}") @@ -614,13 +606,9 @@ test "get statuses by IDs" do describe "getting statuses by ids with restricted unauthenticated for local and remote" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :activities, :local]) do - Config.put([:restrict_unauthenticated, :activities, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) - clear_config([:restrict_unauthenticated, :activities, :remote]) do - Config.put([:restrict_unauthenticated, :activities, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) @@ -640,9 +628,7 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "getting statuses by ids with restricted unauthenticated for local" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :activities, :local]) do - Config.put([:restrict_unauthenticated, :activities, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) @@ -663,9 +649,7 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "getting statuses by ids with restricted unauthenticated for remote" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :activities, :remote]) do - Config.put([:restrict_unauthenticated, :activities, :remote], true) - end + setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) @@ -908,7 +892,7 @@ test "returns 404 error for a wrong id", %{conn: conn} do %{activity: activity} end - clear_config([:instance, :max_pinned_statuses], 1) + setup do: clear_config([:instance, :max_pinned_statuses], 1) test "pin status", %{conn: conn, user: user, activity: activity} do id_str = to_string(activity.id) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index a15c759d4..6fedb4223 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -100,13 +100,9 @@ defp local_and_remote_activities do describe "public with restrict unauthenticated timeline for local and federated timelines" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :timelines, :local]) do - Config.put([:restrict_unauthenticated, :timelines, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true) - clear_config([:restrict_unauthenticated, :timelines, :federated]) do - Config.put([:restrict_unauthenticated, :timelines, :federated], true) - end + setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true) test "if user is unauthenticated", %{conn: conn} do res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) @@ -136,9 +132,7 @@ test "if user is authenticated" do describe "public with restrict unauthenticated timeline for local" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :timelines, :local]) do - Config.put([:restrict_unauthenticated, :timelines, :local], true) - end + setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true) test "if user is unauthenticated", %{conn: conn} do res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) @@ -165,9 +159,7 @@ test "if user is authenticated", %{conn: _conn} do describe "public with restrict unauthenticated timeline for remote" do setup do: local_and_remote_activities() - clear_config([:restrict_unauthenticated, :timelines, :federated]) do - Config.put([:restrict_unauthenticated, :timelines, :federated], true) - end + setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true) test "if user is unauthenticated", %{conn: conn} do res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 7ac7e4af1..da79d38a5 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -7,8 +7,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do import Mock alias Pleroma.Config - clear_config(:media_proxy) - clear_config([Pleroma.Web.Endpoint, :secret_key_base]) + setup do: clear_config(:media_proxy) + setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base]) test "it returns 404 when MediaProxy disabled", %{conn: conn} do Config.put([:media_proxy, :enabled], false) diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs index 8f5fcf2eb..69c2d5dae 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -8,8 +8,8 @@ defmodule Pleroma.Web.MediaProxyTest do import Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy.MediaProxyController - clear_config([:media_proxy, :enabled]) - clear_config(Pleroma.Upload) + setup do: clear_config([:media_proxy, :enabled]) + setup do: clear_config(Pleroma.Upload) describe "when enabled" do setup do diff --git a/test/web/metadata/opengraph_test.exs b/test/web/metadata/opengraph_test.exs index 9d7c009eb..218540e6c 100644 --- a/test/web/metadata/opengraph_test.exs +++ b/test/web/metadata/opengraph_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraphTest do import Pleroma.Factory alias Pleroma.Web.Metadata.Providers.OpenGraph - clear_config([Pleroma.Web.Metadata, :unfurl_nsfw]) + setup do: clear_config([Pleroma.Web.Metadata, :unfurl_nsfw]) test "it renders all supported types of attachments and skips unknown types" do user = insert(:user) diff --git a/test/web/metadata/twitter_card_test.exs b/test/web/metadata/twitter_card_test.exs index 3d75d1ed5..9e9c6853a 100644 --- a/test/web/metadata/twitter_card_test.exs +++ b/test/web/metadata/twitter_card_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do alias Pleroma.Web.Metadata.Utils alias Pleroma.Web.Router - clear_config([Pleroma.Web.Metadata, :unfurl_nsfw]) + setup do: clear_config([Pleroma.Web.Metadata, :unfurl_nsfw]) test "it renders twitter card for user info" do user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index ee10ad5db..43f322606 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -7,8 +7,8 @@ defmodule Pleroma.Web.NodeInfoTest do import Pleroma.Factory - clear_config([:mrf_simple]) - clear_config(:instance) + setup do: clear_config([:mrf_simple]) + setup do: clear_config(:instance) test "GET /.well-known/nodeinfo", %{conn: conn} do links = @@ -105,7 +105,7 @@ test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do end describe "`metadata/federation/enabled`" do - clear_config([:instance, :federating]) + setup do: clear_config([:instance, :federating]) test "it shows if federation is enabled/disabled", %{conn: conn} do Pleroma.Config.put([:instance, :federating], true) diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs index b348281c5..a8fe8a841 100644 --- a/test/web/oauth/ldap_authorization_test.exs +++ b/test/web/oauth/ldap_authorization_test.exs @@ -12,9 +12,9 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do @skip if !Code.ensure_loaded?(:eldap), do: :skip - clear_config_all([:ldap, :enabled], true) + setup_all do: clear_config([:ldap, :enabled], true) - clear_config_all(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator) + setup_all do: clear_config(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator) @tag @skip test "authorizes the existing user using LDAP credentials" do diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index 592612ddf..0b0972b17 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -17,8 +17,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do key: "_test", signing_salt: "cooldude" ] - - clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_activation_required]) describe "in OAuth consumer mode, " do setup do @@ -31,7 +30,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do ] end - clear_config([:auth, :oauth_consumer_strategies], ~w(twitter facebook)) + setup do: clear_config([:auth, :oauth_consumer_strategies], ~w(twitter facebook)) test "GET /oauth/authorize renders auth forms, including OAuth consumer form", %{ app: app, @@ -939,7 +938,7 @@ test "rejects an invalid authorization code" do end describe "POST /oauth/token - refresh token" do - clear_config([:oauth2, :issue_new_refresh_token]) + setup do: clear_config([:oauth2, :issue_new_refresh_token]) test "issues a new access token with keep fresh token" do Pleroma.Config.put([:oauth2, :issue_new_refresh_token], true) diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index 6a3dcf2cd..6787b414b 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -17,7 +17,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do :ok end - clear_config([:instance, :federating], true) + setup do: clear_config([:instance, :federating], true) # Note: see ActivityPubControllerTest for JSON format tests describe "GET /objects/:uuid (text/html)" do diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index bc359707d..2aa87ac30 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -27,7 +27,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do [user: user] end - clear_config([:instance, :account_activation_required], true) + setup do: clear_config([:instance, :account_activation_required], true) test "resend account confirmation email", %{conn: conn, user: user} do conn diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 146f3f4fe..435fb6592 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -12,8 +12,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do Pleroma.Config.get!([:instance, :static_dir]), "emoji" ) - - clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) test "shared & non-shared pack information in list_packs is ok" do conn = build_conn() diff --git a/test/web/plugs/federating_plug_test.exs b/test/web/plugs/federating_plug_test.exs index 13edc4359..2f8aadadc 100644 --- a/test/web/plugs/federating_plug_test.exs +++ b/test/web/plugs/federating_plug_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.FederatingPlugTest do use Pleroma.Web.ConnCase - clear_config([:instance, :federating]) + setup do: clear_config([:instance, :federating]) test "returns and halt the conn when federating is disabled" do Pleroma.Config.put([:instance, :federating], false) diff --git a/test/web/rich_media/helpers_test.exs b/test/web/rich_media/helpers_test.exs index 8237802a7..aa0c5c830 100644 --- a/test/web/rich_media/helpers_test.exs +++ b/test/web/rich_media/helpers_test.exs @@ -19,7 +19,7 @@ defmodule Pleroma.Web.RichMedia.HelpersTest do :ok end - clear_config([:rich_media, :enabled]) + setup do: clear_config([:rich_media, :enabled]) test "refuses to crawl incomplete URLs" do user = insert(:user) diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index aabbedb17..430683ea0 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -8,9 +8,8 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do import Pleroma.Factory - clear_config_all([:static_fe, :enabled], true) - - clear_config([:instance, :federating], true) + setup_all do: clear_config([:static_fe, :enabled], true) + setup do: clear_config([:instance, :federating], true) setup %{conn: conn} do conn = put_req_header(conn, "accept", "text/html") diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 339f99bbf..a5d6e8ecf 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -19,8 +19,7 @@ defmodule Pleroma.Web.StreamerTest do @streamer_timeout 150 @streamer_start_wait 10 - - clear_config([:instance, :skip_thread_containment]) + setup do: clear_config([:instance, :skip_thread_containment]) describe "user streams" do setup do diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs index 5c6087527..5ff8694a8 100644 --- a/test/web/twitter_api/remote_follow_controller_test.exs +++ b/test/web/twitter_api/remote_follow_controller_test.exs @@ -17,11 +17,10 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do :ok end - clear_config_all([:instance, :federating], true) - - clear_config([:instance]) - clear_config([:frontend_configurations, :pleroma_fe]) - clear_config([:user, :deny_follow_blocked]) + setup_all do: clear_config([:instance, :federating], true) + setup do: clear_config([:instance]) + setup do: clear_config([:frontend_configurations, :pleroma_fe]) + setup do: clear_config([:user, :deny_follow_blocked]) describe "GET /ostatus_subscribe - remote_follow/2" do test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} do diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 0e787715a..92f9aa0f5 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -117,7 +117,7 @@ test "it registers a new user and parses mentions in the bio" do end describe "register with one time token" do - clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :registrations_open], false) test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite() @@ -182,7 +182,7 @@ test "returns error on expired token" do end describe "registers with date limited token" do - clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :registrations_open], false) setup do data = %{ @@ -242,7 +242,7 @@ test "returns an error on overdue date", %{data: data} do end describe "registers with reusable token" do - clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :registrations_open], false) test "returns user on success, after him registration fails" do {:ok, invite} = UserInviteToken.create_invite(%{max_use: 100}) @@ -286,7 +286,7 @@ test "returns user on success, after him registration fails" do end describe "registers with reusable date limited token" do - clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :registrations_open], false) test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 71ecd1aa7..30e54bebd 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -18,8 +18,8 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do :ok end - clear_config([:instance]) - clear_config([:frontend_configurations, :pleroma_fe]) + setup do: clear_config([:instance]) + setup do: clear_config([:frontend_configurations, :pleroma_fe]) describe "POST /api/pleroma/follow_import" do setup do: oauth_access(["follow"]) @@ -318,7 +318,7 @@ test "returns json with custom emoji with tags", %{conn: conn} do end describe "GET /api/pleroma/healthcheck" do - clear_config([:instance, :healthcheck]) + setup do: clear_config([:instance, :healthcheck]) test "returns 503 when healthcheck disabled", %{conn: conn} do Config.put([:instance, :healthcheck], false) @@ -427,7 +427,7 @@ test "it returns version in json format", %{conn: conn} do end describe "POST /main/ostatus - remote_subscribe/2" do - clear_config([:instance, :federating], true) + setup do: clear_config([:instance, :federating], true) test "renders subscribe form", %{conn: conn} do user = insert(:user) diff --git a/test/web/web_finger/web_finger_controller_test.exs b/test/web/web_finger/web_finger_controller_test.exs index fcf14dc1e..0023f1e81 100644 --- a/test/web/web_finger/web_finger_controller_test.exs +++ b/test/web/web_finger/web_finger_controller_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.WebFinger.WebFingerControllerTest do :ok end - clear_config_all([:instance, :federating], true) + setup_all do: clear_config([:instance, :federating], true) test "GET host-meta" do response = diff --git a/test/workers/cron/clear_oauth_token_worker_test.exs b/test/workers/cron/clear_oauth_token_worker_test.exs index f056b1a3e..df82dc75d 100644 --- a/test/workers/cron/clear_oauth_token_worker_test.exs +++ b/test/workers/cron/clear_oauth_token_worker_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Workers.Cron.ClearOauthTokenWorkerTest do import Pleroma.Factory alias Pleroma.Workers.Cron.ClearOauthTokenWorker - clear_config([:oauth2, :clean_expired_tokens]) + setup do: clear_config([:oauth2, :clean_expired_tokens]) test "deletes expired tokens" do insert(:oauth_token, diff --git a/test/workers/cron/digest_emails_worker_test.exs b/test/workers/cron/digest_emails_worker_test.exs index 5d65b9fef..0a63bf4e0 100644 --- a/test/workers/cron/digest_emails_worker_test.exs +++ b/test/workers/cron/digest_emails_worker_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Workers.Cron.DigestEmailsWorkerTest do alias Pleroma.User alias Pleroma.Web.CommonAPI - clear_config([:email_notifications, :digest]) + setup do: clear_config([:email_notifications, :digest]) setup do Pleroma.Config.put([:email_notifications, :digest], %{ diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index 56c5aa409..5864f9e5f 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do import Pleroma.Factory import ExUnit.CaptureLog - clear_config([ActivityExpiration, :enabled]) + setup do: clear_config([ActivityExpiration, :enabled]) test "deletes an expiration activity" do Pleroma.Config.put([ActivityExpiration, :enabled], true) diff --git a/test/workers/scheduled_activity_worker_test.exs b/test/workers/scheduled_activity_worker_test.exs index ab9f9c125..b312d975b 100644 --- a/test/workers/scheduled_activity_worker_test.exs +++ b/test/workers/scheduled_activity_worker_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Workers.ScheduledActivityWorkerTest do import Pleroma.Factory import ExUnit.CaptureLog - clear_config([ScheduledActivity, :enabled]) + setup do: clear_config([ScheduledActivity, :enabled]) test "creates a status from the scheduled activity" do Pleroma.Config.put([ScheduledActivity, :enabled], true) -- cgit v1.2.3 From cb8236cda62cddb72f4320af6347defae44b81ca Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 20 Mar 2020 21:19:34 +0400 Subject: Add embeddable posts --- lib/pleroma/web/embed_controller.ex | 42 +++++++++++ lib/pleroma/web/endpoint.ex | 2 +- lib/pleroma/web/router.ex | 2 + .../web/templates/embed/_attachment.html.eex | 8 ++ lib/pleroma/web/templates/embed/show.html.eex | 76 +++++++++++++++++++ lib/pleroma/web/templates/layout/embed.html.eex | 14 ++++ lib/pleroma/web/views/embed_view.ex | 83 +++++++++++++++++++++ priv/static/embed.css | Bin 0 -> 1408 bytes priv/static/embed.js | Bin 0 -> 942 bytes 9 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/embed_controller.ex create mode 100644 lib/pleroma/web/templates/embed/_attachment.html.eex create mode 100644 lib/pleroma/web/templates/embed/show.html.eex create mode 100644 lib/pleroma/web/templates/layout/embed.html.eex create mode 100644 lib/pleroma/web/views/embed_view.ex create mode 100644 priv/static/embed.css create mode 100644 priv/static/embed.js diff --git a/lib/pleroma/web/embed_controller.ex b/lib/pleroma/web/embed_controller.ex new file mode 100644 index 000000000..f6b8a5ee1 --- /dev/null +++ b/lib/pleroma/web/embed_controller.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.EmbedController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.User + + alias Pleroma.Web.ActivityPub.Visibility + + plug(:put_layout, :embed) + + def show(conn, %{"id" => id}) do + with %Activity{local: true} = activity <- + Activity.get_by_id_with_object(id), + true <- Visibility.is_public?(activity.object) do + {:ok, author} = User.get_or_fetch(activity.object.data["actor"]) + + conn + |> delete_resp_header("x-frame-options") + |> delete_resp_header("content-security-policy") + |> render("show.html", + activity: activity, + author: User.sanitize_html(author), + counts: get_counts(activity) + ) + end + end + + defp get_counts(%Activity{} = activity) do + %Object{data: data} = Object.normalize(activity) + + %{ + likes: Map.get(data, "like_count", 0), + replies: Map.get(data, "repliesCount", 0), + announces: Map.get(data, "announcement_count", 0) + } + end +end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 72cb3ee27..4f665db12 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -35,7 +35,7 @@ defmodule Pleroma.Web.Endpoint do at: "/", from: :pleroma, only: - ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc), + ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css), # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3f36f6c1a..eef0a8023 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -637,6 +637,8 @@ defmodule Pleroma.Web.Router do post("/auth/password", MastodonAPI.AuthController, :password_reset) get("/web/*path", MastoFEController, :index) + + get("/embed/:id", EmbedController, :show) end pipeline :remote_media do diff --git a/lib/pleroma/web/templates/embed/_attachment.html.eex b/lib/pleroma/web/templates/embed/_attachment.html.eex new file mode 100644 index 000000000..7e04e9550 --- /dev/null +++ b/lib/pleroma/web/templates/embed/_attachment.html.eex @@ -0,0 +1,8 @@ +<%= case @mediaType do %> +<% "audio" -> %> + +<% "video" -> %> + +<% _ -> %> +<%= @name %> +<% end %> diff --git a/lib/pleroma/web/templates/embed/show.html.eex b/lib/pleroma/web/templates/embed/show.html.eex new file mode 100644 index 000000000..6bf8fac29 --- /dev/null +++ b/lib/pleroma/web/templates/embed/show.html.eex @@ -0,0 +1,76 @@ +
    + + +
    + <%= if status_title(@activity) != "" do %> +
    open<% end %>> + <%= raw status_title(@activity) %> +
    <%= activity_content(@activity) %>
    +
    + <% else %> +
    <%= activity_content(@activity) %>
    + <% end %> + <%= for %{"name" => name, "url" => [url | _]} <- attachments(@activity) do %> +
    + <%= if sensitive?(@activity) do %> +
    + <%= Gettext.gettext("sensitive media") %> +
    + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> +
    +
    + <% else %> + <%= render("_attachment.html", %{name: name, url: url["href"], + mediaType: fetch_media_type(url)}) %> + <% end %> +
    + <% end %> +
    + +
    +
    <%= Gettext.gettext("replies") %>
    <%= @counts.replies %>
    +
    <%= Gettext.gettext("announces") %>
    <%= @counts.announces %>
    +
    <%= Gettext.gettext("likes") %>
    <%= @counts.likes %>
    +
    + +

    + <%= link published(@activity), to: activity_url(@author, @activity) %> +

    +
    + + diff --git a/lib/pleroma/web/templates/layout/embed.html.eex b/lib/pleroma/web/templates/layout/embed.html.eex new file mode 100644 index 000000000..57ae4f802 --- /dev/null +++ b/lib/pleroma/web/templates/layout/embed.html.eex @@ -0,0 +1,14 @@ + + + + + + <%= Pleroma.Config.get([:instance, :name]) %> + + <%= Phoenix.HTML.raw(assigns[:meta] || "") %> + + + + <%= render @view_module, @view_template, assigns %> + + diff --git a/lib/pleroma/web/views/embed_view.ex b/lib/pleroma/web/views/embed_view.ex new file mode 100644 index 000000000..77536835b --- /dev/null +++ b/lib/pleroma/web/views/embed_view.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.EmbedView do + use Pleroma.Web, :view + + alias Calendar.Strftime + alias Pleroma.Activity + alias Pleroma.Emoji.Formatter + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.Gettext + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Metadata.Utils + alias Pleroma.Web.Router.Helpers + + use Phoenix.HTML + + @media_types ["image", "audio", "video"] + + defp emoji_for_user(%User{} = user) do + user.source_data + |> Map.get("tag", []) + |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> + {String.trim(name, ":"), url} + end) + end + + defp fetch_media_type(%{"mediaType" => mediaType}) do + Utils.fetch_media_type(@media_types, mediaType) + end + + defp open_content? do + Pleroma.Config.get( + [:frontend_configurations, :collapse_message_with_subjects], + true + ) + end + + defp full_nickname(user) do + %{host: host} = URI.parse(user.ap_id) + "@" <> user.nickname <> "@" <> host + end + + defp status_title(%Activity{object: %Object{data: %{"name" => name}}}) when is_binary(name), + do: name + + defp status_title(%Activity{object: %Object{data: %{"summary" => summary}}}) + when is_binary(summary), + do: summary + + defp status_title(_), do: nil + + defp activity_content(%Activity{object: %Object{data: %{"content" => content}}}) do + content |> Pleroma.HTML.filter_tags() |> raw() + end + + defp activity_content(_), do: nil + + defp activity_url(%User{local: true}, activity) do + Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + end + + defp activity_url(%User{local: false}, %Activity{object: %Object{data: data}}) do + data["url"] || data["external_url"] || data["id"] + end + + defp attachments(%Activity{object: %Object{data: %{"attachment" => attachments}}}) do + attachments + end + + defp sensitive?(%Activity{object: %Object{data: %{"sensitive" => sensitive}}}) do + sensitive + end + + defp published(%Activity{object: %Object{data: %{"published" => published}}}) do + published + |> NaiveDateTime.from_iso8601!() + |> Strftime.strftime!("%B %d, %Y, %l:%M %p") + end +end diff --git a/priv/static/embed.css b/priv/static/embed.css new file mode 100644 index 000000000..cc79ee7ab Binary files /dev/null and b/priv/static/embed.css differ diff --git a/priv/static/embed.js b/priv/static/embed.js new file mode 100644 index 000000000..f675f6417 Binary files /dev/null and b/priv/static/embed.js differ -- cgit v1.2.3 From 7f9b5284fa7dd1d9100de730a6fe0c93739d1b30 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 20 Mar 2020 20:58:47 +0300 Subject: 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]) -- cgit v1.2.3 From fc2eb1fbd6a5b38a3cf72e557cce1029d6b7f16f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 20 Mar 2020 22:16:57 +0400 Subject: Fix formatter warnings --- test/workers/cron/purge_expired_activities_worker_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index 85ae1e5ef..beac55fb2 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -11,8 +11,8 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do import Pleroma.Factory import ExUnit.CaptureLog - setup do - clear_config([ActivityExpiration, :enabled]) + setup do + clear_config([ActivityExpiration, :enabled]) clear_config([:instance, :rewrite_policy]) end -- cgit v1.2.3 From 981e015f1b68c7cf807b0ddbf3948809f11b7fff Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 22 Mar 2020 17:10:37 +0300 Subject: 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 +---- lib/pleroma/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 -- cgit v1.2.3 From 15be6ba9c200b2a4ae153d26876be1b5cbb6357e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 22 Mar 2020 16:38:12 +0100 Subject: 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 +- .../controllers/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", -- cgit v1.2.3 From c2e415143b1dfe5d89eff06fbce6840c445aa5fa Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 22 Mar 2020 21:51:44 +0300 Subject: WIP: preloading of user relations for timeline/statuses rendering (performance improvement). --- lib/pleroma/user.ex | 6 +- lib/pleroma/user_relationship.ex | 44 ++++++++++++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 69 ++++++++++++++++++---- lib/pleroma/web/mastodon_api/views/status_view.ex | 58 ++++++++++++++++-- 4 files changed, 159 insertions(+), 18 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 + defp user_relationships_opt(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) + + UserRelationship.dictionary( + [reading_user], + actors, + [:block, :mute, :notification_mute, :reblog_mute], + [:block, :inverse_subscription] + ) + else + [] + end + 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) + 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(opts.activities, StatusView, "show.json", 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, -- cgit v1.2.3 From a6ee6784bc74b311d454112c427f41b1fdec6ce0 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 28 Feb 2020 11:16:40 +0300 Subject: creating trusted app from adminFE & mix task --- CHANGELOG.md | 2 + docs/API/admin_api.md | 101 +++++++++++ docs/administration/CLI_tasks/oauth_app.md | 16 ++ lib/mix/tasks/pleroma/app.ex | 49 ++++++ lib/pleroma/web/admin_api/admin_api_controller.ex | 79 +++++++++ .../mastodon_api/controllers/account_controller.ex | 1 + lib/pleroma/web/mastodon_api/views/app_view.ex | 15 ++ lib/pleroma/web/oauth/app.ex | 82 ++++++++- lib/pleroma/web/router.ex | 5 + lib/pleroma/web/twitter_api/twitter_api.ex | 3 +- .../20200227122417_add_trusted_to_apps.exs | 9 + test/support/factory.ex | 2 +- test/tasks/app_test.exs | 65 ++++++++ test/web/admin_api/admin_api_controller_test.exs | 185 +++++++++++++++++++++ .../controllers/account_controller_test.exs | 67 ++++++++ 15 files changed, 678 insertions(+), 3 deletions(-) create mode 100644 docs/administration/CLI_tasks/oauth_app.md create mode 100644 lib/mix/tasks/pleroma/app.ex create mode 100644 priv/repo/migrations/20200227122417_add_trusted_to_apps.exs create mode 100644 test/tasks/app_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a073c64..a1271cbca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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`) +- Mix task to create trusted OAuth App.
    API Changes @@ -145,6 +146,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - ActivityPub: `[:activitypub, :note_replies_output_limit]` setting sets the number of note self-replies to output on outgoing federation. - Admin API: `GET /api/pleroma/admin/stats` to get status count by visibility scope - Admin API: `GET /api/pleroma/admin/statuses` - list all statuses (accepts `godmode` and `local_only`) +- Admin API: endpoints for create/update/delete OAuth Apps.
    ### Fixed diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 47afdfba5..4d12698ec 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -983,3 +983,104 @@ Loads json generated from `config/descriptions.exs`. } } ``` + +## `GET /api/pleroma/admin/oauth_app` + +### List OAuth app + +- Params: + - *optional* `name` + - *optional* `client_id` + - *optional* `page` + - *optional* `page_size` + - *optional* `trusted` + +- Response: + +```json +{ + "apps": [ + { + "id": 1, + "name": "App name", + "client_id": "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret": "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri": "https://example.com/oauth-callback", + "website": "https://example.com", + "trusted": true + } + ], + "count": 17, + "page_size": 50 +} +``` + + +## `POST /api/pleroma/admin/oauth_app` + +### Create OAuth App + +- Params: + - `name` + - `redirect_uris` + - `scopes` + - *optional* `website` + - *optional* `trusted` + +- Response: + +```json +{ + "id": 1, + "name": "App name", + "client_id": "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret": "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri": "https://example.com/oauth-callback", + "website": "https://example.com", + "trusted": true +} +``` + +- On failure: +```json +{ + "redirect_uris": "can't be blank", + "name": "can't be blank" +} +``` + +## `PATCH /api/pleroma/admin/oauth_app/:id` + +### Update OAuth App + +- Params: + - *optional* `name` + - *optional* `redirect_uris` + - *optional* `scopes` + - *optional* `website` + - *optional* `trusted` + +- Response: + +```json +{ + "id": 1, + "name": "App name", + "client_id": "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret": "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri": "https://example.com/oauth-callback", + "website": "https://example.com", + "trusted": true +} +``` + +## `DELETE /api/pleroma/admin/oauth_app/:id` + +### Delete OAuth App + +- Params: None + +- Response: + - On success: `204`, empty response + - On failure: + - 400 Bad Request `"Invalid parameters"` when `status` is missing \ No newline at end of file diff --git a/docs/administration/CLI_tasks/oauth_app.md b/docs/administration/CLI_tasks/oauth_app.md new file mode 100644 index 000000000..4d6bfc25a --- /dev/null +++ b/docs/administration/CLI_tasks/oauth_app.md @@ -0,0 +1,16 @@ +# Creating trusted OAuth App + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Create trusted OAuth App. + +Optional params: + * `-s SCOPES` - scopes for app, e.g. `read,write,follow,push`. + +```sh tab="OTP" + ./bin/pleroma_ctl app create -n APP_NAME -r REDIRECT_URI +``` + +```sh tab="From Source" +mix pleroma.app create -n APP_NAME -r REDIRECT_URI +``` \ No newline at end of file diff --git a/lib/mix/tasks/pleroma/app.ex b/lib/mix/tasks/pleroma/app.ex new file mode 100644 index 000000000..463e2449f --- /dev/null +++ b/lib/mix/tasks/pleroma/app.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.App do + @moduledoc File.read!("docs/administration/CLI_tasks/oauth_app.md") + use Mix.Task + + import Mix.Pleroma + + @shortdoc "Creates trusted OAuth App" + + def run(["create" | options]) do + start_pleroma() + + {opts, _} = + OptionParser.parse!(options, + strict: [name: :string, redirect_uri: :string, scopes: :string], + aliases: [n: :name, r: :redirect_uri, s: :scopes] + ) + + scopes = + if opts[:scopes] do + String.split(opts[:scopes], ",") + else + ["read", "write", "follow", "push"] + end + + params = %{ + client_name: opts[:name], + redirect_uris: opts[:redirect_uri], + trusted: true, + scopes: scopes + } + + with {:ok, app} <- Pleroma.Web.OAuth.App.create(params) do + shell_info("#{app.client_name} successfully created:") + shell_info("App client_id: " <> app.client_id) + shell_info("App client_secret: " <> app.client_secret) + else + {:error, changeset} -> + shell_error("Creating failed:") + + Enum.each(Pleroma.Web.OAuth.App.errors(changeset), fn {key, error} -> + shell_error("#{key}: #{error}") + end) + end + end +end diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 175260bc2..b03fa7169 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -27,7 +27,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint + alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.OAuth.App alias Pleroma.Web.Router require Logger @@ -978,6 +980,83 @@ def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" = conn |> json("") end + def oauth_app_create(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end + + result = + case App.create(params) do + {:ok, app} -> + AppView.render("show.json", %{app: app, admin: true}) + + {:error, changeset} -> + App.errors(changeset) + end + + json(conn, result) + end + + def oauth_app_update(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end + + with {:ok, app} <- App.update(params) do + json(conn, AppView.render("show.json", %{app: app, admin: true})) + else + {:error, changeset} -> + json(conn, App.errors(changeset)) + + nil -> + json_response(conn, :bad_request, "") + end + end + + def oauth_app_list(conn, params) do + {page, page_size} = page_params(params) + + search_params = %{ + client_name: params["name"], + client_id: params["client_id"], + page: page, + page_size: page_size + } + + search_params = + if Map.has_key?(params, "trusted") do + Map.put(search_params, :trusted, params["trusted"]) + else + search_params + end + + with {:ok, apps, count} <- App.search(search_params) do + json( + conn, + AppView.render("index.json", + apps: apps, + count: count, + page_size: page_size, + admin: true + ) + ) + end + end + + def oauth_app_delete(conn, params) do + with {:ok, _app} <- App.destroy(params["id"]) do + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + def stats(conn, _) do count = Stats.get_status_visibility_count() diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 6dbf11ac9..5f8aa2e3e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -92,6 +92,7 @@ def create( |> Map.put("fullname", params["fullname"] || nickname) |> Map.put("bio", params["bio"] || "") |> Map.put("confirm", params["password"]) + |> Map.put("trusted_app", app.trusted) with :ok <- validate_email_param(params), {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), diff --git a/lib/pleroma/web/mastodon_api/views/app_view.ex b/lib/pleroma/web/mastodon_api/views/app_view.ex index d934e2107..36071cd25 100644 --- a/lib/pleroma/web/mastodon_api/views/app_view.ex +++ b/lib/pleroma/web/mastodon_api/views/app_view.ex @@ -7,6 +7,21 @@ defmodule Pleroma.Web.MastodonAPI.AppView do alias Pleroma.Web.OAuth.App + def render("index.json", %{apps: apps, count: count, page_size: page_size, admin: true}) do + %{ + apps: render_many(apps, Pleroma.Web.MastodonAPI.AppView, "show.json", %{admin: true}), + count: count, + page_size: page_size + } + end + + def render("show.json", %{admin: true, app: %App{} = app} = assigns) do + "show.json" + |> render(Map.delete(assigns, :admin)) + |> Map.put(:trusted, app.trusted) + |> Map.put(:id, app.id) + end + def render("show.json", %{app: %App{} = app}) do %{ id: app.id |> to_string, diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index 01ed326f4..6a6d5f2e2 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.App do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias Pleroma.Repo @type t :: %__MODULE__{} @@ -16,14 +17,24 @@ defmodule Pleroma.Web.OAuth.App do field(:website, :string) field(:client_id, :string) field(:client_secret, :string) + field(:trusted, :boolean, default: false) + + has_many(:oauth_authorizations, Pleroma.Web.OAuth.Authorization, on_delete: :delete_all) + has_many(:oauth_tokens, Pleroma.Web.OAuth.Token, on_delete: :delete_all) timestamps() end + @spec changeset(App.t(), map()) :: Ecto.Changeset.t() + def changeset(struct, params) do + cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted]) + end + + @spec register_changeset(App.t(), map()) :: Ecto.Changeset.t() def register_changeset(struct, params \\ %{}) do changeset = struct - |> cast(params, [:client_name, :redirect_uris, :scopes, :website]) + |> changeset(params) |> validate_required([:client_name, :redirect_uris, :scopes]) if changeset.valid? do @@ -41,6 +52,21 @@ def register_changeset(struct, params \\ %{}) do end end + @spec create(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def create(params) do + with changeset <- __MODULE__.register_changeset(%__MODULE__{}, params) do + Repo.insert(changeset) + end + end + + @spec update(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def update(params) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, params["id"]), + changeset <- changeset(app, params) do + Repo.update(changeset) + end + end + @doc """ Gets app by attrs or create new with attrs. And updates the scopes if need. @@ -65,4 +91,58 @@ defp update_scopes(%__MODULE__{} = app, scopes) do |> change(%{scopes: scopes}) |> Repo.update() end + + @spec search(map()) :: {:ok, [App.t()], non_neg_integer()} + def search(params) do + query = from(a in __MODULE__) + + query = + if params[:client_name] do + from(a in query, where: a.client_name == ^params[:client_name]) + else + query + end + + query = + if params[:client_id] do + from(a in query, where: a.client_id == ^params[:client_id]) + else + query + end + + query = + if Map.has_key?(params, :trusted) do + from(a in query, where: a.trusted == ^params[:trusted]) + else + query + end + + query = + from(u in query, + limit: ^params[:page_size], + offset: ^((params[:page] - 1) * params[:page_size]) + ) + + count = Repo.aggregate(__MODULE__, :count, :id) + + {:ok, Repo.all(query), count} + end + + @spec destroy(pos_integer()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + def destroy(id) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do + Repo.delete(app) + end + end + + @spec errors(Ecto.Changeset.t()) :: map() + def errors(changeset) do + Enum.reduce(changeset.errors, %{}, fn + {:client_name, {error, _}}, acc -> + Map.put(acc, :name, error) + + {key, {error, _}}, acc -> + Map.put(acc, key, error) + end) + end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3f36f6c1a..c37ef59a0 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -203,6 +203,11 @@ defmodule Pleroma.Web.Router do post("/reload_emoji", AdminAPIController, :reload_emoji) get("/stats", AdminAPIController, :stats) + + get("/oauth_app", AdminAPIController, :oauth_app_list) + post("/oauth_app", AdminAPIController, :oauth_app_create) + patch("/oauth_app/:id", AdminAPIController, :oauth_app_update) + delete("/oauth_app/:id", AdminAPIController, :oauth_app_delete) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index f9c0994da..7a1ba6936 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do def register_user(params, opts \\ []) do token = params["token"] + trusted_app? = params["trusted_app"] params = %{ nickname: params["nickname"], @@ -29,7 +30,7 @@ def register_user(params, opts \\ []) do captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled]) # true if captcha is disabled or enabled and valid, false otherwise captcha_ok = - if not captcha_enabled do + if trusted_app? || not captcha_enabled do :ok else Pleroma.Captcha.validate( diff --git a/priv/repo/migrations/20200227122417_add_trusted_to_apps.exs b/priv/repo/migrations/20200227122417_add_trusted_to_apps.exs new file mode 100644 index 000000000..4e2a62af0 --- /dev/null +++ b/priv/repo/migrations/20200227122417_add_trusted_to_apps.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddTrustedToApps do + use Ecto.Migration + + def change do + alter table(:apps) do + add(:trusted, :boolean, default: false) + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index af639b6cd..f0b797fd4 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -294,7 +294,7 @@ def follow_activity_factory do def oauth_app_factory do %Pleroma.Web.OAuth.App{ - client_name: "Some client", + client_name: sequence(:client_name, &"Some client #{&1}"), redirect_uris: "https://example.com/callback", scopes: ["read", "write", "follow", "push", "admin"], website: "https://example.com", diff --git a/test/tasks/app_test.exs b/test/tasks/app_test.exs new file mode 100644 index 000000000..b8f03566d --- /dev/null +++ b/test/tasks/app_test.exs @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.AppTest do + use Pleroma.DataCase, async: true + + setup_all do + Mix.shell(Mix.Shell.Process) + + on_exit(fn -> + Mix.shell(Mix.Shell.IO) + end) + end + + describe "creates new app" do + test "with default scopes" do + name = "Some name" + redirect = "https://example.com" + Mix.Tasks.Pleroma.App.run(["create", "-n", name, "-r", redirect]) + + assert_app(name, redirect, ["read", "write", "follow", "push"]) + end + + test "with custom scopes" do + name = "Another name" + redirect = "https://example.com" + + Mix.Tasks.Pleroma.App.run([ + "create", + "-n", + name, + "-r", + redirect, + "-s", + "read,write,follow,push,admin" + ]) + + assert_app(name, redirect, ["read", "write", "follow", "push", "admin"]) + end + end + + test "with errors" do + Mix.Tasks.Pleroma.App.run(["create"]) + {:mix_shell, :error, ["Creating failed:"]} + {:mix_shell, :error, ["name: can't be blank"]} + {:mix_shell, :error, ["redirect_uris: can't be blank"]} + end + + defp assert_app(name, redirect, scopes) do + app = Repo.get_by(Pleroma.Web.OAuth.App, client_name: name) + + assert_received {:mix_shell, :info, [message]} + assert message == "#{name} successfully created:" + + assert_received {:mix_shell, :info, [message]} + assert message == "App client_id: #{app.client_id}" + + assert_received {:mix_shell, :info, [message]} + assert message == "App client_secret: #{app.client_secret}" + + assert app.scopes == scopes + assert app.redirect_uris == redirect + 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 0a902585d..d77e8d1d2 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3623,6 +3623,191 @@ test "status visibility count", %{conn: conn} do response["status_visibility"] end end + + describe "POST /api/pleroma/admin/oauth_app" do + test "errors", %{conn: conn} do + response = conn |> post("/api/pleroma/admin/oauth_app", %{}) |> json_response(200) + + assert response == %{"name" => "can't be blank", "redirect_uris" => "can't be blank"} + end + + test "success", %{conn: conn} do + base_url = Pleroma.Web.base_url() + app_name = "Trusted app" + + response = + conn + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url + }) + |> json_response(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => false + } = response + end + + test "with trusted", %{conn: conn} do + base_url = Pleroma.Web.base_url() + app_name = "Trusted app" + + response = + conn + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url, + trusted: true + }) + |> json_response(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => true + } = response + end + end + + describe "GET /api/pleroma/admin/oauth_app" do + setup do + app = insert(:oauth_app) + {:ok, app: app} + end + + test "list", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/oauth_app") + |> json_response(200) + + assert %{"apps" => apps, "count" => count, "page_size" => _} = response + + assert length(apps) == count + end + + test "with page size", %{conn: conn} do + insert(:oauth_app) + page_size = 1 + + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{page_size: to_string(page_size)}) + |> json_response(200) + + assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response + + assert length(apps) == page_size + end + + test "search by client name", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{name: app.client_name}) + |> json_response(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "search by client id", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{client_id: app.client_id}) + |> json_response(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "only trusted", %{conn: conn} do + app = insert(:oauth_app, trusted: true) + + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{trusted: true}) + |> json_response(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + end + + describe "DELETE /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + response = + conn + |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) + |> json_response(:no_content) + + assert response == "" + end + + test "with non existance id", %{conn: conn} do + response = + conn + |> delete("/api/pleroma/admin/oauth_app/0") + |> json_response(:bad_request) + + assert response == "" + end + end + + describe "PATCH /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + name = "another name" + url = "https://example.com" + scopes = ["admin"] + id = app.id + website = "http://website.com" + + response = + conn + |> patch("/api/pleroma/admin/oauth_app/" <> to_string(app.id), %{ + name: name, + trusted: true, + redirect_uris: url, + scopes: scopes, + website: website + }) + |> json_response(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "id" => ^id, + "name" => ^name, + "redirect_uri" => ^url, + "trusted" => true, + "website" => ^website + } = response + end + + test "without id", %{conn: conn} do + response = + conn + |> patch("/api/pleroma/admin/oauth_app/0") + |> json_response(:bad_request) + + assert response == "" + end + end end # Needed for testing diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index a9fa0ce48..f770232df 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -942,6 +942,73 @@ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_ res = post(conn, "/api/v1/accounts", valid_params) assert json_response(res, 403) == %{"error" => "Invalid credentials"} end + + test "registration from trusted app" do + clear_config([Pleroma.Captcha, :enabled], true) + app = insert(:oauth_app, trusted: true, scopes: ["read", "write", "follow", "push"]) + + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "client_credentials", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert %{"access_token" => token, "token_type" => "Bearer"} = json_response(conn, 200) + + response = + build_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer " <> token) + |> post("/api/v1/accounts", %{ + nickname: "nickanme", + agreement: true, + email: "email@example.com", + fullname: "Lain", + username: "Lain", + password: "some_password", + confirm: "some_password" + }) + |> json_response(200) + + assert %{ + "access_token" => access_token, + "created_at" => _, + "scope" => ["read", "write", "follow", "push"], + "token_type" => "Bearer" + } = response + + response = + build_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer " <> access_token) + |> get("/api/v1/accounts/verify_credentials") + |> json_response(200) + + assert %{ + "acct" => "Lain", + "bot" => false, + "display_name" => "Lain", + "follow_requests_count" => 0, + "followers_count" => 0, + "following_count" => 0, + "locked" => false, + "note" => "", + "source" => %{ + "fields" => [], + "note" => "", + "pleroma" => %{ + "actor_type" => "Person", + "discoverable" => false, + "no_rich_text" => false, + "show_role" => true + }, + "privacy" => "public", + "sensitive" => false + }, + "statuses_count" => 0, + "username" => "Lain" + } = response + end end describe "create account by app / rate limit" do -- cgit v1.2.3 From 3c78e5f3275494b3dc4546e65f19eb3a3c97033a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 23 Mar 2020 12:01:11 +0300 Subject: 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 ++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 75 ++++++++++++++++------ lib/pleroma/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 = + 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 - follow_state = User.get_follow_state(reading_user, target) + 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), -- cgit v1.2.3 From 5a34dca8eda46479a3459b60c623d6fa94fc662b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 23 Mar 2020 14:03:31 +0400 Subject: Add emoji support in statuses in staticfe --- lib/pleroma/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 19c56387b..db61ff266 100644 Binary files a/priv/static/static/static-fe.css and b/priv/static/static/static-fe.css differ -- cgit v1.2.3 From eec1fcaf55bdcbc2d3aed4eaf044bb8ef6c4effa Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 23 Mar 2020 15:58:55 +0100 Subject: 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 -- cgit v1.2.3 From 3bd2829e5c125f961b7508bf40ef534a21070562 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 23 Mar 2020 18:56:01 +0100 Subject: 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 = -- cgit v1.2.3 From 3189c44a0cf6821976819112e93e93ad811cba53 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 24 Mar 2020 15:21:40 +0400 Subject: Remove some TwitterAPI endpoints --- lib/pleroma/web/router.ex | 4 - .../web/twitter_api/controllers/util_controller.ex | 83 -------------- test/web/twitter_api/util_controller_test.exs | 121 --------------------- 3 files changed, 208 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3f36f6c1a..c3ea7b626 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -481,10 +481,6 @@ defmodule Pleroma.Web.Router do scope "/api", Pleroma.Web do pipe_through(:config) - get("/help/test", TwitterAPI.UtilController, :help_test) - post("/help/test", TwitterAPI.UtilController, :help_test) - get("/statusnet/config", TwitterAPI.UtilController, :config) - get("/statusnet/version", TwitterAPI.UtilController, :version) get("/pleroma/frontend_configurations", TwitterAPI.UtilController, :frontend_configurations) end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 537f9f778..bb08f5426 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Notification alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User - alias Pleroma.Web alias Pleroma.Web.CommonAPI alias Pleroma.Web.WebFinger @@ -48,12 +47,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) - plug(Pleroma.Plugs.SetFormatPlug when action in [:config, :version]) - - def help_test(conn, _params) do - json(conn, "ok") - end - def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do with %User{} = user <- User.get_cached_by_nickname(nick), avatar = User.avatar_url(user) do @@ -95,70 +88,6 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ end end - def config(%{assigns: %{format: "xml"}} = conn, _params) do - instance = Pleroma.Config.get(:instance) - - response = """ - - - #{Keyword.get(instance, :name)} - #{Web.base_url()} - #{Keyword.get(instance, :limit)} - #{!Keyword.get(instance, :registrations_open)} - - - """ - - conn - |> put_resp_content_type("application/xml") - |> send_resp(200, response) - end - - def config(conn, _params) do - instance = Pleroma.Config.get(:instance) - - vapid_public_key = Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) - - uploadlimit = %{ - uploadlimit: to_string(Keyword.get(instance, :upload_limit)), - avatarlimit: to_string(Keyword.get(instance, :avatar_upload_limit)), - backgroundlimit: to_string(Keyword.get(instance, :background_upload_limit)), - bannerlimit: to_string(Keyword.get(instance, :banner_upload_limit)) - } - - data = %{ - name: Keyword.get(instance, :name), - description: Keyword.get(instance, :description), - server: Web.base_url(), - textlimit: to_string(Keyword.get(instance, :limit)), - uploadlimit: uploadlimit, - closed: bool_to_val(Keyword.get(instance, :registrations_open), "0", "1"), - private: bool_to_val(Keyword.get(instance, :public, true), "0", "1"), - vapidPublicKey: vapid_public_key, - accountActivationRequired: - bool_to_val(Keyword.get(instance, :account_activation_required, false)), - invitesEnabled: bool_to_val(Keyword.get(instance, :invites_enabled, false)), - safeDMMentionsEnabled: bool_to_val(Pleroma.Config.get([:instance, :safe_dm_mentions])) - } - - managed_config = Keyword.get(instance, :managed_config) - - data = - if managed_config do - pleroma_fe = Pleroma.Config.get([:frontend_configurations, :pleroma_fe]) - Map.put(data, "pleromafe", pleroma_fe) - else - data - end - - json(conn, %{site: data}) - end - - defp bool_to_val(true), do: "1" - defp bool_to_val(_), do: "0" - defp bool_to_val(true, val, _), do: val - defp bool_to_val(_, _, val), do: val - def frontend_configurations(conn, _params) do config = Pleroma.Config.get(:frontend_configurations, %{}) @@ -167,18 +96,6 @@ def frontend_configurations(conn, _params) do json(conn, config) end - def version(%{assigns: %{format: "xml"}} = conn, _params) do - version = Pleroma.Application.named_version() - - conn - |> put_resp_content_type("application/xml") - |> send_resp(200, "#{version}") - end - - def version(conn, _params) do - json(conn, Pleroma.Application.named_version()) - end - def emoji(conn, _params) do emoji = Enum.reduce(Emoji.get_all(), %{}, fn {code, %Emoji{file: file, tags: tags}}, acc -> diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 30e54bebd..5ad682b0b 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -177,105 +177,6 @@ test "it updates notification privacy option", %{user: user, conn: conn} do end end - describe "GET /api/statusnet/config" do - test "it returns config in xml format", %{conn: conn} do - instance = Config.get(:instance) - - response = - conn - |> put_req_header("accept", "application/xml") - |> get("/api/statusnet/config") - |> response(:ok) - - assert response == - "\n\n#{Keyword.get(instance, :name)}\n#{ - Pleroma.Web.base_url() - }\n#{Keyword.get(instance, :limit)}\n#{ - !Keyword.get(instance, :registrations_open) - }\n\n\n" - end - - test "it returns config in json format", %{conn: conn} do - instance = Config.get(:instance) - Config.put([:instance, :managed_config], true) - Config.put([:instance, :registrations_open], false) - Config.put([:instance, :invites_enabled], true) - Config.put([:instance, :public], false) - Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"}) - - response = - conn - |> put_req_header("accept", "application/json") - |> get("/api/statusnet/config") - |> json_response(:ok) - - expected_data = %{ - "site" => %{ - "accountActivationRequired" => "0", - "closed" => "1", - "description" => Keyword.get(instance, :description), - "invitesEnabled" => "1", - "name" => Keyword.get(instance, :name), - "pleromafe" => %{"theme" => "asuka-hospital"}, - "private" => "1", - "safeDMMentionsEnabled" => "0", - "server" => Pleroma.Web.base_url(), - "textlimit" => to_string(Keyword.get(instance, :limit)), - "uploadlimit" => %{ - "avatarlimit" => to_string(Keyword.get(instance, :avatar_upload_limit)), - "backgroundlimit" => to_string(Keyword.get(instance, :background_upload_limit)), - "bannerlimit" => to_string(Keyword.get(instance, :banner_upload_limit)), - "uploadlimit" => to_string(Keyword.get(instance, :upload_limit)) - }, - "vapidPublicKey" => Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) - } - } - - assert response == expected_data - end - - test "returns the state of safe_dm_mentions flag", %{conn: conn} do - Config.put([:instance, :safe_dm_mentions], true) - - response = - conn - |> get("/api/statusnet/config.json") - |> json_response(:ok) - - assert response["site"]["safeDMMentionsEnabled"] == "1" - - Config.put([:instance, :safe_dm_mentions], false) - - response = - conn - |> get("/api/statusnet/config.json") - |> json_response(:ok) - - assert response["site"]["safeDMMentionsEnabled"] == "0" - end - - test "it returns the managed config", %{conn: conn} do - Config.put([:instance, :managed_config], false) - Config.put([:frontend_configurations, :pleroma_fe], %{theme: "asuka-hospital"}) - - response = - conn - |> get("/api/statusnet/config.json") - |> json_response(:ok) - - refute response["site"]["pleromafe"] - - Config.put([:instance, :managed_config], true) - - response = - conn - |> get("/api/statusnet/config.json") - |> json_response(:ok) - - assert response["site"]["pleromafe"] == %{"theme" => "asuka-hospital"} - end - end - describe "GET /api/pleroma/frontend_configurations" do test "returns everything in :pleroma, :frontend_configurations", %{conn: conn} do config = [ @@ -404,28 +305,6 @@ test "with valid permissions and invalid password, it returns an error", %{conn: end end - describe "GET /api/statusnet/version" do - test "it returns version in xml format", %{conn: conn} do - response = - conn - |> put_req_header("accept", "application/xml") - |> get("/api/statusnet/version") - |> response(:ok) - - assert response == "#{Pleroma.Application.named_version()}" - end - - test "it returns version in json format", %{conn: conn} do - response = - conn - |> put_req_header("accept", "application/json") - |> get("/api/statusnet/version") - |> json_response(:ok) - - assert response == "#{Pleroma.Application.named_version()}" - end - end - describe "POST /main/ostatus - remote_subscribe/2" do setup do: clear_config([:instance, :federating], true) -- cgit v1.2.3 From d1a9716a988fe9f670033ad46cc9637038fbd1e8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 24 Mar 2020 17:38:18 +0400 Subject: 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) -- cgit v1.2.3 From 4a2538967caf5b0f9970cc5f973c16ea5d776aa3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 24 Mar 2020 20:18:27 +0400 Subject: Support pagination in conversations --- CHANGELOG.md | 3 +++ lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../pleroma_api/controllers/pleroma_api_controller.ex | 10 +++++----- .../controllers/pleroma_api_controller_test.exs | 17 +++++++++++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a073c64..905364d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,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`.
    +### Fixed +- Support pagination in conversations API + ## [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/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 30e282840..351d1bdb8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -696,7 +696,7 @@ def move(%User{} = origin, %User{} = target, local \\ true) do end end - defp fetch_activities_for_context_query(context, opts) do + def fetch_activities_for_context_query(context, opts) do public = [Constants.as_public()] recipients = diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index dae7f0f2f..edb071baa 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -110,12 +110,11 @@ def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) end def conversation_statuses( - %{assigns: %{user: user}} = conn, + %{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => participation_id} = params ) do - with %Participation{} = participation <- - Participation.get(participation_id, preload: [:conversation]), - true <- user.id == participation.user_id do + with %Participation{user_id: ^user_id} = participation <- + Participation.get(participation_id, preload: [:conversation]) do params = params |> Map.put("blocking_user", user) @@ -124,7 +123,8 @@ def conversation_statuses( activities = participation.conversation.ap_id - |> ActivityPub.fetch_activities_for_context(params) + |> ActivityPub.fetch_activities_for_context_query(params) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, "total", false)) |> Enum.reverse() conn diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index 32250f06f..8bf7eb3be 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -169,6 +169,23 @@ test "/api/v1/pleroma/conversations/:id/statuses" do id_one = activity.id id_two = activity_two.id assert [%{"id" => ^id_one}, %{"id" => ^id_two}] = result + + {:ok, %{id: id_three}} = + CommonAPI.post(other_user, %{ + "status" => "Bye!", + "in_reply_to_status_id" => activity.id, + "in_reply_to_conversation_id" => participation.id + }) + + assert [%{"id" => ^id_two}, %{"id" => ^id_three}] = + conn + |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?limit=2") + |> json_response(:ok) + + assert [%{"id" => ^id_three}] = + conn + |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?min_id=#{id_two}") + |> json_response(:ok) end test "PATCH /api/v1/pleroma/conversations/:id" do -- cgit v1.2.3 From 74560e888e5e3e4dc2fa5b4fec4cf3986a1d1a55 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 24 Mar 2020 18:20:58 +0000 Subject: Apply suggestion to lib/pleroma/web/activity_pub/object_validators/create_validator.ex --- lib/pleroma/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 -- cgit v1.2.3 From aaf00f1ff59fc279758f5fa5ceaf758d683bd216 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 24 Mar 2020 18:24:09 +0000 Subject: 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 -- cgit v1.2.3 From f31688246470273cc35588d0f1c2187edc6084c7 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 24 Mar 2020 18:37:53 +0000 Subject: 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 -- cgit v1.2.3 From 13cbb9f6ada8dcb15bb7ed12be4d88a18c5db7f7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 24 Mar 2020 22:14:26 +0300 Subject: 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 ++ lib/pleroma/web/mastodon_api/views/account_view.ex | 15 ++-- .../web/mastodon_api/views/notification_view.ex | 98 +++++++++++++++++----- lib/pleroma/web/mastodon_api/views/status_view.ex | 89 +++++++++++--------- .../controllers/timeline_controller_test.exs | 1 - 5 files changed, 140 insertions(+), 70 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] - - {user_relationships, following_relationships} = - if reading_user do - activities = opts[:activities] - actors = Enum.map(activities, fn a -> get_user(a.data["actor"]) 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 + 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 - 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} } } -- cgit v1.2.3 From 64165d1df95bc3a22260dafa4584471427685864 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Mar 2020 20:21:27 +0100 Subject: 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]) -- cgit v1.2.3 From 03a18cf037d7a9b4ba84ff456b434d65e3290965 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Mar 2020 20:39:19 +0100 Subject: 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 -- cgit v1.2.3 From e743c2232970e321c833604b232520587ad8e402 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 09:04:00 +0300 Subject: 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 ++++---- .../mastodon_api/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 -- cgit v1.2.3 From 3fa3d45dbecafb06fb7eb4f0260f610d4225e0a7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 13:05:00 +0300 Subject: [#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 -- cgit v1.2.3 From be5e2c4dbba63831ea6a0617556e686969b5080f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 17:01:45 +0300 Subject: 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 ++------- lib/pleroma/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 -- cgit v1.2.3 From 9081a071eecd0eeb4b67008754555e9c9d73eae7 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 25 Mar 2020 18:46:17 +0400 Subject: 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", %{ -- cgit v1.2.3 From c8475cd5c63af18471864fe57504999ddd09e496 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 25 Mar 2020 15:48:15 +0000 Subject: 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 -- cgit v1.2.3 From 460e41585c2cd3f137c0f80173da60167fb318bf Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 25 Mar 2020 20:33:34 +0300 Subject: Further preloading (more endpoints), refactoring, tests. --- lib/pleroma/following_relationship.ex | 6 ++ lib/pleroma/user.ex | 5 +- lib/pleroma/user_relationship.ex | 20 ++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 36 ++++--- .../web/mastodon_api/views/notification_view.ex | 44 ++++---- lib/pleroma/web/mastodon_api/views/status_view.ex | 29 ++---- test/web/mastodon_api/views/account_view_test.exs | 111 +++++++++++---------- .../mastodon_api/views/notification_view_test.exs | 42 ++++---- test/web/mastodon_api/views/status_view_test.exs | 15 ++- 9 files changed, 181 insertions(+), 127 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"])) - - actors = - activities - |> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end) - |> Enum.filter(& &1) - |> Kernel.++(move_activities_targets) + 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) + + 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 - } - - assert expected == - AccountView.render("relationship.json", %{user: user, target: other_user}) + 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) + } + ) + + 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 -- cgit v1.2.3 From 1c3f3a12edcdd4f11433e9ed5422b381afd3c5c4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 26 Mar 2020 16:20:20 +0400 Subject: Add `characterLimit` and `vapidPublicKey` to nodeinfo --- lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 30838b1eb..6947c82b9 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -106,6 +106,7 @@ def raw_nodeinfo do }, staffAccounts: staff_accounts, federation: federation_response, + characterLimit: Config.get([:instance, :limit]), pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ @@ -125,7 +126,8 @@ def raw_nodeinfo do mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), features: features, restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false), + vapidPublicKey: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) } } end -- cgit v1.2.3 From 94a6590e3cb9d5c340bfd589880c19717160706f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 26 Mar 2020 17:59:45 +0400 Subject: Partially restore `/api/statusnet/config.json` --- lib/pleroma/web/router.ex | 3 +++ lib/pleroma/web/twitter_api/controllers/util_controller.ex | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index c3ea7b626..322b074c2 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -482,6 +482,9 @@ defmodule Pleroma.Web.Router do pipe_through(:config) get("/pleroma/frontend_configurations", TwitterAPI.UtilController, :frontend_configurations) + + # Deprecated + get("/statusnet/config", TwitterAPI.UtilController, :config) end scope "/api", Pleroma.Web do diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index bb08f5426..2fc60da5a 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -88,6 +88,18 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ end end + # Deprecated in favor of `/nodeinfo` + # https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2327 + # https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1084 + def config(conn, _params) do + json(conn, %{ + site: %{ + textlimit: to_string(Config.get([:instance, :limit])), + vapidPublicKey: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } + }) + end + def frontend_configurations(conn, _params) do config = Pleroma.Config.get(:frontend_configurations, %{}) -- cgit v1.2.3 From 4cf1007a7d478a54a759d018dd7ce958a45f3977 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:16:54 +0100 Subject: 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 -- cgit v1.2.3 From d7aa0b645b0da48af830f252ae80458afc965281 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 14:23:19 +0000 Subject: 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 -- cgit v1.2.3 From eaacc648392e6544cd3a3b77bde266e34cebf634 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:33:10 +0100 Subject: Refactors. --- lib/pleroma/web/activity_pub/activity_pub.ex | 3 +-- .../web/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 -- cgit v1.2.3 From 0adaab8e753b0ec22feccfc03d301073327a6d31 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:37:42 +0100 Subject: Bump copyright dates. --- COPYING | 4 ++-- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- lib/pleroma/web/activity_pub/object_validators/common_validations.ex | 2 +- lib/pleroma/web/activity_pub/object_validators/create_validator.ex | 2 +- lib/pleroma/web/activity_pub/object_validators/like_validator.ex | 2 +- lib/pleroma/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 +- test/web/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 -- cgit v1.2.3 From 0c60c0a76a2fcc8d13992b51704c21a35da10a0b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 15:44:14 +0100 Subject: Validators: Use correct type for IDs. --- lib/pleroma/web/activity_pub/object_validators/create_validator.ex | 2 +- lib/pleroma/web/activity_pub/object_validators/like_validator.ex | 2 +- lib/pleroma/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: []) -- cgit v1.2.3 From 69fc1dd69ff9d63af1785bb0701576cb5cde51f2 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 26 Mar 2020 14:45:28 +0000 Subject: 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 -- cgit v1.2.3 From 6b793d3f8336fcba5cac596f9e76d0274633f98d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 26 Mar 2020 21:54:01 +0300 Subject: Ensured no auxiliary computations (actors list preparation etc.) related to relationships preloading if no user is present (for statuses / accounts / relationships rendering). --- lib/pleroma/web/mastodon_api/views/account_view.ex | 26 +++++++++++----- .../web/mastodon_api/views/notification_view.ex | 35 ++++++++++++---------- lib/pleroma/web/mastodon_api/views/status_view.ex | 16 ++++++---- 3 files changed, 49 insertions(+), 28 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"])) - - 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) + cond do + Map.has_key?(opts, :relationships) -> + opts[:relationships] + + is_nil(opts[:for]) -> + UserRelationship.view_relationships_option(nil, []) + + 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 = -- cgit v1.2.3 From dfbc05d4965a04a82d4c4c5b8842f4117757f30e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 27 Mar 2020 08:01:03 +0300 Subject: Misc refactoring / tweaks (`ThreadMute.exists?/2`). --- lib/pleroma/thread_mute.ex | 4 ++-- lib/pleroma/web/common_api/common_api.ex | 2 +- lib/pleroma/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]) -- cgit v1.2.3 From be9d18461a5ed6bd835e2eba8d3b54ba61fc51fb Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 28 Mar 2020 18:49:03 +0300 Subject: FollowingRelationship storage & performance optimizations (state turned `ecto_enum`-driven integer, reorganized indices etc.). --- lib/pleroma/ecto_enums.ex | 6 +++ lib/pleroma/following_relationship.ex | 43 +++++++++++++++++++--- lib/pleroma/user.ex | 18 +++++---- lib/pleroma/user/query.ex | 6 +-- lib/pleroma/web/activity_pub/mrf.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 13 ++++--- lib/pleroma/web/common_api/common_api.ex | 4 +- lib/pleroma/web/mastodon_api/views/account_view.ex | 6 +-- ...ge_following_relationships_state_to_integer.exs | 29 +++++++++++++++ ..._following_relationships_following_id_index.exs | 11 ++++++ test/following_relationship_test.exs | 8 ++-- test/tasks/user_test.exs | 2 +- test/user_test.exs | 9 +++-- test/web/activity_pub/transmogrifier_test.exs | 2 +- test/web/common_api/common_api_test.exs | 4 +- .../controllers/follow_request_controller_test.exs | 4 +- test/web/streamer/streamer_test.exs | 6 +-- 17 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs create mode 100644 priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex index d9b601223..b98ac4ba1 100644 --- a/lib/pleroma/ecto_enums.ex +++ b/lib/pleroma/ecto_enums.ex @@ -11,3 +11,9 @@ notification_mute: 4, inverse_subscription: 5 ) + +defenum(FollowingRelationshipStateEnum, + follow_pending: 1, + follow_accept: 2, + follow_reject: 3 +) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index a9538ea4e..a28da8bec 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -13,7 +13,7 @@ defmodule Pleroma.FollowingRelationship do alias Pleroma.User schema "following_relationships" do - field(:state, :string, default: "accept") + field(:state, FollowingRelationshipStateEnum, default: :follow_pending) belongs_to(:follower, User, type: CompatType) belongs_to(:following, User, type: CompatType) @@ -27,6 +27,19 @@ def changeset(%__MODULE__{} = following_relationship, attrs) do |> put_assoc(:follower, attrs.follower) |> put_assoc(:following, attrs.following) |> validate_required([:state, :follower, :following]) + |> unique_constraint(:follower_id, + name: :following_relationships_follower_id_following_id_index + ) + |> validate_not_self_relationship() + end + + def state_to_enum(state) when is_binary(state) do + case state do + "pending" -> :follow_pending + "accept" -> :follow_accept + "reject" -> :follow_reject + _ -> raise "State is not convertible to FollowingRelationshipStateEnum: #{state}" + end end def get(%User{} = follower, %User{} = following) do @@ -35,7 +48,7 @@ def get(%User{} = follower, %User{} = following) do |> Repo.one() end - def update(follower, following, "reject"), do: unfollow(follower, following) + def update(follower, following, :follow_reject), do: unfollow(follower, following) def update(%User{} = follower, %User{} = following, state) do case get(follower, following) do @@ -50,7 +63,7 @@ def update(%User{} = follower, %User{} = following, state) do end end - def follow(%User{} = follower, %User{} = following, state \\ "accept") do + def follow(%User{} = follower, %User{} = following, state \\ :follow_accept) do %__MODULE__{} |> changeset(%{follower: follower, following: following, state: state}) |> Repo.insert(on_conflict: :nothing) @@ -80,7 +93,7 @@ def following_count(%User{} = user) do def get_follow_requests(%User{id: id}) do __MODULE__ |> join(:inner, [r], f in assoc(r, :follower)) - |> where([r], r.state == "pending") + |> where([r], r.state == ^:follow_pending) |> where([r], r.following_id == ^id) |> select([r, f], f) |> Repo.all() @@ -88,7 +101,7 @@ def get_follow_requests(%User{id: id}) do def following?(%User{id: follower_id}, %User{id: followed_id}) do __MODULE__ - |> where(follower_id: ^follower_id, following_id: ^followed_id, state: "accept") + |> where(follower_id: ^follower_id, following_id: ^followed_id, state: ^:follow_accept) |> Repo.exists?() end @@ -97,7 +110,7 @@ def following(%User{} = user) do __MODULE__ |> join(:inner, [r], u in User, on: r.following_id == u.id) |> where([r], r.follower_id == ^user.id) - |> where([r], r.state == "accept") + |> where([r], r.state == ^:follow_accept) |> select([r, u], u.follower_address) |> Repo.all() @@ -157,4 +170,22 @@ def find(following_relationships, follower, following) do fr -> fr.follower_id == follower.id and fr.following_id == following.id end) end + + defp validate_not_self_relationship(%Ecto.Changeset{} = changeset) do + changeset + |> validate_change(:following_id, fn _, following_id -> + if following_id == get_field(changeset, :follower_id) do + [target_id: "can't be equal to follower_id"] + else + [] + end + end) + |> validate_change(:follower_id, fn _, follower_id -> + if follower_id == get_field(changeset, :following_id) do + [source_id: "can't be equal to following_id"] + else + [] + end + end) + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d9aa54057..6ffb82045 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -697,7 +697,7 @@ def needs_update?(_), do: true @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()} def maybe_direct_follow(%User{} = follower, %User{local: true, locked: true} = followed) do - follow(follower, followed, "pending") + follow(follower, followed, :follow_pending) end def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do @@ -717,14 +717,14 @@ def maybe_direct_follow(%User{} = follower, %User{} = followed) do def follow_all(follower, followeds) do followeds |> Enum.reject(fn followed -> blocks?(follower, followed) || blocks?(followed, follower) end) - |> Enum.each(&follow(follower, &1, "accept")) + |> Enum.each(&follow(follower, &1, :follow_accept)) set_cache(follower) end defdelegate following(user), to: FollowingRelationship - def follow(%User{} = follower, %User{} = followed, state \\ "accept") do + def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) cond do @@ -751,7 +751,7 @@ def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do def unfollow(%User{} = follower, %User{} = followed) do case get_follow_state(follower, followed) do - state when state in ["accept", "pending"] -> + state when state in [:follow_pending, :follow_accept] -> FollowingRelationship.unfollow(follower, followed) {:ok, followed} = update_follower_count(followed) @@ -769,6 +769,7 @@ def unfollow(%User{} = follower, %User{} = followed) do defdelegate following?(follower, followed), to: FollowingRelationship + @doc "Returns follow state as FollowingRelationshipStateEnum value" def get_follow_state(%User{} = follower, %User{} = following) do following_relationship = FollowingRelationship.get(follower, following) get_follow_state(follower, following, following_relationship) @@ -782,8 +783,11 @@ def get_follow_state( case {following_relationship, following.local} do {nil, false} -> case Utils.fetch_latest_follow(follower, following) do - %{data: %{"state" => state}} when state in ["pending", "accept"] -> state - _ -> nil + %Activity{data: %{"state" => state}} when state in ["pending", "accept"] -> + FollowingRelationship.state_to_enum(state) + + _ -> + nil end {%{state: state}, _} -> @@ -1282,7 +1286,7 @@ def blocks?(nil, _), do: false def blocks?(%User{} = user, %User{} = target) do blocks_user?(user, target) || - (!User.following?(user, target) && blocks_domain?(user, target)) + (blocks_domain?(user, target) and not User.following?(user, target)) end def blocks_user?(%User{} = user, %User{} = target) do diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 884e33039..ec88088cf 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -148,7 +148,7 @@ defp compose_query({:followers, %User{id: id}}, query) do as: :relationships, on: r.following_id == ^id and r.follower_id == u.id ) - |> where([relationships: r], r.state == "accept") + |> where([relationships: r], r.state == ^:follow_accept) end defp compose_query({:friends, %User{id: id}}, query) do @@ -158,7 +158,7 @@ defp compose_query({:friends, %User{id: id}}, query) do as: :relationships, on: r.following_id == u.id and r.follower_id == ^id ) - |> where([relationships: r], r.state == "accept") + |> where([relationships: r], r.state == ^:follow_accept) end defp compose_query({:recipients_from_activity, to}, query) do @@ -173,7 +173,7 @@ defp compose_query({:recipients_from_activity, to}, query) do ) |> where( [u, following: f, relationships: r], - u.ap_id in ^to or (f.follower_address in ^to and r.state == "accept") + u.ap_id in ^to or (f.follower_address in ^to and r.state == ^:follow_accept) ) |> distinct(true) end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index a0b3af432..f54647945 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -33,7 +33,7 @@ def subdomains_regex(domains) when is_list(domains) do @spec subdomain_match?([Regex.t()], String.t()) :: boolean() def subdomain_match?(domains, host) do - Enum.any?(domains, fn domain -> Regex.match?(domain, host) end) + !!Enum.find(domains, fn domain -> Regex.match?(domain, host) end) end @callback describe() :: {:ok | :error, Map.t()} diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d6549a932..37e485741 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -490,7 +490,8 @@ def handle_incoming( {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept") do + {:ok, _relationship} <- + FollowingRelationship.update(follower, followed, :follow_accept) do ActivityPub.accept(%{ to: [follower.ap_id], actor: followed, @@ -500,7 +501,7 @@ def handle_incoming( else {:user_blocked, true} -> {:ok, _} = Utils.update_follow_state_for_all(activity, "reject") - {:ok, _relationship} = FollowingRelationship.update(follower, followed, "reject") + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_reject) ActivityPub.reject(%{ to: [follower.ap_id], @@ -511,7 +512,7 @@ def handle_incoming( {:follow, {:error, _}} -> {:ok, _} = Utils.update_follow_state_for_all(activity, "reject") - {:ok, _relationship} = FollowingRelationship.update(follower, followed, "reject") + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_reject) ActivityPub.reject(%{ to: [follower.ap_id], @@ -521,7 +522,7 @@ def handle_incoming( }) {:user_locked, true} -> - {:ok, _relationship} = FollowingRelationship.update(follower, followed, "pending") + {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_pending) :noop end @@ -541,7 +542,7 @@ def handle_incoming( {:ok, follow_activity} <- get_follow_activity(follow_object, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept") do + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do ActivityPub.accept(%{ to: follow_activity.data["to"], type: "Accept", @@ -564,7 +565,7 @@ def handle_incoming( {:ok, follow_activity} <- get_follow_activity(follow_object, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "reject"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), {:ok, activity} <- ActivityPub.reject(%{ to: follow_activity.data["to"], diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2646b9f7b..d530da42c 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -42,7 +42,7 @@ def accept_follow_request(follower, followed) do with {:ok, follower} <- User.follow(follower, followed), %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "accept"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept), {:ok, _activity} <- ActivityPub.accept(%{ to: [follower.ap_id], @@ -57,7 +57,7 @@ def accept_follow_request(follower, followed) do def reject_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, "reject"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), {:ok, _activity} <- ActivityPub.reject(%{ to: [follower.ap_id], diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 0efcabc01..f2dc2a9bd 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -71,7 +71,7 @@ def render( followed_by = if following_relationships do case FollowingRelationship.find(following_relationships, target, reading_user) do - %{state: "accept"} -> true + %{state: :follow_accept} -> true _ -> false end else @@ -81,7 +81,7 @@ def render( # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags %{ id: to_string(target.id), - following: follow_state == "accept", + following: follow_state == :follow_accept, followed_by: followed_by, blocking: UserRelationship.exists?( @@ -123,7 +123,7 @@ def render( reading_user, &User.subscribed_to?(&2, &1) ), - requested: follow_state == "pending", + requested: follow_state == :follow_pending, domain_blocking: User.blocks_domain?(reading_user, target), showing_reblogs: not UserRelationship.exists?( diff --git a/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs b/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs new file mode 100644 index 000000000..d5a431c00 --- /dev/null +++ b/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs @@ -0,0 +1,29 @@ +defmodule Pleroma.Repo.Migrations.ChangeFollowingRelationshipsStateToInteger do + use Ecto.Migration + + @alter_apps_scopes "ALTER TABLE following_relationships ALTER COLUMN state" + + def up do + execute(""" + #{@alter_apps_scopes} TYPE integer USING + CASE + WHEN state = 'pending' THEN 1 + WHEN state = 'accept' THEN 2 + WHEN state = 'reject' THEN 3 + ELSE 0 + END; + """) + end + + def down do + execute(""" + #{@alter_apps_scopes} TYPE varchar(255) USING + CASE + WHEN state = 1 THEN 'pending' + WHEN state = 2 THEN 'accept' + WHEN state = 3 THEN 'reject' + ELSE '' + END; + """) + end +end diff --git a/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs b/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs new file mode 100644 index 000000000..4c9faf48f --- /dev/null +++ b/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.AddFollowingRelationshipsFollowingIdIndex do + use Ecto.Migration + + # [:follower_index] index is useless because of [:follower_id, :following_id] index + # [:following_id] index makes sense because of user's followers-targeted queries + def change do + drop_if_exists(index(:following_relationships, [:follower_id])) + + create_if_not_exists(drop_if_exists(index(:following_relationships, [:following_id]))) + end +end diff --git a/test/following_relationship_test.exs b/test/following_relationship_test.exs index 865bb3838..17a468abb 100644 --- a/test/following_relationship_test.exs +++ b/test/following_relationship_test.exs @@ -15,28 +15,28 @@ defmodule Pleroma.FollowingRelationshipTest do test "returns following addresses without internal.fetch" do user = insert(:user) fetch_actor = InternalFetchActor.get_actor() - FollowingRelationship.follow(fetch_actor, user, "accept") + FollowingRelationship.follow(fetch_actor, user, :follow_accept) assert FollowingRelationship.following(fetch_actor) == [user.follower_address] end test "returns following addresses without relay" do user = insert(:user) relay_actor = Relay.get_actor() - FollowingRelationship.follow(relay_actor, user, "accept") + FollowingRelationship.follow(relay_actor, user, :follow_accept) assert FollowingRelationship.following(relay_actor) == [user.follower_address] end test "returns following addresses without remote user" do user = insert(:user) actor = insert(:user, local: false) - FollowingRelationship.follow(actor, user, "accept") + FollowingRelationship.follow(actor, user, :follow_accept) assert FollowingRelationship.following(actor) == [user.follower_address] end test "returns following addresses with local user" do user = insert(:user) actor = insert(:user, local: true) - FollowingRelationship.follow(actor, user, "accept") + FollowingRelationship.follow(actor, user, :follow_accept) assert FollowingRelationship.following(actor) == [ actor.follower_address, diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index b45f37263..8df835b56 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -140,7 +140,7 @@ test "no user to toggle" do test "user is unsubscribed" do followed = insert(:user) user = insert(:user) - User.follow(user, followed, "accept") + User.follow(user, followed, :follow_accept) Mix.Tasks.Pleroma.User.run(["unsubscribe", user.nickname]) diff --git a/test/user_test.exs b/test/user_test.exs index 8055ebd08..e7dfc5980 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -194,7 +194,8 @@ test "doesn't return already accepted or duplicate follow requests" do CommonAPI.follow(pending_follower, locked) CommonAPI.follow(pending_follower, locked) CommonAPI.follow(accepted_follower, locked) - Pleroma.FollowingRelationship.update(accepted_follower, locked, "accept") + + Pleroma.FollowingRelationship.update(accepted_follower, locked, :follow_accept) assert [^pending_follower] = User.get_follow_requests(locked) end @@ -319,7 +320,7 @@ test "unfollow with syncronizes external user" do following_address: "http://localhost:4001/users/fuser2/following" }) - {:ok, user} = User.follow(user, followed, "accept") + {:ok, user} = User.follow(user, followed, :follow_accept) {:ok, user, _activity} = User.unfollow(user, followed) @@ -332,7 +333,7 @@ test "unfollow takes a user and another user" do followed = insert(:user) user = insert(:user) - {:ok, user} = User.follow(user, followed, "accept") + {:ok, user} = User.follow(user, followed, :follow_accept) assert User.following(user) == [user.follower_address, followed.follower_address] @@ -353,7 +354,7 @@ test "unfollow doesn't unfollow yourself" do test "test if a user is following another user" do followed = insert(:user) user = insert(:user) - User.follow(user, followed, "accept") + User.follow(user, followed, :follow_accept) assert User.following?(user, followed) refute User.following?(followed, user) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index b2cabbd30..b998f0d78 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1622,7 +1622,7 @@ test "it upgrades a user to activitypub" do }) user_two = insert(:user) - Pleroma.FollowingRelationship.follow(user_two, user, "accept") + Pleroma.FollowingRelationship.follow(user_two, user, :follow_accept) {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) {:ok, unrelated_activity} = CommonAPI.post(user_two, %{"status" => "test"}) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 0da0bd2e2..e53a7cedd 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -562,7 +562,7 @@ test "cancels a pending follow for a local user" do assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} = CommonAPI.follow(follower, followed) - assert User.get_follow_state(follower, followed) == "pending" + assert User.get_follow_state(follower, followed) == :follow_pending assert {:ok, follower} = CommonAPI.unfollow(follower, followed) assert User.get_follow_state(follower, followed) == nil @@ -584,7 +584,7 @@ test "cancels a pending follow for a remote user" do assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} = CommonAPI.follow(follower, followed) - assert User.get_follow_state(follower, followed) == "pending" + assert User.get_follow_state(follower, followed) == :follow_pending assert {:ok, follower} = CommonAPI.unfollow(follower, followed) assert User.get_follow_state(follower, followed) == nil diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs index dd848821a..d8dbe4800 100644 --- a/test/web/mastodon_api/controllers/follow_request_controller_test.exs +++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs @@ -21,7 +21,7 @@ test "/api/v1/follow_requests works", %{user: user, conn: conn} do other_user = insert(:user) {:ok, _activity} = ActivityPub.follow(other_user, user) - {:ok, other_user} = User.follow(other_user, user, "pending") + {:ok, other_user} = User.follow(other_user, user, :follow_pending) assert User.following?(other_user, user) == false @@ -35,7 +35,7 @@ test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do other_user = insert(:user) {:ok, _activity} = ActivityPub.follow(other_user, user) - {:ok, other_user} = User.follow(other_user, user, "pending") + {:ok, other_user} = User.follow(other_user, user, :follow_pending) user = User.get_cached_by_id(user.id) other_user = User.get_cached_by_id(other_user.id) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index a5d6e8ecf..ad8ce030b 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -197,7 +197,7 @@ test "it doesn't send to user if recipients invalid and thread containment is en Pleroma.Config.put([:instance, :skip_thread_containment], false) author = insert(:user) user = insert(:user) - User.follow(user, author, "accept") + User.follow(user, author, :follow_accept) activity = insert(:note_activity, @@ -220,7 +220,7 @@ test "it sends message if recipients invalid and thread containment is disabled" Pleroma.Config.put([:instance, :skip_thread_containment], true) author = insert(:user) user = insert(:user) - User.follow(user, author, "accept") + User.follow(user, author, :follow_accept) activity = insert(:note_activity, @@ -243,7 +243,7 @@ test "it sends message if recipients invalid and thread containment is enabled b Pleroma.Config.put([:instance, :skip_thread_containment], false) author = insert(:user) user = insert(:user, skip_thread_containment: true) - User.follow(user, author, "accept") + User.follow(user, author, :follow_accept) activity = insert(:note_activity, -- cgit v1.2.3 From eb9744cadea7191b088ddaadfbd5fa4d4fd45090 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 14 Jan 2020 14:42:30 +0300 Subject: activities generation tasks --- benchmarks/load_testing/activities.ex | 515 ++++++++++++++++++++ benchmarks/load_testing/fetcher.ex | 683 ++++++++++++++++++--------- 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, 1171 insertions(+), 748 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 - - 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 - }) + 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 + + @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, - "user" => user + "type" => ["Create", "Announce"], + "user" => user, + "with_muted" => "true" } + end - mastodon_public_timeline_params = %{ - "count" => 20, - "local_only" => true, - "only_media" => "false", + 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"], - "with_muted" => "true", + "local_only" => false, "blocking_user" => user, "muting_user" => user } + end - mastodon_federated_timeline_params = %{ - "count" => 20, - "only_media" => "false", + defp opts_for_public_timeline(user, :local) do + %{ "type" => ["Create", "Announce"], - "with_muted" => "true", + "local_only" => true, "blocking_user" => user, "muting_user" => user } + end - following = User.following(user) - - 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 - }) - - home_activities = - Pleroma.Web.ActivityPub.ActivityPub.fetch_activities( - following, - home_timeline_params - ) + 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 - public_activities = - Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities(mastodon_public_timeline_params) + defp fetch_public_timeline(user) do + opts = opts_for_public_timeline(user) - public_federated_activities = - Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities( - mastodon_federated_timeline_params - ) + fetch_public_timeline(opts, "public timeline") + end + + defp fetch_public_timeline(user, :local) do + opts = opts_for_public_timeline(user, :local) - 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, - }) + fetch_public_timeline(opts, "public timeline only local") 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 - }) + defp fetch_public_timeline(user, :tag) do + opts = opts_for_public_timeline(user, :tag) + + fetch_public_timeline(opts, "hashtag timeline") end - def query_dms(user) do - params = %{ - "count" => "20", - "with_muted" => "true", - "type" => "Create", + 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, - visibility: "direct" + "user" => user } + end + + defp fetch_long_thread(user) do + %{public_thread: public, private_thread: private} = + Agent.get(:benchmark_state, fn state -> state end) + + opts = opts_for_long_thread(user) + + private_input = {private.data["context"], Map.put(opts, "exclude_id", private.id)} - 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 - }) + public_input = {public.data["context"], Map.put(opts, "exclude_id", public.id)} + + 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 - 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 - } + 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) ) - 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 - }) + private_context = + ActivityPub.fetch_activities_for_context( + private.data["context"], + Map.put(fetch_opts, "exclude_id", private.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 + } + }, + 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) - {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) - + Mix.Pleroma.start_pleroma() clean_tables() + {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) - 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()] -- cgit v1.2.3 From 1f29ecdcd7ecdc4ad8d6bc8fc4c34efbc9b7fe1d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 18 Feb 2020 12:19:10 +0300 Subject: sync with develop --- benchmarks/load_testing/activities.ex | 42 +++++++++++++++++ benchmarks/load_testing/helper.ex | 11 +++++ benchmarks/load_testing/users.ex | 61 ++++++++++++++----------- benchmarks/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...") + + {time, _} = + :timer.tc(fn -> + 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: :local}) + |> run_stream(main_user) - main_user - |> get_users(%{limit: number_of_users, local: :external}) - |> 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 -- cgit v1.2.3 From 56503c385e8412a1189748bcf3fdfd4090be9f56 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 17 Mar 2020 13:47:13 +0300 Subject: 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 -- cgit v1.2.3 From 96e279655763fedcb701e59c500023a70568c4c6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 11:59:14 +0300 Subject: 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 -- cgit v1.2.3 From 2afc7a9112fc11bc51abc2b65aea03d6d5045695 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 12:16:45 +0300 Subject: 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`) -- cgit v1.2.3 From 1fcdcb12a717fa3dbd54a5c3778bd216df6449ad Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 12:47:12 +0300 Subject: 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"}, -- cgit v1.2.3 From b607ae1a1c0ef6557094ec0fb10ba2d19d621f7f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 13:50:00 +0300 Subject: removing grouped reports admin api endpoint --- lib/pleroma/web/activity_pub/utils.ex | 96 ---------- lib/pleroma/web/admin_api/admin_api_controller.ex | 8 - lib/pleroma/web/admin_api/views/report_view.ex | 28 +-- lib/pleroma/web/router.ex | 1 - test/web/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) -- cgit v1.2.3 From 9c94b6a327118d8c7ea21355d6c378ef31c54321 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 30 Mar 2020 19:08:37 +0300 Subject: [#2332] Misc. fixes per code change requests. --- lib/pleroma/web/activity_pub/mrf.ex | 2 +- .../20200328130139_add_following_relationships_following_id_index.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index f54647945..a0b3af432 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -33,7 +33,7 @@ def subdomains_regex(domains) when is_list(domains) do @spec subdomain_match?([Regex.t()], String.t()) :: boolean() def subdomain_match?(domains, host) do - !!Enum.find(domains, fn domain -> Regex.match?(domain, host) end) + Enum.any?(domains, fn domain -> Regex.match?(domain, host) end) end @callback describe() :: {:ok | :error, Map.t()} diff --git a/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs b/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs index 4c9faf48f..884832f84 100644 --- a/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs +++ b/priv/repo/migrations/20200328130139_add_following_relationships_following_id_index.exs @@ -6,6 +6,6 @@ defmodule Pleroma.Repo.Migrations.AddFollowingRelationshipsFollowingIdIndex do def change do drop_if_exists(index(:following_relationships, [:follower_id])) - create_if_not_exists(drop_if_exists(index(:following_relationships, [:following_id]))) + create_if_not_exists(index(:following_relationships, [:following_id])) end end -- cgit v1.2.3 From ea9c57b26ed463622e4489736fcddb8fca1b3341 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 31 Mar 2020 09:21:42 +0300 Subject: [#2332] Misc. improvements per code change requests. --- lib/pleroma/ecto_enums.ex | 4 +-- lib/pleroma/following_relationship.ex | 38 +++++++++++++--------- lib/pleroma/user.ex | 2 +- lib/pleroma/user_relationship.ex | 29 +++++++++++------ ...ge_following_relationships_state_to_integer.exs | 6 ++-- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex index b98ac4ba1..6fc47620c 100644 --- a/lib/pleroma/ecto_enums.ex +++ b/lib/pleroma/ecto_enums.ex @@ -4,7 +4,7 @@ import EctoEnum -defenum(UserRelationshipTypeEnum, +defenum(Pleroma.UserRelationship.Type, block: 1, mute: 2, reblog_mute: 3, @@ -12,7 +12,7 @@ inverse_subscription: 5 ) -defenum(FollowingRelationshipStateEnum, +defenum(Pleroma.FollowingRelationship.State, follow_pending: 1, follow_accept: 2, follow_reject: 3 diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index a28da8bec..9ccf40495 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -8,12 +8,13 @@ defmodule Pleroma.FollowingRelationship do import Ecto.Changeset import Ecto.Query + alias Ecto.Changeset alias FlakeId.Ecto.CompatType alias Pleroma.Repo alias Pleroma.User schema "following_relationships" do - field(:state, FollowingRelationshipStateEnum, default: :follow_pending) + field(:state, Pleroma.FollowingRelationship.State, default: :follow_pending) belongs_to(:follower, User, type: CompatType) belongs_to(:following, User, type: CompatType) @@ -33,13 +34,12 @@ def changeset(%__MODULE__{} = following_relationship, attrs) do |> validate_not_self_relationship() end - def state_to_enum(state) when is_binary(state) do - case state do - "pending" -> :follow_pending - "accept" -> :follow_accept - "reject" -> :follow_reject - _ -> raise "State is not convertible to FollowingRelationshipStateEnum: #{state}" - end + def state_to_enum(state) when state in ["pending", "accept", "reject"] do + String.to_existing_atom("follow_#{state}") + end + + def state_to_enum(state) do + raise "State is not convertible to Pleroma.FollowingRelationship.State: #{state}" end def get(%User{} = follower, %User{} = following) do @@ -171,18 +171,26 @@ def find(following_relationships, follower, following) do end) end - defp validate_not_self_relationship(%Ecto.Changeset{} = changeset) do + defp validate_not_self_relationship(%Changeset{} = changeset) do changeset - |> validate_change(:following_id, fn _, following_id -> - if following_id == get_field(changeset, :follower_id) do - [target_id: "can't be equal to follower_id"] + |> validate_follower_id_following_id_inequality() + |> validate_following_id_follower_id_inequality() + end + + defp validate_follower_id_following_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :follower_id, fn _, follower_id -> + if follower_id == get_field(changeset, :following_id) do + [source_id: "can't be equal to following_id"] else [] end end) - |> validate_change(:follower_id, fn _, follower_id -> - if follower_id == get_field(changeset, :following_id) do - [source_id: "can't be equal to following_id"] + end + + defp validate_following_id_follower_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :following_id, fn _, following_id -> + if following_id == get_field(changeset, :follower_id) do + [target_id: "can't be equal to follower_id"] else [] end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 6ffb82045..4f3abd7d5 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -769,7 +769,7 @@ def unfollow(%User{} = follower, %User{} = followed) do defdelegate following?(follower, followed), to: FollowingRelationship - @doc "Returns follow state as FollowingRelationshipStateEnum value" + @doc "Returns follow state as Pleroma.FollowingRelationship.State value" def get_follow_state(%User{} = follower, %User{} = following) do following_relationship = FollowingRelationship.get(follower, following) get_follow_state(follower, following, following_relationship) diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 18a5eec72..ad0d303b1 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 Ecto.Changeset alias Pleroma.FollowingRelationship alias Pleroma.Repo alias Pleroma.User @@ -16,12 +17,12 @@ defmodule Pleroma.UserRelationship do schema "user_relationships" do belongs_to(:source, User, type: FlakeId.Ecto.CompatType) belongs_to(:target, User, type: FlakeId.Ecto.CompatType) - field(:relationship_type, UserRelationshipTypeEnum) + field(:relationship_type, Pleroma.UserRelationship.Type) timestamps(updated_at: false) end - for relationship_type <- Keyword.keys(UserRelationshipTypeEnum.__enum_map__()) do + for relationship_type <- Keyword.keys(Pleroma.UserRelationship.Type.__enum_map__()) do # `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), @@ -40,7 +41,7 @@ def unquote(:"#{relationship_type}_exists?")(source, target), def user_relationship_types, do: Keyword.keys(user_relationship_mappings()) - def user_relationship_mappings, do: UserRelationshipTypeEnum.__enum_map__() + def user_relationship_mappings, do: Pleroma.UserRelationship.Type.__enum_map__() def changeset(%UserRelationship{} = user_relationship, params \\ %{}) do user_relationship @@ -147,18 +148,26 @@ def view_relationships_option(%User{} = reading_user, actors) do %{user_relationships: user_relationships, following_relationships: following_relationships} end - defp validate_not_self_relationship(%Ecto.Changeset{} = changeset) do + defp validate_not_self_relationship(%Changeset{} = changeset) do changeset - |> validate_change(:target_id, fn _, target_id -> - if target_id == get_field(changeset, :source_id) do - [target_id: "can't be equal to source_id"] + |> validate_source_id_target_id_inequality() + |> validate_target_id_source_id_inequality() + end + + defp validate_source_id_target_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :source_id, fn _, source_id -> + if source_id == get_field(changeset, :target_id) do + [source_id: "can't be equal to target_id"] else [] end end) - |> validate_change(:source_id, fn _, source_id -> - if source_id == get_field(changeset, :target_id) do - [source_id: "can't be equal to target_id"] + end + + defp validate_target_id_source_id_inequality(%Changeset{} = changeset) do + validate_change(changeset, :target_id, fn _, target_id -> + if target_id == get_field(changeset, :source_id) do + [target_id: "can't be equal to source_id"] else [] end diff --git a/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs b/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs index d5a431c00..2b0820f3f 100644 --- a/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs +++ b/priv/repo/migrations/20200328124805_change_following_relationships_state_to_integer.exs @@ -1,11 +1,11 @@ defmodule Pleroma.Repo.Migrations.ChangeFollowingRelationshipsStateToInteger do use Ecto.Migration - @alter_apps_scopes "ALTER TABLE following_relationships ALTER COLUMN state" + @alter_following_relationship_state "ALTER TABLE following_relationships ALTER COLUMN state" def up do execute(""" - #{@alter_apps_scopes} TYPE integer USING + #{@alter_following_relationship_state} TYPE integer USING CASE WHEN state = 'pending' THEN 1 WHEN state = 'accept' THEN 2 @@ -17,7 +17,7 @@ def up do def down do execute(""" - #{@alter_apps_scopes} TYPE varchar(255) USING + #{@alter_following_relationship_state} TYPE varchar(255) USING CASE WHEN state = 1 THEN 'pending' WHEN state = 2 THEN 'accept' -- cgit v1.2.3 From f6835333be745cd411b5d2571c304fc7a16d645e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 12:55:25 +0000 Subject: 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}} <- -- cgit v1.2.3 From d191b0942f64a32a2bf450318fac85981aa17c83 Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 31 Mar 2020 22:48:42 +0900 Subject: 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) -- cgit v1.2.3 From 643f15e77b7cdaaf2c22a876c98e5680edc32dc3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 16:11:38 +0200 Subject: Validators: ObjectID is an http uri. --- .../activity_pub/object_validators/types/object.ex | 16 ++++++--- .../object_validators/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 -- cgit v1.2.3 From df5f89c0d6d8d385434d5d8a51719fa41631d7b2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 31 Mar 2020 18:22:25 +0300 Subject: 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 -- cgit v1.2.3 From 7af0959a07ebd5f8242704658ccb770d86fdb4c6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 31 Mar 2020 18:30:19 +0300 Subject: 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 -- cgit v1.2.3 From aebec1bac9831da2bed5ee571225d92dc99a5d59 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:47:34 +0200 Subject: Validator Test: Small refactor. --- test/web/activity_pub/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) -- cgit v1.2.3 From 057438a657eaadb963e006b84b890ae4f8441808 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:56:05 +0200 Subject: 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 -- cgit v1.2.3 From 0be1fa0a8695df87a8b22279b885956943e33796 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:00:48 +0000 Subject: 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 -- cgit v1.2.3 From 288f2b5a7c728959d43205a97d5225b34b5b8161 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:00:55 +0000 Subject: 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 -- cgit v1.2.3 From ecac57732a063c1ad01aeb5aa4eb9853b6f904e9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 19:16:45 +0200 Subject: 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 -- cgit v1.2.3 From 1b323ce1c668c6a26617a05dcc12ee255c764e88 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 17:28:18 +0000 Subject: Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- lib/pleroma/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 -- cgit v1.2.3 From c982093cc2f538e8ef9dde365e163a944c6cb6d0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 31 Mar 2020 19:33:41 +0200 Subject: 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 -- cgit v1.2.3 From dbf9d719f98770056ac906b3087e7ed501cd64e6 Mon Sep 17 00:00:00 2001 From: kPherox Date: Wed, 1 Apr 2020 00:05:13 +0900 Subject: split test for update profile fields --- .../account_controller/update_credentials_test.exs | 68 ++++++++++++---------- 1 file changed, 38 insertions(+), 30 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,32 +315,49 @@ 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 + test "update fields with empty name", %{conn: conn} do + fields = [ + %{"name" => "foo", "value" => ""}, + %{"name" => "", "value" => "bar"} + ] + + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) + |> json_response(200) + + assert account["fields"] == [ + %{"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}] + 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"} == @@ -346,7 +368,7 @@ test "update fields", %{conn: conn} do Pleroma.Config.put([:instance, :max_account_fields], 1) fields = [ - %{"name" => "foo", "value" => "bar"}, + %{"name" => "foo", "value" => "bar"}, %{"name" => "link", "value" => "cofe.io"} ] @@ -354,20 +376,6 @@ test "update fields", %{conn: conn} do conn |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) |> json_response(403) - - fields = [ - %{"name" => "foo", "value" => ""}, - %{"name" => "", "value" => "bar"} - ] - - account = - conn - |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(200) - - assert account["fields"] == [ - %{"name" => "foo", "value" => ""} - ] end end end -- cgit v1.2.3 From 7408f003a663c5f634cabad963c0446ba54810bf Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 31 Mar 2020 11:13:53 +0000 Subject: 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 -- cgit v1.2.3 From 219d3aaa2d1fd2474a88ec40d7e6938741e7fc4b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 31 Mar 2020 13:05:16 -0500 Subject: Update AdminFE build in preparation for Pleroma 2.0.2 --- priv/static/adminfe/app.85534e14.css | Bin 0 -> 12836 bytes priv/static/adminfe/app.c836e084.css | Bin 12836 -> 0 bytes priv/static/adminfe/chunk-0d8f.650c8e81.css | Bin 3433 -> 0 bytes priv/static/adminfe/chunk-0d8f.d85f5a29.css | Bin 0 -> 3433 bytes priv/static/adminfe/chunk-136a.3936457d.css | Bin 4946 -> 0 bytes priv/static/adminfe/chunk-136a.f1130f8e.css | Bin 0 -> 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 priv/static/adminfe/chunk-46ef.145de4f9.css | Bin 0 -> 1790 bytes priv/static/adminfe/chunk-46ef.d45db7be.css | Bin 1790 -> 0 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 priv/static/adminfe/chunk-88c9.184084df.css | Bin 0 -> 5731 bytes priv/static/adminfe/chunk-cf57.26596375.css | Bin 0 -> 3244 bytes priv/static/adminfe/chunk-cf57.4d39576f.css | Bin 3221 -> 0 bytes priv/static/adminfe/chunk-e5cf.cba3ae06.css | Bin 5731 -> 0 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.d2c3c6b3.js | Bin 181998 -> 0 bytes priv/static/adminfe/static/js/app.d2c3c6b3.js.map | Bin 403968 -> 0 bytes priv/static/adminfe/static/js/app.d898cc2b.js | Bin 0 -> 185128 bytes priv/static/adminfe/static/js/app.d898cc2b.js.map | Bin 0 -> 410154 bytes priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js | Bin 0 -> 33538 bytes .../adminfe/static/js/chunk-0d8f.6d50ff86.js.map | Bin 0 -> 116201 bytes priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js | Bin 33538 -> 0 bytes .../adminfe/static/js/chunk-0d8f.a85e3222.js.map | Bin 116201 -> 0 bytes priv/static/adminfe/static/js/chunk-136a.142aa42a.js | Bin 19553 -> 0 bytes .../adminfe/static/js/chunk-136a.142aa42a.js.map | Bin 69090 -> 0 bytes priv/static/adminfe/static/js/chunk-136a.c4719e3e.js | Bin 0 -> 19553 bytes .../adminfe/static/js/chunk-136a.c4719e3e.js.map | Bin 0 -> 69090 bytes priv/static/adminfe/static/js/chunk-13e9.79da1569.js | Bin 0 -> 9528 bytes .../adminfe/static/js/chunk-13e9.79da1569.js.map | Bin 0 -> 40125 bytes priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js | Bin 0 -> 28194 bytes .../adminfe/static/js/chunk-2b9c.cf321c74.js.map | Bin 0 -> 95810 bytes priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js | Bin 9526 -> 0 bytes .../adminfe/static/js/chunk-46cf.3bd3567a.js.map | Bin 40123 -> 0 bytes priv/static/adminfe/static/js/chunk-46ef.215af110.js | Bin 7765 -> 0 bytes .../adminfe/static/js/chunk-46ef.215af110.js.map | Bin 26170 -> 0 bytes priv/static/adminfe/static/js/chunk-46ef.671cac7d.js | Bin 0 -> 7765 bytes .../adminfe/static/js/chunk-46ef.671cac7d.js.map | Bin 0 -> 26170 bytes priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js | Bin 23331 -> 0 bytes .../adminfe/static/js/chunk-4e7d.a40ad735.js.map | Bin 80396 -> 0 bytes priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js | Bin 0 -> 103449 bytes .../adminfe/static/js/chunk-87b3.3c11ef09.js.map | Bin 0 -> 358904 bytes priv/static/adminfe/static/js/chunk-87b3.4704cadf.js | Bin 103161 -> 0 bytes .../adminfe/static/js/chunk-87b3.4704cadf.js.map | Bin 358274 -> 0 bytes priv/static/adminfe/static/js/chunk-88c9.e3583744.js | Bin 0 -> 24234 bytes .../adminfe/static/js/chunk-88c9.e3583744.js.map | Bin 0 -> 92387 bytes priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js | Bin 0 -> 29728 bytes .../adminfe/static/js/chunk-cf57.3e45f57f.js.map | Bin 0 -> 89855 bytes priv/static/adminfe/static/js/chunk-cf57.42b96339.js | Bin 29100 -> 0 bytes .../adminfe/static/js/chunk-cf57.42b96339.js.map | Bin 88026 -> 0 bytes priv/static/adminfe/static/js/chunk-e5cf.501d7902.js | Bin 24234 -> 0 bytes .../adminfe/static/js/chunk-e5cf.501d7902.js.map | Bin 92386 -> 0 bytes priv/static/adminfe/static/js/runtime.cb26bbd1.js | Bin 0 -> 3969 bytes priv/static/adminfe/static/js/runtime.cb26bbd1.js.map | Bin 0 -> 16759 bytes priv/static/adminfe/static/js/runtime.fa19e5d1.js | Bin 3969 -> 0 bytes priv/static/adminfe/static/js/runtime.fa19e5d1.js.map | Bin 16759 -> 0 bytes 59 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/app.85534e14.css delete mode 100644 priv/static/adminfe/app.c836e084.css delete mode 100644 priv/static/adminfe/chunk-0d8f.650c8e81.css create mode 100644 priv/static/adminfe/chunk-0d8f.d85f5a29.css delete mode 100644 priv/static/adminfe/chunk-136a.3936457d.css create mode 100644 priv/static/adminfe/chunk-136a.f1130f8e.css 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 create mode 100644 priv/static/adminfe/chunk-46ef.145de4f9.css delete mode 100644 priv/static/adminfe/chunk-46ef.d45db7be.css 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 create mode 100644 priv/static/adminfe/chunk-88c9.184084df.css create mode 100644 priv/static/adminfe/chunk-cf57.26596375.css delete mode 100644 priv/static/adminfe/chunk-cf57.4d39576f.css delete mode 100644 priv/static/adminfe/chunk-e5cf.cba3ae06.css 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 create mode 100644 priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js create mode 100644 priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js delete mode 100644 priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-136a.142aa42a.js delete mode 100644 priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-136a.c4719e3e.js create mode 100644 priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map 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 delete mode 100644 priv/static/adminfe/static/js/chunk-46ef.215af110.js delete mode 100644 priv/static/adminfe/static/js/chunk-46ef.215af110.js.map create mode 100644 priv/static/adminfe/static/js/chunk-46ef.671cac7d.js create mode 100644 priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map 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 create mode 100644 priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js 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 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 create mode 100644 priv/static/adminfe/static/js/runtime.cb26bbd1.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.fa19e5d1.js delete mode 100644 priv/static/adminfe/static/js/runtime.fa19e5d1.js.map diff --git a/priv/static/adminfe/app.85534e14.css b/priv/static/adminfe/app.85534e14.css new file mode 100644 index 000000000..473ec1b86 Binary files /dev/null and b/priv/static/adminfe/app.85534e14.css differ diff --git a/priv/static/adminfe/app.c836e084.css b/priv/static/adminfe/app.c836e084.css deleted file mode 100644 index 473ec1b86..000000000 Binary files a/priv/static/adminfe/app.c836e084.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0d8f.650c8e81.css b/priv/static/adminfe/chunk-0d8f.650c8e81.css deleted file mode 100644 index 0b2a3f669..000000000 Binary files a/priv/static/adminfe/chunk-0d8f.650c8e81.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0d8f.d85f5a29.css b/priv/static/adminfe/chunk-0d8f.d85f5a29.css new file mode 100644 index 000000000..931620872 Binary files /dev/null and b/priv/static/adminfe/chunk-0d8f.d85f5a29.css differ diff --git a/priv/static/adminfe/chunk-136a.3936457d.css b/priv/static/adminfe/chunk-136a.3936457d.css deleted file mode 100644 index 2857a9d6e..000000000 Binary files a/priv/static/adminfe/chunk-136a.3936457d.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-136a.f1130f8e.css b/priv/static/adminfe/chunk-136a.f1130f8e.css new file mode 100644 index 000000000..f492b37d0 Binary files /dev/null and b/priv/static/adminfe/chunk-136a.f1130f8e.css differ diff --git a/priv/static/adminfe/chunk-13e9.98eaadba.css b/priv/static/adminfe/chunk-13e9.98eaadba.css new file mode 100644 index 000000000..9f377eee2 Binary files /dev/null and b/priv/static/adminfe/chunk-13e9.98eaadba.css differ diff --git a/priv/static/adminfe/chunk-2b9c.feb61a2b.css b/priv/static/adminfe/chunk-2b9c.feb61a2b.css new file mode 100644 index 000000000..f54eca1f5 Binary files /dev/null and b/priv/static/adminfe/chunk-2b9c.feb61a2b.css differ diff --git a/priv/static/adminfe/chunk-46cf.a43e9415.css b/priv/static/adminfe/chunk-46cf.a43e9415.css deleted file mode 100644 index aa7160528..000000000 Binary files a/priv/static/adminfe/chunk-46cf.a43e9415.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-46ef.145de4f9.css b/priv/static/adminfe/chunk-46ef.145de4f9.css new file mode 100644 index 000000000..deb5249ac Binary files /dev/null and b/priv/static/adminfe/chunk-46ef.145de4f9.css differ diff --git a/priv/static/adminfe/chunk-46ef.d45db7be.css b/priv/static/adminfe/chunk-46ef.d45db7be.css deleted file mode 100644 index d6cc7d182..000000000 Binary files a/priv/static/adminfe/chunk-46ef.d45db7be.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-4e7d.7aace723.css b/priv/static/adminfe/chunk-4e7d.7aace723.css deleted file mode 100644 index 9a35b64a0..000000000 Binary files a/priv/static/adminfe/chunk-4e7d.7aace723.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-87b3.2affd602.css b/priv/static/adminfe/chunk-87b3.2affd602.css deleted file mode 100644 index c4fa46d3e..000000000 Binary files a/priv/static/adminfe/chunk-87b3.2affd602.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-87b3.3c6ede9c.css b/priv/static/adminfe/chunk-87b3.3c6ede9c.css new file mode 100644 index 000000000..f0e6bf4ee Binary files /dev/null and b/priv/static/adminfe/chunk-87b3.3c6ede9c.css differ diff --git a/priv/static/adminfe/chunk-88c9.184084df.css b/priv/static/adminfe/chunk-88c9.184084df.css new file mode 100644 index 000000000..f3299f33b Binary files /dev/null and b/priv/static/adminfe/chunk-88c9.184084df.css differ diff --git a/priv/static/adminfe/chunk-cf57.26596375.css b/priv/static/adminfe/chunk-cf57.26596375.css new file mode 100644 index 000000000..9f72b88c1 Binary files /dev/null and b/priv/static/adminfe/chunk-cf57.26596375.css differ diff --git a/priv/static/adminfe/chunk-cf57.4d39576f.css b/priv/static/adminfe/chunk-cf57.4d39576f.css deleted file mode 100644 index 1190aca24..000000000 Binary files a/priv/static/adminfe/chunk-cf57.4d39576f.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-e5cf.cba3ae06.css b/priv/static/adminfe/chunk-e5cf.cba3ae06.css deleted file mode 100644 index a74b42d14..000000000 Binary files a/priv/static/adminfe/chunk-e5cf.cba3ae06.css and /dev/null differ 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 c527207dd..000000000 Binary files a/priv/static/adminfe/static/js/app.d2c3c6b3.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.d2c3c6b3.js.map b/priv/static/adminfe/static/js/app.d2c3c6b3.js.map deleted file mode 100644 index 7b2d4dc05..000000000 Binary files a/priv/static/adminfe/static/js/app.d2c3c6b3.js.map and /dev/null differ 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 000000000..9d60db06b Binary files /dev/null and b/priv/static/adminfe/static/js/app.d898cc2b.js differ diff --git a/priv/static/adminfe/static/js/app.d898cc2b.js.map b/priv/static/adminfe/static/js/app.d898cc2b.js.map new file mode 100644 index 000000000..1c4ec7590 Binary files /dev/null and b/priv/static/adminfe/static/js/app.d898cc2b.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js b/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js new file mode 100644 index 000000000..4b0945f57 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map b/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map new file mode 100644 index 000000000..da24cbef5 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js b/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js deleted file mode 100644 index e3b0ae986..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map b/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map deleted file mode 100644 index cf75f3243..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0d8f.a85e3222.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-136a.142aa42a.js b/priv/static/adminfe/static/js/chunk-136a.142aa42a.js deleted file mode 100644 index 812089b5f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-136a.142aa42a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map b/priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map deleted file mode 100644 index f6b4c84aa..000000000 Binary files a/priv/static/adminfe/static/js/chunk-136a.142aa42a.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js b/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js new file mode 100644 index 000000000..0c2f1a52e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js differ diff --git a/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map b/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map new file mode 100644 index 000000000..4b137fd49 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-13e9.79da1569.js b/priv/static/adminfe/static/js/chunk-13e9.79da1569.js new file mode 100644 index 000000000..b98177b82 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-13e9.79da1569.js differ diff --git a/priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map b/priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map new file mode 100644 index 000000000..118a47034 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map differ 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 000000000..f06da0268 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js differ 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 000000000..1ec750dd1 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map differ 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 0795a46b6..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map b/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map deleted file mode 100644 index 9993be4aa..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46cf.3bd3567a.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.215af110.js b/priv/static/adminfe/static/js/chunk-46ef.215af110.js deleted file mode 100644 index db11c7488..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46ef.215af110.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.215af110.js.map b/priv/static/adminfe/static/js/chunk-46ef.215af110.js.map deleted file mode 100644 index 2da3dbec6..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46ef.215af110.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js new file mode 100644 index 000000000..805cdea13 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map new file mode 100644 index 000000000..f6b420bb2 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map differ 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 ef2379ed9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map b/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map deleted file mode 100644 index b349f12eb..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4e7d.a40ad735.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js new file mode 100644 index 000000000..3899ff190 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map new file mode 100644 index 000000000..6c6a85667 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js b/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js deleted file mode 100644 index 9766fd7d2..000000000 Binary files a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map b/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map deleted file mode 100644 index 7472fcd92..000000000 Binary files a/priv/static/adminfe/static/js/chunk-87b3.4704cadf.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-88c9.e3583744.js b/priv/static/adminfe/static/js/chunk-88c9.e3583744.js new file mode 100644 index 000000000..0070fc30a Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-88c9.e3583744.js differ 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 000000000..20e503d0c Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map differ 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 000000000..2b4fd918f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js differ diff --git a/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map b/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map new file mode 100644 index 000000000..6457630bd Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-cf57.42b96339.js b/priv/static/adminfe/static/js/chunk-cf57.42b96339.js deleted file mode 100644 index 81122f992..000000000 Binary files a/priv/static/adminfe/static/js/chunk-cf57.42b96339.js and /dev/null differ 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 7471835b9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-cf57.42b96339.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js b/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js deleted file mode 100644 index fe5552943..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js and /dev/null differ 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 60676bfe7..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e5cf.501d7902.js.map and /dev/null differ 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 000000000..7180cc6e3 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.cb26bbd1.js differ diff --git a/priv/static/adminfe/static/js/runtime.cb26bbd1.js.map b/priv/static/adminfe/static/js/runtime.cb26bbd1.js.map new file mode 100644 index 000000000..631198682 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.cb26bbd1.js.map differ 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 b905e42e1..000000000 Binary files a/priv/static/adminfe/static/js/runtime.fa19e5d1.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.fa19e5d1.js.map b/priv/static/adminfe/static/js/runtime.fa19e5d1.js.map deleted file mode 100644 index 6a2565556..000000000 Binary files a/priv/static/adminfe/static/js/runtime.fa19e5d1.js.map and /dev/null differ -- cgit v1.2.3 From f6dc33615bb6a27cd0a963bb8a610bb30bd6d619 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 31 Mar 2020 14:29:43 -0500 Subject: 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 &&\ -- cgit v1.2.3 From 2553400a662de7170dd56ee0950a6c1bb1513e45 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Mar 2020 22:01:49 +0200 Subject: Initial failing test statement against funkwhale channels --- test/web/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) -- cgit v1.2.3 From b30fb1f3bbf8fb8e49cc5276225dc09771c79477 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 29 Mar 2020 22:30:50 +0200 Subject: 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 -- cgit v1.2.3 From 185520d1b4d3fdf8ecde7814faec92bbb531ce59 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 30 Mar 2020 02:01:09 +0200 Subject: 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 +- .../web/templates/static_fe/static_fe/_user_card.html.eex | 2 +- .../web/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 -- cgit v1.2.3 From d3cd3b96bff4c8ba205d4699eb8cf9d1b6fd5a7d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 31 Mar 2020 17:28:41 -0500 Subject: 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: -- cgit v1.2.3 From c2715ed77269bea1eb70d0d5e4b00e7d86eed854 Mon Sep 17 00:00:00 2001 From: jp Date: Tue, 31 Mar 2020 21:31:23 -0400 Subject: 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 -- cgit v1.2.3 From bcaaba4660c7f2f31756bbd64ed93fcd8e0b1d85 Mon Sep 17 00:00:00 2001 From: jp Date: Tue, 31 Mar 2020 22:16:36 -0400 Subject: 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 -- cgit v1.2.3 From 94ddbe4098e167f9537d168261a6cc76fa17508b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 1 Apr 2020 09:55:05 +0300 Subject: 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 -- cgit v1.2.3 From 037b49c415060b4c7ad5a570da80857b4d2c43f1 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 1 Apr 2020 16:10:17 +0200 Subject: Validators: Correct ObjectID filename --- .../activity_pub/object_validators/types/object.ex | 33 ---------------------- .../object_validators/types/object_id.ex | 33 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 33 deletions(-) delete mode 100644 lib/pleroma/web/activity_pub/object_validators/types/object.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/object_id.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/types/object.ex b/lib/pleroma/web/activity_pub/object_validators/types/object.ex deleted file mode 100644 index 8e70effe4..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/types/object.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do - use Ecto.Type - - 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 - _ -> - :error - end - end - - def cast(%{"id" => object}), do: cast(object) - - 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/activity_pub/object_validators/types/object_id.ex b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex new file mode 100644 index 000000000..8e70effe4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex @@ -0,0 +1,33 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do + use Ecto.Type + + 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 + _ -> + :error + end + end + + def cast(%{"id" => object}), do: cast(object) + + def cast(_) do + :error + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end -- cgit v1.2.3 From 2f2bd7fe72f474b7177c751a2dc3af716622ba91 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 1 Apr 2020 19:49:09 +0300 Subject: Ability to control the output of account/pleroma/relationship in statuses in order to improve the rendering performance. See `[:extensions, output_relationships_in_statuses_by_default]` setting and `with_relationships` param. --- CHANGELOG.md | 3 +- benchmarks/load_testing/fetcher.ex | 21 ++++++++---- config/config.exs | 2 ++ config/description.exs | 16 +++++++++ lib/mix/tasks/pleroma/benchmark.ex | 3 +- lib/pleroma/user_relationship.ex | 18 +++++++--- lib/pleroma/web/admin_api/admin_api_controller.ex | 6 ++-- lib/pleroma/web/admin_api/views/report_view.ex | 7 +++- lib/pleroma/web/common_api/activity_draft.ex | 2 +- lib/pleroma/web/controller_helper.ex | 24 +++++++++++-- .../mastodon_api/controllers/account_controller.ex | 15 ++++++-- .../controllers/notification_controller.ex | 8 +++-- .../mastodon_api/controllers/search_controller.ex | 24 ++++++++++--- .../mastodon_api/controllers/status_controller.ex | 26 +++++++++++--- .../controllers/timeline_controller.ex | 40 +++++++++++++++++----- lib/pleroma/web/mastodon_api/views/account_view.ex | 15 +++++--- .../web/mastodon_api/views/notification_view.ex | 30 +++++++++------- lib/pleroma/web/mastodon_api/views/status_view.ex | 11 ++++-- .../pleroma_api/controllers/account_controller.ex | 9 +++-- .../controllers/pleroma_api_controller.ex | 17 ++++++--- .../20190414125034_migrate_old_bookmarks.exs | 1 - .../20190711042021_create_safe_jsonb_set.exs | 1 - .../controllers/notification_controller_test.exs | 20 +++++++++++ .../controllers/status_controller_test.exs | 6 ++-- .../controllers/timeline_controller_test.exs | 29 ++++++++++++++-- 25 files changed, 278 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350e03894..a391bf1fa 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. +- Configuration: `:extensions/:output_relationships_in_statuses_by_default` option (if `false`, disables the output of account/pleroma/relationship for statuses and notifications by default, breaking the compatibility with older PleromaFE versions).
    API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. @@ -20,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [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. +- Mastodon API: Fix being able to request enormous amount of statuses in timelines leading to DoS. Now limited to 40 per request. ### Removed - **Breaking**: Removed 1.0+ deprecated configurations `Pleroma.Upload, :strip_exif` and `:instance, :dedupe_media` diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index bd65ac84f..786929ace 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -386,47 +386,56 @@ defp render_timelines(user) do favourites = ActivityPub.fetch_favourites(user) + output_relationships = + !!Pleroma.Config.get([:extensions, :output_relationships_in_statuses_by_default]) + Benchee.run( %{ "Rendering home timeline" => fn -> StatusView.render("index.json", %{ activities: home_activities, for: user, - as: :activity + as: :activity, + skip_relationships: !output_relationships }) end, "Rendering direct timeline" => fn -> StatusView.render("index.json", %{ activities: direct_activities, for: user, - as: :activity + as: :activity, + skip_relationships: !output_relationships }) end, "Rendering public timeline" => fn -> StatusView.render("index.json", %{ activities: public_activities, for: user, - as: :activity + as: :activity, + skip_relationships: !output_relationships }) end, "Rendering tag timeline" => fn -> StatusView.render("index.json", %{ activities: tag_activities, for: user, - as: :activity + as: :activity, + skip_relationships: !output_relationships }) end, "Rendering notifications" => fn -> Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ notifications: notifications, - for: user + for: user, + skip_relationships: !output_relationships }) end, "Rendering favourites timeline" => fn -> StatusView.render("index.json", %{ activities: favourites, for: user, - as: :activity + as: :activity, + skip_relationships: !output_relationships }) end }, diff --git a/config/config.exs b/config/config.exs index 2ab939107..73bf658fe 100644 --- a/config/config.exs +++ b/config/config.exs @@ -262,6 +262,8 @@ extended_nickname_format: true, cleanup_attachments: false +config :pleroma, :extensions, output_relationships_in_statuses_by_default: true + config :pleroma, :feed, post_title: %{ max_length: 100, diff --git a/config/description.exs b/config/description.exs index 9612adba7..d127f8f20 100644 --- a/config/description.exs +++ b/config/description.exs @@ -121,6 +121,22 @@ } ] }, + %{ + group: :pleroma, + key: :extensions, + type: :group, + description: "Pleroma-specific extensions", + children: [ + %{ + key: :output_relationships_in_statuses_by_default, + type: :beeolean, + description: + "If `true`, outputs account/pleroma/relationship map for each rendered status / notification (for all clients). " <> + "If `false`, outputs the above only if `with_relationships` param is tru-ish " <> + "(that breaks compatibility with older PleromaFE versions which do not send this param but expect the output)." + } + ] + }, %{ group: :pleroma, key: Pleroma.Uploaders.Local, diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/lib/mix/tasks/pleroma/benchmark.ex index a4885b70c..b2bbe40ac 100644 --- a/lib/mix/tasks/pleroma/benchmark.ex +++ b/lib/mix/tasks/pleroma/benchmark.ex @@ -67,7 +67,8 @@ def run(["render_timeline", nickname | _] = args) do Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ activities: activities, for: user, - as: :activity + as: :activity, + skip_relationships: true }) end }, diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 18a5eec72..d42dc250e 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -129,17 +129,27 @@ def exists?(dictionary, rel_type, source, target, func) do end @doc ":relationships option for StatusView / AccountView / NotificationView" - def view_relationships_option(nil = _reading_user, _actors) do + def view_relationships_option(reading_user, actors, opts \\ []) + + def view_relationships_option(nil = _reading_user, _actors, _opts) do %{user_relationships: [], following_relationships: []} end - def view_relationships_option(%User{} = reading_user, actors) do + def view_relationships_option(%User{} = reading_user, actors, opts) do + {source_to_target_rel_types, target_to_source_rel_types} = + if opts[:source_mutes_only] do + # This option is used for rendering statuses (FE needs `muted` flag for each one anyways) + {[:mute], []} + else + {[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]} + end + user_relationships = UserRelationship.dictionary( [reading_user], actors, - [:block, :mute, :notification_mute, :reblog_mute], - [:block, :inverse_subscription] + source_to_target_rel_types, + target_to_source_rel_types ) following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index ca5439920..747d97f80 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -258,7 +258,7 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do conn |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity}) + |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) end def list_user_statuses(conn, %{"nickname" => nickname} = params) do @@ -277,7 +277,7 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do conn |> put_view(StatusView) - |> render("index.json", %{activities: activities, as: :activity}) + |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) else _ -> {:error, :not_found} end @@ -801,7 +801,7 @@ def list_statuses(%{assigns: %{user: _admin}} = conn, params) do conn |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity}) + |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) end def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index ca0bcebc7..d50969b2a 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -38,7 +38,12 @@ def render("show.json", %{report: report, user: user, account: account, statuses actor: merge_account_views(user), content: content, created_at: created_at, - statuses: StatusView.render("index.json", %{activities: statuses, as: :activity}), + statuses: + StatusView.render("index.json", %{ + activities: statuses, + as: :activity, + skip_relationships: false + }), state: report.data["state"], notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}) } diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index c4356f93b..c1cd15bb2 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -187,7 +187,7 @@ defp object(draft) do end defp preview?(draft) do - preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) || false + preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) %__MODULE__{draft | preview?: preview?} end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index b49523ec3..4780081b2 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -5,10 +5,18 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller - # As in MastoAPI, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html + alias Pleroma.Config + + # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] - def truthy_param?(blank_value) when blank_value in [nil, ""], do: nil - def truthy_param?(value), do: value not in @falsy_param_values + + def explicitly_falsy_param?(value), do: value in @falsy_param_values + + # Note: `nil` and `""` are considered falsy values in Pleroma + def falsy_param?(value), + do: explicitly_falsy_param?(value) or value in [nil, ""] + + def truthy_param?(value), do: not falsy_param?(value) def json_response(conn, status, json) do conn @@ -96,4 +104,14 @@ def try_render(conn, _, _) do def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) + + @doc "Whether to skip rendering `[:account][:pleroma][:relationship]`for statuses/notifications" + def skip_relationships?(params) do + if Config.get([:extensions, :output_relationships_in_statuses_by_default]) do + false + else + # BREAKING: older PleromaFE versions do not send this param but _do_ expect relationships. + not truthy_param?(params["with_relationships"]) + end + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 21bc3d5a5..7da1a11f6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -6,7 +6,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] + only: [ + add_link_headers: 2, + truthy_param?: 1, + assign_account_by_id: 2, + json_response: 3, + skip_relationships?: 1 + ] alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter @@ -237,7 +243,12 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do conn |> add_link_headers(activities) |> put_view(StatusView) - |> render("index.json", activities: activities, for: reading_user, as: :activity) + |> render("index.json", + activities: activities, + for: reading_user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) else _e -> render_error(conn, :not_found, "Can't find user") end diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 0c9218454..c7e808253 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, skip_relationships?: 1] alias Pleroma.Notification alias Pleroma.Plugs.OAuthScopesPlug @@ -45,7 +45,11 @@ def index(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(notifications) - |> render("index.json", notifications: notifications, for: user) + |> render("index.json", + notifications: notifications, + for: user, + skip_relationships: skip_relationships?(params) + ) end # GET /api/v1/notifications/:id diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index fcab4ef63..c258742dd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -5,13 +5,14 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do use Pleroma.Web, :controller + import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 2, skip_relationships?: 1] + alias Pleroma.Activity alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.ControllerHelper alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView @@ -66,10 +67,11 @@ defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = para defp search_options(params, user) do [ + skip_relationships: skip_relationships?(params), resolve: params["resolve"] == "true", following: params["following"] == "true", - limit: ControllerHelper.fetch_integer_param(params, "limit"), - offset: ControllerHelper.fetch_integer_param(params, "offset"), + limit: fetch_integer_param(params, "limit"), + offset: fetch_integer_param(params, "offset"), type: params["type"], author: get_author(params), for_user: user @@ -79,12 +81,24 @@ defp search_options(params, user) do defp resource_search(_, "accounts", query, options) do accounts = with_fallback(fn -> User.search(query, options) end) - AccountView.render("index.json", users: accounts, for: options[:for_user], as: :user) + + AccountView.render("index.json", + users: accounts, + for: options[:for_user], + as: :user, + skip_relationships: false + ) end defp resource_search(_, "statuses", query, options) do statuses = with_fallback(fn -> Activity.search(options[:for_user], query, options) end) - StatusView.render("index.json", activities: statuses, for: options[:for_user], as: :activity) + + StatusView.render("index.json", + activities: statuses, + for: options[:for_user], + as: :activity, + skip_relationships: options[:skip_relationships] + ) end defp resource_search(:v2, "hashtags", query, _options) do diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 37afe6949..eb3d90aeb 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -5,7 +5,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [try_render: 3, add_link_headers: 2] + import Pleroma.Web.ControllerHelper, + only: [try_render: 3, add_link_headers: 2, skip_relationships?: 1] require Ecto.Query @@ -101,7 +102,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do `ids` query param is required """ - def index(%{assigns: %{user: user}} = conn, %{"ids" => ids}) do + def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = params) do limit = 100 activities = @@ -110,7 +111,12 @@ def index(%{assigns: %{user: user}} = conn, %{"ids" => ids}) do |> Activity.all_by_ids_with_object() |> Enum.filter(&Visibility.visible_for_user?(&1, user)) - render(conn, "index.json", activities: activities, for: user, as: :activity) + render(conn, "index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end @doc """ @@ -360,7 +366,12 @@ def favourites(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(activities) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end @doc "GET /api/v1/bookmarks" @@ -378,6 +389,11 @@ def bookmarks(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(bookmarks) - |> render("index.json", %{activities: activities, for: user, as: :activity}) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 91f41416d..b3c58005e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] + only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1, skip_relationships?: 1] alias Pleroma.Pagination alias Pleroma.Plugs.OAuthScopesPlug @@ -14,9 +14,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - # TODO: Replace with a macro when there is a Phoenix release with + # TODO: Replace with a macro when there is a Phoenix release with the following commit in it: # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e - # in it plug(RateLimiter, [name: :timeline, bucket_name: :direct_timeline] when action == :direct) plug(RateLimiter, [name: :timeline, bucket_name: :public_timeline] when action == :public) @@ -49,7 +48,12 @@ def home(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(activities) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end # GET /api/v1/timelines/direct @@ -68,7 +72,12 @@ def direct(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(activities) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end # GET /api/v1/timelines/public @@ -95,7 +104,12 @@ def public(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) else render_error(conn, :unauthorized, "authorization required for timeline view") end @@ -140,7 +154,12 @@ def hashtag(%{assigns: %{user: user}} = conn, params) do conn |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", activities: activities, for: user, as: :activity) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end # GET /api/v1/timelines/list/:list_id @@ -164,7 +183,12 @@ def list(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do |> ActivityPub.fetch_activities_bounded(following, params) |> Enum.reverse() - render(conn, "index.json", activities: activities, for: user, as: :activity) + render(conn, "index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) else _e -> render_error(conn, :forbidden, "Error.") end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index c482bba64..b20a00a89 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MediaProxy def render("index.json", %{users: users} = opts) do + # Note: :skip_relationships option is currently intentionally not supported for accounts relationships_opt = cond do Map.has_key?(opts, :relationships) -> @@ -190,11 +191,15 @@ defp do_render("show.json", %{user: user} = opts) do end) relationship = - render("relationship.json", %{ - user: opts[:for], - target: user, - relationships: opts[:relationships] - }) + if opts[:skip_relationships] do + %{} + else + render("relationship.json", %{ + user: opts[:for], + target: user, + relationships: opts[:relationships] + }) + end %{ id: to_string(user.id), diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 89f5734ff..78d187f9a 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -51,14 +51,15 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op |> Enum.filter(& &1) |> Kernel.++(move_activities_targets) - UserRelationship.view_relationships_option(reading_user, actors) + UserRelationship.view_relationships_option(reading_user, actors, + source_mutes_only: opts[:skip_relationships] + ) end - opts = %{ - for: reading_user, - parent_activities: parent_activities, - relationships: relationships_opt - } + opts = + opts + |> Map.put(:parent_activities, parent_activities) + |> Map.put(:relationships, relationships_opt) safe_render_many(notifications, NotificationView, "show.json", opts) end @@ -82,12 +83,16 @@ def render( mastodon_type = Activity.mastodon_notification_type(activity) + render_opts = %{ + relationships: opts[:relationships], + skip_relationships: opts[:skip_relationships] + } + with %{id: _} = account <- - AccountView.render("show.json", %{ - user: actor, - for: reading_user, - relationships: opts[:relationships] - }) do + AccountView.render( + "show.json", + Map.merge(render_opts, %{user: actor, for: reading_user}) + ) do response = %{ id: to_string(notification.id), type: mastodon_type, @@ -98,8 +103,6 @@ def render( } } - render_opts = %{relationships: opts[:relationships]} - case mastodon_type do "mention" -> put_status(response, activity, reading_user, render_opts) @@ -111,6 +114,7 @@ def render( put_status(response, parent_activity_fn.(), reading_user, render_opts) "move" -> + # Note: :skip_relationships option being applied to _account_ rendering (here) put_target(response, activity, reading_user, render_opts) "follow" -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 82326986c..9cbd31878 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -97,7 +97,9 @@ def render("index.json", opts) do true -> actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) - UserRelationship.view_relationships_option(opts[:for], actors) + UserRelationship.view_relationships_option(opts[:for], actors, + source_mutes_only: opts[:skip_relationships] + ) end opts = @@ -151,7 +153,8 @@ def render( AccountView.render("show.json", %{ user: user, for: opts[:for], - relationships: opts[:relationships] + relationships: opts[:relationships], + skip_relationships: opts[:skip_relationships] }), in_reply_to_id: nil, in_reply_to_account_id: nil, @@ -299,6 +302,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} _ -> [] end + # Status muted state (would do 1 request per status unless user mutes are preloaded) muted = thread_muted? || UserRelationship.exists?( @@ -317,7 +321,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} AccountView.render("show.json", %{ user: user, for: opts[:for], - relationships: opts[:relationships] + relationships: opts[:relationships], + skip_relationships: opts[:skip_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/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index dcba67d03..9d0b3b1e4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] + only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2, skip_relationships?: 1] alias Ecto.Changeset alias Pleroma.Plugs.OAuthScopesPlug @@ -139,7 +139,12 @@ def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do conn |> add_link_headers(activities) |> put_view(StatusView) - |> render("index.json", activities: activities, for: for_user, as: :activity) + |> render("index.json", + activities: activities, + for: for_user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) end @doc "POST /api/v1/pleroma/accounts/:id/subscribe" diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index dae7f0f2f..83983b576 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, skip_relationships?: 1] alias Pleroma.Activity alias Pleroma.Conversation.Participation @@ -130,7 +130,12 @@ def conversation_statuses( conn |> add_link_headers(activities) |> put_view(StatusView) - |> render("index.json", %{activities: activities, for: user, as: :activity}) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + skip_relationships: skip_relationships?(params) + ) else _error -> conn @@ -184,13 +189,17 @@ def read_notification(%{assigns: %{user: user}} = conn, %{"id" => notification_i end end - def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do + def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id} = params) do with notifications <- Notification.set_read_up_to(user, max_id) do notifications = Enum.take(notifications, 80) conn |> put_view(NotificationView) - |> render("index.json", %{notifications: notifications, for: user}) + |> render("index.json", + notifications: notifications, + for: user, + skip_relationships: skip_relationships?(params) + ) end end end diff --git a/priv/repo/migrations/20190414125034_migrate_old_bookmarks.exs b/priv/repo/migrations/20190414125034_migrate_old_bookmarks.exs index c618ea381..b6f0ac66b 100644 --- a/priv/repo/migrations/20190414125034_migrate_old_bookmarks.exs +++ b/priv/repo/migrations/20190414125034_migrate_old_bookmarks.exs @@ -3,7 +3,6 @@ defmodule Pleroma.Repo.Migrations.MigrateOldBookmarks do import Ecto.Query alias Pleroma.Activity alias Pleroma.Bookmark - alias Pleroma.User alias Pleroma.Repo def up do diff --git a/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs b/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs index 2f336a5e8..43d616705 100644 --- a/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs +++ b/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs @@ -1,6 +1,5 @@ defmodule Pleroma.Repo.Migrations.CreateSafeJsonbSet do use Ecto.Migration - alias Pleroma.User def change do execute(""" diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index 7a0011646..42a311f99 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -12,6 +12,26 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do import Pleroma.Factory + test "does NOT render account/pleroma/relationship if this is disabled by default" do + clear_config([:extensions, :output_relationships_in_statuses_by_default], false) + + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, [_notification]} = Notification.create_notifications(activity) + + response = + conn + |> assign(:user, user) + |> get("/api/v1/notifications") + |> json_response(200) + + assert Enum.all?(response, fn n -> + get_in(n, ["account", "pleroma", "relationship"]) == %{} + end) + end + test "list of notifications" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index d59974d50..6b126217a 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1043,6 +1043,8 @@ test "replaces missing description with an empty string", %{conn: conn, user: us end test "bookmarks" do + bookmarks_uri = "/api/v1/bookmarks?with_relationships=true" + %{conn: conn} = oauth_access(["write:bookmarks", "read:bookmarks"]) author = insert(:user) @@ -1064,7 +1066,7 @@ test "bookmarks" do assert json_response(response2, 200)["bookmarked"] == true - bookmarks = get(conn, "/api/v1/bookmarks") + bookmarks = get(conn, bookmarks_uri) assert [json_response(response2, 200), json_response(response1, 200)] == json_response(bookmarks, 200) @@ -1073,7 +1075,7 @@ test "bookmarks" do assert json_response(response1, 200)["bookmarked"] == false - bookmarks = get(conn, "/api/v1/bookmarks") + bookmarks = get(conn, bookmarks_uri) assert [json_response(response2, 200)] == json_response(bookmarks, 200) end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 97b1c3e66..06efdc901 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -20,7 +20,30 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do describe "home" do setup do: oauth_access(["read:statuses"]) + test "does NOT render account/pleroma/relationship if this is disabled by default", %{ + user: user, + conn: conn + } do + clear_config([:extensions, :output_relationships_in_statuses_by_default], false) + + other_user = insert(:user) + + {:ok, _} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + + response = + conn + |> assign(:user, user) + |> get("/api/v1/timelines/home") + |> json_response(200) + + assert Enum.all?(response, fn n -> + get_in(n, ["account", "pleroma", "relationship"]) == %{} + end) + end + test "the home timeline", %{user: user, conn: conn} do + uri = "/api/v1/timelines/home?with_relationships=true" + following = insert(:user, nickname: "followed") third_user = insert(:user, nickname: "repeated") @@ -28,13 +51,13 @@ test "the home timeline", %{user: user, conn: conn} do {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"}) {:ok, _, _} = CommonAPI.repeat(activity.id, following) - ret_conn = get(conn, "/api/v1/timelines/home") + ret_conn = get(conn, uri) assert Enum.empty?(json_response(ret_conn, :ok)) {:ok, _user} = User.follow(user, following) - ret_conn = get(conn, "/api/v1/timelines/home") + ret_conn = get(conn, uri) assert [ %{ @@ -59,7 +82,7 @@ test "the home timeline", %{user: user, conn: conn} do {:ok, _user} = User.follow(third_user, user) - ret_conn = get(conn, "/api/v1/timelines/home") + ret_conn = get(conn, uri) assert [ %{ -- cgit v1.2.3 From 2d64500a9dee8bc53c988719bde1c1f4f41575b7 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 1 Apr 2020 20:26:33 +0300 Subject: error improvement for email_invite endpoint --- docs/API/admin_api.md | 13 ++++++++ lib/pleroma/web/admin_api/admin_api_controller.ex | 17 ++++++++-- test/web/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 -- cgit v1.2.3 From 23219e6fb3163bfac07fb5fb1b2602dcd27e47c2 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 1 Apr 2020 23:00:59 +0400 Subject: Add OpenAPI --- lib/pleroma/web/api_spec.ex | 30 +++++++ .../web/api_spec/operations/app_operation.ex | 94 ++++++++++++++++++++++ .../web/api_spec/schemas/app_create_request.ex | 33 ++++++++ .../web/api_spec/schemas/app_create_response.ex | 33 ++++++++ .../web/mastodon_api/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, -- cgit v1.2.3 From 591f7015d91b383dae1ee29576d13c0fad65cad6 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 2 Apr 2020 09:34:11 +0300 Subject: update Oban package --- mix.exs | 2 +- mix.lock | 6 +++--- .../repo/migrations/20200402063221_update_oban_jobs_table.exs | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 priv/repo/migrations/20200402063221_update_oban_jobs_table.exs diff --git a/mix.exs b/mix.exs index 87c025d89..375bc67c1 100644 --- a/mix.exs +++ b/mix.exs @@ -108,7 +108,7 @@ defp deps do {:ecto_enum, "~> 1.4"}, {:ecto_sql, "~> 3.3.2"}, {:postgrex, ">= 0.13.5"}, - {:oban, "~> 0.12.1"}, + {:oban, "~> 1.2"}, {:gettext, "~> 0.15"}, {:comeonin, "~> 4.1.1"}, {:pbkdf2_elixir, "~> 0.12.3"}, diff --git a/mix.lock b/mix.lock index 6cca578d6..50be45a4d 100644 --- a/mix.lock +++ b/mix.lock @@ -26,7 +26,7 @@ "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.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": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, @@ -55,7 +55,7 @@ "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"}, + "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, @@ -73,7 +73,7 @@ "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, - "oban": {:hex, :oban, "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"}, + "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "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/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs b/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs new file mode 100644 index 000000000..c8ee12192 --- /dev/null +++ b/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.UpdateObanJobsTable do + use Ecto.Migration + + def up do + Oban.Migrations.up() + end + + def down do + Oban.Migrations.down(version: 1) + end +end -- cgit v1.2.3 From fd97b0e634d30dec3217efcf3d67610d1b54bf8b Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 9 Mar 2020 17:00:16 +0100 Subject: Chats: Basic implementation. --- lib/pleroma/chat.ex | 41 ++++++++++++++++++++ .../migrations/20200309123730_create_chats.exs | 16 ++++++++ test/chat_test.exs | 44 ++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 lib/pleroma/chat.ex create mode 100644 priv/repo/migrations/20200309123730_create_chats.exs create mode 100644 test/chat_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex new file mode 100644 index 000000000..e2a8b8eba --- /dev/null +++ b/lib/pleroma/chat.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat do + use Ecto.Schema + import Ecto.Changeset + + alias Pleroma.User + alias Pleroma.Repo + + @moduledoc """ + Chat keeps a reference to DirectMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). + + It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. + """ + + schema "chats" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + field(:recipient, :string) + field(:unread, :integer, default: 0) + + timestamps() + end + + def creation_cng(struct, params) do + struct + |> cast(params, [:user_id, :recipient]) + |> validate_required([:user_id, :recipient]) + |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) + end + + def get_or_create(user_id, recipient) do + %__MODULE__{} + |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> Repo.insert( + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + conflict_target: [:user_id, :recipient] + ) + end +end diff --git a/priv/repo/migrations/20200309123730_create_chats.exs b/priv/repo/migrations/20200309123730_create_chats.exs new file mode 100644 index 000000000..715d798ea --- /dev/null +++ b/priv/repo/migrations/20200309123730_create_chats.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.CreateChats do + use Ecto.Migration + + def change do + create table(:chats) do + add(:user_id, references(:users, type: :uuid)) + # Recipient is an ActivityPub id, to future-proof for group support. + add(:recipient, :string) + add(:unread, :integer, default: 0) + timestamps() + end + + # There's only one chat between a user and a recipient. + create(index(:chats, [:user_id, :recipient], unique: true)) + end +end diff --git a/test/chat_test.exs b/test/chat_test.exs new file mode 100644 index 000000000..ca9206802 --- /dev/null +++ b/test/chat_test.exs @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ChatTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Chat + + import Pleroma.Factory + + describe "creation and getting" do + test "it creates a chat for a user and recipient" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.id + end + + test "it returns a chat for a user and recipient if it already exists" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.id == chat_two.id + end + + test "a returning chat will have an updated `update_at` field" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + :timer.sleep(1500) + {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.id == chat_two.id + assert chat.updated_at != chat_two.updated_at + end + end +end -- cgit v1.2.3 From 0aa24a150bbb153f55ca92dfb595385b4fe3839c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 2 Apr 2020 17:33:23 +0400 Subject: Add oAuth --- lib/pleroma/web/api_spec.ex | 16 +++++++++++++++- lib/pleroma/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 => -- cgit v1.2.3 From aa78325117c879ecb7ec76383c239078275adbd9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 2 Apr 2020 19:23:30 +0300 Subject: [#2323] Fixed a typo causing /accounts/relationships to render default relationships. Improved the tests. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 8 +++++--- lib/pleroma/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 %{ -- cgit v1.2.3 From 8a0ffaa9ead2574707cb45c014cb421ff31f7a03 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 2 Apr 2020 23:01:29 +0400 Subject: 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. -- cgit v1.2.3 From fc81e5a49c34224e07e85f490a30f92db0835d45 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 6 Apr 2020 10:20:44 +0300 Subject: Enforcement of OAuth scopes check for authenticated API endpoints, :skip_plug plug to mark a plug explicitly skipped (disabled). --- lib/pleroma/plugs/auth_expected_plug.ex | 13 ++++++++ lib/pleroma/plugs/oauth_scopes_plug.ex | 3 ++ lib/pleroma/plugs/plug_helper.ex | 38 ++++++++++++++++++++++ lib/pleroma/web/masto_fe_controller.ex | 2 +- .../mastodon_api/controllers/account_controller.ex | 9 +++-- .../controllers/mastodon_api_controller.ex | 18 +++++++--- .../controllers/suggestion_controller.ex | 9 +++-- lib/pleroma/web/oauth/oauth_controller.ex | 2 ++ .../controllers/pleroma_api_controller.ex | 2 +- lib/pleroma/web/router.ex | 3 +- .../web/twitter_api/twitter_api_controller.ex | 2 ++ lib/pleroma/web/web.ex | 23 +++++++++++++ .../controllers/suggestion_controller_test.exs | 26 --------------- .../controllers/pleroma_api_controller_test.exs | 2 +- 14 files changed, 113 insertions(+), 39 deletions(-) create mode 100644 lib/pleroma/plugs/auth_expected_plug.ex create mode 100644 lib/pleroma/plugs/plug_helper.ex diff --git a/lib/pleroma/plugs/auth_expected_plug.ex b/lib/pleroma/plugs/auth_expected_plug.ex new file mode 100644 index 000000000..9e4a4bec8 --- /dev/null +++ b/lib/pleroma/plugs/auth_expected_plug.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.AuthExpectedPlug do + import Plug.Conn + + def init(options), do: options + + def call(conn, _) do + put_private(conn, :auth_expected, true) + end +end diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex index 38df074ad..b09e1bb4d 100644 --- a/lib/pleroma/plugs/oauth_scopes_plug.ex +++ b/lib/pleroma/plugs/oauth_scopes_plug.ex @@ -8,12 +8,15 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do alias Pleroma.Config alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Plugs.PlugHelper @behaviour Plug def init(%{scopes: _} = options), do: options def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do + conn = PlugHelper.append_to_called_plugs(conn, __MODULE__) + op = options[:op] || :| token = assigns[:token] diff --git a/lib/pleroma/plugs/plug_helper.ex b/lib/pleroma/plugs/plug_helper.ex new file mode 100644 index 000000000..4f83e9414 --- /dev/null +++ b/lib/pleroma/plugs/plug_helper.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.PlugHelper do + @moduledoc "Pleroma Plug helper" + + def append_to_called_plugs(conn, plug_module) do + append_to_private_list(conn, :called_plugs, plug_module) + end + + def append_to_skipped_plugs(conn, plug_module) do + append_to_private_list(conn, :skipped_plugs, plug_module) + end + + def plug_called?(conn, plug_module) do + contained_in_private_list?(conn, :called_plugs, plug_module) + end + + def plug_skipped?(conn, plug_module) do + contained_in_private_list?(conn, :skipped_plugs, plug_module) + end + + def plug_called_or_skipped?(conn, plug_module) do + plug_called?(conn, plug_module) || plug_skipped?(conn, plug_module) + end + + defp append_to_private_list(conn, private_variable, value) do + list = conn.private[private_variable] || [] + modified_list = Enum.uniq(list ++ [value]) + Plug.Conn.put_private(conn, private_variable, modified_list) + end + + defp contained_in_private_list?(conn, private_variable, value) do + list = conn.private[private_variable] || [] + value in list + end +end diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 43649ad26..557cde328 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastoFEController do when action == :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :index) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :manifest]) @doc "GET /web/*path" def index(%{assigns: %{user: user, token: token}} = conn, _params) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 21bc3d5a5..bd6853d12 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -15,10 +15,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI + alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI + plug(:skip_plug, OAuthScopesPlug when action == :identity_proofs) + plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} @@ -369,6 +372,8 @@ def blocks(%{assigns: %{user: user}} = conn, _) do end @doc "GET /api/v1/endorsements" - def endorsements(conn, params), - do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) + def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params) + + @doc "GET /api/v1/identity_proofs" + def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params) end diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index 14075307d..ac8c18f24 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -3,21 +3,31 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do + @moduledoc """ + Contains stubs for unimplemented Mastodon API endpoints. + + Note: instead of routing directly to this controller's action, + it's preferable to define an action in relevant (non-generic) controller, + set up OAuth rules for it and call this controller's function from it. + """ + use Pleroma.Web, :controller require Logger + plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug when action in [:empty_array, :empty_object]) + + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - # Stubs for unimplemented mastodon api - # def empty_array(conn, _) do - Logger.debug("Unimplemented, returning an empty array") + Logger.debug("Unimplemented, returning an empty array (list)") json(conn, []) end def empty_object(conn, _) do - Logger.debug("Unimplemented, returning an empty object") + Logger.debug("Unimplemented, returning an empty object (map)") json(conn, %{}) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex index 0cdc7bd8d..c93a43969 100644 --- a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -5,10 +5,13 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionController do use Pleroma.Web, :controller + alias Pleroma.Plugs.OAuthScopesPlug + require Logger + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index) + @doc "GET /api/v1/suggestions" - def index(conn, _) do - json(conn, []) - end + def index(conn, params), + do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params) end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 46688db7e..0121cd661 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -27,6 +27,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do plug(:fetch_flash) plug(RateLimiter, [name: :authentication] when action == :create_authorization) + plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug) + action_fallback(Pleroma.Web.OAuth.FallbackController) @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob" diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index dae7f0f2f..75f61b675 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do plug( OAuthScopesPlug, - %{scopes: ["write:conversations"]} when action == :update_conversation + %{scopes: ["write:conversations"]} when action in [:update_conversation, :read_conversations] ) plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5a0902739..3d57073d0 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -34,6 +34,7 @@ defmodule Pleroma.Web.Router do pipeline :authenticated_api do plug(:accepts, ["json"]) plug(:fetch_session) + plug(Pleroma.Plugs.AuthExpectedPlug) plug(Pleroma.Plugs.OAuthPlug) plug(Pleroma.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Plugs.UserFetcherPlug) @@ -333,7 +334,7 @@ defmodule Pleroma.Web.Router do get("/accounts/relationships", AccountController, :relationships) get("/accounts/:id/lists", AccountController, :lists) - get("/accounts/:id/identity_proofs", MastodonAPIController, :empty_array) + get("/accounts/:id/identity_proofs", AccountController, :identity_proofs) get("/follow_requests", FollowRequestController, :index) get("/blocks", AccountController, :blocks) diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 0229aea97..31adc2817 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -15,6 +15,8 @@ defmodule Pleroma.Web.TwitterAPI.Controller do plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) + plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) + plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) action_fallback(:errors) diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index cf3ac1287..1af29ce78 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -29,11 +29,34 @@ def controller do import Pleroma.Web.Router.Helpers import Pleroma.Web.TranslationHelpers + alias Pleroma.Plugs.PlugHelper + plug(:set_put_layout) defp set_put_layout(conn, _) do put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) end + + # Marks a plug as intentionally skipped + # (states that the plug is not called for a good reason, not by a mistake) + defp skip_plug(conn, plug_module) do + PlugHelper.append_to_skipped_plugs(conn, plug_module) + end + + # Here we can apply before-action hooks (e.g. verify whether auth checks were preformed) + defp action(conn, params) do + if conn.private[:auth_expected] && + not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do + conn + |> render_error( + :forbidden, + "Security violation: OAuth scopes check was neither handled nor explicitly skipped." + ) + |> halt() + else + super(conn, params) + end + end end end diff --git a/test/web/mastodon_api/controllers/suggestion_controller_test.exs b/test/web/mastodon_api/controllers/suggestion_controller_test.exs index c697a39f8..8d0e70db8 100644 --- a/test/web/mastodon_api/controllers/suggestion_controller_test.exs +++ b/test/web/mastodon_api/controllers/suggestion_controller_test.exs @@ -7,34 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do alias Pleroma.Config - import Pleroma.Factory - import Tesla.Mock - setup do: oauth_access(["read"]) - setup %{user: user} do - other_user = insert(:user) - host = Config.get([Pleroma.Web.Endpoint, :url, :host]) - url500 = "http://test500?#{host}&#{user.nickname}" - url200 = "http://test200?#{host}&#{user.nickname}" - - mock(fn - %{method: :get, url: ^url500} -> - %Tesla.Env{status: 500, body: "bad request"} - - %{method: :get, url: ^url200} -> - %Tesla.Env{ - status: 200, - body: - ~s([{"acct":"yj455","avatar":"https://social.heldscal.la/avatar/201.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/201.jpeg"}, {"acct":"#{ - other_user.ap_id - }","avatar":"https://social.heldscal.la/avatar/202.jpeg","avatar_static":"https://social.heldscal.la/avatar/s/202.jpeg"}]) - } - end) - - [other_user: other_user] - end - test "returns empty result", %{conn: conn} do res = conn diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index 32250f06f..8f0cbe9b2 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -203,7 +203,7 @@ test "PATCH /api/v1/pleroma/conversations/:id" do test "POST /api/v1/pleroma/conversations/read" do user = insert(:user) - %{user: other_user, conn: conn} = oauth_access(["write:notifications"]) + %{user: other_user, conn: conn} = oauth_access(["write:conversations"]) {:ok, _activity} = CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"}) -- cgit v1.2.3 From b59ac37b2c09d5dc80b59bd3a2aea36989bee713 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Apr 2020 10:45:25 +0300 Subject: tests for emoji mix task --- coveralls.json | 6 + docs/administration/CLI_tasks/emoji.md | 4 +- lib/mix/tasks/pleroma/emoji.ex | 80 +++++---- test/fixtures/emoji/packs/blank.png.zip | Bin 0 -> 284 bytes test/fixtures/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, 306 insertions(+), 33 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 + {opts, [src], []} = + OptionParser.parse( + args, + strict: [ + name: :string, + license: :string, + homepage: :string, + description: :string, + files: :string, + extensions: :string + ] + ) - license = String.trim(IO.gets("License: ")) - homepage = String.trim(IO.gets("Homepage: ")) - description = String.trim(IO.gets("Description: ")) + 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}.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 + proposed_files_name = "#{name}_files.json" + files_name = get_option(opts, :files, "Save file list to:", 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}]: ") + 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 000000000..651daf127 Binary files /dev/null and b/test/fixtures/emoji/packs/blank.png.zip differ 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 -- cgit v1.2.3 From a43e05591639132ce121d2e14258944a53004438 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Apr 2020 14:27:20 +0300 Subject: 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 -- cgit v1.2.3 From e67cde0ed6b55450b5f309f9ed86f7f8e2a1e73f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Apr 2020 13:46:34 +0200 Subject: 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 -- cgit v1.2.3 From 772bc258cde11b3203ad9420f69321ccd56db91a Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Apr 2020 13:53:24 +0200 Subject: ObjectID Validator: Refactor. --- .../activity_pub/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 -- cgit v1.2.3 From 03eebabe8e5b2e3f96f6ffe51a6f063a42f6a5d2 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 3 Apr 2020 22:52:25 +0400 Subject: 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 => -- cgit v1.2.3 From 06471940e0cb917bb362cbcb9d872ab1336a04cf Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 7 Apr 2020 08:44:53 +0000 Subject: 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", -- cgit v1.2.3 From 1a4875adfa8fa8f65f1db7b4ec3cf868b7e3dee7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 7 Apr 2020 21:52:32 +0300 Subject: [#1559] Support for "follow_request" notifications (configurable). (Not currently supported by PleromaFE, thus disabled by default). --- CHANGELOG.md | 1 + config/config.exs | 2 + config/description.exs | 14 ++++ lib/pleroma/activity.ex | 36 ++++++++-- lib/pleroma/notification.ex | 11 +++- lib/pleroma/user.ex | 2 + .../web/mastodon_api/views/notification_view.ex | 3 + lib/pleroma/web/push/impl.ex | 76 ++++++++++++++-------- lib/pleroma/web/push/subscription.ex | 8 +++ 9 files changed, 117 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e5d807c..b3b63ac54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. +- Notifications: Added `follow_request` notification type (configurable, see `[:notifications, :enable_follow_request_notifications]` setting).
    API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. diff --git a/config/config.exs b/config/config.exs index 232a91bf1..d40c2240b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -559,6 +559,8 @@ inactivity_threshold: 7 } +config :pleroma, :notifications, enable_follow_request_notifications: false + config :pleroma, :oauth2, token_expires_in: 600, issue_new_refresh_token: true, diff --git a/config/description.exs b/config/description.exs index 642f1a3ce..b1938912c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2267,6 +2267,20 @@ } ] }, + %{ + group: :pleroma, + key: :notifications, + type: :group, + description: "Notification settings", + children: [ + %{ + key: :enable_follow_request_notifications, + type: :boolean, + description: + "Enables notifications on new follow requests (causes issues with older PleromaFE versions)." + } + ] + }, %{ group: :pleroma, key: Pleroma.Emails.UserEmail, diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 5a8329e69..3803d8e50 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -27,17 +27,13 @@ defmodule Pleroma.Activity do # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 @mastodon_notification_types %{ "Create" => "mention", - "Follow" => "follow", + "Follow" => ["follow", "follow_request"], "Announce" => "reblog", "Like" => "favourite", "Move" => "move", "EmojiReact" => "pleroma:emoji_reaction" } - @mastodon_to_ap_notification_types for {k, v} <- @mastodon_notification_types, - into: %{}, - do: {v, k} - schema "activities" do field(:data, :map) field(:local, :boolean, default: true) @@ -291,15 +287,41 @@ defp purge_web_resp_cache(%Activity{} = activity) do defp purge_web_resp_cache(nil), do: nil - for {ap_type, type} <- @mastodon_notification_types do + def follow_accepted?( + %Activity{data: %{"type" => "Follow", "object" => followed_ap_id}} = activity + ) do + with %User{} = follower <- Activity.user_actor(activity), + %User{} = followed <- User.get_cached_by_ap_id(followed_ap_id) do + Pleroma.FollowingRelationship.following?(follower, followed) + else + _ -> false + end + end + + def follow_accepted?(_), do: false + + for {ap_type, type} <- @mastodon_notification_types, not is_list(type) do def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), do: unquote(type) end + def mastodon_notification_type(%Activity{data: %{"type" => "Follow"}} = activity) do + if follow_accepted?(activity) do + "follow" + else + "follow_request" + end + end + def mastodon_notification_type(%Activity{}), do: nil def from_mastodon_notification_type(type) do - Map.get(@mastodon_to_ap_notification_types, type) + with {k, _v} <- + Enum.find(@mastodon_notification_types, fn {_k, v} -> + v == type or (is_list(v) and type in v) + end) do + k + end end def all_by_actor_and_id(actor, status_ids \\ []) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 04ee510b9..73e19bf97 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -284,8 +284,17 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act end end + def create_notifications(%Activity{data: %{"type" => "Follow"}} = activity) do + if Pleroma.Config.get([:notifications, :enable_follow_request_notifications]) || + Activity.follow_accepted?(activity) do + do_create_notifications(activity) + else + {:ok, []} + end + end + def create_notifications(%Activity{data: %{"type" => type}} = activity) - when type in ["Like", "Announce", "Follow", "Move", "EmojiReact"] do + when type in ["Like", "Announce", "Move", "EmojiReact"] do do_create_notifications(activity) end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 71c8c3a4e..ac2594417 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -699,6 +699,8 @@ def needs_update?(%User{local: false} = user) do def needs_update?(_), do: true @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()} + + # "Locked" (self-locked) users demand explicit authorization of follow requests def maybe_direct_follow(%User{} = follower, %User{local: true, locked: true} = followed) do follow(follower, followed, "pending") end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index ae87d4701..feed47129 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -116,6 +116,9 @@ def render( "follow" -> response + "follow_request" -> + response + "pleroma:emoji_reaction" -> response |> put_status(parent_activity_fn.(), reading_user, render_opts) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index afa510f08..89d45b2e1 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query + defdelegate mastodon_notification_type(activity), to: Activity + @types ["Create", "Follow", "Announce", "Like", "Move"] @doc "Performs sending notifications for user subscriptions" @@ -24,32 +26,32 @@ def perform( %{ activity: %{data: %{"type" => activity_type}} = activity, user: %User{id: user_id} - } = notif + } = notification ) when activity_type in @types do - actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) + actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) - type = Activity.mastodon_notification_type(notif.activity) + mastodon_type = mastodon_notification_type(notification.activity) gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) object = Object.normalize(activity) user = User.get_cached_by_id(user_id) direct_conversation_id = Activity.direct_conversation_id(activity, user) - for subscription <- fetch_subsriptions(user_id), - get_in(subscription.data, ["alerts", type]) do + for subscription <- fetch_subscriptions(user_id), + Subscription.enabled?(subscription, mastodon_type) do %{ access_token: subscription.token.token, - notification_id: notif.id, - notification_type: type, + notification_id: notification.id, + notification_type: mastodon_type, icon: avatar_url, preferred_locale: "en", pleroma: %{ - activity_id: notif.activity.id, + activity_id: notification.activity.id, direct_conversation_id: direct_conversation_id } } - |> Map.merge(build_content(notif, actor, object)) + |> Map.merge(build_content(notification, actor, object, mastodon_type)) |> Jason.encode!() |> push_message(build_sub(subscription), gcm_api_key, subscription) end @@ -82,7 +84,7 @@ def push_message(body, sub, api_key, subscription) do end @doc "Gets user subscriptions" - def fetch_subsriptions(user_id) do + def fetch_subscriptions(user_id) do Subscription |> where(user_id: ^user_id) |> preload(:token) @@ -99,28 +101,36 @@ def build_sub(subscription) do } end + def build_content(notification, actor, object, mastodon_type \\ nil) + def build_content( %{ activity: %{data: %{"directMessage" => true}}, user: %{notification_settings: %{privacy_option: true}} }, actor, - _ + _object, + _mastodon_type ) do %{title: "New Direct Message", body: "@#{actor.nickname}"} end - def build_content(notif, actor, object) do + def build_content(notification, actor, object, mastodon_type) do + mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + %{ - title: format_title(notif), - body: format_body(notif, actor, object) + title: format_title(notification, mastodon_type), + body: format_body(notification, actor, object, mastodon_type) } end + def format_body(activity, actor, object, mastodon_type \\ nil) + def format_body( %{activity: %{data: %{"type" => "Create"}}}, actor, - %{data: %{"content" => content}} + %{data: %{"content" => content}}, + _mastodon_type ) do "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" end @@ -128,33 +138,43 @@ def format_body( def format_body( %{activity: %{data: %{"type" => "Announce"}}}, actor, - %{data: %{"content" => content}} + %{data: %{"content" => content}}, + _mastodon_type ) do "@#{actor.nickname} repeated: #{Utils.scrub_html_and_truncate(content, 80)}" end def format_body( - %{activity: %{data: %{"type" => type}}}, + %{activity: %{data: %{"type" => type}}} = notification, actor, - _object + _object, + mastodon_type ) when type in ["Follow", "Like"] do - case type do - "Follow" -> "@#{actor.nickname} has followed you" - "Like" -> "@#{actor.nickname} has favorited your post" + mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + + case {type, mastodon_type} do + {"Follow", "follow"} -> "@#{actor.nickname} has followed you" + {"Follow", "follow_request"} -> "@#{actor.nickname} has requested to follow you" + {"Like", _} -> "@#{actor.nickname} has favorited your post" end end - def format_title(%{activity: %{data: %{"directMessage" => true}}}) do + def format_title(activity, mastodon_type \\ nil) + + def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_type) do "New Direct Message" end - def format_title(%{activity: %{data: %{"type" => type}}}) do - case type do - "Create" -> "New Mention" - "Follow" -> "New Follower" - "Announce" -> "New Repeat" - "Like" -> "New Favorite" + def format_title(%{activity: %{data: %{"type" => type}}} = notification, mastodon_type) do + mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + + case {type, mastodon_type} do + {"Create", _} -> "New Mention" + {"Follow", "follow"} -> "New Follower" + {"Follow", "follow_request"} -> "New Follow Request" + {"Announce", _} -> "New Repeat" + {"Like", _} -> "New Favorite" end end end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 5c448d6c9..b99b0c5fb 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -32,6 +32,14 @@ defp alerts(%{"data" => %{"alerts" => alerts}}) do %{"alerts" => alerts} end + def enabled?(subscription, "follow_request") do + enabled?(subscription, "follow") + end + + def enabled?(subscription, alert_type) do + get_in(subscription.data, ["alerts", alert_type]) + end + def create( %User{} = user, %Token{} = token, -- cgit v1.2.3 From 3775683a04e9b819f88bfba533b755bbd5b3c2df Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Apr 2020 15:55:43 +0200 Subject: ChatMessage: Basic incoming handling. --- lib/pleroma/chat.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 1 + lib/pleroma/web/activity_pub/object_validator.ex | 30 ++++++++++- .../object_validators/chat_message_validator.ex | 58 ++++++++++++++++++++++ .../create_chat_message_validator.ex | 35 +++++++++++++ .../object_validators/create_note_validator.ex | 30 +++++++++++ .../object_validators/create_validator.ex | 30 ----------- .../object_validators/types/recipients.ex | 23 +++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 7 +++ .../transmogrifier/chat_message_handling.ex | 30 +++++++++++ test/fixtures/create-chat-message.json | 19 +++++++ .../object_validators/types/recipients_test.exs | 15 ++++++ .../transmogrifier/chat_message_test.exs | 32 ++++++++++++ 13 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/create_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/recipients.ex create mode 100644 lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex create mode 100644 test/fixtures/create-chat-message.json create mode 100644 test/web/activity_pub/object_validators/types/recipients_test.exs create mode 100644 test/web/activity_pub/transmogrifier/chat_message_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index e2a8b8eba..07ad62b97 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Chat do alias Pleroma.Repo @moduledoc """ - Chat keeps a reference to DirectMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). + Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. """ diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 19286fd01..0b4892501 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -397,6 +397,7 @@ defp do_unreact_with_emoji(user, reaction_id, options) do end end + # TODO: Is this even used now? # TODO: This is weird, maybe we shouldn't check here if we can make the activity. @spec like(User.t(), Object.t(), String.t() | nil, boolean()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index dc4bce059..49cc72561 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,18 +12,46 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) def validate(%{"type" => "Like"} = object, meta) do with {:ok, object} <- - object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do + object + |> LikeValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object |> Map.from_struct()) {:ok, object, meta} end end + def validate(%{"type" => "ChatMessage"} = object, meta) do + with {:ok, object} <- + object + |> ChatMessageValidator.cast_and_apply() do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def validate(%{"type" => "Create"} = object, meta) do + with {:ok, object} <- + object + |> CreateChatMessageValidator.cast_and_apply() do + object = stringify_keys(object) + {:ok, object, meta} + end + end + + def stringify_keys(%{__struct__: _} = object) do + object + |> Map.from_struct() + |> stringify_keys + end + def stringify_keys(object) do object |> Map.new(fn {key, val} -> {to_string(key), val} end) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex new file mode 100644 index 000000000..ab5be3596 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:to, Types.Recipients, default: []) + field(:type, :string) + field(:content, :string) + field(:actor, Types.ObjectID) + field(:published, Types.DateTime) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def fix(data) do + data + |> Map.put_new("actor", data["attributedTo"]) + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["ChatMessage"]) + |> validate_required([:id, :actor, :to, :type, :content]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex new file mode 100644 index 000000000..659311480 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# NOTES +# - Can probably be a generic create validator +# - doesn't embed, will only get the object id +# - object has to be validated first, maybe with some meta info from the surrounding create +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:actor, Types.ObjectID) + field(:type, :string) + field(:to, Types.Recipients, default: []) + field(:object, Types.ObjectID) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex new file mode 100644 index 000000000..926804ce7 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.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.ActivityPub.ObjectValidators.CreateNoteValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, 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 + cast(%__MODULE__{}, data, __schema__(:fields)) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_validator.ex deleted file mode 100644 index 926804ce7..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/create_validator.ex +++ /dev/null @@ -1,30 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do - use Ecto.Schema - - alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types - - import Ecto.Changeset - - @primary_key false - - embedded_schema do - field(:id, Types.ObjectID, 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 - cast(%__MODULE__{}, data, __schema__(:fields)) - end -end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex new file mode 100644 index 000000000..5a3040842 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do + use Ecto.Type + + def type, do: {:array, :string} + + def cast(object) when is_binary(object) do + cast([object]) + end + + def cast([_ | _] = data), do: {:ok, data} + + 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/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0a8ad62ad..becc35ea3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator @@ -612,6 +613,12 @@ def handle_incoming( |> handle_incoming(options) end + def handle_incoming( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, + options + ), + do: ChatMessageHandling.handle_incoming(data, options) + def handle_incoming(%{"type" => "Like"} = data, _options) do with {_, {:ok, cast_data_sym}} <- {:casting_data, diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex new file mode 100644 index 000000000..b5843736f --- /dev/null +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.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.ActivityPub.Transmogrifier.ChatMessageHandling do + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator + alias Pleroma.Web.ActivityPub.Pipeline + + def handle_incoming( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data, + _options + ) do + with {_, {:ok, cast_data_sym}} <- + {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, + cast_data = ObjectValidator.stringify_keys(cast_data_sym), + {_, {:ok, object_cast_data_sym}} <- + {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, + object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), + {_, {:ok, validated_object, _meta}} <- + {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, + {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, + {_, {:ok, activity, _meta}} <- + {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do + {:ok, activity} + end + end +end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json new file mode 100644 index 000000000..4aa17f4a5 --- /dev/null +++ b/test/fixtures/create-chat-message.json @@ -0,0 +1,19 @@ +{ + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad.", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" +} diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs new file mode 100644 index 000000000..2f9218774 --- /dev/null +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients + use Pleroma.DataCase + + test "it works with a list" do + list = ["https://lain.com/users/lain"] + assert {:ok, list} == Recipients.cast(list) + end + + test "it turns a single string into a list" do + recipient = "https://lain.com/users/lain" + + assert {:ok, [recipient]} == Recipients.cast(recipient) + end +end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs new file mode 100644 index 000000000..aed62c520 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do + use Pleroma.DataCase + + import Pleroma.Factory + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Transmogrifier + + describe "handle_incoming" do + test "it insert it" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + author = insert(:user, ap_id: data["actor"], local: false) + recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + + {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) + + assert activity.actor == author.ap_id + assert activity.recipients == [recipient.ap_id, author.ap_id] + + %Object{} = object = Object.get_by_ap_id(activity.data["object"]) + assert object + end + end +end -- cgit v1.2.3 From 5739c498c029914c446656244cdd213a3e358fec Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 8 Apr 2020 18:46:01 +0300 Subject: 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`.
    +### 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) -- cgit v1.2.3 From f35c28bf070014dfba4b988bfc47fbf93baef81f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 8 Apr 2020 21:26:22 +0300 Subject: [#1559] Added / fixed tests for follow / follow_request notifications. --- test/notification_test.exs | 80 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 837a9dacd..0877aaaaf 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -11,8 +11,10 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Notification alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.FollowingRelationship alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.Push alias Pleroma.Web.Streamer @@ -272,16 +274,6 @@ test "it doesn't create a notification for user if he is the activity author" do refute Notification.create_notification(activity, author) end - test "it doesn't create a notification for follow-unfollow-follow chains" do - user = insert(:user) - followed_user = insert(:user) - {:ok, _, _, activity} = CommonAPI.follow(user, followed_user) - Notification.create_notification(activity, followed_user) - CommonAPI.unfollow(user, followed_user) - {:ok, _, _, activity_dupe} = CommonAPI.follow(user, followed_user) - refute Notification.create_notification(activity_dupe, followed_user) - end - test "it doesn't create duplicate notifications for follow+subscribed users" do user = insert(:user) subscriber = insert(:user) @@ -304,6 +296,74 @@ test "it doesn't create subscription notifications if the recipient cannot see t end end + describe "follow / follow_request notifications" do + test "it creates `follow` notification for approved Follow activity" do + user = insert(:user) + followed_user = insert(:user, locked: false) + + {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user) + assert FollowingRelationship.following?(user, followed_user) + assert [notification] = Notification.for_user(followed_user) + + assert %{type: "follow"} = + NotificationView.render("show.json", %{ + notification: notification, + for: followed_user + }) + end + + test "if `follow_request` notifications are enabled, " <> + "it creates `follow_request` notification for pending Follow activity" do + clear_config([:notifications, :enable_follow_request_notifications], true) + user = insert(:user) + followed_user = insert(:user, locked: true) + + {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user) + refute FollowingRelationship.following?(user, followed_user) + assert [notification] = Notification.for_user(followed_user) + + render_opts = %{notification: notification, for: followed_user} + assert %{type: "follow_request"} = NotificationView.render("show.json", render_opts) + + # After request is accepted, the same notification is rendered with type "follow": + assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user) + + notification_id = notification.id + assert [%{id: ^notification_id}] = Notification.for_user(followed_user) + assert %{type: "follow"} = NotificationView.render("show.json", render_opts) + end + + test "if `follow_request` notifications are disabled, " <> + "it does NOT create `follow*` notification for pending Follow activity" do + clear_config([:notifications, :enable_follow_request_notifications], false) + user = insert(:user) + followed_user = insert(:user, locked: true) + + {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user) + refute FollowingRelationship.following?(user, followed_user) + assert [] = Notification.for_user(followed_user) + + # After request is accepted, no new notifications are generated: + assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user) + assert [] = Notification.for_user(followed_user) + end + + test "it doesn't create a notification for follow-unfollow-follow chains" do + user = insert(:user) + followed_user = insert(:user, locked: false) + + {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user) + assert FollowingRelationship.following?(user, followed_user) + assert [notification] = Notification.for_user(followed_user) + + CommonAPI.unfollow(user, followed_user) + {:ok, _, _, _activity_dupe} = CommonAPI.follow(user, followed_user) + + notification_id = notification.id + assert [%{id: ^notification_id}] = Notification.for_user(followed_user) + end + end + describe "get notification" do test "it gets a notification that belongs to the user" do user = insert(:user) -- cgit v1.2.3 From 3965772b261e78669441a5bf3a597f1a69f78a7f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 8 Apr 2020 21:33:37 +0300 Subject: [#1559] Minor change (analysis). --- test/notification_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 0877aaaaf..a7f53e319 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -8,10 +8,10 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory import Mock + alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.FollowingRelationship alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.NotificationView -- cgit v1.2.3 From d067eaa7b3bb76e7fc5ae019d6e00510b657171d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 8 Apr 2020 22:58:31 +0300 Subject: 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 ++--- .../account_controller/update_credentials_test.exs | 4 ++-- .../controllers/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) -- cgit v1.2.3 From c401b00c7885823744183dbd077db9239585d20d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 9 Apr 2020 04:36:39 +0200 Subject: ObjectValidators.Types.ObjectID: Fix when URI.parse returns %URL{host: ""} --- .../web/activity_pub/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 -- cgit v1.2.3 From 73134e248a031613151df87fdd406580d16dc6b9 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 08:03:21 +0300 Subject: 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. -- cgit v1.2.3 From c8bfbf511eeca2045267ad4792c35648625788cf Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:24 +0000 Subject: 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" } ] ``` -- cgit v1.2.3 From 4c60fdcbb1ab06183b8e300cbbb84d70ecd3e25b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:31 +0000 Subject: 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, _} -> -- cgit v1.2.3 From 1cf0d5ab0d579ee4a1a779c308fedb0ab8ec3884 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:36 +0000 Subject: 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 -- cgit v1.2.3 From 365c34a7a96a9cbd5acb30eb6eedf195eeaff131 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:44 +0000 Subject: 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 -- cgit v1.2.3 From 9795ff5b016e74c0e7b94ac2ea28023208d1f8ee Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 10:17:50 +0000 Subject: 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 -- cgit v1.2.3 From f20a19de853e8834f7774ee0098a14213bc7427f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Apr 2020 13:28:54 +0300 Subject: 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 -- cgit v1.2.3 From 2e78686686f04726ad73749ee744b8a9df91ffb8 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 12:44:20 +0200 Subject: SideEffects: Handle ChatMessage creation. --- lib/pleroma/chat.ex | 15 +++++++++----- lib/pleroma/web/activity_pub/builder.ex | 22 +++++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 29 ++++++++++++++++++++++++++++ test/chat_test.exs | 14 ++++++++------ test/web/activity_pub/side_effects_test.exs | 26 +++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 07ad62b97..b61bc4c0e 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -18,23 +18,28 @@ defmodule Pleroma.Chat do schema "chats" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) - field(:unread, :integer, default: 0) + field(:unread, :integer, default: 0, read_after_writes: true) timestamps() end def creation_cng(struct, params) do struct - |> cast(params, [:user_id, :recipient]) + |> cast(params, [:user_id, :recipient, :unread]) |> validate_required([:user_id, :recipient]) |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end - def get_or_create(user_id, recipient) do + def get(user_id, recipient) do + __MODULE__ + |> Repo.get_by(user_id: user_id, recipient: recipient) + end + + def bump_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) |> Repo.insert( - on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()], inc: [unread: 1]], conflict_target: [:user_id, :recipient] ) end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 429a510b8..f0a6c1e1b 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,28 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + def create(actor, object_id, recipients) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "to" => recipients, + "object" => object_id, + "type" => "Create" + }, []} + end + + def chat_message(actor, recipient, content) do + {:ok, + %{ + "id" => Utils.generate_object_id(), + "actor" => actor.ap_id, + "type" => "ChatMessage", + "to" => [recipient], + "content" => content + }, []} + end + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 666a4e310..594f32700 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked object, a `Follow` activity will add the user to the follower collection, and so on. """ + alias Pleroma.Chat alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -21,8 +23,35 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do {:ok, object, meta} end + def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, meta) do + object = Object.get_by_ap_id(object_id) + + {:ok, _object} = handle_object_creation(object) + + {:ok, activity, meta} + end + # Nothing to do def handle(object, meta) do {:ok, object, meta} end + + def handle_object_creation(%{data: %{"type" => "ChatMessage"}} = object) do + actor = User.get_cached_by_ap_id(object.data["actor"]) + recipient = User.get_cached_by_ap_id(hd(object.data["to"])) + + [[actor, recipient], [recipient, actor]] + |> Enum.each(fn [user, other_user] -> + if user.local do + Chat.bump_or_create(user.id, other_user.ap_id) + end + end) + + {:ok, object} + end + + # Nothing to do + def handle_object_creation(object) do + {:ok, object} + end end diff --git a/test/chat_test.exs b/test/chat_test.exs index ca9206802..bb2b46d51 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -14,7 +14,7 @@ test "it creates a chat for a user and recipient" do user = insert(:user) other_user = insert(:user) - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id end @@ -23,19 +23,21 @@ test "it returns a chat for a user and recipient if it already exists" do user = insert(:user) other_user = insert(:user) - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) - {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id == chat_two.id end - test "a returning chat will have an updated `update_at` field" do + test "a returning chat will have an updated `update_at` field and an incremented unread count" do user = insert(:user) other_user = insert(:user) - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + assert chat.unread == 1 :timer.sleep(1500) - {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + assert chat_two.unread == 2 assert chat.id == chat_two.id assert chat.updated_at != chat_two.updated_at diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b67bd14b3..5fd8372b5 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase + alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder @@ -31,4 +32,29 @@ test "add the like to the original object", %{like: like, user: user} do assert user.ap_id in object.data["likes"] end end + + describe "creation of ChatMessages" do + test "it creates a Chat for the local users and bumps the unread count" do + author = insert(:user, local: false) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + {:ok, chat_message_object} = Object.create(chat_message_data) + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + + # The remote user won't get a chat + chat = Chat.get(author.id, recipient.ap_id) + refute chat + + # The local user will get a chat + chat = Chat.get(recipient.id, author.ap_id) + assert chat + end + end end -- cgit v1.2.3 From 4b047850718086a6d2edb5b2d94c6f888eba3016 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 12:46:33 +0200 Subject: SideEffects: Extend ChatMessage test. --- test/web/activity_pub/side_effects_test.exs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 5fd8372b5..b629d0d5d 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -55,6 +55,26 @@ test "it creates a Chat for the local users and bumps the unread count" do # The local user will get a chat chat = Chat.get(recipient.id, author.ap_id) assert chat + + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + {:ok, chat_message_object} = Object.create(chat_message_data) + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + + # Both users are local and get the chat + chat = Chat.get(author.id, recipient.ap_id) + assert chat + + chat = Chat.get(recipient.id, author.ap_id) + assert chat end end end -- cgit v1.2.3 From 8e637ae1a7b75fa08679ae9cf424650fc105de85 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 13:20:16 +0200 Subject: CommonAPI: Basic ChatMessage support. --- lib/pleroma/web/common_api/common_api.ex | 23 +++++++++++++++++++++++ test/web/common_api/common_api_test.exs | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 636cf3301..39e15adbf 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.UserRelationship @@ -23,6 +24,28 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger + def post_chat_message(user, recipient, content) do + transaction = + Repo.transaction(fn -> + with {_, {:ok, chat_message_data, _meta}} <- + {:build_object, Builder.chat_message(user, recipient.ap_id, content)}, + {_, {:ok, chat_message_object}} <- + {:create_object, Object.create(chat_message_data)}, + {_, {:ok, create_activity_data, _meta}} <- + {:build_create_activity, + Builder.create(user, chat_message_object.data["id"], [recipient.ap_id])}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do + {:ok, activity} + end + end) + + case transaction do + {:ok, value} -> value + error -> error + end + end + def follow(follower, followed) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index f46ad0272..1aea06d24 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.CommonAPITest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Conversation.Participation alias Pleroma.Object alias Pleroma.User @@ -21,6 +22,26 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + describe "posting chat messages" do + test "it posts a chat message" do + author = insert(:user) + recipient = insert(:user) + + {:ok, activity} = CommonAPI.post_chat_message(author, recipient, "a test message") + + assert activity.data["type"] == "Create" + assert activity.local + object = Object.normalize(activity) + + assert object.data["type"] == "ChatMessage" + assert object.data["to"] == [recipient.ap_id] + assert object.data["content"] == "a test message" + + assert Chat.get(author.id, recipient.ap_id) + assert Chat.get(recipient.id, author.ap_id) + end + end + test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) -- cgit v1.2.3 From ac672a9d6bfdd3cba7692f80a883bd38b0b09a57 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 9 Apr 2020 15:13:37 +0300 Subject: [#1559] Addressed code review requests. --- lib/pleroma/activity.ex | 8 ++++--- .../web/mastodon_api/views/notification_view.ex | 9 +++----- lib/pleroma/web/push/impl.ex | 25 +++++++++++----------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 3803d8e50..6213d0eb7 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -300,6 +300,8 @@ def follow_accepted?( def follow_accepted?(_), do: false + @spec mastodon_notification_type(Activity.t()) :: String.t() | nil + for {ap_type, type} <- @mastodon_notification_types, not is_list(type) do def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), do: unquote(type) @@ -315,11 +317,11 @@ def mastodon_notification_type(%Activity{data: %{"type" => "Follow"}} = activity def mastodon_notification_type(%Activity{}), do: nil + @spec from_mastodon_notification_type(String.t()) :: String.t() | nil + @doc "Converts Mastodon notification type to AR activity type" def from_mastodon_notification_type(type) do with {k, _v} <- - Enum.find(@mastodon_notification_types, fn {_k, v} -> - v == type or (is_list(v) and type in v) - end) do + Enum.find(@mastodon_notification_types, fn {_k, v} -> type in List.wrap(v) end) do k end end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index feed47129..7001fd7b9 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -113,17 +113,14 @@ def render( "move" -> put_target(response, activity, reading_user, render_opts) - "follow" -> - response - - "follow_request" -> - response - "pleroma:emoji_reaction" -> response |> put_status(parent_activity_fn.(), reading_user, render_opts) |> put_emoji(activity) + type when type in ["follow", "follow_request"] -> + response + _ -> nil end diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 89d45b2e1..f1740a6e0 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -153,10 +153,10 @@ def format_body( when type in ["Follow", "Like"] do mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) - case {type, mastodon_type} do - {"Follow", "follow"} -> "@#{actor.nickname} has followed you" - {"Follow", "follow_request"} -> "@#{actor.nickname} has requested to follow you" - {"Like", _} -> "@#{actor.nickname} has favorited your post" + case mastodon_type do + "follow" -> "@#{actor.nickname} has followed you" + "follow_request" -> "@#{actor.nickname} has requested to follow you" + "favourite" -> "@#{actor.nickname} has favorited your post" end end @@ -166,15 +166,16 @@ def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_typ "New Direct Message" end - def format_title(%{activity: %{data: %{"type" => type}}} = notification, mastodon_type) do - mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + def format_title(%{activity: activity}, mastodon_type) do + mastodon_type = mastodon_type || mastodon_notification_type(activity) - case {type, mastodon_type} do - {"Create", _} -> "New Mention" - {"Follow", "follow"} -> "New Follower" - {"Follow", "follow_request"} -> "New Follow Request" - {"Announce", _} -> "New Repeat" - {"Like", _} -> "New Favorite" + case mastodon_type do + "mention" -> "New Mention" + "follow" -> "New Follower" + "follow_request" -> "New Follow Request" + "reblog" -> "New Repeat" + "favourite" -> "New Favorite" + type -> "New #{String.capitalize(type || "event")}" end end end -- cgit v1.2.3 From d37a102933dbfbb0996546b4d148bbe36fbd4220 Mon Sep 17 00:00:00 2001 From: kPherox Date: Thu, 9 Apr 2020 21:16:29 +0900 Subject: 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 -- cgit v1.2.3 From d545b883eb3c5b79b89a49ccaf9256c31b401145 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 17:08:43 +0400 Subject: Add `/api/v1/notifications/:id/dismiss` endpoint --- .../controllers/notification_controller.ex | 3 ++- lib/pleroma/web/router.ex | 4 +++- .../controllers/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) -- cgit v1.2.3 From 68abea313d0be49aa6b8d4b980aa361383f991a7 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 15:13:55 +0200 Subject: ChatController: Add creation and return of chats. --- lib/pleroma/chat.ex | 10 ++++ .../web/pleroma_api/controllers/chat_controller.ex | 47 ++++++++++++++++++ lib/pleroma/web/router.ex | 7 +++ .../controllers/chat_controller_test.exs | 55 ++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 lib/pleroma/web/pleroma_api/controllers/chat_controller.ex create mode 100644 test/web/pleroma_api/controllers/chat_controller_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index b61bc4c0e..2475019d1 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -35,6 +35,16 @@ def get(user_id, recipient) do |> Repo.get_by(user_id: user_id, recipient: recipient) end + def get_or_create(user_id, recipient) do + %__MODULE__{} + |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> Repo.insert( + on_conflict: :nothing, + returning: true, + conflict_target: [:user_id, :recipient] + ) + end + def bump_or_create(user_id, recipient) do %__MODULE__{} |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex new file mode 100644 index 000000000..0ee8bea33 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.PleromaAPI.ChatController do + use Pleroma.Web, :controller + + alias Pleroma.Chat + alias Pleroma.Repo + + import Ecto.Query + + def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do + chats = + from(c in Chat, + where: c.user_id == ^user_id, + order_by: [desc: c.updated_at] + ) + |> Repo.all() + + represented_chats = + Enum.map(chats, fn chat -> + %{ + id: chat.id, + recipient: chat.recipient, + unread: chat.unread + } + end) + + conn + |> json(represented_chats) + end + + def create(%{assigns: %{user: user}} = conn, params) do + recipient = params["ap_id"] |> URI.decode_www_form() + + with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do + represented_chat = %{ + id: chat.id, + recipient: chat.recipient, + unread: chat.unread + } + + conn + |> json(represented_chat) + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3ecd59cd1..18ce9ee4b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -284,6 +284,13 @@ defmodule Pleroma.Web.Router do end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do + scope [] do + pipe_through(:authenticated_api) + + post("/chats/by-ap-id/:ap_id", ChatController, :create) + get("/chats", ChatController, :index) + end + scope [] do pipe_through(:authenticated_api) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs new file mode 100644 index 000000000..40c09d1cd --- /dev/null +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Chat + + import Pleroma.Factory + + describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do + test "it creates or returns a chat", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + result = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") + |> json_response(200) + + assert result["id"] + end + end + + describe "GET /api/v1/pleroma/chats" do + test "it return a list of chats the current user is participating in, in descending order of updates", + %{conn: conn} do + user = insert(:user) + har = insert(:user) + jafnhar = insert(:user) + tridi = insert(:user) + + {:ok, chat_1} = Chat.get_or_create(user.id, har.ap_id) + :timer.sleep(1000) + {:ok, _chat_2} = Chat.get_or_create(user.id, jafnhar.ap_id) + :timer.sleep(1000) + {:ok, chat_3} = Chat.get_or_create(user.id, tridi.ap_id) + :timer.sleep(1000) + + # bump the second one + {:ok, chat_2} = Chat.bump_or_create(user.id, jafnhar.ap_id) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats") + |> json_response(200) + + ids = Enum.map(result, & &1["id"]) + + assert ids == [chat_2.id, chat_3.id, chat_1.id] + end + end +end -- cgit v1.2.3 From e8fd0dd689be0c7bbca006f7267955329279da98 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 16:59:49 +0200 Subject: ChatController: Basic support for returning messages. --- .../web/pleroma_api/controllers/chat_controller.ex | 40 ++++++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 28 +++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 0ee8bea33..de23b9a22 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -5,10 +5,50 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do use Pleroma.Web, :controller alias Pleroma.Chat + alias Pleroma.Object alias Pleroma.Repo import Ecto.Query + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) do + with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do + messages = + from(o in Object, + where: fragment("?->>'type' = ?", o.data, "ChatMessage"), + where: + fragment( + """ + (?->>'actor' = ? and ?->'to' = ?) + OR (?->>'actor' = ? and ?->'to' = ?) + """, + o.data, + ^user.ap_id, + o.data, + ^[chat.recipient], + o.data, + ^chat.recipient, + o.data, + ^[user.ap_id] + ), + order_by: [desc: o.id] + ) + |> Repo.all() + + represented_messages = + messages + |> Enum.map(fn message -> + %{ + actor: message.data["actor"], + id: message.id, + content: message.data["content"] + } + end) + + conn + |> json(represented_messages) + end + end + def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do chats = from(c in Chat, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 18ce9ee4b..368e77d3e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -289,6 +289,7 @@ defmodule Pleroma.Web.Router do post("/chats/by-ap-id/:ap_id", ChatController, :create) get("/chats", ChatController, :index) + get("/chats/:id/messages", ChatController, :messages) end scope [] do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 40c09d1cd..6b2db5064 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,9 +5,37 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat + alias Pleroma.Web.CommonAPI import Pleroma.Factory + describe "GET /api/v1/pleroma/chats/:id/messages" do + # TODO + # - Test that statuses don't show + # - Test the case where it's not the user's chat + # - Test the returned data + test "it returns the messages for a given chat", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + + {:ok, _} = CommonAPI.post_chat_message(user, other_user, "hey") + {:ok, _} = CommonAPI.post_chat_message(user, third_user, "hey") + {:ok, _} = CommonAPI.post_chat_message(user, other_user, "how are you?") + {:ok, _} = CommonAPI.post_chat_message(other_user, user, "fine, how about you?") + + chat = Chat.get(user.id, other_user.ap_id) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + |> json_response(200) + + assert length(result) == 3 + end + end + describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do test "it creates or returns a chat", %{conn: conn} do user = insert(:user) -- cgit v1.2.3 From 2cc68414245805dc3b83c200798e424f139e71fc Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Apr 2020 17:18:31 +0200 Subject: ChatController: Basic message posting. --- .../web/pleroma_api/controllers/chat_controller.ex | 26 ++++++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 17 ++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index de23b9a22..972330f4e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -7,9 +7,35 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI import Ecto.Query + # TODO + # - Oauth stuff + # - Views / Representers + # - Error handling + + def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + "id" => id, + "content" => content + }) do + with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), + %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), + {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), + message <- Object.normalize(activity) do + represented_message = %{ + actor: message.data["actor"], + id: message.id, + content: message.data["content"] + } + + conn + |> json(represented_message) + end + end + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 368e77d3e..ce69725dc 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -290,6 +290,7 @@ defmodule Pleroma.Web.Router do post("/chats/by-ap-id/:ap_id", ChatController, :create) get("/chats", ChatController, :index) get("/chats/:id/messages", ChatController, :messages) + post("/chats/:id/messages", ChatController, :post_chat_message) end scope [] do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 6b2db5064..b4230e5ad 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -9,6 +9,23 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory + describe "POST /api/v1/pleroma/chats/:id/messages" do + test "it posts a message to the chat", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> assign(:user, user) + |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) + |> json_response(200) + + assert result["content"] == "Hallo!!" + end + end + describe "GET /api/v1/pleroma/chats/:id/messages" do # TODO # - Test that statuses don't show -- cgit v1.2.3 From 0e8f6d24b87812664d3bb021d17f120686cf2401 Mon Sep 17 00:00:00 2001 From: kPherox Date: Fri, 10 Apr 2020 00:19:09 +0900 Subject: 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 -- cgit v1.2.3 From c826d5195f1746449eb369e86a730f14de9fa267 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 23:36:17 +0400 Subject: 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 -- cgit v1.2.3 From 781ac28859596fce5f2fd24ffe1cdf24caaaa2fc Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 15 Mar 2020 17:26:58 +0300 Subject: 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. -- cgit v1.2.3 From 2a08f44b026bae611064b6ac459e7df16e4a36f9 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Mon, 16 Mar 2020 00:50:03 +0300 Subject: 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 -- cgit v1.2.3 From 7306d2d06942f7912fd42809b1feb9ac43089012 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 31 Mar 2020 13:59:26 +0300 Subject: 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 -- cgit v1.2.3 From 0b8f9a66aefdf4c9e2b7c1fa931e19cd724b6b4b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 2 Apr 2020 23:37:14 +0300 Subject: 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 -- cgit v1.2.3 From adeb82e4966a505e9ac65743e6336db27558e38f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 8 Apr 2020 00:38:48 +0300 Subject: 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 -- cgit v1.2.3 From 9abf13abe05f3f53bdf21d4d97242e571b1767c6 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 8 Apr 2020 00:39:55 +0300 Subject: 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` -- cgit v1.2.3 From c2aad36aa86694d4131adb2ed47441beca2ab2e8 Mon Sep 17 00:00:00 2001 From: kPherox Date: Thu, 9 Apr 2020 23:19:41 +0000 Subject: 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() -- cgit v1.2.3 From 5628984df4809888746ea005decf3856ca929858 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 1 Apr 2020 01:50:53 +0200 Subject: User: remove source_data use for follower_address and following_address --- lib/pleroma/user.ex | 91 ++++++++++++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 71c8c3a4e..d030c7314 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -306,6 +306,7 @@ def banner_url(user, options \\ []) do end end + # Should probably be renamed or removed 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 @@ -339,6 +340,13 @@ defp truncate_if_exists(params, key, max_length) do end end + defp fix_follower_address(%{follower_address: _, following_address: _} = params), do: params + + defp fix_follower_address(%{nickname: nickname} = params), + do: Map.put(params, :follower_address, ap_followers(%User{nickname: nickname})) + + defp fix_follower_address(params), do: params + def remote_user_creation(params) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) @@ -348,53 +356,44 @@ def remote_user_creation(params) do |> truncate_if_exists(:name, name_limit) |> truncate_if_exists(:bio, bio_limit) |> truncate_fields_param() + |> fix_follower_address() - changeset = - %User{local: false} - |> cast( - params, - [ - :bio, - :name, - :ap_id, - :nickname, - :avatar, - :ap_enabled, - :source_data, - :banner, - :locked, - :magic_key, - :uri, - :hide_followers, - :hide_follows, - :hide_followers_count, - :hide_follows_count, - :follower_count, - :fields, - :following_count, - :discoverable, - :invisible, - :actor_type, - :also_known_as - ] - ) - |> validate_required([:name, :ap_id]) - |> unique_constraint(:nickname) - |> validate_format(:nickname, @email_regex) - |> validate_length(:bio, max: bio_limit) - |> validate_length(:name, max: name_limit) - |> validate_fields(true) - - case params[:source_data] do - %{"followers" => followers, "following" => following} -> - changeset - |> put_change(:follower_address, followers) - |> put_change(:following_address, following) - - _ -> - followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) - put_change(changeset, :follower_address, followers) - end + %User{local: false} + |> cast( + params, + [ + :bio, + :name, + :ap_id, + :nickname, + :avatar, + :ap_enabled, + :source_data, + :banner, + :locked, + :magic_key, + :uri, + :follower_address, + :following_address, + :hide_followers, + :hide_follows, + :hide_followers_count, + :hide_follows_count, + :follower_count, + :fields, + :following_count, + :discoverable, + :invisible, + :actor_type, + :also_known_as + ] + ) + |> validate_required([:name, :ap_id]) + |> unique_constraint(:nickname) + |> validate_format(:nickname, @email_regex) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, max: name_limit) + |> validate_fields(true) end def update_changeset(struct, params \\ %{}) do -- cgit v1.2.3 From 19eedb3d0424abb235eec1a51457ed0bf3a0e95d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 1 Apr 2020 06:58:48 +0200 Subject: User: Move public_key from source_data to own field --- lib/pleroma/user.ex | 9 ++++++--- lib/pleroma/web/activity_pub/activity_pub.ex | 4 +++- .../migrations/20200401030751_users_add_public_key.exs | 17 +++++++++++++++++ test/signature_test.exs | 13 ++++--------- 4 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 priv/repo/migrations/20200401030751_users_add_public_key.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d030c7314..0adea42ec 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -82,6 +82,7 @@ defmodule Pleroma.User do field(:password, :string, virtual: true) field(:password_confirmation, :string, virtual: true) field(:keys, :string) + field(:public_key, :string) field(:ap_id, :string) field(:avatar, :map) field(:local, :boolean, default: true) @@ -366,6 +367,7 @@ def remote_user_creation(params) do :name, :ap_id, :nickname, + :public_key, :avatar, :ap_enabled, :source_data, @@ -407,6 +409,7 @@ def update_changeset(struct, params \\ %{}) do :bio, :name, :avatar, + :public_key, :locked, :no_rich_text, :default_scope, @@ -503,6 +506,7 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do :name, :follower_address, :following_address, + :public_key, :avatar, :last_refreshed_at, :ap_enabled, @@ -1616,8 +1620,7 @@ defp create_service_actor(uri, nickname) do |> set_cache() end - # AP style - def public_key(%{source_data: %{"publicKey" => %{"publicKeyPem" => public_key_pem}}}) do + def public_key(%{public_key: public_key_pem}) when is_binary(public_key_pem) do key = public_key_pem |> :public_key.pem_decode() @@ -1627,7 +1630,7 @@ def public_key(%{source_data: %{"publicKey" => %{"publicKeyPem" => public_key_pe {:ok, key} end - def public_key(_), do: {:error, "not found key"} + def public_key(_), do: {:error, "key not found"} def get_public_key_for_ap_id(ap_id) do with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 19286fd01..0e4a9d842 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1432,6 +1432,7 @@ defp object_to_user_data(data) do discoverable = data["discoverable"] || false invisible = data["invisible"] || false actor_type = data["type"] || "Person" + public_key = data["publicKey"]["publicKeyPem"] user_data = %{ ap_id: data["id"], @@ -1449,7 +1450,8 @@ defp object_to_user_data(data) do following_address: data["following"], bio: data["summary"], actor_type: actor_type, - also_known_as: Map.get(data, "alsoKnownAs", []) + also_known_as: Map.get(data, "alsoKnownAs", []), + public_key: public_key } # nickname can be nil because of virtual actors diff --git a/priv/repo/migrations/20200401030751_users_add_public_key.exs b/priv/repo/migrations/20200401030751_users_add_public_key.exs new file mode 100644 index 000000000..04e5ad1e2 --- /dev/null +++ b/priv/repo/migrations/20200401030751_users_add_public_key.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.Repo.Migrations.UsersAddPublicKey do + use Ecto.Migration + + def up do + alter table(:users) do + add_if_not_exists(:public_key, :text) + end + + execute("UPDATE users SET public_key = source_data->'publicKey'->>'publicKeyPem'") + end + + def down do + alter table(:users) do + remove_if_exists(:public_key, :text) + end + end +end diff --git a/test/signature_test.exs b/test/signature_test.exs index 04736d8b9..d5a2a62c4 100644 --- a/test/signature_test.exs +++ b/test/signature_test.exs @@ -19,12 +19,7 @@ defmodule Pleroma.SignatureTest do @private_key "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA48qb4v6kqigZutO9Ot0wkp27GIF2LiVaADgxQORZozZR63jH\nTaoOrS3Xhngbgc8SSOhfXET3omzeCLqaLNfXnZ8OXmuhJfJSU6mPUvmZ9QdT332j\nfN/g3iWGhYMf/M9ftCKh96nvFVO/tMruzS9xx7tkrfJjehdxh/3LlJMMImPtwcD7\nkFXwyt1qZTAU6Si4oQAJxRDQXHp1ttLl3Ob829VM7IKkrVmY8TD+JSlV0jtVJPj6\n1J19ytKTx/7UaucYvb9HIiBpkuiy5n/irDqKLVf5QEdZoNCdojOZlKJmTLqHhzKP\n3E9TxsUjhrf4/EqegNc/j982RvOxeu4i40zMQwIDAQABAoIBAQDH5DXjfh21i7b4\ncXJuw0cqget617CDUhemdakTDs9yH+rHPZd3mbGDWuT0hVVuFe4vuGpmJ8c+61X0\nRvugOlBlavxK8xvYlsqTzAmPgKUPljyNtEzQ+gz0I+3mH2jkin2rL3D+SksZZgKm\nfiYMPIQWB2WUF04gB46DDb2mRVuymGHyBOQjIx3WC0KW2mzfoFUFRlZEF+Nt8Ilw\nT+g/u0aZ1IWoszbsVFOEdghgZET0HEarum0B2Je/ozcPYtwmU10iBANGMKdLqaP/\nj954BPunrUf6gmlnLZKIKklJj0advx0NA+cL79+zeVB3zexRYSA5o9q0WPhiuTwR\n/aedWHnBAoGBAP0sDWBAM1Y4TRAf8ZI9PcztwLyHPzfEIqzbObJJnx1icUMt7BWi\n+/RMOnhrlPGE1kMhOqSxvXYN3u+eSmWTqai2sSH5Hdw2EqnrISSTnwNUPINX7fHH\njEkgmXQ6ixE48SuBZnb4w1EjdB/BA6/sjL+FNhggOc87tizLTkMXmMtTAoGBAOZV\n+wPuAMBDBXmbmxCuDIjoVmgSlgeRunB1SA8RCPAFAiUo3+/zEgzW2Oz8kgI+xVwM\n33XkLKrWG1Orhpp6Hm57MjIc5MG+zF4/YRDpE/KNG9qU1tiz0UD5hOpIU9pP4bR/\ngxgPxZzvbk4h5BfHWLpjlk8UUpgk6uxqfti48c1RAoGBALBOKDZ6HwYRCSGMjUcg\n3NPEUi84JD8qmFc2B7Tv7h2he2ykIz9iFAGpwCIyETQsJKX1Ewi0OlNnD3RhEEAy\nl7jFGQ+mkzPSeCbadmcpYlgIJmf1KN/x7fDTAepeBpCEzfZVE80QKbxsaybd3Dp8\nCfwpwWUFtBxr4c7J+gNhAGe/AoGAPn8ZyqkrPv9wXtyfqFjxQbx4pWhVmNwrkBPi\nZ2Qh3q4dNOPwTvTO8vjghvzIyR8rAZzkjOJKVFgftgYWUZfM5gE7T2mTkBYq8W+U\n8LetF+S9qAM2gDnaDx0kuUTCq7t87DKk6URuQ/SbI0wCzYjjRD99KxvChVGPBHKo\n1DjqMuECgYEAgJGNm7/lJCS2wk81whfy/ttKGsEIkyhPFYQmdGzSYC5aDc2gp1R3\nxtOkYEvdjfaLfDGEa4UX8CHHF+w3t9u8hBtcdhMH6GYb9iv6z0VBTt4A/11HUR49\n3Z7TQ18Iyh3jAUCzFV9IJlLIExq5Y7P4B3ojWFBN607sDCt8BMPbDYs=\n-----END RSA PRIVATE KEY-----" - @public_key %{ - "id" => "https://mastodon.social/users/lambadalambda#main-key", - "owner" => "https://mastodon.social/users/lambadalambda", - "publicKeyPem" => - "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n" - } + @public_key "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n" @rsa_public_key { :RSAPublicKey, @@ -42,7 +37,7 @@ defp make_fake_conn(key_id), test "it returns key" do expected_result = {:ok, @rsa_public_key} - user = insert(:user, source_data: %{"publicKey" => @public_key}) + user = insert(:user, public_key: @public_key) assert Signature.fetch_public_key(make_fake_conn(user.ap_id)) == expected_result end @@ -53,8 +48,8 @@ test "it returns error when not found user" do end) =~ "[error] Could not decode user" end - test "it returns error if public key is empty" do - user = insert(:user, source_data: %{"publicKey" => %{}}) + test "it returns error if public key is nil" do + user = insert(:user, public_key: nil) assert Signature.fetch_public_key(make_fake_conn(user.ap_id)) == {:error, :error} end -- cgit v1.2.3 From b6bed1a284ce07359642e0a884d2476ca387439d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 9 Apr 2020 13:01:35 +0200 Subject: Types.URI: New --- lib/pleroma/user.ex | 3 ++- .../activity_pub/object_validators/note_validator.ex | 1 + .../object_validators/types/object_id.ex | 12 +++--------- .../web/activity_pub/object_validators/types/uri.ex | 20 ++++++++++++++++++++ 4 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/uri.ex diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 0adea42ec..027386a22 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -28,6 +28,7 @@ defmodule Pleroma.User do alias Pleroma.UserRelationship alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils @@ -113,7 +114,7 @@ defmodule Pleroma.User do field(:show_role, :boolean, default: true) field(:settings, :map, default: nil) field(:magic_key, :string, default: nil) - field(:uri, :string, default: nil) + field(:uri, Types.Uri, default: nil) field(:hide_followers_count, :boolean, default: false) field(:hide_follows_count, :boolean, default: false) field(:hide_followers, :boolean, default: false) 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 c95b622e4..462a5620a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -35,6 +35,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inRepyTo, :string) + field(:uri, Types.Uri) field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) 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 f6e749b33..f71f76370 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 @@ -15,15 +15,9 @@ def cast(object) when is_binary(object) do def cast(%{"id" => object}), do: cast(object) - def cast(_) do - :error - end + def cast(_), do: :error - def dump(data) do - {:ok, data} - end + def dump(data), do: {:ok, data} - def load(data) do - {:ok, data} - end + def load(data), do: {:ok, data} end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/uri.ex b/lib/pleroma/web/activity_pub/object_validators/types/uri.ex new file mode 100644 index 000000000..24845bcc0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/uri.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Uri do + use Ecto.Type + + def type, do: :string + + def cast(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, uri} + _ -> :error + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end -- cgit v1.2.3 From 369c03834c5f2638080ff515055723e6c1c716bf Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 1 Apr 2020 07:15:38 +0200 Subject: formatter: Use user.uri instead of user.source_data.uri --- lib/pleroma/formatter.ex | 7 ++----- test/formatter_test.exs | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index c44e7fc8b..02a93a8dc 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -31,7 +31,7 @@ def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do def mention_handler("@" <> nickname, buffer, opts, acc) do case User.get_cached_by_nickname(nickname) do %User{id: id} = user -> - ap_id = get_ap_id(user) + user_url = user.uri || user.ap_id nickname_text = get_nickname_text(nickname, opts) link = @@ -42,7 +42,7 @@ def mention_handler("@" <> nickname, buffer, opts, acc) do ["@", Phoenix.HTML.Tag.content_tag(:span, nickname_text)], "data-user": id, class: "u-url mention", - href: ap_id, + href: user_url, rel: "ugc" ), class: "h-card" @@ -146,9 +146,6 @@ def truncate(text, max_length \\ 200, omission \\ "...") do end end - defp get_ap_id(%User{source_data: %{"url" => url}}) when is_binary(url), do: url - defp get_ap_id(%User{ap_id: ap_id}), do: ap_id - defp get_nickname_text(nickname, %{mentions_format: :full}), do: User.full_nickname(nickname) defp get_nickname_text(nickname, _), do: User.local_nickname(nickname) end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index 93fd8eab7..bef5a2c28 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -140,7 +140,7 @@ test "gives a replacement for user links, using local nicknames in user links te archaeme = insert(:user, nickname: "archa_eme_", - source_data: %{"url" => "https://archeme/@archa_eme_"} + uri: "https://archeme/@archa_eme_" ) archaeme_remote = insert(:user, %{nickname: "archaeme@archae.me"}) -- cgit v1.2.3 From 62656ab259cec1a8585abecf45096b283fa4c60a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 1 Apr 2020 07:47:07 +0200 Subject: User: Move inbox & shared_inbox to own fields --- lib/pleroma/user.ex | 8 ++++ lib/pleroma/web/activity_pub/activity_pub.ex | 19 +++++++- lib/pleroma/web/activity_pub/publisher.ex | 13 +++--- .../20200401072456_users_add_inboxes.exs | 20 +++++++++ test/web/activity_pub/publisher_test.exs | 52 +++++++--------------- test/web/federator_test.exs | 4 +- 6 files changed, 70 insertions(+), 46 deletions(-) create mode 100644 priv/repo/migrations/20200401072456_users_add_inboxes.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 027386a22..7d8f3a76b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -134,6 +134,8 @@ defmodule Pleroma.User do field(:skip_thread_containment, :boolean, default: false) field(:actor_type, :string, default: "Person") field(:also_known_as, {:array, :string}, default: []) + field(:inbox, :string) + field(:shared_inbox, :string) embeds_one( :notification_settings, @@ -367,6 +369,8 @@ def remote_user_creation(params) do :bio, :name, :ap_id, + :inbox, + :shared_inbox, :nickname, :public_key, :avatar, @@ -411,6 +415,8 @@ def update_changeset(struct, params \\ %{}) do :name, :avatar, :public_key, + :inbox, + :shared_inbox, :locked, :no_rich_text, :default_scope, @@ -508,6 +514,8 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do :follower_address, :following_address, :public_key, + :inbox, + :shared_inbox, :avatar, :last_refreshed_at, :ap_enabled, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0e4a9d842..f0bbecc9b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1432,7 +1432,20 @@ defp object_to_user_data(data) do discoverable = data["discoverable"] || false invisible = data["invisible"] || false actor_type = data["type"] || "Person" - public_key = data["publicKey"]["publicKeyPem"] + + public_key = + if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do + data["publicKey"]["publicKeyPem"] + else + nil + end + + shared_inbox = + if is_map(data["endpoints"]) && is_binary(data["endpoints"]["sharedInbox"]) do + data["endpoints"]["sharedInbox"] + else + nil + end user_data = %{ ap_id: data["id"], @@ -1451,7 +1464,9 @@ defp object_to_user_data(data) do bio: data["summary"], actor_type: actor_type, also_known_as: Map.get(data, "alsoKnownAs", []), - public_key: public_key + public_key: public_key, + inbox: data["inbox"], + shared_inbox: shared_inbox } # nickname can be nil because of virtual actors diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 6c558e7f0..b70cbd043 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -141,8 +141,8 @@ defp get_cc_ap_ids(ap_id, recipients) do |> Enum.map(& &1.ap_id) end - defp maybe_use_sharedinbox(%User{source_data: data}), - do: (is_map(data["endpoints"]) && Map.get(data["endpoints"], "sharedInbox")) || data["inbox"] + defp maybe_use_sharedinbox(%User{shared_inbox: nil, inbox: inbox}), do: inbox + defp maybe_use_sharedinbox(%User{shared_inbox: shared_inbox}), do: shared_inbox @doc """ Determine a user inbox to use based on heuristics. These heuristics @@ -157,7 +157,7 @@ defp maybe_use_sharedinbox(%User{source_data: data}), """ def determine_inbox( %Activity{data: activity_data}, - %User{source_data: data} = user + %User{inbox: inbox} = user ) do to = activity_data["to"] || [] cc = activity_data["cc"] || [] @@ -174,7 +174,7 @@ def determine_inbox( maybe_use_sharedinbox(user) true -> - data["inbox"] + inbox end end @@ -192,14 +192,13 @@ def publish(%User{} = actor, %{data: %{"bcc" => bcc}} = activity) inboxes = recipients |> Enum.filter(&User.ap_enabled?/1) - |> Enum.map(fn %{source_data: data} -> data["inbox"] end) + |> Enum.map(fn actor -> actor.inbox end) |> Enum.filter(fn inbox -> should_federate?(inbox, public) end) |> Instances.filter_reachable() Repo.checkout(fn -> Enum.each(inboxes, fn {inbox, unreachable_since} -> - %User{ap_id: ap_id} = - Enum.find(recipients, fn %{source_data: data} -> data["inbox"] == inbox end) + %User{ap_id: ap_id} = Enum.find(recipients, fn actor -> actor.inbox == inbox end) # Get all the recipients on the same host and add them to cc. Otherwise, a remote # instance would only accept a first message for the first recipient and ignore the rest. diff --git a/priv/repo/migrations/20200401072456_users_add_inboxes.exs b/priv/repo/migrations/20200401072456_users_add_inboxes.exs new file mode 100644 index 000000000..0947f0ab2 --- /dev/null +++ b/priv/repo/migrations/20200401072456_users_add_inboxes.exs @@ -0,0 +1,20 @@ +defmodule Pleroma.Repo.Migrations.UsersAddInboxes do + use Ecto.Migration + + def up do + alter table(:users) do + add_if_not_exists(:inbox, :text) + add_if_not_exists(:shared_inbox, :text) + end + + execute("UPDATE users SET inbox = source_data->>'inbox'") + execute("UPDATE users SET shared_inbox = source_data->'endpoints'->>'sharedInbox'") + end + + def down do + alter table(:users) do + remove_if_exists(:inbox, :text) + remove_if_exists(:shared_inbox, :text) + end + end +end diff --git a/test/web/activity_pub/publisher_test.exs b/test/web/activity_pub/publisher_test.exs index 801da03c1..c2bc38d52 100644 --- a/test/web/activity_pub/publisher_test.exs +++ b/test/web/activity_pub/publisher_test.exs @@ -48,10 +48,7 @@ test "it returns links" do describe "determine_inbox/2" do test "it returns sharedInbox for messages involving as:Public in to" do - user = - insert(:user, %{ - source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}} - }) + user = insert(:user, %{shared_inbox: "http://example.com/inbox"}) activity = %Activity{ data: %{"to" => [@as_public], "cc" => [user.follower_address]} @@ -61,10 +58,7 @@ test "it returns sharedInbox for messages involving as:Public in to" do end test "it returns sharedInbox for messages involving as:Public in cc" do - user = - insert(:user, %{ - source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}} - }) + user = insert(:user, %{shared_inbox: "http://example.com/inbox"}) activity = %Activity{ data: %{"cc" => [@as_public], "to" => [user.follower_address]} @@ -74,11 +68,7 @@ test "it returns sharedInbox for messages involving as:Public in cc" do end test "it returns sharedInbox for messages involving multiple recipients in to" do - user = - insert(:user, %{ - source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}} - }) - + user = insert(:user, %{shared_inbox: "http://example.com/inbox"}) user_two = insert(:user) user_three = insert(:user) @@ -90,11 +80,7 @@ test "it returns sharedInbox for messages involving multiple recipients in to" d end test "it returns sharedInbox for messages involving multiple recipients in cc" do - user = - insert(:user, %{ - source_data: %{"endpoints" => %{"sharedInbox" => "http://example.com/inbox"}} - }) - + user = insert(:user, %{shared_inbox: "http://example.com/inbox"}) user_two = insert(:user) user_three = insert(:user) @@ -107,12 +93,10 @@ test "it returns sharedInbox for messages involving multiple recipients in cc" d test "it returns sharedInbox for messages involving multiple recipients in total" do user = - insert(:user, - source_data: %{ - "inbox" => "http://example.com/personal-inbox", - "endpoints" => %{"sharedInbox" => "http://example.com/inbox"} - } - ) + insert(:user, %{ + shared_inbox: "http://example.com/inbox", + inbox: "http://example.com/personal-inbox" + }) user_two = insert(:user) @@ -125,12 +109,10 @@ test "it returns sharedInbox for messages involving multiple recipients in total test "it returns inbox for messages involving single recipients in total" do user = - insert(:user, - source_data: %{ - "inbox" => "http://example.com/personal-inbox", - "endpoints" => %{"sharedInbox" => "http://example.com/inbox"} - } - ) + insert(:user, %{ + shared_inbox: "http://example.com/inbox", + inbox: "http://example.com/personal-inbox" + }) activity = %Activity{ data: %{"to" => [user.ap_id], "cc" => []} @@ -258,11 +240,11 @@ test "it returns inbox for messages involving single recipients in total" do [:passthrough], [] do follower = - insert(:user, + insert(:user, %{ local: false, - source_data: %{"inbox" => "https://domain.com/users/nick1/inbox"}, + inbox: "https://domain.com/users/nick1/inbox", ap_enabled: true - ) + }) actor = insert(:user, follower_address: follower.ap_id) user = insert(:user) @@ -295,14 +277,14 @@ test "it returns inbox for messages involving single recipients in total" do fetcher = insert(:user, local: false, - source_data: %{"inbox" => "https://domain.com/users/nick1/inbox"}, + inbox: "https://domain.com/users/nick1/inbox", ap_enabled: true ) another_fetcher = insert(:user, local: false, - source_data: %{"inbox" => "https://domain2.com/users/nick1/inbox"}, + inbox: "https://domain2.com/users/nick1/inbox", ap_enabled: true ) diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index da844c24c..59e53bb03 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -78,7 +78,7 @@ test "it federates only to reachable instances via AP" do local: false, nickname: "nick1@domain.com", ap_id: "https://domain.com/users/nick1", - source_data: %{"inbox" => inbox1}, + inbox: inbox1, ap_enabled: true }) @@ -86,7 +86,7 @@ test "it federates only to reachable instances via AP" do local: false, nickname: "nick2@domain2.com", ap_id: "https://domain2.com/users/nick2", - source_data: %{"inbox" => inbox2}, + inbox: inbox2, ap_enabled: true }) -- cgit v1.2.3 From 9172d719ccbf84d55236007d329fc880db69fe42 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 3 Apr 2020 13:03:32 +0200 Subject: profile emojis in User.emoji instead of source_data --- lib/pleroma/emoji/formatter.ex | 14 ++------- lib/pleroma/user.ex | 27 ++++++++++++----- lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- lib/pleroma/web/activity_pub/views/user_view.ex | 2 +- lib/pleroma/web/common_api/common_api.ex | 20 ------------- lib/pleroma/web/common_api/utils.ex | 17 +---------- .../mastodon_api/controllers/account_controller.ex | 6 +--- lib/pleroma/web/mastodon_api/views/account_view.ex | 10 +++---- .../pleroma_api/controllers/account_controller.ex | 15 +++------- lib/pleroma/web/static_fe/static_fe_view.ex | 9 ------ .../static_fe/static_fe/_user_card.html.eex | 2 +- .../templates/static_fe/static_fe/profile.html.eex | 2 +- .../migrations/20200406100225_users_add_emoji.exs | 35 ++++++++++++++++++++++ test/emoji/formatter_test.exs | 24 ++++----------- test/web/activity_pub/transmogrifier_test.exs | 14 +++++++++ test/web/activity_pub/views/user_view_test.exs | 2 +- test/web/common_api/common_api_test.exs | 12 -------- test/web/common_api/common_api_utils_test.exs | 23 -------------- test/web/mastodon_api/views/account_view_test.exs | 16 ++-------- 20 files changed, 103 insertions(+), 158 deletions(-) create mode 100644 priv/repo/migrations/20200406100225_users_add_emoji.exs diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex index 59ff2cac3..dc45b8a38 100644 --- a/lib/pleroma/emoji/formatter.ex +++ b/lib/pleroma/emoji/formatter.ex @@ -38,22 +38,14 @@ def demojify(text) do def demojify(text, nil), do: text - @doc "Outputs a list of the emoji-shortcodes in a text" - def get_emoji(text) when is_binary(text) do - Enum.filter(Emoji.get_all(), fn {emoji, %Emoji{}} -> - String.contains?(text, ":#{emoji}:") - end) - end - - def get_emoji(_), do: [] - @doc "Outputs a list of the emoji-Maps in a text" def get_emoji_map(text) when is_binary(text) do - get_emoji(text) + Emoji.get_all() + |> Enum.filter(fn {emoji, %Emoji{}} -> String.contains?(text, ":#{emoji}:") end) |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> Map.put(acc, name, "#{Pleroma.Web.Endpoint.static_url()}#{file}") end) end - def get_emoji_map(_), do: [] + def get_emoji_map(_), do: %{} end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7d8f3a76b..cd3551e11 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -15,6 +15,7 @@ defmodule Pleroma.User do alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Delivery + alias Pleroma.Emoji alias Pleroma.FollowingRelationship alias Pleroma.Formatter alias Pleroma.HTML @@ -124,7 +125,7 @@ defmodule Pleroma.User do field(:pinned_activities, {:array, :string}, default: []) field(:email_notifications, :map, default: %{"digest" => false}) field(:mascot, :map, default: nil) - field(:emoji, {:array, :map}, default: []) + field(:emoji, :map, default: %{}) field(:pleroma_settings_store, :map, default: %{}) field(:fields, {:array, :map}, default: []) field(:raw_fields, {:array, :map}, default: []) @@ -368,6 +369,7 @@ def remote_user_creation(params) do [ :bio, :name, + :emoji, :ap_id, :inbox, :shared_inbox, @@ -413,6 +415,7 @@ def update_changeset(struct, params \\ %{}) do [ :bio, :name, + :emoji, :avatar, :public_key, :inbox, @@ -443,6 +446,7 @@ def update_changeset(struct, params \\ %{}) do |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) |> put_fields() + |> put_emoji() |> 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)) @@ -478,6 +482,18 @@ defp parse_fields(value) do |> elem(0) end + defp put_emoji(changeset) do + bio = get_change(changeset, :bio) + name = get_change(changeset, :name) + + if bio || name do + emoji = Map.merge(Emoji.Formatter.get_emoji_map(bio), Emoji.Formatter.get_emoji_map(name)) + put_change(changeset, :emoji, emoji) + 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 @@ -511,6 +527,7 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do [ :bio, :name, + :emoji, :follower_address, :following_address, :public_key, @@ -618,7 +635,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do struct |> confirmation_changeset(need_confirmation: need_confirmation?) - |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation]) + |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation, :emoji]) |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) @@ -1969,12 +1986,6 @@ def update_background(user, background) do |> update_and_set_cache() end - def update_source_data(user, source_data) do - user - |> cast(%{source_data: source_data}, [:source_data]) - |> update_and_set_cache() - end - def roles(%{is_moderator: is_moderator, is_admin: is_admin}) do %{ admin: is_admin, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index f0bbecc9b..63502b484 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1427,6 +1427,14 @@ defp object_to_user_data(data) do |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + emojis = + data + |> Map.get("tag", []) + |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> + Map.put(acc, String.trim(name, ":"), url) + end) + locked = data["manuallyApprovesFollowers"] || false data = Transmogrifier.maybe_fix_user_object(data) discoverable = data["discoverable"] || false @@ -1454,6 +1462,7 @@ defp object_to_user_data(data) do source_data: data, banner: banner, fields: fields, + emoji: emojis, locked: locked, discoverable: discoverable, invisible: invisible, diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0a8ad62ad..3d4070fd5 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1129,7 +1129,7 @@ defp build_mention_tag(%{ap_id: ap_id, nickname: nickname} = _) do def take_emoji_tags(%User{emoji: emoji}) do emoji - |> Enum.flat_map(&Map.to_list/1) + |> Map.to_list() |> Enum.map(&build_emoji_tag/1) end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index bc21ac6c7..d3d79dd5e 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -103,7 +103,7 @@ def render("user.json", %{user: user}) do }, "endpoints" => endpoints, "attachment" => fields, - "tag" => (user.source_data["tag"] || []) ++ emoji_tags, + "tag" => emoji_tags, "discoverable" => user.discoverable } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 636cf3301..952a8d8cb 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -332,26 +332,6 @@ defp maybe_create_activity_expiration({:ok, activity}, %NaiveDateTime{} = expire defp maybe_create_activity_expiration(result, _), do: result - # Updates the emojis for a user based on their profile - def update(user) do - emoji = emoji_from_profile(user) - source_data = Map.put(user.source_data, "tag", emoji) - - user = - case User.update_source_data(user, source_data) do - {:ok, user} -> user - _ -> user - end - - ActivityPub.update(%{ - local: true, - to: [Pleroma.Constants.as_public(), user.follower_address], - cc: [], - actor: user.ap_id, - object: Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) - }) - end - def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do with %Activity{ actor: ^user_ap_id, diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 635e7cd38..7eec5aa09 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Conversation.Participation - alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.Plugs.AuthenticationPlug @@ -18,7 +17,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy require Logger @@ -175,7 +173,7 @@ def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_i "replies" => %{"type" => "Collection", "totalItems" => 0} } - {note, Map.merge(emoji, Emoji.Formatter.get_emoji_map(option))} + {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))} end) end_time = @@ -431,19 +429,6 @@ def confirm_current_password(user, password) do end end - def emoji_from_profile(%User{bio: bio, name: name}) do - [bio, name] - |> Enum.map(&Emoji.Formatter.get_emoji/1) - |> Enum.concat() - |> Enum.map(fn {shortcode, %Emoji{file: path}} -> - %{ - "type" => "Emoji", - "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{path}"}, - "name" => ":#{shortcode}:" - } - end) - end - def maybe_notify_to_recipients( recipients, %Activity{data: %{"to" => to, "type" => _type}} = _activity diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 21bc3d5a5..3fcaa6be6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -140,9 +140,7 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do end @doc "PATCH /api/v1/accounts/update_credentials" - def update_credentials(%{assigns: %{user: original_user}} = conn, params) do - user = original_user - + def update_credentials(%{assigns: %{user: user}} = conn, params) do user_params = [ :no_rich_text, @@ -178,8 +176,6 @@ def update_credentials(%{assigns: %{user: original_user}} = conn, params) do changeset = User.update_changeset(user, user_params) with {:ok, user} <- User.update_and_set_cache(changeset) do - if original_user != user, do: CommonAPI.update(user) - render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) else _e -> render_error(conn, :forbidden, "Invalid request") diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 99e62f580..966032b69 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -180,13 +180,11 @@ defp do_render("show.json", %{user: user} = opts) do bot = user.actor_type in ["Application", "Service"] emojis = - (user.source_data["tag"] || []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) - |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> + Enum.map(user.emoji, fn {shortcode, url} -> %{ - "shortcode" => String.trim(name, ":"), - "url" => MediaProxy.url(url), - "static_url" => MediaProxy.url(url), + "shortcode" => shortcode, + "url" => url, + "static_url" => url, "visible_in_picker" => false } end) diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index dcba67d03..ed4fdfdba 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView require Pleroma.Constants @@ -58,38 +57,32 @@ def confirmation_resend(conn, params) do @doc "PATCH /api/v1/pleroma/accounts/update_avatar" def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do - {:ok, user} = + {:ok, _user} = user |> Changeset.change(%{avatar: nil}) |> User.update_and_set_cache() - CommonAPI.update(user) - json(conn, %{url: nil}) end def update_avatar(%{assigns: %{user: user}} = conn, params) do {:ok, %{data: data}} = ActivityPub.upload(params, type: :avatar) - {:ok, user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() + {:ok, _user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() %{"url" => [%{"href" => href} | _]} = data - CommonAPI.update(user) - json(conn, %{url: href}) end @doc "PATCH /api/v1/pleroma/accounts/update_banner" def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do - with {:ok, user} <- User.update_banner(user, %{}) do - CommonAPI.update(user) + with {:ok, _user} <- User.update_banner(user, %{}) do json(conn, %{url: nil}) end end def update_banner(%{assigns: %{user: user}} = conn, params) do with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner), - {:ok, user} <- User.update_banner(user, object.data) do - CommonAPI.update(user) + {:ok, _user} <- User.update_banner(user, object.data) do %{"url" => [%{"href" => href} | _]} = object.data json(conn, %{url: href}) diff --git a/lib/pleroma/web/static_fe/static_fe_view.ex b/lib/pleroma/web/static_fe/static_fe_view.ex index 66d87620c..b3d1d1ec8 100644 --- a/lib/pleroma/web/static_fe/static_fe_view.ex +++ b/lib/pleroma/web/static_fe/static_fe_view.ex @@ -18,15 +18,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEView do @media_types ["image", "audio", "video"] - def emoji_for_user(%User{} = user) do - user.source_data - |> Map.get("tag", []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) - |> Enum.map(fn %{"icon" => %{"url" => url}, "name" => name} -> - {String.trim(name, ":"), url} - end) - end - def fetch_media_type(%{"mediaType" => mediaType}) do Utils.fetch_media_type(@media_types, mediaType) end 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 2a7582d45..56f3a1524 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 @@ -4,7 +4,7 @@ - <%= raw (@user.name |> Formatter.emojify(emoji_for_user(@user))) %> + <%= raw Formatter.emojify(@user.name, @user.emoji) %> <%= @user.nickname %> 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 e7d2aecad..3191bf450 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 @@ -7,7 +7,7 @@ - <%= raw Formatter.emojify(@user.name, emoji_for_user(@user)) %> | + <%= raw Formatter.emojify(@user.name, @user.emoji) %> | <%= link "@#{@user.nickname}@#{Endpoint.host()}", to: (@user.uri || @user.ap_id) %>

    <%= raw @user.bio %>

    diff --git a/priv/repo/migrations/20200406100225_users_add_emoji.exs b/priv/repo/migrations/20200406100225_users_add_emoji.exs new file mode 100644 index 000000000..d0254c170 --- /dev/null +++ b/priv/repo/migrations/20200406100225_users_add_emoji.exs @@ -0,0 +1,35 @@ +defmodule Pleroma.Repo.Migrations.UsersPopulateEmoji do + use Ecto.Migration + + import Ecto.Query + + alias Pleroma.User + alias Pleroma.Repo + + def up do + execute("ALTER TABLE users ALTER COLUMN emoji SET DEFAULT '{}'::jsonb") + execute("UPDATE users SET emoji = DEFAULT WHERE emoji = '[]'::jsonb") + + from(u in User) + |> select([u], struct(u, [:id, :ap_id, :source_data])) + |> Repo.stream() + |> Enum.each(fn user -> + emoji = + user.source_data + |> Map.get("tag", []) + |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> + Map.put(acc, String.trim(name, ":"), url) + end) + + user + |> Ecto.Changeset.cast(%{emoji: emoji}, [:emoji]) + |> Repo.update() + end) + end + + def down do + execute("ALTER TABLE users ALTER COLUMN emoji SET DEFAULT '[]'::jsonb") + execute("UPDATE users SET emoji = DEFAULT WHERE emoji = '{}'::jsonb") + end +end diff --git a/test/emoji/formatter_test.exs b/test/emoji/formatter_test.exs index 3bfee9420..12af6cd8b 100644 --- a/test/emoji/formatter_test.exs +++ b/test/emoji/formatter_test.exs @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Emoji.FormatterTest do - alias Pleroma.Emoji alias Pleroma.Emoji.Formatter use Pleroma.DataCase @@ -32,30 +31,19 @@ test "it does not add XSS emoji" do end end - describe "get_emoji" do + describe "get_emoji_map" do test "it returns the emoji used in the text" do - text = "I love :firefox:" - - assert Formatter.get_emoji(text) == [ - {"firefox", - %Emoji{ - code: "firefox", - file: "/emoji/Firefox.gif", - tags: ["Gif", "Fun"], - safe_code: "firefox", - safe_file: "/emoji/Firefox.gif" - }} - ] + assert Formatter.get_emoji_map("I love :firefox:") == %{ + "firefox" => "http://localhost:4001/emoji/Firefox.gif" + } end test "it returns a nice empty result when no emojis are present" do - text = "I love moominamma" - assert Formatter.get_emoji(text) == [] + assert Formatter.get_emoji_map("I love moominamma") == %{} end test "it doesn't die when text is absent" do - text = nil - assert Formatter.get_emoji(text) == [] + assert Formatter.get_emoji_map(nil) == %{} end end end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 6dfd823f7..d7f11d1d7 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -2182,4 +2182,18 @@ test "sets `replies` collection with a limited number of self-replies" do Transmogrifier.set_replies(object.data)["replies"] end end + + test "take_emoji_tags/1" do + user = insert(:user, %{emoji: %{"firefox" => "https://example.org/firefox.png"}}) + + assert Transmogrifier.take_emoji_tags(user) == [ + %{ + "icon" => %{"type" => "Image", "url" => "https://example.org/firefox.png"}, + "id" => "https://example.org/firefox.png", + "name" => ":firefox:", + "type" => "Emoji", + "updated" => "1970-01-01T00:00:00Z" + } + ] + end end diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index ecb2dc386..20578161b 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -38,7 +38,7 @@ test "Renders profile fields" do end test "Renders with emoji tags" do - user = insert(:user, emoji: [%{"bib" => "/test"}]) + user = insert(:user, emoji: %{"bib" => "/test"}) assert %{ "tag" => [ diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index f46ad0272..5e78c5758 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -97,18 +97,6 @@ test "it adds emoji in the object" do assert Object.normalize(activity).data["emoji"]["firefox"] end - test "it adds emoji when updating profiles" do - user = insert(:user, %{name: ":firefox:"}) - - {:ok, activity} = CommonAPI.update(user) - user = User.get_cached_by_ap_id(user.ap_id) - [firefox] = user.source_data["tag"] - - assert firefox["name"] == ":firefox:" - - assert Pleroma.Constants.as_public() in activity.recipients - end - describe "posting" do test "it supports explicit addressing" do user = insert(:user) diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index 98cf02d49..b21445fe9 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.Endpoint use Pleroma.DataCase import ExUnit.CaptureLog @@ -42,28 +41,6 @@ test "correct password given" do end end - test "parses emoji from name and bio" do - {:ok, user} = UserBuilder.insert(%{name: ":blank:", bio: ":firefox:"}) - - expected = [ - %{ - "type" => "Emoji", - "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}/emoji/Firefox.gif"}, - "name" => ":firefox:" - }, - %{ - "type" => "Emoji", - "icon" => %{ - "type" => "Image", - "url" => "#{Endpoint.url()}/emoji/blank.png" - }, - "name" => ":blank:" - } - ] - - assert expected == Utils.emoji_from_profile(user) - end - describe "format_input/3" do test "works for bare text/plain" do text = "hello world!" diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 4435f69ff..85fa4f6a2 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -19,16 +19,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do end test "Represent a user account" do - source_data = %{ - "tag" => [ - %{ - "type" => "Emoji", - "icon" => %{"url" => "/file.png"}, - "name" => ":karjalanpiirakka:" - } - ] - } - background_image = %{ "url" => [%{"href" => "https://example.com/images/asuka_hospital.png"}] } @@ -37,13 +27,13 @@ test "Represent a user account" do insert(:user, %{ follower_count: 3, note_count: 5, - source_data: source_data, background: background_image, nickname: "shp@shitposter.club", name: ":karjalanpiirakka: shp", bio: "valid html. a
    b
    c
    d
    f", - inserted_at: ~N[2017-08-15 15:47:06.597036] + inserted_at: ~N[2017-08-15 15:47:06.597036], + emoji: %{"karjalanpiirakka" => "/file.png"} }) expected = %{ @@ -117,7 +107,6 @@ test "Represent a Service(bot) account" do insert(:user, %{ follower_count: 3, note_count: 5, - source_data: %{}, actor_type: "Service", nickname: "shp@shitposter.club", inserted_at: ~N[2017-08-15 15:47:06.597036] @@ -311,7 +300,6 @@ test "represent an embedded relationship" do insert(:user, %{ follower_count: 0, note_count: 5, - source_data: %{}, actor_type: "Service", nickname: "shp@shitposter.club", inserted_at: ~N[2017-08-15 15:47:06.597036] -- cgit v1.2.3 From 3420dec494203b46d37ddc17f7e1235dc908a5b3 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 6 Apr 2020 10:44:48 +0200 Subject: Remove User.fields/1 --- lib/pleroma/user.ex | 19 +------------------ lib/pleroma/web/activity_pub/views/user_view.ex | 5 +---- test/web/activity_pub/transmogrifier_test.exs | 8 ++++---- 3 files changed, 6 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index cd3551e11..79e9b2c86 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1993,21 +1993,6 @@ def roles(%{is_moderator: is_moderator, is_admin: is_admin}) do } end - # ``fields`` is an array of mastodon profile field, containing ``{"name": "…", "value": "…"}``. - # For example: [{"name": "Pronoun", "value": "she/her"}, …] - def fields(%{fields: nil, source_data: %{"attachment" => attachment}}) do - limit = Pleroma.Config.get([:instance, :max_remote_account_fields], 0) - - attachment - |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) - |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) - |> Enum.take(limit) - end - - def fields(%{fields: nil}), do: [] - - def fields(%{fields: fields}), do: fields - 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) @@ -2195,9 +2180,7 @@ def sanitize_html(%User{} = user) do # - display name def sanitize_html(%User{} = user, filter) do fields = - user - |> User.fields() - |> Enum.map(fn %{"name" => name, "value" => value} -> + Enum.map(user.fields, fn %{"name" => name, "value" => value} -> %{ "name" => name, "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index d3d79dd5e..34590b16d 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -79,10 +79,7 @@ def render("user.json", %{user: user}) do emoji_tags = Transmogrifier.take_emoji_tags(user) - fields = - user - |> User.fields() - |> Enum.map(&Map.put(&1, "type", "PropertyValue")) + fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue")) %{ "id" => user.ap_id, diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index d7f11d1d7..8ddc75669 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -746,7 +746,7 @@ test "it works with custom profile fields" do user = User.get_cached_by_ap_id(activity.actor) - assert User.fields(user) == [ + assert user.fields == [ %{"name" => "foo", "value" => "bar"}, %{"name" => "foo1", "value" => "bar1"} ] @@ -767,7 +767,7 @@ test "it works with custom profile fields" do user = User.get_cached_by_ap_id(user.ap_id) - assert User.fields(user) == [ + assert user.fields == [ %{"name" => "foo", "value" => "updated"}, %{"name" => "foo1", "value" => "updated"} ] @@ -785,7 +785,7 @@ test "it works with custom profile fields" do user = User.get_cached_by_ap_id(user.ap_id) - assert User.fields(user) == [ + assert user.fields == [ %{"name" => "foo", "value" => "updated"}, %{"name" => "foo1", "value" => "updated"} ] @@ -796,7 +796,7 @@ test "it works with custom profile fields" do user = User.get_cached_by_ap_id(user.ap_id) - assert User.fields(user) == [] + assert user.fields == [] end test "it works for incoming update activities which lock the account" do -- cgit v1.2.3 From e89078ac2a27bb0a833c982dbb5eef63ddea3cc0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 6 Apr 2020 10:59:35 +0200 Subject: User: remove source_data --- lib/pleroma/user.ex | 3 --- lib/pleroma/web/activity_pub/activity_pub.ex | 1 - .../20200406105422_users_remove_source_data.exs | 15 +++++++++++++++ test/user_test.exs | 2 +- test/web/activity_pub/activity_pub_test.exs | 1 - 5 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 priv/repo/migrations/20200406105422_users_remove_source_data.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 79e9b2c86..d05dfb480 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -97,7 +97,6 @@ defmodule Pleroma.User do field(:last_digest_emailed_at, :naive_datetime) field(:banner, :map, default: %{}) field(:background, :map, default: %{}) - field(:source_data, :map, default: %{}) field(:note_count, :integer, default: 0) field(:follower_count, :integer, default: 0) field(:following_count, :integer, default: 0) @@ -377,7 +376,6 @@ def remote_user_creation(params) do :public_key, :avatar, :ap_enabled, - :source_data, :banner, :locked, :magic_key, @@ -536,7 +534,6 @@ def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do :avatar, :last_refreshed_at, :ap_enabled, - :source_data, :banner, :locked, :magic_key, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 63502b484..9b832f4cb 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1459,7 +1459,6 @@ defp object_to_user_data(data) do ap_id: data["id"], uri: get_actor_url(data["url"]), ap_enabled: true, - source_data: data, banner: banner, fields: fields, emoji: emojis, diff --git a/priv/repo/migrations/20200406105422_users_remove_source_data.exs b/priv/repo/migrations/20200406105422_users_remove_source_data.exs new file mode 100644 index 000000000..9812d480f --- /dev/null +++ b/priv/repo/migrations/20200406105422_users_remove_source_data.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.UsersRemoveSourceData do + use Ecto.Migration + + def up do + alter table(:users) do + remove_if_exists(:source_data, :map) + end + end + + def down do + alter table(:users) do + add_if_not_exists(:source_data, :map, default: %{}) + end + end +end diff --git a/test/user_test.exs b/test/user_test.exs index d39787f35..d35005353 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -581,7 +581,7 @@ test "updates an existing user, if stale" do {:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin") - assert user.source_data["endpoints"] + assert user.inbox refute user.last_refreshed_at == orig_user.last_refreshed_at end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 17e7b97de..6410df49b 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -180,7 +180,6 @@ test "it returns a user" do {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) assert user.ap_id == user_id assert user.nickname == "admin@mastodon.example.org" - assert user.source_data assert user.ap_enabled assert user.follower_address == "http://mastodon.example.org/users/admin/followers" end -- cgit v1.2.3 From 64c78581fe397b6d9356c52cf3f43becd2ff3b4e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 10 Apr 2020 14:47:56 +0200 Subject: Chat: Only create them for valid users for now. --- lib/pleroma/chat.ex | 7 +++++++ test/chat_test.exs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 2475019d1..c2044881f 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -26,6 +26,13 @@ defmodule Pleroma.Chat do def creation_cng(struct, params) do struct |> cast(params, [:user_id, :recipient, :unread]) + |> validate_change(:recipient, fn + :recipient, recipient -> + case User.get_cached_by_ap_id(recipient) do + nil -> [recipient: "must a an existing user"] + _ -> [] + end + end) |> validate_required([:user_id, :recipient]) |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end diff --git a/test/chat_test.exs b/test/chat_test.exs index bb2b46d51..952598c87 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -10,6 +10,13 @@ defmodule Pleroma.ChatTest do import Pleroma.Factory describe "creation and getting" do + test "it only works if the recipient is a valid user (for now)" do + user = insert(:user) + + assert {:error, _chat} = Chat.bump_or_create(user.id, "http://some/nonexisting/account") + assert {:error, _chat} = Chat.get_or_create(user.id, "http://some/nonexisting/account") + end + test "it creates a chat for a user and recipient" do user = insert(:user) other_user = insert(:user) -- cgit v1.2.3 From 6ff8812ea3403a2f4a31206a96a58fad93fff51f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Apr 2020 11:37:02 -0500 Subject: Add a section for changelog entries that pertain to the next patch release. This will make it easier to keep changelogs synced between develop and stable branches. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5d5f800..36897503a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Support pagination in conversations API +## [unreleased-patch] + ## [2.0.2] - 2020-04-08 ### Added - Support for Funkwhale's `Audio` activity -- cgit v1.2.3 From ad92cef844d4f4211a65fd37b08f8bd8abea8dda Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 10 Apr 2020 21:27:50 +0300 Subject: fix Oban migration --- priv/repo/migrations/20200402063221_update_oban_jobs_table.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs b/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs index c8ee12192..e7ff04008 100644 --- a/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs +++ b/priv/repo/migrations/20200402063221_update_oban_jobs_table.exs @@ -2,10 +2,10 @@ defmodule Pleroma.Repo.Migrations.UpdateObanJobsTable do use Ecto.Migration def up do - Oban.Migrations.up() + Oban.Migrations.up(version: 8) end def down do - Oban.Migrations.down(version: 1) + Oban.Migrations.down(version: 7) end end -- cgit v1.2.3 From 88b16fdfb7b40877aecae5d45f6f3a1c54362f13 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 11 Apr 2020 16:01:09 +0300 Subject: [#1364] Disabled notifications on activities from blocked domains. --- CHANGELOG.md | 1 + lib/pleroma/notification.ex | 20 +++++++++++++------- test/notification_test.exs | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36897503a..22d0645fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Support pagination in conversations API +- Filtering of push notifications on activities from blocked domains ## [unreleased-patch] diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 04ee510b9..02363ddb0 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -321,10 +321,11 @@ 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)} + {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)} NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1 """ + @spec get_notified_from_activity(Activity.t(), boolean()) :: {list(User.t()), list(User.t())} def get_notified_from_activity(activity, local_only \\ true) def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) @@ -337,17 +338,22 @@ 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_relationship_restricted_ap_ids(activity) - |> exclude_thread_muter_ap_ids(activity) - potential_receivers = potential_receiver_ap_ids |> Enum.uniq() |> User.get_users_from_set(local_only) + activity_actor_domain = activity.actor && URI.parse(activity.actor).host + + notification_enabled_ap_ids = + for u <- potential_receivers, activity_actor_domain not in u.domain_blocks, do: u.ap_id + + # Since even subscribers and followers can mute / thread-mute, filtering all above AP IDs + notification_enabled_ap_ids = + notification_enabled_ap_ids + |> exclude_relationship_restricted_ap_ids(activity) + |> exclude_thread_muter_ap_ids(activity) + notification_enabled_users = Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) diff --git a/test/notification_test.exs b/test/notification_test.exs index 837a9dacd..caa941934 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -609,6 +609,21 @@ test "it returns thread-muting recipient in disabled recipients list" do assert [other_user] == disabled_receivers refute other_user in enabled_receivers end + + test "it returns domain-blocking recipient in disabled recipients list" do + blocked_domain = "blocked.domain" + user = insert(:user, %{ap_id: "https://#{blocked_domain}/@actor"}) + other_user = insert(:user) + + {:ok, other_user} = User.block_domain(other_user, blocked_domain) + + {: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 + end end describe "notification lifecycle" do -- cgit v1.2.3 From c077ad0b3305e74f5b8d1b9bf38d4f480d76c1a6 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 11 Apr 2020 21:44:52 +0300 Subject: Remove User.upgrade_changeset in favor of remote_user_creation The two changesets had the same purpose, yet some changes were updated in one, but not the other (`uri`, for example). Also makes `Transmogrifier.upgrade_user_from_ap_id` be called from `ActivityPub.make_user_from_ap_id` only when the user is actually not AP enabled yet. I did not bother rewriting tests that used `User.insert_or_update` to use the changeset instead because they seemed to just test the implementation, rather than behavior. --- lib/pleroma/user.ex | 60 +++--------------------- lib/pleroma/web/activity_pub/activity_pub.ex | 15 +++++- lib/pleroma/web/activity_pub/transmogrifier.ex | 14 ++---- test/user_test.exs | 64 +++----------------------- test/web/activity_pub/views/user_view_test.exs | 2 +- 5 files changed, 31 insertions(+), 124 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 71c8c3a4e..fab405233 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -339,18 +339,20 @@ defp truncate_if_exists(params, key, max_length) do end end - def remote_user_creation(params) do + def remote_user_changeset(struct \\ %User{local: false}, params) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) params = params + |> Map.put(:name, blank?(params[:name]) || params[:nickname]) + |> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now()) |> truncate_if_exists(:name, name_limit) |> truncate_if_exists(:bio, bio_limit) |> truncate_fields_param() changeset = - %User{local: false} + struct |> cast( params, [ @@ -375,7 +377,8 @@ def remote_user_creation(params) do :discoverable, :invisible, :actor_type, - :also_known_as + :also_known_as, + :last_refreshed_at ] ) |> validate_required([:name, :ap_id]) @@ -488,49 +491,6 @@ defp put_upload(value, type) do 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) - - params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now()) - - params = if remote?, do: truncate_fields_param(params), else: params - - struct - |> cast( - params, - [ - :bio, - :name, - :follower_address, - :following_address, - :avatar, - :last_refreshed_at, - :ap_enabled, - :source_data, - :banner, - :locked, - :magic_key, - :follower_count, - :following_count, - :hide_follows, - :fields, - :hide_followers, - :allow_following_move, - :discoverable, - :hide_followers_count, - :hide_follows_count, - :actor_type, - :also_known_as - ] - ) - |> unique_constraint(:nickname) - |> validate_format(:nickname, local_nickname_regex()) - |> validate_length(:bio, max: bio_limit) - |> validate_length(:name, max: name_limit) - |> validate_fields(remote?) - end - def update_as_admin_changeset(struct, params) do struct |> update_changeset(params) @@ -1642,14 +1602,6 @@ def get_public_key_for_ap_id(ap_id) do defp blank?(""), do: nil defp blank?(n), do: n - def insert_or_update_user(data) do - data - |> Map.put(:name, blank?(data[:name]) || data[:nickname]) - |> remote_user_creation() - |> Repo.insert(on_conflict: {:replace_all_except, [:id]}, conflict_target: :nickname) - |> set_cache() - end - def ap_enabled?(%User{local: true}), do: true def ap_enabled?(%User{ap_enabled: ap_enabled}), do: ap_enabled def ap_enabled?(_), do: false diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 86b105b7f..2602b966b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1551,11 +1551,22 @@ def fetch_and_prepare_user_from_ap_id(ap_id) do end def make_user_from_ap_id(ap_id) do - if _user = User.get_cached_by_ap_id(ap_id) do + user = User.get_cached_by_ap_id(ap_id) + + if user && !User.ap_enabled?(user) do Transmogrifier.upgrade_user_from_ap_id(ap_id) else with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do - User.insert_or_update_user(data) + if user do + user + |> User.remote_user_changeset(data) + |> User.update_and_set_cache() + else + data + |> User.remote_user_changeset() + |> Repo.insert() + |> User.set_cache() + end else e -> {:error, e} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f9951cc5d..18fd56bed 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -710,7 +710,7 @@ def handle_incoming( {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object) actor - |> User.upgrade_changeset(new_user_data, true) + |> User.remote_user_changeset(new_user_data) |> User.update_and_set_cache() ActivityPub.update(%{ @@ -1253,12 +1253,8 @@ def perform(:user_upgrade, user) do def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), - already_ap <- User.ap_enabled?(user), - {:ok, user} <- upgrade_user(user, data) do - if not already_ap do - TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) - end - + {:ok, user} <- update_user(user, data) do + TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) {:ok, user} else %User{} = user -> {:ok, user} @@ -1266,9 +1262,9 @@ def upgrade_user_from_ap_id(ap_id) do end end - defp upgrade_user(user, data) do + defp update_user(user, data) do user - |> User.upgrade_changeset(data, true) + |> User.remote_user_changeset(data) |> User.update_and_set_cache() end diff --git a/test/user_test.exs b/test/user_test.exs index d39787f35..5c24955c2 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -609,7 +609,7 @@ test "returns an ap_followers link for a user" do ) <> "/followers" end - describe "remote user creation changeset" do + describe "remote user changeset" do @valid_remote %{ bio: "hello", name: "Someone", @@ -621,28 +621,28 @@ test "returns an ap_followers link for a user" do setup do: clear_config([:instance, :user_name_length]) test "it confirms validity" do - cs = User.remote_user_creation(@valid_remote) + cs = User.remote_user_changeset(@valid_remote) assert cs.valid? end test "it sets the follower_adress" do - cs = User.remote_user_creation(@valid_remote) + cs = User.remote_user_changeset(@valid_remote) # remote users get a fake local follower address assert cs.changes.follower_address == User.ap_followers(%User{nickname: @valid_remote[:nickname]}) end test "it enforces the fqn format for nicknames" do - cs = User.remote_user_creation(%{@valid_remote | nickname: "bla"}) + cs = User.remote_user_changeset(%{@valid_remote | nickname: "bla"}) assert Ecto.Changeset.get_field(cs, :local) == false assert cs.changes.avatar refute cs.valid? end test "it has required fields" do - [:name, :ap_id] + [:ap_id] |> Enum.each(fn field -> - cs = User.remote_user_creation(Map.delete(@valid_remote, field)) + cs = User.remote_user_changeset(Map.delete(@valid_remote, field)) refute cs.valid? end) end @@ -1198,58 +1198,6 @@ test "get_public_key_for_ap_id fetches a user that's not in the db" do assert {:ok, _key} = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end - describe "insert or update a user from given data" do - test "with normal data" do - user = insert(:user, %{nickname: "nick@name.de"}) - data = %{ap_id: user.ap_id <> "xxx", name: user.name, nickname: user.nickname} - - assert {:ok, %User{}} = User.insert_or_update_user(data) - end - - test "with overly long fields" do - current_max_length = Pleroma.Config.get([:instance, :account_field_value_length], 255) - user = insert(:user, nickname: "nickname@supergood.domain") - - data = %{ - ap_id: user.ap_id, - name: user.name, - nickname: user.nickname, - fields: [ - %{"name" => "myfield", "value" => String.duplicate("h", current_max_length + 1)} - ] - } - - assert {:ok, %User{}} = User.insert_or_update_user(data) - end - - test "with an overly long bio" do - current_max_length = Pleroma.Config.get([:instance, :user_bio_length], 5000) - user = insert(:user, nickname: "nickname@supergood.domain") - - data = %{ - ap_id: user.ap_id, - name: user.name, - nickname: user.nickname, - bio: String.duplicate("h", current_max_length + 1) - } - - assert {:ok, %User{}} = User.insert_or_update_user(data) - end - - test "with an overly long display name" do - current_max_length = Pleroma.Config.get([:instance, :user_name_length], 100) - user = insert(:user, nickname: "nickname@supergood.domain") - - data = %{ - ap_id: user.ap_id, - name: String.duplicate("h", current_max_length + 1), - nickname: user.nickname - } - - assert {:ok, %User{}} = User.insert_or_update_user(data) - end - end - describe "per-user rich-text filtering" do test "html_filter_policy returns default policies, when rich-text is enabled" do user = insert(:user) diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index ecb2dc386..514fd97b8 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -29,7 +29,7 @@ test "Renders profile fields" do {:ok, user} = insert(:user) - |> User.upgrade_changeset(%{fields: fields}) + |> User.update_changeset(%{fields: fields}) |> User.update_and_set_cache() assert %{ -- cgit v1.2.3 From 2ba754ffe11b98305e0c0607fec7ca4d510aa67f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 12 Apr 2020 18:49:31 +0300 Subject: Fix mix tasks failing on OTP releases No idea why this was even added. Closes #1678 --- lib/mix/pleroma.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 4dfcc32e7..3ad6edbfb 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -5,7 +5,6 @@ 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 -- cgit v1.2.3 From c556efb761a3e7fc2beb4540d6f58dbfe8e4abfe Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 12 Apr 2020 21:53:03 +0300 Subject: [#1364] Enabled notifications on followed domain-blocked users' activities. --- lib/pleroma/following_relationship.ex | 35 ++++++++++++++++++--- lib/pleroma/notification.ex | 59 ++++++++++++++++++++++++++++------- test/notification_test.exs | 35 +++++++++++++++++++-- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index a9538ea4e..11e06c5cc 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -69,6 +69,29 @@ def follower_count(%User{} = user) do |> Repo.aggregate(:count, :id) end + def followers_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.follower_id == u.id) + |> where([r], r.following_id == ^user.id) + |> where([r], r.state == "accept") + end + + def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do + query = + user + |> followers_query() + |> select([r, u], u.ap_id) + + query = + if from_ap_ids do + where(query, [r, u], u.ap_id in ^from_ap_ids) + else + query + end + + Repo.all(query) + end + def following_count(%User{id: nil}), do: 0 def following_count(%User{} = user) do @@ -92,12 +115,16 @@ def following?(%User{id: follower_id}, %User{id: followed_id}) do |> Repo.exists?() end + def following_query(%User{} = user) do + __MODULE__ + |> join(:inner, [r], u in User, on: r.following_id == u.id) + |> where([r], r.follower_id == ^user.id) + |> where([r], r.state == "accept") + end + def following(%User{} = user) do following = - __MODULE__ - |> join(:inner, [r], u in User, on: r.following_id == u.id) - |> where([r], r.follower_id == ^user.id) - |> where([r], r.state == "accept") + following_query(user) |> select([r, u], u.follower_address) |> Repo.all() diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 02363ddb0..da05ff2e4 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Notification do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination @@ -81,6 +82,7 @@ def for_user_query(user, opts \\ %{}) do |> exclude_visibility(opts) end + # Excludes blocked users and non-followed domain-blocked users defp exclude_blocked(query, user, opts) do blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) @@ -88,7 +90,16 @@ defp exclude_blocked(query, user, opts) do |> where([n, a], a.actor not in ^blocked_ap_ids) |> where( [n, a], - fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.domain_blocks + fragment( + # "NOT (actor's domain in domain_blocks) OR (actor is in followed AP IDs)" + "NOT (substring(? from '.*://([^/]*)') = ANY(?)) OR \ + ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr \ + ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = 'accept')", + a.actor, + ^user.domain_blocks, + a.actor, + ^User.binary_id(user.id) + ) ) end @@ -338,19 +349,11 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo |> Utils.maybe_notify_followers(activity) |> Enum.uniq() - potential_receivers = - potential_receiver_ap_ids - |> Enum.uniq() - |> User.get_users_from_set(local_only) - - activity_actor_domain = activity.actor && URI.parse(activity.actor).host - - notification_enabled_ap_ids = - for u <- potential_receivers, activity_actor_domain not in u.domain_blocks, do: u.ap_id + potential_receivers = User.get_users_from_set(potential_receiver_ap_ids, local_only) - # Since even subscribers and followers can mute / thread-mute, filtering all above AP IDs notification_enabled_ap_ids = - notification_enabled_ap_ids + potential_receiver_ap_ids + |> exclude_domain_blocker_ap_ids(activity, potential_receivers) |> exclude_relationship_restricted_ap_ids(activity) |> exclude_thread_muter_ap_ids(activity) @@ -362,6 +365,38 @@ 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 who domain-block and not follow activity actor" + def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) + + def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: [] + + def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do + activity_actor_domain = activity.actor && URI.parse(activity.actor).host + + users = + ap_ids + |> Enum.map(fn ap_id -> + Enum.find(preloaded_users, &(&1.ap_id == ap_id)) || + User.get_cached_by_ap_id(ap_id) + end) + |> Enum.filter(& &1) + + domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id + + domain_blocker_follower_ap_ids = + if Enum.any?(domain_blocker_ap_ids) do + activity + |> Activity.user_actor() + |> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids) + else + [] + end + + ap_ids + |> Kernel.--(domain_blocker_ap_ids) + |> Kernel.++(domain_blocker_follower_ap_ids) + end + @doc "Filters out AP IDs of users basing on their relationships with activity actor user" def exclude_relationship_restricted_ap_ids([], _activity), do: [] diff --git a/test/notification_test.exs b/test/notification_test.exs index caa941934..4e5559bb1 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -610,7 +610,7 @@ test "it returns thread-muting recipient in disabled recipients list" do refute other_user in enabled_receivers end - test "it returns domain-blocking recipient in disabled recipients list" do + test "it returns non-following domain-blocking recipient in disabled recipients list" do blocked_domain = "blocked.domain" user = insert(:user, %{ap_id: "https://#{blocked_domain}/@actor"}) other_user = insert(:user) @@ -624,6 +624,22 @@ test "it returns domain-blocking recipient in disabled recipients list" do assert [] == enabled_receivers assert [other_user] == disabled_receivers end + + test "it returns following domain-blocking recipient in enabled recipients list" do + blocked_domain = "blocked.domain" + user = insert(:user, %{ap_id: "https://#{blocked_domain}/@actor"}) + other_user = insert(:user) + + {:ok, other_user} = User.block_domain(other_user, blocked_domain) + {:ok, other_user} = User.follow(other_user, user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + + {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) + + assert [other_user] == enabled_receivers + assert [] == disabled_receivers + end end describe "notification lifecycle" do @@ -886,7 +902,7 @@ test "it doesn't return notifications for blocked user" do assert Notification.for_user(user) == [] end - test "it doesn't return notifications for blocked domain" do + test "it doesn't return notifications for domain-blocked non-followed user" do user = insert(:user) blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") @@ -896,6 +912,18 @@ test "it doesn't return notifications for blocked domain" do assert Notification.for_user(user) == [] end + test "it returns notifications for domain-blocked but followed user" do + user = insert(:user) + blocked = insert(:user, ap_id: "http://some-domain.com") + + {:ok, user} = User.block_domain(user, "some-domain.com") + {:ok, _} = User.follow(user, blocked) + + {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"}) + + assert length(Notification.for_user(user)) == 1 + end + test "it doesn't return notifications for muted thread" do user = insert(:user) another_user = insert(:user) @@ -926,7 +954,8 @@ test "it doesn't return notifications from a blocked user when with_muted is set assert Enum.empty?(Notification.for_user(user, %{with_muted: true})) end - test "it doesn't return notifications from a domain-blocked user when with_muted is set" do + test "when with_muted is set, " <> + "it doesn't return notifications from a domain-blocked non-followed user" do user = insert(:user) blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") -- cgit v1.2.3 From ed894802d5dfe60072b9445cb28e7b474a9f393b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 12 Apr 2020 18:46:47 -0500 Subject: Expand MRF SimplePolicy docs --- docs/configuration/mrf.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md index c3957c255..9f13c3d18 100644 --- a/docs/configuration/mrf.md +++ b/docs/configuration/mrf.md @@ -41,11 +41,14 @@ config :pleroma, :instance, Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_simple` config object. These groups are: -* `media_removal`: Servers in this group will have media stripped from incoming messages. -* `media_nsfw`: Servers in this group will have the #nsfw tag and sensitive setting injected into incoming messages which contain media. * `reject`: Servers in this group will have their messages rejected. -* `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields. +* `accept`: If not empty, only messages from these instances will be accepted (whitelist federation). +* `media_nsfw`: Servers in this group will have the #nsfw tag and sensitive setting injected into incoming messages which contain media. +* `media_removal`: Servers in this group will have media stripped from incoming messages. +* `avatar_removal`: Avatars from these servers will be stripped from incoming messages. +* `banner_removal`: Banner images from these servers will be stripped from incoming messages. * `report_removal`: Servers in this group will have their reports (flags) rejected. +* `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields. Servers should be configured as lists. -- cgit v1.2.3 From 9a3c74b244bce6097a8c6da99692bfc9973e1ec8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 12 Apr 2020 20:26:35 -0500 Subject: Always accept deletions through SimplePolicy --- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 3 +++ test/web/activity_pub/mrf/simple_policy_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 4edc007fd..b23f263f5 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -148,6 +148,9 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image defp check_banner_removal(_actor_info, object), do: {:ok, object} + @impl true + def filter(%{"type" => "Delete"} = object), do: {:ok, object} + @impl true def filter(%{"actor" => actor} = object) do actor_info = URI.parse(actor) diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index 91c24c2d9..eaa595706 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -258,6 +258,14 @@ test "actor has a matching host" do assert SimplePolicy.filter(remote_user) == {:reject, nil} end + + test "always accept deletions" do + Config.put([:mrf_simple, :reject], ["remote.instance"]) + + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} + end end describe "when :accept" do @@ -308,6 +316,14 @@ test "actor has a matching host" do assert SimplePolicy.filter(remote_user) == {:ok, remote_user} end + + test "always accept deletions" do + Config.put([:mrf_simple, :accept], ["non.matching.remote"]) + + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} + end end describe "when :avatar_removal" do @@ -408,4 +424,11 @@ defp build_remote_user do "type" => "Person" } end + + defp build_remote_deletion_message do + %{ + "type" => "Delete", + "actor" => "https://remote.instance/users/bob" + } + end end -- cgit v1.2.3 From c5c09fc61b7b6e591e9de23028e5caea8f26b996 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 13 Apr 2020 06:53:45 +0300 Subject: fix mediaType of object --- lib/pleroma/web/activity_pub/transmogrifier.ex | 21 +++++++++++++++++---- test/fixtures/tesla_mock/bittube-video.json | 1 + test/fixtures/tesla_mock/hanimated.json | 1 + test/support/http_request_mock.ex | 16 ++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 9 +++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/tesla_mock/bittube-video.json create mode 100644 test/fixtures/tesla_mock/hanimated.json diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 39feae285..17e3c203a 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -35,6 +35,7 @@ def fix_object(object, options \\ []) do |> fix_actor |> fix_url |> fix_attachments + |> fix_media_type |> fix_context |> fix_in_reply_to(options) |> fix_emoji @@ -357,6 +358,12 @@ def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) def fix_type(object, _), do: object + defp fix_media_type(%{"mediaType" => _} = object) do + Map.put(object, "mediaType", "text/html") + end + + defp fix_media_type(object), do: object + defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do with true <- id =~ "follows", %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), @@ -1207,18 +1214,24 @@ def add_attributed_to(object) do def prepare_attachments(object) do attachments = - (object["attachment"] || []) + object + |> Map.get("attachment", []) |> Enum.map(fn data -> [%{"mediaType" => media_type, "href" => href} | _] = data["url"] - %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"} + + %{ + "url" => href, + "mediaType" => media_type, + "name" => data["name"], + "type" => "Document" + } end) Map.put(object, "attachment", attachments) end def strip_internal_fields(object) do - object - |> Map.drop(Pleroma.Constants.object_internal_fields()) + Map.drop(object, Pleroma.Constants.object_internal_fields()) end defp strip_internal_tags(%{"tag" => tags} = object) do diff --git a/test/fixtures/tesla_mock/bittube-video.json b/test/fixtures/tesla_mock/bittube-video.json new file mode 100644 index 000000000..be839862f --- /dev/null +++ b/test/fixtures/tesla_mock/bittube-video.json @@ -0,0 +1 @@ +{"type":"Video","id":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b","name":"Implications of 5G Rollout Simply Explained","duration":"PT428S","uuid":"2aad7dfb-5c75-4ee6-a9ed-08436af0558b","tag":[{"type":"Hashtag","name":"5g"},{"type":"Hashtag","name":"big brother"},{"type":"Hashtag","name":"facial recognition"},{"type":"Hashtag","name":"smart device"}],"category":{"identifier":"15","name":"Science & Technology"},"language":{"identifier":"en","name":"English"},"views":5,"sensitive":false,"waitTranscoding":true,"state":1,"commentsEnabled":true,"downloadEnabled":true,"published":"2020-04-12T11:55:44.805Z","originallyPublishedAt":null,"updated":"2020-04-13T02:01:24.279Z","mediaType":"text/markdown","content":null,"support":null,"subtitleLanguage":[],"icon":{"type":"Image","url":"https://bittube.video/static/thumbnails/2aad7dfb-5c75-4ee6-a9ed-08436af0558b.jpg","mediaType":"image/jpeg","width":223,"height":122},"url":[{"type":"Link","mediaType":"text/html","href":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b"},{"type":"Link","mediaType":"video/mp4","href":"https://bittube.video/static/webseed/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.mp4","height":240,"size":17158094,"fps":30},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://bittube.video/static/torrents/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.torrent","height":240},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fbittube.video%2Fstatic%2Ftorrents%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.torrent&xt=urn:btih:16c8f60d788a29e7ff195de44b4a1558b41dc6c3&dn=Implications+of+5G+Rollout+Simply+Explained&tr=wss%3A%2F%2Fbittube.video%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fbittube.video%2Ftracker%2Fannounce&ws=https%3A%2F%2Fbittube.video%2Fstatic%2Fwebseed%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.mp4","height":240},{"type":"Link","mediaType":"video/mp4","href":"https://bittube.video/static/webseed/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.mp4","height":0,"size":5215186,"fps":0},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://bittube.video/static/torrents/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.torrent","height":0},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fbittube.video%2Fstatic%2Ftorrents%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.torrent&xt=urn:btih:8a043b09291f2947423ce96d1cd0e977662d6de8&dn=Implications+of+5G+Rollout+Simply+Explained&tr=wss%3A%2F%2Fbittube.video%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fbittube.video%2Ftracker%2Fannounce&ws=https%3A%2F%2Fbittube.video%2Fstatic%2Fwebseed%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.mp4","height":0},{"type":"Link","mediaType":"video/mp4","href":"https://bittube.video/static/webseed/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.mp4","height":360,"size":22813140,"fps":30},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://bittube.video/static/torrents/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.torrent","height":360},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fbittube.video%2Fstatic%2Ftorrents%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.torrent&xt=urn:btih:d121f7493998d4204b3d33d00da7fea1c9a42484&dn=Implications+of+5G+Rollout+Simply+Explained&tr=wss%3A%2F%2Fbittube.video%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fbittube.video%2Ftracker%2Fannounce&ws=https%3A%2F%2Fbittube.video%2Fstatic%2Fwebseed%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.mp4","height":360}],"likes":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/likes","dislikes":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/dislikes","shares":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/announces","comments":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/comments","attributedTo":[{"type":"Person","id":"https://bittube.video/accounts/hanimated.moh"},{"type":"Group","id":"https://bittube.video/video-channels/hanimated.moh_channel"}],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://bittube.video/accounts/hanimated.moh/followers"],"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017","pt":"https://joinpeertube.org/ns#","sc":"http://schema.org#","Hashtag":"as:Hashtag","uuid":"sc:identifier","category":"sc:category","licence":"sc:license","subtitleLanguage":"sc:subtitleLanguage","sensitive":"as:sensitive","language":"sc:inLanguage","expires":"sc:expires","CacheFile":"pt:CacheFile","Infohash":"pt:Infohash","originallyPublishedAt":"sc:datePublished","views":{"@type":"sc:Number","@id":"pt:views"},"state":{"@type":"sc:Number","@id":"pt:state"},"size":{"@type":"sc:Number","@id":"pt:size"},"fps":{"@type":"sc:Number","@id":"pt:fps"},"startTimestamp":{"@type":"sc:Number","@id":"pt:startTimestamp"},"stopTimestamp":{"@type":"sc:Number","@id":"pt:stopTimestamp"},"position":{"@type":"sc:Number","@id":"pt:position"},"commentsEnabled":{"@type":"sc:Boolean","@id":"pt:commentsEnabled"},"downloadEnabled":{"@type":"sc:Boolean","@id":"pt:downloadEnabled"},"waitTranscoding":{"@type":"sc:Boolean","@id":"pt:waitTranscoding"},"support":{"@type":"sc:Text","@id":"pt:support"}},{"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"playlists":{"@id":"pt:playlists","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}]} diff --git a/test/fixtures/tesla_mock/hanimated.json b/test/fixtures/tesla_mock/hanimated.json new file mode 100644 index 000000000..564deebd9 --- /dev/null +++ b/test/fixtures/tesla_mock/hanimated.json @@ -0,0 +1 @@ +{"type":"Person","id":"https://bittube.video/accounts/hanimated.moh","following":"https://bittube.video/accounts/hanimated.moh/following","followers":"https://bittube.video/accounts/hanimated.moh/followers","playlists":"https://bittube.video/accounts/hanimated.moh/playlists","inbox":"https://bittube.video/accounts/hanimated.moh/inbox","outbox":"https://bittube.video/accounts/hanimated.moh/outbox","preferredUsername":"hanimated.moh","url":"https://bittube.video/accounts/hanimated.moh","name":"Nosat","endpoints":{"sharedInbox":"https://bittube.video/inbox"},"publicKey":{"id":"https://bittube.video/accounts/hanimated.moh#main-key","owner":"https://bittube.video/accounts/hanimated.moh","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwuoQT+4uyAboQcf/okCM\nFqUS/LuqFc2888OSKZFAz00Op/dyOB/pkr1+QLxbl8ZGiUWhmnmhNwmmd3tbhSsC\nvLv9Mz/YaWQPYLfRS/s/7iIxdniC4lo/YgicOrzcvetHmk1feOg5vb5/yc+bgUSm\nOk+L4azqXP9GmZyofzvufT65bUmzQRFXP19eL55YZWvZDaC81QAfRXsqtCqbehtF\nQNOjGhnl6a7Kfe8KprRDPV/3WvvFjftnNO2qenIIOFLLeznkQ0ELP6lyb9pvv/1C\n2/GRh2BwmgVlCTw1kTxLSdj80BFX5P8AudSiIx079lVkhamEhzsNLkMpQFqWAAlg\nrQIDAQAB\n-----END PUBLIC KEY-----"},"icon":{"type":"Image","mediaType":"image/jpeg","url":"https://bittube.video/lazy-static/avatars/84b8acc3-e48b-4642-a9f4-360a4499579b.jpg"},"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017","pt":"https://joinpeertube.org/ns#","sc":"http://schema.org#","Hashtag":"as:Hashtag","uuid":"sc:identifier","category":"sc:category","licence":"sc:license","subtitleLanguage":"sc:subtitleLanguage","sensitive":"as:sensitive","language":"sc:inLanguage","expires":"sc:expires","CacheFile":"pt:CacheFile","Infohash":"pt:Infohash","originallyPublishedAt":"sc:datePublished","views":{"@type":"sc:Number","@id":"pt:views"},"state":{"@type":"sc:Number","@id":"pt:state"},"size":{"@type":"sc:Number","@id":"pt:size"},"fps":{"@type":"sc:Number","@id":"pt:fps"},"startTimestamp":{"@type":"sc:Number","@id":"pt:startTimestamp"},"stopTimestamp":{"@type":"sc:Number","@id":"pt:stopTimestamp"},"position":{"@type":"sc:Number","@id":"pt:position"},"commentsEnabled":{"@type":"sc:Boolean","@id":"pt:commentsEnabled"},"downloadEnabled":{"@type":"sc:Boolean","@id":"pt:downloadEnabled"},"waitTranscoding":{"@type":"sc:Boolean","@id":"pt:waitTranscoding"},"support":{"@type":"sc:Text","@id":"pt:support"}},{"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"playlists":{"@id":"pt:playlists","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}],"summary":null} diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 20cb2b3d1..54dde0432 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -308,6 +308,22 @@ def get("https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" }} end + def get("https://bittube.video/accounts/hanimated.moh", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/hanimated.json") + }} + end + + def get("https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/bittube-video.json") + }} + end + def get("https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39", _, _, [ {"accept", "application/activity+json"} ]) do diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 2332029e5..de9663fa9 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1221,6 +1221,15 @@ test "it rejects activities without a valid ID" do :error = Transmogrifier.handle_incoming(data) end + test "it remaps mediaType of object" do + {:ok, object} = + Fetcher.fetch_object_from_id( + "https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b" + ) + + assert object.data["mediaType"] == "text/html" + end + test "it remaps video URLs as attachments if necessary" do {:ok, object} = Fetcher.fetch_object_from_id( -- cgit v1.2.3 From a050f3e015a6c5c8d38d535692d4da7a6b1e9c60 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 10 Apr 2020 14:42:52 +0300 Subject: fix for logger configuration through admin-fe --- lib/pleroma/config/transfer_task.ex | 100 ++++++++++++++--------- test/web/admin_api/admin_api_controller_test.exs | 17 ++-- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 936bc9ab1..3871e1cbb 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -54,10 +54,19 @@ def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do [:pleroma, nil, :prometheus] end + {logger, other} = + (Repo.all(ConfigDB) ++ deleted_settings) + |> Enum.map(&transform_and_merge/1) + |> Enum.split_with(fn {group, _, _, _} -> group in [:logger, :quack] end) + + logger + |> Enum.sort() + |> Enum.each(&configure/1) + started_applications = Application.started_applications() - (Repo.all(ConfigDB) ++ deleted_settings) - |> Enum.map(&merge_and_update/1) + other + |> Enum.map(&update/1) |> Enum.uniq() |> Enum.reject(&(&1 in reject_restart)) |> maybe_set_pleroma_last() @@ -81,51 +90,66 @@ defp maybe_set_pleroma_last(apps) 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 + defp transform_and_merge(%{group: group, key: key, value: value} = setting) do + group = ConfigDB.from_string(group) + key = ConfigDB.from_string(key) + value = ConfigDB.from_binary(value) - nil - end + default = Config.Holder.default_config(group, key) - defp group_for_restart(group, _, _, _) when group != :pleroma, do: group + merged = + cond do + Ecto.get_meta(setting, :state) == :deleted -> default + can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value) + true -> value + end - defp group_for_restart(group, key, value, _) do - if pleroma_need_restart?(group, key, value), do: group + {group, key, value, merged} end - defp merge_and_update(setting) do - try do - key = ConfigDB.from_string(setting.key) - group = ConfigDB.from_string(setting.group) + # change logger configuration in runtime, without restart + defp configure({:quack, key, _, merged}) do + Logger.configure_backend(Quack.Logger, [{key, merged}]) + :ok = update_env(:quack, key, merged) + end - default = Config.Holder.default_config(group, key) - value = ConfigDB.from_binary(setting.value) + defp configure({_, :backends, _, merged}) do + # removing current backends + Enum.each(Application.get_env(:logger, :backends), &Logger.remove_backend/1) - merged_value = - cond do - Ecto.get_meta(setting, :state) == :deleted -> default - can_be_merged?(default, value) -> ConfigDB.merge_group(group, key, default, value) - true -> value - end + Enum.each(merged, &Logger.add_backend/1) - :ok = update_env(group, key, merged_value) + :ok = update_env(:logger, :backends, merged) + end - group_for_restart(group, key, value, merged_value) + defp configure({group, key, _, merged}) do + merged = + if key == :console do + put_in(merged[:format], merged[:format] <> "\n") + else + merged + end + + backend = + if key == :ex_syslogger, + do: {ExSyslogger, :ex_syslogger}, + else: key + + Logger.configure_backend(backend, merged) + :ok = update_env(:logger, group, merged) + end + + defp update({group, key, value, merged}) do + try do + :ok = update_env(group, key, merged) + + if group != :pleroma or pleroma_need_restart?(group, key, value), do: group rescue error -> error_msg = - "updating env causes error, group: " <> - inspect(setting.group) <> - " key: " <> - inspect(setting.key) <> - " value: " <> - inspect(ConfigDB.from_binary(setting.value)) <> " error: " <> inspect(error) + "updating env causes error, group: #{inspect(group)}, key: #{inspect(key)}, value: #{ + inspect(value) + } error: #{inspect(error)}" Logger.warn(error_msg) @@ -133,6 +157,9 @@ defp merge_and_update(setting) do end end + defp update_env(group, key, nil), do: Application.delete_env(group, key) + defp update_env(group, key, value), do: Application.put_env(group, key, value) + @spec pleroma_need_restart?(atom(), atom(), any()) :: boolean() def pleroma_need_restart?(group, key, value) do group_and_key_need_reboot?(group, key) or group_and_subkey_need_reboot?(group, key, value) @@ -150,9 +177,6 @@ defp group_and_subkey_need_reboot?(group, key, value) do end) end - defp update_env(group, key, nil), do: Application.delete_env(group, key) - defp update_env(group, key, value), do: Application.put_env(group, key, value) - defp restart(_, :pleroma, env), do: Restarter.Pleroma.restart_after_boot(env) defp restart(started_applications, app, _) do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index f02f6ae7a..60ec895f5 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2273,13 +2273,17 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do value: :erlang.term_to_binary([]) ) + Pleroma.Config.TransferTask.load_and_update_env([], false) + + assert Application.get_env(:logger, :backends) == [] + conn = post(conn, "/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, key: config.key, - value: [":console", %{"tuple" => ["ExSyslogger", ":ex_syslogger"]}] + value: [":console"] } ] }) @@ -2290,8 +2294,7 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do "group" => ":logger", "key" => ":backends", "value" => [ - ":console", - %{"tuple" => ["ExSyslogger", ":ex_syslogger"]} + ":console" ], "db" => [":backends"] } @@ -2299,14 +2302,8 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do } assert Application.get_env(:logger, :backends) == [ - :console, - {ExSyslogger, :ex_syslogger} + :console ] - - capture_log(fn -> - require Logger - Logger.warn("Ooops...") - end) =~ "Ooops..." end test "saving full setting if value is not keyword", %{conn: conn} do -- cgit v1.2.3 From de34c4ee6b0487941bfcf02a48b45a578ae329af Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 13 Apr 2020 08:59:06 +0300 Subject: changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36897503a..e29be28d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support pagination in conversations API ## [unreleased-patch] +### Fixed +- Logger configuration through AdminFE ## [2.0.2] - 2020-04-08 ### Added -- cgit v1.2.3 From dc2637c18880160286f50505b1140a58fdfdf7d1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 13 Apr 2020 09:16:35 +0300 Subject: [#2342] Removed changelog entry for temporary configuration option. --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b41502a27..7d9b10b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. -- Configuration: `:extensions/:output_relationships_in_statuses_by_default` option (if `false`, disables the output of account/pleroma/relationship for statuses and notifications by default, breaking the compatibility with older PleromaFE versions).
    API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. -- cgit v1.2.3 From 99b0bc198921099816a5f809f11a7579b3993274 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 13 Apr 2020 13:24:31 +0300 Subject: [#1364] Resolved merge conflicts with `develop`. Refactoring. --- lib/pleroma/following_relationship.ex | 34 ++++++++++++++++++++++++++++++++-- lib/pleroma/notification.ex | 14 +------------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 219a64352..3a3082e72 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -10,11 +10,12 @@ defmodule Pleroma.FollowingRelationship do alias Ecto.Changeset alias FlakeId.Ecto.CompatType + alias Pleroma.FollowingRelationship.State alias Pleroma.Repo alias Pleroma.User schema "following_relationships" do - field(:state, Pleroma.FollowingRelationship.State, default: :follow_pending) + field(:state, State, default: :follow_pending) belongs_to(:follower, User, type: CompatType) belongs_to(:following, User, type: CompatType) @@ -22,6 +23,11 @@ defmodule Pleroma.FollowingRelationship do timestamps() end + @doc "Returns underlying integer code for state atom" + def state_int_code(state_atom), do: State.__enum_map__() |> Keyword.fetch!(state_atom) + + def accept_state_code, do: state_int_code(:follow_accept) + def changeset(%__MODULE__{} = following_relationship, attrs) do following_relationship |> cast(attrs, [:state]) @@ -86,7 +92,7 @@ def followers_query(%User{} = user) do __MODULE__ |> join(:inner, [r], u in User, on: r.follower_id == u.id) |> where([r], r.following_id == ^user.id) - |> where([r], r.state == "accept") + |> where([r], r.state == ^:follow_accept) end def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do @@ -198,6 +204,30 @@ def find(following_relationships, follower, following) do end) end + @doc """ + For a query with joined activity, + keeps rows where activity's actor is followed by user -or- is NOT domain-blocked by user. + """ + def keep_following_or_not_domain_blocked(query, user) do + where( + query, + [_, activity], + fragment( + # "(actor's domain NOT in domain_blocks) OR (actor IS in followed AP IDs)" + """ + NOT (substring(? from '.*://([^/]*)') = ANY(?)) OR + ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr + ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = ?) + """, + activity.actor, + ^user.domain_blocks, + activity.actor, + ^User.binary_id(user.id), + ^accept_state_code() + ) + ) + end + defp validate_not_self_relationship(%Changeset{} = changeset) do changeset |> validate_follower_id_following_id_inequality() diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index da05ff2e4..b76dd176c 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -88,19 +88,7 @@ defp exclude_blocked(query, user, opts) do query |> where([n, a], a.actor not in ^blocked_ap_ids) - |> where( - [n, a], - fragment( - # "NOT (actor's domain in domain_blocks) OR (actor is in followed AP IDs)" - "NOT (substring(? from '.*://([^/]*)') = ANY(?)) OR \ - ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr \ - ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = 'accept')", - a.actor, - ^user.domain_blocks, - a.actor, - ^User.binary_id(user.id) - ) - ) + |> FollowingRelationship.keep_following_or_not_domain_blocked(user) end defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do -- cgit v1.2.3 From 5c76afb06c731557b537f928296e0b5c259f8d5e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 13 Apr 2020 15:38:50 +0300 Subject: [#2342] Removed description.exs entry for temporary configuration option. --- config/description.exs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/config/description.exs b/config/description.exs index 1b450db58..642f1a3ce 100644 --- a/config/description.exs +++ b/config/description.exs @@ -121,22 +121,6 @@ } ] }, - %{ - group: :pleroma, - key: :extensions, - type: :group, - description: "Pleroma-specific extensions", - children: [ - %{ - key: :output_relationships_in_statuses_by_default, - type: :beeolean, - description: - "If `true`, outputs account/pleroma/relationship map for each rendered status / notification (for all clients). " <> - "If `false`, outputs the above only if `with_relationships` param is tru-ish " <> - "(that breaks compatibility with older PleromaFE versions which do not send this param but expect the output)." - } - ] - }, %{ group: :pleroma, key: Pleroma.Uploaders.Local, -- cgit v1.2.3 From b08ded6c2f5ee29c6efc8c67cfc2ce0a679f0c77 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 3 Apr 2020 22:45:08 +0400 Subject: Add spec for AccountController.create --- .../web/api_spec/operations/account_operation.ex | 68 +++++++ lib/pleroma/web/api_spec/render_error.ex | 27 +++ .../web/api_spec/schemas/account_create_request.ex | 56 ++++++ .../api_spec/schemas/account_create_response.ex | 29 +++ .../mastodon_api/controllers/account_controller.ex | 36 ++-- lib/pleroma/web/twitter_api/twitter_api.ex | 106 +++++----- test/web/api_spec/account_operation_test.exs | 48 +++++ .../controllers/account_controller_test.exs | 30 ++- test/web/twitter_api/twitter_api_test.exs | 222 ++++++++++----------- 9 files changed, 428 insertions(+), 194 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/account_operation.ex create mode 100644 lib/pleroma/web/api_spec/render_error.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_create_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_create_response.ex create mode 100644 test/web/api_spec/account_operation_test.exs diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex new file mode 100644 index 000000000..9085f1af1 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AccountOperation do + alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Helpers + + @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: ["accounts"], + summary: "Register an account", + description: + "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.", + operationId: "AccountController.create", + requestBody: Helpers.request_body("Parameters", AccountCreateRequest, required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", AccountCreateResponse) + } + } + end + + def verify_credentials_operation do + :ok + end + + def update_credentials_operation do + :ok + end + + def relationships_operation do + :ok + end + + def show_operation do + :ok + end + + def statuses_operation do + :ok + end + + def followers_operation do + :ok + end + + def following_operation, do: :ok + def lists_operation, do: :ok + def follow_operation, do: :ok + def unfollow_operation, do: :ok + def mute_operation, do: :ok + def unmute_operation, do: :ok + def block_operation, do: :ok + def unblock_operation, do: :ok + def follows_operation, do: :ok + def mutes_operation, do: :ok + def blocks_operation, do: :ok + def endorsements_operation, do: :ok +end diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex new file mode 100644 index 000000000..e063d115b --- /dev/null +++ b/lib/pleroma/web/api_spec/render_error.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.RenderError do + @behaviour Plug + + alias Plug.Conn + alias OpenApiSpex.Plug.JsonRenderError + + @impl Plug + def init(opts), do: opts + + @impl Plug + + def call(%{private: %{open_api_spex: %{operation_id: "AccountController.create"}}} = conn, _) do + conn + |> Conn.put_status(:bad_request) + |> Phoenix.Controller.json(%{"error" => "Missing parameters"}) + end + + def call(conn, reason) do + opts = JsonRenderError.init(reason) + + JsonRenderError.call(conn, opts) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/account_create_request.ex b/lib/pleroma/web/api_spec/schemas/account_create_request.ex new file mode 100644 index 000000000..398e2d613 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_create_request.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountCreateRequest", + description: "POST body for creating an account", + type: :object, + properties: %{ + reason: %Schema{ + type: :string, + description: + "Text that will be reviewed by moderators if registrations require manual approval" + }, + username: %Schema{type: :string, description: "The desired username for the account"}, + email: %Schema{ + type: :string, + description: + "The email address to be used for login. Required when `account_activation_required` is enabled.", + format: :email + }, + password: %Schema{type: :string, description: "The password to be used for login"}, + agreement: %Schema{ + type: :boolean, + description: + "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." + }, + locale: %Schema{ + type: :string, + description: "The language of the confirmation email that will be sent" + }, + # Pleroma-specific properties: + fullname: %Schema{type: :string, description: "Full name"}, + bio: %Schema{type: :string, description: "Bio", default: ""}, + captcha_solution: %Schema{type: :string, description: "Provider-specific captcha solution"}, + captcha_token: %Schema{type: :string, description: "Provider-specific captcha token"}, + captcha_answer_data: %Schema{type: :string, description: "Provider-specific captcha data"}, + token: %Schema{ + type: :string, + description: "Invite token required when the registrations aren't public" + } + }, + required: [:username, :password, :agreement], + example: %{ + "username" => "cofe", + "email" => "cofe@example.com", + "password" => "secret", + "agreement" => "true", + "bio" => "☕️" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_create_response.ex b/lib/pleroma/web/api_spec/schemas/account_create_response.ex new file mode 100644 index 000000000..f41a034c0 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_create_response.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountCreateResponse", + description: "Response schema for an account", + type: :object, + properties: %{ + token_type: %Schema{type: :string}, + access_token: %Schema{type: :string}, + scope: %Schema{type: :array, items: %Schema{type: :string}}, + created_at: %Schema{type: :integer} + }, + example: %{ + "JSON" => %{ + "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", + "created_at" => 1_585_918_714, + "scope" => ["read", "write", "follow", "push"], + "token_type" => "Bearer" + } + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7da1a11f6..eb082daf8 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -80,27 +80,33 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(RateLimiter, [name: :app_account_creation] when action == :create) plug(:assign_account_by_id when action in @needs_account) + plug( + OpenApiSpex.Plug.CastAndValidate, + [render_error: Pleroma.Web.ApiSpec.RenderError] when action == :create + ) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation + @doc "POST /api/v1/accounts" - def create( - %{assigns: %{app: app}} = conn, - %{"username" => nickname, "password" => _, "agreement" => true} = params - ) do + def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do params = params |> Map.take([ - "email", - "captcha_solution", - "captcha_token", - "captcha_answer_data", - "token", - "password" + :email, + :bio, + :captcha_solution, + :captcha_token, + :captcha_answer_data, + :token, + :password, + :fullname ]) - |> Map.put("nickname", nickname) - |> Map.put("fullname", params["fullname"] || nickname) - |> Map.put("bio", params["bio"] || "") - |> Map.put("confirm", params["password"]) + |> Map.put(:nickname, params.username) + |> Map.put(:fullname, params.fullname || params.username) + |> Map.put(:bio, params.bio || "") + |> Map.put(:confirm, params.password) with :ok <- validate_email_param(params), {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), @@ -124,7 +130,7 @@ def create(conn, _) do render_error(conn, :forbidden, "Invalid credentials") end - defp validate_email_param(%{"email" => _}), do: :ok + defp validate_email_param(%{:email => email}) when not is_nil(email), do: :ok defp validate_email_param(_) do case Pleroma.Config.get([:instance, :account_activation_required]) do diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index f9c0994da..37be48b5a 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -12,72 +12,56 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do require Pleroma.Constants def register_user(params, opts \\ []) do - token = params["token"] - - params = %{ - nickname: params["nickname"], - name: params["fullname"], - bio: User.parse_bio(params["bio"]), - email: params["email"], - password: params["password"], - password_confirmation: params["confirm"], - captcha_solution: params["captcha_solution"], - captcha_token: params["captcha_token"], - captcha_answer_data: params["captcha_answer_data"] - } - - captcha_enabled = Pleroma.Config.get([Pleroma.Captcha, :enabled]) - # true if captcha is disabled or enabled and valid, false otherwise - captcha_ok = - if not captcha_enabled do - :ok - else - Pleroma.Captcha.validate( - params[:captcha_token], - params[:captcha_solution], - params[:captcha_answer_data] - ) - end - - # Captcha invalid - if captcha_ok != :ok do - {:error, error} = captcha_ok - # I have no idea how this error handling works - {:error, %{error: Jason.encode!(%{captcha: [error]})}} - else - registration_process( - params, - %{ - registrations_open: Pleroma.Config.get([:instance, :registrations_open]), - token: token - }, - opts - ) + params = + params + |> Map.take([ + :nickname, + :password, + :captcha_solution, + :captcha_token, + :captcha_answer_data, + :token, + :email + ]) + |> Map.put(:bio, User.parse_bio(params[:bio] || "")) + |> Map.put(:name, params.fullname) + |> Map.put(:password_confirmation, params[:confirm]) + + case validate_captcha(params) do + :ok -> + if Pleroma.Config.get([:instance, :registrations_open]) do + create_user(params, opts) + else + create_user_with_invite(params, opts) + end + + {:error, error} -> + # I have no idea how this error handling works + {:error, %{error: Jason.encode!(%{captcha: [error]})}} end end - defp registration_process(params, %{registrations_open: true}, opts) do - create_user(params, opts) + defp validate_captcha(params) do + if Pleroma.Config.get([Pleroma.Captcha, :enabled]) do + Pleroma.Captcha.validate( + params.captcha_token, + params.captcha_solution, + params.captcha_answer_data + ) + else + :ok + end end - defp registration_process(params, %{token: token}, opts) do - invite = - unless is_nil(token) do - Repo.get_by(UserInviteToken, %{token: token}) - end - - valid_invite? = invite && UserInviteToken.valid_invite?(invite) - - case invite do - nil -> - {:error, "Invalid token"} - - invite when valid_invite? -> - UserInviteToken.update_usage!(invite) - create_user(params, opts) - - _ -> - {:error, "Expired token"} + defp create_user_with_invite(params, opts) do + with %{token: token} when is_binary(token) <- params, + %UserInviteToken{} = invite <- Repo.get_by(UserInviteToken, %{token: token}), + true <- UserInviteToken.valid_invite?(invite) do + UserInviteToken.update_usage!(invite) + create_user(params, opts) + else + nil -> {:error, "Invalid token"} + _ -> {:error, "Expired token"} end end diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs new file mode 100644 index 000000000..4f8d04698 --- /dev/null +++ b/test/web/api_spec/account_operation_test.exs @@ -0,0 +1,48 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.AccountOperationTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + + import OpenApiSpex.TestAssertions + import Pleroma.Factory + + test "AccountCreateRequest example matches schema" do + api_spec = ApiSpec.spec() + schema = AccountCreateRequest.schema() + assert_schema(schema.example, "AccountCreateRequest", api_spec) + end + + test "AccountCreateResponse example matches schema" do + api_spec = ApiSpec.spec() + schema = AccountCreateResponse.schema() + assert_schema(schema.example, "AccountCreateResponse", api_spec) + end + + test "AccountController produces a AccountCreateResponse", %{conn: conn} do + api_spec = ApiSpec.spec() + app_token = insert(:oauth_token, user: nil) + + json = + conn + |> put_req_header("authorization", "Bearer " <> app_token.token) + |> put_req_header("content-type", "application/json") + |> post( + "/api/v1/accounts", + %{ + username: "foo", + email: "bar@example.org", + password: "qwerty", + agreement: true + } + ) + |> json_response(200) + + assert_schema(json, "AccountCreateResponse", 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 a450a732c..6fe46af3c 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -830,6 +830,7 @@ test "Account registration via Application", %{conn: conn} do conn = build_conn() + |> put_req_header("content-type", "multipart/form-data") |> put_req_header("authorization", "Bearer " <> token) |> post("/api/v1/accounts", %{ username: "lain", @@ -858,11 +859,12 @@ test "returns error when user already registred", %{conn: conn, valid_params: va _user = insert(:user, email: "lain@example.org") app_token = insert(:oauth_token, user: nil) - conn = + res = conn |> put_req_header("authorization", "Bearer " <> app_token.token) + |> put_req_header("content-type", "application/json") + |> post("/api/v1/accounts", valid_params) - res = post(conn, "/api/v1/accounts", valid_params) assert json_response(res, 400) == %{"error" => "{\"email\":[\"has already been taken\"]}"} end @@ -872,7 +874,10 @@ test "returns bad_request if missing required params", %{ } do app_token = insert(:oauth_token, user: nil) - conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token) + conn = + conn + |> put_req_header("authorization", "Bearer " <> app_token.token) + |> put_req_header("content-type", "application/json") res = post(conn, "/api/v1/accounts", valid_params) assert json_response(res, 200) @@ -897,7 +902,11 @@ test "returns bad_request if missing email params when :account_activation_requi Pleroma.Config.put([:instance, :account_activation_required], true) app_token = insert(:oauth_token, user: nil) - conn = put_req_header(conn, "authorization", "Bearer " <> app_token.token) + + conn = + conn + |> put_req_header("authorization", "Bearer " <> app_token.token) + |> put_req_header("content-type", "application/json") res = conn @@ -920,6 +929,7 @@ test "allow registration without an email", %{conn: conn, valid_params: valid_pa res = conn + |> put_req_header("content-type", "application/json") |> Map.put(:remote_ip, {127, 0, 0, 7}) |> post("/api/v1/accounts", Map.delete(valid_params, :email)) @@ -932,6 +942,7 @@ test "allow registration with an empty email", %{conn: conn, valid_params: valid res = conn + |> put_req_header("content-type", "application/json") |> Map.put(:remote_ip, {127, 0, 0, 8}) |> post("/api/v1/accounts", Map.put(valid_params, :email, "")) @@ -939,9 +950,12 @@ test "allow registration with an empty email", %{conn: conn, valid_params: valid end test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_params} do - conn = put_req_header(conn, "authorization", "Bearer " <> "invalid-token") + res = + conn + |> put_req_header("authorization", "Bearer " <> "invalid-token") + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v1/accounts", valid_params) - res = post(conn, "/api/v1/accounts", valid_params) assert json_response(res, 403) == %{"error" => "Invalid credentials"} end end @@ -956,10 +970,12 @@ test "respects rate limit setting", %{conn: conn} do conn |> put_req_header("authorization", "Bearer " <> app_token.token) |> Map.put(:remote_ip, {15, 15, 15, 15}) + |> put_req_header("content-type", "multipart/form-data") for i <- 1..2 do conn = - post(conn, "/api/v1/accounts", %{ + conn + |> post("/api/v1/accounts", %{ username: "#{i}lain", email: "#{i}lain@example.org", password: "PlzDontHackLain", diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index f6e13b661..7926a0757 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -18,11 +18,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do test "it registers a new user and returns the user." do data = %{ - "nickname" => "lain", - "email" => "lain@wired.jp", - "fullname" => "lain iwakura", - "password" => "bear", - "confirm" => "bear" + :nickname => "lain", + :email => "lain@wired.jp", + :fullname => "lain iwakura", + :password => "bear", + :confirm => "bear" } {:ok, user} = TwitterAPI.register_user(data) @@ -35,12 +35,12 @@ test "it registers a new user and returns the user." do test "it registers a new user with empty string in bio and returns the user." do data = %{ - "nickname" => "lain", - "email" => "lain@wired.jp", - "fullname" => "lain iwakura", - "bio" => "", - "password" => "bear", - "confirm" => "bear" + :nickname => "lain", + :email => "lain@wired.jp", + :fullname => "lain iwakura", + :bio => "", + :password => "bear", + :confirm => "bear" } {:ok, user} = TwitterAPI.register_user(data) @@ -60,12 +60,12 @@ test "it sends confirmation email if :account_activation_required is specified i end data = %{ - "nickname" => "lain", - "email" => "lain@wired.jp", - "fullname" => "lain iwakura", - "bio" => "", - "password" => "bear", - "confirm" => "bear" + :nickname => "lain", + :email => "lain@wired.jp", + :fullname => "lain iwakura", + :bio => "", + :password => "bear", + :confirm => "bear" } {:ok, user} = TwitterAPI.register_user(data) @@ -87,23 +87,23 @@ test "it sends confirmation email if :account_activation_required is specified i test "it registers a new user and parses mentions in the bio" do data1 = %{ - "nickname" => "john", - "email" => "john@gmail.com", - "fullname" => "John Doe", - "bio" => "test", - "password" => "bear", - "confirm" => "bear" + :nickname => "john", + :email => "john@gmail.com", + :fullname => "John Doe", + :bio => "test", + :password => "bear", + :confirm => "bear" } {:ok, user1} = TwitterAPI.register_user(data1) data2 = %{ - "nickname" => "lain", - "email" => "lain@wired.jp", - "fullname" => "lain iwakura", - "bio" => "@john test", - "password" => "bear", - "confirm" => "bear" + :nickname => "lain", + :email => "lain@wired.jp", + :fullname => "lain iwakura", + :bio => "@john test", + :password => "bear", + :confirm => "bear" } {:ok, user2} = TwitterAPI.register_user(data2) @@ -123,13 +123,13 @@ test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite() data = %{ - "nickname" => "vinny", - "email" => "pasta@pizza.vs", - "fullname" => "Vinny Vinesauce", - "bio" => "streamer", - "password" => "hiptofbees", - "confirm" => "hiptofbees", - "token" => invite.token + :nickname => "vinny", + :email => "pasta@pizza.vs", + :fullname => "Vinny Vinesauce", + :bio => "streamer", + :password => "hiptofbees", + :confirm => "hiptofbees", + :token => invite.token } {:ok, user} = TwitterAPI.register_user(data) @@ -145,13 +145,13 @@ test "returns user on success" do test "returns error on invalid token" do data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => "DudeLetMeInImAFairy" + :nickname => "GrimReaper", + :email => "death@reapers.afterlife", + :fullname => "Reaper Grim", + :bio => "Your time has come", + :password => "scythe", + :confirm => "scythe", + :token => "DudeLetMeInImAFairy" } {:error, msg} = TwitterAPI.register_user(data) @@ -165,13 +165,13 @@ test "returns error on expired token" do UserInviteToken.update_invite!(invite, used: true) data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => invite.token + :nickname => "GrimReaper", + :email => "death@reapers.afterlife", + :fullname => "Reaper Grim", + :bio => "Your time has come", + :password => "scythe", + :confirm => "scythe", + :token => invite.token } {:error, msg} = TwitterAPI.register_user(data) @@ -186,16 +186,16 @@ test "returns error on expired token" do setup do data = %{ - "nickname" => "vinny", - "email" => "pasta@pizza.vs", - "fullname" => "Vinny Vinesauce", - "bio" => "streamer", - "password" => "hiptofbees", - "confirm" => "hiptofbees" + :nickname => "vinny", + :email => "pasta@pizza.vs", + :fullname => "Vinny Vinesauce", + :bio => "streamer", + :password => "hiptofbees", + :confirm => "hiptofbees" } check_fn = fn invite -> - data = Map.put(data, "token", invite.token) + data = Map.put(data, :token, invite.token) {:ok, user} = TwitterAPI.register_user(data) fetched_user = User.get_cached_by_nickname("vinny") @@ -250,13 +250,13 @@ test "returns user on success, after him registration fails" do UserInviteToken.update_invite!(invite, uses: 99) data = %{ - "nickname" => "vinny", - "email" => "pasta@pizza.vs", - "fullname" => "Vinny Vinesauce", - "bio" => "streamer", - "password" => "hiptofbees", - "confirm" => "hiptofbees", - "token" => invite.token + :nickname => "vinny", + :email => "pasta@pizza.vs", + :fullname => "Vinny Vinesauce", + :bio => "streamer", + :password => "hiptofbees", + :confirm => "hiptofbees", + :token => invite.token } {:ok, user} = TwitterAPI.register_user(data) @@ -269,13 +269,13 @@ test "returns user on success, after him registration fails" do AccountView.render("show.json", %{user: fetched_user}) data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => invite.token + :nickname => "GrimReaper", + :email => "death@reapers.afterlife", + :fullname => "Reaper Grim", + :bio => "Your time has come", + :password => "scythe", + :confirm => "scythe", + :token => invite.token } {:error, msg} = TwitterAPI.register_user(data) @@ -292,13 +292,13 @@ test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) data = %{ - "nickname" => "vinny", - "email" => "pasta@pizza.vs", - "fullname" => "Vinny Vinesauce", - "bio" => "streamer", - "password" => "hiptofbees", - "confirm" => "hiptofbees", - "token" => invite.token + :nickname => "vinny", + :email => "pasta@pizza.vs", + :fullname => "Vinny Vinesauce", + :bio => "streamer", + :password => "hiptofbees", + :confirm => "hiptofbees", + :token => invite.token } {:ok, user} = TwitterAPI.register_user(data) @@ -317,13 +317,13 @@ test "error after max uses" do UserInviteToken.update_invite!(invite, uses: 99) data = %{ - "nickname" => "vinny", - "email" => "pasta@pizza.vs", - "fullname" => "Vinny Vinesauce", - "bio" => "streamer", - "password" => "hiptofbees", - "confirm" => "hiptofbees", - "token" => invite.token + :nickname => "vinny", + :email => "pasta@pizza.vs", + :fullname => "Vinny Vinesauce", + :bio => "streamer", + :password => "hiptofbees", + :confirm => "hiptofbees", + :token => invite.token } {:ok, user} = TwitterAPI.register_user(data) @@ -335,13 +335,13 @@ test "error after max uses" do AccountView.render("show.json", %{user: fetched_user}) data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => invite.token + :nickname => "GrimReaper", + :email => "death@reapers.afterlife", + :fullname => "Reaper Grim", + :bio => "Your time has come", + :password => "scythe", + :confirm => "scythe", + :token => invite.token } {:error, msg} = TwitterAPI.register_user(data) @@ -355,13 +355,13 @@ test "returns error on overdue date" do UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100}) data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => invite.token + :nickname => "GrimReaper", + :email => "death@reapers.afterlife", + :fullname => "Reaper Grim", + :bio => "Your time has come", + :password => "scythe", + :confirm => "scythe", + :token => invite.token } {:error, msg} = TwitterAPI.register_user(data) @@ -377,13 +377,13 @@ test "returns error on with overdue date and after max" do UserInviteToken.update_invite!(invite, uses: 100) data = %{ - "nickname" => "GrimReaper", - "email" => "death@reapers.afterlife", - "fullname" => "Reaper Grim", - "bio" => "Your time has come", - "password" => "scythe", - "confirm" => "scythe", - "token" => invite.token + :nickname => "GrimReaper", + :email => "death@reapers.afterlife", + :fullname => "Reaper Grim", + :bio => "Your time has come", + :password => "scythe", + :confirm => "scythe", + :token => invite.token } {:error, msg} = TwitterAPI.register_user(data) @@ -395,11 +395,11 @@ test "returns error on with overdue date and after max" do test "it returns the error on registration problems" do data = %{ - "nickname" => "lain", - "email" => "lain@wired.jp", - "fullname" => "lain iwakura", - "bio" => "close the world.", - "password" => "bear" + :nickname => "lain", + :email => "lain@wired.jp", + :fullname => "lain iwakura", + :bio => "close the world.", + :password => "bear" } {:error, error_object} = TwitterAPI.register_user(data) -- cgit v1.2.3 From f80116125f928de36c93627bbdf5f6578396f53b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 6 Apr 2020 00:15:37 +0400 Subject: Add spec for AccountController.verify_credentials --- lib/pleroma/web/api_spec.ex | 2 +- .../web/api_spec/operations/account_operation.ex | 14 +- .../web/api_spec/operations/app_operation.ex | 6 +- lib/pleroma/web/api_spec/render_error.ex | 2 +- lib/pleroma/web/api_spec/schemas/account.ex | 181 +++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/account_emoji.ex | 31 ++++ lib/pleroma/web/api_spec/schemas/account_field.ex | 28 ++++ test/web/api_spec/account_operation_test.exs | 7 + 8 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/account.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_emoji.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_field.ex diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 41e48a085..c85fe30d1 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -31,7 +31,7 @@ def spec do password: %OpenApiSpex.OAuthFlow{ authorizationUrl: "/oauth/authorize", tokenUrl: "/oauth/token", - scopes: %{"read" => "read"} + scopes: %{"read" => "read", "write" => "write"} } } } diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9085f1af1..3d2270c29 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse - alias Pleroma.Web.ApiSpec.Helpers @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -30,7 +31,16 @@ def create_operation do end def verify_credentials_operation do - :ok + %Operation{ + tags: ["accounts"], + description: "Test to make sure that the user token works.", + summary: "Verify account credentials", + operationId: "AccountController.verify_credentials", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => Operation.response("Account", "application/json", Account) + } + } end def update_credentials_operation do diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex index 26d8dbd42..935215c64 100644 --- a/lib/pleroma/web/api_spec/operations/app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -51,11 +51,7 @@ def verify_credentials_operation do summary: "Verify your app works", description: "Confirm that the app's OAuth2 credentials work.", operationId: "AppController.verify_credentials", - security: [ - %{ - "oAuth" => ["read"] - } - ], + security: [%{"oAuth" => ["read"]}], responses: %{ 200 => Operation.response("App", "application/json", %Schema{ diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex index e063d115b..9184c43b6 100644 --- a/lib/pleroma/web/api_spec/render_error.ex +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ApiSpec.RenderError do @behaviour Plug - alias Plug.Conn alias OpenApiSpex.Plug.JsonRenderError + alias Plug.Conn @impl Plug def init(opts), do: opts diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex new file mode 100644 index 000000000..59c4ac4a4 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -0,0 +1,181 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Account do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + alias Pleroma.Web.ApiSpec.Schemas.AccountField + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Account", + description: "Response schema for an account", + type: :object, + properties: %{ + acct: %Schema{type: :string}, + avatar_static: %Schema{type: :string}, + avatar: %Schema{type: :string}, + bot: %Schema{type: :boolean}, + created_at: %Schema{type: :string, format: "date-time"}, + display_name: %Schema{type: :string}, + emojis: %Schema{type: :array, items: AccountEmoji}, + fields: %Schema{type: :array, items: AccountField}, + follow_requests_count: %Schema{type: :integer}, + followers_count: %Schema{type: :integer}, + following_count: %Schema{type: :integer}, + header_static: %Schema{type: :string}, + header: %Schema{type: :string}, + id: %Schema{type: :string}, + locked: %Schema{type: :boolean}, + note: %Schema{type: :string}, + statuses_count: %Schema{type: :integer}, + url: %Schema{type: :string}, + username: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + allow_following_move: %Schema{type: :boolean}, + background_image: %Schema{type: :boolean, nullable: true}, + chat_token: %Schema{type: :string}, + confirmation_pending: %Schema{type: :boolean}, + hide_favorites: %Schema{type: :boolean}, + hide_followers_count: %Schema{type: :boolean}, + hide_followers: %Schema{type: :boolean}, + hide_follows_count: %Schema{type: :boolean}, + hide_follows: %Schema{type: :boolean}, + is_admin: %Schema{type: :boolean}, + is_moderator: %Schema{type: :boolean}, + skip_thread_containment: %Schema{type: :boolean}, + tags: %Schema{type: :array, items: %Schema{type: :string}}, + unread_conversation_count: %Schema{type: :integer}, + notification_settings: %Schema{ + type: :object, + properties: %{ + followers: %Schema{type: :boolean}, + follows: %Schema{type: :boolean}, + non_followers: %Schema{type: :boolean}, + non_follows: %Schema{type: :boolean}, + privacy_option: %Schema{type: :boolean} + } + }, + relationship: %Schema{ + type: :object, + properties: %{ + blocked_by: %Schema{type: :boolean}, + blocking: %Schema{type: :boolean}, + domain_blocking: %Schema{type: :boolean}, + endorsed: %Schema{type: :boolean}, + followed_by: %Schema{type: :boolean}, + following: %Schema{type: :boolean}, + id: %Schema{type: :string}, + muting: %Schema{type: :boolean}, + muting_notifications: %Schema{type: :boolean}, + requested: %Schema{type: :boolean}, + showing_reblogs: %Schema{type: :boolean}, + subscribing: %Schema{type: :boolean} + } + }, + settings_store: %Schema{ + type: :object + } + } + }, + source: %Schema{ + type: :object, + properties: %{ + fields: %Schema{type: :array, items: AccountField}, + note: %Schema{type: :string}, + privacy: %Schema{type: :string}, + sensitive: %Schema{type: :boolean}, + pleroma: %Schema{ + type: :object, + properties: %{ + actor_type: %Schema{type: :string}, + discoverable: %Schema{type: :boolean}, + no_rich_text: %Schema{type: :boolean}, + show_role: %Schema{type: :boolean} + } + } + } + } + }, + example: %{ + "JSON" => %{ + "acct" => "foobar", + "avatar" => "https://mypleroma.com/images/avi.png", + "avatar_static" => "https://mypleroma.com/images/avi.png", + "bot" => false, + "created_at" => "2020-03-24T13:05:58.000Z", + "display_name" => "foobar", + "emojis" => [], + "fields" => [], + "follow_requests_count" => 0, + "followers_count" => 0, + "following_count" => 1, + "header" => "https://mypleroma.com/images/banner.png", + "header_static" => "https://mypleroma.com/images/banner.png", + "id" => "9tKi3esbG7OQgZ2920", + "locked" => false, + "note" => "cofe", + "pleroma" => %{ + "allow_following_move" => true, + "background_image" => nil, + "confirmation_pending" => true, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "skip_thread_containment" => false, + "chat_token" => + "SFMyNTY.g3QAAAACZAAEZGF0YW0AAAASOXRLaTNlc2JHN09RZ1oyOTIwZAAGc2lnbmVkbgYARNplS3EB.Mb_Iaqew2bN1I1o79B_iP7encmVCpTKC4OtHZRxdjKc", + "unread_conversation_count" => 0, + "tags" => [], + "notification_settings" => %{ + "followers" => true, + "follows" => true, + "non_followers" => true, + "non_follows" => true, + "privacy_option" => false + }, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + }, + "settings_store" => %{ + "pleroma-fe" => %{} + } + }, + "source" => %{ + "fields" => [], + "note" => "foobar", + "pleroma" => %{ + "actor_type" => "Person", + "discoverable" => false, + "no_rich_text" => false, + "show_role" => true + }, + "privacy" => "public", + "sensitive" => false + }, + "statuses_count" => 0, + "url" => "https://mypleroma.com/users/foobar", + "username" => "foobar" + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_emoji.ex b/lib/pleroma/web/api_spec/schemas/account_emoji.ex new file mode 100644 index 000000000..403b13b15 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_emoji.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountEmoji do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountEmoji", + description: "Response schema for account custom fields", + type: :object, + properties: %{ + shortcode: %Schema{type: :string}, + url: %Schema{type: :string}, + static_url: %Schema{type: :string}, + visible_in_picker: %Schema{type: :boolean} + }, + example: %{ + "JSON" => %{ + "shortcode" => "fatyoshi", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", + "visible_in_picker" => true + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_field.ex b/lib/pleroma/web/api_spec/schemas/account_field.ex new file mode 100644 index 000000000..8906d812d --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_field.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountField do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountField", + description: "Response schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string}, + verified_at: %Schema{type: :string, format: "date-time", nullable: true} + }, + example: %{ + "JSON" => %{ + "name" => "Website", + "value" => + "https://pleroma.com", + "verified_at" => "2019-08-29T04:14:55.571+00:00" + } + } + }) +end diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs index 4f8d04698..37501b8cc 100644 --- a/test/web/api_spec/account_operation_test.exs +++ b/test/web/api_spec/account_operation_test.exs @@ -6,12 +6,19 @@ defmodule Pleroma.Web.ApiSpec.AccountOperationTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse import OpenApiSpex.TestAssertions import Pleroma.Factory + test "Account example matches schema" do + api_spec = ApiSpec.spec() + schema = Account.schema() + assert_schema(schema.example, "Account", api_spec) + end + test "AccountCreateRequest example matches schema" do api_spec = ApiSpec.spec() schema = AccountCreateRequest.schema() -- cgit v1.2.3 From 260cbddc943e53a85762e56852de65d2b900cc04 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 7 Apr 2020 14:53:12 +0400 Subject: Add spec for AccountController.update_credentials --- lib/pleroma/web/api_spec/helpers.ex | 2 +- .../web/api_spec/operations/account_operation.ex | 14 ++- .../api_spec/schemas/account_field_attribute.ex | 26 +++++ .../schemas/account_update_credentials_request.ex | 123 +++++++++++++++++++++ .../mastodon_api/controllers/account_controller.ex | 41 ++++--- test/support/conn_case.ex | 5 + test/web/api_spec/account_operation_test.exs | 32 ++++++ .../account_controller/update_credentials_test.exs | 2 + 8 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/account_field_attribute.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index 35cf4c0d8..7348dcbee 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ApiSpec.Helpers do def request_body(description, schema_ref, opts \\ []) do - media_types = ["application/json", "multipart/form-data"] + media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] content = media_types diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 3d2270c29..d7b56cc2b 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -44,7 +45,18 @@ def verify_credentials_operation do end def update_credentials_operation do - :ok + %Operation{ + tags: ["accounts"], + summary: "Update account credentials", + description: "Update the user's display and preferences.", + operationId: "AccountController.update_credentials", + security: [%{"oAuth" => ["write:accounts"]}], + requestBody: + Helpers.request_body("Parameters", AccountUpdateCredentialsRequest, required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", Account) + } + } end def relationships_operation do diff --git a/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex b/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex new file mode 100644 index 000000000..fbbdf95f5 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountAttributeField do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountAttributeField", + description: "Request schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string} + }, + required: [:name, :value], + example: %{ + "JSON" => %{ + "name" => "Website", + "value" => "https://pleroma.com" + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex new file mode 100644 index 000000000..a50bce5ed --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountAttributeField + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountUpdateCredentialsRequest", + description: "POST body for creating an account", + type: :object, + properties: %{ + bot: %Schema{ + type: :boolean, + description: "Whether the account has a bot flag." + }, + display_name: %Schema{ + type: :string, + description: "The display name to use for the profile." + }, + note: %Schema{type: :string, description: "The account bio."}, + avatar: %Schema{ + type: :string, + description: "Avatar image encoded using multipart/form-data", + format: :binary + }, + header: %Schema{ + type: :string, + description: "Header image encoded using multipart/form-data", + format: :binary + }, + locked: %Schema{ + type: :boolean, + description: "Whether manual approval of follow requests is required." + }, + fields_attributes: %Schema{ + oneOf: [%Schema{type: :array, items: AccountAttributeField}, %Schema{type: :object}] + }, + # NOTE: `source` field is not supported + # + # source: %Schema{ + # type: :object, + # properties: %{ + # privacy: %Schema{type: :string}, + # sensitive: %Schema{type: :boolean}, + # language: %Schema{type: :string} + # } + # }, + + # Pleroma-specific fields + no_rich_text: %Schema{ + type: :boolean, + description: "html tags are stripped from all statuses requested from the API" + }, + hide_followers: %Schema{type: :boolean, description: "user's followers will be hidden"}, + hide_follows: %Schema{type: :boolean, description: "user's follows will be hidden"}, + hide_followers_count: %Schema{ + type: :boolean, + description: "user's follower count will be hidden" + }, + hide_follows_count: %Schema{ + type: :boolean, + description: "user's follow count will be hidden" + }, + hide_favorites: %Schema{ + type: :boolean, + description: "user's favorites timeline will be hidden" + }, + show_role: %Schema{ + type: :boolean, + description: "user's role (e.g admin, moderator) will be exposed to anyone in the + API" + }, + default_scope: %Schema{ + type: :string, + description: "The scope returned under privacy key in Source subentity" + }, + pleroma_settings_store: %Schema{ + type: :object, + description: "Opaque user settings to be saved on the backend." + }, + skip_thread_containment: %Schema{ + type: :boolean, + description: "Skip filtering out broken threads" + }, + allow_following_move: %Schema{ + type: :boolean, + description: "Allows automatically follow moved following accounts" + }, + pleroma_background_image: %Schema{ + type: :string, + description: "Sets the background image of the user.", + format: :binary + }, + discoverable: %Schema{ + type: :boolean, + description: "Discovery of this account in search results and other services is allowed." + }, + actor_type: %Schema{type: :string, description: "the type of this account."} + }, + example: %{ + bot: false, + display_name: "cofe", + note: "foobar", + fields_attributes: [%{name: "foo", value: "bar"}], + no_rich_text: false, + hide_followers: true, + hide_follows: false, + hide_followers_count: false, + hide_follows_count: false, + hide_favorites: false, + show_role: false, + default_scope: "private", + pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, + skip_thread_containment: false, + allow_following_move: false, + discoverable: false, + actor_type: "Person" + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index eb082daf8..9c986b3b2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -82,7 +82,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OpenApiSpex.Plug.CastAndValidate, - [render_error: Pleroma.Web.ApiSpec.RenderError] when action == :create + [render_error: Pleroma.Web.ApiSpec.RenderError] + when action in [:create, :verify_credentials, :update_credentials] ) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @@ -152,9 +153,15 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do end @doc "PATCH /api/v1/accounts/update_credentials" - def update_credentials(%{assigns: %{user: original_user}} = conn, params) do + def update_credentials(%{assigns: %{user: original_user}, body_params: params} = conn, _params) do user = original_user + params = + params + |> Map.from_struct() + |> Enum.filter(fn {_, value} -> not is_nil(value) end) + |> Enum.into(%{}) + user_params = [ :no_rich_text, @@ -170,22 +177,22 @@ def update_credentials(%{assigns: %{user: original_user}} = conn, params) do :discoverable ] |> Enum.reduce(%{}, fn key, acc -> - add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) + add_if_present(acc, params, key, key, &{:ok, truthy_param?(&1)}) end) - |> add_if_present(params, "display_name", :name) - |> 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, :display_name, :name) + |> 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", + :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) + |> 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) changeset = User.update_changeset(user, user_params) @@ -200,7 +207,7 @@ def update_credentials(%{assigns: %{user: original_user}} = conn, params) do defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do with true <- Map.has_key?(params, params_field), - {:ok, new_value} <- value_function.(params[params_field]) do + {:ok, new_value} <- value_function.(Map.get(params, params_field)) do Map.put(map, map_field, new_value) else _ -> map @@ -211,7 +218,13 @@ defp normalize_fields_attributes(fields) do if Enum.all?(fields, &is_tuple/1) do Enum.map(fields, fn {_, v} -> v end) else - fields + Enum.map(fields, fn + %Pleroma.Web.ApiSpec.Schemas.AccountAttributeField{} = field -> + %{"name" => field.name, "value" => field.value} + + field -> + field + end) end end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 064874201..36ce372c2 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -51,6 +51,11 @@ defp oauth_access(scopes, opts \\ []) do %{user: user, token: token, conn: conn} end + defp request_content_type(%{conn: conn}) do + conn = put_req_header(conn, "content-type", "multipart/form-data") + [conn: conn] + end + defp ensure_federating_or_authenticated(conn, url, user) do initial_setting = Config.get([:instance, :federating]) on_exit(fn -> Config.put([:instance, :federating], initial_setting) end) diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs index 37501b8cc..a54059074 100644 --- a/test/web/api_spec/account_operation_test.exs +++ b/test/web/api_spec/account_operation_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperationTest do alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest import OpenApiSpex.TestAssertions import Pleroma.Factory @@ -31,6 +32,12 @@ test "AccountCreateResponse example matches schema" do assert_schema(schema.example, "AccountCreateResponse", api_spec) end + test "AccountUpdateCredentialsRequest example matches schema" do + api_spec = ApiSpec.spec() + schema = AccountUpdateCredentialsRequest.schema() + assert_schema(schema.example, "AccountUpdateCredentialsRequest", api_spec) + end + test "AccountController produces a AccountCreateResponse", %{conn: conn} do api_spec = ApiSpec.spec() app_token = insert(:oauth_token, user: nil) @@ -52,4 +59,29 @@ test "AccountController produces a AccountCreateResponse", %{conn: conn} do assert_schema(json, "AccountCreateResponse", api_spec) end + + test "AccountUpdateCredentialsRequest produces an Account", %{conn: conn} do + api_spec = ApiSpec.spec() + token = insert(:oauth_token, scopes: ["read", "write"]) + + json = + conn + |> put_req_header("authorization", "Bearer " <> token.token) + |> put_req_header("content-type", "application/json") + |> patch( + "/api/v1/accounts/update_credentials", + %{ + hide_followers_count: "true", + hide_follows_count: "true", + skip_thread_containment: "true", + hide_follows: "true", + pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, + note: "foobar", + fields_attributes: [%{name: "foo", value: "bar"}] + } + ) + |> json_response(200) + + assert_schema(json, "Account", api_spec) + 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 2d256f63c..0e890a980 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 @@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do describe "updating credentials" do setup do: oauth_access(["write:accounts"]) + setup :request_content_type test "sets user settings in a generic way", %{conn: conn} do res_conn = @@ -237,6 +238,7 @@ test "requires 'write:accounts' permission" do for token <- [token1, token2] do conn = build_conn() + |> put_req_header("content-type", "multipart/form-data") |> put_req_header("authorization", "Bearer #{token.token}") |> patch("/api/v1/accounts/update_credentials", %{}) -- cgit v1.2.3 From ab400b2ddb205271b0a2680c45db18844f59a27d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 7 Apr 2020 16:18:23 +0400 Subject: Add specs for ActorType and VisibilityScope --- lib/pleroma/web/api_spec/schemas/account.ex | 6 ++++-- .../api_spec/schemas/account_update_credentials_request.ex | 9 ++++----- lib/pleroma/web/api_spec/schemas/actor_type.ex | 13 +++++++++++++ lib/pleroma/web/api_spec/schemas/visibility_scope.ex | 14 ++++++++++++++ .../account_controller/update_credentials_test.exs | 4 ++-- 5 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/actor_type.ex create mode 100644 lib/pleroma/web/api_spec/schemas/visibility_scope.ex diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 59c4ac4a4..beb093182 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji alias Pleroma.Web.ApiSpec.Schemas.AccountField + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope require OpenApiSpex @@ -87,12 +89,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do properties: %{ fields: %Schema{type: :array, items: AccountField}, note: %Schema{type: :string}, - privacy: %Schema{type: :string}, + privacy: VisibilityScope, sensitive: %Schema{type: :boolean}, pleroma: %Schema{ type: :object, properties: %{ - actor_type: %Schema{type: :string}, + actor_type: ActorType, discoverable: %Schema{type: :boolean}, no_rich_text: %Schema{type: :boolean}, show_role: %Schema{type: :boolean} diff --git a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex index a50bce5ed..6ab48193e 100644 --- a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex +++ b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.AccountAttributeField + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope require OpenApiSpex OpenApiSpex.schema(%{ @@ -73,10 +75,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest do description: "user's role (e.g admin, moderator) will be exposed to anyone in the API" }, - default_scope: %Schema{ - type: :string, - description: "The scope returned under privacy key in Source subentity" - }, + default_scope: VisibilityScope, pleroma_settings_store: %Schema{ type: :object, description: "Opaque user settings to be saved on the backend." @@ -98,7 +97,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest do type: :boolean, description: "Discovery of this account in search results and other services is allowed." }, - actor_type: %Schema{type: :string, description: "the type of this account."} + actor_type: ActorType }, example: %{ bot: false, diff --git a/lib/pleroma/web/api_spec/schemas/actor_type.ex b/lib/pleroma/web/api_spec/schemas/actor_type.ex new file mode 100644 index 000000000..ac9b46678 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/actor_type.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ActorType do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ActorType", + type: :string, + enum: ["Application", "Group", "Organization", "Person", "Service"] + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex new file mode 100644 index 000000000..8c81a4d73 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.VisibilityScope do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "VisibilityScope", + description: "Status visibility", + type: :string, + enum: ["public", "unlisted", "private", "direct"] + }) +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 0e890a980..a3356c12f 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 @@ -106,10 +106,10 @@ test "updates the user's allow_following_move", %{user: user, conn: conn} do end test "updates the user's default scope", %{conn: conn} do - conn = patch(conn, "/api/v1/accounts/update_credentials", %{default_scope: "cofe"}) + conn = patch(conn, "/api/v1/accounts/update_credentials", %{default_scope: "unlisted"}) assert user_data = json_response(conn, 200) - assert user_data["source"]["privacy"] == "cofe" + assert user_data["source"]["privacy"] == "unlisted" end test "updates the user's hide_followers status", %{conn: conn} do -- cgit v1.2.3 From d7d6a83233f24b80005b4f49a8697535620e4b83 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 7 Apr 2020 18:29:05 +0400 Subject: Add spec for AccountController.relationships --- .../web/api_spec/operations/account_operation.ex | 24 +++++++++- .../schemas/account_relationship_response.ex | 43 +++++++++++++++++ .../schemas/account_relationships_response.ex | 55 ++++++++++++++++++++++ .../mastodon_api/controllers/account_controller.ex | 4 +- test/web/api_spec/account_operation_test.exs | 24 ++++++++++ .../controllers/account_controller_test.exs | 14 ++++-- 6 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/account_relationship_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/account_relationships_response.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index d7b56cc2b..352f66e9d 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -4,10 +4,12 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias OpenApiSpex.Operation + alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest @spec open_api_operation(atom) :: Operation.t() @@ -60,7 +62,27 @@ def update_credentials_operation do end def relationships_operation do - :ok + %Operation{ + tags: ["accounts"], + summary: "Check relationships to other accounts", + operationId: "AccountController.relationships", + description: "Find out whether a given account is followed, blocked, muted, etc.", + security: [%{"oAuth" => ["read:follows"]}], + parameters: [ + Operation.parameter( + :id, + :query, + %Schema{ + oneOf: [%Schema{type: :array, items: %Schema{type: :string}}, %Schema{type: :string}] + }, + "Account IDs", + example: "123" + ) + ], + responses: %{ + 200 => Operation.response("Account", "application/json", AccountRelationshipsResponse) + } + } end def show_operation do diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship_response.ex b/lib/pleroma/web/api_spec/schemas/account_relationship_response.ex new file mode 100644 index 000000000..9974b946b --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_relationship_response.ex @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountRelationshipResponse", + description: "Response schema for an account relationship", + type: :object, + properties: %{ + id: %Schema{type: :string}, + following: %Schema{type: :boolean}, + showing_reblogs: %Schema{type: :boolean}, + followed_by: %Schema{type: :boolean}, + blocking: %Schema{type: :boolean}, + blocked_by: %Schema{type: :boolean}, + muting: %Schema{type: :boolean}, + muting_notifications: %Schema{type: :boolean}, + requested: %Schema{type: :boolean}, + domain_blocking: %Schema{type: :boolean}, + endorsed: %Schema{type: :boolean} + }, + example: %{ + "JSON" => %{ + "id" => "1", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => false, + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => false, + "endorsed" => false + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex b/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex new file mode 100644 index 000000000..2ca632310 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountRelationshipsResponse", + description: "Response schema for account relationships", + type: :array, + items: Pleroma.Web.ApiSpec.Schemas.AccountRelationshipResponse, + example: [ + %{ + "id" => "1", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => false, + "endorsed" => true + }, + %{ + "id" => "2", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => true, + "muting_notifications" => false, + "requested" => true, + "domain_blocking" => false, + "endorsed" => false + }, + %{ + "id" => "3", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => true, + "blocked_by" => false, + "muting" => true, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => true, + "endorsed" => false + } + ] + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 9c986b3b2..1652e3a1b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -83,7 +83,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OpenApiSpex.Plug.CastAndValidate, [render_error: Pleroma.Web.ApiSpec.RenderError] - when action in [:create, :verify_credentials, :update_credentials] + when action in [:create, :verify_credentials, :update_credentials, :relationships] ) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @@ -229,7 +229,7 @@ defp normalize_fields_attributes(fields) do end @doc "GET /api/v1/accounts/relationships" - def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do targets = User.get_all_by_ids(List.wrap(id)) render(conn, "relationships.json", user: user, targets: targets) diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs index a54059074..58a38d8af 100644 --- a/test/web/api_spec/account_operation_test.exs +++ b/test/web/api_spec/account_operation_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperationTest do alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest import OpenApiSpex.TestAssertions @@ -84,4 +85,27 @@ test "AccountUpdateCredentialsRequest produces an Account", %{conn: conn} do assert_schema(json, "Account", api_spec) end + + test "AccountRelationshipsResponse example matches schema" do + api_spec = ApiSpec.spec() + schema = AccountRelationshipsResponse.schema() + assert_schema(schema.example, "AccountRelationshipsResponse", api_spec) + end + + test "/api/v1/accounts/relationships produces AccountRelationshipsResponse", %{ + conn: conn + } do + token = insert(:oauth_token, scopes: ["read", "write"]) + other_user = insert(:user) + {:ok, _user} = Pleroma.User.follow(token.user, other_user) + api_spec = ApiSpec.spec() + + assert [relationship] = + conn + |> put_req_header("authorization", "Bearer " <> token.token) + |> get("/api/v1/accounts/relationships?id=#{other_user.id}") + |> json_response(:ok) + + assert_schema([relationship], "AccountRelationshipsResponse", 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 6fe46af3c..060a7c1cd 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -1062,14 +1062,18 @@ test "locked accounts" do setup do: oauth_access(["read:follows"]) test "returns the relationships for the current user", %{user: user, conn: conn} do - other_user = insert(:user) + %{id: other_user_id} = other_user = insert(:user) {:ok, _user} = User.follow(user, other_user) - conn = get(conn, "/api/v1/accounts/relationships", %{"id" => [other_user.id]}) - - assert [relationship] = json_response(conn, 200) + assert [%{"id" => ^other_user_id}] = + conn + |> get("/api/v1/accounts/relationships?id=#{other_user.id}") + |> json_response(200) - assert to_string(other_user.id) == relationship["id"] + assert [%{"id" => ^other_user_id}] = + conn + |> get("/api/v1/accounts/relationships?id[]=#{other_user.id}") + |> json_response(200) end test "returns an empty list on a bad request", %{conn: conn} do -- cgit v1.2.3 From 278b3fa0ad0ca58a9e5549e98d24944bbe0bf766 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 7 Apr 2020 18:53:12 +0400 Subject: Add spec for AccountController.show --- lib/pleroma/web/api_spec/operations/account_operation.ex | 16 +++++++++++++++- .../web/mastodon_api/controllers/account_controller.ex | 4 ++-- test/web/api_spec/account_operation_test.exs | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 352f66e9d..5b1b2eb4c 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -86,7 +86,21 @@ def relationships_operation do end def show_operation do - :ok + %Operation{ + tags: ["accounts"], + summary: "Account", + operationId: "AccountController.show", + description: "View information about a profile.", + parameters: [ + Operation.parameter(:id, :path, :string, "Account ID or nickname", + example: "123", + required: true + ) + ], + responses: %{ + 200 => Operation.response("Account", "application/json", Account) + } + } end def statuses_operation do diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1652e3a1b..67375f31c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -83,7 +83,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OpenApiSpex.Plug.CastAndValidate, [render_error: Pleroma.Web.ApiSpec.RenderError] - when action in [:create, :verify_credentials, :update_credentials, :relationships] + when action in [:create, :verify_credentials, :update_credentials, :relationships, :show] ) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @@ -239,7 +239,7 @@ def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) @doc "GET /api/v1/accounts/:id" - def show(%{assigns: %{user: for_user}} = conn, %{"id" => nickname_or_id}) do + def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), true <- User.visible_for?(user, for_user) do render(conn, "show.json", user: user, for: for_user) diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs index 58a38d8af..6cc08ee0e 100644 --- a/test/web/api_spec/account_operation_test.exs +++ b/test/web/api_spec/account_operation_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ApiSpec.AccountOperationTest do - use Pleroma.Web.ConnCase, async: true + use Pleroma.Web.ConnCase alias Pleroma.Web.ApiSpec alias Pleroma.Web.ApiSpec.Schemas.Account @@ -108,4 +108,18 @@ test "/api/v1/accounts/relationships produces AccountRelationshipsResponse", %{ assert_schema([relationship], "AccountRelationshipsResponse", api_spec) end + + test "/api/v1/accounts/:id produces Account", %{ + conn: conn + } do + user = insert(:user) + api_spec = ApiSpec.spec() + + assert resp = + conn + |> get("/api/v1/accounts/#{user.id}") + |> json_response(:ok) + + assert_schema(resp, "Account", api_spec) + end end -- cgit v1.2.3 From 03124c96cc192ef8c4893738a0cee552c6984da6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 8 Apr 2020 22:33:25 +0400 Subject: Add spec for AccountController.statuses --- lib/pleroma/web/activity_pub/activity_pub.ex | 11 +- lib/pleroma/web/api_spec.ex | 8 + .../web/api_spec/operations/account_operation.ex | 45 +++- .../schemas/account_update_credentials_request.ex | 5 +- lib/pleroma/web/api_spec/schemas/boolean_like.ex | 36 ++++ lib/pleroma/web/api_spec/schemas/poll.ex | 35 ++++ lib/pleroma/web/api_spec/schemas/status.ex | 227 +++++++++++++++++++++ .../web/api_spec/schemas/statuses_response.ex | 13 ++ .../mastodon_api/controllers/account_controller.ex | 17 +- lib/pleroma/web/mastodon_api/views/status_view.ex | 8 +- mix.exs | 4 +- mix.lock | 4 +- test/web/api_spec/account_operation_test.exs | 16 ++ .../controllers/account_controller_test.exs | 60 ++++-- 14 files changed, 444 insertions(+), 45 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/boolean_like.ex create mode 100644 lib/pleroma/web/api_spec/schemas/poll.ex create mode 100644 lib/pleroma/web/api_spec/schemas/status.ex create mode 100644 lib/pleroma/web/api_spec/schemas/statuses_response.ex diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 86b105b7f..1909ce097 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -853,7 +853,7 @@ defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) end defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) - when visibility not in @valid_visibilities do + when visibility not in [nil | @valid_visibilities] do Logger.error("Could not exclude visibility to #{visibility}") query end @@ -1060,7 +1060,7 @@ defp restrict_media(_query, %{"only_media" => _val, "skip_preload" => true}) do raise "Can't use the child object without preloading!" end - defp restrict_media(query, %{"only_media" => val}) when val == "true" or val == "1" do + defp restrict_media(query, %{"only_media" => val}) when val in [true, "true", "1"] do from( [_activity, object] in query, where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) @@ -1069,7 +1069,7 @@ defp restrict_media(query, %{"only_media" => val}) when val == "true" or val == defp restrict_media(query, _), do: query - defp restrict_replies(query, %{"exclude_replies" => val}) when val == "true" or val == "1" do + defp restrict_replies(query, %{"exclude_replies" => val}) when val in [true, "true", "1"] do from( [_activity, object] in query, where: fragment("?->>'inReplyTo' is null", object.data) @@ -1078,7 +1078,7 @@ defp restrict_replies(query, %{"exclude_replies" => val}) when val == "true" or defp restrict_replies(query, _), do: query - defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val == "true" or val == "1" do + defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val in [true, "true", "1"] do from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data)) end @@ -1157,7 +1157,8 @@ defp restrict_unlisted(query) do ) end - defp restrict_pinned(query, %{"pinned" => "true", "pinned_activity_ids" => ids}) do + defp restrict_pinned(query, %{"pinned" => pinned, "pinned_activity_ids" => ids}) + when pinned in [true, "true", "1"] do from(activity in query, where: activity.id in ^ids) end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index c85fe30d1..d11e776d0 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ApiSpec do alias OpenApiSpex.OpenApi + alias OpenApiSpex.Operation alias Pleroma.Web.Endpoint alias Pleroma.Web.Router @@ -24,6 +25,13 @@ def spec do # populate the paths from a phoenix router paths: OpenApiSpex.Paths.from_router(Router), components: %OpenApiSpex.Components{ + parameters: %{ + "accountIdOrNickname" => + Operation.parameter(:id, :path, :string, "Account ID or nickname", + example: "123", + required: true + ) + }, securitySchemes: %{ "oAuth" => %OpenApiSpex.SecurityScheme{ type: "oauth2", diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 5b1b2eb4c..09e6d24ed 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias OpenApiSpex.Operation + alias OpenApiSpex.Reference alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.Account @@ -11,6 +12,9 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.StatusesResponse + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -91,12 +95,7 @@ def show_operation do summary: "Account", operationId: "AccountController.show", description: "View information about a profile.", - parameters: [ - Operation.parameter(:id, :path, :string, "Account ID or nickname", - example: "123", - required: true - ) - ], + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], responses: %{ 200 => Operation.response("Account", "application/json", Account) } @@ -104,7 +103,39 @@ def show_operation do end def statuses_operation do - :ok + %Operation{ + tags: ["accounts"], + summary: "Statuses", + operationId: "AccountController.statuses", + description: + "Statuses posted to the given account. Public (for public statuses only), or user token + `read:statuses` (for private statuses the user is authorized to see)", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:pinned, :query, BooleanLike, "Pinned"), + Operation.parameter(:tagged, :query, :string, "With tag"), + Operation.parameter(:only_media, :query, BooleanLike, "Only meadia"), + Operation.parameter(:with_muted, :query, BooleanLike, "With muted"), + Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblobs"), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude visibilities" + ), + Operation.parameter(:max_id, :query, :string, "Max ID"), + Operation.parameter(:min_id, :query, :string, "Mix ID"), + Operation.parameter(:since_id, :query, :string, "Since ID"), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20, maximum: 40}, + "Limit" + ) + ], + responses: %{ + 200 => Operation.response("Statuses", "application/json", StatusesResponse) + } + } end def followers_operation do diff --git a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex index 6ab48193e..35220c78a 100644 --- a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex +++ b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex @@ -38,7 +38,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest do description: "Whether manual approval of follow requests is required." }, fields_attributes: %Schema{ - oneOf: [%Schema{type: :array, items: AccountAttributeField}, %Schema{type: :object}] + oneOf: [ + %Schema{type: :array, items: AccountAttributeField}, + %Schema{type: :object, additionalProperties: %Schema{type: AccountAttributeField}} + ] }, # NOTE: `source` field is not supported # diff --git a/lib/pleroma/web/api_spec/schemas/boolean_like.ex b/lib/pleroma/web/api_spec/schemas/boolean_like.ex new file mode 100644 index 000000000..f3bfb74da --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/boolean_like.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "BooleanLike", + description: """ + The following values will be treated as `false`: + - false + - 0 + - "0", + - "f", + - "F", + - "false", + - "FALSE", + - "off", + - "OFF" + + All other non-null values will be treated as `true` + """, + anyOf: [ + %Schema{type: :boolean}, + %Schema{type: :string}, + %Schema{type: :integer} + ] + }) + + def after_cast(value, _schmea) do + {:ok, Pleroma.Web.ControllerHelper.truthy_param?(value)} + end +end diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex new file mode 100644 index 000000000..2a9975f85 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Poll do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Poll", + description: "Response schema for account custom fields", + type: :object, + properties: %{ + id: %Schema{type: :string}, + expires_at: %Schema{type: :string, format: "date-time"}, + expired: %Schema{type: :boolean}, + multiple: %Schema{type: :boolean}, + votes_count: %Schema{type: :integer}, + voted: %Schema{type: :boolean}, + emojis: %Schema{type: :array, items: AccountEmoji}, + options: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + title: %Schema{type: :string}, + votes_count: %Schema{type: :integer} + } + } + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex new file mode 100644 index 000000000..486c3a0fe --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -0,0 +1,227 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Status do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Status", + description: "Response schema for a status", + type: :object, + properties: %{ + account: Account, + application: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true} + } + }, + bookmarked: %Schema{type: :boolean}, + card: %Schema{ + type: :object, + nullable: true, + properties: %{ + type: %Schema{type: :string}, + provider_name: %Schema{type: :string}, + provider_url: %Schema{type: :string}, + url: %Schema{type: :string}, + image: %Schema{type: :string}, + title: %Schema{type: :string}, + description: %Schema{type: :string} + } + }, + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: "date-time"}, + emojis: %Schema{type: :array, items: AccountEmoji}, + favourited: %Schema{type: :boolean}, + favourites_count: %Schema{type: :integer}, + id: %Schema{type: :string}, + in_reply_to_account_id: %Schema{type: :string, nullable: true}, + in_reply_to_id: %Schema{type: :string, nullable: true}, + language: %Schema{type: :string, nullable: true}, + media_attachments: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + url: %Schema{type: :string}, + remote_url: %Schema{type: :string}, + preview_url: %Schema{type: :string}, + text_url: %Schema{type: :string}, + description: %Schema{type: :string}, + type: %Schema{type: :string, enum: ["image", "video", "audio", "unknown"]}, + pleroma: %Schema{ + type: :object, + properties: %{mime_type: %Schema{type: :string}} + } + } + } + }, + mentions: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + acct: %Schema{type: :string}, + username: %Schema{type: :string}, + url: %Schema{type: :string} + } + } + }, + muted: %Schema{type: :boolean}, + pinned: %Schema{type: :boolean}, + pleroma: %Schema{ + type: :object, + properties: %{ + content: %Schema{type: :object, additionalProperties: %Schema{type: :string}}, + conversation_id: %Schema{type: :integer}, + direct_conversation_id: %Schema{type: :string, nullable: true}, + emoji_reactions: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + count: %Schema{type: :integer}, + me: %Schema{type: :boolean} + } + } + }, + expires_at: %Schema{type: :string, format: "date-time", nullable: true}, + in_reply_to_account_acct: %Schema{type: :string, nullable: true}, + local: %Schema{type: :boolean}, + spoiler_text: %Schema{type: :object, additionalProperties: %Schema{type: :string}}, + thread_muted: %Schema{type: :boolean} + } + }, + poll: %Schema{type: Poll, nullable: true}, + reblog: %Schema{ + allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], + nullable: true + }, + reblogged: %Schema{type: :boolean}, + reblogs_count: %Schema{type: :integer}, + replies_count: %Schema{type: :integer}, + sensitive: %Schema{type: :boolean}, + spoiler_text: %Schema{type: :string}, + tags: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string}, + url: %Schema{type: :string} + } + } + }, + uri: %Schema{type: :string}, + url: %Schema{type: :string}, + visibility: VisibilityScope + }, + example: %{ + "JSON" => %{ + "account" => %{ + "acct" => "nick6", + "avatar" => "http://localhost:4001/images/avi.png", + "avatar_static" => "http://localhost:4001/images/avi.png", + "bot" => false, + "created_at" => "2020-04-07T19:48:51.000Z", + "display_name" => "Test テスト User 6", + "emojis" => [], + "fields" => [], + "followers_count" => 1, + "following_count" => 0, + "header" => "http://localhost:4001/images/banner.png", + "header_static" => "http://localhost:4001/images/banner.png", + "id" => "9toJCsKN7SmSf3aj5c", + "locked" => false, + "note" => "Tester Number 6", + "pleroma" => %{ + "background_image" => nil, + "confirmation_pending" => false, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => true, + "id" => "9toJCsKN7SmSf3aj5c", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + }, + "skip_thread_containment" => false, + "tags" => [] + }, + "source" => %{ + "fields" => [], + "note" => "Tester Number 6", + "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, + "sensitive" => false + }, + "statuses_count" => 1, + "url" => "http://localhost:4001/users/nick6", + "username" => "nick6" + }, + "application" => %{"name" => "Web", "website" => nil}, + "bookmarked" => false, + "card" => nil, + "content" => "foobar", + "created_at" => "2020-04-07T19:48:51.000Z", + "emojis" => [], + "favourited" => false, + "favourites_count" => 0, + "id" => "9toJCu5YZW7O7gfvH6", + "in_reply_to_account_id" => nil, + "in_reply_to_id" => nil, + "language" => nil, + "media_attachments" => [], + "mentions" => [], + "muted" => false, + "pinned" => false, + "pleroma" => %{ + "content" => %{"text/plain" => "foobar"}, + "conversation_id" => 345_972, + "direct_conversation_id" => nil, + "emoji_reactions" => [], + "expires_at" => nil, + "in_reply_to_account_acct" => nil, + "local" => true, + "spoiler_text" => %{"text/plain" => ""}, + "thread_muted" => false + }, + "poll" => nil, + "reblog" => nil, + "reblogged" => false, + "reblogs_count" => 0, + "replies_count" => 0, + "sensitive" => false, + "spoiler_text" => "", + "tags" => [], + "uri" => "http://localhost:4001/objects/0f5dad44-0e9e-4610-b377-a2631e499190", + "url" => "http://localhost:4001/notice/9toJCu5YZW7O7gfvH6", + "visibility" => "private" + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/statuses_response.ex b/lib/pleroma/web/api_spec/schemas/statuses_response.ex new file mode 100644 index 000000000..fb7c7e0aa --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/statuses_response.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.StatusesResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "StatusesResponse", + type: :array, + items: Pleroma.Web.ApiSpec.Schemas.Status + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 67375f31c..208df5698 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -83,7 +83,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OpenApiSpex.Plug.CastAndValidate, [render_error: Pleroma.Web.ApiSpec.RenderError] - when action in [:create, :verify_credentials, :update_credentials, :relationships, :show] + when action in [ + :create, + :verify_credentials, + :update_credentials, + :relationships, + :show, + :statuses + ] ) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @@ -250,12 +257,14 @@ def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do @doc "GET /api/v1/accounts/:id/statuses" def statuses(%{assigns: %{user: reading_user}} = conn, params) do - with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user), + with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user), true <- User.visible_for?(user, reading_user) do params = params - |> Map.put("tag", params["tagged"]) - |> Map.delete("godmode") + |> Map.delete(:tagged) + |> Enum.filter(&(not is_nil(&1))) + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("tag", params[:tagged]) activities = ActivityPub.fetch_user_activities(user, reading_user, params) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index b5850e1ae..ba40fd63e 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -521,11 +521,9 @@ def render_content(object), do: object.data["content"] || "" """ @spec build_tags(list(any())) :: list(map()) def build_tags(object_tags) when is_list(object_tags) do - object_tags = for tag when is_binary(tag) <- object_tags, do: tag - - Enum.reduce(object_tags, [], fn tag, tags -> - tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}] - end) + object_tags + |> Enum.filter(&is_binary/1) + |> Enum.map(&%{name: &1, url: "/tag/#{URI.encode(&1)}"}) end def build_tags(_), do: [] diff --git a/mix.exs b/mix.exs index c781995e0..ec69d70c0 100644 --- a/mix.exs +++ b/mix.exs @@ -189,7 +189,9 @@ defp deps do ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, {:mox, "~> 0.5", only: :test}, {:restarter, path: "./restarter"}, - {:open_api_spex, "~> 3.6"} + {:open_api_spex, + git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", + ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock index ba4e3ac44..779be4f87 100644 --- a/mix.lock +++ b/mix.lock @@ -74,7 +74,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, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, - "open_api_spex": {: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"}, + "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"}, "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, @@ -82,7 +82,7 @@ "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": {:hex, :plug, "1.10.0", "6508295cbeb4c654860845fb95260737e4a8838d34d115ad76cd487584e2fc4d", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "422a9727e667be1bf5ab1de03be6fa0ad67b775b2d84ed908f3264415ef29d4a"}, "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"}, diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs index 6cc08ee0e..892ade71c 100644 --- a/test/web/api_spec/account_operation_test.exs +++ b/test/web/api_spec/account_operation_test.exs @@ -122,4 +122,20 @@ test "/api/v1/accounts/:id produces Account", %{ assert_schema(resp, "Account", api_spec) end + + test "/api/v1/accounts/:id/statuses produces StatusesResponse", %{ + conn: conn + } do + user = insert(:user) + Pleroma.Web.CommonAPI.post(user, %{"status" => "foobar"}) + + api_spec = ApiSpec.spec() + + assert resp = + conn + |> get("/api/v1/accounts/#{user.id}/statuses") + |> json_response(:ok) + + assert_schema(resp, "StatusesResponse", 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 060a7c1cd..969256fa4 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -10,9 +10,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.ApiSpec alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.Token + import OpenApiSpex.TestAssertions import Pleroma.Factory describe "account fetching" do @@ -245,22 +247,23 @@ test "respects blocks", %{user: user_one, conn: conn} do {:ok, activity} = CommonAPI.post(user_two, %{"status" => "User one sux0rz"}) {:ok, repeat, _} = CommonAPI.repeat(activity.id, user_three) - resp = get(conn, "/api/v1/accounts/#{user_two.id}/statuses") - - assert [%{"id" => id}] = json_response(resp, 200) + assert resp = get(conn, "/api/v1/accounts/#{user_two.id}/statuses") |> json_response(200) + assert [%{"id" => id}] = resp + assert_schema(resp, "StatusesResponse", ApiSpec.spec()) assert id == activity.id # Even a blocked user will deliver the full user timeline, there would be # no point in looking at a blocked users timeline otherwise - resp = get(conn, "/api/v1/accounts/#{user_two.id}/statuses") - - assert [%{"id" => id}] = json_response(resp, 200) + assert resp = get(conn, "/api/v1/accounts/#{user_two.id}/statuses") |> json_response(200) + assert [%{"id" => id}] = resp assert id == activity.id + assert_schema(resp, "StatusesResponse", ApiSpec.spec()) # Third user's timeline includes the repeat when viewed by unauthenticated user - resp = get(build_conn(), "/api/v1/accounts/#{user_three.id}/statuses") - assert [%{"id" => id}] = json_response(resp, 200) + resp = get(build_conn(), "/api/v1/accounts/#{user_three.id}/statuses") |> json_response(200) + assert [%{"id" => id}] = resp assert id == repeat.id + assert_schema(resp, "StatusesResponse", ApiSpec.spec()) # When viewing a third user's timeline, the blocked users' statuses will NOT be shown resp = get(conn, "/api/v1/accounts/#{user_three.id}/statuses") @@ -286,30 +289,34 @@ test "gets users statuses", %{conn: conn} do {:ok, private_activity} = CommonAPI.post(user_one, %{"status" => "private", "visibility" => "private"}) - resp = get(conn, "/api/v1/accounts/#{user_one.id}/statuses") - - assert [%{"id" => id}] = json_response(resp, 200) + resp = get(conn, "/api/v1/accounts/#{user_one.id}/statuses") |> json_response(200) + assert [%{"id" => id}] = resp assert id == to_string(activity.id) + assert_schema(resp, "StatusesResponse", ApiSpec.spec()) resp = conn |> assign(:user, user_two) |> assign(:token, insert(:oauth_token, user: user_two, scopes: ["read:statuses"])) |> get("/api/v1/accounts/#{user_one.id}/statuses") + |> json_response(200) - assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200) + assert [%{"id" => id_one}, %{"id" => id_two}] = resp assert id_one == to_string(direct_activity.id) assert id_two == to_string(activity.id) + assert_schema(resp, "StatusesResponse", ApiSpec.spec()) resp = conn |> assign(:user, user_three) |> assign(:token, insert(:oauth_token, user: user_three, scopes: ["read:statuses"])) |> get("/api/v1/accounts/#{user_one.id}/statuses") + |> json_response(200) - assert [%{"id" => id_one}, %{"id" => id_two}] = json_response(resp, 200) + assert [%{"id" => id_one}, %{"id" => id_two}] = resp assert id_one == to_string(private_activity.id) assert id_two == to_string(activity.id) + assert_schema(resp, "StatusesResponse", ApiSpec.spec()) end test "unimplemented pinned statuses feature", %{conn: conn} do @@ -335,40 +342,45 @@ test "gets an users media", %{conn: conn} do {:ok, image_post} = CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]}) - conn = get(conn, "/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "true"}) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?only_media=true") assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(image_post.id) + assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) - conn = get(build_conn(), "/api/v1/accounts/#{user.id}/statuses", %{"only_media" => "1"}) + conn = get(build_conn(), "/api/v1/accounts/#{user.id}/statuses?only_media=1") assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(image_post.id) + assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) end test "gets a user's statuses without reblogs", %{user: user, conn: conn} do {:ok, post} = CommonAPI.post(user, %{"status" => "HI!!!"}) {:ok, _, _} = CommonAPI.repeat(post.id, user) - conn = get(conn, "/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "true"}) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=true") assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(post.id) + assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) - conn = get(conn, "/api/v1/accounts/#{user.id}/statuses", %{"exclude_reblogs" => "1"}) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=1") assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(post.id) + assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) end test "filters user's statuses by a hashtag", %{user: user, conn: conn} do {:ok, post} = CommonAPI.post(user, %{"status" => "#hashtag"}) {:ok, _post} = CommonAPI.post(user, %{"status" => "hashtag"}) - conn = get(conn, "/api/v1/accounts/#{user.id}/statuses", %{"tagged" => "hashtag"}) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?tagged=hashtag") assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(post.id) + assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) end test "the user views their own timelines and excludes direct messages", %{ @@ -378,11 +390,11 @@ test "the user views their own timelines and excludes direct messages", %{ {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) {:ok, _direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) - conn = - get(conn, "/api/v1/accounts/#{user.id}/statuses", %{"exclude_visibilities" => ["direct"]}) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_visibilities[]=direct") assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(public_activity.id) + assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) end end @@ -420,9 +432,11 @@ test "if user is authenticated", %{local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) end end @@ -441,6 +455,7 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) end test "if user is authenticated", %{local: local, remote: remote} do @@ -448,9 +463,11 @@ test "if user is authenticated", %{local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) end end @@ -463,6 +480,7 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") @@ -476,9 +494,11 @@ test "if user is authenticated", %{local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") assert length(json_response(res_conn, 200)) == 1 + assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) end end -- cgit v1.2.3 From bd6e2b300f82e66afb121c2339c3cbbfb0b1a446 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 8 Apr 2020 23:16:20 +0400 Subject: Add spec for AccountController.followers --- .../web/api_spec/operations/account_operation.ex | 19 ++++++++++++++++++- lib/pleroma/web/api_spec/schemas/accounts_response.ex | 13 +++++++++++++ .../mastodon_api/controllers/account_controller.ex | 8 +++++++- .../controllers/account_controller_test.exs | 4 ++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/accounts_response.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 09e6d24ed..070c74758 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.StatusesResponse @@ -139,7 +140,23 @@ def statuses_operation do end def followers_operation do - :ok + %Operation{ + tags: ["accounts"], + summary: "Followers", + operationId: "AccountController.followers", + security: [%{"oAuth" => ["read:accounts"]}], + description: + "Accounts which follow the given account, if network is not hidden by the account owner.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:max_id, :query, :string, "Max ID"), + Operation.parameter(:since_id, :query, :string, "Since ID"), + Operation.parameter(:limit, :query, :integer, "Limit") + ], + responses: %{ + 200 => Operation.response("Accounts", "application/json", AccountsResponse) + } + } end def following_operation, do: :ok diff --git a/lib/pleroma/web/api_spec/schemas/accounts_response.ex b/lib/pleroma/web/api_spec/schemas/accounts_response.ex new file mode 100644 index 000000000..b714f59e7 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/accounts_response.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountsResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountsResponse", + type: :array, + items: Pleroma.Web.ApiSpec.Schemas.Account + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 208df5698..1ffccdd1d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -89,7 +89,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :update_credentials, :relationships, :show, - :statuses + :statuses, + :followers ] ) @@ -284,6 +285,11 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do @doc "GET /api/v1/accounts/:id/followers" def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do + params = + params + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + followers = cond do for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 969256fa4..79b3adc69 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -513,6 +513,7 @@ test "getting followers", %{user: user, conn: conn} do assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(user.id) + assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end test "getting followers, hide_followers", %{user: user, conn: conn} do @@ -536,6 +537,7 @@ test "getting followers, hide_followers, same user requesting" do |> get("/api/v1/accounts/#{other_user.id}/followers") refute [] == json_response(conn, 200) + assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end test "getting followers, pagination", %{user: user, conn: conn} do @@ -551,6 +553,7 @@ test "getting followers, pagination", %{user: user, conn: conn} do assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) assert id3 == follower3.id assert id2 == follower2.id + assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?max_id=#{follower3.id}") @@ -566,6 +569,7 @@ test "getting followers, pagination", %{user: user, conn: conn} do assert [link_header] = get_resp_header(res_conn, "link") assert link_header =~ ~r/min_id=#{follower2.id}/ assert link_header =~ ~r/max_id=#{follower2.id}/ + assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) end end -- cgit v1.2.3 From e105cc12b67e44eb4e19293b850731f300999a4f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 8 Apr 2020 23:38:07 +0400 Subject: Add spec for AccountController.following --- .../web/api_spec/operations/account_operation.ex | 35 ++++++++++++++++++++-- .../mastodon_api/controllers/account_controller.ex | 8 ++++- .../controllers/account_controller_test.exs | 5 ++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 070c74758..456d08a45 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -150,8 +150,40 @@ def followers_operation do parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, Operation.parameter(:max_id, :query, :string, "Max ID"), + Operation.parameter(:min_id, :query, :string, "Mix ID"), Operation.parameter(:since_id, :query, :string, "Since ID"), - Operation.parameter(:limit, :query, :integer, "Limit") + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20, maximum: 40}, + "Limit" + ) + ], + responses: %{ + 200 => Operation.response("Accounts", "application/json", AccountsResponse) + } + } + end + + def following_operation do + %Operation{ + tags: ["accounts"], + summary: "Following", + operationId: "AccountController.following", + security: [%{"oAuth" => ["read:accounts"]}], + description: + "Accounts which the given account is following, if network is not hidden by the account owner.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:max_id, :query, :string, "Max ID"), + Operation.parameter(:min_id, :query, :string, "Mix ID"), + Operation.parameter(:since_id, :query, :string, "Since ID"), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20, maximum: 40}, + "Limit" + ) ], responses: %{ 200 => Operation.response("Accounts", "application/json", AccountsResponse) @@ -159,7 +191,6 @@ def followers_operation do } end - def following_operation, do: :ok def lists_operation, do: :ok def follow_operation, do: :ok def unfollow_operation, do: :ok diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1ffccdd1d..e74180662 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -90,7 +90,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :relationships, :show, :statuses, - :followers + :followers, + :following ] ) @@ -304,6 +305,11 @@ def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do @doc "GET /api/v1/accounts/:id/following" def following(%{assigns: %{user: for_user, account: user}} = conn, params) do + params = + params + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + followers = cond do for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 79b3adc69..341c9b015 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -584,6 +584,7 @@ test "getting following", %{user: user, conn: conn} do assert [%{"id" => id}] = json_response(conn, 200) assert id == to_string(other_user.id) + assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end test "getting following, hide_follows, other user requesting" do @@ -598,6 +599,7 @@ test "getting following, hide_follows, other user requesting" do |> get("/api/v1/accounts/#{user.id}/following") assert [] == json_response(conn, 200) + assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end test "getting following, hide_follows, same user requesting" do @@ -627,12 +629,14 @@ test "getting following, pagination", %{user: user, conn: conn} do assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) assert id3 == following3.id assert id2 == following2.id + assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?max_id=#{following3.id}") assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) assert id2 == following2.id assert id1 == following1.id + assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}") @@ -643,6 +647,7 @@ test "getting following, pagination", %{user: user, conn: conn} do assert [link_header] = get_resp_header(res_conn, "link") assert link_header =~ ~r/min_id=#{following2.id}/ assert link_header =~ ~r/max_id=#{following2.id}/ + assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) end end -- cgit v1.2.3 From 1b680a98ae15035215fa8489f825af72532340c4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 8 Apr 2020 23:51:46 +0400 Subject: Add spec for AccountController.lists --- .../web/api_spec/operations/account_operation.ex | 18 +++++++++++++++- lib/pleroma/web/api_spec/schemas/list.ex | 25 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/lists_response.ex | 16 ++++++++++++++ .../mastodon_api/controllers/account_controller.ex | 3 ++- .../controllers/account_controller_test.exs | 1 + 5 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/list.ex create mode 100644 lib/pleroma/web/api_spec/schemas/lists_response.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 456d08a45..ad10f4ec9 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.AccountsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.ListsResponse alias Pleroma.Web.ApiSpec.Schemas.StatusesResponse alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope @@ -191,7 +192,22 @@ def following_operation do } end - def lists_operation, do: :ok + def lists_operation do + %Operation{ + tags: ["accounts"], + summary: "Lists containing this account", + operationId: "AccountController.lists", + security: [%{"oAuth" => ["read:lists"]}], + description: "User lists that you have added this account to.", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} + ], + responses: %{ + 200 => Operation.response("Lists", "application/json", ListsResponse) + } + } + end + def follow_operation, do: :ok def unfollow_operation, do: :ok def mute_operation, do: :ok diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex new file mode 100644 index 000000000..30fa7db93 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/list.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.ApiSpec.Schemas.List do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "List", + description: "Response schema for a list", + type: :object, + properties: %{ + id: %Schema{type: :string}, + title: %Schema{type: :string} + }, + example: %{ + "JSON" => %{ + "id" => "123", + "title" => "my list" + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/lists_response.ex b/lib/pleroma/web/api_spec/schemas/lists_response.ex new file mode 100644 index 000000000..132454579 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/lists_response.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ListsResponse do + alias Pleroma.Web.ApiSpec.Schemas.List + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ListsResponse", + description: "Response schema for lists", + type: :array, + items: List + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index e74180662..2c5cd8cde 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -91,7 +91,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :show, :statuses, :followers, - :following + :following, + :lists ] ) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 341c9b015..706eea5d9 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -1051,6 +1051,7 @@ test "returns lists to which the account belongs" do |> json_response(200) assert res == [%{"id" => to_string(list.id), "title" => "Test List"}] + assert_schema(res, "ListsResponse", ApiSpec.spec()) end end -- cgit v1.2.3 From 854780c72bc90a55d7005a05861d45771c5aeadf Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 15:25:24 +0400 Subject: Add spec for AccountController.follow --- lib/pleroma/web/api_spec.ex | 2 +- .../web/api_spec/operations/account_operation.ex | 28 +++++++++++--- .../web/api_spec/schemas/account_relationship.ex | 45 ++++++++++++++++++++++ .../schemas/account_relationship_response.ex | 43 --------------------- .../schemas/account_relationships_response.ex | 5 ++- .../mastodon_api/controllers/account_controller.ex | 7 ++-- .../controllers/account_controller_test.exs | 1 + 7 files changed, 77 insertions(+), 54 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/account_relationship.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_relationship_response.ex diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index d11e776d0..b3c1e3ea2 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -39,7 +39,7 @@ def spec do password: %OpenApiSpex.OAuthFlow{ authorizationUrl: "/oauth/authorize", tokenUrl: "/oauth/token", - scopes: %{"read" => "read", "write" => "write"} + scopes: %{"read" => "read", "write" => "write", "follow" => "follow"} } } } diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index ad10f4ec9..a76141f7a 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest @@ -186,9 +187,7 @@ def following_operation do "Limit" ) ], - responses: %{ - 200 => Operation.response("Accounts", "application/json", AccountsResponse) - } + responses: %{200 => Operation.response("Accounts", "application/json", AccountsResponse)} } end @@ -199,16 +198,33 @@ def lists_operation do operationId: "AccountController.lists", security: [%{"oAuth" => ["read:lists"]}], description: "User lists that you have added this account to.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{200 => Operation.response("Lists", "application/json", ListsResponse)} + } + end + + def follow_operation do + %Operation{ + tags: ["accounts"], + summary: "Follow", + operationId: "AccountController.follow", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Follow the given account", parameters: [ - %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter( + :reblogs, + :query, + BooleanLike, + "Receive this account's reblogs in home timeline? Defaults to true." + ) ], responses: %{ - 200 => Operation.response("Lists", "application/json", ListsResponse) + 200 => Operation.response("Relationship", "application/json", AccountRelationship) } } end - def follow_operation, do: :ok def unfollow_operation, do: :ok def mute_operation, do: :ok def unmute_operation, do: :ok diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex new file mode 100644 index 000000000..7db3b49bb --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -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.Schemas.AccountRelationship do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountRelationship", + description: "Response schema for relationship", + type: :object, + properties: %{ + blocked_by: %Schema{type: :boolean}, + blocking: %Schema{type: :boolean}, + domain_blocking: %Schema{type: :boolean}, + endorsed: %Schema{type: :boolean}, + followed_by: %Schema{type: :boolean}, + following: %Schema{type: :boolean}, + id: %Schema{type: :string}, + muting: %Schema{type: :boolean}, + muting_notifications: %Schema{type: :boolean}, + requested: %Schema{type: :boolean}, + showing_reblogs: %Schema{type: :boolean}, + subscribing: %Schema{type: :boolean} + }, + example: %{ + "JSON" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship_response.ex b/lib/pleroma/web/api_spec/schemas/account_relationship_response.ex deleted file mode 100644 index 9974b946b..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_relationship_response.ex +++ /dev/null @@ -1,43 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipResponse do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountRelationshipResponse", - description: "Response schema for an account relationship", - type: :object, - properties: %{ - id: %Schema{type: :string}, - following: %Schema{type: :boolean}, - showing_reblogs: %Schema{type: :boolean}, - followed_by: %Schema{type: :boolean}, - blocking: %Schema{type: :boolean}, - blocked_by: %Schema{type: :boolean}, - muting: %Schema{type: :boolean}, - muting_notifications: %Schema{type: :boolean}, - requested: %Schema{type: :boolean}, - domain_blocking: %Schema{type: :boolean}, - endorsed: %Schema{type: :boolean} - }, - example: %{ - "JSON" => %{ - "id" => "1", - "following" => true, - "showing_reblogs" => true, - "followed_by" => true, - "blocking" => false, - "blocked_by" => false, - "muting" => false, - "muting_notifications" => false, - "requested" => false, - "domain_blocking" => false, - "endorsed" => false - } - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex b/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex index 2ca632310..960e14db1 100644 --- a/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex +++ b/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse do title: "AccountRelationshipsResponse", description: "Response schema for account relationships", type: :array, - items: Pleroma.Web.ApiSpec.Schemas.AccountRelationshipResponse, + items: Pleroma.Web.ApiSpec.Schemas.AccountRelationship, example: [ %{ "id" => "1", @@ -22,6 +22,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse do "muting_notifications" => false, "requested" => false, "domain_blocking" => false, + "subscribing" => false, "endorsed" => true }, %{ @@ -35,6 +36,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse do "muting_notifications" => false, "requested" => true, "domain_blocking" => false, + "subscribing" => false, "endorsed" => false }, %{ @@ -48,6 +50,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse do "muting_notifications" => false, "requested" => false, "domain_blocking" => true, + "subscribing" => true, "endorsed" => false } ] diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 2c5cd8cde..d2ad65ef3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -92,7 +92,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :statuses, :followers, :following, - :lists + :lists, + :follow ] ) @@ -337,8 +338,8 @@ def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, :not_found} end - def follow(%{assigns: %{user: follower, account: followed}} = conn, _params) do - with {:ok, follower} <- MastodonAPI.follow(follower, followed, conn.params) do + def follow(%{assigns: %{user: follower, account: followed}} = conn, params) do + with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do render(conn, "relationship.json", user: follower, target: followed) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 706eea5d9..7a3d58600 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -669,6 +669,7 @@ test "following / unfollowing a user", %{conn: conn} do assert %{"id" => id} = json_response(conn, 200) assert id == to_string(other_user.id) + assert_schema(json_response(conn, 200), "AccountRelationship", ApiSpec.spec()) end test "cancelling follow request", %{conn: conn} do -- cgit v1.2.3 From aa958a6dda7cdcf12e9cd9232e7c6be421610317 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 17:57:21 +0400 Subject: Add spec for AccountController.unfollow --- lib/pleroma/web/api_spec/operations/account_operation.ex | 15 ++++++++++++++- .../web/mastodon_api/controllers/account_controller.ex | 3 ++- .../mastodon_api/controllers/account_controller_test.exs | 16 ++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index a76141f7a..8925ebefd 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -225,7 +225,20 @@ def follow_operation do } end - def unfollow_operation, do: :ok + def unfollow_operation do + %Operation{ + tags: ["accounts"], + summary: "Unfollow", + operationId: "AccountController.unfollow", + security: [%{"oAuth" => ["follow", "write:follows"]}], + description: "Unfollow the given account", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + def mute_operation, do: :ok def unmute_operation, do: :ok def block_operation, do: :ok diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index d2ad65ef3..1ecce2928 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -93,7 +93,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :followers, :following, :lists, - :follow + :follow, + :unfollow ] ) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 7a3d58600..d56e7fb4a 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -660,10 +660,12 @@ test "following / unfollowing a user", %{conn: conn} do ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/follow") assert %{"id" => _id, "following" => true} = json_response(ret_conn, 200) + assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/unfollow") assert %{"id" => _id, "following" => false} = json_response(ret_conn, 200) + assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) conn = post(conn, "/api/v1/follows", %{"uri" => other_user.nickname}) @@ -675,11 +677,15 @@ test "following / unfollowing a user", %{conn: conn} do test "cancelling follow request", %{conn: conn} do %{id: other_user_id} = insert(:user, %{locked: true}) - assert %{"id" => ^other_user_id, "following" => false, "requested" => true} = - conn |> post("/api/v1/accounts/#{other_user_id}/follow") |> json_response(:ok) + resp = conn |> post("/api/v1/accounts/#{other_user_id}/follow") |> json_response(:ok) - assert %{"id" => ^other_user_id, "following" => false, "requested" => false} = - conn |> post("/api/v1/accounts/#{other_user_id}/unfollow") |> json_response(:ok) + assert %{"id" => ^other_user_id, "following" => false, "requested" => true} = resp + assert_schema(resp, "AccountRelationship", ApiSpec.spec()) + + resp = conn |> post("/api/v1/accounts/#{other_user_id}/unfollow") |> json_response(:ok) + + assert %{"id" => ^other_user_id, "following" => false, "requested" => false} = resp + assert_schema(resp, "AccountRelationship", ApiSpec.spec()) end test "following without reblogs" do @@ -690,6 +696,7 @@ test "following without reblogs" do ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow?reblogs=false") assert %{"showing_reblogs" => false} = json_response(ret_conn, 200) + assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) {:ok, reblog, _} = CommonAPI.repeat(activity.id, followed) @@ -701,6 +708,7 @@ test "following without reblogs" do ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow?reblogs=true") assert %{"showing_reblogs" => true} = json_response(ret_conn, 200) + assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) conn = get(conn, "/api/v1/timelines/home") -- cgit v1.2.3 From e4195d4a684908d58482f9c865375a080e7b78bc Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 18:28:14 +0400 Subject: Add specs for AccountController.mute and AccountController.unmute --- .../web/api_spec/operations/account_operation.ex | 41 ++++++++++++++++++++-- .../web/api_spec/schemas/account_mute_request.ex | 24 +++++++++++++ .../mastodon_api/controllers/account_controller.ex | 10 +++--- .../controllers/account_controller_test.exs | 13 +++++-- 4 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/account_mute_request.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 8925ebefd..62ae2eead 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountMuteRequest alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse alias Pleroma.Web.ApiSpec.Schemas.AccountsResponse @@ -239,8 +240,44 @@ def unfollow_operation do } end - def mute_operation, do: :ok - def unmute_operation, do: :ok + def mute_operation do + %Operation{ + tags: ["accounts"], + summary: "Mute", + operationId: "AccountController.mute", + security: [%{"oAuth" => ["follow", "write:mutes"]}], + requestBody: Helpers.request_body("Parameters", AccountMuteRequest), + description: + "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter( + :notifications, + :query, + %Schema{allOf: [BooleanLike], default: true}, + "Mute notifications in addition to statuses? Defaults to `true`." + ) + ], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def unmute_operation do + %Operation{ + tags: ["accounts"], + summary: "Unmute", + operationId: "AccountController.unmute", + security: [%{"oAuth" => ["follow", "write:mutes"]}], + description: "Unmute the given account.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + def block_operation, do: :ok def unblock_operation, do: :ok def follows_operation, do: :ok diff --git a/lib/pleroma/web/api_spec/schemas/account_mute_request.ex b/lib/pleroma/web/api_spec/schemas/account_mute_request.ex new file mode 100644 index 000000000..a61f6d04c --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_mute_request.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountMuteRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountMuteRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + notifications: %Schema{ + type: :boolean, + description: "Mute notifications in addition to statuses? Defaults to true.", + default: true + } + }, + example: %{ + "notifications" => true + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1ecce2928..9aba2e094 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -94,7 +94,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :following, :lists, :follow, - :unfollow + :unfollow, + :mute, + :unmute ] ) @@ -359,10 +361,8 @@ def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) d end @doc "POST /api/v1/accounts/:id/mute" - def mute(%{assigns: %{user: muter, account: muted}} = conn, params) do - notifications? = params |> Map.get("notifications", true) |> truthy_param?() - - with {:ok, _user_relationships} <- User.mute(muter, muted, notifications?) do + def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do + with {:ok, _user_relationships} <- User.mute(muter, muted, params.notifications) do render(conn, "relationship.json", user: muter, target: muted) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index d56e7fb4a..91d4685cb 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -751,32 +751,41 @@ test "following / unfollowing errors", %{user: user, conn: conn} do test "with notifications", %{conn: conn} do other_user = insert(:user) - ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/mute") + ret_conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/accounts/#{other_user.id}/mute") response = json_response(ret_conn, 200) assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = response + assert_schema(response, "AccountRelationship", ApiSpec.spec()) conn = post(conn, "/api/v1/accounts/#{other_user.id}/unmute") response = json_response(conn, 200) assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response + assert_schema(response, "AccountRelationship", ApiSpec.spec()) end test "without notifications", %{conn: conn} do other_user = insert(:user) ret_conn = - post(conn, "/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"}) + conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"}) response = json_response(ret_conn, 200) assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = response + assert_schema(response, "AccountRelationship", ApiSpec.spec()) conn = post(conn, "/api/v1/accounts/#{other_user.id}/unmute") response = json_response(conn, 200) assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response + assert_schema(response, "AccountRelationship", ApiSpec.spec()) end end -- cgit v1.2.3 From 68a979b8243b9a5b685df2c13388a93b9ede1900 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 18:41:18 +0400 Subject: Add specs for AccountController.block and AccountController.unblock --- .../web/api_spec/operations/account_operation.ex | 31 ++++++++++++++++++++-- .../mastodon_api/controllers/account_controller.ex | 4 ++- .../controllers/account_controller_test.exs | 2 ++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 62ae2eead..73fbe8785 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -278,8 +278,35 @@ def unmute_operation do } end - def block_operation, do: :ok - def unblock_operation, do: :ok + def block_operation do + %Operation{ + tags: ["accounts"], + summary: "Block", + operationId: "AccountController.block", + security: [%{"oAuth" => ["follow", "write:blocks"]}], + description: + "Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline)", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def unblock_operation do + %Operation{ + tags: ["accounts"], + summary: "Unblock", + operationId: "AccountController.unblock", + security: [%{"oAuth" => ["follow", "write:blocks"]}], + description: "Unblock the given account.", + parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + def follows_operation, do: :ok def mutes_operation, do: :ok def blocks_operation, do: :ok diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 9aba2e094..c1f70f32c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -96,7 +96,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :follow, :unfollow, :mute, - :unmute + :unmute, + :block, + :unblock ] ) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 91d4685cb..f71b54ade 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -819,10 +819,12 @@ test "blocking / unblocking a user" do ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/block") assert %{"id" => _id, "blocking" => true} = json_response(ret_conn, 200) + assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) conn = post(conn, "/api/v1/accounts/#{other_user.id}/unblock") assert %{"id" => _id, "blocking" => false} = json_response(conn, 200) + assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) end describe "create account by app" do -- cgit v1.2.3 From ab185d3ea47deb38128dc501acdf27c47c542de2 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 20:12:09 +0400 Subject: Add spec for AccountController.follows --- .../web/api_spec/operations/account_operation.ex | 15 ++++++++++++++- .../web/api_spec/schemas/account_follows_request.ex | 18 ++++++++++++++++++ .../mastodon_api/controllers/account_controller.ex | 5 +++-- .../controllers/account_controller_test.exs | 20 ++++++++++++++++---- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/account_follows_request.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 73fbe8785..9fef7ece1 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse + alias Pleroma.Web.ApiSpec.Schemas.AccountFollowsRequest alias Pleroma.Web.ApiSpec.Schemas.AccountMuteRequest alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse @@ -307,7 +308,19 @@ def unblock_operation do } end - def follows_operation, do: :ok + def follows_operation do + %Operation{ + tags: ["accounts"], + summary: "Follows", + operationId: "AccountController.follows", + security: [%{"oAuth" => ["follow", "write:follows"]}], + requestBody: Helpers.request_body("Parameters", AccountFollowsRequest, required: true), + responses: %{ + 200 => Operation.response("Account", "application/json", Account) + } + } + end + def mutes_operation, do: :ok def blocks_operation, do: :ok def endorsements_operation, do: :ok diff --git a/lib/pleroma/web/api_spec/schemas/account_follows_request.ex b/lib/pleroma/web/api_spec/schemas/account_follows_request.ex new file mode 100644 index 000000000..4fbe615d6 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/account_follows_request.ex @@ -0,0 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.AccountFollowsRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AccountFollowsRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + uri: %Schema{type: :string} + }, + required: [:uri] + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index c1f70f32c..4340b9c84 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -98,7 +98,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do :mute, :unmute, :block, - :unblock + :unblock, + :follows ] ) @@ -401,7 +402,7 @@ def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do end @doc "POST /api/v1/follows" - def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do + def follows(%{assigns: %{user: follower}, body_params: %{uri: uri}} = conn, _) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, {_, true} <- {:followed, follower.id != followed.id}, {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index f71b54ade..fa2091c5e 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -667,11 +667,14 @@ test "following / unfollowing a user", %{conn: conn} do assert %{"id" => _id, "following" => false} = json_response(ret_conn, 200) assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) - conn = post(conn, "/api/v1/follows", %{"uri" => other_user.nickname}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/follows", %{"uri" => other_user.nickname}) assert %{"id" => id} = json_response(conn, 200) assert id == to_string(other_user.id) - assert_schema(json_response(conn, 200), "AccountRelationship", ApiSpec.spec()) + assert_schema(json_response(conn, 200), "Account", ApiSpec.spec()) end test "cancelling follow request", %{conn: conn} do @@ -728,7 +731,12 @@ test "following / unfollowing errors", %{user: user, conn: conn} do # self follow via uri user = User.get_cached_by_id(user.id) - conn_res = post(conn, "/api/v1/follows", %{"uri" => user.nickname}) + + conn_res = + conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v1/follows", %{"uri" => user.nickname}) + assert %{"error" => "Record not found"} = json_response(conn_res, 404) # follow non existing user @@ -736,7 +744,11 @@ test "following / unfollowing errors", %{user: user, conn: conn} do assert %{"error" => "Record not found"} = json_response(conn_res, 404) # follow non existing user via uri - conn_res = post(conn, "/api/v1/follows", %{"uri" => "doesntexist"}) + conn_res = + conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v1/follows", %{"uri" => "doesntexist"}) + assert %{"error" => "Record not found"} = json_response(conn_res, 404) # unfollow non existing user -- cgit v1.2.3 From 7e0b42d99f3eb9520bc29cc29c06512c55183482 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 9 Apr 2020 20:34:21 +0400 Subject: Add specs for AccountController.mutes, AccountController.blocks, AccountController.mutes, AccountController.endorsements --- .../web/api_spec/operations/account_operation.ex | 41 ++++++++++++++++++++-- .../mastodon_api/controllers/account_controller.ex | 23 +----------- .../controllers/account_controller_test.exs | 2 ++ 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9fef7ece1..9749c3b60 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -321,7 +321,42 @@ def follows_operation do } end - def mutes_operation, do: :ok - def blocks_operation, do: :ok - def endorsements_operation, do: :ok + def mutes_operation do + %Operation{ + tags: ["accounts"], + summary: "Muted accounts", + operationId: "AccountController.mutes", + description: "Accounts the user has muted.", + security: [%{"oAuth" => ["follow", "read:mutes"]}], + responses: %{ + 200 => Operation.response("Accounts", "application/json", AccountsResponse) + } + } + end + + def blocks_operation do + %Operation{ + tags: ["accounts"], + summary: "Blocked users", + operationId: "AccountController.blocks", + description: "View your blocks. See also accounts/:id/{block,unblock}", + security: [%{"oAuth" => ["read:blocks"]}], + responses: %{ + 200 => Operation.response("Accounts", "application/json", AccountsResponse) + } + } + end + + def endorsements_operation do + %Operation{ + tags: ["accounts"], + summary: "Endorsements", + operationId: "AccountController.endorsements", + description: "Not implemented", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => Operation.response("Empry array", "application/json", %Schema{type: :array}) + } + } + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 4340b9c84..f72c91c51 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -80,28 +80,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(RateLimiter, [name: :app_account_creation] when action == :create) plug(:assign_account_by_id when action in @needs_account) - plug( - OpenApiSpex.Plug.CastAndValidate, - [render_error: Pleroma.Web.ApiSpec.RenderError] - when action in [ - :create, - :verify_credentials, - :update_credentials, - :relationships, - :show, - :statuses, - :followers, - :following, - :lists, - :follow, - :unfollow, - :mute, - :unmute, - :block, - :unblock, - :follows - ] - ) + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index fa2091c5e..86136f7e4 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -1155,6 +1155,7 @@ test "getting a list of mutes" do other_user_id = to_string(other_user.id) assert [%{"id" => ^other_user_id}] = json_response(conn, 200) + assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end test "getting a list of blocks" do @@ -1170,5 +1171,6 @@ test "getting a list of blocks" do other_user_id = to_string(other_user.id) assert [%{"id" => ^other_user_id}] = json_response(conn, 200) + assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end end -- cgit v1.2.3 From c28aaf9d82a781508eba886bd455767a110d1b7c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 13 Apr 2020 21:21:04 +0400 Subject: Add OpenAPI spec for CustomEmojiController --- .../api_spec/operations/custom_emoji_operation.ex | 25 +++++++++++++ lib/pleroma/web/api_spec/schemas/custom_emoji.ex | 30 ++++++++++++++++ .../web/api_spec/schemas/custom_emojis_response.ex | 42 ++++++++++++++++++++++ .../controllers/custom_emoji_controller.ex | 4 +++ .../controllers/custom_emoji_controller_test.exs | 27 +++++++++++--- 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/custom_emoji.ex create mode 100644 lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex diff --git a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex new file mode 100644 index 000000000..cf2215823 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.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.ApiSpec.CustomEmojiOperation do + alias OpenApiSpex.Operation + alias Pleroma.Web.ApiSpec.Schemas.CustomEmojisResponse + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["custom_emojis"], + summary: "List custom custom emojis", + description: "Returns custom emojis that are available on the server.", + operationId: "CustomEmojiController.index", + responses: %{ + 200 => Operation.response("Custom Emojis", "application/json", CustomEmojisResponse) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/custom_emoji.ex b/lib/pleroma/web/api_spec/schemas/custom_emoji.ex new file mode 100644 index 000000000..5531b2081 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/custom_emoji.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.CustomEmoji do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CustomEmoji", + description: "Response schema for an CustomEmoji", + type: :object, + properties: %{ + shortcode: %Schema{type: :string}, + url: %Schema{type: :string}, + static_url: %Schema{type: :string}, + visible_in_picker: %Schema{type: :boolean}, + category: %Schema{type: :string}, + tags: %Schema{type: :array} + }, + example: %{ + "shortcode" => "aaaa", + "url" => "https://files.mastodon.social/custom_emojis/images/000/007/118/original/aaaa.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/static/aaaa.png", + "visible_in_picker" => true + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex b/lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex new file mode 100644 index 000000000..01582a63d --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.CustomEmojisResponse do + alias Pleroma.Web.ApiSpec.Schemas.CustomEmoji + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "CustomEmojisResponse", + description: "Response schema for custom emojis", + type: :array, + items: CustomEmoji, + example: [ + %{ + "category" => "Fun", + "shortcode" => "blank", + "static_url" => "https://lain.com/emoji/blank.png", + "tags" => ["Fun"], + "url" => "https://lain.com/emoji/blank.png", + "visible_in_picker" => true + }, + %{ + "category" => "Gif,Fun", + "shortcode" => "firefox", + "static_url" => "https://lain.com/emoji/Firefox.gif", + "tags" => ["Gif", "Fun"], + "url" => "https://lain.com/emoji/Firefox.gif", + "visible_in_picker" => true + }, + %{ + "category" => "pack:mixed", + "shortcode" => "sadcat", + "static_url" => "https://lain.com/emoji/mixed/sadcat.png", + "tags" => ["pack:mixed"], + "url" => "https://lain.com/emoji/mixed/sadcat.png", + "visible_in_picker" => true + } + ] + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex index d82de1db5..3bfebef8b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -5,6 +5,10 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do use Pleroma.Web, :controller + plug(OpenApiSpex.Plug.CastAndValidate) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation + def index(conn, _params) do render(conn, "index.json", custom_emojis: Pleroma.Emoji.get_all()) end diff --git a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs index 6567a0667..0b2ffa470 100644 --- a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs +++ b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs @@ -4,13 +4,18 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiControllerTest do use Pleroma.Web.ConnCase, async: true + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.CustomEmoji + alias Pleroma.Web.ApiSpec.Schemas.CustomEmojisResponse + import OpenApiSpex.TestAssertions test "with tags", %{conn: conn} do - [emoji | _body] = - conn - |> get("/api/v1/custom_emojis") - |> json_response(200) + assert resp = + conn + |> get("/api/v1/custom_emojis") + |> json_response(200) + assert [emoji | _body] = resp assert Map.has_key?(emoji, "shortcode") assert Map.has_key?(emoji, "static_url") assert Map.has_key?(emoji, "tags") @@ -18,5 +23,19 @@ test "with tags", %{conn: conn} do assert Map.has_key?(emoji, "category") assert Map.has_key?(emoji, "url") assert Map.has_key?(emoji, "visible_in_picker") + assert_schema(resp, "CustomEmojisResponse", ApiSpec.spec()) + assert_schema(emoji, "CustomEmoji", ApiSpec.spec()) + end + + test "CustomEmoji example matches schema" do + api_spec = ApiSpec.spec() + schema = CustomEmoji.schema() + assert_schema(schema.example, "CustomEmoji", api_spec) + end + + test "CustomEmojisResponse example matches schema" do + api_spec = ApiSpec.spec() + schema = CustomEmojisResponse.schema() + assert_schema(schema.example, "CustomEmojisResponse", api_spec) end end -- cgit v1.2.3 From 4dca712e90a81a1a754608eb4f8e22dae99eb755 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 13 Apr 2020 22:44:52 +0400 Subject: Add OpenAPI spec for DomainBlockController --- lib/pleroma/web/api_spec.ex | 2 +- .../api_spec/operations/domain_block_operation.ex | 64 ++++++++++++++++++++++ .../web/api_spec/schemas/domain_block_request.ex | 20 +++++++ .../web/api_spec/schemas/domain_blocks_response.ex | 16 ++++++ .../controllers/domain_block_controller.ex | 7 ++- .../controllers/domain_block_controller_test.exs | 20 ++++++- 6 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/domain_block_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/domain_block_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 41e48a085..3890489e3 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -31,7 +31,7 @@ def spec do password: %OpenApiSpex.OAuthFlow{ authorizationUrl: "/oauth/authorize", tokenUrl: "/oauth/token", - scopes: %{"read" => "read"} + scopes: %{"read" => "read", "write" => "write", "follow" => "follow"} } } } diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex new file mode 100644 index 000000000..dd14837c3 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.DomainBlockRequest + alias Pleroma.Web.ApiSpec.Schemas.DomainBlocksResponse + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["domain_blocks"], + summary: "Fetch domain blocks", + description: "View domains the user has blocked.", + security: [%{"oAuth" => ["follow", "read:blocks"]}], + operationId: "DomainBlockController.index", + responses: %{ + 200 => Operation.response("Domain blocks", "application/json", DomainBlocksResponse) + } + } + end + + def create_operation do + %Operation{ + tags: ["domain_blocks"], + summary: "Block a domain", + description: """ + Block a domain to: + + - hide all public posts from it + - hide all notifications from it + - remove all followers from it + - prevent following new users from it (but does not remove existing follows) + """, + operationId: "DomainBlockController.create", + requestBody: Helpers.request_body("Parameters", DomainBlockRequest, required: true), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def delete_operation do + %Operation{ + tags: ["domain_blocks"], + summary: "Unblock a domain", + description: "Remove a domain block, if it exists in the user's array of blocked domains.", + operationId: "DomainBlockController.delete", + requestBody: Helpers.request_body("Parameters", DomainBlockRequest, required: true), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/domain_block_request.ex b/lib/pleroma/web/api_spec/schemas/domain_block_request.ex new file mode 100644 index 000000000..ee9238361 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/domain_block_request.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.DomainBlockRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "DomainBlockRequest", + type: :object, + properties: %{ + domain: %Schema{type: :string} + }, + required: [:domain], + example: %{ + "domain" => "facebook.com" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex b/lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex new file mode 100644 index 000000000..d895aca4e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.DomainBlocksResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "DomainBlocksResponse", + description: "Response schema for domain blocks", + type: :array, + items: %Schema{type: :string}, + example: ["google.com", "facebook.com"] + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index e4156cbe6..84de79413 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -8,6 +8,9 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User + plug(OpenApiSpex.Plug.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation + plug( OAuthScopesPlug, %{scopes: ["follow", "read:blocks"]} when action == :index @@ -26,13 +29,13 @@ def index(%{assigns: %{user: user}} = conn, _) do end @doc "POST /api/v1/domain_blocks" - def create(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + def create(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do User.block_domain(blocker, domain) json(conn, %{}) end @doc "DELETE /api/v1/domain_blocks" - def delete(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) do + def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do User.unblock_domain(blocker, domain) json(conn, %{}) end diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs index 8d24b3b88..d66190c90 100644 --- a/test/web/mastodon_api/controllers/domain_block_controller_test.exs +++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -6,20 +6,29 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do use Pleroma.Web.ConnCase alias Pleroma.User + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.DomainBlocksResponse import Pleroma.Factory + import OpenApiSpex.TestAssertions test "blocking / unblocking a domain" do %{user: user, conn: conn} = oauth_access(["write:blocks"]) other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"}) - ret_conn = post(conn, "/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) + ret_conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) assert %{} = json_response(ret_conn, 200) user = User.get_cached_by_ap_id(user.ap_id) assert User.blocks?(user, other_user) - ret_conn = delete(conn, "/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) + ret_conn = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) assert %{} = json_response(ret_conn, 200) user = User.get_cached_by_ap_id(user.ap_id) @@ -41,5 +50,12 @@ test "getting a list of domain blocks" do assert "bad.site" in domain_blocks assert "even.worse.site" in domain_blocks + assert_schema(domain_blocks, "DomainBlocksResponse", ApiSpec.spec()) + end + + test "DomainBlocksResponse example matches schema" do + api_spec = ApiSpec.spec() + schema = DomainBlocksResponse.schema() + assert_schema(schema.example, "DomainBlocksResponse", api_spec) end end -- cgit v1.2.3 From f3725b8fc4506e4bb400878214b9886ed954590f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 13 Apr 2020 17:04:43 -0500 Subject: Fix spelling --- 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 4d4ba913c..acafe1bea 100644 --- a/lib/pleroma/pool/connections.ex +++ b/lib/pleroma/pool/connections.ex @@ -243,7 +243,7 @@ def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do @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)}") + Logger.debug("received DOWN message for #{inspect(conn_pid)} reason -> #{inspect(reason)}") state = with {key, conn} <- find_conn(state.conns, conn_pid) do -- cgit v1.2.3 From c4e7ed660c0c561d7b014664ca393d39dc7ee29a Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 14 Apr 2020 08:43:47 +0300 Subject: fix logger message --- lib/pleroma/plugs/mapped_signature_to_identity_plug.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex index 4f124ed4d..84b7c5d83 100644 --- a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex +++ b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex @@ -42,13 +42,13 @@ def call(%{assigns: %{valid_signature: true}, params: %{"actor" => actor}} = con else {:user_match, false} -> Logger.debug("Failed to map identity from signature (payload actor mismatch)") - Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{inspect(actor)}") assign(conn, :valid_signature, false) # remove me once testsuite uses mapped capabilities instead of what we do now {:user, nil} -> Logger.debug("Failed to map identity from signature (lookup failure)") - Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}, actor=#{actor}") conn end end @@ -60,7 +60,7 @@ def call(%{assigns: %{valid_signature: true}} = conn, _opts) do else _ -> Logger.debug("Failed to map identity from signature (no payload actor mismatch)") - Logger.debug("key_id=#{key_id_from_conn(conn)}") + Logger.debug("key_id=#{inspect(key_id_from_conn(conn))}") assign(conn, :valid_signature, false) end end -- cgit v1.2.3 From d8b12ffd5909a2698cce50d81b69f00b8893d41b Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 14 Apr 2020 15:06:09 +0200 Subject: Marker update migration: Don't try to update virtual field. --- priv/repo/migrations/20200210050658_update_markers.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200210050658_update_markers.exs b/priv/repo/migrations/20200210050658_update_markers.exs index b280e156c..db7a355ec 100644 --- a/priv/repo/migrations/20200210050658_update_markers.exs +++ b/priv/repo/migrations/20200210050658_update_markers.exs @@ -32,7 +32,7 @@ defp update_markers do end) Repo.insert_all("markers", markers_attrs, - on_conflict: {:replace, [:last_read_id, :unread_count]}, + on_conflict: {:replace, [:last_read_id]}, conflict_target: [:user_id, :timeline] ) end -- cgit v1.2.3 From 7c060432fcac294269742ac3f452beb3f9a2fdab Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 14 Apr 2020 16:31:30 +0000 Subject: Revert "Merge branch 'marker-update-fix' into 'develop'" This reverts merge request !2380 --- priv/repo/migrations/20200210050658_update_markers.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200210050658_update_markers.exs b/priv/repo/migrations/20200210050658_update_markers.exs index db7a355ec..b280e156c 100644 --- a/priv/repo/migrations/20200210050658_update_markers.exs +++ b/priv/repo/migrations/20200210050658_update_markers.exs @@ -32,7 +32,7 @@ defp update_markers do end) Repo.insert_all("markers", markers_attrs, - on_conflict: {:replace, [:last_read_id]}, + on_conflict: {:replace, [:last_read_id, :unread_count]}, conflict_target: [:user_id, :timeline] ) end -- cgit v1.2.3 From 4576520461e2e3a1c78133aaf31cb742a2a1a689 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 14 Apr 2020 16:32:22 +0000 Subject: Revert "Merge branch 'issue/1276' into 'develop'" This reverts merge request !1877 --- CHANGELOG.md | 1 - docs/API/differences_in_mastoapi_responses.md | 17 +++----- lib/pleroma/marker.ex | 45 +------------------ lib/pleroma/notification.ex | 47 ++++++-------------- lib/pleroma/web/mastodon_api/views/marker_view.ex | 5 +-- mix.lock | 50 +++++++++++----------- .../migrations/20200210050658_update_markers.exs | 39 ----------------- test/marker_test.exs | 29 +------------ test/notification_test.exs | 13 ------ .../controllers/marker_controller_test.exs | 10 ++--- test/web/mastodon_api/views/marker_view_test.exs | 8 ++-- 11 files changed, 51 insertions(+), 213 deletions(-) delete mode 100644 priv/repo/migrations/20200210050658_update_markers.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7fc1802..56b235f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,7 +123,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: `pleroma.thread_muted` to the Status entity - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message - Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload. -- Mastodon API: Add `pleroma.unread_count` to the Marker entity - Admin API: Render whole status in grouped reports - 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. diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 0a7520f9e..1059155cf 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -185,15 +185,8 @@ Post here request with `grant_type=refresh_token` to obtain new access token. Re 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 registrations aren't public. - - -## Markers - -Has these additional fields under the `pleroma` object: - -- `unread_count`: contains number unread notifications +- `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. diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 4d82860f5..443927392 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -9,34 +9,24 @@ defmodule Pleroma.Marker do import Ecto.Query alias Ecto.Multi - alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User - alias __MODULE__ @timelines ["notifications"] - @type t :: %__MODULE__{} schema "markers" do field(:last_read_id, :string, default: "") field(:timeline, :string, default: "") field(:lock_version, :integer, default: 0) - field(:unread_count, :integer, default: 0, virtual: true) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() end - @doc "Gets markers by user and timeline." - @spec get_markers(User.t(), list(String)) :: list(t()) def get_markers(user, timelines \\ []) do - user - |> get_query(timelines) - |> unread_count_query() - |> Repo.all() + Repo.all(get_query(user, timelines)) end - @spec upsert(User.t(), map()) :: {:ok | :error, any()} def upsert(%User{} = user, attrs) do attrs |> Map.take(@timelines) @@ -55,27 +45,6 @@ def upsert(%User{} = user, attrs) do |> Repo.transaction() end - @spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t() - def multi_set_last_read_id(multi, %User{} = user, "notifications") do - multi - |> Multi.run(:counters, fn _repo, _changes -> - {:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}} - end) - |> Multi.insert( - :marker, - fn %{counters: attrs} -> - %Marker{timeline: "notifications", user_id: user.id} - |> struct(attrs) - |> Ecto.Changeset.change() - end, - returning: true, - on_conflict: {:replace, [:last_read_id]}, - conflict_target: [:user_id, :timeline] - ) - end - - def multi_set_last_read_id(multi, _, _), do: multi - defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do {:ok, marker} -> %__MODULE__{marker | user: user} @@ -102,16 +71,4 @@ defp get_query(user, timelines) do |> by_user_id(user.id) |> by_timeline(timelines) end - - defp unread_count_query(query) do - from( - q in query, - left_join: n in "notifications", - on: n.user_id == q.user_id and n.seen == false, - group_by: [:id], - select_merge: %{ - unread_count: fragment("count(?)", n.id) - } - ) - end end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 3084bac3b..04ee510b9 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -5,9 +5,7 @@ defmodule Pleroma.Notification do use Ecto.Schema - alias Ecto.Multi alias Pleroma.Activity - alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination @@ -40,17 +38,6 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end - @spec last_read_query(User.t()) :: Ecto.Queryable.t() - def last_read_query(user) do - from(q in Pleroma.Notification, - where: q.user_id == ^user.id, - where: q.seen == true, - select: type(q.id, :string), - limit: 1, - order_by: [desc: :id] - ) - end - defp for_user_query_ap_id_opts(user, opts) do ap_id_relationships = [:block] ++ @@ -199,23 +186,25 @@ def for_user_since(user, date) do |> Repo.all() end - def set_read_up_to(%{id: user_id} = user, id) do + def set_read_up_to(%{id: user_id} = _user, id) do query = from( n in Notification, where: n.user_id == ^user_id, where: n.id <= ^id, where: n.seen == false, + update: [ + set: [ + seen: true, + updated_at: ^NaiveDateTime.utc_now() + ] + ], # Ideally we would preload object and activities here # but Ecto does not support preloads in update_all select: n.id ) - {:ok, %{ids: {_, notification_ids}}} = - Multi.new() - |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) - |> Marker.multi_set_last_read_id(user, "notifications") - |> Repo.transaction() + {_, notification_ids} = Repo.update_all(query, []) Notification |> where([n], n.id in ^notification_ids) @@ -232,18 +221,11 @@ def set_read_up_to(%{id: user_id} = user, id) do |> Repo.all() end - @spec read_one(User.t(), String.t()) :: - {:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil def read_one(%User{} = user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do - Multi.new() - |> Multi.update(:update, changeset(notification, %{seen: true})) - |> Marker.multi_set_last_read_id(user, "notifications") - |> Repo.transaction() - |> case do - {:ok, %{update: notification}} -> {:ok, notification} - {:error, :update, changeset, _} -> {:error, changeset} - end + notification + |> changeset(%{seen: true}) + |> Repo.update() end end @@ -325,11 +307,8 @@ defp do_create_notifications(%Activity{} = activity) do # TODO move to sql, too. def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do unless skip?(activity, user) do - {:ok, %{notification: notification}} = - Multi.new() - |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) - |> Marker.multi_set_last_read_id(user, "notifications") - |> Repo.transaction() + notification = %Notification{user_id: user.id, activity: activity} + {:ok, notification} = Repo.insert(notification) if do_send do Streamer.stream(["user", "user:notification"], notification) diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 415dae93b..985368fe5 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -10,10 +10,7 @@ def render("markers.json", %{markers: markers}) do Map.put_new(acc, m.timeline, %{ last_read_id: m.last_read_id, version: m.lock_version, - updated_at: NaiveDateTime.to_iso8601(m.updated_at), - pleroma: %{ - unread_count: m.unread_count - } + updated_at: NaiveDateTime.to_iso8601(m.updated_at) }) end) end diff --git a/mix.lock b/mix.lock index 23467bbb4..ba4e3ac44 100644 --- a/mix.lock +++ b/mix.lock @@ -2,8 +2,8 @@ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, - "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, + "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, @@ -19,47 +19,47 @@ "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, - "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "48e513299cd28b12c77266c0ed5b1c844368e5c1823724994ae84834f43d6bbe"}, + "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, - "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"}, + "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, - "esshd": {:hex, :esshd, "0.1.0", "6f93a2062adb43637edad0ea7357db2702a4b80dd9683482fe00f5134e97f4c1", [:mix], [], "hexpm", "98d0f3c6f4b8a0333170df770c6fe772b3d04564fb514c1a09504cf5ab2f48a5"}, + "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.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.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"}, + "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"}, + "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, - "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm", "8453e2289d94c3199396eb517d65d6715ef26bcae0ee83eb5ff7a84445458d76"}, - "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm", "5cacd405e72b2609a7e1f891bddb80c53d0b3b7b0036d1648e7382ca108c41c8"}, - "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm", "f7d97341e536f95b96eef2988d6d4230f7262cf239cda0e2e63123ee0b717222"}, + "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, - "httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "89149056039084024a284cd703b2d1900d584958dba432132cb21ef35aed7487"}, + "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.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, - "joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "eb02df7d5526df13063397e051b926b7006d5986d66f399eefc474f560cdad6a"}, - "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm", "6429c4fee52b2dda7861ee19a4f09c8c1ffa213bee3a1ec187828fde95d447ed"}, + "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, + "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, - "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm", "1feaf05ee886815ad047cad7ede17d6910710986148ae09cf73eee2989717b81"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, @@ -71,37 +71,35 @@ "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.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {: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.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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", "256ad7a140efadc3f0290470369da5bd3de985ec7c706eba07c2641b228974be"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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", "fe15d9fee5b82f5e64800502011ffe530650d42e1710ae9b14bc4c9be38bf303"}, - "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8b01b3d6d39731ab18aa548d928b5796166d2500755f553725cfe967bafba7d9"}, + "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.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, - "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, + "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.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, + "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, "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"}, - "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm", "6de553ba9ac0668d3728b699d5065543f3e40c854154017461ee8c09038752da"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, "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"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, - "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"}, diff --git a/priv/repo/migrations/20200210050658_update_markers.exs b/priv/repo/migrations/20200210050658_update_markers.exs deleted file mode 100644 index b280e156c..000000000 --- a/priv/repo/migrations/20200210050658_update_markers.exs +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Pleroma.Repo.Migrations.UpdateMarkers do - use Ecto.Migration - import Ecto.Query - alias Pleroma.Repo - - def up do - update_markers() - end - - def down do - :ok - end - - defp update_markers do - now = NaiveDateTime.utc_now() - - markers_attrs = - from(q in "notifications", - select: %{ - timeline: "notifications", - user_id: q.user_id, - last_read_id: - type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) - }, - group_by: [q.user_id] - ) - |> Repo.all() - |> Enum.map(fn attrs -> - attrs - |> Map.put_new(:inserted_at, now) - |> Map.put_new(:updated_at, now) - end) - - Repo.insert_all("markers", markers_attrs, - on_conflict: {:replace, [:last_read_id, :unread_count]}, - conflict_target: [:user_id, :timeline] - ) - end -end diff --git a/test/marker_test.exs b/test/marker_test.exs index 5b6d0b4a4..c80ae16b6 100644 --- a/test/marker_test.exs +++ b/test/marker_test.exs @@ -8,39 +8,12 @@ defmodule Pleroma.MarkerTest do import Pleroma.Factory - describe "multi_set_unread_count/3" do - test "returns multi" do - user = insert(:user) - - assert %Ecto.Multi{ - operations: [marker: {:run, _}, counters: {:run, _}] - } = - Marker.multi_set_last_read_id( - Ecto.Multi.new(), - user, - "notifications" - ) - end - - test "return empty multi" do - user = insert(:user) - multi = Ecto.Multi.new() - assert Marker.multi_set_last_read_id(multi, user, "home") == multi - end - end - describe "get_markers/2" do test "returns user markers" do user = insert(:user) marker = insert(:marker, user: user) - insert(:notification, user: user) - insert(:notification, user: user) insert(:marker, timeline: "home", user: user) - - assert Marker.get_markers( - user, - ["notifications"] - ) == [%Marker{refresh_record(marker) | unread_count: 2}] + assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)] end end diff --git a/test/notification_test.exs b/test/notification_test.exs index f78a47af6..837a9dacd 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -45,9 +45,6 @@ test "notifies someone when they are directly addressed" do assert notified_ids == [other_user.id, third_user.id] assert notification.activity_id == activity.id assert other_notification.activity_id == activity.id - - assert [%Pleroma.Marker{unread_count: 2}] = - Pleroma.Marker.get_markers(other_user, ["notifications"]) end test "it creates a notification for subscribed users" do @@ -413,16 +410,6 @@ test "it sets all notifications as read up to a specified notification ID" do assert n1.seen == true assert n2.seen == true assert n3.seen == false - - assert %Pleroma.Marker{} = - m = - Pleroma.Repo.get_by( - Pleroma.Marker, - user_id: other_user.id, - timeline: "notifications" - ) - - assert m.last_read_id == to_string(n2.id) end end diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 7280abd10..919f295bd 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -11,7 +11,6 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do test "gets markers with correct scopes", %{conn: conn} do user = insert(:user) token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) - insert_list(7, :notification, user: user) {:ok, %{"notifications" => marker}} = Pleroma.Marker.upsert( @@ -30,8 +29,7 @@ test "gets markers with correct scopes", %{conn: conn} do "notifications" => %{ "last_read_id" => "69420", "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0, - "pleroma" => %{"unread_count" => 7} + "version" => 0 } } end @@ -72,8 +70,7 @@ test "creates a marker with correct scopes", %{conn: conn} do "notifications" => %{ "last_read_id" => "69420", "updated_at" => _, - "version" => 0, - "pleroma" => %{"unread_count" => 0} + "version" => 0 } } = response end @@ -102,8 +99,7 @@ test "updates exist marker", %{conn: conn} do "notifications" => %{ "last_read_id" => "69888", "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0, - "pleroma" => %{"unread_count" => 0} + "version" => 0 } } end diff --git a/test/web/mastodon_api/views/marker_view_test.exs b/test/web/mastodon_api/views/marker_view_test.exs index 48a0a6d33..893cf8857 100644 --- a/test/web/mastodon_api/views/marker_view_test.exs +++ b/test/web/mastodon_api/views/marker_view_test.exs @@ -8,21 +8,19 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do import Pleroma.Factory test "returns markers" do - marker1 = insert(:marker, timeline: "notifications", last_read_id: "17", unread_count: 5) + marker1 = insert(:marker, timeline: "notifications", last_read_id: "17") marker2 = insert(:marker, timeline: "home", last_read_id: "42") assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{ "home" => %{ last_read_id: "42", updated_at: NaiveDateTime.to_iso8601(marker2.updated_at), - version: 0, - pleroma: %{unread_count: 0} + version: 0 }, "notifications" => %{ last_read_id: "17", updated_at: NaiveDateTime.to_iso8601(marker1.updated_at), - version: 0, - pleroma: %{unread_count: 5} + version: 0 } } end -- cgit v1.2.3 From 3bf78f2be7c151cd64ed954570dbf8592e836f56 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 14 Apr 2020 11:43:53 -0500 Subject: Fix Oban not receiving :ok from RichMediaHelper job --- lib/pleroma/web/rich_media/helpers.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 0314535d2..9d3d7f978 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -64,5 +64,8 @@ def fetch_data_for_activity(%Activity{data: %{"type" => "Create"}} = activity) d def fetch_data_for_activity(_), do: %{} - def perform(:fetch, %Activity{} = activity), do: fetch_data_for_activity(activity) + def perform(:fetch, %Activity{} = activity) do + fetch_data_for_activity(activity) + :ok + end end -- cgit v1.2.3 From f7e623c11c4b6f4f323a4317e9489092be73f9cd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 14 Apr 2020 20:19:08 +0300 Subject: [#1364] Resolved merge conflicts with `develop`. --- lib/pleroma/notification.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index f517282f7..b76dd176c 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Notification do alias Pleroma.Activity alias Pleroma.FollowingRelationship - alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination -- cgit v1.2.3 From cc4ff19e34fae2c4ba944e235861b6cb800b7c86 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 15 Apr 2020 00:49:21 +0300 Subject: openapi: add application/x-www-form-urlencoded to body types Closes #1683 --- lib/pleroma/web/api_spec/helpers.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index 35cf4c0d8..7348dcbee 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ApiSpec.Helpers do def request_body(description, schema_ref, opts \\ []) do - media_types = ["application/json", "multipart/form-data"] + media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] content = media_types -- cgit v1.2.3 From 6bc76df287d7f4beb35c3a55b784b07ce9d833ff Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 15 Apr 2020 12:05:22 +0200 Subject: Uploads: Sandbox them in the CSP. --- lib/pleroma/plugs/uploaded_media.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 36ff024a7..94147e0c4 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -41,6 +41,7 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do conn -> conn end + |> merge_resp_headers([{"content-security-policy", "sandbox"}]) config = Pleroma.Config.get(Pleroma.Upload) -- cgit v1.2.3 From d3e876aeeebfcdd2821ef8310bd60b785e6df560 Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 10:26:44 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9749c3b60..7ead44197 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -120,7 +120,8 @@ def statuses_operation do Operation.parameter(:tagged, :query, :string, "With tag"), Operation.parameter(:only_media, :query, BooleanLike, "Only meadia"), Operation.parameter(:with_muted, :query, BooleanLike, "With muted"), - Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblobs"), + Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), + Operation.parameter( :exclude_visibilities, :query, -- cgit v1.2.3 From a7feca1604fe7f22d10c0fd3284f14eae8609852 Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 10:26:53 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 7ead44197..1c726a612 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -119,7 +119,7 @@ def statuses_operation do Operation.parameter(:pinned, :query, BooleanLike, "Pinned"), Operation.parameter(:tagged, :query, :string, "With tag"), Operation.parameter(:only_media, :query, BooleanLike, "Only meadia"), - Operation.parameter(:with_muted, :query, BooleanLike, "With muted"), + Operation.parameter(:with_muted, :query, BooleanLike, "Include statuses from muted acccounts."), Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), Operation.parameter( -- cgit v1.2.3 From a794ba655f5a0a8b5512ad718601e5a03b9aebef Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 10:27:01 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 1c726a612..6ce2cfe25 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -118,7 +118,7 @@ def statuses_operation do %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, Operation.parameter(:pinned, :query, BooleanLike, "Pinned"), Operation.parameter(:tagged, :query, :string, "With tag"), - Operation.parameter(:only_media, :query, BooleanLike, "Only meadia"), + Operation.parameter(:only_media, :query, BooleanLike, "Include only statuses with media attached"), Operation.parameter(:with_muted, :query, BooleanLike, "Include statuses from muted acccounts."), Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), -- cgit v1.2.3 From bfa26b09370ee049f8d70c4112709f2666c590d1 Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 10:30:19 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 6ce2cfe25..7d4f7586d 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -129,7 +129,7 @@ def statuses_operation do "Exclude visibilities" ), Operation.parameter(:max_id, :query, :string, "Max ID"), - Operation.parameter(:min_id, :query, :string, "Mix ID"), + Operation.parameter(:min_id, :query, :string, "Return the oldest statuses newer than this id. "), Operation.parameter(:since_id, :query, :string, "Since ID"), Operation.parameter( :limit, -- cgit v1.2.3 From a45bd91d4e79ed354ab3903b195cf74e4327d4d0 Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 10:48:32 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 7d4f7586d..31dfbb098 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -116,7 +116,7 @@ def statuses_operation do "Statuses posted to the given account. Public (for public statuses only), or user token + `read:statuses` (for private statuses the user is authorized to see)", parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, - Operation.parameter(:pinned, :query, BooleanLike, "Pinned"), + Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), Operation.parameter(:tagged, :query, :string, "With tag"), Operation.parameter(:only_media, :query, BooleanLike, "Include only statuses with media attached"), Operation.parameter(:with_muted, :query, BooleanLike, "Include statuses from muted acccounts."), -- cgit v1.2.3 From 81a4c15816bf4fbe3e70ba1d34adff5dfaee1cbc Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 10:48:52 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 31dfbb098..dee28d1aa 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -128,7 +128,7 @@ def statuses_operation do %Schema{type: :array, items: VisibilityScope}, "Exclude visibilities" ), - Operation.parameter(:max_id, :query, :string, "Max ID"), + Operation.parameter(:max_id, :query, :string, "Return statuses older than this id"), Operation.parameter(:min_id, :query, :string, "Return the oldest statuses newer than this id. "), Operation.parameter(:since_id, :query, :string, "Since ID"), Operation.parameter( -- cgit v1.2.3 From 5a2e45a2189514662f46a293f764682daba7b52d Mon Sep 17 00:00:00 2001 From: minibikini Date: Wed, 15 Apr 2020 11:29:10 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/account_operation.ex --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index dee28d1aa..92622e2ff 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -130,7 +130,7 @@ def statuses_operation do ), Operation.parameter(:max_id, :query, :string, "Return statuses older than this id"), Operation.parameter(:min_id, :query, :string, "Return the oldest statuses newer than this id. "), - Operation.parameter(:since_id, :query, :string, "Since ID"), + Operation.parameter(:since_id, :query, :string, "Return the newest statuses newer than this id. "), Operation.parameter( :limit, :query, -- cgit v1.2.3 From 8ed162b65538ee3cb5b125587fd65657b36ca143 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 15 Apr 2020 15:39:32 +0400 Subject: Fix formatting --- .../web/api_spec/operations/account_operation.ex | 31 +++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 92622e2ff..6c9de51bb 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -118,19 +118,38 @@ def statuses_operation do %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), Operation.parameter(:tagged, :query, :string, "With tag"), - Operation.parameter(:only_media, :query, BooleanLike, "Include only statuses with media attached"), - Operation.parameter(:with_muted, :query, BooleanLike, "Include statuses from muted acccounts."), + Operation.parameter( + :only_media, + :query, + BooleanLike, + "Include only statuses with media attached" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include statuses from muted acccounts." + ), Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), - Operation.parameter( :exclude_visibilities, :query, %Schema{type: :array, items: VisibilityScope}, "Exclude visibilities" ), - Operation.parameter(:max_id, :query, :string, "Return statuses older than this id"), - Operation.parameter(:min_id, :query, :string, "Return the oldest statuses newer than this id. "), - Operation.parameter(:since_id, :query, :string, "Return the newest statuses newer than this id. "), + Operation.parameter(:max_id, :query, :string, "Return statuses older than this ID"), + Operation.parameter( + :min_id, + :query, + :string, + "Return the oldest statuses newer than this ID" + ), + Operation.parameter( + :since_id, + :query, + :string, + "Return the newest statuses newer than this ID" + ), Operation.parameter( :limit, :query, -- cgit v1.2.3 From 22bde21c4f1a84a1fbe733070e8926366a3c01dc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 15 Apr 2020 15:27:34 +0300 Subject: remote_ip plug adds remote_ip_found flag --- lib/pleroma/plugs/rate_limiter/rate_limiter.ex | 17 +++----------- lib/pleroma/plugs/remote_ip.ex | 7 ++---- mix.exs | 2 +- mix.lock | 2 +- test/plugs/rate_limiter_test.exs | 31 +++++++++++++++++--------- 5 files changed, 27 insertions(+), 32 deletions(-) diff --git a/lib/pleroma/plugs/rate_limiter/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex index 1529da717..c51e2c634 100644 --- a/lib/pleroma/plugs/rate_limiter/rate_limiter.ex +++ b/lib/pleroma/plugs/rate_limiter/rate_limiter.ex @@ -110,20 +110,9 @@ defp handle(conn, action_settings) do end def disabled?(conn) do - localhost_or_socket = - case Config.get([Pleroma.Web.Endpoint, :http, :ip]) do - {127, 0, 0, 1} -> true - {0, 0, 0, 0, 0, 0, 0, 1} -> true - {:local, _} -> true - _ -> false - end - - remote_ip_not_found = - if Map.has_key?(conn.assigns, :remote_ip_found), - do: !conn.assigns.remote_ip_found, - else: false - - localhost_or_socket and remote_ip_not_found + if Map.has_key?(conn.assigns, :remote_ip_found), + do: !conn.assigns.remote_ip_found, + else: false end @inspect_bucket_not_found {:error, :not_found} diff --git a/lib/pleroma/plugs/remote_ip.ex b/lib/pleroma/plugs/remote_ip.ex index 0ac9050d0..2eca4f8f6 100644 --- a/lib/pleroma/plugs/remote_ip.ex +++ b/lib/pleroma/plugs/remote_ip.ex @@ -7,8 +7,6 @@ defmodule Pleroma.Plugs.RemoteIp do This is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration. """ - import Plug.Conn - @behaviour Plug @headers ~w[ @@ -28,12 +26,11 @@ defmodule Pleroma.Plugs.RemoteIp do def init(_), do: nil - def call(%{remote_ip: original_remote_ip} = conn, _) do + def call(conn, _) do config = Pleroma.Config.get(__MODULE__, []) if Keyword.get(config, :enabled, false) do - %{remote_ip: new_remote_ip} = conn = RemoteIp.call(conn, remote_ip_opts(config)) - assign(conn, :remote_ip_found, original_remote_ip != new_remote_ip) + RemoteIp.call(conn, remote_ip_opts(config)) else conn end diff --git a/mix.exs b/mix.exs index c781995e0..c5e5fd432 100644 --- a/mix.exs +++ b/mix.exs @@ -183,7 +183,7 @@ defp deps do {:flake_id, "~> 0.1.0"}, {:remote_ip, git: "https://git.pleroma.social/pleroma/remote_ip.git", - ref: "825dc00aaba5a1b7c4202a532b696b595dd3bcb3"}, + ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"}, {:captcha, git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, diff --git a/mix.lock b/mix.lock index ba4e3ac44..2b9c54548 100644 --- a/mix.lock +++ b/mix.lock @@ -97,7 +97,7 @@ "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", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, - "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "825dc00aaba5a1b7c4202a532b696b595dd3bcb3", [ref: "825dc00aaba5a1b7c4202a532b696b595dd3bcb3"]}, + "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "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"}, diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs index 0ce9f3a0a..4d3d694f4 100644 --- a/test/plugs/rate_limiter_test.exs +++ b/test/plugs/rate_limiter_test.exs @@ -5,8 +5,10 @@ defmodule Pleroma.Plugs.RateLimiterTest do use Pleroma.Web.ConnCase + alias Phoenix.ConnTest alias Pleroma.Config alias Pleroma.Plugs.RateLimiter + alias Plug.Conn import Pleroma.Factory import Pleroma.Tests.Helpers, only: [clear_config: 1, clear_config: 2] @@ -36,8 +38,15 @@ test "config is required for plug to work" do end test "it is disabled if it remote ip plug is enabled but no remote ip is found" do - Config.put([Pleroma.Web.Endpoint, :http, :ip], {127, 0, 0, 1}) - assert RateLimiter.disabled?(Plug.Conn.assign(build_conn(), :remote_ip_found, false)) + assert RateLimiter.disabled?(Conn.assign(build_conn(), :remote_ip_found, false)) + end + + test "it is enabled if remote ip found" do + refute RateLimiter.disabled?(Conn.assign(build_conn(), :remote_ip_found, true)) + end + + test "it is enabled if remote_ip_found flag doesn't exist" do + refute RateLimiter.disabled?(build_conn()) end test "it restricts based on config values" do @@ -58,7 +67,7 @@ test "it restricts based on config values" do end conn = RateLimiter.call(conn, plug_opts) - assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests) + assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests) assert conn.halted Process.sleep(50) @@ -68,7 +77,7 @@ test "it restricts based on config values" do conn = RateLimiter.call(conn, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn, limiter_name, plug_opts) - refute conn.status == Plug.Conn.Status.code(:too_many_requests) + refute conn.status == Conn.Status.code(:too_many_requests) refute conn.resp_body refute conn.halted end @@ -98,7 +107,7 @@ test "`params` option allows different queries to be tracked independently" do plug_opts = RateLimiter.init(name: limiter_name, params: ["id"]) conn = build_conn(:get, "/?id=1") - conn = Plug.Conn.fetch_query_params(conn) + conn = Conn.fetch_query_params(conn) conn_2 = build_conn(:get, "/?id=2") RateLimiter.call(conn, plug_opts) @@ -119,7 +128,7 @@ test "it supports combination of options modifying bucket name" do id = "100" conn = build_conn(:get, "/?id=#{id}") - conn = Plug.Conn.fetch_query_params(conn) + conn = Conn.fetch_query_params(conn) conn_2 = build_conn(:get, "/?id=#{101}") RateLimiter.call(conn, plug_opts) @@ -147,13 +156,13 @@ test "are restricted based on remote IP" do conn = RateLimiter.call(conn, plug_opts) - assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests) + assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests) assert conn.halted conn_2 = RateLimiter.call(conn_2, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn_2, limiter_name, plug_opts) - refute conn_2.status == Plug.Conn.Status.code(:too_many_requests) + refute conn_2.status == Conn.Status.code(:too_many_requests) refute conn_2.resp_body refute conn_2.halted end @@ -187,7 +196,7 @@ test "can have limits separate from unauthenticated connections" do conn = RateLimiter.call(conn, plug_opts) - assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests) + assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests) assert conn.halted end @@ -210,12 +219,12 @@ test "different users are counted independently" do end conn = RateLimiter.call(conn, plug_opts) - assert %{"error" => "Throttled"} = Phoenix.ConnTest.json_response(conn, :too_many_requests) + assert %{"error" => "Throttled"} = ConnTest.json_response(conn, :too_many_requests) assert conn.halted conn_2 = RateLimiter.call(conn_2, plug_opts) assert {1, 4} = RateLimiter.inspect_bucket(conn_2, limiter_name, plug_opts) - refute conn_2.status == Plug.Conn.Status.code(:too_many_requests) + refute conn_2.status == Conn.Status.code(:too_many_requests) refute conn_2.resp_body refute conn_2.halted end -- cgit v1.2.3 From 0e647ff55aa3128f45cd9df79b8af06da57c009e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 15 Apr 2020 16:45:45 +0400 Subject: Abstract pagination params in OpenAPI spec --- lib/pleroma/web/api_spec/helpers.ex | 22 +++++ .../web/api_spec/operations/account_operation.ex | 108 +++++++-------------- 2 files changed, 57 insertions(+), 73 deletions(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index 7348dcbee..ce40fb9e8 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -3,6 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ApiSpec.Helpers do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + def request_body(description, schema_ref, opts \\ []) do media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] @@ -24,4 +27,23 @@ def request_body(description, schema_ref, opts \\ []) do required: opts[:required] || false } end + + def pagination_params do + [ + Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), + Operation.parameter(:min_id, :query, :string, "Return the oldest items newer than this ID"), + Operation.parameter( + :since_id, + :query, + :string, + "Return the newest items newer than this ID" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20, maximum: 40}, + "Limit" + ) + ] + end end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 6c9de51bb..fe44a917a 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Reference alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse @@ -21,6 +20,8 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.StatusesResponse alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + import Pleroma.Web.ApiSpec.Helpers + @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -35,7 +36,7 @@ def create_operation do description: "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.", operationId: "AccountController.create", - requestBody: Helpers.request_body("Parameters", AccountCreateRequest, required: true), + requestBody: request_body("Parameters", AccountCreateRequest, required: true), responses: %{ 200 => Operation.response("Account", "application/json", AccountCreateResponse) } @@ -62,8 +63,7 @@ def update_credentials_operation do description: "Update the user's display and preferences.", operationId: "AccountController.update_credentials", security: [%{"oAuth" => ["write:accounts"]}], - requestBody: - Helpers.request_body("Parameters", AccountUpdateCredentialsRequest, required: true), + requestBody: request_body("Parameters", AccountUpdateCredentialsRequest, required: true), responses: %{ 200 => Operation.response("Account", "application/json", Account) } @@ -114,49 +114,31 @@ def statuses_operation do operationId: "AccountController.statuses", description: "Statuses posted to the given account. Public (for public statuses only), or user token + `read:statuses` (for private statuses the user is authorized to see)", - parameters: [ - %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, - Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), - Operation.parameter(:tagged, :query, :string, "With tag"), - Operation.parameter( - :only_media, - :query, - BooleanLike, - "Include only statuses with media attached" - ), - Operation.parameter( - :with_muted, - :query, - BooleanLike, - "Include statuses from muted acccounts." - ), - Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), - Operation.parameter( - :exclude_visibilities, - :query, - %Schema{type: :array, items: VisibilityScope}, - "Exclude visibilities" - ), - Operation.parameter(:max_id, :query, :string, "Return statuses older than this ID"), - Operation.parameter( - :min_id, - :query, - :string, - "Return the oldest statuses newer than this ID" - ), - Operation.parameter( - :since_id, - :query, - :string, - "Return the newest statuses newer than this ID" - ), - Operation.parameter( - :limit, - :query, - %Schema{type: :integer, default: 20, maximum: 40}, - "Limit" - ) - ], + parameters: + [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:pinned, :query, BooleanLike, "Include only pinned statuses"), + Operation.parameter(:tagged, :query, :string, "With tag"), + Operation.parameter( + :only_media, + :query, + BooleanLike, + "Include only statuses with media attached" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include statuses from muted acccounts." + ), + Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude visibilities" + ) + ] ++ pagination_params(), responses: %{ 200 => Operation.response("Statuses", "application/json", StatusesResponse) } @@ -171,18 +153,8 @@ def followers_operation do security: [%{"oAuth" => ["read:accounts"]}], description: "Accounts which follow the given account, if network is not hidden by the account owner.", - parameters: [ - %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, - Operation.parameter(:max_id, :query, :string, "Max ID"), - Operation.parameter(:min_id, :query, :string, "Mix ID"), - Operation.parameter(:since_id, :query, :string, "Since ID"), - Operation.parameter( - :limit, - :query, - %Schema{type: :integer, default: 20, maximum: 40}, - "Limit" - ) - ], + parameters: + [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), responses: %{ 200 => Operation.response("Accounts", "application/json", AccountsResponse) } @@ -197,18 +169,8 @@ def following_operation do security: [%{"oAuth" => ["read:accounts"]}], description: "Accounts which the given account is following, if network is not hidden by the account owner.", - parameters: [ - %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, - Operation.parameter(:max_id, :query, :string, "Max ID"), - Operation.parameter(:min_id, :query, :string, "Mix ID"), - Operation.parameter(:since_id, :query, :string, "Since ID"), - Operation.parameter( - :limit, - :query, - %Schema{type: :integer, default: 20, maximum: 40}, - "Limit" - ) - ], + parameters: + [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), responses: %{200 => Operation.response("Accounts", "application/json", AccountsResponse)} } end @@ -267,7 +229,7 @@ def mute_operation do summary: "Mute", operationId: "AccountController.mute", security: [%{"oAuth" => ["follow", "write:mutes"]}], - requestBody: Helpers.request_body("Parameters", AccountMuteRequest), + requestBody: request_body("Parameters", AccountMuteRequest), description: "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).", parameters: [ @@ -334,7 +296,7 @@ def follows_operation do summary: "Follows", operationId: "AccountController.follows", security: [%{"oAuth" => ["follow", "write:follows"]}], - requestBody: Helpers.request_body("Parameters", AccountFollowsRequest, required: true), + requestBody: request_body("Parameters", AccountFollowsRequest, required: true), responses: %{ 200 => Operation.response("Account", "application/json", Account) } -- cgit v1.2.3 From 16f4787bf7e4849192d999eb2177ca7e1a34fbc9 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 15 Apr 2020 16:51:37 +0400 Subject: Add a TODO note --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1909ce097..5926a6cad 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1157,6 +1157,8 @@ defp restrict_unlisted(query) do ) end + # TODO: when all endpoints migrated to OpenAPI compare `pinned` with `true` (boolean) only, + # the same for `restrict_media/2`, `restrict_replies/2`, 'restrict_reblogs/2' and `restrict_muted/2` defp restrict_pinned(query, %{"pinned" => pinned, "pinned_activity_ids" => ids}) when pinned in [true, "true", "1"] do from(activity in query, where: activity.id in ^ids) -- cgit v1.2.3 From 65f04b7806e342ed8967e5fa760e1509a776036e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 15 Apr 2020 17:16:32 +0400 Subject: Fix credo warning --- lib/pleroma/web/activity_pub/activity_pub.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5926a6cad..fa913a2aa 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1158,7 +1158,9 @@ defp restrict_unlisted(query) do end # TODO: when all endpoints migrated to OpenAPI compare `pinned` with `true` (boolean) only, - # the same for `restrict_media/2`, `restrict_replies/2`, 'restrict_reblogs/2' and `restrict_muted/2` + # the same for `restrict_media/2`, `restrict_replies/2`, 'restrict_reblogs/2' + # and `restrict_muted/2` + defp restrict_pinned(query, %{"pinned" => pinned, "pinned_activity_ids" => ids}) when pinned in [true, "true", "1"] do from(activity in query, where: activity.id in ^ids) -- cgit v1.2.3 From 6ace22b56a3ced833bd990de5715048d6bd32f80 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 15 Apr 2020 18:23:16 +0200 Subject: Chat: Add views, don't return them in timeline queries. --- lib/pleroma/web/activity_pub/activity_pub.ex | 13 ++++ .../web/api_spec/operations/chat_operation.ex | 81 ++++++++++++++++++++++ lib/pleroma/web/common_api/common_api.ex | 2 +- .../web/pleroma_api/controllers/chat_controller.ex | 47 ++++--------- .../web/pleroma_api/views/chat_message_view.ex | 28 ++++++++ lib/pleroma/web/pleroma_api/views/chat_view.ex | 21 ++++++ .../controllers/timeline_controller_test.exs | 3 + .../controllers/chat_controller_test.exs | 8 ++- .../pleroma_api/views/chat_message_view_test.exs | 42 +++++++++++ 9 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/chat_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat_message_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat_view.ex create mode 100644 test/web/pleroma_api/views/chat_message_view_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 4a56beb73..b6ba91052 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1207,6 +1207,18 @@ defp exclude_poll_votes(query, _) do end end + defp exclude_chat_messages(query, %{"include_chat_messages" => true}), do: query + + defp exclude_chat_messages(query, _) do + if has_named_binding?(query, :object) do + from([activity, object: o] in query, + where: fragment("not(?->>'type' = ?)", o.data, "ChatMessage") + ) + else + query + end + end + defp exclude_id(query, %{"exclude_id" => id}) when is_binary(id) do from(activity in query, where: activity.id != ^id) end @@ -1312,6 +1324,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_instance(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) + |> exclude_chat_messages(opts) |> exclude_visibility(opts) end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex new file mode 100644 index 000000000..038ebb29d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ChatOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["chat"], + summary: "Create a chat", + responses: %{ + 200 => + Operation.response("Chat", "application/json", %Schema{ + type: :object, + description: "A created chat is returned", + properties: %{ + id: %Schema{type: :integer} + } + }) + } + } + end + + def index_operation do + %Operation{ + tags: ["chat"], + summary: "Get a list of chats that you participated in", + responses: %{ + 200 => + Operation.response("Chats", "application/json", %Schema{ + type: :array, + description: "A list of chats", + items: %Schema{ + type: :object, + description: "A chat" + } + }) + } + } + end + + def messages_operation do + %Operation{ + tags: ["chat"], + summary: "Get the most recent messages of the chat", + responses: %{ + 200 => + Operation.response("Messages", "application/json", %Schema{ + type: :array, + description: "A list of chat messages", + items: %Schema{ + type: :object, + description: "A chat message" + } + }) + } + } + end + + def post_chat_message_operation do + %Operation{ + tags: ["chat"], + summary: "Post a message to the chat", + responses: %{ + 200 => + Operation.response("Message", "application/json", %Schema{ + type: :object, + description: "A chat message" + }) + } + } + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2f13daf0c..c306c1e96 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -24,7 +24,7 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger - def post_chat_message(user, recipient, content) do + def post_chat_message(%User{} = user, %User{} = recipient, content) do transaction = Repo.transaction(fn -> with {_, {:ok, chat_message_data, _meta}} <- diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 972330f4e..5ec546847 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -9,6 +9,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.PleromaAPI.ChatMessageView import Ecto.Query @@ -17,6 +19,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do # - Views / Representers # - Error handling + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ "id" => id, "content" => content @@ -25,14 +29,9 @@ def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), message <- Object.normalize(activity) do - represented_message = %{ - actor: message.data["actor"], - id: message.id, - content: message.data["content"] - } - conn - |> json(represented_message) + |> put_view(ChatMessageView) + |> render("show.json", for: user, object: message, chat: chat) end end @@ -60,18 +59,9 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) d ) |> Repo.all() - represented_messages = - messages - |> Enum.map(fn message -> - %{ - actor: message.data["actor"], - id: message.id, - content: message.data["content"] - } - end) - conn - |> json(represented_messages) + |> put_view(ChatMessageView) + |> render("index.json", for: user, objects: messages, chat: chat) end end @@ -83,31 +73,18 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do ) |> Repo.all() - represented_chats = - Enum.map(chats, fn chat -> - %{ - id: chat.id, - recipient: chat.recipient, - unread: chat.unread - } - end) - conn - |> json(represented_chats) + |> put_view(ChatView) + |> render("index.json", chats: chats) end def create(%{assigns: %{user: user}} = conn, params) do recipient = params["ap_id"] |> URI.decode_www_form() with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do - represented_chat = %{ - id: chat.id, - recipient: chat.recipient, - unread: chat.unread - } - conn - |> json(represented_chat) + |> put_view(ChatView) + |> render("show.json", chat: chat) end end end diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex new file mode 100644 index 000000000..2df591358 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -0,0 +1,28 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageView do + use Pleroma.Web, :view + + alias Pleroma.Chat + + def render( + "show.json", + %{ + object: %{id: id, data: %{"type" => "ChatMessage"} = chat_message}, + chat: %Chat{id: chat_id} + } + ) do + %{ + id: id, + content: chat_message["content"], + chat_id: chat_id, + actor: chat_message["actor"] + } + end + + def render("index.json", opts) do + render_many(opts[:objects], __MODULE__, "show.json", Map.put(opts, :as, :object)) + end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex new file mode 100644 index 000000000..ee48385bf --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -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.PleromaAPI.ChatView do + use Pleroma.Web, :view + + alias Pleroma.Chat + + def render("show.json", %{chat: %Chat{} = chat}) do + %{ + id: chat.id, + recipient: chat.recipient, + unread: chat.unread + } + end + + def render("index.json", %{chats: chats}) do + render_many(chats, __MODULE__, "show.json") + end +end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..a5c227991 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -51,6 +51,9 @@ test "the home timeline", %{user: user, conn: conn} do {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"}) {:ok, _, _} = CommonAPI.repeat(activity.id, following) + # This one should not show up in the TL + {:ok, _activity} = CommonAPI.post_chat_message(third_user, user, ":gun:") + ret_conn = get(conn, uri) assert Enum.empty?(json_response(ret_conn, :ok)) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index b4230e5ad..dad37a889 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -23,14 +23,13 @@ test "it posts a message to the chat", %{conn: conn} do |> json_response(200) assert result["content"] == "Hallo!!" + assert result["chat_id"] == chat.id end end describe "GET /api/v1/pleroma/chats/:id/messages" do # TODO - # - Test that statuses don't show # - Test the case where it's not the user's chat - # - Test the returned data test "it returns the messages for a given chat", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -49,6 +48,11 @@ test "it returns the messages for a given chat", %{conn: conn} do |> get("/api/v1/pleroma/chats/#{chat.id}/messages") |> json_response(200) + result + |> Enum.each(fn message -> + assert message["chat_id"] == chat.id + end) + assert length(result) == 3 end end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs new file mode 100644 index 000000000..e690da022 --- /dev/null +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do + use Pleroma.DataCase + + alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.ChatMessageView + + import Pleroma.Factory + + test "it displays a chat message" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis") + + chat = Chat.get(user.id, recipient.ap_id) + + object = Object.normalize(activity) + + chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + + assert chat_message[:id] == object.id + assert chat_message[:content] == "kippis" + assert chat_message[:actor] == user.ap_id + assert chat_message[:chat_id] + + {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") + + object = Object.normalize(activity) + + chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) + + assert chat_message_two[:id] == object.id + assert chat_message_two[:content] == "gkgkgk" + assert chat_message_two[:actor] == recipient.ap_id + assert chat_message_two[:chat_id] == chat_message[:chat_id] + end +end -- cgit v1.2.3 From aa0a4a1e78655024e992f9c677efed45593ab7b8 Mon Sep 17 00:00:00 2001 From: Ilja Date: Wed, 15 Apr 2020 19:03:27 +0200 Subject: small fix in the rewrite_policy example --- docs/configuration/mrf.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md index c3957c255..287416b2a 100644 --- a/docs/configuration/mrf.md +++ b/docs/configuration/mrf.md @@ -113,7 +113,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RewritePolicy do @impl true def describe do - {:ok, %{mrf_sample: %{content: "new message content"}}}` + {:ok, %{mrf_sample: %{content: "new message content"}}} end end ``` -- cgit v1.2.3 From bde1189c349dc114aca2e9310dda840a1007825f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 15 Apr 2020 21:19:16 +0300 Subject: [#2349] Made :skip_plug/2 prevent plug from being executed even if explicitly called. Refactoring. Tests. --- lib/pleroma/plugs/auth_expected_plug.ex | 4 +++ lib/pleroma/plugs/oauth_scopes_plug.ex | 6 ++-- lib/pleroma/tests/oauth_test_controller.ex | 31 ++++++++++++++++++ lib/pleroma/web/router.ex | 11 +++++++ lib/pleroma/web/web.ex | 32 ++++++++++++++++-- test/plugs/oauth_scopes_plug_test.exs | 13 ++++++++ test/web/auth/oauth_test_controller_test.exs | 49 ++++++++++++++++++++++++++++ 7 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 lib/pleroma/tests/oauth_test_controller.ex create mode 100644 test/web/auth/oauth_test_controller_test.exs diff --git a/lib/pleroma/plugs/auth_expected_plug.ex b/lib/pleroma/plugs/auth_expected_plug.ex index 9e4a4bec8..f79597dc3 100644 --- a/lib/pleroma/plugs/auth_expected_plug.ex +++ b/lib/pleroma/plugs/auth_expected_plug.ex @@ -10,4 +10,8 @@ def init(options), do: options def call(conn, _) do put_private(conn, :auth_expected, true) end + + def auth_expected?(conn) do + conn.private[:auth_expected] + end end diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex index b09e1bb4d..66f48c28c 100644 --- a/lib/pleroma/plugs/oauth_scopes_plug.ex +++ b/lib/pleroma/plugs/oauth_scopes_plug.ex @@ -10,13 +10,13 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.PlugHelper + use Pleroma.Web, :plug + @behaviour Plug def init(%{scopes: _} = options), do: options - def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do - conn = PlugHelper.append_to_called_plugs(conn, __MODULE__) - + def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do op = options[:op] || :| token = assigns[:token] diff --git a/lib/pleroma/tests/oauth_test_controller.ex b/lib/pleroma/tests/oauth_test_controller.ex new file mode 100644 index 000000000..58d517f78 --- /dev/null +++ b/lib/pleroma/tests/oauth_test_controller.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# A test controller reachable only in :test env. +# Serves to test OAuth scopes check skipping / enforcement. +defmodule Pleroma.Tests.OAuthTestController do + @moduledoc false + + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + + plug(:skip_plug, OAuthScopesPlug when action == :skipped_oauth) + + plug(OAuthScopesPlug, %{scopes: ["read"]} when action != :missed_oauth) + + def skipped_oauth(conn, _params) do + noop(conn) + end + + def performed_oauth(conn, _params) do + noop(conn) + end + + def missed_oauth(conn, _params) do + noop(conn) + end + + defp noop(conn), do: json(conn, %{}) +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8d13cd6c9..c85ad9f8b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -672,6 +672,17 @@ defmodule Pleroma.Web.Router do end end + # Test-only routes needed to test action dispatching and plug chain execution + if Pleroma.Config.get(:env) == :test do + scope "/test/authenticated_api", Pleroma.Tests do + pipe_through(:authenticated_api) + + for action <- [:skipped_oauth, :performed_oauth, :missed_oauth] do + get("/#{action}", OAuthTestController, action) + end + end + end + scope "/", Pleroma.Web.MongooseIM do get("/user_exists", MongooseIMController, :user_exists) get("/check_password", MongooseIMController, :check_password) diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index 1af29ce78..ae7c94640 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -37,15 +37,21 @@ defp set_put_layout(conn, _) do put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) end - # Marks a plug as intentionally skipped - # (states that the plug is not called for a good reason, not by a mistake) + # Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain defp skip_plug(conn, plug_module) do + try do + plug_module.ensure_skippable() + rescue + UndefinedFunctionError -> + raise "#{plug_module} is not skippable. Append `use Pleroma.Web, :plug` to its code." + end + PlugHelper.append_to_skipped_plugs(conn, plug_module) end # Here we can apply before-action hooks (e.g. verify whether auth checks were preformed) defp action(conn, params) do - if conn.private[:auth_expected] && + if Pleroma.Plugs.AuthExpectedPlug.auth_expected?(conn) && not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do conn |> render_error( @@ -119,6 +125,26 @@ def channel do end end + def plug do + quote do + alias Pleroma.Plugs.PlugHelper + + def ensure_skippable, do: :noop + + @impl Plug + @doc "If marked as skipped, returns `conn`, and calls `perform/2` otherwise." + def call(%Plug.Conn{} = conn, options) do + if PlugHelper.plug_skipped?(conn, __MODULE__) do + conn + else + conn + |> PlugHelper.append_to_called_plugs(__MODULE__) + |> perform(options) + end + end + end + end + @doc """ When used, dispatch to the appropriate controller/view/etc. """ diff --git a/test/plugs/oauth_scopes_plug_test.exs b/test/plugs/oauth_scopes_plug_test.exs index e79ecf263..abab7abb0 100644 --- a/test/plugs/oauth_scopes_plug_test.exs +++ b/test/plugs/oauth_scopes_plug_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.PlugHelper alias Pleroma.Repo import Mock @@ -16,6 +17,18 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do :ok end + test "is not performed if marked as skipped", %{conn: conn} do + with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do + conn = + conn + |> PlugHelper.append_to_skipped_plugs(OAuthScopesPlug) + |> OAuthScopesPlug.call(%{scopes: ["random_scope"]}) + + refute called(OAuthScopesPlug.perform(:_, :_)) + refute conn.halted + end + end + test "if `token.scopes` fulfills specified 'any of' conditions, " <> "proceeds with no op", %{conn: conn} do diff --git a/test/web/auth/oauth_test_controller_test.exs b/test/web/auth/oauth_test_controller_test.exs new file mode 100644 index 000000000..a2f6009ac --- /dev/null +++ b/test/web/auth/oauth_test_controller_test.exs @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Tests.OAuthTestControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + setup %{conn: conn} do + user = insert(:user) + conn = assign(conn, :user, user) + %{conn: conn, user: user} + end + + test "missed_oauth", %{conn: conn} do + res = + conn + |> get("/test/authenticated_api/missed_oauth") + |> json_response(403) + + assert res == + %{ + "error" => + "Security violation: OAuth scopes check was neither handled nor explicitly skipped." + } + end + + test "skipped_oauth", %{conn: conn} do + conn + |> assign(:token, nil) + |> get("/test/authenticated_api/skipped_oauth") + |> json_response(200) + end + + test "performed_oauth", %{user: user} do + %{conn: good_token_conn} = oauth_access(["read"], user: user) + + good_token_conn + |> get("/test/authenticated_api/performed_oauth") + |> json_response(200) + + %{conn: bad_token_conn} = oauth_access(["follow"], user: user) + + bad_token_conn + |> get("/test/authenticated_api/performed_oauth") + |> json_response(403) + end +end -- cgit v1.2.3 From 4b3b1fec4e57bd07ac75700bf34cd188ce43b545 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 15 Apr 2020 21:19:43 +0300 Subject: added an endpoint for getting unread notification count --- CHANGELOG.md | 1 + docs/API/differences_in_mastoapi_responses.md | 17 +++++--- lib/pleroma/marker.ex | 45 ++++++++++++++++++- lib/pleroma/notification.ex | 47 ++++++++++++++------ lib/pleroma/web/mastodon_api/views/marker_view.ex | 5 ++- mix.lock | 50 +++++++++++----------- .../migrations/20200415181818_update_markers.exs | 40 +++++++++++++++++ test/marker_test.exs | 29 ++++++++++++- test/notification_test.exs | 13 ++++++ .../controllers/marker_controller_test.exs | 10 +++-- test/web/mastodon_api/views/marker_view_test.exs | 8 ++-- 11 files changed, 214 insertions(+), 51 deletions(-) create mode 100644 priv/repo/migrations/20200415181818_update_markers.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b235f6d..3f7fc1802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: `pleroma.thread_muted` to the Status entity - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message - Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload. +- Mastodon API: Add `pleroma.unread_count` to the Marker entity - Admin API: Render whole status in grouped reports - 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. diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 1059155cf..0a7520f9e 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -185,8 +185,15 @@ Post here request with `grant_type=refresh_token` to obtain new access token. Re 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 registrations 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. + + +## Markers + +Has these additional fields under the `pleroma` object: + +- `unread_count`: contains number unread notifications diff --git a/lib/pleroma/marker.ex b/lib/pleroma/marker.ex index 443927392..4d82860f5 100644 --- a/lib/pleroma/marker.ex +++ b/lib/pleroma/marker.ex @@ -9,24 +9,34 @@ defmodule Pleroma.Marker do import Ecto.Query alias Ecto.Multi + alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User + alias __MODULE__ @timelines ["notifications"] + @type t :: %__MODULE__{} schema "markers" do field(:last_read_id, :string, default: "") field(:timeline, :string, default: "") field(:lock_version, :integer, default: 0) + field(:unread_count, :integer, default: 0, virtual: true) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) timestamps() end + @doc "Gets markers by user and timeline." + @spec get_markers(User.t(), list(String)) :: list(t()) def get_markers(user, timelines \\ []) do - Repo.all(get_query(user, timelines)) + user + |> get_query(timelines) + |> unread_count_query() + |> Repo.all() end + @spec upsert(User.t(), map()) :: {:ok | :error, any()} def upsert(%User{} = user, attrs) do attrs |> Map.take(@timelines) @@ -45,6 +55,27 @@ def upsert(%User{} = user, attrs) do |> Repo.transaction() end + @spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t() + def multi_set_last_read_id(multi, %User{} = user, "notifications") do + multi + |> Multi.run(:counters, fn _repo, _changes -> + {:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}} + end) + |> Multi.insert( + :marker, + fn %{counters: attrs} -> + %Marker{timeline: "notifications", user_id: user.id} + |> struct(attrs) + |> Ecto.Changeset.change() + end, + returning: true, + on_conflict: {:replace, [:last_read_id]}, + conflict_target: [:user_id, :timeline] + ) + end + + def multi_set_last_read_id(multi, _, _), do: multi + defp get_marker(user, timeline) do case Repo.find_resource(get_query(user, timeline)) do {:ok, marker} -> %__MODULE__{marker | user: user} @@ -71,4 +102,16 @@ defp get_query(user, timelines) do |> by_user_id(user.id) |> by_timeline(timelines) end + + defp unread_count_query(query) do + from( + q in query, + left_join: n in "notifications", + on: n.user_id == q.user_id and n.seen == false, + group_by: [:id], + select_merge: %{ + unread_count: fragment("count(?)", n.id) + } + ) + end end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 04ee510b9..3084bac3b 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -5,7 +5,9 @@ defmodule Pleroma.Notification do use Ecto.Schema + alias Ecto.Multi alias Pleroma.Activity + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination @@ -38,6 +40,17 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end + @spec last_read_query(User.t()) :: Ecto.Queryable.t() + def last_read_query(user) do + from(q in Pleroma.Notification, + where: q.user_id == ^user.id, + where: q.seen == true, + select: type(q.id, :string), + limit: 1, + order_by: [desc: :id] + ) + end + defp for_user_query_ap_id_opts(user, opts) do ap_id_relationships = [:block] ++ @@ -186,25 +199,23 @@ def for_user_since(user, date) do |> Repo.all() end - def set_read_up_to(%{id: user_id} = _user, id) do + def set_read_up_to(%{id: user_id} = user, id) do query = from( n in Notification, where: n.user_id == ^user_id, where: n.id <= ^id, where: n.seen == false, - update: [ - set: [ - seen: true, - updated_at: ^NaiveDateTime.utc_now() - ] - ], # Ideally we would preload object and activities here # but Ecto does not support preloads in update_all select: n.id ) - {_, notification_ids} = Repo.update_all(query, []) + {:ok, %{ids: {_, notification_ids}}} = + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() Notification |> where([n], n.id in ^notification_ids) @@ -221,11 +232,18 @@ def set_read_up_to(%{id: user_id} = _user, id) do |> Repo.all() end + @spec read_one(User.t(), String.t()) :: + {:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil def read_one(%User{} = user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do - notification - |> changeset(%{seen: true}) - |> Repo.update() + Multi.new() + |> Multi.update(:update, changeset(notification, %{seen: true})) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + |> case do + {:ok, %{update: notification}} -> {:ok, notification} + {:error, :update, changeset, _} -> {:error, changeset} + end end end @@ -307,8 +325,11 @@ defp do_create_notifications(%Activity{} = activity) do # TODO move to sql, too. 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) + {:ok, %{notification: notification}} = + Multi.new() + |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() if do_send do Streamer.stream(["user", "user:notification"], notification) diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 985368fe5..415dae93b 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -10,7 +10,10 @@ def render("markers.json", %{markers: markers}) do Map.put_new(acc, m.timeline, %{ last_read_id: m.last_read_id, version: m.lock_version, - updated_at: NaiveDateTime.to_iso8601(m.updated_at) + updated_at: NaiveDateTime.to_iso8601(m.updated_at), + pleroma: %{ + unread_count: m.unread_count + } }) end) end diff --git a/mix.lock b/mix.lock index 2b9c54548..38adc45e3 100644 --- a/mix.lock +++ b/mix.lock @@ -2,8 +2,8 @@ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, - "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, + "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, @@ -19,47 +19,47 @@ "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, - "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, + "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "48e513299cd28b12c77266c0ed5b1c844368e5c1823724994ae84834f43d6bbe"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, + "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, + "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, - "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, + "esshd": {:hex, :esshd, "0.1.0", "6f93a2062adb43637edad0ea7357db2702a4b80dd9683482fe00f5134e97f4c1", [:mix], [], "hexpm", "98d0f3c6f4b8a0333170df770c6fe772b3d04564fb514c1a09504cf5ab2f48a5"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, + "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.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"}, + "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, + "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, - "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, - "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, - "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, + "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm", "8453e2289d94c3199396eb517d65d6715ef26bcae0ee83eb5ff7a84445458d76"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm", "5cacd405e72b2609a7e1f891bddb80c53d0b3b7b0036d1648e7382ca108c41c8"}, + "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm", "f7d97341e536f95b96eef2988d6d4230f7262cf239cda0e2e63123ee0b717222"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, - "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, + "httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "89149056039084024a284cd703b2d1900d584958dba432132cb21ef35aed7487"}, "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.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, - "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"}, + "joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "eb02df7d5526df13063397e051b926b7006d5986d66f399eefc474f560cdad6a"}, + "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm", "6429c4fee52b2dda7861ee19a4f09c8c1ffa213bee3a1ec187828fde95d447ed"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, - "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm", "1feaf05ee886815ad047cad7ede17d6910710986148ae09cf73eee2989717b81"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, @@ -71,35 +71,37 @@ "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {: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"}, - "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": {:hex, :phoenix, "1.4.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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", "256ad7a140efadc3f0290470369da5bd3de985ec7c706eba07c2641b228974be"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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", "fe15d9fee5b82f5e64800502011ffe530650d42e1710ae9b14bc4c9be38bf303"}, + "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8b01b3d6d39731ab18aa548d928b5796166d2500755f553725cfe967bafba7d9"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"}, "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, - "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, - "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, + "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, "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"}, + "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm", "6de553ba9ac0668d3728b699d5065543f3e40c854154017461ee8c09038752da"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, "recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"}, diff --git a/priv/repo/migrations/20200415181818_update_markers.exs b/priv/repo/migrations/20200415181818_update_markers.exs new file mode 100644 index 000000000..976363565 --- /dev/null +++ b/priv/repo/migrations/20200415181818_update_markers.exs @@ -0,0 +1,40 @@ +defmodule Pleroma.Repo.Migrations.UpdateMarkers do + use Ecto.Migration + import Ecto.Query + alias Pleroma.Repo + + def up do + update_markers() + end + + def down do + :ok + end + + defp update_markers do + now = NaiveDateTime.utc_now() + + markers_attrs = + from(q in "notifications", + select: %{ + timeline: "notifications", + user_id: q.user_id, + last_read_id: + type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string) + }, + group_by: [q.user_id] + ) + |> Repo.all() + |> Enum.map(fn %{last_read_id: last_read_id} = attrs -> + attrs + |> Map.put(:last_read_id, last_read_id || "") + |> Map.put_new(:inserted_at, now) + |> Map.put_new(:updated_at, now) + end) + + Repo.insert_all("markers", markers_attrs, + on_conflict: {:replace, [:last_read_id]}, + conflict_target: [:user_id, :timeline] + ) + end +end diff --git a/test/marker_test.exs b/test/marker_test.exs index c80ae16b6..5b6d0b4a4 100644 --- a/test/marker_test.exs +++ b/test/marker_test.exs @@ -8,12 +8,39 @@ defmodule Pleroma.MarkerTest do import Pleroma.Factory + describe "multi_set_unread_count/3" do + test "returns multi" do + user = insert(:user) + + assert %Ecto.Multi{ + operations: [marker: {:run, _}, counters: {:run, _}] + } = + Marker.multi_set_last_read_id( + Ecto.Multi.new(), + user, + "notifications" + ) + end + + test "return empty multi" do + user = insert(:user) + multi = Ecto.Multi.new() + assert Marker.multi_set_last_read_id(multi, user, "home") == multi + end + end + describe "get_markers/2" do test "returns user markers" do user = insert(:user) marker = insert(:marker, user: user) + insert(:notification, user: user) + insert(:notification, user: user) insert(:marker, timeline: "home", user: user) - assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)] + + assert Marker.get_markers( + user, + ["notifications"] + ) == [%Marker{refresh_record(marker) | unread_count: 2}] end end diff --git a/test/notification_test.exs b/test/notification_test.exs index 837a9dacd..f78a47af6 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -45,6 +45,9 @@ test "notifies someone when they are directly addressed" do assert notified_ids == [other_user.id, third_user.id] assert notification.activity_id == activity.id assert other_notification.activity_id == activity.id + + assert [%Pleroma.Marker{unread_count: 2}] = + Pleroma.Marker.get_markers(other_user, ["notifications"]) end test "it creates a notification for subscribed users" do @@ -410,6 +413,16 @@ test "it sets all notifications as read up to a specified notification ID" do assert n1.seen == true assert n2.seen == true assert n3.seen == false + + assert %Pleroma.Marker{} = + m = + Pleroma.Repo.get_by( + Pleroma.Marker, + user_id: other_user.id, + timeline: "notifications" + ) + + assert m.last_read_id == to_string(n2.id) end end diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 919f295bd..7280abd10 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do test "gets markers with correct scopes", %{conn: conn} do user = insert(:user) token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) + insert_list(7, :notification, user: user) {:ok, %{"notifications" => marker}} = Pleroma.Marker.upsert( @@ -29,7 +30,8 @@ test "gets markers with correct scopes", %{conn: conn} do "notifications" => %{ "last_read_id" => "69420", "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0 + "version" => 0, + "pleroma" => %{"unread_count" => 7} } } end @@ -70,7 +72,8 @@ test "creates a marker with correct scopes", %{conn: conn} do "notifications" => %{ "last_read_id" => "69420", "updated_at" => _, - "version" => 0 + "version" => 0, + "pleroma" => %{"unread_count" => 0} } } = response end @@ -99,7 +102,8 @@ test "updates exist marker", %{conn: conn} do "notifications" => %{ "last_read_id" => "69888", "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), - "version" => 0 + "version" => 0, + "pleroma" => %{"unread_count" => 0} } } end diff --git a/test/web/mastodon_api/views/marker_view_test.exs b/test/web/mastodon_api/views/marker_view_test.exs index 893cf8857..48a0a6d33 100644 --- a/test/web/mastodon_api/views/marker_view_test.exs +++ b/test/web/mastodon_api/views/marker_view_test.exs @@ -8,19 +8,21 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do import Pleroma.Factory test "returns markers" do - marker1 = insert(:marker, timeline: "notifications", last_read_id: "17") + marker1 = insert(:marker, timeline: "notifications", last_read_id: "17", unread_count: 5) marker2 = insert(:marker, timeline: "home", last_read_id: "42") assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{ "home" => %{ last_read_id: "42", updated_at: NaiveDateTime.to_iso8601(marker2.updated_at), - version: 0 + version: 0, + pleroma: %{unread_count: 0} }, "notifications" => %{ last_read_id: "17", updated_at: NaiveDateTime.to_iso8601(marker1.updated_at), - version: 0 + version: 0, + pleroma: %{unread_count: 5} } } end -- cgit v1.2.3 From cf4ebba77471f188ce7da45df0b9ea76dbe31916 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 15 Apr 2020 22:59:25 +0400 Subject: Cleanup SubscriptionController --- .../controllers/subscription_controller.ex | 34 ++++++++++++---------- .../mastodon_api/views/push_subscription_view.ex | 19 ------------ .../web/mastodon_api/views/subscription_view.ex | 19 ++++++++++++ .../controllers/subscription_controller_test.exs | 13 +++++---- .../views/push_subscription_view_test.exs | 23 --------------- .../mastodon_api/views/subscription_view_test.exs | 23 +++++++++++++++ 6 files changed, 68 insertions(+), 63 deletions(-) delete mode 100644 lib/pleroma/web/mastodon_api/views/push_subscription_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/subscription_view.ex delete mode 100644 test/web/mastodon_api/views/push_subscription_view_test.exs create mode 100644 test/web/mastodon_api/views/subscription_view_test.exs diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex index 11df6fc4a..4647c1f96 100644 --- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -6,25 +6,22 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do @moduledoc "The module represents functions to manage user subscriptions." use Pleroma.Web, :controller - alias Pleroma.Web.MastodonAPI.PushSubscriptionView, as: View alias Pleroma.Web.Push alias Pleroma.Web.Push.Subscription action_fallback(:errors) plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(:restrict_push_enabled) # Creates PushSubscription # POST /api/v1/push/subscription # def create(%{assigns: %{user: user, token: token}} = conn, params) do - with true <- Push.enabled(), - {:ok, _} <- Subscription.delete_if_exists(user, token), + with {:ok, _} <- Subscription.delete_if_exists(user, token), {:ok, subscription} <- Subscription.create(user, token, params) do - view = View.render("push_subscription.json", subscription: subscription) - json(conn, view) + render(conn, "show.json", subscription: subscription) end end @@ -32,10 +29,8 @@ def create(%{assigns: %{user: user, token: token}} = conn, params) do # GET /api/v1/push/subscription # def get(%{assigns: %{user: user, token: token}} = conn, _params) do - with true <- Push.enabled(), - {:ok, subscription} <- Subscription.get(user, token) do - view = View.render("push_subscription.json", subscription: subscription) - json(conn, view) + with {:ok, subscription} <- Subscription.get(user, token) do + render(conn, "show.json", subscription: subscription) end end @@ -43,10 +38,8 @@ def get(%{assigns: %{user: user, token: token}} = conn, _params) do # PUT /api/v1/push/subscription # def update(%{assigns: %{user: user, token: token}} = conn, params) do - with true <- Push.enabled(), - {:ok, subscription} <- Subscription.update(user, token, params) do - view = View.render("push_subscription.json", subscription: subscription) - json(conn, view) + with {:ok, subscription} <- Subscription.update(user, token, params) do + render(conn, "show.json", subscription: subscription) end end @@ -54,11 +47,20 @@ def update(%{assigns: %{user: user, token: token}} = conn, params) do # DELETE /api/v1/push/subscription # def delete(%{assigns: %{user: user, token: token}} = conn, _params) do - with true <- Push.enabled(), - {:ok, _response} <- Subscription.delete(user, token), + with {:ok, _response} <- Subscription.delete(user, token), do: json(conn, %{}) end + defp restrict_push_enabled(conn, _) do + if Push.enabled() do + conn + else + conn + |> render_error(:forbidden, "Web push subscription is disabled on this Pleroma instance") + |> halt() + end + end + # fallback action # def errors(conn, {:error, :not_found}) do diff --git a/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex b/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex deleted file mode 100644 index d32cef6e2..000000000 --- a/lib/pleroma/web/mastodon_api/views/push_subscription_view.ex +++ /dev/null @@ -1,19 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.MastodonAPI.PushSubscriptionView do - use Pleroma.Web, :view - alias Pleroma.Web.Push - - def render("push_subscription.json", %{subscription: subscription}) do - %{ - id: to_string(subscription.id), - endpoint: subscription.endpoint, - alerts: Map.get(subscription.data, "alerts"), - server_key: server_key() - } - end - - defp server_key, do: Keyword.get(Push.vapid_config(), :public_key) -end diff --git a/lib/pleroma/web/mastodon_api/views/subscription_view.ex b/lib/pleroma/web/mastodon_api/views/subscription_view.ex new file mode 100644 index 000000000..7c67cc924 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/subscription_view.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SubscriptionView do + use Pleroma.Web, :view + alias Pleroma.Web.Push + + def render("show.json", %{subscription: subscription}) do + %{ + id: to_string(subscription.id), + endpoint: subscription.endpoint, + alerts: Map.get(subscription.data, "alerts"), + server_key: server_key() + } + end + + defp server_key, do: Keyword.get(Push.vapid_config(), :public_key) +end diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs index 987158a74..5682498c0 100644 --- a/test/web/mastodon_api/controllers/subscription_controller_test.exs +++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs @@ -35,7 +35,10 @@ defmacro assert_error_when_disable_push(do: yield) do quote do vapid_details = Application.get_env(:web_push_encryption, :vapid_details, []) Application.put_env(:web_push_encryption, :vapid_details, []) - assert "Something went wrong" == unquote(yield) + + assert %{"error" => "Web push subscription is disabled on this Pleroma instance"} == + unquote(yield) + Application.put_env(:web_push_encryption, :vapid_details, vapid_details) end end @@ -45,7 +48,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> post("/api/v1/push/subscription", %{}) - |> json_response(500) + |> json_response(403) end end @@ -74,7 +77,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> get("/api/v1/push/subscription", %{}) - |> json_response(500) + |> json_response(403) end end @@ -127,7 +130,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}}) - |> json_response(500) + |> json_response(403) end end @@ -155,7 +158,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> delete("/api/v1/push/subscription", %{}) - |> json_response(500) + |> json_response(403) end end diff --git a/test/web/mastodon_api/views/push_subscription_view_test.exs b/test/web/mastodon_api/views/push_subscription_view_test.exs deleted file mode 100644 index 10c6082a5..000000000 --- a/test/web/mastodon_api/views/push_subscription_view_test.exs +++ /dev/null @@ -1,23 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.MastodonAPI.PushSubscriptionViewTest do - use Pleroma.DataCase - import Pleroma.Factory - alias Pleroma.Web.MastodonAPI.PushSubscriptionView, as: View - alias Pleroma.Web.Push - - test "Represent a subscription" do - subscription = insert(:push_subscription, data: %{"alerts" => %{"mention" => true}}) - - expected = %{ - alerts: %{"mention" => true}, - endpoint: subscription.endpoint, - id: to_string(subscription.id), - server_key: Keyword.get(Push.vapid_config(), :public_key) - } - - assert expected == View.render("push_subscription.json", %{subscription: subscription}) - end -end diff --git a/test/web/mastodon_api/views/subscription_view_test.exs b/test/web/mastodon_api/views/subscription_view_test.exs new file mode 100644 index 000000000..981524c0e --- /dev/null +++ b/test/web/mastodon_api/views/subscription_view_test.exs @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.SubscriptionViewTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.MastodonAPI.SubscriptionView, as: View + alias Pleroma.Web.Push + + test "Represent a subscription" do + subscription = insert(:push_subscription, data: %{"alerts" => %{"mention" => true}}) + + expected = %{ + alerts: %{"mention" => true}, + endpoint: subscription.endpoint, + id: to_string(subscription.id), + server_key: Keyword.get(Push.vapid_config(), :public_key) + } + + assert expected == View.render("show.json", %{subscription: subscription}) + end +end -- cgit v1.2.3 From 3d4eca5dd4be297f03c244497d78db03e82a9d81 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 12:56:29 +0200 Subject: CommonAPI: Escape HTML for chat messages. --- lib/pleroma/web/common_api/common_api.ex | 8 +++++++- test/web/common_api/common_api_test.exs | 11 +++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c306c1e96..2c25850db 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Formatter import Pleroma.Web.Gettext import Pleroma.Web.CommonAPI.Utils @@ -28,7 +29,12 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do transaction = Repo.transaction(fn -> with {_, {:ok, chat_message_data, _meta}} <- - {:build_object, Builder.chat_message(user, recipient.ap_id, content)}, + {:build_object, + Builder.chat_message( + user, + recipient.ap_id, + content |> Formatter.html_escape("text/plain") + )}, {_, {:ok, chat_message_object}} <- {:create_object, Object.create(chat_message_data)}, {_, {:ok, create_activity_data, _meta}} <- diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 168721c81..abe3e6f8d 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -27,7 +27,12 @@ test "it posts a chat message" do author = insert(:user) recipient = insert(:user) - {:ok, activity} = CommonAPI.post_chat_message(author, recipient, "a test message") + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "a test message " + ) assert activity.data["type"] == "Create" assert activity.local @@ -35,7 +40,9 @@ test "it posts a chat message" do assert object.data["type"] == "ChatMessage" assert object.data["to"] == [recipient.ap_id] - assert object.data["content"] == "a test message" + + assert object.data["content"] == + "a test message <script>alert('uuu')</script>" assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) -- cgit v1.2.3 From 72ef6cc4f2f601e26ba84c16ad2c91bd72867629 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 13 Apr 2020 14:07:23 +0300 Subject: added need_reboot endpoint to admin api --- CHANGELOG.md | 6 ++++ docs/API/admin_api.md | 21 +++++++++++--- lib/pleroma/web/admin_api/admin_api_controller.ex | 35 ++++++++--------------- lib/pleroma/web/router.ex | 1 + test/web/admin_api/admin_api_controller_test.exs | 18 ++++++++++-- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b235f6d..804d3aa91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Logger configuration through AdminFE +### Added +
    + API Changes +- Admin API: `GET /api/pleroma/admin/need_reboot`. +
    + ## [2.0.2] - 2020-04-08 ### Added - Support for Funkwhale's `Audio` activity diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 57fb6bc6a..0ba88470a 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -786,6 +786,8 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ### Restarts pleroma application +**Only works when configuration from database is enabled.** + - Params: none - Response: - On failure: @@ -795,11 +797,24 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret {} ``` +## `GET /api/pleroma/admin/need_reboot` + +### Returns the flag whether the pleroma should be restarted + +- Params: none +- Response: + - `need_reboot` - boolean +```json +{ + "need_reboot": false +} +``` + ## `GET /api/pleroma/admin/config` ### Get list of merged default settings with saved in database. -*If `need_reboot` flag exists in response, instance must be restarted, so reboot time settings can take effect.* +*If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect.* **Only works when configuration from database is enabled.** @@ -821,13 +836,12 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret "need_reboot": true } ``` - need_reboot - *optional*, if were changed reboot time settings. ## `POST /api/pleroma/admin/config` ### Update config settings -*If `need_reboot` flag exists in response, instance must be restarted, so reboot time settings can take effect.* +*If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect.* **Only works when configuration from database is enabled.** @@ -971,7 +985,6 @@ config :quack, "need_reboot": true } ``` -need_reboot - *optional*, if were changed reboot time settings. ## ` GET /api/pleroma/admin/config/descriptions` diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 831c3bd02..8de7d70a3 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -914,16 +914,7 @@ def config_show(conn, _params) do end) |> List.flatten() - response = %{configs: merged} - - response = - if Restarter.Pleroma.need_reboot?() do - Map.put(response, :need_reboot, true) - else - response - end - - json(conn, response) + json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) end end @@ -950,28 +941,22 @@ def config_update(conn, %{"configs" => configs}) do Config.TransferTask.load_and_update_env(deleted, false) - need_reboot? = - Restarter.Pleroma.need_reboot?() || - Enum.any?(updated, fn config -> + if !Restarter.Pleroma.need_reboot?() do + changed_reboot_settings? = + (updated ++ deleted) + |> Enum.any?(fn config -> group = ConfigDB.from_string(config.group) key = ConfigDB.from_string(config.key) value = ConfigDB.from_binary(config.value) Config.TransferTask.pleroma_need_restart?(group, key, value) end) - response = %{configs: updated} - - response = - if need_reboot? do - Restarter.Pleroma.need_reboot() - Map.put(response, :need_reboot, need_reboot?) - else - response - end + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() + end conn |> put_view(ConfigView) - |> render("index.json", response) + |> render("index.json", %{configs: updated, need_reboot: Restarter.Pleroma.need_reboot?()}) end end @@ -983,6 +968,10 @@ def restart(conn, _params) do end end + def need_reboot(conn, _params) do + json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) + end + defp configurable_from_database(conn) do if Config.get(:configurable_from_database) do :ok diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5f5ec1c81..fd94913a1 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -203,6 +203,7 @@ defmodule Pleroma.Web.Router do get("/config", AdminAPIController, :config_show) post("/config", AdminAPIController, :config_update) get("/config/descriptions", AdminAPIController, :config_descriptions) + get("/need_reboot", AdminAPIController, :need_reboot) get("/restart", AdminAPIController, :restart) get("/moderation_log", AdminAPIController, :list_log) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 60ec895f5..158966365 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2110,7 +2110,7 @@ test "saving config which need pleroma reboot", %{conn: conn} do |> get("/api/pleroma/admin/config") |> json_response(200) - refute Map.has_key?(configs, "need_reboot") + assert configs["need_reboot"] == false end test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do @@ -2166,7 +2166,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", |> get("/api/pleroma/admin/config") |> json_response(200) - refute Map.has_key?(configs, "need_reboot") + assert configs["need_reboot"] == false end test "saving config with nested merge", %{conn: conn} do @@ -2861,6 +2861,20 @@ test "pleroma restarts", %{conn: conn} do end end + test "need_reboot flag", %{conn: conn} do + assert conn + |> get("/api/pleroma/admin/need_reboot") + |> json_response(200) == %{"need_reboot" => false} + + Restarter.Pleroma.need_reboot() + + assert conn + |> get("/api/pleroma/admin/need_reboot") + |> json_response(200) == %{"need_reboot" => true} + + on_exit(fn -> Restarter.Pleroma.refresh() end) + end + describe "GET /api/pleroma/admin/statuses" do test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do blocked = insert(:user) -- cgit v1.2.3 From e2ced0491770d6260fe51d5144b81200fd97f268 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 15:21:47 +0200 Subject: ChatMessages: Better validation. --- lib/pleroma/web/activity_pub/object_validator.ex | 6 ++- .../object_validators/chat_message_validator.ex | 26 +++++++++++ .../object_validators/common_validations.ex | 6 ++- .../create_chat_message_validator.ex | 5 +++ .../transmogrifier/chat_message_handling.ex | 3 ++ test/fixtures/create-chat-message.json | 2 +- test/web/activity_pub/object_validator_test.exs | 52 ++++++++++++++++++++++ .../transmogrifier/chat_message_test.exs | 34 +++++++++++++- 8 files changed, 128 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 49cc72561..259bbeb64 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -31,7 +31,8 @@ def validate(%{"type" => "Like"} = object, meta) do def validate(%{"type" => "ChatMessage"} = object, meta) do with {:ok, object} <- object - |> ChatMessageValidator.cast_and_apply() do + |> ChatMessageValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} end @@ -40,7 +41,8 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do def validate(%{"type" => "Create"} = object, meta) do with {:ok, object} <- object - |> CreateChatMessageValidator.cast_and_apply() do + |> CreateChatMessageValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index ab5be3596..a4e4460cd 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.User import Ecto.Changeset @@ -54,5 +55,30 @@ def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) |> validate_required([:id, :actor, :to, :type, :content]) + |> validate_length(:to, is: 1) + |> validate_local_concern() + end + + @doc "Validates if at least one of the users in this ChatMessage is a local user, otherwise we don't want the message in our system. It also validates the presence of both users in our system." + def validate_local_concern(cng) do + with actor_ap <- get_field(cng, :actor), + {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, + {_, %User{} = recipient} <- + {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, + {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do + cng + else + {:local?, false} -> + cng + |> add_error(:actor, "actor and recipient are both remote") + + {:find_actor, _} -> + cng + |> add_error(:actor, "can't find user") + + {:find_recipient, _} -> + cng + |> add_error(:to, "can't find user") + end end end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index b479c3918..02f3a6438 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -8,7 +8,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do alias Pleroma.Object alias Pleroma.User - def validate_actor_presence(cng, field_name \\ :actor) do + def validate_actor_presence(cng) do + validate_user_presence(cng, :actor) + end + + def validate_user_presence(cng, field_name) do cng |> validate_change(field_name, fn field_name, actor -> if User.get_cached_by_ap_id(actor) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 659311480..ce52d5623 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -32,4 +32,9 @@ def cast_and_apply(data) do def cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end + + # No validation yet + def cast_and_validate(data) do + cast_data(data) + end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index b5843736f..815b866c9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -25,6 +25,9 @@ def handle_incoming( {_, {:ok, activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do {:ok, activity} + else + e -> + {:error, e} end end end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json index 4aa17f4a5..2e4608f43 100644 --- a/test/fixtures/create-chat-message.json +++ b/test/fixtures/create-chat-message.json @@ -3,7 +3,7 @@ "id": "http://2hu.gensokyo/objects/1", "object": { "attributedTo": "http://2hu.gensokyo/users/raymoo", - "content": "You expected a cute girl? Too bad.", + "content": "You expected a cute girl? Too bad. ", "id": "http://2hu.gensokyo/objects/2", "published": "2020-02-12T14:08:20Z", "to": [ diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3c5c3696e..bf0bfdfaf 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -5,9 +5,61 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.Builder import Pleroma.Factory + describe "chat messages" do + setup do + user = insert(:user) + recipient = insert(:user, local: false) + + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + + %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} + end + + test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do + assert {:ok, _object, _meta} = ObjectValidator.validate(valid_chat_message, []) + end + + test "does not validate if the actor or the recipient is not in our system", %{ + valid_chat_message: valid_chat_message + } do + chat_message = + valid_chat_message + |> Map.put("actor", "https://raymoo.com/raymoo") + + {:error, _} = ObjectValidator.validate(chat_message, []) + + chat_message = + valid_chat_message + |> Map.put("to", ["https://raymoo.com/raymoo"]) + + {:error, _} = ObjectValidator.validate(chat_message, []) + end + + test "does not validate for a message with multiple recipients", %{ + valid_chat_message: valid_chat_message, + user: user, + recipient: recipient + } do + chat_message = + valid_chat_message + |> Map.put("to", [user.ap_id, recipient.ap_id]) + + assert {:error, _} = ObjectValidator.validate(chat_message, []) + end + + test "does not validate if it doesn't concern local users" do + user = insert(:user, local: false) + recipient = insert(:user, local: false) + + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + assert {:error, _} = ObjectValidator.validate(valid_chat_message, []) + end + end + describe "likes" do setup do user = insert(:user) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index aed62c520..5b238f9c4 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -12,13 +12,43 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do alias Pleroma.Web.ActivityPub.Transmogrifier describe "handle_incoming" do - test "it insert it" do + test "it rejects messages that don't contain content" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + object = + data["object"] + |> Map.delete("content") + + data = + data + |> Map.put("object", object) + + _author = insert(:user, ap_id: data["actor"], local: false) + _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + + {:error, _} = Transmogrifier.handle_incoming(data) + end + + test "it rejects messages that don't concern local users" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + _author = insert(:user, ap_id: data["actor"], local: false) + _recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + + {:error, _} = Transmogrifier.handle_incoming(data) + end + + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") |> Poison.decode!() author = insert(:user, ap_id: data["actor"], local: false) - recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + recipient = insert(:user, ap_id: List.first(data["to"]), local: true) {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) -- cgit v1.2.3 From ca598e9c27a7a66b014523845e62046d19364f2f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 15:27:35 +0200 Subject: AccountView: Return user ap_id. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 1 + test/web/mastodon_api/views/account_view_test.exs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8fb96a22a..f20453744 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -234,6 +234,7 @@ defp do_render("show.json", %{user: user} = opts) do # Pleroma extension pleroma: %{ + ap_id: user.ap_id, confirmation_pending: user.confirmation_pending, tags: user.tags, hide_followers_count: user.hide_followers_count, diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 4435f69ff..2be0d8d0f 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -82,6 +82,7 @@ test "Represent a user account" do fields: [] }, pleroma: %{ + ap_id: user.ap_id, background_image: "https://example.com/images/asuka_hospital.png", confirmation_pending: false, tags: [], @@ -152,6 +153,7 @@ test "Represent a Service(bot) account" do fields: [] }, pleroma: %{ + ap_id: user.ap_id, background_image: nil, confirmation_pending: false, tags: [], @@ -351,6 +353,7 @@ test "represent an embedded relationship" do fields: [] }, pleroma: %{ + ap_id: user.ap_id, background_image: nil, confirmation_pending: false, tags: [], -- cgit v1.2.3 From 77ee64b9930bf6b439f87112fa35e302f5125aa2 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 16 Apr 2020 17:54:57 +0300 Subject: user: remove blank? --- lib/pleroma/user.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index fab405233..753b0c686 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -343,9 +343,15 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + name = + case params[:name] do + name when is_binary(name) and byte_size(name) > 0 -> name + _ -> params[:nickname] + end + params = params - |> Map.put(:name, blank?(params[:name]) || params[:nickname]) + |> Map.put(:name, name) |> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now()) |> truncate_if_exists(:name, name_limit) |> truncate_if_exists(:bio, bio_limit) @@ -1599,9 +1605,6 @@ def get_public_key_for_ap_id(ap_id) do end end - defp blank?(""), do: nil - defp blank?(n), do: n - def ap_enabled?(%User{local: true}), do: true def ap_enabled?(%User{ap_enabled: ap_enabled}), do: ap_enabled def ap_enabled?(_), do: false -- cgit v1.2.3 From 4d330d9df13b7ff5d24fdd8b4eec1e111fa51297 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 16 Apr 2020 18:05:36 +0300 Subject: fix for use of published from different entities --- lib/pleroma/web/feed/feed_view.ex | 9 +++------ lib/pleroma/web/templates/feed/feed/_activity.atom.eex | 8 ++++---- lib/pleroma/web/templates/feed/feed/_activity.rss.eex | 8 ++++---- .../web/templates/feed/feed/_tag_activity.atom.eex | 16 ++++++++-------- .../web/templates/feed/feed/_tag_activity.xml.eex | 15 +++++++-------- test/web/feed/tag_controller_test.exs | 4 ++-- 6 files changed, 28 insertions(+), 32 deletions(-) diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index e18adaea8..1ae03e7e2 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -23,7 +23,7 @@ def pub_date(date) when is_binary(date) do def pub_date(%DateTime{} = date), do: Timex.format!(date, "{RFC822}") def prepare_activity(activity, opts \\ []) do - object = activity_object(activity) + object = Object.normalize(activity) actor = if opts[:actor] do @@ -33,7 +33,6 @@ def prepare_activity(activity, opts \\ []) do %{ activity: activity, data: Map.get(object, :data), - object: object, actor: actor } end @@ -68,9 +67,7 @@ def logo(user) do def last_activity(activities), do: List.last(activities) - def activity_object(activity), do: Object.normalize(activity) - - def activity_title(%{data: %{"content" => content}}, opts \\ %{}) do + def activity_title(%{"content" => content}, opts \\ %{}) do content |> Pleroma.Web.Metadata.Utils.scrub_html() |> Pleroma.Emoji.Formatter.demojify() @@ -78,7 +75,7 @@ def activity_title(%{data: %{"content" => content}}, opts \\ %{}) do |> escape() end - def activity_content(%{data: %{"content" => content}}) do + def activity_content(%{"content" => content}) do content |> String.replace(~r/[\n\r]/, "") |> escape() diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index ac8a75009..78350f2aa 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -2,10 +2,10 @@ http://activitystrea.ms/schema/1.0/note http://activitystrea.ms/schema/1.0/post <%= @data["id"] %> - <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> - <%= activity_content(@object) %> - <%= @data["published"] %> - <%= @data["published"] %> + <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> + <%= activity_content(@data) %> + <%= @activity.data["published"] %> + <%= @activity.data["published"] %> <%= activity_context(@activity) %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index a4dbed638..a304a16af 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -2,10 +2,10 @@ http://activitystrea.ms/schema/1.0/note http://activitystrea.ms/schema/1.0/post <%= @data["id"] %> - <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> - <%= activity_content(@object) %> - <%= @data["published"] %> - <%= @data["published"] %> + <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> + <%= activity_content(@data) %> + <%= @activity.data["published"] %> + <%= @activity.data["published"] %> <%= activity_context(@activity) %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index da4fa6d6c..cf5874a91 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -1,12 +1,12 @@ http://activitystrea.ms/schema/1.0/note http://activitystrea.ms/schema/1.0/post - + <%= render @view_module, "_tag_author.atom", assigns %> - + <%= @data["id"] %> - <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> - <%= activity_content(@object) %> + <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> + <%= activity_content(@data) %> <%= if @activity.local do %> @@ -15,8 +15,8 @@ <% end %> - <%= @data["published"] %> - <%= @data["published"] %> + <%= @activity.data["published"] %> + <%= @activity.data["published"] %> <%= activity_context(@activity) %> @@ -26,7 +26,7 @@ <%= if @data["summary"] do %> <%= @data["summary"] %> <% end %> - + <%= for id <- @activity.recipients do %> <%= if id == Pleroma.Constants.as_public() do %> <% end %> <% end %> - + <%= for tag <- @data["tag"] || [] do %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex index 295574df1..2334e24a2 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.xml.eex @@ -1,15 +1,14 @@ - <%= activity_title(@object, Keyword.get(@feed_config, :post_title, %{})) %> - - + <%= activity_title(@data, Keyword.get(@feed_config, :post_title, %{})) %> + + <%= activity_context(@activity) %> <%= activity_context(@activity) %> - <%= pub_date(@data["published"]) %> - - <%= activity_content(@object) %> + <%= pub_date(@activity.data["published"]) %> + + <%= activity_content(@data) %> <%= for attachment <- @data["attachment"] || [] do %> <% end %> - - + diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index e863df86b..d95aac108 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -150,8 +150,8 @@ test "gets a feed (RSS)", %{conn: conn} do obj2 = Object.normalize(activity2) assert xpath(xml, ~x"//channel/item/description/text()"sl) == [ - HtmlEntities.decode(FeedView.activity_content(obj2)), - HtmlEntities.decode(FeedView.activity_content(obj1)) + HtmlEntities.decode(FeedView.activity_content(obj2.data)), + HtmlEntities.decode(FeedView.activity_content(obj1.data)) ] response = -- cgit v1.2.3 From e983f708846a5784e23b7e18734a61ed7f6e3636 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 17:50:24 +0200 Subject: ChatMessagesHandling: Strip HTML of incoming messages. --- lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex | 3 +++ test/web/activity_pub/transmogrifier/chat_message_test.exs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index 815b866c9..11bd10456 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -19,6 +19,9 @@ def handle_incoming( {_, {:ok, object_cast_data_sym}} <- {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), + # For now, just strip HTML + stripped_content = Pleroma.HTML.strip_tags(object_cast_data["content"]), + object_cast_data = object_cast_data |> Map.put("content", stripped_content), {_, {:ok, validated_object, _meta}} <- {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 5b238f9c4..7e7f9ebec 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -56,7 +56,9 @@ test "it inserts it and creates a chat" do assert activity.recipients == [recipient.ap_id, author.ap_id] %Object{} = object = Object.get_by_ap_id(activity.data["object"]) + assert object + assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" end end end -- cgit v1.2.3 From f8c3ae7a627817789776f11497041445bb273c19 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 16 Apr 2020 18:43:31 +0200 Subject: ChatController: Handle pagination. --- .../web/pleroma_api/controllers/chat_controller.ex | 12 ++--- .../web/pleroma_api/views/chat_message_view.ex | 4 +- lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- .../controllers/chat_controller_test.exs | 62 ++++++++++++++++++++-- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 5ec546847..8cf8d82e4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Pagination import Ecto.Query @@ -35,7 +36,7 @@ def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ end end - def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) do + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = from(o in Object, @@ -54,10 +55,9 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) d ^chat.recipient, o.data, ^[user.ap_id] - ), - order_by: [desc: o.id] + ) ) - |> Repo.all() + |> Pagination.fetch_paginated(params) conn |> put_view(ChatMessageView) @@ -65,13 +65,13 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id}) d end end - def index(%{assigns: %{user: %{id: user_id}}} = conn, _params) do + def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do chats = from(c in Chat, where: c.user_id == ^user_id, order_by: [desc: c.updated_at] ) - |> Repo.all() + |> Pagination.fetch_paginated(params) conn |> put_view(ChatView) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index 2df591358..fdbb9ff1b 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -15,9 +15,9 @@ def render( } ) do %{ - id: id, + id: id |> to_string(), content: chat_message["content"], - chat_id: chat_id, + chat_id: chat_id |> to_string(), actor: chat_message["actor"] } end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index ee48385bf..7b8c6450a 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat}) do %{ - id: chat.id, + id: chat.id |> to_string(), recipient: chat.recipient, unread: chat.unread } diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index dad37a889..f30fd6615 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -23,11 +23,38 @@ test "it posts a message to the chat", %{conn: conn} do |> json_response(200) assert result["content"] == "Hallo!!" - assert result["chat_id"] == chat.id + assert result["chat_id"] == chat.id |> to_string() end end describe "GET /api/v1/pleroma/chats/:id/messages" do + test "it paginates", %{conn: conn} do + user = insert(:user) + recipient = insert(:user) + + Enum.each(1..30, fn _ -> + {:ok, _} = CommonAPI.post_chat_message(user, recipient, "hey") + end) + + chat = Chat.get(user.id, recipient.ap_id) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + |> json_response(200) + + assert length(result) == 20 + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages", %{"max_id" => List.last(result)["id"]}) + |> json_response(200) + + assert length(result) == 10 + end + # TODO # - Test the case where it's not the user's chat test "it returns the messages for a given chat", %{conn: conn} do @@ -50,7 +77,7 @@ test "it returns the messages for a given chat", %{conn: conn} do result |> Enum.each(fn message -> - assert message["chat_id"] == chat.id + assert message["chat_id"] == chat.id |> to_string() end) assert length(result) == 3 @@ -73,6 +100,31 @@ test "it creates or returns a chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats" do + test "it paginates", %{conn: conn} do + user = insert(:user) + + Enum.each(1..30, fn _ -> + recipient = insert(:user) + {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + end) + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats") + |> json_response(200) + + assert length(result) == 20 + + result = + conn + |> assign(:user, user) + |> get("/api/v1/pleroma/chats", %{max_id: List.last(result)["id"]}) + |> json_response(200) + + assert length(result) == 10 + end + test "it return a list of chats the current user is participating in, in descending order of updates", %{conn: conn} do user = insert(:user) @@ -98,7 +150,11 @@ test "it return a list of chats the current user is participating in, in descend ids = Enum.map(result, & &1["id"]) - assert ids == [chat_2.id, chat_3.id, chat_1.id] + assert ids == [ + chat_2.id |> to_string(), + chat_3.id |> to_string(), + chat_1.id |> to_string() + ] end end end -- cgit v1.2.3 From 304ea09f4c9902a1f96f30541e6c5d253527dd47 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 17 Apr 2020 08:42:48 +0300 Subject: fix for logger configuration --- lib/pleroma/config/transfer_task.ex | 9 +++++++-- test/config/transfer_task_test.exs | 32 ++++++++++++-------------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 3871e1cbb..f4722f99d 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -122,7 +122,7 @@ defp configure({_, :backends, _, merged}) do :ok = update_env(:logger, :backends, merged) end - defp configure({group, key, _, merged}) do + defp configure({_, key, _, merged}) when key in [:console, :ex_syslogger] do merged = if key == :console do put_in(merged[:format], merged[:format] <> "\n") @@ -136,7 +136,12 @@ defp configure({group, key, _, merged}) do else: key Logger.configure_backend(backend, merged) - :ok = update_env(:logger, group, merged) + :ok = update_env(:logger, key, merged) + end + + defp configure({_, key, _, merged}) do + Logger.configure([{key, merged}]) + :ok = update_env(:logger, key, merged) end defp update({group, key, value, merged}) do diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 0265a6156..00db0b686 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -16,6 +16,7 @@ test "transfer config values from db to env" do refute Application.get_env(:pleroma, :test_key) refute Application.get_env(:idna, :test_key) refute Application.get_env(:quack, :test_key) + initial = Application.get_env(:logger, :level) ConfigDB.create(%{ group: ":pleroma", @@ -35,16 +36,20 @@ test "transfer config values from db to env" do value: [:test_value1, :test_value2] }) + ConfigDB.create(%{group: ":logger", key: ":level", value: :debug}) + TransferTask.start_link([]) assert Application.get_env(:pleroma, :test_key) == [live: 2, com: 3] assert Application.get_env(:idna, :test_key) == [live: 15, com: 35] assert Application.get_env(:quack, :test_key) == [:test_value1, :test_value2] + assert Application.get_env(:logger, :level) == :debug on_exit(fn -> Application.delete_env(:pleroma, :test_key) Application.delete_env(:idna, :test_key) Application.delete_env(:quack, :test_key) + Application.put_env(:logger, :level, initial) end) end @@ -78,8 +83,8 @@ test "transfer config values for 1 group and some keys" do end test "transfer config values with full subkey update" do - emoji = Application.get_env(:pleroma, :emoji) - assets = Application.get_env(:pleroma, :assets) + clear_config(:emoji) + clear_config(:assets) ConfigDB.create(%{ group: ":pleroma", @@ -99,11 +104,6 @@ test "transfer config values with full subkey update" do assert emoji_env[:groups] == [a: 1, b: 2] assets_env = Application.get_env(:pleroma, :assets) assert assets_env[:mascots] == [a: 1, b: 2] - - on_exit(fn -> - Application.put_env(:pleroma, :emoji, emoji) - Application.put_env(:pleroma, :assets, assets) - end) end describe "pleroma restart" do @@ -112,8 +112,7 @@ test "transfer config values with full subkey update" do end test "don't restart if no reboot time settings were changed" do - emoji = Application.get_env(:pleroma, :emoji) - on_exit(fn -> Application.put_env(:pleroma, :emoji, emoji) end) + clear_config(:emoji) ConfigDB.create(%{ group: ":pleroma", @@ -128,8 +127,7 @@ test "don't restart if no reboot time settings were changed" do end test "on reboot time key" do - chat = Application.get_env(:pleroma, :chat) - on_exit(fn -> Application.put_env(:pleroma, :chat, chat) end) + clear_config(:chat) ConfigDB.create(%{ group: ":pleroma", @@ -141,8 +139,7 @@ test "on reboot time key" do end test "on reboot time subkey" do - captcha = Application.get_env(:pleroma, Pleroma.Captcha) - on_exit(fn -> Application.put_env(:pleroma, Pleroma.Captcha, captcha) end) + clear_config(Pleroma.Captcha) ConfigDB.create(%{ group: ":pleroma", @@ -154,13 +151,8 @@ test "on reboot time subkey" do end test "don't restart pleroma on reboot time key and subkey if there is false flag" do - chat = Application.get_env(:pleroma, :chat) - captcha = Application.get_env(:pleroma, Pleroma.Captcha) - - on_exit(fn -> - Application.put_env(:pleroma, :chat, chat) - Application.put_env(:pleroma, Pleroma.Captcha, captcha) - end) + clear_config(:chat) + clear_config(Pleroma.Captcha) ConfigDB.create(%{ group: ":pleroma", -- cgit v1.2.3 From 4d22b100b777b59e79180d5d3ea8615db940b1fc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 17 Apr 2020 12:33:11 +0300 Subject: move changelogs entries to unreleased section --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6737408..2239a5288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. +- Mix task to create trusted OAuth App.
    API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. +- Admin API: endpoints for create/update/delete OAuth Apps.
    ### Fixed @@ -155,7 +157,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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`) -- Mix task to create trusted OAuth App.
    API Changes @@ -202,7 +203,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - ActivityPub: `[:activitypub, :note_replies_output_limit]` setting sets the number of note self-replies to output on outgoing federation. - Admin API: `GET /api/pleroma/admin/stats` to get status count by visibility scope - Admin API: `GET /api/pleroma/admin/statuses` - list all statuses (accepts `godmode` and `local_only`) -- Admin API: endpoints for create/update/delete OAuth Apps.
    ### Fixed -- cgit v1.2.3 From 6cda360fea8a42168b5835ef903cf3bf89c8151a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 16 Apr 2020 10:36:37 +0300 Subject: don't restart postgrex --- lib/pleroma/config/loader.ex | 2 +- lib/pleroma/config/transfer_task.ex | 20 +++++++++++--------- test/config/transfer_task_test.exs | 9 +++++++++ test/fixtures/config/temp.secret.exs | 2 ++ test/tasks/config_test.exs | 3 ++- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 6ca6550bd..0f3ecf1ed 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -47,7 +47,7 @@ defp filter(configs) do @spec filter_group(atom(), keyword()) :: keyword() def filter_group(group, configs) do Enum.reject(configs[group], fn {key, _v} -> - key in @reject_keys or (group == :phoenix and key == :serve_endpoints) + key in @reject_keys or (group == :phoenix and key == :serve_endpoints) or group == :postgrex end) end end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index f4722f99d..c02b70e96 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -46,14 +46,6 @@ 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 - # TODO: some problem with prometheus after restart! - reject_restart = - if restart_pleroma? do - [nil, :prometheus] - else - [:pleroma, nil, :prometheus] - end - {logger, other} = (Repo.all(ConfigDB) ++ deleted_settings) |> Enum.map(&transform_and_merge/1) @@ -65,10 +57,20 @@ def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do started_applications = Application.started_applications() + # TODO: some problem with prometheus after restart! + reject = [nil, :prometheus, :postgrex] + + reject = + if restart_pleroma? do + reject + else + [:pleroma | reject] + end + other |> Enum.map(&update/1) |> Enum.uniq() - |> Enum.reject(&(&1 in reject_restart)) + |> Enum.reject(&(&1 in reject)) |> maybe_set_pleroma_last() |> Enum.each(&restart(started_applications, &1, Config.get(:env))) diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 00db0b686..473899d1d 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -16,6 +16,7 @@ test "transfer config values from db to env" do refute Application.get_env(:pleroma, :test_key) refute Application.get_env(:idna, :test_key) refute Application.get_env(:quack, :test_key) + refute Application.get_env(:postgrex, :test_key) initial = Application.get_env(:logger, :level) ConfigDB.create(%{ @@ -36,6 +37,12 @@ test "transfer config values from db to env" do value: [:test_value1, :test_value2] }) + ConfigDB.create(%{ + group: ":postgrex", + key: ":test_key", + value: :value + }) + ConfigDB.create(%{group: ":logger", key: ":level", value: :debug}) TransferTask.start_link([]) @@ -44,11 +51,13 @@ test "transfer config values from db to env" do assert Application.get_env(:idna, :test_key) == [live: 15, com: 35] assert Application.get_env(:quack, :test_key) == [:test_value1, :test_value2] assert Application.get_env(:logger, :level) == :debug + assert Application.get_env(:postgrex, :test_key) == :value on_exit(fn -> Application.delete_env(:pleroma, :test_key) Application.delete_env(:idna, :test_key) Application.delete_env(:quack, :test_key) + Application.delete_env(:postgrex, :test_key) Application.put_env(:logger, :level, initial) end) end diff --git a/test/fixtures/config/temp.secret.exs b/test/fixtures/config/temp.secret.exs index f4686c101..dc950ca30 100644 --- a/test/fixtures/config/temp.secret.exs +++ b/test/fixtures/config/temp.secret.exs @@ -7,3 +7,5 @@ config :quack, level: :info config :pleroma, Pleroma.Repo, pool: Ecto.Adapters.SQL.Sandbox + +config :postgrex, :json_library, Poison diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index 3dee4f082..04bc947a9 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -38,7 +38,7 @@ test "error if file with custom settings doesn't exist" do on_exit(fn -> Application.put_env(:quack, :level, initial) end) end - test "settings are migrated to db" do + test "filtered settings are migrated to db" do assert Repo.all(ConfigDB) == [] Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs") @@ -47,6 +47,7 @@ test "settings are migrated to db" do config2 = ConfigDB.get_by_params(%{group: ":pleroma", key: ":second_setting"}) config3 = ConfigDB.get_by_params(%{group: ":quack", key: ":level"}) refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) + refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"}) assert ConfigDB.from_binary(config1.value) == [key: "value", key2: [Repo]] assert ConfigDB.from_binary(config2.value) == [key: "value2", key2: ["Activity"]] -- cgit v1.2.3 From d45ae6485811189e98f774ecdb46f0ccdfa8b2b3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 13:04:46 +0200 Subject: ChatController: Use OAuth scopes. --- .../web/pleroma_api/controllers/chat_controller.ex | 18 ++++++++-- .../controllers/chat_controller_test.exs | 41 +++++++++++----------- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8cf8d82e4..31c723426 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView @@ -16,10 +17,18 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do import Ecto.Query # TODO - # - Oauth stuff - # - Views / Representers # - Error handling + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"]} when action in [:messages, :index] + ) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ @@ -62,6 +71,11 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = conn |> put_view(ChatMessageView) |> render("index.json", for: user, objects: messages, chat: chat) + else + _ -> + conn + |> put_status(:not_found) + |> json(%{error: "not found"}) end end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index f30fd6615..0750c7273 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -10,15 +10,15 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages" do - test "it posts a message to the chat", %{conn: conn} do - user = insert(:user) + setup do: oauth_access(["write:statuses"]) + + test "it posts a message to the chat", %{conn: conn, user: user} do other_user = insert(:user) {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) result = conn - |> assign(:user, user) |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) |> json_response(200) @@ -28,8 +28,9 @@ test "it posts a message to the chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats/:id/messages" do - test "it paginates", %{conn: conn} do - user = insert(:user) + setup do: oauth_access(["read:statuses"]) + + test "it paginates", %{conn: conn, user: user} do recipient = insert(:user) Enum.each(1..30, fn _ -> @@ -40,7 +41,6 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats/#{chat.id}/messages") |> json_response(200) @@ -48,17 +48,13 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats/#{chat.id}/messages", %{"max_id" => List.last(result)["id"]}) |> json_response(200) assert length(result) == 10 end - # TODO - # - Test the case where it's not the user's chat - test "it returns the messages for a given chat", %{conn: conn} do - user = insert(:user) + test "it returns the messages for a given chat", %{conn: conn, user: user} do other_user = insert(:user) third_user = insert(:user) @@ -71,7 +67,6 @@ test "it returns the messages for a given chat", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats/#{chat.id}/messages") |> json_response(200) @@ -81,17 +76,25 @@ test "it returns the messages for a given chat", %{conn: conn} do end) assert length(result) == 3 + + # Trying to get the chat of a different user + result = + conn + |> assign(:user, other_user) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages") + + assert result |> json_response(404) end end describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do + setup do: oauth_access(["write:statuses"]) + test "it creates or returns a chat", %{conn: conn} do - user = insert(:user) other_user = insert(:user) result = conn - |> assign(:user, user) |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") |> json_response(200) @@ -100,9 +103,9 @@ test "it creates or returns a chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats" do - test "it paginates", %{conn: conn} do - user = insert(:user) + setup do: oauth_access(["read:statuses"]) + test "it paginates", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) @@ -110,7 +113,6 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats") |> json_response(200) @@ -118,7 +120,6 @@ test "it paginates", %{conn: conn} do result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats", %{max_id: List.last(result)["id"]}) |> json_response(200) @@ -126,8 +127,7 @@ test "it paginates", %{conn: conn} do end test "it return a list of chats the current user is participating in, in descending order of updates", - %{conn: conn} do - user = insert(:user) + %{conn: conn, user: user} do har = insert(:user) jafnhar = insert(:user) tridi = insert(:user) @@ -144,7 +144,6 @@ test "it return a list of chats the current user is participating in, in descend result = conn - |> assign(:user, user) |> get("/api/v1/pleroma/chats") |> json_response(200) -- cgit v1.2.3 From 46f051048fb1afb02fe81b872ae9f595f2c5f2c1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 17 Apr 2020 14:32:15 +0200 Subject: migrations/20200406100225_users_add_emoji: Fix tag to Emoji filtering --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- priv/repo/migrations/20200406100225_users_add_emoji.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 35af0f7dc..d403405a0 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1430,7 +1430,7 @@ defp object_to_user_data(data) do emojis = data |> Map.get("tag", []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> Map.put(acc, String.trim(name, ":"), url) end) diff --git a/priv/repo/migrations/20200406100225_users_add_emoji.exs b/priv/repo/migrations/20200406100225_users_add_emoji.exs index d0254c170..9f57abb5c 100644 --- a/priv/repo/migrations/20200406100225_users_add_emoji.exs +++ b/priv/repo/migrations/20200406100225_users_add_emoji.exs @@ -17,7 +17,7 @@ def up do emoji = user.source_data |> Map.get("tag", []) - |> Enum.filter(fn %{"type" => t} -> t == "Emoji" end) + |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> Map.put(acc, String.trim(name, ":"), url) end) -- cgit v1.2.3 From 372614cfd3119b589c9c47619445714e8ae6c07e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 14:23:59 +0200 Subject: ChatView: Add a mastodon api representation of the recipient. --- .../transmogrifier/chat_message_handling.ex | 1 + lib/pleroma/web/pleroma_api/views/chat_view.ex | 7 +++++- .../transmogrifier/chat_message_test.exs | 19 ++++++++++++++ test/web/pleroma_api/views/chat_view_test.exs | 29 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/web/pleroma_api/views/chat_view_test.exs diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index 11bd10456..cfe3b767b 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -22,6 +22,7 @@ def handle_incoming( # For now, just strip HTML stripped_content = Pleroma.HTML.strip_tags(object_cast_data["content"]), object_cast_data = object_cast_data |> Map.put("content", stripped_content), + {_, true} <- {:to_fields_match, cast_data["to"] == object_cast_data["to"]}, {_, {:ok, validated_object, _meta}} <- {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 7b8c6450a..1e9ef4356 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -6,11 +6,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.User + alias Pleroma.Web.MastodonAPI.AccountView + + def render("show.json", %{chat: %Chat{} = chat} = opts) do + recipient = User.get_cached_by_ap_id(chat.recipient) - def render("show.json", %{chat: %Chat{} = chat}) do %{ id: chat.id |> to_string(), recipient: chat.recipient, + recipient_account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread } end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 7e7f9ebec..4d6f24609 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do import Pleroma.Factory alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Web.ActivityPub.Transmogrifier @@ -42,6 +43,21 @@ test "it rejects messages that don't concern local users" do {:error, _} = Transmogrifier.handle_incoming(data) end + test "it rejects messages where the `to` field of activity and object don't match" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + author = insert(:user, ap_id: data["actor"]) + _recipient = insert(:user, ap_id: List.first(data["to"])) + + data = + data + |> Map.put("to", author.ap_id) + + {:error, _} = Transmogrifier.handle_incoming(data) + end + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") @@ -59,6 +75,9 @@ test "it inserts it and creates a chat" do assert object assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" + + refute Chat.get(author.id, recipient.ap_id) + assert Chat.get(recipient.id, author.ap_id) end end end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs new file mode 100644 index 000000000..1eb0c6241 --- /dev/null +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do + use Pleroma.DataCase + + alias Pleroma.Chat + alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.MastodonAPI.AccountView + + import Pleroma.Factory + + test "it represents a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + represented_chat = ChatView.render("show.json", chat: chat) + + assert represented_chat == %{ + id: "#{chat.id}", + recipient: recipient.ap_id, + recipient_account: AccountView.render("show.json", user: recipient), + unread: 0 + } + end +end -- cgit v1.2.3 From 26d9c83316fe5d8a3bf1f8fadae727788a92a725 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 15:50:15 +0200 Subject: SideEffects: Test for notification creation. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ test/web/activity_pub/side_effects_test.exs | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 666a4e310..6a8f1af96 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -17,7 +17,9 @@ def handle(object, meta \\ []) 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 diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b67bd14b3..0b6b55156 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -5,7 +5,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase + alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects @@ -15,13 +17,14 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do describe "like objects" do setup do + poster = insert(:user) user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) {:ok, like_data, _meta} = Builder.like(user, post.object) {:ok, like, _meta} = ActivityPub.persist(like_data, local: true) - %{like: like, user: user} + %{like: like, user: user, poster: poster} end test "add the like to the original object", %{like: like, user: user} do @@ -30,5 +33,10 @@ test "add the like to the original object", %{like: like, user: user} do assert object.data["like_count"] == 1 assert user.ap_id in object.data["likes"] end + + test "creates a notification", %{like: like, poster: poster} do + {:ok, like, _} = SideEffects.handle(like) + assert Repo.get_by(Notification, user_id: poster.id, activity_id: like.id) + end end end -- cgit v1.2.3 From c8458209110ef65101f965e460329308e5843559 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 17 Apr 2020 16:55:01 +0200 Subject: Notifications: Create a chat notification. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ .../web/mastodon_api/views/notification_view.ex | 30 +++++++++++++++++++++- test/web/activity_pub/side_effects_test.exs | 19 ++++++++++++++ .../mastodon_api/views/notification_view_test.exs | 26 +++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 594f32700..f32a99ec4 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -28,6 +28,8 @@ def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, met {:ok, _object} = handle_object_creation(object) + Notification.create_notifications(activity) + {:ok, activity, meta} end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 734ffbf39..5d231f0c4 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -8,11 +8,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.User + alias Pleroma.Object alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.PleromaAPI.ChatMessageView def render("index.json", %{notifications: notifications, for: reading_user} = opts) do activities = Enum.map(notifications, & &1.activity) @@ -81,7 +83,20 @@ def render( end end - mastodon_type = Activity.mastodon_notification_type(activity) + # This returns the notification type by activity, but both chats and statuses are in "Create" activities. + mastodon_type = + case Activity.mastodon_notification_type(activity) do + "mention" -> + object = Object.normalize(activity) + + case object do + %{data: %{"type" => "ChatMessage"}} -> "pleroma:chat_mention" + _ -> "mention" + end + + type -> + type + end render_opts = %{ relationships: opts[:relationships], @@ -125,6 +140,9 @@ def render( |> put_status(parent_activity_fn.(), reading_user, render_opts) |> put_emoji(activity) + "pleroma:chat_mention" -> + put_chat_message(response, activity, reading_user, render_opts) + _ -> nil end @@ -137,6 +155,16 @@ defp put_emoji(response, activity) do Map.put(response, :emoji, activity.data["content"]) end + defp put_chat_message(response, activity, reading_user, opts) do + object = Object.normalize(activity) + author = User.get_cached_by_ap_id(object.data["actor"]) + chat = Pleroma.Chat.get(reading_user.id, author.ap_id) + render_opts = Map.merge(opts, %{object: object, for: reading_user, chat: chat}) + chat_message_render = ChatMessageView.render("show.json", render_opts) + + Map.put(response, :chat_message, chat_message_render) + end + 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) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b629d0d5d..d3ad4866c 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -6,7 +6,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects @@ -34,6 +36,23 @@ test "add the like to the original object", %{like: like, user: user} do end describe "creation of ChatMessages" do + test "notifies the recipient" do + author = insert(:user, local: false) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + {:ok, chat_message_object} = Object.create(chat_message_data) + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + + assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) + end + test "it creates a Chat for the local users and bumps the unread count" do author = insert(:user, local: false) recipient = insert(:user, local: true) diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c3ec9dfec..a48c298f2 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -6,7 +6,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Notification + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -14,6 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.PleromaAPI.ChatMessageView import Pleroma.Factory defp test_notifications_rendering(notifications, user, expected_result) do @@ -31,6 +34,29 @@ defp test_notifications_rendering(notifications, user, expected_result) do assert expected_result == result end + test "ChatMessage notification" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "what's up my dude") + + {:ok, [notification]} = Notification.create_notifications(activity) + + object = Object.normalize(activity) + chat = Chat.get(recipient.id, user.ap_id) + + expected = %{ + id: to_string(notification.id), + pleroma: %{is_seen: false}, + type: "pleroma:chat_mention", + account: AccountView.render("show.json", %{user: user, for: recipient}), + chat_message: + ChatMessageView.render("show.json", %{object: object, for: recipient, chat: chat}), + created_at: Utils.to_masto_date(notification.inserted_at) + } + + test_notifications_rendering([notification], recipient, [expected]) + end + test "Mention notification" do user = insert(:user) mentioned_user = insert(:user) -- cgit v1.2.3 From 163341857a726e8d74b6ddcd1230579e4c36a1b5 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 17 Apr 2020 19:27:22 +0400 Subject: Improve OpenAPI errors --- lib/pleroma/web/api_spec/render_error.ex | 220 ++++++++++++++++++++- .../controllers/account_controller_test.exs | 11 +- 2 files changed, 222 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex index 9184c43b6..b5877ca9c 100644 --- a/lib/pleroma/web/api_spec/render_error.ex +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -5,23 +5,227 @@ defmodule Pleroma.Web.ApiSpec.RenderError do @behaviour Plug - alias OpenApiSpex.Plug.JsonRenderError - alias Plug.Conn + import Plug.Conn, only: [put_status: 2] + import Phoenix.Controller, only: [json: 2] + import Pleroma.Web.Gettext @impl Plug def init(opts), do: opts @impl Plug - def call(%{private: %{open_api_spex: %{operation_id: "AccountController.create"}}} = conn, _) do + def call(conn, errors) do + errors = + Enum.map(errors, fn + %{name: nil} = err -> + %OpenApiSpex.Cast.Error{err | name: List.last(err.path)} + + err -> + err + end) + conn - |> Conn.put_status(:bad_request) - |> Phoenix.Controller.json(%{"error" => "Missing parameters"}) + |> put_status(:bad_request) + |> json(%{ + error: errors |> Enum.map(&message/1) |> Enum.join(" "), + errors: errors |> Enum.map(&render_error/1) + }) + end + + defp render_error(error) do + pointer = OpenApiSpex.path_to_string(error) + + %{ + title: "Invalid value", + source: %{ + pointer: pointer + }, + message: OpenApiSpex.Cast.Error.message(error) + } + end + + defp message(%{reason: :invalid_schema_type, type: type, name: name}) do + gettext("%{name} - Invalid schema.type. Got: %{type}.", + name: name, + type: inspect(type) + ) + end + + defp message(%{reason: :null_value, name: name} = error) do + case error.type do + nil -> + gettext("%{name} - null value.", name: name) + + type -> + gettext("%{name} - null value where %{type} expected.", + name: name, + type: type + ) + end + end + + defp message(%{reason: :all_of, meta: %{invalid_schema: invalid_schema}}) do + gettext( + "Failed to cast value as %{invalid_schema}. Value must be castable using `allOf` schemas listed.", + invalid_schema: invalid_schema + ) + end + + defp message(%{reason: :any_of, meta: %{failed_schemas: failed_schemas}}) do + gettext("Failed to cast value using any of: %{failed_schemas}.", + failed_schemas: failed_schemas + ) + end + + defp message(%{reason: :one_of, meta: %{failed_schemas: failed_schemas}}) do + gettext("Failed to cast value to one of: %{failed_schemas}.", failed_schemas: failed_schemas) end - def call(conn, reason) do - opts = JsonRenderError.init(reason) + defp message(%{reason: :min_length, length: length, name: name}) do + gettext("%{name} - String length is smaller than minLength: %{length}.", + name: name, + length: length + ) + end + + defp message(%{reason: :max_length, length: length, name: name}) do + gettext("%{name} - String length is larger than maxLength: %{length}.", + name: name, + length: length + ) + end + + defp message(%{reason: :unique_items, name: name}) do + gettext("%{name} - Array items must be unique.", name: name) + end + + defp message(%{reason: :min_items, length: min, value: array, name: name}) do + gettext("%{name} - Array length %{length} is smaller than minItems: %{min}.", + name: name, + length: length(array), + min: min + ) + end + + defp message(%{reason: :max_items, length: max, value: array, name: name}) do + gettext("%{name} - Array length %{length} is larger than maxItems: %{}.", + name: name, + length: length(array), + max: max + ) + end + + defp message(%{reason: :multiple_of, length: multiple, value: count, name: name}) do + gettext("%{name} - %{count} is not a multiple of %{multiple}.", + name: name, + count: count, + multiple: multiple + ) + end + + defp message(%{reason: :exclusive_max, length: max, value: value, name: name}) + when value >= max do + gettext("%{name} - %{value} is larger than exclusive maximum %{max}.", + name: name, + value: value, + max: max + ) + end + + defp message(%{reason: :maximum, length: max, value: value, name: name}) + when value > max do + gettext("%{name} - %{value} is larger than inclusive maximum %{max}.", + name: name, + value: value, + max: max + ) + end + + defp message(%{reason: :exclusive_multiple, length: min, value: value, name: name}) + when value <= min do + gettext("%{name} - %{value} is smaller than exclusive minimum %{min}.", + name: name, + value: value, + min: min + ) + end + + defp message(%{reason: :minimum, length: min, value: value, name: name}) + when value < min do + gettext("%{name} - %{value} is smaller than inclusive minimum %{min}.", + name: name, + value: value, + min: min + ) + end + + defp message(%{reason: :invalid_type, type: type, value: value, name: name}) do + gettext("%{name} - Invalid %{type}. Got: %{value}.", + name: name, + value: OpenApiSpex.TermType.type(value), + type: type + ) + end + + defp message(%{reason: :invalid_format, format: format, name: name}) do + gettext("%{name} - Invalid format. Expected %{format}.", name: name, format: inspect(format)) + end + + defp message(%{reason: :invalid_enum, name: name}) do + gettext("%{name} - Invalid value for enum.", name: name) + end + + defp message(%{reason: :polymorphic_failed, type: polymorphic_type}) do + gettext("Failed to cast to any schema in %{polymorphic_type}", + polymorphic_type: polymorphic_type + ) + end + + defp message(%{reason: :unexpected_field, name: name}) do + gettext("Unexpected field: %{name}.", name: safe_string(name)) + end + + defp message(%{reason: :no_value_for_discriminator, name: field}) do + gettext("Value used as discriminator for `%{field}` matches no schemas.", name: field) + end + + defp message(%{reason: :invalid_discriminator_value, name: field}) do + gettext("No value provided for required discriminator `%{field}`.", name: field) + end + + defp message(%{reason: :unknown_schema, name: name}) do + gettext("Unknown schema: %{name}.", name: name) + end + + defp message(%{reason: :missing_field, name: name}) do + gettext("Missing field: %{name}.", name: name) + end + + defp message(%{reason: :missing_header, name: name}) do + gettext("Missing header: %{name}.", name: name) + end + + defp message(%{reason: :invalid_header, name: name}) do + gettext("Invalid value for header: %{name}.", name: name) + end + + defp message(%{reason: :max_properties, meta: meta}) do + gettext( + "Object property count %{property_count} is greater than maxProperties: %{max_properties}.", + property_count: meta.property_count, + max_properties: meta.max_properties + ) + end + + defp message(%{reason: :min_properties, meta: meta}) do + gettext( + "Object property count %{property_count} is less than minProperties: %{min_properties}", + property_count: meta.property_count, + min_properties: meta.min_properties + ) + end - JsonRenderError.call(conn, opts) + defp safe_string(string) do + to_string(string) |> String.slice(0..39) 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 86136f7e4..133d7f642 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -952,7 +952,16 @@ test "returns bad_request if missing required params", %{ |> post("/api/v1/accounts", Map.delete(valid_params, attr)) |> json_response(400) - assert res == %{"error" => "Missing parameters"} + assert res == %{ + "error" => "Missing field: #{attr}.", + "errors" => [ + %{ + "message" => "Missing field: #{attr}", + "source" => %{"pointer" => "/#{attr}"}, + "title" => "Invalid value" + } + ] + } end) end -- cgit v1.2.3 From 66f55106bda23e0cfb01cb63f7397f4383518963 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 17 Apr 2020 21:21:10 +0300 Subject: [#1682] Fixed Basic Auth permissions issue by disabling OAuth scopes checks when password is provided. Refactored plugs skipping functionality. --- CHANGELOG.md | 1 + lib/pleroma/plugs/authentication_plug.ex | 6 +++- lib/pleroma/plugs/legacy_authentication_plug.ex | 3 ++ lib/pleroma/plugs/plug_helper.ex | 24 +++++++------ lib/pleroma/web/web.ex | 28 +++++++++++---- test/plugs/authentication_plug_test.exs | 7 +++- test/plugs/legacy_authentication_plug_test.exs | 6 +++- test/plugs/oauth_scopes_plug_test.exs | 3 +- test/web/auth/basic_auth_test.exs | 46 +++++++++++++++++++++++++ 9 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 test/web/auth/basic_auth_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2239a5288..53a3d7fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased-patch] ### Fixed - Logger configuration through AdminFE +- HTTP Basic Authentication permissions issue ### Added
    diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index 089028d77..0061c69dc 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -4,8 +4,11 @@ defmodule Pleroma.Plugs.AuthenticationPlug do alias Comeonin.Pbkdf2 - import Plug.Conn + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User + + import Plug.Conn + require Logger def init(options), do: options @@ -37,6 +40,7 @@ def call( if Pbkdf2.checkpw(password, password_hash) do conn |> assign(:user, auth_user) + |> OAuthScopesPlug.skip_plug() else conn end diff --git a/lib/pleroma/plugs/legacy_authentication_plug.ex b/lib/pleroma/plugs/legacy_authentication_plug.ex index 5c5c36c56..d346e01a6 100644 --- a/lib/pleroma/plugs/legacy_authentication_plug.ex +++ b/lib/pleroma/plugs/legacy_authentication_plug.ex @@ -4,6 +4,8 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlug do import Plug.Conn + + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User def init(options) do @@ -27,6 +29,7 @@ def call( conn |> assign(:auth_user, user) |> assign(:user, user) + |> OAuthScopesPlug.skip_plug() else _ -> conn diff --git a/lib/pleroma/plugs/plug_helper.ex b/lib/pleroma/plugs/plug_helper.ex index 4f83e9414..9c67be8ef 100644 --- a/lib/pleroma/plugs/plug_helper.ex +++ b/lib/pleroma/plugs/plug_helper.ex @@ -5,30 +5,32 @@ defmodule Pleroma.Plugs.PlugHelper do @moduledoc "Pleroma Plug helper" - def append_to_called_plugs(conn, plug_module) do - append_to_private_list(conn, :called_plugs, plug_module) - end + @called_plugs_list_id :called_plugs + def called_plugs_list_id, do: @called_plugs_list_id - def append_to_skipped_plugs(conn, plug_module) do - append_to_private_list(conn, :skipped_plugs, plug_module) - end + @skipped_plugs_list_id :skipped_plugs + def skipped_plugs_list_id, do: @skipped_plugs_list_id + @doc "Returns `true` if specified plug was called." def plug_called?(conn, plug_module) do - contained_in_private_list?(conn, :called_plugs, plug_module) + contained_in_private_list?(conn, @called_plugs_list_id, plug_module) end + @doc "Returns `true` if specified plug was explicitly marked as skipped." def plug_skipped?(conn, plug_module) do - contained_in_private_list?(conn, :skipped_plugs, plug_module) + contained_in_private_list?(conn, @skipped_plugs_list_id, plug_module) end + @doc "Returns `true` if specified plug was either called or explicitly marked as skipped." def plug_called_or_skipped?(conn, plug_module) do plug_called?(conn, plug_module) || plug_skipped?(conn, plug_module) end - defp append_to_private_list(conn, private_variable, value) do - list = conn.private[private_variable] || [] + # Appends plug to known list (skipped, called). Intended to be used from within plug code only. + def append_to_private_list(conn, list_id, value) do + list = conn.private[list_id] || [] modified_list = Enum.uniq(list ++ [value]) - Plug.Conn.put_private(conn, private_variable, modified_list) + Plug.Conn.put_private(conn, list_id, modified_list) end defp contained_in_private_list?(conn, private_variable, value) do diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index ae7c94640..bf48ce26c 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -40,17 +40,22 @@ defp set_put_layout(conn, _) do # Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain defp skip_plug(conn, plug_module) do try do - plug_module.ensure_skippable() + plug_module.skip_plug(conn) rescue UndefinedFunctionError -> raise "#{plug_module} is not skippable. Append `use Pleroma.Web, :plug` to its code." end - - PlugHelper.append_to_skipped_plugs(conn, plug_module) end - # Here we can apply before-action hooks (e.g. verify whether auth checks were preformed) + # Executed just before actual controller action, invokes before-action hooks (callbacks) defp action(conn, params) do + with %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do + super(conn, params) + end + end + + # Halts if authenticated API action neither performs nor explicitly skips OAuth scopes check + defp maybe_halt_on_missing_oauth_scopes_check(conn) do if Pleroma.Plugs.AuthExpectedPlug.auth_expected?(conn) && not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do conn @@ -60,7 +65,7 @@ defp action(conn, params) do ) |> halt() else - super(conn, params) + conn end end end @@ -129,7 +134,16 @@ def plug do quote do alias Pleroma.Plugs.PlugHelper - def ensure_skippable, do: :noop + @doc """ + Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain. + """ + def skip_plug(conn) do + PlugHelper.append_to_private_list( + conn, + PlugHelper.skipped_plugs_list_id(), + __MODULE__ + ) + end @impl Plug @doc "If marked as skipped, returns `conn`, and calls `perform/2` otherwise." @@ -138,7 +152,7 @@ def call(%Plug.Conn{} = conn, options) do conn else conn - |> PlugHelper.append_to_called_plugs(__MODULE__) + |> PlugHelper.append_to_private_list(PlugHelper.called_plugs_list_id(), __MODULE__) |> perform(options) end end diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs index ae2f3f8ec..646bda9d3 100644 --- a/test/plugs/authentication_plug_test.exs +++ b/test/plugs/authentication_plug_test.exs @@ -6,6 +6,8 @@ defmodule Pleroma.Plugs.AuthenticationPlugTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Plugs.AuthenticationPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.PlugHelper alias Pleroma.User import ExUnit.CaptureLog @@ -36,13 +38,16 @@ test "it does nothing if a user is assigned", %{conn: conn} do assert ret_conn == conn end - test "with a correct password in the credentials, it assigns the auth_user", %{conn: conn} do + test "with a correct password in the credentials, " <> + "it assigns the auth_user and marks OAuthScopesPlug as skipped", + %{conn: conn} do conn = conn |> assign(:auth_credentials, %{password: "guy"}) |> AuthenticationPlug.call(%{}) assert conn.assigns.user == conn.assigns.auth_user + assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end test "with a wrong password in the credentials, it does nothing", %{conn: conn} do diff --git a/test/plugs/legacy_authentication_plug_test.exs b/test/plugs/legacy_authentication_plug_test.exs index 7559de7d3..3b8c07627 100644 --- a/test/plugs/legacy_authentication_plug_test.exs +++ b/test/plugs/legacy_authentication_plug_test.exs @@ -8,6 +8,8 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlugTest do import Pleroma.Factory alias Pleroma.Plugs.LegacyAuthenticationPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.PlugHelper alias Pleroma.User setup do @@ -36,7 +38,8 @@ test "it does nothing if a user is assigned", %{conn: conn, user: user} do end @tag :skip_on_mac - test "it authenticates the auth_user if present and password is correct and resets the password", + test "if `auth_user` is present and password is correct, " <> + "it authenticates the user, resets the password, marks OAuthScopesPlug as skipped", %{ conn: conn, user: user @@ -49,6 +52,7 @@ test "it authenticates the auth_user if present and password is correct and rese conn = LegacyAuthenticationPlug.call(conn, %{}) assert conn.assigns.user.id == user.id + assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end @tag :skip_on_mac diff --git a/test/plugs/oauth_scopes_plug_test.exs b/test/plugs/oauth_scopes_plug_test.exs index abab7abb0..edbc94227 100644 --- a/test/plugs/oauth_scopes_plug_test.exs +++ b/test/plugs/oauth_scopes_plug_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.Plugs.PlugHelper alias Pleroma.Repo import Mock @@ -21,7 +20,7 @@ test "is not performed if marked as skipped", %{conn: conn} do with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do conn = conn - |> PlugHelper.append_to_skipped_plugs(OAuthScopesPlug) + |> OAuthScopesPlug.skip_plug() |> OAuthScopesPlug.call(%{scopes: ["random_scope"]}) refute called(OAuthScopesPlug.perform(:_, :_)) diff --git a/test/web/auth/basic_auth_test.exs b/test/web/auth/basic_auth_test.exs new file mode 100644 index 000000000..64f8a6863 --- /dev/null +++ b/test/web/auth/basic_auth_test.exs @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.BasicAuthTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + test "with HTTP Basic Auth used, grants access to OAuth scope-restricted endpoints", %{ + conn: conn + } do + user = insert(:user) + assert Comeonin.Pbkdf2.checkpw("test", user.password_hash) + + basic_auth_contents = + (URI.encode_www_form(user.nickname) <> ":" <> URI.encode_www_form("test")) + |> Base.encode64() + + # Succeeds with HTTP Basic Auth + response = + conn + |> put_req_header("authorization", "Basic " <> basic_auth_contents) + |> get("/api/v1/accounts/verify_credentials") + |> json_response(200) + + user_nickname = user.nickname + assert %{"username" => ^user_nickname} = response + + # Succeeds with a properly scoped OAuth token + valid_token = insert(:oauth_token, scopes: ["read:accounts"]) + + conn + |> put_req_header("authorization", "Bearer #{valid_token.token}") + |> get("/api/v1/accounts/verify_credentials") + |> json_response(200) + + # Fails with a wrong-scoped OAuth token (proof of restriction) + invalid_token = insert(:oauth_token, scopes: ["read:something"]) + + conn + |> put_req_header("authorization", "Bearer #{invalid_token.token}") + |> get("/api/v1/accounts/verify_credentials") + |> json_response(403) + end +end -- cgit v1.2.3 From 24e0db6310851783375549e1e68c661c237261a5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 17 Apr 2020 13:22:25 -0500 Subject: pleroma-fe bundle: update to ac9985aedbc2ed53121eec06a95013186c4eefd4 --- priv/static/font/fontello.1575660578688.eot | Bin 24628 -> 0 bytes priv/static/font/fontello.1575660578688.svg | 126 --------------------- priv/static/font/fontello.1575660578688.ttf | Bin 24460 -> 0 bytes priv/static/font/fontello.1575660578688.woff | Bin 14832 -> 0 bytes priv/static/font/fontello.1575660578688.woff2 | Bin 12664 -> 0 bytes priv/static/font/fontello.1575662648966.eot | Bin 24628 -> 0 bytes priv/static/font/fontello.1575662648966.svg | 126 --------------------- priv/static/font/fontello.1575662648966.ttf | Bin 24460 -> 0 bytes priv/static/font/fontello.1575662648966.woff | Bin 14832 -> 0 bytes priv/static/font/fontello.1575662648966.woff2 | Bin 12628 -> 0 bytes priv/static/fontello.1575660578688.css | Bin 3491 -> 0 bytes priv/static/fontello.1575662648966.css | Bin 3491 -> 0 bytes priv/static/index.html | 2 +- priv/static/static/font/fontello.1583594169021.eot | Bin 22444 -> 0 bytes priv/static/static/font/fontello.1583594169021.svg | 118 ------------------- priv/static/static/font/fontello.1583594169021.ttf | Bin 22276 -> 0 bytes .../static/static/font/fontello.1583594169021.woff | Bin 13656 -> 0 bytes .../static/font/fontello.1583594169021.woff2 | Bin 11564 -> 0 bytes priv/static/static/font/fontello.1587147224637.eot | Bin 0 -> 22444 bytes priv/static/static/font/fontello.1587147224637.svg | 118 +++++++++++++++++++ priv/static/static/font/fontello.1587147224637.ttf | Bin 0 -> 22276 bytes .../static/static/font/fontello.1587147224637.woff | Bin 0 -> 13656 bytes .../static/font/fontello.1587147224637.woff2 | Bin 0 -> 11544 bytes priv/static/static/fontello.1583594169021.css | Bin 3296 -> 0 bytes priv/static/static/fontello.1587147224637.css | Bin 0 -> 3296 bytes priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js | Bin 1033661 -> 0 bytes .../static/js/app.5c94bdec79a7d0f3cfcb.js.map | Bin 1658468 -> 0 bytes priv/static/static/js/app.def6476e8bc9b214218b.js | Bin 0 -> 1045614 bytes .../static/js/app.def6476e8bc9b214218b.js.map | Bin 0 -> 1658859 bytes priv/static/sw-pleroma.js | Bin 31329 -> 31329 bytes 30 files changed, 119 insertions(+), 371 deletions(-) delete mode 100644 priv/static/font/fontello.1575660578688.eot delete mode 100644 priv/static/font/fontello.1575660578688.svg delete mode 100644 priv/static/font/fontello.1575660578688.ttf delete mode 100644 priv/static/font/fontello.1575660578688.woff delete mode 100644 priv/static/font/fontello.1575660578688.woff2 delete mode 100644 priv/static/font/fontello.1575662648966.eot delete mode 100644 priv/static/font/fontello.1575662648966.svg delete mode 100644 priv/static/font/fontello.1575662648966.ttf delete mode 100644 priv/static/font/fontello.1575662648966.woff delete mode 100644 priv/static/font/fontello.1575662648966.woff2 delete mode 100644 priv/static/fontello.1575660578688.css delete mode 100644 priv/static/fontello.1575662648966.css delete mode 100644 priv/static/static/font/fontello.1583594169021.eot delete mode 100644 priv/static/static/font/fontello.1583594169021.svg delete mode 100644 priv/static/static/font/fontello.1583594169021.ttf delete mode 100644 priv/static/static/font/fontello.1583594169021.woff delete mode 100644 priv/static/static/font/fontello.1583594169021.woff2 create mode 100644 priv/static/static/font/fontello.1587147224637.eot create mode 100644 priv/static/static/font/fontello.1587147224637.svg create mode 100644 priv/static/static/font/fontello.1587147224637.ttf create mode 100644 priv/static/static/font/fontello.1587147224637.woff create mode 100644 priv/static/static/font/fontello.1587147224637.woff2 delete mode 100644 priv/static/static/fontello.1583594169021.css create mode 100644 priv/static/static/fontello.1587147224637.css delete mode 100644 priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js delete mode 100644 priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js.map create mode 100644 priv/static/static/js/app.def6476e8bc9b214218b.js create mode 100644 priv/static/static/js/app.def6476e8bc9b214218b.js.map diff --git a/priv/static/font/fontello.1575660578688.eot b/priv/static/font/fontello.1575660578688.eot deleted file mode 100644 index 31a66127f..000000000 Binary files a/priv/static/font/fontello.1575660578688.eot and /dev/null differ diff --git a/priv/static/font/fontello.1575660578688.svg b/priv/static/font/fontello.1575660578688.svg deleted file mode 100644 index 19fa56ba4..000000000 --- a/priv/static/font/fontello.1575660578688.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - -Copyright (C) 2019 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/font/fontello.1575660578688.ttf b/priv/static/font/fontello.1575660578688.ttf deleted file mode 100644 index 7e990495e..000000000 Binary files a/priv/static/font/fontello.1575660578688.ttf and /dev/null differ diff --git a/priv/static/font/fontello.1575660578688.woff b/priv/static/font/fontello.1575660578688.woff deleted file mode 100644 index 239190cba..000000000 Binary files a/priv/static/font/fontello.1575660578688.woff and /dev/null differ diff --git a/priv/static/font/fontello.1575660578688.woff2 b/priv/static/font/fontello.1575660578688.woff2 deleted file mode 100644 index b4d3537c5..000000000 Binary files a/priv/static/font/fontello.1575660578688.woff2 and /dev/null differ diff --git a/priv/static/font/fontello.1575662648966.eot b/priv/static/font/fontello.1575662648966.eot deleted file mode 100644 index a5cb925ad..000000000 Binary files a/priv/static/font/fontello.1575662648966.eot and /dev/null differ diff --git a/priv/static/font/fontello.1575662648966.svg b/priv/static/font/fontello.1575662648966.svg deleted file mode 100644 index 19fa56ba4..000000000 --- a/priv/static/font/fontello.1575662648966.svg +++ /dev/null @@ -1,126 +0,0 @@ - - - -Copyright (C) 2019 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/font/fontello.1575662648966.ttf b/priv/static/font/fontello.1575662648966.ttf deleted file mode 100644 index ec67a3d00..000000000 Binary files a/priv/static/font/fontello.1575662648966.ttf and /dev/null differ diff --git a/priv/static/font/fontello.1575662648966.woff b/priv/static/font/fontello.1575662648966.woff deleted file mode 100644 index feee99308..000000000 Binary files a/priv/static/font/fontello.1575662648966.woff and /dev/null differ diff --git a/priv/static/font/fontello.1575662648966.woff2 b/priv/static/font/fontello.1575662648966.woff2 deleted file mode 100644 index a126c585f..000000000 Binary files a/priv/static/font/fontello.1575662648966.woff2 and /dev/null differ diff --git a/priv/static/fontello.1575660578688.css b/priv/static/fontello.1575660578688.css deleted file mode 100644 index f232f5600..000000000 Binary files a/priv/static/fontello.1575660578688.css and /dev/null differ diff --git a/priv/static/fontello.1575662648966.css b/priv/static/fontello.1575662648966.css deleted file mode 100644 index a47f73e3a..000000000 Binary files a/priv/static/fontello.1575662648966.css and /dev/null differ diff --git a/priv/static/index.html b/priv/static/index.html index 4304bdcbb..66c9b53de 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/font/fontello.1583594169021.eot b/priv/static/static/font/fontello.1583594169021.eot deleted file mode 100644 index f822a48a3..000000000 Binary files a/priv/static/static/font/fontello.1583594169021.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1583594169021.svg b/priv/static/static/font/fontello.1583594169021.svg deleted file mode 100644 index b905a0f6c..000000000 --- a/priv/static/static/font/fontello.1583594169021.svg +++ /dev/null @@ -1,118 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1583594169021.ttf b/priv/static/static/font/fontello.1583594169021.ttf deleted file mode 100644 index 5ed36e9aa..000000000 Binary files a/priv/static/static/font/fontello.1583594169021.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1583594169021.woff b/priv/static/static/font/fontello.1583594169021.woff deleted file mode 100644 index 408c26afb..000000000 Binary files a/priv/static/static/font/fontello.1583594169021.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1583594169021.woff2 b/priv/static/static/font/fontello.1583594169021.woff2 deleted file mode 100644 index b963e9489..000000000 Binary files a/priv/static/static/font/fontello.1583594169021.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1587147224637.eot b/priv/static/static/font/fontello.1587147224637.eot new file mode 100644 index 000000000..523e14f27 Binary files /dev/null and b/priv/static/static/font/fontello.1587147224637.eot differ diff --git a/priv/static/static/font/fontello.1587147224637.svg b/priv/static/static/font/fontello.1587147224637.svg new file mode 100644 index 000000000..b905a0f6c --- /dev/null +++ b/priv/static/static/font/fontello.1587147224637.svg @@ -0,0 +1,118 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1587147224637.ttf b/priv/static/static/font/fontello.1587147224637.ttf new file mode 100644 index 000000000..ec6f7f9b4 Binary files /dev/null and b/priv/static/static/font/fontello.1587147224637.ttf differ diff --git a/priv/static/static/font/fontello.1587147224637.woff b/priv/static/static/font/fontello.1587147224637.woff new file mode 100644 index 000000000..da56c9221 Binary files /dev/null and b/priv/static/static/font/fontello.1587147224637.woff differ diff --git a/priv/static/static/font/fontello.1587147224637.woff2 b/priv/static/static/font/fontello.1587147224637.woff2 new file mode 100644 index 000000000..6192c0f22 Binary files /dev/null and b/priv/static/static/font/fontello.1587147224637.woff2 differ diff --git a/priv/static/static/fontello.1583594169021.css b/priv/static/static/fontello.1583594169021.css deleted file mode 100644 index c096e6103..000000000 Binary files a/priv/static/static/fontello.1583594169021.css and /dev/null differ diff --git a/priv/static/static/fontello.1587147224637.css b/priv/static/static/fontello.1587147224637.css new file mode 100644 index 000000000..48e6a5b3c Binary files /dev/null and b/priv/static/static/fontello.1587147224637.css differ diff --git a/priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js b/priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js deleted file mode 100644 index 7ef7a5f12..000000000 Binary files a/priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js and /dev/null differ diff --git a/priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js.map b/priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js.map deleted file mode 100644 index 163f78149..000000000 Binary files a/priv/static/static/js/app.5c94bdec79a7d0f3cfcb.js.map and /dev/null differ diff --git a/priv/static/static/js/app.def6476e8bc9b214218b.js b/priv/static/static/js/app.def6476e8bc9b214218b.js new file mode 100644 index 000000000..1e6ced42d Binary files /dev/null and b/priv/static/static/js/app.def6476e8bc9b214218b.js differ diff --git a/priv/static/static/js/app.def6476e8bc9b214218b.js.map b/priv/static/static/js/app.def6476e8bc9b214218b.js.map new file mode 100644 index 000000000..a03cad258 Binary files /dev/null and b/priv/static/static/js/app.def6476e8bc9b214218b.js.map differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 88e8fcd5a..92361720e 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ -- cgit v1.2.3 From eb61564005b743acefe7bb31c9369c38c9dfad6e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 17 Apr 2020 23:55:56 +0200 Subject: migrations/20200406100225_users_add_emoji: Fix tag to Emoji filtering, electric bongaloo --- lib/pleroma/web/activity_pub/activity_pub.ex | 5 ++++- priv/repo/migrations/20200406100225_users_add_emoji.exs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d403405a0..cb942c211 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1430,7 +1430,10 @@ defp object_to_user_data(data) do emojis = data |> Map.get("tag", []) - |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) + |> Enum.filter(fn + %{"type" => t} -> t == "Emoji" + _ -> false + end) |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> Map.put(acc, String.trim(name, ":"), url) end) diff --git a/priv/repo/migrations/20200406100225_users_add_emoji.exs b/priv/repo/migrations/20200406100225_users_add_emoji.exs index 9f57abb5c..f754502ae 100644 --- a/priv/repo/migrations/20200406100225_users_add_emoji.exs +++ b/priv/repo/migrations/20200406100225_users_add_emoji.exs @@ -17,7 +17,10 @@ def up do emoji = user.source_data |> Map.get("tag", []) - |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) + |> Enum.filter(fn + %{"type" => t} -> t == "Emoji" + _ -> false + end) |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> Map.put(acc, String.trim(name, ":"), url) end) -- cgit v1.2.3 From 24f760c2f732465151655fd4cd69cc149546b29f Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Fri, 17 Apr 2020 22:48:37 +0000 Subject: Apply suggestion to lib/pleroma/web/activity_pub/activity_pub.ex --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index cb942c211..eedea08a2 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1431,7 +1431,7 @@ defp object_to_user_data(data) do data |> Map.get("tag", []) |> Enum.filter(fn - %{"type" => t} -> t == "Emoji" + %{"type" => "Emoji"} -> true _ -> false end) |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> -- cgit v1.2.3 From d698ecef9b5ede19474f1a45b776f8ad9f8b7678 Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Fri, 17 Apr 2020 22:48:40 +0000 Subject: Apply suggestion to priv/repo/migrations/20200406100225_users_add_emoji.exs --- priv/repo/migrations/20200406100225_users_add_emoji.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200406100225_users_add_emoji.exs b/priv/repo/migrations/20200406100225_users_add_emoji.exs index f754502ae..f248108de 100644 --- a/priv/repo/migrations/20200406100225_users_add_emoji.exs +++ b/priv/repo/migrations/20200406100225_users_add_emoji.exs @@ -18,7 +18,7 @@ def up do user.source_data |> Map.get("tag", []) |> Enum.filter(fn - %{"type" => t} -> t == "Emoji" + %{"type" => "Emoji"} -> true _ -> false end) |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> -- cgit v1.2.3 From c906ffc51a3c36213f2c10d6d20046beb32e5d10 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 19 Apr 2020 20:23:48 +0200 Subject: mix.exs: Do not bail out when .git doesn’t exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mix.exs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/mix.exs b/mix.exs index c5e5fd432..b76aef180 100644 --- a/mix.exs +++ b/mix.exs @@ -221,19 +221,26 @@ defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i # Pre-release version, denoted from patch version with a hyphen + {tag, tag_err} = + System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) + + {describe, describe_err} = System.cmd("git", ["describe", "--tags", "--abbrev=8"]) + {commit_hash, commit_hash_err} = System.cmd("git", ["rev-parse", "--short", "HEAD"]) + git_pre_release = - with {tag, 0} <- - System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true), - {describe, 0} <- System.cmd("git", ["describe", "--tags", "--abbrev=8"]) do - describe - |> String.trim() - |> String.replace(String.trim(tag), "") - |> String.trim_leading("-") - |> String.trim() - else - _ -> - {commit_hash, 0} = System.cmd("git", ["rev-parse", "--short", "HEAD"]) + cond do + tag_err == 0 and describe_err == 0 -> + describe + |> String.trim() + |> String.replace(String.trim(tag), "") + |> String.trim_leading("-") + |> String.trim() + + commit_hash_err == 0 -> "0-g" <> String.trim(commit_hash) + + true -> + "" end # Branch name as pre-release version component, denoted with a dot @@ -251,6 +258,8 @@ defp version(version) do |> String.replace(identifier_filter, "-") branch_name + else + _ -> "stable" end build_name = -- cgit v1.2.3 From ce23673ca1539350802326c62d6e72bd040950f6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 11:45:11 +0200 Subject: ChatMessageValidator: Don't validate messages that are too long. --- .../web/activity_pub/object_validators/chat_message_validator.ex | 1 + test/web/activity_pub/object_validator_test.exs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index a4e4460cd..caf2138a7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -56,6 +56,7 @@ def validate_data(data_cng) do |> validate_inclusion(:type, ["ChatMessage"]) |> validate_required([:id, :actor, :to, :type, :content]) |> validate_length(:to, is: 1) + |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) |> validate_local_concern() end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index bf0bfdfaf..e416e0808 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do describe "chat messages" do setup do + clear_config([:instance, :remote_limit]) user = insert(:user) recipient = insert(:user, local: false) @@ -23,6 +24,13 @@ test "validates for a basic object we build", %{valid_chat_message: valid_chat_m assert {:ok, _object, _meta} = ObjectValidator.validate(valid_chat_message, []) end + test "does not validate if the message is longer than the remote_limit", %{ + valid_chat_message: valid_chat_message + } do + Pleroma.Config.put([:instance, :remote_limit], 2) + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + test "does not validate if the actor or the recipient is not in our system", %{ valid_chat_message: valid_chat_message } do -- cgit v1.2.3 From 5b6818b3e5dc39e328f6f8d4b8f4587e5e1cef94 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 12:08:47 +0200 Subject: CommonAPI: Obey local limit for chat messages. --- lib/pleroma/web/common_api/common_api.ex | 8 +++++++- test/web/common_api/common_api_test.exs | 18 ++++++++++++++++++ test/web/pleroma_api/views/chat_message_view_test.exs | 4 ++-- test/web/pleroma_api/views/chat_view_test.exs | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 2b8add2fa..fcb0af4e8 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -28,7 +28,10 @@ defmodule Pleroma.Web.CommonAPI do def post_chat_message(%User{} = user, %User{} = recipient, content) do transaction = Repo.transaction(fn -> - with {_, {:ok, chat_message_data, _meta}} <- + with {_, true} <- + {:content_length, + String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, + {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( user, @@ -43,6 +46,9 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do {:ok, activity} + else + {:content_length, false} -> {:error, :content_too_long} + e -> e end end) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 1984aac8d..c17e30210 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -23,6 +23,8 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :max_pinned_statuses]) describe "posting chat messages" do + setup do: clear_config([:instance, :chat_limit]) + test "it posts a chat message" do author = insert(:user) recipient = insert(:user) @@ -47,6 +49,22 @@ test "it posts a chat message" do assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) end + + test "it reject messages over the local limit" do + Pleroma.Config.put([:instance, :chat_limit], 2) + + author = insert(:user) + recipient = insert(:user) + + {:error, message} = + CommonAPI.post_chat_message( + author, + recipient, + "123" + ) + + assert message == :content_too_long + end end test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index e690da022..ad8febee6 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -23,7 +23,7 @@ test "it displays a chat message" do chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) - assert chat_message[:id] == object.id + assert chat_message[:id] == object.id |> to_string() assert chat_message[:content] == "kippis" assert chat_message[:actor] == user.ap_id assert chat_message[:chat_id] @@ -34,7 +34,7 @@ test "it displays a chat message" do chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) - assert chat_message_two[:id] == object.id + assert chat_message_two[:id] == object.id |> to_string() assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:actor] == recipient.ap_id assert chat_message_two[:chat_id] == chat_message[:chat_id] diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 1eb0c6241..3dca555e8 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do +defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat -- cgit v1.2.3 From 970b74383b43aa9a54c3cf59012944355e3eafbc Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 12:29:19 +0200 Subject: Credo fixes. --- lib/pleroma/chat.ex | 2 +- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- .../web/activity_pub/object_validators/chat_message_validator.ex | 2 +- lib/pleroma/web/common_api/common_api.ex | 2 +- lib/pleroma/web/mastodon_api/views/notification_view.ex | 5 +++-- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 6 +++--- test/web/activity_pub/object_validator_test.exs | 2 +- test/web/pleroma_api/views/chat_view_test.exs | 2 +- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index c2044881f..b8545063a 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Chat do use Ecto.Schema import Ecto.Changeset - alias Pleroma.User alias Pleroma.Repo + alias Pleroma.User @moduledoc """ Chat keeps a reference to ChatMessage conversations between a user and an recipient. The recipient can be a user (for now) or a group (not implemented yet). diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 259bbeb64..03db681ec 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,9 +11,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator + 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/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index caf2138a7..6e3477cd1 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index fcb0af4e8..9e25f4c2f 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship + alias Pleroma.Formatter alias Pleroma.Object alias Pleroma.Repo alias Pleroma.ThreadMute @@ -17,7 +18,6 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Formatter import Pleroma.Web.Gettext import Pleroma.Web.CommonAPI.Utils diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 5d231f0c4..0b05d178b 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -7,8 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Activity alias Pleroma.Notification - alias Pleroma.User alias Pleroma.Object + alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView @@ -83,7 +83,8 @@ def render( end end - # This returns the notification type by activity, but both chats and statuses are in "Create" activities. + # This returns the notification type by activity, but both chats and statuses + # are in "Create" activities. mastodon_type = case Activity.mastodon_notification_type(activity) do "mention" -> diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 31c723426..9d8b9b3cf 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -6,13 +6,13 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Chat alias Pleroma.Object + alias Pleroma.Pagination + alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView - alias Pleroma.Pagination + alias Pleroma.Web.PleromaAPI.ChatView import Ecto.Query diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index e416e0808..3ac5ecaf4 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,11 +1,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI - alias Pleroma.Web.ActivityPub.Builder import Pleroma.Factory diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 3dca555e8..725da5ff8 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,8 +6,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory -- cgit v1.2.3 From b836d3d104f75841d71f9cf7c5c8cb5c07ba7294 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 13:14:59 +0200 Subject: ChatMessageValidator: Require `published` field --- lib/pleroma/web/activity_pub/builder.ex | 6 ++++-- .../web/activity_pub/object_validators/chat_message_validator.ex | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f0a6c1e1b..b67166a30 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -17,7 +17,8 @@ def create(actor, object_id, recipients) do "actor" => actor.ap_id, "to" => recipients, "object" => object_id, - "type" => "Create" + "type" => "Create", + "published" => DateTime.utc_now() |> DateTime.to_iso8601() }, []} end @@ -28,7 +29,8 @@ def chat_message(actor, recipient, content) do "actor" => actor.ap_id, "type" => "ChatMessage", "to" => [recipient], - "content" => content + "content" => content, + "published" => DateTime.utc_now() |> DateTime.to_iso8601() }, []} end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 6e3477cd1..9b8262553 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -54,7 +54,7 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) - |> validate_required([:id, :actor, :to, :type, :content]) + |> validate_required([:id, :actor, :to, :type, :content, :published]) |> validate_length(:to, is: 1) |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) |> validate_local_concern() -- cgit v1.2.3 From 7e53da250e3b41e01073148efea0fc4f49dea9d5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 14:08:54 +0200 Subject: ChatMessage: Support emoji. --- lib/pleroma/web/activity_pub/builder.ex | 4 ++- .../object_validators/chat_message_validator.ex | 1 + test/fixtures/create-chat-message.json | 30 +++++++++++----------- test/web/activity_pub/object_validator_test.exs | 6 +++-- test/web/common_api/common_api_test.exs | 8 ++++-- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index b67166a30..7576ed278 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do This module encodes our addressing policies and general shape of our objects. """ + alias Pleroma.Emoji alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils @@ -30,7 +31,8 @@ def chat_message(actor, recipient, content) do "type" => "ChatMessage", "to" => [recipient], "content" => content, - "published" => DateTime.utc_now() |> DateTime.to_iso8601() + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "emoji" => Emoji.Formatter.get_emoji_map(content) }, []} end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 9b8262553..2feb65f29 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -20,6 +20,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do field(:content, :string) field(:actor, Types.ObjectID) field(:published, Types.DateTime) + field(:emoji, :map, default: %{}) end def cast_and_apply(data) do diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json index 2e4608f43..6db5b9f5c 100644 --- a/test/fixtures/create-chat-message.json +++ b/test/fixtures/create-chat-message.json @@ -1,19 +1,19 @@ { - "actor": "http://2hu.gensokyo/users/raymoo", - "id": "http://2hu.gensokyo/objects/1", - "object": { - "attributedTo": "http://2hu.gensokyo/users/raymoo", - "content": "You expected a cute girl? Too bad. ", - "id": "http://2hu.gensokyo/objects/2", - "published": "2020-02-12T14:08:20Z", - "to": [ - "http://2hu.gensokyo/users/marisa" - ], - "type": "ChatMessage" - }, - "published": "2018-02-12T14:08:20Z", + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad. ", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", "to": [ - "http://2hu.gensokyo/users/marisa" + "http://2hu.gensokyo/users/marisa" ], - "type": "Create" + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" } diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3ac5ecaf4..8230ae0d9 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -15,13 +15,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do user = insert(:user) recipient = insert(:user, local: false) - {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey :firefox:") %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} end test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do - assert {:ok, _object, _meta} = ObjectValidator.validate(valid_chat_message, []) + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object == valid_chat_message end test "does not validate if the message is longer than the remote_limit", %{ diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index c17e30210..86b3648ac 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -33,7 +33,7 @@ test "it posts a chat message" do CommonAPI.post_chat_message( author, recipient, - "a test message " + "a test message :firefox:" ) assert activity.data["type"] == "Create" @@ -44,7 +44,11 @@ test "it posts a chat message" do assert object.data["to"] == [recipient.ap_id] assert object.data["content"] == - "a test message <script>alert('uuu')</script>" + "a test message <script>alert('uuu')</script> :firefox:" + + assert object.data["emoji"] == %{ + "firefox" => "http://localhost:4001/emoji/Firefox.gif" + } assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) -- cgit v1.2.3 From b5df4a98e4044cf1360f03f7dc3a0b59932ec8f5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 20 Apr 2020 14:38:53 +0200 Subject: ChatMessageView: Support emoji. --- lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 6 +++++- test/web/pleroma_api/views/chat_message_view_test.exs | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index fdbb9ff1b..b40ab92a0 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.StatusView def render( "show.json", @@ -18,7 +20,9 @@ def render( id: id |> to_string(), content: chat_message["content"], chat_id: chat_id |> to_string(), - actor: chat_message["actor"] + actor: chat_message["actor"], + created_at: Utils.to_masto_date(chat_message["published"]), + emojis: StatusView.build_emojis(chat_message["emoji"]) } end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index ad8febee6..115335f10 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -15,7 +15,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do test "it displays a chat message" do user = insert(:user) recipient = insert(:user) - {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis") + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") chat = Chat.get(user.id, recipient.ap_id) @@ -24,9 +24,11 @@ test "it displays a chat message" do chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) assert chat_message[:id] == object.id |> to_string() - assert chat_message[:content] == "kippis" + assert chat_message[:content] == "kippis :firefox:" assert chat_message[:actor] == user.ap_id assert chat_message[:chat_id] + assert chat_message[:created_at] + assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") -- cgit v1.2.3 From 258d8975797298b9eadddb48a8a2fcf3a9dbf211 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 20 Apr 2020 16:38:00 +0400 Subject: Cleanup and DRY the Router --- lib/pleroma/web/router.ex | 113 ++++++++++++++++------------------------------ 1 file changed, 40 insertions(+), 73 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 7e5960949..153802a43 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -16,79 +16,60 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.UserEnabledPlug) end - pipeline :api do - plug(:accepts, ["json"]) - plug(:fetch_session) + pipeline :authenticate do plug(Pleroma.Plugs.OAuthPlug) plug(Pleroma.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Plugs.UserFetcherPlug) plug(Pleroma.Plugs.SessionAuthenticationPlug) plug(Pleroma.Plugs.LegacyAuthenticationPlug) plug(Pleroma.Plugs.AuthenticationPlug) + end + + pipeline :after_auth do plug(Pleroma.Plugs.UserEnabledPlug) 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 + pipeline :base_api do plug(:accepts, ["json"]) plug(:fetch_session) + plug(:authenticate) + plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) + end + + pipeline :api do + plug(:base_api) + plug(:after_auth) + plug(Pleroma.Plugs.IdempotencyPlug) + end + + pipeline :authenticated_api do + plug(:base_api) plug(Pleroma.Plugs.AuthExpectedPlug) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) + plug(:after_auth) plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.IdempotencyPlug) - plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec) end pipeline :admin_api do - plug(:accepts, ["json"]) - plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) + plug(:base_api) plug(Pleroma.Plugs.AdminSecretAuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) + plug(:after_auth) 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 - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) - plug(Pleroma.Plugs.EnsureUserKeyPlug) + plug(:browser) + plug(:authenticate) + plug(:after_auth) end pipeline :pleroma_html do - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) + plug(:browser) + plug(:authenticate) plug(Pleroma.Plugs.EnsureUserKeyPlug) end @@ -515,7 +496,7 @@ defmodule Pleroma.Web.Router do end scope "/api" do - pipe_through(:api) + pipe_through(:base_api) get("/openapi", OpenApiSpex.Plug.RenderSpec, []) end @@ -529,10 +510,6 @@ defmodule Pleroma.Web.Router do post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read) end - pipeline :ap_service_actor do - plug(:accepts, ["activity+json", "json"]) - end - pipeline :ostatus do plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"]) plug(Pleroma.Plugs.StaticFEPlug) @@ -543,8 +520,7 @@ defmodule Pleroma.Web.Router do end scope "/", Pleroma.Web do - pipe_through(:ostatus) - pipe_through(:http_signature) + pipe_through([:ostatus, :http_signature]) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) @@ -562,13 +538,6 @@ defmodule Pleroma.Web.Router do get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end - # Server to Server (S2S) AP interactions - pipeline :activitypub do - plug(:accepts, ["activity+json", "json"]) - plug(Pleroma.Web.Plugs.HTTPSignaturePlug) - plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) - end - scope "/", Pleroma.Web.ActivityPub do # XXX: not really ostatus pipe_through(:ostatus) @@ -576,19 +545,22 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/outbox", ActivityPubController, :outbox) end + pipeline :ap_service_actor do + plug(:accepts, ["activity+json", "json"]) + end + + # Server to Server (S2S) AP interactions + pipeline :activitypub do + plug(:ap_service_actor) + plug(:http_signature) + end + # Client to Server (C2S) AP interactions pipeline :activitypub_client do - plug(:accepts, ["activity+json", "json"]) + plug(:ap_service_actor) plug(:fetch_session) - plug(Pleroma.Plugs.OAuthPlug) - plug(Pleroma.Plugs.BasicAuthDecoderPlug) - plug(Pleroma.Plugs.UserFetcherPlug) - plug(Pleroma.Plugs.SessionAuthenticationPlug) - plug(Pleroma.Plugs.LegacyAuthenticationPlug) - plug(Pleroma.Plugs.AuthenticationPlug) - plug(Pleroma.Plugs.UserEnabledPlug) - plug(Pleroma.Plugs.SetUserSessionIdPlug) - plug(Pleroma.Plugs.EnsureUserKeyPlug) + plug(:authenticate) + plug(:after_auth) end scope "/", Pleroma.Web.ActivityPub do @@ -660,12 +632,7 @@ defmodule Pleroma.Web.Router do get("/web/*path", MastoFEController, :index) end - pipeline :remote_media do - end - scope "/proxy/", Pleroma.Web.MediaProxy do - pipe_through(:remote_media) - get("/:sig/:url", MediaProxyController, :remote) get("/:sig/:url/:filename", MediaProxyController, :remote) end -- cgit v1.2.3 From 8b4de61d6449f70e0a5e84be3082724c7f50ffee Mon Sep 17 00:00:00 2001 From: Ilja Date: Mon, 20 Apr 2020 12:59:16 +0000 Subject: Fix ObjectAgePolicyTest The policy didn't block old posts as it should. * I fixed it and tested on a test server * I added the settings to description so that this information is shown in nodeinfo * TODO: I didn't work TTD and still need to fix the tests --- CHANGELOG.md | 2 + .../web/activity_pub/mrf/object_age_policy.ex | 10 ++++- .../activity_pub/mrf/object_age_policy_test.exs | 52 +++++++++++----------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9270e801f..e454bd9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Logger configuration through AdminFE - HTTP Basic Authentication permissions issue +- ObjectAgePolicy didn't filter out old messages ### Added +- NodeInfo: ObjectAgePolicy settings to the `federation` list.
    API Changes - Admin API: `GET /api/pleroma/admin/need_reboot`. diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index 4a8bc91ae..b0ccb63c8 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do @moduledoc "Filter activities depending on their age" @behaviour Pleroma.Web.ActivityPub.MRF - defp check_date(%{"published" => published} = message) do + defp check_date(%{"object" => %{"published" => published}} = message) do with %DateTime{} = now <- DateTime.utc_now(), {:ok, %DateTime{} = then, _} <- DateTime.from_iso8601(published), max_ttl <- Config.get([:mrf_object_age, :threshold]), @@ -96,5 +96,11 @@ def filter(%{"type" => "Create", "published" => _} = message) do def filter(message), do: {:ok, message} @impl true - def describe, do: {:ok, %{}} + def describe do + mrf_object_age = + Pleroma.Config.get(:mrf_object_age) + |> Enum.into(%{}) + + {:ok, %{mrf_object_age: mrf_object_age}} + end end 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 7ee195eeb..b0fb753bd 100644 --- a/test/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/web/activity_pub/mrf/object_age_policy_test.exs @@ -20,26 +20,38 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicyTest do :ok end + defp get_old_message do + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + end + + defp get_new_message do + old_message = get_old_message() + + new_object = + old_message + |> Map.get("object") + |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601()) + + old_message + |> Map.put("object", new_object) + end + describe "with reject action" do test "it rejects an old post" do Config.put([:mrf_object_age, :actions], [:reject]) - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + data = get_old_message() - {:reject, _} = ObjectAgePolicy.filter(data) + assert match?({:reject, _}, ObjectAgePolicy.filter(data)) end test "it allows a new post" do Config.put([:mrf_object_age, :actions], [:reject]) - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601()) + data = get_new_message() - {:ok, _} = ObjectAgePolicy.filter(data) + assert match?({:ok, _}, ObjectAgePolicy.filter(data)) end end @@ -47,9 +59,7 @@ test "it allows a new post" do test "it delists an old post" do Config.put([:mrf_object_age, :actions], [:delist]) - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + data = get_old_message() {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"]) @@ -61,14 +71,11 @@ test "it delists an old post" do test "it allows a new post" do Config.put([:mrf_object_age, :actions], [:delist]) - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601()) + data = get_new_message() {:ok, _user} = User.get_or_fetch_by_ap_id(data["actor"]) - {:ok, ^data} = ObjectAgePolicy.filter(data) + assert match?({:ok, ^data}, ObjectAgePolicy.filter(data)) end end @@ -76,9 +83,7 @@ test "it allows a new post" do test "it strips followers collections from an old post" do Config.put([:mrf_object_age, :actions], [:strip_followers]) - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + data = get_old_message() {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"]) @@ -91,14 +96,11 @@ test "it strips followers collections from an old post" do test "it allows a new post" do Config.put([:mrf_object_age, :actions], [:strip_followers]) - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("published", DateTime.utc_now() |> DateTime.to_iso8601()) + data = get_new_message() {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"]) - {:ok, ^data} = ObjectAgePolicy.filter(data) + assert match?({:ok, ^data}, ObjectAgePolicy.filter(data)) end end end -- cgit v1.2.3 From ed3974af248a1b201d2008f1a128ee53550ef78b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 20 Apr 2020 18:39:05 +0400 Subject: Add OpenAPI spec for `AccountController.identity_proofs` operation --- lib/pleroma/web/api_spec/operations/account_operation.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index fe44a917a..d3cebaf05 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -341,4 +341,16 @@ def endorsements_operation do } } end + + def identity_proofs_operation do + %Operation{ + tags: ["accounts"], + summary: "Identity proofs", + operationId: "AccountController.identity_proofs", + description: "Not implemented", + responses: %{ + 200 => Operation.response("Empry array", "application/json", %Schema{type: :array}) + } + } + end end -- cgit v1.2.3 From b54c8813d632cb44c7deb207e91bd32f01f33794 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 13 Apr 2020 13:48:32 -0500 Subject: Add :reject_deletes option to SimplePolicy --- CHANGELOG.md | 2 + config/config.exs | 3 +- config/description.exs | 10 ++- docs/configuration/mrf.md | 3 +- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 14 +++- test/web/activity_pub/mrf/simple_policy_test.exs | 79 ++++++++++++++++++----- 6 files changed, 89 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36897503a..cd2536f9e 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/). - 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. +- Added `:reject_deletes` group to SimplePolicy
    API Changes - Mastodon API: Support for `include_types` in `/api/v1/notifications`. @@ -20,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Support pagination in conversations API +- **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again ## [unreleased-patch] diff --git a/config/config.exs b/config/config.exs index 232a91bf1..9a6b93a37 100644 --- a/config/config.exs +++ b/config/config.exs @@ -334,7 +334,8 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [] + banner_removal: [], + reject_deletes: [] config :pleroma, :mrf_keyword, reject: [], diff --git a/config/description.exs b/config/description.exs index 642f1a3ce..9d8e3b93c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1317,13 +1317,13 @@ %{ key: :reject, type: {:list, :string}, - description: "List of instances to reject any activities from", + description: "List of instances to reject activities from (except deletes)", suggestions: ["example.com", "*.example.com"] }, %{ key: :accept, type: {:list, :string}, - description: "List of instances to accept any activities from", + description: "List of instances to only accept activities from (except deletes)", suggestions: ["example.com", "*.example.com"] }, %{ @@ -1343,6 +1343,12 @@ type: {:list, :string}, description: "List of instances to strip banners from", suggestions: ["example.com", "*.example.com"] + }, + %{ + key: :reject_deletes, + type: {:list, :string}, + description: "List of instances to reject deletions from", + suggestions: ["example.com", "*.example.com"] } ] }, diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md index c3957c255..2eb9631bd 100644 --- a/docs/configuration/mrf.md +++ b/docs/configuration/mrf.md @@ -43,9 +43,10 @@ Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_si * `media_removal`: Servers in this group will have media stripped from incoming messages. * `media_nsfw`: Servers in this group will have the #nsfw tag and sensitive setting injected into incoming messages which contain media. -* `reject`: Servers in this group will have their messages rejected. +* `reject`: Servers in this group will have their messages (except deletions) rejected. * `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields. * `report_removal`: Servers in this group will have their reports (flags) rejected. +* `reject_deletes`: Deletion requests will be rejected from these servers. Servers should be configured as lists. diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index b23f263f5..b7dcb1b86 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -149,7 +149,19 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image defp check_banner_removal(_actor_info, object), do: {:ok, object} @impl true - def filter(%{"type" => "Delete"} = object), do: {:ok, object} + def filter(%{"type" => "Delete", "actor" => actor} = object) do + %{host: actor_host} = URI.parse(actor) + + reject_deletes = + Pleroma.Config.get([:mrf_simple, :reject_deletes]) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(reject_deletes, actor_host) do + {:reject, nil} + else + {:ok, object} + end + end @impl true def filter(%{"actor" => actor} = object) do diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index eaa595706..b7b9bc6a2 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -17,7 +17,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do reject: [], accept: [], avatar_removal: [], - banner_removal: [] + banner_removal: [], + reject_deletes: [] ) describe "when :media_removal" do @@ -258,14 +259,6 @@ test "actor has a matching host" do assert SimplePolicy.filter(remote_user) == {:reject, nil} end - - test "always accept deletions" do - Config.put([:mrf_simple, :reject], ["remote.instance"]) - - deletion_message = build_remote_deletion_message() - - assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} - end end describe "when :accept" do @@ -316,14 +309,6 @@ test "actor has a matching host" do assert SimplePolicy.filter(remote_user) == {:ok, remote_user} end - - test "always accept deletions" do - Config.put([:mrf_simple, :accept], ["non.matching.remote"]) - - deletion_message = build_remote_deletion_message() - - assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} - end end describe "when :avatar_removal" do @@ -398,6 +383,66 @@ test "match with wildcard domain" do end end + describe "when :reject_deletes is empty" do + setup do: Config.put([:mrf_simple, :reject_deletes], []) + + test "it accepts deletions even from rejected servers" do + Config.put([:mrf_simple, :reject], ["remote.instance"]) + + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} + end + + test "it accepts deletions even from non-whitelisted servers" do + Config.put([:mrf_simple, :accept], ["non.matching.remote"]) + + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} + end + end + + describe "when :reject_deletes is not empty but it doesn't have a matching host" do + setup do: Config.put([:mrf_simple, :reject_deletes], ["non.matching.remote"]) + + test "it accepts deletions even from rejected servers" do + Config.put([:mrf_simple, :reject], ["remote.instance"]) + + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} + end + + test "it accepts deletions even from non-whitelisted servers" do + Config.put([:mrf_simple, :accept], ["non.matching.remote"]) + + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:ok, deletion_message} + end + end + + describe "when :reject_deletes has a matching host" do + setup do: Config.put([:mrf_simple, :reject_deletes], ["remote.instance"]) + + test "it rejects the deletion" do + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:reject, nil} + end + end + + describe "when :reject_deletes match with wildcard domain" do + setup do: Config.put([:mrf_simple, :reject_deletes], ["*.remote.instance"]) + + test "it rejects the deletion" do + deletion_message = build_remote_deletion_message() + + assert SimplePolicy.filter(deletion_message) == {:reject, nil} + end + end + defp build_local_message do %{ "actor" => "#{Pleroma.Web.base_url()}/users/alice", -- cgit v1.2.3 From f685cbd30940b3fd92a2f6c8a161729bc2ceaab6 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 21 Apr 2020 16:29:19 +0300 Subject: Automatic checks of authentication / instance publicity. Definition of missing OAuth scopes in AdminAPIController. Refactoring. --- docs/dev.md | 33 +++++ lib/pleroma/plugs/auth_expected_plug.ex | 17 --- lib/pleroma/plugs/ensure_authenticated_plug.ex | 8 +- .../plugs/ensure_public_or_authenticated_plug.ex | 6 +- .../plugs/expect_authenticated_check_plug.ex | 20 +++ .../expect_public_or_authenticated_check_plug.ex | 21 +++ lib/pleroma/plugs/oauth_scopes_plug.ex | 14 +- lib/pleroma/web/admin_api/admin_api_controller.ex | 36 +++-- lib/pleroma/web/fallback_redirect_controller.ex | 2 + lib/pleroma/web/masto_fe_controller.ex | 7 +- .../mastodon_api/controllers/account_controller.ex | 26 ++-- .../mastodon_api/controllers/auth_controller.ex | 4 +- .../controllers/conversation_controller.ex | 6 +- .../controllers/domain_block_controller.ex | 2 - .../mastodon_api/controllers/filter_controller.ex | 2 - .../controllers/follow_request_controller.ex | 2 - .../mastodon_api/controllers/list_controller.ex | 8 +- .../mastodon_api/controllers/marker_controller.ex | 2 +- .../controllers/mastodon_api_controller.ex | 2 - .../mastodon_api/controllers/media_controller.ex | 2 - .../controllers/notification_controller.ex | 2 - .../mastodon_api/controllers/poll_controller.ex | 2 - .../mastodon_api/controllers/report_controller.ex | 2 - .../controllers/scheduled_activity_controller.ex | 2 - .../mastodon_api/controllers/search_controller.ex | 2 - .../mastodon_api/controllers/status_controller.ex | 2 +- .../controllers/subscription_controller.ex | 2 +- .../controllers/timeline_controller.ex | 2 +- .../web/media_proxy/media_proxy_controller.ex | 1 + .../pleroma_api/controllers/account_controller.ex | 14 +- .../controllers/emoji_api_controller.ex | 16 ++- .../pleroma_api/controllers/mascot_controller.ex | 2 - .../controllers/pleroma_api_controller.ex | 16 ++- .../pleroma_api/controllers/scrobble_controller.ex | 2 - lib/pleroma/web/router.ex | 152 +++++++++++---------- .../web/twitter_api/controllers/util_controller.ex | 7 - .../web/twitter_api/twitter_api_controller.ex | 14 +- lib/pleroma/web/web.ex | 85 +++++++++--- test/plugs/ensure_authenticated_plug_test.exs | 16 ++- .../ensure_public_or_authenticated_plug_test.exs | 4 +- test/plugs/oauth_scopes_plug_test.exs | 36 +---- .../activity_pub/activity_pub_controller_test.exs | 2 +- .../controllers/emoji_api_controller_test.exs | 11 +- .../twitter_api/twitter_api_controller_test.exs | 8 +- 44 files changed, 355 insertions(+), 267 deletions(-) create mode 100644 docs/dev.md delete mode 100644 lib/pleroma/plugs/auth_expected_plug.ex create mode 100644 lib/pleroma/plugs/expect_authenticated_check_plug.ex create mode 100644 lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex diff --git a/docs/dev.md b/docs/dev.md new file mode 100644 index 000000000..0ecf43a9e --- /dev/null +++ b/docs/dev.md @@ -0,0 +1,33 @@ +This document contains notes and guidelines for Pleroma developers. + +# Authentication & Authorization + +## OAuth token-based authentication & authorization + +* Pleroma supports hierarchical OAuth scopes, just like Mastodon but with added granularity of admin scopes. + For a reference, see [Mastodon OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/). + +* It is important to either define OAuth scope restrictions or explicitly mark OAuth scope check as skipped, for every + controller action. To define scopes, call `plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: [...]})`. To explicitly set + OAuth scopes check skipped, call `plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug )`. + +* In controllers, `use Pleroma.Web, :controller` will result in `action/2` (see `Pleroma.Web.controller/0` for definition) + be called prior to actual controller action, and it'll perform security / privacy checks before passing control to + actual controller action. For routes with `:authenticated_api` pipeline, authentication & authorization are expected, + thus `OAuthScopesPlug` will be run unless explicitly skipped (also `EnsureAuthenticatedPlug` will be executed + immediately before action even if there was an early run to give an early error, since `OAuthScopesPlug` supports + `:proceed_unauthenticated` option, and other plugs may support similar options as well). For `:api` pipeline routes, + `EnsurePublicOrAuthenticatedPlug` will be called to ensure that the instance is not private or user is authenticated + (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy + for users. + +## [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) + +* With HTTP Basic Auth, OAuth scopes check is _not_ performed for any action (since password is provided during the auth, + requester is able to obtain a token with full permissions anyways). `Pleroma.Plugs.AuthenticationPlug` and + `Pleroma.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Plugs.OAuthScopesPlug.skip_plug(conn)` when password + is provided. + +## Auth-related configuration, OAuth consumer mode etc. + +See `Authentication` section of [`docs/configuration/cheatsheet.md`](docs/configuration/cheatsheet.md#authentication). diff --git a/lib/pleroma/plugs/auth_expected_plug.ex b/lib/pleroma/plugs/auth_expected_plug.ex deleted file mode 100644 index f79597dc3..000000000 --- a/lib/pleroma/plugs/auth_expected_plug.ex +++ /dev/null @@ -1,17 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Plugs.AuthExpectedPlug do - import Plug.Conn - - def init(options), do: options - - def call(conn, _) do - put_private(conn, :auth_expected, true) - end - - def auth_expected?(conn) do - conn.private[:auth_expected] - end -end diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 054d2297f..9c8f5597f 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -5,17 +5,21 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlug do import Plug.Conn import Pleroma.Web.TranslationHelpers + alias Pleroma.User + use Pleroma.Web, :plug + def init(options) do options end - def call(%{assigns: %{user: %User{}}} = conn, _) do + @impl true + def perform(%{assigns: %{user: %User{}}} = conn, _) do conn end - def call(conn, options) do + def perform(conn, options) do perform = cond do options[:if_func] -> options[:if_func].() diff --git a/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex b/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex index d980ff13d..7265bb87a 100644 --- a/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex @@ -5,14 +5,18 @@ defmodule Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug do import Pleroma.Web.TranslationHelpers import Plug.Conn + alias Pleroma.Config alias Pleroma.User + use Pleroma.Web, :plug + def init(options) do options end - def call(conn, _) do + @impl true + def perform(conn, _) do public? = Config.get!([:instance, :public]) case {public?, conn} do diff --git a/lib/pleroma/plugs/expect_authenticated_check_plug.ex b/lib/pleroma/plugs/expect_authenticated_check_plug.ex new file mode 100644 index 000000000..66b8d5de5 --- /dev/null +++ b/lib/pleroma/plugs/expect_authenticated_check_plug.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.ExpectAuthenticatedCheckPlug do + @moduledoc """ + Marks `Pleroma.Plugs.EnsureAuthenticatedPlug` as expected to be executed later in plug chain. + + No-op plug which affects `Pleroma.Web` operation (is checked with `PlugHelper.plug_called?/2`). + """ + + use Pleroma.Web, :plug + + def init(options), do: options + + @impl true + def perform(conn, _) do + conn + end +end diff --git a/lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex b/lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex new file mode 100644 index 000000000..ba0ef76bd --- /dev/null +++ b/lib/pleroma/plugs/expect_public_or_authenticated_check_plug.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug do + @moduledoc """ + Marks `Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug` as expected to be executed later in plug + chain. + + No-op plug which affects `Pleroma.Web` operation (is checked with `PlugHelper.plug_called?/2`). + """ + + use Pleroma.Web, :plug + + def init(options), do: options + + @impl true + def perform(conn, _) do + conn + end +end diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex index 66f48c28c..a61582566 100644 --- a/lib/pleroma/plugs/oauth_scopes_plug.ex +++ b/lib/pleroma/plugs/oauth_scopes_plug.ex @@ -7,15 +7,12 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do import Pleroma.Web.Gettext alias Pleroma.Config - alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - alias Pleroma.Plugs.PlugHelper use Pleroma.Web, :plug - @behaviour Plug - def init(%{scopes: _} = options), do: options + @impl true def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do op = options[:op] || :| token = assigns[:token] @@ -34,7 +31,6 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do conn |> assign(:user, nil) |> assign(:token, nil) - |> maybe_perform_instance_privacy_check(options) true -> missing_scopes = scopes -- matched_scopes @@ -71,12 +67,4 @@ def transform_scopes(scopes, options) do scopes end end - - defp maybe_perform_instance_privacy_check(%Plug.Conn{} = conn, options) do - if options[:skip_instance_privacy_check] do - conn - else - EnsurePublicOrAuthenticatedPlug.call(conn, []) - end - end end diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 9c79310c0..816c11e01 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -48,6 +48,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do %{scopes: ["write:accounts"], admin: true} when action in [ :get_password_reset, + :force_password_reset, :user_delete, :users_create, :user_toggle_activation, @@ -56,7 +57,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :tag_users, :untag_users, :right_add, + :right_add_multiple, :right_delete, + :right_delete_multiple, :update_user_credentials ] ) @@ -84,13 +87,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["write:reports"], admin: true} - when action in [:reports_update] + when action in [:reports_update, :report_notes_create, :report_notes_delete] ) plug( OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} - when action == :list_user_statuses + when action in [:list_statuses, :list_user_statuses, :list_instance_statuses] ) plug( @@ -102,13 +105,30 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["read"], admin: true} - when action in [:config_show, :list_log, :stats] + when action in [ + :config_show, + :list_log, + :stats, + :relay_list, + :config_descriptions, + :need_reboot + ] ) plug( OAuthScopesPlug, %{scopes: ["write"], admin: true} - when action == :config_update + when action in [ + :restart, + :config_update, + :resend_confirmation_email, + :confirm_email, + :oauth_app_create, + :oauth_app_list, + :oauth_app_update, + :oauth_app_delete, + :reload_emoji + ] ) action_fallback(:errors) @@ -1103,25 +1123,25 @@ def stats(conn, _) do |> json(%{"status_visibility" => count}) end - def errors(conn, {:error, :not_found}) do + defp errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) |> json(dgettext("errors", "Not found")) end - def errors(conn, {:error, reason}) do + defp errors(conn, {:error, reason}) do conn |> put_status(:bad_request) |> json(reason) end - def errors(conn, {:param_cast, _}) do + defp errors(conn, {:param_cast, _}) do conn |> put_status(:bad_request) |> json(dgettext("errors", "Invalid parameters")) end - def errors(conn, _) do + defp errors(conn, _) do conn |> put_status(:internal_server_error) |> json(dgettext("errors", "Something went wrong")) diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index c13518030..0d9d578fc 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,7 +4,9 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller + require Logger + alias Pleroma.User alias Pleroma.Web.Metadata diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 557cde328..9a2ec517a 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -13,11 +13,14 @@ defmodule Pleroma.Web.MastoFEController do # Note: :index action handles attempt of unauthenticated access to private instance with redirect plug( OAuthScopesPlug, - %{scopes: ["read"], fallback: :proceed_unauthenticated, skip_instance_privacy_check: true} + %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :manifest]) + plug( + :skip_plug, + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :manifest] + ) @doc "GET /web/*path" def index(%{assigns: %{user: user, token: token}} = conn, _params) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index e8e59ac66..9b8cc0d0d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -26,12 +26,24 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI - plug(:skip_plug, OAuthScopesPlug when action == :identity_proofs) + plug(:skip_plug, OAuthScopesPlug when action in [:create, :identity_proofs]) + + plug( + :skip_plug, + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + when action in [:create, :show, :statuses] + ) plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action == :show + when action in [:show, :endorsements] + ) + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]} + when action == :statuses ) plug( @@ -56,21 +68,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships) - # Note: :follows (POST /api/v1/follows) is the same as :follow, consider removing :follows plug( OAuthScopesPlug, - %{scopes: ["follow", "write:follows"]} when action in [:follows, :follow, :unfollow] + %{scopes: ["follow", "write:follows"]} when action in [:follow_by_uri, :follow, :unfollow] ) plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes) plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute]) - plug( - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - when action not in [:create, :show, :statuses] - ) - @relationship_actions [:follow, :unfollow] @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a @@ -356,7 +362,7 @@ def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do end @doc "POST /api/v1/follows" - def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do + def follow_by_uri(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, {_, true} <- {:followed, follower.id != followed.id}, {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index 37b389382..753b3db3e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -13,10 +13,10 @@ defmodule Pleroma.Web.MastodonAPI.AuthController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - @local_mastodon_name "Mastodon-Local" - plug(Pleroma.Plugs.RateLimiter, [name: :password_reset] when action == :password_reset) + @local_mastodon_name "Mastodon-Local" + @doc "GET /web/login" def login(%{assigns: %{user: %User{}}} = conn, _params) do redirect(conn, to: local_mastodon_root_path(conn)) diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index 7c9b11bf1..c44641526 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -14,9 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index) - plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action == :read) - - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index) @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do @@ -28,7 +26,7 @@ def index(%{assigns: %{user: user}} = conn, params) do end @doc "POST /api/v1/conversations/:id/read" - def read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do + def mark_as_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do with %Participation{} = participation <- Repo.get_by(Participation, id: participation_id, user_id: user.id), {:ok, participation} <- Participation.mark_as_read(participation) do diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index 84de79413..c4fa383f2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -21,8 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do %{scopes: ["follow", "write:blocks"]} when action != :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "GET /api/v1/domain_blocks" def index(%{assigns: %{user: user}} = conn, _) do json(conn, Map.get(user, :domain_blocks, [])) diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index 7b0b937a2..7fd0562c9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -17,8 +17,6 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do %{scopes: ["write:filters"]} when action not in @oauth_read_actions ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "GET /api/v1/filters" def index(%{assigns: %{user: user}} = conn, _) do filters = Filter.get_filters(user) diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index 1ca86f63f..25f2269b9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -21,8 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do %{scopes: ["follow", "write:follows"]} when action != :index ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "GET /api/v1/follow_requests" def index(%{assigns: %{user: followed}} = conn, _params) do follow_requests = User.get_follow_requests(followed) diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index dac4daa7b..bfe856025 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -11,16 +11,16 @@ defmodule Pleroma.Web.MastodonAPI.ListController do plug(:list_by_id_and_user when action not in [:index, :create]) - plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in [:index, :show, :list_accounts]) + @oauth_read_actions [:index, :show, :list_accounts] + + plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions) plug( OAuthScopesPlug, %{scopes: ["write:lists"]} - when action in [:create, :update, :delete, :add_to_list, :remove_from_list] + when action not in @oauth_read_actions ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) # GET /api/v1/lists diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex index 58e8a30c2..9f9d4574e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do ) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) # GET /api/v1/markers diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index ac8c18f24..f0492b189 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -17,8 +17,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug when action in [:empty_array, :empty_object]) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) def empty_array(conn, _) do diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 2b6f00952..e36751220 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -15,8 +15,6 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do plug(OAuthScopesPlug, %{scopes: ["write:media"]}) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "POST /api/v1/media" def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do with {:ok, object} <- diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 7fb536b09..311405277 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -20,8 +20,6 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - # GET /api/v1/notifications def index(conn, %{"account_id" => account_id} = params) do case Pleroma.User.get_cached_by_id(account_id) do diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index d9f894118..af9b66eff 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -22,8 +22,6 @@ defmodule Pleroma.Web.MastodonAPI.PollController do plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "GET /api/v1/polls/:id" def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex index f5782be13..9fbaa7bd1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -11,8 +11,6 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "POST /api/v1/reports" def create(%{assigns: %{user: user}} = conn, params) do with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex index e1e6bd89b..899b78873 100644 --- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -18,8 +18,6 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) @doc "GET /api/v1/scheduled_statuses" diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index c258742dd..b54c56967 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -21,8 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 397dd10e3..eade83aaf 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -77,7 +77,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action not in [:index, :show]) + plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :show]) @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex index 4647c1f96..d184ea1d0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do action_fallback(:errors) plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug(:restrict_push_enabled) # Creates PushSubscription diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index b3c58005e..891f924bc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -26,7 +26,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :public) + plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action == :public) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 1a09ac62a..4657a4383 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do use Pleroma.Web, :controller + alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 60405fbff..d6ffdcbe4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -17,6 +17,13 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do require Pleroma.Constants + plug(:skip_plug, OAuthScopesPlug when action == :confirmation_resend) + + plug( + :skip_plug, + Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action == :confirmation_resend + ) + plug( OAuthScopesPlug, %{scopes: ["follow", "write:follows"]} when action in [:subscribe, :unsubscribe] @@ -35,13 +42,8 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) - # An extra safety measure for possible actions not guarded by OAuth permissions specification - plug( - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - when action != :confirmation_resend - ) - plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend) + plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index 03e95e020..e01825b48 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -1,6 +1,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do use Pleroma.Web, :controller + alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug alias Pleroma.Plugs.OAuthScopesPlug require Logger @@ -11,17 +12,20 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do when action in [ :create, :delete, - :download_from, - :list_from, + :save_from, :import_from_fs, :update_file, :update_metadata ] ) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug( + :skip_plug, + [OAuthScopesPlug, ExpectPublicOrAuthenticatedCheckPlug] + when action in [:download_shared, :list_packs, :list_from] + ) - def emoji_dir_path do + defp emoji_dir_path do Path.join( Pleroma.Config.get!([:instance, :static_dir]), "emoji" @@ -212,13 +216,13 @@ defp shareable_packs_available(address) do end @doc """ - An admin endpoint to request downloading a pack named `pack_name` from the instance + An admin endpoint to request downloading and storing a pack named `pack_name` from the instance `instance_address`. If the requested instance's admin chose to share the pack, it will be downloaded from that instance, otherwise it will be downloaded from the fallback source, if there is one. """ - def download_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do + def save_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do address = String.trim(address) if shareable_packs_available(address) do diff --git a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex index d9c1c8636..d4e0d8b7c 100644 --- a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -12,8 +12,6 @@ defmodule Pleroma.Web.PleromaAPI.MascotController do plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - @doc "GET /api/v1/pleroma/mascot" def show(%{assigns: %{user: user}} = conn, _params) do json(conn, User.get_mascot(user)) diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index fe1b97a20..7a65697e8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -34,12 +34,14 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do plug( OAuthScopesPlug, - %{scopes: ["write:conversations"]} when action in [:update_conversation, :read_conversations] + %{scopes: ["write:conversations"]} + when action in [:update_conversation, :mark_conversations_as_read] ) - plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification) - - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + plug( + OAuthScopesPlug, + %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read + ) def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} = params) do with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), @@ -167,7 +169,7 @@ def update_conversation( end end - def read_conversations(%{assigns: %{user: user}} = conn, _params) do + def mark_conversations_as_read(%{assigns: %{user: user}} = conn, _params) do with {:ok, _, participations} <- Participation.mark_all_as_read(user) do conn |> add_link_headers(participations) @@ -176,7 +178,7 @@ def read_conversations(%{assigns: %{user: user}} = conn, _params) do end end - def read_notification(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do + def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do with {:ok, notification} <- Notification.read_one(user, notification_id) do conn |> put_view(NotificationView) @@ -189,7 +191,7 @@ def read_notification(%{assigns: %{user: user}} = conn, %{"id" => notification_i end end - def read_notification(%{assigns: %{user: user}} = conn, %{"max_id" => max_id} = params) do + def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"max_id" => max_id} = params) do with notifications <- Notification.set_read_up_to(user, max_id) do notifications = Enum.take(notifications, 80) diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 4463ec477..c81e8535e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -16,8 +16,6 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :user_scrobbles) plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do params = if !params["length"] do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 153802a43..04c1c5941 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -16,6 +16,14 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Plugs.UserEnabledPlug) end + pipeline :expect_authentication do + plug(Pleroma.Plugs.ExpectAuthenticatedCheckPlug) + end + + pipeline :expect_public_instance_or_authentication do + plug(Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug) + end + pipeline :authenticate do plug(Pleroma.Plugs.OAuthPlug) plug(Pleroma.Plugs.BasicAuthDecoderPlug) @@ -39,20 +47,22 @@ defmodule Pleroma.Web.Router do end pipeline :api do + plug(:expect_public_instance_or_authentication) plug(:base_api) plug(:after_auth) plug(Pleroma.Plugs.IdempotencyPlug) end pipeline :authenticated_api do + plug(:expect_authentication) plug(:base_api) - plug(Pleroma.Plugs.AuthExpectedPlug) plug(:after_auth) plug(Pleroma.Plugs.EnsureAuthenticatedPlug) plug(Pleroma.Plugs.IdempotencyPlug) end pipeline :admin_api do + plug(:expect_authentication) plug(:base_api) plug(Pleroma.Plugs.AdminSecretAuthenticationPlug) plug(:after_auth) @@ -200,24 +210,28 @@ defmodule Pleroma.Web.Router do end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do + # Modifying packs scope "/packs" do - # Modifying packs pipe_through(:admin_api) post("/import_from_fs", EmojiAPIController, :import_from_fs) - post("/:pack_name/update_file", EmojiAPIController, :update_file) post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) put("/:name", EmojiAPIController, :create) delete("/:name", EmojiAPIController, :delete) - post("/download_from", EmojiAPIController, :download_from) - post("/list_from", EmojiAPIController, :list_from) + + # Note: /download_from downloads and saves to instance, not to requester + post("/download_from", EmojiAPIController, :save_from) end + # Pack info / downloading scope "/packs" do - # Pack info / downloading get("/", EmojiAPIController, :list_packs) get("/:name/download_shared/", EmojiAPIController, :download_shared) + get("/list_from", EmojiAPIController, :list_from) + + # Deprecated: POST /api/pleroma/emoji/packs/list_from (use GET instead) + post("/list_from", EmojiAPIController, :list_from) end end @@ -277,7 +291,7 @@ defmodule Pleroma.Web.Router do get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) get("/conversations/:id", PleromaAPIController, :conversation) - post("/conversations/read", PleromaAPIController, :read_conversations) + post("/conversations/read", PleromaAPIController, :mark_conversations_as_read) end scope [] do @@ -286,7 +300,7 @@ defmodule Pleroma.Web.Router do patch("/conversations/:id", PleromaAPIController, :update_conversation) put("/statuses/:id/reactions/:emoji", PleromaAPIController, :react_with_emoji) delete("/statuses/:id/reactions/:emoji", PleromaAPIController, :unreact_with_emoji) - post("/notifications/read", PleromaAPIController, :read_notification) + post("/notifications/read", PleromaAPIController, :mark_notifications_as_read) patch("/accounts/update_avatar", AccountController, :update_avatar) patch("/accounts/update_banner", AccountController, :update_banner) @@ -322,53 +336,81 @@ defmodule Pleroma.Web.Router do pipe_through(:authenticated_api) get("/accounts/verify_credentials", AccountController, :verify_credentials) + patch("/accounts/update_credentials", AccountController, :update_credentials) get("/accounts/relationships", AccountController, :relationships) - get("/accounts/:id/lists", AccountController, :lists) get("/accounts/:id/identity_proofs", AccountController, :identity_proofs) - - get("/follow_requests", FollowRequestController, :index) + get("/endorsements", AccountController, :endorsements) get("/blocks", AccountController, :blocks) get("/mutes", AccountController, :mutes) - get("/timelines/home", TimelineController, :home) - get("/timelines/direct", TimelineController, :direct) + post("/follows", AccountController, :follow_by_uri) + post("/accounts/:id/follow", AccountController, :follow) + post("/accounts/:id/unfollow", AccountController, :unfollow) + post("/accounts/:id/block", AccountController, :block) + post("/accounts/:id/unblock", AccountController, :unblock) + post("/accounts/:id/mute", AccountController, :mute) + post("/accounts/:id/unmute", AccountController, :unmute) - get("/favourites", StatusController, :favourites) - get("/bookmarks", StatusController, :bookmarks) + get("/conversations", ConversationController, :index) + post("/conversations/:id/read", ConversationController, :mark_as_read) + + get("/domain_blocks", DomainBlockController, :index) + post("/domain_blocks", DomainBlockController, :create) + delete("/domain_blocks", DomainBlockController, :delete) + + get("/filters", FilterController, :index) + + post("/filters", FilterController, :create) + get("/filters/:id", FilterController, :show) + put("/filters/:id", FilterController, :update) + delete("/filters/:id", FilterController, :delete) + + get("/follow_requests", FollowRequestController, :index) + post("/follow_requests/:id/authorize", FollowRequestController, :authorize) + post("/follow_requests/:id/reject", FollowRequestController, :reject) + + get("/lists", ListController, :index) + get("/lists/:id", ListController, :show) + get("/lists/:id/accounts", ListController, :list_accounts) + + delete("/lists/:id", ListController, :delete) + post("/lists", ListController, :create) + put("/lists/:id", ListController, :update) + post("/lists/:id/accounts", ListController, :add_to_list) + delete("/lists/:id/accounts", ListController, :remove_from_list) + + get("/markers", MarkerController, :index) + post("/markers", MarkerController, :upsert) + + post("/media", MediaController, :create) + put("/media/:id", MediaController, :update) get("/notifications", NotificationController, :index) get("/notifications/:id", NotificationController, :show) + post("/notifications/:id/dismiss", NotificationController, :dismiss) post("/notifications/clear", NotificationController, :clear) 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) - - get("/lists", ListController, :index) - get("/lists/:id", ListController, :show) - get("/lists/:id/accounts", ListController, :list_accounts) - - get("/domain_blocks", DomainBlockController, :index) - - get("/filters", FilterController, :index) + post("/polls/:id/votes", PollController, :vote) - get("/suggestions", SuggestionController, :index) + post("/reports", ReportController, :create) - get("/conversations", ConversationController, :index) - post("/conversations/:id/read", ConversationController, :read) + get("/scheduled_statuses", ScheduledActivityController, :index) + get("/scheduled_statuses/:id", ScheduledActivityController, :show) - get("/endorsements", AccountController, :endorsements) + put("/scheduled_statuses/:id", ScheduledActivityController, :update) + delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) - patch("/accounts/update_credentials", AccountController, :update_credentials) + get("/favourites", StatusController, :favourites) + get("/bookmarks", StatusController, :bookmarks) post("/statuses", StatusController, :create) delete("/statuses/:id", StatusController, :delete) - post("/statuses/:id/reblog", StatusController, :reblog) post("/statuses/:id/unreblog", StatusController, :unreblog) post("/statuses/:id/favourite", StatusController, :favourite) @@ -380,49 +422,15 @@ defmodule Pleroma.Web.Router do post("/statuses/:id/mute", StatusController, :mute_conversation) post("/statuses/:id/unmute", StatusController, :unmute_conversation) - put("/scheduled_statuses/:id", ScheduledActivityController, :update) - delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) - - post("/polls/:id/votes", PollController, :vote) - - post("/media", MediaController, :create) - put("/media/:id", MediaController, :update) - - delete("/lists/:id", ListController, :delete) - post("/lists", ListController, :create) - put("/lists/:id", ListController, :update) - - post("/lists/:id/accounts", ListController, :add_to_list) - delete("/lists/:id/accounts", ListController, :remove_from_list) - - post("/filters", FilterController, :create) - get("/filters/:id", FilterController, :show) - put("/filters/:id", FilterController, :update) - delete("/filters/:id", FilterController, :delete) - - post("/reports", ReportController, :create) - - post("/follows", AccountController, :follows) - post("/accounts/:id/follow", AccountController, :follow) - post("/accounts/:id/unfollow", AccountController, :unfollow) - post("/accounts/:id/block", AccountController, :block) - post("/accounts/:id/unblock", AccountController, :unblock) - post("/accounts/:id/mute", AccountController, :mute) - post("/accounts/:id/unmute", AccountController, :unmute) - - post("/follow_requests/:id/authorize", FollowRequestController, :authorize) - post("/follow_requests/:id/reject", FollowRequestController, :reject) - - post("/domain_blocks", DomainBlockController, :create) - delete("/domain_blocks", DomainBlockController, :delete) - post("/push/subscription", SubscriptionController, :create) get("/push/subscription", SubscriptionController, :get) put("/push/subscription", SubscriptionController, :update) delete("/push/subscription", SubscriptionController, :delete) - get("/markers", MarkerController, :index) - post("/markers", MarkerController, :upsert) + get("/suggestions", SuggestionController, :index) + + get("/timelines/home", TimelineController, :home) + get("/timelines/direct", TimelineController, :direct) end scope "/api/web", Pleroma.Web do @@ -507,7 +515,11 @@ defmodule Pleroma.Web.Router do get("/oauth_tokens", TwitterAPI.Controller, :oauth_tokens) delete("/oauth_tokens/:id", TwitterAPI.Controller, :revoke_token) - post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read) + post( + "/qvitter/statuses/notifications/read", + TwitterAPI.Controller, + :mark_notifications_as_read + ) end pipeline :ostatus do diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 537f9f778..9a4c39fa9 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -25,13 +25,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do when action == :follow_import ) - # Note: follower can submit the form (with password auth) not being signed in (having no token) - plug( - OAuthScopesPlug, - %{fallback: :proceed_unauthenticated, scopes: ["follow", "write:follows"]} - when action == :do_remote_follow - ) - plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks_import) plug( diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 31adc2817..55228616a 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -13,12 +13,13 @@ defmodule Pleroma.Web.TwitterAPI.Controller do require Logger - plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) + plug( + OAuthScopesPlug, + %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read + ) plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) - action_fallback(:errors) def confirm_email(conn, %{"user_id" => uid, "token" => token}) do @@ -64,7 +65,10 @@ defp json_reply(conn, status, json) do |> send_resp(status, json) end - def notifications_read(%{assigns: %{user: user}} = conn, %{"latest_id" => latest_id} = params) do + def mark_notifications_as_read( + %{assigns: %{user: user}} = conn, + %{"latest_id" => latest_id} = params + ) do Notification.set_read_up_to(user, latest_id) notifications = Notification.for_user(user, params) @@ -75,7 +79,7 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"latest_id" => latest |> render("index.json", %{notifications: notifications, for: user}) end - def notifications_read(%{assigns: %{user: _user}} = conn, _) do + def mark_notifications_as_read(%{assigns: %{user: _user}} = conn, _) do bad_request_reply(conn, "You need to specify latest_id") end diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index bf48ce26c..ec04c05f0 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -2,6 +2,11 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only +defmodule Pleroma.Web.Plug do + # Substitute for `call/2` which is defined with `use Pleroma.Web, :plug` + @callback perform(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() +end + defmodule Pleroma.Web do @moduledoc """ A module that keeps using definitions for controllers, @@ -20,44 +25,79 @@ defmodule Pleroma.Web do below. """ + alias Pleroma.Plugs.EnsureAuthenticatedPlug + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Plugs.ExpectAuthenticatedCheckPlug + alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.PlugHelper + def controller do quote do use Phoenix.Controller, namespace: Pleroma.Web import Plug.Conn + import Pleroma.Web.Gettext import Pleroma.Web.Router.Helpers import Pleroma.Web.TranslationHelpers - alias Pleroma.Plugs.PlugHelper - plug(:set_put_layout) defp set_put_layout(conn, _) do put_layout(conn, Pleroma.Config.get(:app_layout, "app.html")) end - # Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain - defp skip_plug(conn, plug_module) do - try do - plug_module.skip_plug(conn) - rescue - UndefinedFunctionError -> - raise "#{plug_module} is not skippable. Append `use Pleroma.Web, :plug` to its code." - end + # Marks plugs intentionally skipped and blocks their execution if present in plugs chain + defp skip_plug(conn, plug_modules) do + plug_modules + |> List.wrap() + |> Enum.reduce( + conn, + fn plug_module, conn -> + try do + plug_module.skip_plug(conn) + rescue + UndefinedFunctionError -> + raise "`#{plug_module}` is not skippable. Append `use Pleroma.Web, :plug` to its code." + end + end + ) end # Executed just before actual controller action, invokes before-action hooks (callbacks) defp action(conn, params) do - with %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do + with %Plug.Conn{halted: false} <- maybe_perform_public_or_authenticated_check(conn), + %Plug.Conn{halted: false} <- maybe_perform_authenticated_check(conn), + %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do super(conn, params) end end + # Ensures instance is public -or- user is authenticated if such check was scheduled + defp maybe_perform_public_or_authenticated_check(conn) do + if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) do + EnsurePublicOrAuthenticatedPlug.call(conn, %{}) + else + conn + end + end + + # Ensures user is authenticated if such check was scheduled + # Note: runs prior to action even if it was already executed earlier in plug chain + # (since OAuthScopesPlug has option of proceeding unauthenticated) + defp maybe_perform_authenticated_check(conn) do + if PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) do + EnsureAuthenticatedPlug.call(conn, %{}) + else + conn + end + end + # Halts if authenticated API action neither performs nor explicitly skips OAuth scopes check defp maybe_halt_on_missing_oauth_scopes_check(conn) do - if Pleroma.Plugs.AuthExpectedPlug.auth_expected?(conn) && - not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do + if PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) and + not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do conn |> render_error( :forbidden, @@ -132,7 +172,8 @@ def channel do def plug do quote do - alias Pleroma.Plugs.PlugHelper + @behaviour Pleroma.Web.Plug + @behaviour Plug @doc """ Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain. @@ -146,14 +187,22 @@ def skip_plug(conn) do end @impl Plug - @doc "If marked as skipped, returns `conn`, and calls `perform/2` otherwise." + @doc """ + If marked as skipped, returns `conn`, otherwise calls `perform/2`. + Note: multiple invocations of the same plug (with different or same options) are allowed. + """ def call(%Plug.Conn{} = conn, options) do if PlugHelper.plug_skipped?(conn, __MODULE__) do conn else - conn - |> PlugHelper.append_to_private_list(PlugHelper.called_plugs_list_id(), __MODULE__) - |> perform(options) + conn = + PlugHelper.append_to_private_list( + conn, + PlugHelper.called_plugs_list_id(), + __MODULE__ + ) + + apply(__MODULE__, :perform, [conn, options]) end end end diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs index 7f3559b83..689fe757f 100644 --- a/test/plugs/ensure_authenticated_plug_test.exs +++ b/test/plugs/ensure_authenticated_plug_test.exs @@ -20,7 +20,7 @@ test "it continues if a user is assigned", %{conn: conn} do conn = assign(conn, :user, %User{}) ret_conn = EnsureAuthenticatedPlug.call(conn, %{}) - assert ret_conn == conn + refute ret_conn.halted end end @@ -34,20 +34,22 @@ test "it continues if a user is assigned", %{conn: conn} do test "it continues if a user is assigned", %{conn: conn, true_fn: true_fn, false_fn: false_fn} do conn = assign(conn, :user, %User{}) - assert EnsureAuthenticatedPlug.call(conn, if_func: true_fn) == conn - assert EnsureAuthenticatedPlug.call(conn, if_func: false_fn) == conn - assert EnsureAuthenticatedPlug.call(conn, unless_func: true_fn) == conn - assert EnsureAuthenticatedPlug.call(conn, unless_func: false_fn) == conn + refute EnsureAuthenticatedPlug.call(conn, if_func: true_fn).halted + refute EnsureAuthenticatedPlug.call(conn, if_func: false_fn).halted + refute EnsureAuthenticatedPlug.call(conn, unless_func: true_fn).halted + refute EnsureAuthenticatedPlug.call(conn, unless_func: false_fn).halted end test "it continues if a user is NOT assigned but :if_func evaluates to `false`", %{conn: conn, false_fn: false_fn} do - assert EnsureAuthenticatedPlug.call(conn, if_func: false_fn) == conn + ret_conn = EnsureAuthenticatedPlug.call(conn, if_func: false_fn) + refute ret_conn.halted end test "it continues if a user is NOT assigned but :unless_func evaluates to `true`", %{conn: conn, true_fn: true_fn} do - assert EnsureAuthenticatedPlug.call(conn, unless_func: true_fn) == conn + ret_conn = EnsureAuthenticatedPlug.call(conn, unless_func: true_fn) + refute ret_conn.halted end test "it halts if a user is NOT assigned and :if_func evaluates to `true`", diff --git a/test/plugs/ensure_public_or_authenticated_plug_test.exs b/test/plugs/ensure_public_or_authenticated_plug_test.exs index 411252274..fc2934369 100644 --- a/test/plugs/ensure_public_or_authenticated_plug_test.exs +++ b/test/plugs/ensure_public_or_authenticated_plug_test.exs @@ -29,7 +29,7 @@ test "it continues if public", %{conn: conn} do conn |> EnsurePublicOrAuthenticatedPlug.call(%{}) - assert ret_conn == conn + refute ret_conn.halted end test "it continues if a user is assigned, even if not public", %{conn: conn} do @@ -43,6 +43,6 @@ test "it continues if a user is assigned, even if not public", %{conn: conn} do conn |> EnsurePublicOrAuthenticatedPlug.call(%{}) - assert ret_conn == conn + refute ret_conn.halted end end diff --git a/test/plugs/oauth_scopes_plug_test.exs b/test/plugs/oauth_scopes_plug_test.exs index edbc94227..884de7b4d 100644 --- a/test/plugs/oauth_scopes_plug_test.exs +++ b/test/plugs/oauth_scopes_plug_test.exs @@ -5,17 +5,12 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do use Pleroma.Web.ConnCase, async: true - alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo import Mock import Pleroma.Factory - setup_with_mocks([{EnsurePublicOrAuthenticatedPlug, [], [call: fn conn, _ -> conn end]}]) do - :ok - end - test "is not performed if marked as skipped", %{conn: conn} do with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do conn = @@ -60,7 +55,7 @@ test "if `token.scopes` fulfills specified 'all of' conditions, " <> describe "with `fallback: :proceed_unauthenticated` option, " do test "if `token.scopes` doesn't fulfill specified conditions, " <> - "clears :user and :token assigns and calls EnsurePublicOrAuthenticatedPlug", + "clears :user and :token assigns", %{conn: conn} do user = insert(:user) token1 = insert(:oauth_token, scopes: ["read", "write"], user: user) @@ -79,35 +74,6 @@ test "if `token.scopes` doesn't fulfill specified conditions, " <> refute ret_conn.halted refute ret_conn.assigns[:user] refute ret_conn.assigns[:token] - - assert called(EnsurePublicOrAuthenticatedPlug.call(ret_conn, :_)) - end - end - - test "with :skip_instance_privacy_check option, " <> - "if `token.scopes` doesn't fulfill specified conditions, " <> - "clears :user and :token assigns and does NOT call EnsurePublicOrAuthenticatedPlug", - %{conn: conn} do - user = insert(:user) - token1 = insert(:oauth_token, scopes: ["read:statuses", "write"], user: user) - - for token <- [token1, nil], op <- [:|, :&] do - ret_conn = - conn - |> assign(:user, user) - |> assign(:token, token) - |> OAuthScopesPlug.call(%{ - scopes: ["read"], - op: op, - fallback: :proceed_unauthenticated, - skip_instance_privacy_check: true - }) - - refute ret_conn.halted - refute ret_conn.assigns[:user] - refute ret_conn.assigns[:token] - - refute called(EnsurePublicOrAuthenticatedPlug.call(ret_conn, :_)) end end end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index fbacb3993..eca526604 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -766,7 +766,7 @@ test "it requires authentication if instance is NOT federating", %{ end describe "POST /users/:nickname/outbox" do - test "it rejects posts from other users / unauuthenticated users", %{conn: conn} do + test "it rejects posts from other users / unauthenticated users", %{conn: conn} do data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!() user = insert(:user) other_user = insert(:user) diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 435fb6592..4246eb400 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -38,8 +38,7 @@ test "shared & non-shared pack information in list_packs is ok" do end test "listing remote packs" do - admin = insert(:user, is_admin: true) - %{conn: conn} = oauth_access(["admin:write"], user: admin) + conn = build_conn() resp = build_conn() @@ -76,7 +75,7 @@ test "downloading a shared pack from download_shared" do assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end) end - test "downloading shared & unshared packs from another instance via download_from, deleting them" do + test "downloading shared & unshared packs from another instance, deleting them" do on_exit(fn -> File.rm_rf!("#{@emoji_dir_path}/test_pack2") File.rm_rf!("#{@emoji_dir_path}/test_pack_nonshared2") @@ -136,7 +135,7 @@ test "downloading shared & unshared packs from another instance via download_fro |> post( emoji_api_path( conn, - :download_from + :save_from ), %{ instance_address: "https://old-instance", @@ -152,7 +151,7 @@ test "downloading shared & unshared packs from another instance via download_fro |> post( emoji_api_path( conn, - :download_from + :save_from ), %{ instance_address: "https://example.com", @@ -179,7 +178,7 @@ test "downloading shared & unshared packs from another instance via download_fro |> post( emoji_api_path( conn, - :download_from + :save_from ), %{ instance_address: "https://example.com", diff --git a/test/web/twitter_api/twitter_api_controller_test.exs b/test/web/twitter_api/twitter_api_controller_test.exs index ab0a2c3df..464d0ea2e 100644 --- a/test/web/twitter_api/twitter_api_controller_test.exs +++ b/test/web/twitter_api/twitter_api_controller_test.exs @@ -19,13 +19,9 @@ test "without valid credentials", %{conn: conn} do end test "with credentials, without any params" do - %{user: current_user, conn: conn} = - oauth_access(["read:notifications", "write:notifications"]) + %{conn: conn} = oauth_access(["write:notifications"]) - conn = - conn - |> assign(:user, current_user) - |> post("/api/qvitter/statuses/notifications/read") + conn = post(conn, "/api/qvitter/statuses/notifications/read") assert json_response(conn, 400) == %{ "error" => "You need to specify latest_id", -- cgit v1.2.3 From 97ad0c45977261df3068ca4f0c3febce3173c058 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Apr 2020 17:51:06 +0200 Subject: Chats: Add API specs. --- .../web/api_spec/operations/chat_operation.ex | 96 ++++++++++++++-------- .../schemas/chat_message_create_request.ex | 20 +++++ .../web/api_spec/schemas/chat_message_response.ex | 38 +++++++++ .../web/api_spec/schemas/chat_messages_response.ex | 41 +++++++++ lib/pleroma/web/api_spec/schemas/chat_response.ex | 73 ++++++++++++++++ lib/pleroma/web/api_spec/schemas/chats_response.ex | 69 ++++++++++++++++ .../controllers/chat_controller_test.exs | 42 ++++++++++ 7 files changed, 346 insertions(+), 33 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_messages_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chats_response.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 038ebb29d..5bd41ec4f 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -4,7 +4,12 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation - alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -16,16 +21,25 @@ def create_operation do %Operation{ tags: ["chat"], summary: "Create a chat", + parameters: [ + Operation.parameter( + :ap_id, + :path, + :string, + "The ActivityPub id of the recipient of this chat.", + required: true, + example: "https://lain.com/users/lain" + ) + ], responses: %{ 200 => - Operation.response("Chat", "application/json", %Schema{ - type: :object, - description: "A created chat is returned", - properties: %{ - id: %Schema{type: :integer} - } - }) - } + Operation.response("The created or existing chat", "application/json", ChatResponse) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] } end @@ -33,17 +47,19 @@ def index_operation do %Operation{ tags: ["chat"], summary: "Get a list of chats that you participated in", + parameters: [ + Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), + Operation.parameter(:min_id, :query, :string, "Return only chats after this id"), + Operation.parameter(:max_id, :query, :string, "Return only chats before this id") + ], responses: %{ - 200 => - Operation.response("Chats", "application/json", %Schema{ - type: :array, - description: "A list of chats", - items: %Schema{ - type: :object, - description: "A chat" - } - }) - } + 200 => Operation.response("The chats of the user", "application/json", ChatsResponse) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] } end @@ -51,17 +67,21 @@ def messages_operation do %Operation{ tags: ["chat"], summary: "Get the most recent messages of the chat", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), + Operation.parameter(:min_id, :query, :string, "Return only messages after this id"), + Operation.parameter(:max_id, :query, :string, "Return only messages before this id") + ], responses: %{ 200 => - Operation.response("Messages", "application/json", %Schema{ - type: :array, - description: "A list of chat messages", - items: %Schema{ - type: :object, - description: "A chat message" - } - }) - } + Operation.response("The messages in the chat", "application/json", ChatMessagesResponse) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] } end @@ -69,13 +89,23 @@ def post_chat_message_operation do %Operation{ tags: ["chat"], summary: "Post a message to the chat", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat") + ], + requestBody: Helpers.request_body("Parameters", ChatMessageCreateRequest, required: true), responses: %{ 200 => - Operation.response("Message", "application/json", %Schema{ - type: :object, - description: "A chat message" - }) - } + Operation.response( + "The newly created ChatMessage", + "application/json", + ChatMessageResponse + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] } end end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex new file mode 100644 index 000000000..4dafcda43 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessageCreateRequest", + description: "POST body for creating an chat message", + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The content of your message"} + }, + example: %{ + "content" => "Hey wanna buy feet pics?" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex new file mode 100644 index 000000000..e94c00369 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessageResponse", + description: "Response schema for a ChatMessage", + type: :object, + properties: %{ + id: %Schema{type: :string}, + actor: %Schema{type: :string, description: "The ActivityPub id of the actor"}, + chat_id: %Schema{type: :string}, + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :datetime}, + emojis: %Schema{type: :array} + }, + example: %{ + "actor" => "https://dontbulling.me/users/lain", + "chat_id" => "1", + "content" => "hey you again", + "created_at" => "2020-04-21T15:06:45.000Z", + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "id" => "14" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex b/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex new file mode 100644 index 000000000..302bdec95 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse do + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessagesResponse", + description: "Response schema for multiple ChatMessages", + type: :array, + items: ChatMessageResponse, + example: [ + %{ + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "created_at" => "2020-04-21T15:11:46.000Z", + "content" => "Check this out :firefox:", + "id" => "13", + "chat_id" => "1", + "actor" => "https://dontbulling.me/users/lain" + }, + %{ + "actor" => "https://dontbulling.me/users/lain", + "content" => "Whats' up?", + "id" => "12", + "chat_id" => "1", + "emojis" => [], + "created_at" => "2020-04-21T15:06:45.000Z" + } + ] + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_response.ex b/lib/pleroma/web/api_spec/schemas/chat_response.ex new file mode 100644 index 000000000..a80f4d173 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_response.ex @@ -0,0 +1,73 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatResponse do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatResponse", + description: "Response schema for a Chat", + type: :object, + properties: %{ + id: %Schema{type: :string}, + recipient: %Schema{type: :string}, + # TODO: Make this reference the account structure. + recipient_account: %Schema{type: :object}, + unread: %Schema{type: :integer} + }, + example: %{ + "recipient" => "https://dontbulling.me/users/lain", + "recipient_account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chats_response.ex b/lib/pleroma/web/api_spec/schemas/chats_response.ex new file mode 100644 index 000000000..3349e0691 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chats_response.ex @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatsResponse do + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatsResponse", + description: "Response schema for multiple Chats", + type: :array, + items: ChatResponse, + example: [ + %{ + "recipient" => "https://dontbulling.me/users/lain", + "recipient_account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + ] + }) +end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 0750c7273..52a34d23f 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,8 +5,14 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse alias Pleroma.Web.CommonAPI + import OpenApiSpex.TestAssertions import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages" do @@ -24,6 +30,7 @@ test "it posts a message to the chat", %{conn: conn, user: user} do assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() + assert_schema(result, "ChatMessageResponse", ApiSpec.spec()) end end @@ -45,6 +52,7 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 20 + assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) result = conn @@ -52,6 +60,7 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 10 + assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) end test "it returns the messages for a given chat", %{conn: conn, user: user} do @@ -76,6 +85,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end) assert length(result) == 3 + assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) # Trying to get the chat of a different user result = @@ -99,6 +109,7 @@ test "it creates or returns a chat", %{conn: conn} do |> json_response(200) assert result["id"] + assert_schema(result, "ChatResponse", ApiSpec.spec()) end end @@ -117,6 +128,7 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 20 + assert_schema(result, "ChatsResponse", ApiSpec.spec()) result = conn @@ -124,6 +136,8 @@ test "it paginates", %{conn: conn, user: user} do |> json_response(200) assert length(result) == 10 + + assert_schema(result, "ChatsResponse", ApiSpec.spec()) end test "it return a list of chats the current user is participating in, in descending order of updates", @@ -154,6 +168,34 @@ test "it return a list of chats the current user is participating in, in descend chat_3.id |> to_string(), chat_1.id |> to_string() ] + + assert_schema(result, "ChatsResponse", ApiSpec.spec()) + end + end + + describe "schemas" do + test "Chat example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatResponse.schema() + assert_schema(schema.example, "ChatResponse", api_spec) + end + + test "Chats example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatsResponse.schema() + assert_schema(schema.example, "ChatsResponse", api_spec) + end + + test "ChatMessage example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatMessageResponse.schema() + assert_schema(schema.example, "ChatMessageResponse", api_spec) + end + + test "ChatsMessage example matches schema" do + api_spec = ApiSpec.spec() + schema = ChatMessagesResponse.schema() + assert_schema(schema.example, "ChatMessagesResponse", api_spec) end end end -- cgit v1.2.3 From 66c2eb670b273d808f0a9c1ae087df064718ca3d Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Apr 2020 18:23:00 +0200 Subject: ChatController: Validate parameters. --- .../web/api_spec/operations/chat_operation.ex | 4 ++++ .../web/pleroma_api/controllers/chat_controller.ex | 22 ++++++++++++++-------- .../controllers/chat_controller_test.exs | 5 +++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 5bd41ec4f..dc99bd773 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -21,6 +21,7 @@ def create_operation do %Operation{ tags: ["chat"], summary: "Create a chat", + operationId: "ChatController.create", parameters: [ Operation.parameter( :ap_id, @@ -47,6 +48,7 @@ def index_operation do %Operation{ tags: ["chat"], summary: "Get a list of chats that you participated in", + operationId: "ChatController.index", parameters: [ Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), Operation.parameter(:min_id, :query, :string, "Return only chats after this id"), @@ -67,6 +69,7 @@ def messages_operation do %Operation{ tags: ["chat"], summary: "Get the most recent messages of the chat", + operationId: "ChatController.messages", parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat"), Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), @@ -89,6 +92,7 @@ def post_chat_message_operation do %Operation{ tags: ["chat"], summary: "Post a message to the chat", + operationId: "ChatController.post_chat_message", parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 9d8b9b3cf..771ad6217 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -14,6 +14,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView + import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] + import Ecto.Query # TODO @@ -29,12 +31,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do %{scopes: ["read:statuses"]} when action in [:messages, :index] ) + plug(OpenApiSpex.Plug.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation - def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ - "id" => id, - "content" => content - }) do + def post_chat_message( + %{body_params: %{content: content}, assigns: %{user: %{id: user_id} = user}} = conn, + %{ + id: id + } + ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), @@ -45,7 +51,7 @@ def post_chat_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ end end - def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = params) do + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = from(o in Object, @@ -66,7 +72,7 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{"id" => id} = ^[user.ap_id] ) ) - |> Pagination.fetch_paginated(params) + |> Pagination.fetch_paginated(params |> stringify_keys()) conn |> put_view(ChatMessageView) @@ -85,7 +91,7 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do where: c.user_id == ^user_id, order_by: [desc: c.updated_at] ) - |> Pagination.fetch_paginated(params) + |> Pagination.fetch_paginated(params |> stringify_keys) conn |> put_view(ChatView) @@ -93,7 +99,7 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do end def create(%{assigns: %{user: user}} = conn, params) do - recipient = params["ap_id"] |> URI.decode_www_form() + recipient = params[:ap_id] with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do conn diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 52a34d23f..84610e511 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -25,6 +25,7 @@ test "it posts a message to the chat", %{conn: conn, user: user} do result = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) |> json_response(200) @@ -56,7 +57,7 @@ test "it paginates", %{conn: conn, user: user} do result = conn - |> get("/api/v1/pleroma/chats/#{chat.id}/messages", %{"max_id" => List.last(result)["id"]}) + |> get("/api/v1/pleroma/chats/#{chat.id}/messages?max_id=#{List.last(result)["id"]}") |> json_response(200) assert length(result) == 10 @@ -132,7 +133,7 @@ test "it paginates", %{conn: conn, user: user} do result = conn - |> get("/api/v1/pleroma/chats", %{max_id: List.last(result)["id"]}) + |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") |> json_response(200) assert length(result) == 10 -- cgit v1.2.3 From 6c8390fa4d47a86c34bcc71681ba30f04d14eae9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Apr 2020 18:32:30 +0200 Subject: ChatControllerTest: Credo fixes. --- test/web/pleroma_api/controllers/chat_controller_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 84610e511..07b698013 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -6,10 +6,10 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do alias Pleroma.Chat alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse alias Pleroma.Web.CommonAPI import OpenApiSpex.TestAssertions -- cgit v1.2.3 From f0238d010a61ab935b61beebd5674593a75f17dc Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 21 Apr 2020 23:30:24 +0400 Subject: Improve OpenAPI schema - Removes unneeded wrapping in examples - Adds `:format` attributes --- lib/pleroma/web/api_spec/schemas/account.ex | 152 ++++++++------- .../web/api_spec/schemas/account_create_request.ex | 6 +- .../api_spec/schemas/account_create_response.ex | 12 +- lib/pleroma/web/api_spec/schemas/account_emoji.ex | 18 +- lib/pleroma/web/api_spec/schemas/account_field.ex | 14 +- .../api_spec/schemas/account_field_attribute.ex | 6 +- .../api_spec/schemas/account_follows_request.ex | 2 +- .../web/api_spec/schemas/account_relationship.ex | 26 ++- .../web/api_spec/schemas/app_create_request.ex | 6 +- .../web/api_spec/schemas/app_create_response.ex | 4 +- lib/pleroma/web/api_spec/schemas/list.ex | 6 +- lib/pleroma/web/api_spec/schemas/status.ex | 206 ++++++++++----------- 12 files changed, 225 insertions(+), 233 deletions(-) diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index beb093182..3634a7c76 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -17,8 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ acct: %Schema{type: :string}, - avatar_static: %Schema{type: :string}, - avatar: %Schema{type: :string}, + avatar_static: %Schema{type: :string, format: :uri}, + avatar: %Schema{type: :string, format: :uri}, bot: %Schema{type: :boolean}, created_at: %Schema{type: :string, format: "date-time"}, display_name: %Schema{type: :string}, @@ -27,13 +27,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do follow_requests_count: %Schema{type: :integer}, followers_count: %Schema{type: :integer}, following_count: %Schema{type: :integer}, - header_static: %Schema{type: :string}, - header: %Schema{type: :string}, + header_static: %Schema{type: :string, format: :uri}, + header: %Schema{type: :string, format: :uri}, id: %Schema{type: :string}, locked: %Schema{type: :boolean}, - note: %Schema{type: :string}, + note: %Schema{type: :string, format: :html}, statuses_count: %Schema{type: :integer}, - url: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, username: %Schema{type: :string}, pleroma: %Schema{ type: :object, @@ -104,80 +104,78 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do } }, example: %{ - "JSON" => %{ - "acct" => "foobar", - "avatar" => "https://mypleroma.com/images/avi.png", - "avatar_static" => "https://mypleroma.com/images/avi.png", - "bot" => false, - "created_at" => "2020-03-24T13:05:58.000Z", - "display_name" => "foobar", - "emojis" => [], + "acct" => "foobar", + "avatar" => "https://mypleroma.com/images/avi.png", + "avatar_static" => "https://mypleroma.com/images/avi.png", + "bot" => false, + "created_at" => "2020-03-24T13:05:58.000Z", + "display_name" => "foobar", + "emojis" => [], + "fields" => [], + "follow_requests_count" => 0, + "followers_count" => 0, + "following_count" => 1, + "header" => "https://mypleroma.com/images/banner.png", + "header_static" => "https://mypleroma.com/images/banner.png", + "id" => "9tKi3esbG7OQgZ2920", + "locked" => false, + "note" => "cofe", + "pleroma" => %{ + "allow_following_move" => true, + "background_image" => nil, + "confirmation_pending" => true, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "skip_thread_containment" => false, + "chat_token" => + "SFMyNTY.g3QAAAACZAAEZGF0YW0AAAASOXRLaTNlc2JHN09RZ1oyOTIwZAAGc2lnbmVkbgYARNplS3EB.Mb_Iaqew2bN1I1o79B_iP7encmVCpTKC4OtHZRxdjKc", + "unread_conversation_count" => 0, + "tags" => [], + "notification_settings" => %{ + "followers" => true, + "follows" => true, + "non_followers" => true, + "non_follows" => true, + "privacy_option" => false + }, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + }, + "settings_store" => %{ + "pleroma-fe" => %{} + } + }, + "source" => %{ "fields" => [], - "follow_requests_count" => 0, - "followers_count" => 0, - "following_count" => 1, - "header" => "https://mypleroma.com/images/banner.png", - "header_static" => "https://mypleroma.com/images/banner.png", - "id" => "9tKi3esbG7OQgZ2920", - "locked" => false, - "note" => "cofe", + "note" => "foobar", "pleroma" => %{ - "allow_following_move" => true, - "background_image" => nil, - "confirmation_pending" => true, - "hide_favorites" => true, - "hide_followers" => false, - "hide_followers_count" => false, - "hide_follows" => false, - "hide_follows_count" => false, - "is_admin" => false, - "is_moderator" => false, - "skip_thread_containment" => false, - "chat_token" => - "SFMyNTY.g3QAAAACZAAEZGF0YW0AAAASOXRLaTNlc2JHN09RZ1oyOTIwZAAGc2lnbmVkbgYARNplS3EB.Mb_Iaqew2bN1I1o79B_iP7encmVCpTKC4OtHZRxdjKc", - "unread_conversation_count" => 0, - "tags" => [], - "notification_settings" => %{ - "followers" => true, - "follows" => true, - "non_followers" => true, - "non_follows" => true, - "privacy_option" => false - }, - "relationship" => %{ - "blocked_by" => false, - "blocking" => false, - "domain_blocking" => false, - "endorsed" => false, - "followed_by" => false, - "following" => false, - "id" => "9tKi3esbG7OQgZ2920", - "muting" => false, - "muting_notifications" => false, - "requested" => false, - "showing_reblogs" => true, - "subscribing" => false - }, - "settings_store" => %{ - "pleroma-fe" => %{} - } - }, - "source" => %{ - "fields" => [], - "note" => "foobar", - "pleroma" => %{ - "actor_type" => "Person", - "discoverable" => false, - "no_rich_text" => false, - "show_role" => true - }, - "privacy" => "public", - "sensitive" => false + "actor_type" => "Person", + "discoverable" => false, + "no_rich_text" => false, + "show_role" => true }, - "statuses_count" => 0, - "url" => "https://mypleroma.com/users/foobar", - "username" => "foobar" - } + "privacy" => "public", + "sensitive" => false + }, + "statuses_count" => 0, + "url" => "https://mypleroma.com/users/foobar", + "username" => "foobar" } }) end diff --git a/lib/pleroma/web/api_spec/schemas/account_create_request.ex b/lib/pleroma/web/api_spec/schemas/account_create_request.ex index 398e2d613..49fa12159 100644 --- a/lib/pleroma/web/api_spec/schemas/account_create_request.ex +++ b/lib/pleroma/web/api_spec/schemas/account_create_request.ex @@ -23,7 +23,11 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest do "The email address to be used for login. Required when `account_activation_required` is enabled.", format: :email }, - password: %Schema{type: :string, description: "The password to be used for login"}, + password: %Schema{ + type: :string, + description: "The password to be used for login", + format: :password + }, agreement: %Schema{ type: :boolean, description: diff --git a/lib/pleroma/web/api_spec/schemas/account_create_response.ex b/lib/pleroma/web/api_spec/schemas/account_create_response.ex index f41a034c0..2237351a2 100644 --- a/lib/pleroma/web/api_spec/schemas/account_create_response.ex +++ b/lib/pleroma/web/api_spec/schemas/account_create_response.ex @@ -15,15 +15,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse do token_type: %Schema{type: :string}, access_token: %Schema{type: :string}, scope: %Schema{type: :array, items: %Schema{type: :string}}, - created_at: %Schema{type: :integer} + created_at: %Schema{type: :integer, format: :"date-time"} }, example: %{ - "JSON" => %{ - "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", - "created_at" => 1_585_918_714, - "scope" => ["read", "write", "follow", "push"], - "token_type" => "Bearer" - } + "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", + "created_at" => 1_585_918_714, + "scope" => ["read", "write", "follow", "push"], + "token_type" => "Bearer" } }) end diff --git a/lib/pleroma/web/api_spec/schemas/account_emoji.ex b/lib/pleroma/web/api_spec/schemas/account_emoji.ex index 403b13b15..6c1d4d95c 100644 --- a/lib/pleroma/web/api_spec/schemas/account_emoji.ex +++ b/lib/pleroma/web/api_spec/schemas/account_emoji.ex @@ -13,19 +13,17 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountEmoji do type: :object, properties: %{ shortcode: %Schema{type: :string}, - url: %Schema{type: :string}, - static_url: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + static_url: %Schema{type: :string, format: :uri}, visible_in_picker: %Schema{type: :boolean} }, example: %{ - "JSON" => %{ - "shortcode" => "fatyoshi", - "url" => - "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", - "static_url" => - "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", - "visible_in_picker" => true - } + "shortcode" => "fatyoshi", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", + "visible_in_picker" => true } }) end diff --git a/lib/pleroma/web/api_spec/schemas/account_field.ex b/lib/pleroma/web/api_spec/schemas/account_field.ex index 8906d812d..fa97073a0 100644 --- a/lib/pleroma/web/api_spec/schemas/account_field.ex +++ b/lib/pleroma/web/api_spec/schemas/account_field.ex @@ -13,16 +13,14 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountField do type: :object, properties: %{ name: %Schema{type: :string}, - value: %Schema{type: :string}, - verified_at: %Schema{type: :string, format: "date-time", nullable: true} + value: %Schema{type: :string, format: :html}, + verified_at: %Schema{type: :string, format: :"date-time", nullable: true} }, example: %{ - "JSON" => %{ - "name" => "Website", - "value" => - "https://pleroma.com", - "verified_at" => "2019-08-29T04:14:55.571+00:00" - } + "name" => "Website", + "value" => + "https://pleroma.com", + "verified_at" => "2019-08-29T04:14:55.571+00:00" } }) end diff --git a/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex b/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex index fbbdf95f5..89e483655 100644 --- a/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex +++ b/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex @@ -17,10 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountAttributeField do }, required: [:name, :value], example: %{ - "JSON" => %{ - "name" => "Website", - "value" => "https://pleroma.com" - } + "name" => "Website", + "value" => "https://pleroma.com" } }) end diff --git a/lib/pleroma/web/api_spec/schemas/account_follows_request.ex b/lib/pleroma/web/api_spec/schemas/account_follows_request.ex index 4fbe615d6..19dce0cb2 100644 --- a/lib/pleroma/web/api_spec/schemas/account_follows_request.ex +++ b/lib/pleroma/web/api_spec/schemas/account_follows_request.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountFollowsRequest do description: "POST body for muting an account", type: :object, properties: %{ - uri: %Schema{type: :string} + uri: %Schema{type: :string, format: :uri} }, required: [:uri] }) diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex index 7db3b49bb..f2bd37d39 100644 --- a/lib/pleroma/web/api_spec/schemas/account_relationship.ex +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -26,20 +26,18 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do subscribing: %Schema{type: :boolean} }, example: %{ - "JSON" => %{ - "blocked_by" => false, - "blocking" => false, - "domain_blocking" => false, - "endorsed" => false, - "followed_by" => false, - "following" => false, - "id" => "9tKi3esbG7OQgZ2920", - "muting" => false, - "muting_notifications" => false, - "requested" => false, - "showing_reblogs" => true, - "subscribing" => false - } + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => false, + "id" => "9tKi3esbG7OQgZ2920", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false } }) 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 index 8a83abef3..7e92205cf 100644 --- a/lib/pleroma/web/api_spec/schemas/app_create_request.ex +++ b/lib/pleroma/web/api_spec/schemas/app_create_request.ex @@ -21,7 +21,11 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AppCreateRequest do 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"} + website: %Schema{ + type: :string, + description: "A URL to the homepage of your app", + format: :uri + } }, required: [:client_name, :redirect_uris], example: %{ diff --git a/lib/pleroma/web/api_spec/schemas/app_create_response.ex b/lib/pleroma/web/api_spec/schemas/app_create_response.ex index f290fb031..3c41d4ee5 100644 --- a/lib/pleroma/web/api_spec/schemas/app_create_response.ex +++ b/lib/pleroma/web/api_spec/schemas/app_create_response.ex @@ -16,9 +16,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AppCreateResponse do name: %Schema{type: :string}, client_id: %Schema{type: :string}, client_secret: %Schema{type: :string}, - redirect_uri: %Schema{type: :string}, + redirect_uri: %Schema{type: :string, format: :uri}, vapid_key: %Schema{type: :string}, - website: %Schema{type: :string, nullable: true} + website: %Schema{type: :string, nullable: true, format: :uri} }, example: %{ "id" => "123", diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex index 30fa7db93..f85fac2b8 100644 --- a/lib/pleroma/web/api_spec/schemas/list.ex +++ b/lib/pleroma/web/api_spec/schemas/list.ex @@ -16,10 +16,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.List do title: %Schema{type: :string} }, example: %{ - "JSON" => %{ - "id" => "123", - "title" => "my list" - } + "id" => "123", + "title" => "my list" } }) end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 486c3a0fe..a022450e6 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :object, properties: %{ name: %Schema{type: :string}, - website: %Schema{type: :string, nullable: true} + website: %Schema{type: :string, nullable: true, format: :uri} } }, bookmarked: %Schema{type: :boolean}, @@ -29,16 +29,16 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :object, nullable: true, properties: %{ - type: %Schema{type: :string}, - provider_name: %Schema{type: :string}, - provider_url: %Schema{type: :string}, - url: %Schema{type: :string}, - image: %Schema{type: :string}, + type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]}, + provider_name: %Schema{type: :string, nullable: true}, + provider_url: %Schema{type: :string, format: :uri}, + url: %Schema{type: :string, format: :uri}, + image: %Schema{type: :string, nullable: true, format: :uri}, title: %Schema{type: :string}, description: %Schema{type: :string} } }, - content: %Schema{type: :string}, + content: %Schema{type: :string, format: :html}, created_at: %Schema{type: :string, format: "date-time"}, emojis: %Schema{type: :array, items: AccountEmoji}, favourited: %Schema{type: :boolean}, @@ -53,10 +53,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :object, properties: %{ id: %Schema{type: :string}, - url: %Schema{type: :string}, - remote_url: %Schema{type: :string}, - preview_url: %Schema{type: :string}, - text_url: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + remote_url: %Schema{type: :string, format: :uri}, + preview_url: %Schema{type: :string, format: :uri}, + text_url: %Schema{type: :string, format: :uri}, description: %Schema{type: :string}, type: %Schema{type: :string, enum: ["image", "video", "audio", "unknown"]}, pleroma: %Schema{ @@ -74,7 +74,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do id: %Schema{type: :string}, acct: %Schema{type: :string}, username: %Schema{type: :string}, - url: %Schema{type: :string} + url: %Schema{type: :string, format: :uri} } } }, @@ -120,108 +120,106 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do type: :object, properties: %{ name: %Schema{type: :string}, - url: %Schema{type: :string} + url: %Schema{type: :string, format: :uri} } } }, - uri: %Schema{type: :string}, - url: %Schema{type: :string}, + uri: %Schema{type: :string, format: :uri}, + url: %Schema{type: :string, nullable: true, format: :uri}, visibility: VisibilityScope }, example: %{ - "JSON" => %{ - "account" => %{ - "acct" => "nick6", - "avatar" => "http://localhost:4001/images/avi.png", - "avatar_static" => "http://localhost:4001/images/avi.png", - "bot" => false, - "created_at" => "2020-04-07T19:48:51.000Z", - "display_name" => "Test テスト User 6", - "emojis" => [], - "fields" => [], - "followers_count" => 1, - "following_count" => 0, - "header" => "http://localhost:4001/images/banner.png", - "header_static" => "http://localhost:4001/images/banner.png", - "id" => "9toJCsKN7SmSf3aj5c", - "locked" => false, - "note" => "Tester Number 6", - "pleroma" => %{ - "background_image" => nil, - "confirmation_pending" => false, - "hide_favorites" => true, - "hide_followers" => false, - "hide_followers_count" => false, - "hide_follows" => false, - "hide_follows_count" => false, - "is_admin" => false, - "is_moderator" => false, - "relationship" => %{ - "blocked_by" => false, - "blocking" => false, - "domain_blocking" => false, - "endorsed" => false, - "followed_by" => false, - "following" => true, - "id" => "9toJCsKN7SmSf3aj5c", - "muting" => false, - "muting_notifications" => false, - "requested" => false, - "showing_reblogs" => true, - "subscribing" => false - }, - "skip_thread_containment" => false, - "tags" => [] - }, - "source" => %{ - "fields" => [], - "note" => "Tester Number 6", - "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, - "sensitive" => false - }, - "statuses_count" => 1, - "url" => "http://localhost:4001/users/nick6", - "username" => "nick6" - }, - "application" => %{"name" => "Web", "website" => nil}, - "bookmarked" => false, - "card" => nil, - "content" => "foobar", + "account" => %{ + "acct" => "nick6", + "avatar" => "http://localhost:4001/images/avi.png", + "avatar_static" => "http://localhost:4001/images/avi.png", + "bot" => false, "created_at" => "2020-04-07T19:48:51.000Z", + "display_name" => "Test テスト User 6", "emojis" => [], - "favourited" => false, - "favourites_count" => 0, - "id" => "9toJCu5YZW7O7gfvH6", - "in_reply_to_account_id" => nil, - "in_reply_to_id" => nil, - "language" => nil, - "media_attachments" => [], - "mentions" => [], - "muted" => false, - "pinned" => false, + "fields" => [], + "followers_count" => 1, + "following_count" => 0, + "header" => "http://localhost:4001/images/banner.png", + "header_static" => "http://localhost:4001/images/banner.png", + "id" => "9toJCsKN7SmSf3aj5c", + "locked" => false, + "note" => "Tester Number 6", "pleroma" => %{ - "content" => %{"text/plain" => "foobar"}, - "conversation_id" => 345_972, - "direct_conversation_id" => nil, - "emoji_reactions" => [], - "expires_at" => nil, - "in_reply_to_account_acct" => nil, - "local" => true, - "spoiler_text" => %{"text/plain" => ""}, - "thread_muted" => false + "background_image" => nil, + "confirmation_pending" => false, + "hide_favorites" => true, + "hide_followers" => false, + "hide_followers_count" => false, + "hide_follows" => false, + "hide_follows_count" => false, + "is_admin" => false, + "is_moderator" => false, + "relationship" => %{ + "blocked_by" => false, + "blocking" => false, + "domain_blocking" => false, + "endorsed" => false, + "followed_by" => false, + "following" => true, + "id" => "9toJCsKN7SmSf3aj5c", + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "showing_reblogs" => true, + "subscribing" => false + }, + "skip_thread_containment" => false, + "tags" => [] }, - "poll" => nil, - "reblog" => nil, - "reblogged" => false, - "reblogs_count" => 0, - "replies_count" => 0, - "sensitive" => false, - "spoiler_text" => "", - "tags" => [], - "uri" => "http://localhost:4001/objects/0f5dad44-0e9e-4610-b377-a2631e499190", - "url" => "http://localhost:4001/notice/9toJCu5YZW7O7gfvH6", - "visibility" => "private" - } + "source" => %{ + "fields" => [], + "note" => "Tester Number 6", + "pleroma" => %{"actor_type" => "Person", "discoverable" => false}, + "sensitive" => false + }, + "statuses_count" => 1, + "url" => "http://localhost:4001/users/nick6", + "username" => "nick6" + }, + "application" => %{"name" => "Web", "website" => nil}, + "bookmarked" => false, + "card" => nil, + "content" => "foobar", + "created_at" => "2020-04-07T19:48:51.000Z", + "emojis" => [], + "favourited" => false, + "favourites_count" => 0, + "id" => "9toJCu5YZW7O7gfvH6", + "in_reply_to_account_id" => nil, + "in_reply_to_id" => nil, + "language" => nil, + "media_attachments" => [], + "mentions" => [], + "muted" => false, + "pinned" => false, + "pleroma" => %{ + "content" => %{"text/plain" => "foobar"}, + "conversation_id" => 345_972, + "direct_conversation_id" => nil, + "emoji_reactions" => [], + "expires_at" => nil, + "in_reply_to_account_acct" => nil, + "local" => true, + "spoiler_text" => %{"text/plain" => ""}, + "thread_muted" => false + }, + "poll" => nil, + "reblog" => nil, + "reblogged" => false, + "reblogs_count" => 0, + "replies_count" => 0, + "sensitive" => false, + "spoiler_text" => "", + "tags" => [], + "uri" => "http://localhost:4001/objects/0f5dad44-0e9e-4610-b377-a2631e499190", + "url" => "http://localhost:4001/notice/9toJCu5YZW7O7gfvH6", + "visibility" => "private" } }) end -- cgit v1.2.3 From 11433cd38d9761ddf3fdb94f8c39526910b975c1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 21 Apr 2020 23:54:45 +0400 Subject: Add OpenAPI schema for FlakeID --- lib/pleroma/web/api_spec/schemas/account.ex | 22 ++++------------------ .../web/api_spec/schemas/account_relationship.ex | 3 ++- lib/pleroma/web/api_spec/schemas/flake_id.ex | 14 ++++++++++++++ lib/pleroma/web/api_spec/schemas/poll.ex | 3 ++- lib/pleroma/web/api_spec/schemas/status.ex | 3 ++- 5 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/flake_id.ex diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 3634a7c76..f57015254 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -6,7 +6,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji alias Pleroma.Web.ApiSpec.Schemas.AccountField + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope require OpenApiSpex @@ -29,7 +31,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do following_count: %Schema{type: :integer}, header_static: %Schema{type: :string, format: :uri}, header: %Schema{type: :string, format: :uri}, - id: %Schema{type: :string}, + id: FlakeID, locked: %Schema{type: :boolean}, note: %Schema{type: :string, format: :html}, statuses_count: %Schema{type: :integer}, @@ -62,23 +64,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do privacy_option: %Schema{type: :boolean} } }, - relationship: %Schema{ - type: :object, - properties: %{ - blocked_by: %Schema{type: :boolean}, - blocking: %Schema{type: :boolean}, - domain_blocking: %Schema{type: :boolean}, - endorsed: %Schema{type: :boolean}, - followed_by: %Schema{type: :boolean}, - following: %Schema{type: :boolean}, - id: %Schema{type: :string}, - muting: %Schema{type: :boolean}, - muting_notifications: %Schema{type: :boolean}, - requested: %Schema{type: :boolean}, - showing_reblogs: %Schema{type: :boolean}, - subscribing: %Schema{type: :boolean} - } - }, + relationship: AccountRelationship, settings_store: %Schema{ type: :object } diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex index f2bd37d39..8b982669e 100644 --- a/lib/pleroma/web/api_spec/schemas/account_relationship.ex +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.FlakeID require OpenApiSpex @@ -18,7 +19,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do endorsed: %Schema{type: :boolean}, followed_by: %Schema{type: :boolean}, following: %Schema{type: :boolean}, - id: %Schema{type: :string}, + id: FlakeID, muting: %Schema{type: :boolean}, muting_notifications: %Schema{type: :boolean}, requested: %Schema{type: :boolean}, diff --git a/lib/pleroma/web/api_spec/schemas/flake_id.ex b/lib/pleroma/web/api_spec/schemas/flake_id.ex new file mode 100644 index 000000000..b8e03b8a1 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/flake_id.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.FlakeID do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "FlakeID", + description: + "Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are sortable strings", + type: :string + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex index 2a9975f85..5fc9e889f 100644 --- a/lib/pleroma/web/api_spec/schemas/poll.ex +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID require OpenApiSpex @@ -13,7 +14,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do description: "Response schema for account custom fields", type: :object, properties: %{ - id: %Schema{type: :string}, + id: FlakeID, expires_at: %Schema{type: :string, format: "date-time"}, expired: %Schema{type: :boolean}, multiple: %Schema{type: :boolean}, diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index a022450e6..bf5f04691 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.Poll alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope @@ -43,7 +44,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do emojis: %Schema{type: :array, items: AccountEmoji}, favourited: %Schema{type: :boolean}, favourites_count: %Schema{type: :integer}, - id: %Schema{type: :string}, + id: FlakeID, in_reply_to_account_id: %Schema{type: :string, nullable: true}, in_reply_to_id: %Schema{type: :string, nullable: true}, language: %Schema{type: :string, nullable: true}, -- cgit v1.2.3 From 2e62a63749e040b108b8afe2c8839c470f89fa04 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 12:48:52 +0200 Subject: ChatMessageValidator: Validation changes Don't validate if the recipient is blocking the actor. --- .../activity_pub/object_validators/chat_message_validator.ex | 12 +++++++++++- test/web/activity_pub/object_validator_test.exs | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 2feb65f29..8b5bb4fdc 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -61,15 +61,25 @@ def validate_data(data_cng) do |> validate_local_concern() end - @doc "Validates if at least one of the users in this ChatMessage is a local user, otherwise we don't want the message in our system. It also validates the presence of both users in our system." + @doc """ + Validates the following + - If both users are in our system + - If at least one of the users in this ChatMessage is a local user + - If the recipient is not blocking the actor + """ def validate_local_concern(cng) do with actor_ap <- get_field(cng, :actor), {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, {_, %User{} = recipient} <- {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, + {_, false} <- {:blocking_actor?, User.blocks?(recipient, actor)}, {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do cng else + {:blocking_actor?, true} -> + cng + |> add_error(:actor, "actor is blocked by recipient") + {:local?, false} -> cng |> add_error(:actor, "actor and recipient are both remote") diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 8230ae0d9..bc2317e55 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -33,6 +33,15 @@ test "does not validate if the message is longer than the remote_limit", %{ refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) end + test "does not validate if the recipient is blocking the actor", %{ + valid_chat_message: valid_chat_message, + user: user, + recipient: recipient + } do + Pleroma.User.block(recipient, user) + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + test "does not validate if the actor or the recipient is not in our system", %{ valid_chat_message: valid_chat_message } do -- cgit v1.2.3 From 1d6338f2d38284e94e17be58c21c7f34b5621ab7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 12:52:39 +0200 Subject: Litepub: Add ChatMessage. --- priv/static/schemas/litepub-0.1.jsonld | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 278ad2f96..7cc3fee40 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -30,6 +30,7 @@ "@type": "@id" }, "EmojiReact": "litepub:EmojiReact", + "ChatMessage": "litepub:ChatMessage", "alsoKnownAs": { "@id": "as:alsoKnownAs", "@type": "@id" -- cgit v1.2.3 From f719a5b23a9bec4ed94f36c07e24aa1413654bae Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 13:28:34 +0200 Subject: WebPush: Return proper values for jobs. --- lib/pleroma/web/push/impl.ex | 3 ++- test/web/push/impl_test.exs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index f1740a6e0..a9f893f7b 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -55,11 +55,12 @@ def perform( |> Jason.encode!() |> push_message(build_sub(subscription), gcm_api_key, subscription) end + |> (&{:ok, &1}).() end def perform(_) do Logger.warn("Unknown notification type") - :error + {:error, :unknown_type} end @doc "Push message to web" diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 9121d90e7..b2664bf28 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -63,12 +63,12 @@ test "performs sending notifications" do activity: activity ) - assert Impl.perform(notif) == [:ok, :ok] + assert Impl.perform(notif) == {:ok, [:ok, :ok]} end @tag capture_log: true test "returns error if notif does not match " do - assert Impl.perform(%{}) == :error + assert Impl.perform(%{}) == {:error, :unknown_type} end test "successful message sending" do -- cgit v1.2.3 From 923513b6417973f700a80ee969c6c92ed2c9faee Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 13:28:52 +0200 Subject: Federator: Return proper values for jobs --- lib/pleroma/web/federator/federator.ex | 13 +++++++++---- test/web/federator_test.exs | 7 +++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/federator/federator.ex b/lib/pleroma/web/federator/federator.ex index fd904ef0a..f5803578d 100644 --- a/lib/pleroma/web/federator/federator.ex +++ b/lib/pleroma/web/federator/federator.ex @@ -72,19 +72,24 @@ def perform(:incoming_ap_doc, params) do # actor shouldn't be acting on objects outside their own AP server. with {:ok, _user} <- ap_enabled_actor(params["actor"]), nil <- Activity.normalize(params["id"]), - :ok <- Containment.contain_origin_from_id(params["actor"], params), + {_, :ok} <- + {:correct_origin?, Containment.contain_origin_from_id(params["actor"], params)}, {:ok, activity} <- Transmogrifier.handle_incoming(params) do {:ok, activity} else + {:correct_origin?, _} -> + Logger.debug("Origin containment failure for #{params["id"]}") + {:error, :origin_containment_failed} + %Activity{} -> Logger.debug("Already had #{params["id"]}") - :error + {:error, :already_present} - _e -> + e -> # Just drop those for now Logger.debug("Unhandled activity") Logger.debug(Jason.encode!(params, pretty: true)) - :error + {:error, e} end end diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index 59e53bb03..261518ef0 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -130,6 +130,9 @@ test "successfully processes incoming AP docs with correct origin" do assert {:ok, job} = Federator.incoming_ap_doc(params) assert {:ok, _activity} = ObanHelpers.perform(job) + + assert {:ok, job} = Federator.incoming_ap_doc(params) + assert {:error, :already_present} = ObanHelpers.perform(job) end test "rejects incoming AP docs with incorrect origin" do @@ -148,7 +151,7 @@ test "rejects incoming AP docs with incorrect origin" do } assert {:ok, job} = Federator.incoming_ap_doc(params) - assert :error = ObanHelpers.perform(job) + assert {:error, :origin_containment_failed} = ObanHelpers.perform(job) end test "it does not crash if MRF rejects the post" do @@ -164,7 +167,7 @@ test "it does not crash if MRF rejects the post" do |> Poison.decode!() assert {:ok, job} = Federator.incoming_ap_doc(params) - assert :error = ObanHelpers.perform(job) + assert {:error, _} = ObanHelpers.perform(job) end end end -- cgit v1.2.3 From 5102468d0f8067548ef233b5947da4bc517f5774 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 14:06:39 +0200 Subject: Polls: Persist and show voters' count --- lib/pleroma/object.ex | 5 ++++- lib/pleroma/web/activity_pub/activity_pub.ex | 5 +++-- lib/pleroma/web/mastodon_api/views/poll_view.ex | 7 +++++++ test/web/mastodon_api/views/poll_view_test.exs | 16 ++++++++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 9574432f0..e678fd415 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -261,7 +261,7 @@ def decrease_replies_count(ap_id) do end end - def increase_vote_count(ap_id, name) do + def increase_vote_count(ap_id, name, actor) do with %Object{} = object <- Object.normalize(ap_id), "Question" <- object.data["type"] do multiple = Map.has_key?(object.data, "anyOf") @@ -276,12 +276,15 @@ def increase_vote_count(ap_id, name) do option end) + voters = [actor | object.data["voters"] || []] |> Enum.uniq() + data = if multiple do Map.put(object.data, "anyOf", options) else Map.put(object.data, "oneOf", options) end + |> Map.put("voters", voters) object |> Object.change(%{data: data}) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index eedea08a2..4a133498e 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -118,9 +118,10 @@ def decrease_replies_count_if_reply(_object), do: :noop def increase_poll_votes_if_vote(%{ "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, - "type" => "Create" + "type" => "Create", + "actor" => actor }) do - Object.increase_vote_count(reply_ap_id, name) + Object.increase_vote_count(reply_ap_id, name, actor) end def increase_poll_votes_if_vote(_create_data), do: :noop diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 40edbb213..59a5deb28 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -19,6 +19,7 @@ def render("show.json", %{object: object, multiple: multiple, options: options} expired: expired, multiple: multiple, votes_count: votes_count, + voters_count: (multiple || nil) && voters_count(object), options: options, voted: voted?(params), emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]) @@ -62,6 +63,12 @@ defp options_and_votes_count(options) do end) end + defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do + length(voters) + end + + defp voters_count(_), do: 0 + defp voted?(%{object: object} = opts) do if opts[:for] do existing_votes = Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) diff --git a/test/web/mastodon_api/views/poll_view_test.exs b/test/web/mastodon_api/views/poll_view_test.exs index 6211fa888..63b204387 100644 --- a/test/web/mastodon_api/views/poll_view_test.exs +++ b/test/web/mastodon_api/views/poll_view_test.exs @@ -43,7 +43,8 @@ test "renders a poll" do %{title: "why are you even asking?", votes_count: 0} ], voted: false, - votes_count: 0 + votes_count: 0, + voters_count: nil } result = PollView.render("show.json", %{object: object}) @@ -69,9 +70,20 @@ test "detects if it is multiple choice" do } }) + voter = insert(:user) + object = Object.normalize(activity) - assert %{multiple: true} = PollView.render("show.json", %{object: object}) + {:ok, _votes, object} = CommonAPI.vote(voter, object, [0, 1]) + + assert match?( + %{ + multiple: true, + voters_count: 1, + votes_count: 2 + }, + PollView.render("show.json", %{object: object}) + ) end test "detects emoji" do -- cgit v1.2.3 From 568f48435e7db4582c54a443b2d543cc004f7f9b Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 12:10:20 +0000 Subject: Apply suggestion to docs/installation/debian_based_en.md --- docs/installation/debian_based_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index a900ec61d..62d8733f7 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -7,7 +7,7 @@ This guide will assume you are on Debian Stretch. This guide should also work wi * `postgresql` (9.6+, Ubuntu 16.04 comes with 9.5, you can get a newer version from [here](https://www.postgresql.org/download/linux/ubuntu/)) * `postgresql-contrib` (9.6+, same situtation as above) -* `elixir` (1.8+, [install from here, Debian and Ubuntu ship older versions](https://elixir-lang.org/install.html#unix-and-unix-like) or use [asdf](https://github.com/asdf-vm/asdf) as the pleroma user) +* `elixir` (1.8+, Follow the guide to install from the Erlang Solutions repo or use [asdf](https://github.com/asdf-vm/asdf) as the pleroma user) * `erlang-dev` * `erlang-nox` * `git` -- cgit v1.2.3 From c10485db163d56acd7206980f91f0e51153ef36a Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 14:26:19 +0200 Subject: StatusController: Ignore nil scheduled_at parameters. --- lib/pleroma/web/mastodon_api/controllers/status_controller.ex | 3 ++- test/web/mastodon_api/controllers/status_controller_test.exs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 397dd10e3..f6e4f7d66 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -127,7 +127,8 @@ def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = params) do def create( %{assigns: %{user: user}} = conn, %{"status" => _, "scheduled_at" => scheduled_at} = params - ) do + ) + when not is_nil(scheduled_at) do params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) with {:far_enough, true} <- {:far_enough, ScheduledActivity.far_enough?(scheduled_at)}, diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 162f7b1b2..85068edd0 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -302,6 +302,17 @@ test "creates a scheduled activity", %{conn: conn} do assert [] == Repo.all(Activity) end + test "ignores nil values", %{conn: conn} do + conn = + post(conn, "/api/v1/statuses", %{ + "status" => "not scheduled", + "scheduled_at" => nil + }) + + assert result = json_response(conn, 200) + assert Activity.get_by_id(result["id"]) + end + test "creates a scheduled activity with a media attachment", %{user: user, conn: conn} do scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) -- cgit v1.2.3 From 5b3952619818d38f8fdba9a64b050ce3f24394ff Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 15:04:26 +0200 Subject: AccountController: Use code 400 for self-follow. --- .../mastodon_api/controllers/account_controller.ex | 21 +++++++++++---------- .../controllers/account_controller_test.exs | 6 +++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index e8e59ac66..5a92cebd8 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -293,7 +293,7 @@ def lists(%{assigns: %{user: user, account: account}} = conn, _params) do @doc "POST /api/v1/accounts/:id/follow" def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do - {:error, :not_found} + {:error, "Can not follow yourself"} end def follow(%{assigns: %{user: follower, account: followed}} = conn, _params) do @@ -306,7 +306,7 @@ def follow(%{assigns: %{user: follower, account: followed}} = conn, _params) do @doc "POST /api/v1/accounts/:id/unfollow" def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do - {:error, :not_found} + {:error, "Can not unfollow yourself"} end def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do @@ -356,14 +356,15 @@ def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do end @doc "POST /api/v1/follows" - def follows(%{assigns: %{user: follower}} = conn, %{"uri" => uri}) do - with {_, %User{} = followed} <- {:followed, User.get_cached_by_nickname(uri)}, - {_, true} <- {:followed, follower.id != followed.id}, - {:ok, follower, followed, _} <- CommonAPI.follow(follower, followed) do - render(conn, "show.json", user: followed, for: follower) - else - {:followed, _} -> {:error, :not_found} - {:error, message} -> json_response(conn, :forbidden, %{error: message}) + def follows(conn, %{"uri" => uri}) do + case User.get_cached_by_nickname(uri) do + %User{} = user -> + conn + |> assign(:account, user) + |> follow(%{}) + + nil -> + {:error, :not_found} 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 61c2697b2..8c428efee 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -681,17 +681,17 @@ test "following without reblogs" do test "following / unfollowing errors", %{user: user, conn: conn} do # self follow conn_res = post(conn, "/api/v1/accounts/#{user.id}/follow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) + assert %{"error" => "Can not follow yourself"} = json_response(conn_res, 400) # self unfollow user = User.get_cached_by_id(user.id) conn_res = post(conn, "/api/v1/accounts/#{user.id}/unfollow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) + assert %{"error" => "Can not unfollow yourself"} = json_response(conn_res, 400) # self follow via uri user = User.get_cached_by_id(user.id) conn_res = post(conn, "/api/v1/follows", %{"uri" => user.nickname}) - assert %{"error" => "Record not found"} = json_response(conn_res, 404) + assert %{"error" => "Can not follow yourself"} = json_response(conn_res, 400) # follow non existing user conn_res = post(conn, "/api/v1/accounts/doesntexist/follow") -- cgit v1.2.3 From 8b88e2a6e2b3a777ca99bf94676ab47f2d4cc0ea Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 15:31:37 +0200 Subject: Stats: Ignore internal users for user count. --- lib/pleroma/stats.ex | 19 ++++++++---- test/stat_test.exs | 70 --------------------------------------------- test/stats_test.exs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 75 deletions(-) delete mode 100644 test/stat_test.exs create mode 100644 test/stats_test.exs diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 4446562ac..6763786a7 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -45,11 +45,11 @@ def get_peers do end def init(_args) do - {:ok, get_stat_data()} + {:ok, calculate_stat_data()} end def handle_call(:force_update, _from, _state) do - new_stats = get_stat_data() + new_stats = calculate_stat_data() {:reply, new_stats, new_stats} end @@ -58,12 +58,12 @@ def handle_call(:get_state, _from, state) do end def handle_cast(:run_update, _state) do - new_stats = get_stat_data() + new_stats = calculate_stat_data() {:noreply, new_stats} end - defp get_stat_data do + def calculate_stat_data do peers = from( u in User, @@ -77,7 +77,16 @@ defp get_stat_data do status_count = Repo.aggregate(User.Query.build(%{local: true}), :sum, :note_count) - user_count = Repo.aggregate(User.Query.build(%{local: true, active: true}), :count, :id) + users_query = + from(u in User, + where: u.deactivated != true, + where: u.local == true, + where: not is_nil(u.nickname), + where: fragment("? not like 'internal.%'", u.nickname), + where: fragment("? not like '%/relay'", u.ap_id) + ) + + user_count = Repo.aggregate(users_query, :count, :id) %{ peers: peers, diff --git a/test/stat_test.exs b/test/stat_test.exs deleted file mode 100644 index bccc1c8d0..000000000 --- a/test/stat_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.StateTest do - use Pleroma.DataCase - import Pleroma.Factory - alias Pleroma.Web.CommonAPI - - describe "status visibility count" do - test "on new status" do - user = insert(:user) - other_user = insert(:user) - - CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - - Enum.each(0..1, fn _ -> - CommonAPI.post(user, %{ - "visibility" => "unlisted", - "status" => "hey" - }) - end) - - Enum.each(0..2, fn _ -> - CommonAPI.post(user, %{ - "visibility" => "direct", - "status" => "hey @#{other_user.nickname}" - }) - end) - - Enum.each(0..3, fn _ -> - CommonAPI.post(user, %{ - "visibility" => "private", - "status" => "hey" - }) - end) - - assert %{direct: 3, private: 4, public: 1, unlisted: 2} = - Pleroma.Stats.get_status_visibility_count() - end - - test "on status delete" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - assert %{public: 1} = Pleroma.Stats.get_status_visibility_count() - CommonAPI.delete(activity.id, user) - assert %{public: 0} = Pleroma.Stats.get_status_visibility_count() - end - - test "on status visibility update" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - assert %{public: 1, private: 0} = Pleroma.Stats.get_status_visibility_count() - {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{"visibility" => "private"}) - assert %{public: 0, private: 1} = Pleroma.Stats.get_status_visibility_count() - end - - test "doesn't count unrelated activities" do - user = insert(:user) - other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - _ = CommonAPI.follow(user, other_user) - CommonAPI.favorite(other_user, activity.id) - CommonAPI.repeat(activity.id, other_user) - - assert %{direct: 0, private: 0, public: 1, unlisted: 0} = - Pleroma.Stats.get_status_visibility_count() - end - end -end diff --git a/test/stats_test.exs b/test/stats_test.exs new file mode 100644 index 000000000..73c7c1495 --- /dev/null +++ b/test/stats_test.exs @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.StatsTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.CommonAPI + + describe "user count" do + test "it ignores internal users" do + _user = insert(:user, local: true) + _internal = insert(:user, local: true, nickname: nil) + _internal = insert(:user, local: true, nickname: "internal.dude") + _internal = Pleroma.Web.ActivityPub.Relay.get_actor() + + assert match?(%{stats: %{user_count: 1}}, Pleroma.Stats.calculate_stat_data()) + end + end + + describe "status visibility count" do + test "on new status" do + user = insert(:user) + other_user = insert(:user) + + CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + + Enum.each(0..1, fn _ -> + CommonAPI.post(user, %{ + "visibility" => "unlisted", + "status" => "hey" + }) + end) + + Enum.each(0..2, fn _ -> + CommonAPI.post(user, %{ + "visibility" => "direct", + "status" => "hey @#{other_user.nickname}" + }) + end) + + Enum.each(0..3, fn _ -> + CommonAPI.post(user, %{ + "visibility" => "private", + "status" => "hey" + }) + end) + + assert %{direct: 3, private: 4, public: 1, unlisted: 2} = + Pleroma.Stats.get_status_visibility_count() + end + + test "on status delete" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + assert %{public: 1} = Pleroma.Stats.get_status_visibility_count() + CommonAPI.delete(activity.id, user) + assert %{public: 0} = Pleroma.Stats.get_status_visibility_count() + end + + test "on status visibility update" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + assert %{public: 1, private: 0} = Pleroma.Stats.get_status_visibility_count() + {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{"visibility" => "private"}) + assert %{public: 0, private: 1} = Pleroma.Stats.get_status_visibility_count() + end + + test "doesn't count unrelated activities" do + user = insert(:user) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + _ = CommonAPI.follow(user, other_user) + CommonAPI.favorite(other_user, activity.id) + CommonAPI.repeat(activity.id, other_user) + + assert %{direct: 0, private: 0, public: 1, unlisted: 0} = + Pleroma.Stats.get_status_visibility_count() + end + end +end -- cgit v1.2.3 From 452072ec95781214df262962b71c60b7d771b7b1 Mon Sep 17 00:00:00 2001 From: Karol Kosek Date: Wed, 22 Apr 2020 16:02:40 +0200 Subject: static_fe: Add microformats2 classes --- .../web/templates/static_fe/static_fe/_attachment.html.eex | 6 +++--- lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex | 10 +++++++--- .../web/templates/static_fe/static_fe/_user_card.html.eex | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex index 7e04e9550..4853e7f4b 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/_attachment.html.eex @@ -1,8 +1,8 @@ <%= case @mediaType do %> <% "audio" -> %> - + <% "video" -> %> - + <% _ -> %> -<%= @name %> +<%= @name %> <% end %> diff --git a/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex index df5e5eedd..df0244795 100644 --- a/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex +++ b/lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex @@ -1,12 +1,16 @@ -
    id="selected" <% end %>> +
    id="selected" <% end %>>

    - <%= link format_date(@published), to: @link, class: "activity-link" %> + + +

    <%= render("_user_card.html", %{user: @user}) %>
    <%= if @title != "" do %>
    open<% end %>> - <%= raw @title %> + <%= raw @title %>
    <%= raw @content %>
    <% else %> 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 56f3a1524..977b894d3 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,10 +1,10 @@
    - +
    - <%= raw Formatter.emojify(@user.name, @user.emoji) %> + <%= raw Formatter.emojify(@user.name, @user.emoji) %> <%= @user.nickname %>
    -- cgit v1.2.3 From 7a3a88a13ef526fba18bb6aeadc93f5da934dc5b Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 17:21:13 +0200 Subject: Streamer: Stream boosts to the boosting user. --- lib/pleroma/user.ex | 4 +++- lib/pleroma/web/streamer/worker.ex | 18 ------------------ test/user_test.exs | 12 ++++++++++++ test/web/streamer/streamer_test.exs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index bef4679cb..477237756 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1180,7 +1180,9 @@ def get_users_from_set(ap_ids, local_only \\ true) do end @spec get_recipients_from_activity(Activity.t()) :: [User.t()] - def get_recipients_from_activity(%Activity{recipients: to}) do + def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do + to = [actor | to] + User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false}) |> Repo.all() end diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex index abfed21c8..f6160fa4d 100644 --- a/lib/pleroma/web/streamer/worker.ex +++ b/lib/pleroma/web/streamer/worker.ex @@ -158,24 +158,6 @@ defp should_send?(%User{} = user, %Notification{activity: activity}) do should_send?(user, activity) end - def push_to_socket(topics, topic, %Activity{data: %{"type" => "Announce"}} = item) do - Enum.each(topics[topic] || [], fn %StreamerSocket{ - transport_pid: transport_pid, - user: socket_user - } -> - # Get the current user so we have up-to-date blocks etc. - if socket_user do - user = User.get_cached_by_ap_id(socket_user.ap_id) - - if should_send?(user, item) do - send(transport_pid, {:text, StreamerView.render("update.json", item, user)}) - end - else - send(transport_pid, {:text, StreamerView.render("update.json", item)}) - end - end) - end - def push_to_socket(topics, topic, %Participation{} = participation) do Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> send(transport_pid, {:text, StreamerView.render("conversation.json", participation)}) diff --git a/test/user_test.exs b/test/user_test.exs index 65e118d6d..cd4041673 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -987,6 +987,18 @@ test "it imports user blocks from list" do end describe "get_recipients_from_activity" do + test "works for announces" do + actor = insert(:user) + user = insert(:user, local: true) + + {:ok, activity} = CommonAPI.post(actor, %{"status" => "hello"}) + {:ok, announce, _} = CommonAPI.repeat(activity.id, user) + + recipients = User.get_recipients_from_activity(announce) + + assert user in recipients + end + test "get recipients" do actor = insert(:user) user = insert(:user, local: true) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index eb082b79f..8b8d8af6c 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -28,6 +28,42 @@ defmodule Pleroma.Web.StreamerTest do {:ok, %{user: user, notify: notify}} end + test "it streams the user's post in the 'user' stream", %{user: user} do + task = + Task.async(fn -> + assert_receive {:text, _}, @streamer_timeout + end) + + Streamer.add_socket( + "user", + %{transport_pid: task.pid, assigns: %{user: user}} + ) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + + Streamer.stream("user", activity) + Task.await(task) + end + + test "it streams boosts of the user in the 'user' stream", %{user: user} do + task = + Task.async(fn -> + assert_receive {:text, _}, @streamer_timeout + end) + + Streamer.add_socket( + "user", + %{transport_pid: task.pid, assigns: %{user: user}} + ) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) + {:ok, announce, _} = CommonAPI.repeat(activity.id, user) + + Streamer.stream("user", announce) + Task.await(task) + end + test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do task = Task.async(fn -> -- cgit v1.2.3 From 88b82e5c3edae649f1caa45c6ef805828e4b8b1e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 21 Apr 2020 20:05:25 +0400 Subject: Fix follow import --- .../web/twitter_api/controllers/util_controller.ex | 25 +++++++++++----------- test/web/twitter_api/util_controller_test.exs | 24 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 537f9f778..824951d59 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -199,15 +199,16 @@ def follow_import(conn, %{"list" => %Plug.Upload{} = listfile}) do end def follow_import(%{assigns: %{user: follower}} = conn, %{"list" => list}) do - with lines <- String.split(list, "\n"), - followed_identifiers <- - Enum.map(lines, fn line -> - String.split(line, ",") |> List.first() - end) - |> List.delete("Account address") do - User.follow_import(follower, followed_identifiers) - json(conn, "job started") - end + followed_identifiers = + list + |> String.split("\n") + |> Enum.map(&(&1 |> String.split(",") |> List.first())) + |> List.delete("Account address") + |> Enum.map(&(&1 |> String.trim() |> String.trim_leading("@"))) + |> Enum.reject(&(&1 == "")) + + User.follow_import(follower, followed_identifiers) + json(conn, "job started") end def blocks_import(conn, %{"list" => %Plug.Upload{} = listfile}) do @@ -215,10 +216,8 @@ def blocks_import(conn, %{"list" => %Plug.Upload{} = listfile}) do end def blocks_import(%{assigns: %{user: blocker}} = conn, %{"list" => list}) do - with blocked_identifiers <- String.split(list) do - User.blocks_import(blocker, blocked_identifiers) - json(conn, "job started") - end + User.blocks_import(blocker, _blocked_identifiers = String.split(list)) + json(conn, "job started") end def change_password(%{assigns: %{user: user}} = conn, params) do diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 30e54bebd..85aaab19b 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -95,6 +95,30 @@ test "requires 'follow' or 'write:follows' permissions" do end end end + + test "it imports with different nickname variations", %{conn: conn} do + [user2, user3, user4, user5, user6] = insert_list(5, :user) + + identifiers = + [ + user2.ap_id, + user3.nickname, + " ", + "@" <> user4.nickname, + user5.nickname <> "@localhost", + "@" <> user6.nickname <> "@localhost" + ] + |> Enum.join("\n") + + response = + conn + |> post("/api/pleroma/follow_import", %{"list" => identifiers}) + |> json_response(:ok) + + assert response == "job started" + assert [job_result] = ObanHelpers.perform_all() + assert job_result == [user2, user3, user4, user5, user6] + end end describe "POST /api/pleroma/blocks_import" do -- cgit v1.2.3 From e7771424a895572626ec36a98bf862952dc6ff07 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 22 Apr 2020 18:13:13 +0400 Subject: Fix blocks import --- .../web/twitter_api/controllers/util_controller.ex | 3 ++- test/web/twitter_api/util_controller_test.exs | 25 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 824951d59..d5d5ce08f 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -216,7 +216,8 @@ def blocks_import(conn, %{"list" => %Plug.Upload{} = listfile}) do end def blocks_import(%{assigns: %{user: blocker}} = conn, %{"list" => list}) do - User.blocks_import(blocker, _blocked_identifiers = String.split(list)) + blocked_identifiers = list |> String.split() |> Enum.map(&String.trim_leading(&1, "@")) + User.blocks_import(blocker, blocked_identifiers) json(conn, "job started") end diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 85aaab19b..d835331ae 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -96,7 +96,7 @@ test "requires 'follow' or 'write:follows' permissions" do end end - test "it imports with different nickname variations", %{conn: conn} do + test "it imports follows with different nickname variations", %{conn: conn} do [user2, user3, user4, user5, user6] = insert_list(5, :user) identifiers = @@ -160,6 +160,29 @@ test "it imports blocks users from file", %{user: user1, conn: conn} do ) end end + + test "it imports blocks with different nickname variations", %{conn: conn} do + [user2, user3, user4, user5, user6] = insert_list(5, :user) + + identifiers = + [ + user2.ap_id, + user3.nickname, + "@" <> user4.nickname, + user5.nickname <> "@localhost", + "@" <> user6.nickname <> "@localhost" + ] + |> Enum.join(" ") + + response = + conn + |> post("/api/pleroma/blocks_import", %{"list" => identifiers}) + |> json_response(:ok) + + assert response == "job started" + assert [job_result] = ObanHelpers.perform_all() + assert job_result == [user2, user3, user4, user5, user6] + end end describe "PUT /api/pleroma/notification_settings" do -- cgit v1.2.3 From 6db52c3b3680efbdb56d53d84d5dec0f4b6e34f0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 22 Apr 2020 19:00:08 +0400 Subject: Fix Oban warning Warning example: [warn] Expected Elixir.Pleroma.Workers.BackgroundWorker.perform/2 to return :ok, {:ok, value}, or {:error, reason}. Instead received: [error: "not found @user@server.party", error: "not found "] The job will be considered a success. --- lib/pleroma/workers/background_worker.ex | 4 ++-- test/user_test.exs | 4 ++-- test/web/twitter_api/util_controller_test.exs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 0f8ece2c4..57c3a9c3a 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -35,7 +35,7 @@ def perform( _job ) do blocker = User.get_cached_by_id(blocker_id) - User.perform(:blocks_import, blocker, blocked_identifiers) + {:ok, User.perform(:blocks_import, blocker, blocked_identifiers)} end def perform( @@ -47,7 +47,7 @@ def perform( _job ) do follower = User.get_cached_by_id(follower_id) - User.perform(:follow_import, follower, followed_identifiers) + {:ok, User.perform(:follow_import, follower, followed_identifiers)} end def perform(%{"op" => "media_proxy_preload", "message" => message}, _job) do diff --git a/test/user_test.exs b/test/user_test.exs index 65e118d6d..23e7cf6e3 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -756,8 +756,8 @@ test "it imports user followings from list" do ] {:ok, job} = User.follow_import(user1, identifiers) - result = ObanHelpers.perform(job) + assert {:ok, result} = ObanHelpers.perform(job) assert is_list(result) assert result == [user2, user3] end @@ -979,8 +979,8 @@ test "it imports user blocks from list" do ] {:ok, job} = User.blocks_import(user1, identifiers) - result = ObanHelpers.perform(job) + assert {:ok, result} = ObanHelpers.perform(job) assert is_list(result) assert result == [user2, user3] end diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index d835331ae..b701239a0 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -116,7 +116,7 @@ test "it imports follows with different nickname variations", %{conn: conn} do |> json_response(:ok) assert response == "job started" - assert [job_result] = ObanHelpers.perform_all() + assert [{:ok, job_result}] = ObanHelpers.perform_all() assert job_result == [user2, user3, user4, user5, user6] end end @@ -180,7 +180,7 @@ test "it imports blocks with different nickname variations", %{conn: conn} do |> json_response(:ok) assert response == "job started" - assert [job_result] = ObanHelpers.perform_all() + assert [{:ok, job_result}] = ObanHelpers.perform_all() assert job_result == [user2, user3, user4, user5, user6] end end -- cgit v1.2.3 From 771c1ad735eb06842278e823a29acb94fd9acafb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 22 Apr 2020 19:31:03 +0400 Subject: Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8e7efc3..702c58180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Support pagination in conversations API - **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again +- Fix follower/blocks import when nicknames starts with @ ## [unreleased-patch] ### Fixed -- cgit v1.2.3 From b03aeae8b935b0a211c51cc3be5f1c15591f5a2f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 22 Apr 2020 15:31:41 +0000 Subject: Apply suggestion to lib/pleroma/notification.ex --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d305a43ba..aaa675253 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -362,7 +362,7 @@ 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 who domain-block and not follow activity actor" + @doc "Filters out AP IDs domain-blocking and not following the activity's actor" def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: [] -- cgit v1.2.3 From 2958a7d246f40141a88bcb7bdd6a477c4f65f0bc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 22 Apr 2020 18:50:25 +0300 Subject: Fixed OAuth restrictions for :api routes. Made auth info dropped for :api routes if OAuth check was neither performed nor explicitly skipped. --- docs/dev.md | 30 +++++++------------ lib/pleroma/plugs/oauth_scopes_plug.ex | 13 +++++++-- .../mastodon_api/controllers/account_controller.ex | 4 +-- .../web/mastodon_api/controllers/app_controller.ex | 8 +++++ .../controllers/custom_emoji_controller.ex | 6 ++++ .../controllers/instance_controller.ex | 6 ++++ .../controllers/mastodon_api_controller.ex | 6 +++- .../mastodon_api/controllers/search_controller.ex | 2 ++ .../controllers/timeline_controller.ex | 17 +++++++---- .../controllers/pleroma_api_controller.ex | 6 ++++ .../pleroma_api/controllers/scrobble_controller.ex | 6 +++- lib/pleroma/web/router.ex | 34 ++++++++++------------ .../web/twitter_api/twitter_api_controller.ex | 6 ++-- lib/pleroma/web/web.ex | 14 ++++++++- 14 files changed, 103 insertions(+), 55 deletions(-) diff --git a/docs/dev.md b/docs/dev.md index 0ecf43a9e..f1b4cbf8b 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -4,29 +4,19 @@ This document contains notes and guidelines for Pleroma developers. ## OAuth token-based authentication & authorization -* Pleroma supports hierarchical OAuth scopes, just like Mastodon but with added granularity of admin scopes. - For a reference, see [Mastodon OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/). - -* It is important to either define OAuth scope restrictions or explicitly mark OAuth scope check as skipped, for every - controller action. To define scopes, call `plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: [...]})`. To explicitly set - OAuth scopes check skipped, call `plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug )`. - -* In controllers, `use Pleroma.Web, :controller` will result in `action/2` (see `Pleroma.Web.controller/0` for definition) - be called prior to actual controller action, and it'll perform security / privacy checks before passing control to - actual controller action. For routes with `:authenticated_api` pipeline, authentication & authorization are expected, - thus `OAuthScopesPlug` will be run unless explicitly skipped (also `EnsureAuthenticatedPlug` will be executed - immediately before action even if there was an early run to give an early error, since `OAuthScopesPlug` supports - `:proceed_unauthenticated` option, and other plugs may support similar options as well). For `:api` pipeline routes, - `EnsurePublicOrAuthenticatedPlug` will be called to ensure that the instance is not private or user is authenticated - (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy - for users. +* Pleroma supports hierarchical OAuth scopes, just like Mastodon but with added granularity of admin scopes. For a reference, see [Mastodon OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/). + +* It is important to either define OAuth scope restrictions or explicitly mark OAuth scope check as skipped, for every controller action. To define scopes, call `plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: [...]})`. To explicitly set OAuth scopes check skipped, call `plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug )`. + +* In controllers, `use Pleroma.Web, :controller` will result in `action/2` (see `Pleroma.Web.controller/0` for definition) be called prior to actual controller action, and it'll perform security / privacy checks before passing control to actual controller action. + + For routes with `:authenticated_api` pipeline, authentication & authorization are expected, thus `OAuthScopesPlug` will be run unless explicitly skipped (also `EnsureAuthenticatedPlug` will be executed immediately before action even if there was an early run to give an early error, since `OAuthScopesPlug` supports `:proceed_unauthenticated` option, and other plugs may support similar options as well). + + For `:api` pipeline routes, it'll be verified whether `OAuthScopesPlug` was called or explicitly skipped, and if it was not then auth information will be dropped for request. Then `EnsurePublicOrAuthenticatedPlug` will be called to ensure that either the instance is not private or user is authenticated (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy for users. ## [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) -* With HTTP Basic Auth, OAuth scopes check is _not_ performed for any action (since password is provided during the auth, - requester is able to obtain a token with full permissions anyways). `Pleroma.Plugs.AuthenticationPlug` and - `Pleroma.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Plugs.OAuthScopesPlug.skip_plug(conn)` when password - is provided. +* With HTTP Basic Auth, OAuth scopes check is _not_ performed for any action (since password is provided during the auth, requester is able to obtain a token with full permissions anyways). `Pleroma.Plugs.AuthenticationPlug` and `Pleroma.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Plugs.OAuthScopesPlug.skip_plug(conn)` when password is provided. ## Auth-related configuration, OAuth consumer mode etc. diff --git a/lib/pleroma/plugs/oauth_scopes_plug.ex b/lib/pleroma/plugs/oauth_scopes_plug.ex index a61582566..efc25b79f 100644 --- a/lib/pleroma/plugs/oauth_scopes_plug.ex +++ b/lib/pleroma/plugs/oauth_scopes_plug.ex @@ -28,9 +28,7 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do conn options[:fallback] == :proceed_unauthenticated -> - conn - |> assign(:user, nil) - |> assign(:token, nil) + drop_auth_info(conn) true -> missing_scopes = scopes -- matched_scopes @@ -46,6 +44,15 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do end end + @doc "Drops authentication info from connection" + def drop_auth_info(conn) do + # To simplify debugging, setting a private variable on `conn` if auth info is dropped + conn + |> put_private(:authentication_ignored, true) + |> assign(:user, nil) + |> assign(:token, nil) + end + @doc "Filters descendants of supported scopes" def filter_descendants(scopes, supported_scopes) do Enum.filter( diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 9b8cc0d0d..e501b3555 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -37,7 +37,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :endorsements] + when action in [:show, :followers, :following, :endorsements] ) plug( @@ -49,7 +49,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} - when action in [:endorsements, :verify_credentials, :followers, :following] + when action in [:endorsements, :verify_credentials] ) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials) diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 005c60444..408e11474 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.AppController do use Pleroma.Web, :controller + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.Web.OAuth.App @@ -13,7 +14,14 @@ defmodule Pleroma.Web.MastodonAPI.AppController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] + when action == :create + ) + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials) + plug(OpenApiSpex.Plug.CastAndValidate) @local_mastodon_name "Mastodon-Local" diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex index 3bfebef8b..000ad743f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -7,6 +7,12 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do plug(OpenApiSpex.Plug.CastAndValidate) + plug( + :skip_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] + when action == :index + ) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation def index(conn, _params) do diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 27b5b1a52..237f85677 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -5,6 +5,12 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do use Pleroma.Web, :controller + plug( + :skip_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] + when action in [:show, :peers] + ) + @doc "GET /api/v1/instance" def show(conn, _params) do render(conn, "show.json") diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index f0492b189..e7767de4e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -15,7 +15,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do require Logger - plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug when action in [:empty_array, :empty_object]) + plug( + :skip_plug, + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] + when action in [:empty_array, :empty_object] + ) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index b54c56967..cd49da6ad 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -21,6 +21,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) + # Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped) + plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 891f924bc..040a0b9dd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1, skip_relationships?: 1] alias Pleroma.Pagination + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User @@ -26,7 +27,13 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) - plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action == :public) + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action in [:public, :hashtag] + ) + + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:public, :hashtag]) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) @@ -93,7 +100,9 @@ def public(%{assigns: %{user: user}} = conn, params) do restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key]) - if not (restrict? and is_nil(user)) do + if restrict? and is_nil(user) do + render_error(conn, :unauthorized, "authorization required for timeline view") + else activities = params |> Map.put("type", ["Create", "Announce"]) @@ -110,12 +119,10 @@ def public(%{assigns: %{user: user}} = conn, params) do as: :activity, skip_relationships: skip_relationships?(params) ) - else - render_error(conn, :unauthorized, "authorization required for timeline view") end end - def hashtag_fetching(params, user, local_only) do + defp hashtag_fetching(params, user, local_only) do tags = [params["tag"], params["any"]] |> List.flatten() diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 7a65697e8..2c1874051 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -26,6 +26,12 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do when action in [:conversation, :conversation_statuses] ) + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action == :emoji_reactions_by + ) + plug( OAuthScopesPlug, %{scopes: ["write:statuses"]} diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index c81e8535e..22da6c0ad 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -13,7 +13,11 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView - plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :user_scrobbles) + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :user_scrobbles + ) + plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 04c1c5941..db158d366 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -312,14 +312,10 @@ defmodule Pleroma.Web.Router do post("/scrobble", ScrobbleController, :new_scrobble) end - scope [] do - pipe_through(:api) - get("/accounts/:id/favourites", AccountController, :favourites) - end - scope [] do pipe_through(:authenticated_api) + get("/accounts/:id/favourites", AccountController, :favourites) post("/accounts/:id/subscribe", AccountController, :subscribe) post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) end @@ -353,6 +349,8 @@ defmodule Pleroma.Web.Router do post("/accounts/:id/mute", AccountController, :mute) post("/accounts/:id/unmute", AccountController, :unmute) + get("/apps/verify_credentials", AppController, :verify_credentials) + get("/conversations", ConversationController, :index) post("/conversations/:id/read", ConversationController, :mark_as_read) @@ -431,6 +429,7 @@ defmodule Pleroma.Web.Router do get("/timelines/home", TimelineController, :home) get("/timelines/direct", TimelineController, :direct) + get("/timelines/list/:list_id", TimelineController, :list) end scope "/api/web", Pleroma.Web do @@ -442,15 +441,24 @@ defmodule Pleroma.Web.Router do scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through(:api) - post("/accounts", AccountController, :create) get("/accounts/search", SearchController, :account_search) + get("/search", SearchController, :search) + + get("/accounts/:id/statuses", AccountController, :statuses) + get("/accounts/:id/followers", AccountController, :followers) + get("/accounts/:id/following", AccountController, :following) + get("/accounts/:id", AccountController, :show) + + post("/accounts", AccountController, :create) get("/instance", InstanceController, :show) get("/instance/peers", InstanceController, :peers) post("/apps", AppController, :create) - get("/apps/verify_credentials", AppController, :verify_credentials) + get("/statuses", StatusController, :index) + get("/statuses/:id", StatusController, :show) + get("/statuses/:id/context", StatusController, :context) get("/statuses/:id/card", StatusController, :card) get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) @@ -461,20 +469,8 @@ defmodule Pleroma.Web.Router do get("/timelines/public", TimelineController, :public) get("/timelines/tag/:tag", TimelineController, :hashtag) - get("/timelines/list/:list_id", TimelineController, :list) - - get("/statuses", StatusController, :index) - get("/statuses/:id", StatusController, :show) - get("/statuses/:id/context", StatusController, :context) get("/polls/:id", PollController, :show) - - get("/accounts/:id/statuses", AccountController, :statuses) - get("/accounts/:id/followers", AccountController, :followers) - get("/accounts/:id/following", AccountController, :following) - get("/accounts/:id", AccountController, :show) - - get("/search", SearchController, :search) end scope "/api/v2", Pleroma.Web.MastodonAPI do diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 55228616a..e4f182b02 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read ) - plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) + plug(:skip_plug, OAuthScopesPlug when action in [:confirm_email, :oauth_tokens, :revoke_token]) action_fallback(:errors) @@ -47,13 +47,13 @@ def revoke_token(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do json_reply(conn, 201, "") end - def errors(conn, {:param_cast, _}) do + defp errors(conn, {:param_cast, _}) do conn |> put_status(400) |> json("Invalid parameters") end - def errors(conn, _) do + defp errors(conn, _) do conn |> put_status(500) |> json("Something went wrong") diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index ec04c05f0..e2416fb2e 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -67,13 +67,25 @@ defp skip_plug(conn, plug_modules) do # Executed just before actual controller action, invokes before-action hooks (callbacks) defp action(conn, params) do - with %Plug.Conn{halted: false} <- maybe_perform_public_or_authenticated_check(conn), + with %Plug.Conn{halted: false} <- maybe_drop_authentication_if_oauth_check_ignored(conn), + %Plug.Conn{halted: false} <- maybe_perform_public_or_authenticated_check(conn), %Plug.Conn{halted: false} <- maybe_perform_authenticated_check(conn), %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do super(conn, params) end end + # For non-authenticated API actions, drops auth info if OAuth scopes check was ignored + # (neither performed nor explicitly skipped) + defp maybe_drop_authentication_if_oauth_check_ignored(conn) do + if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) and + not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do + OAuthScopesPlug.drop_auth_info(conn) + else + conn + end + end + # Ensures instance is public -or- user is authenticated if such check was scheduled defp maybe_perform_public_or_authenticated_check(conn) do if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) do -- cgit v1.2.3 From 1b06a27746ccbbdec77b7bc1571783a64ade4431 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 22 Apr 2020 20:20:19 +0400 Subject: Update Flake ID description --- docs/API/differences_in_mastoapi_responses.md | 2 +- lib/pleroma/web/api_spec/schemas/flake_id.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 1059155cf..62725edb4 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -4,7 +4,7 @@ A Pleroma instance can be identified by " (compatible; Pleroma ## Flake IDs -Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are sortable strings +Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings ## Attachment cap diff --git a/lib/pleroma/web/api_spec/schemas/flake_id.ex b/lib/pleroma/web/api_spec/schemas/flake_id.ex index b8e03b8a1..3b5f6477a 100644 --- a/lib/pleroma/web/api_spec/schemas/flake_id.ex +++ b/lib/pleroma/web/api_spec/schemas/flake_id.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.FlakeID do OpenApiSpex.schema(%{ title: "FlakeID", description: - "Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are sortable strings", + "Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings", type: :string }) end -- cgit v1.2.3 From e62173dfc8b739508345b7ab97477ae04fcdb457 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 18:40:53 +0200 Subject: SideEffects: Run in transaction. This fixes race conditions. --- lib/pleroma/web/activity_pub/side_effects.ex | 11 +++++++---- test/web/common_api/common_api_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 6a8f1af96..a0f71fd88 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -15,12 +15,15 @@ def handle(object, meta \\ []) # - 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) + Pleroma.Repo.transaction(fn -> + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) - Notification.create_notifications(object) + Notification.create_notifications(object) - {:ok, object, meta} + {:ok, object, meta} + end) + |> (fn {:ok, res} -> res end).() end # Nothing to do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index e130736ec..68a29108a 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -21,6 +21,33 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + test "favoriting race condition" do + user = insert(:user) + users_serial = insert_list(10, :user) + users = insert_list(10, :user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "."}) + + users_serial + |> Enum.map(fn user -> + CommonAPI.favorite(user, activity.id) + end) + + object = Object.get_by_ap_id(activity.data["object"]) + assert object.data["like_count"] == 10 + + users + |> Enum.map(fn user -> + Task.async(fn -> + CommonAPI.favorite(user, activity.id) + end) + end) + |> Enum.map(&Task.await/1) + + object = Object.get_by_ap_id(activity.data["object"]) + assert object.data["like_count"] == 20 + end + test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) -- cgit v1.2.3 From f5bda09de648a6de3151c8614005ebc70447facb Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 19:02:22 +0200 Subject: Stats: Use `invisible` property for filtering. --- lib/pleroma/stats.ex | 3 +-- test/stats_test.exs | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 6763786a7..8d2809bbb 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -82,8 +82,7 @@ def calculate_stat_data do where: u.deactivated != true, where: u.local == true, where: not is_nil(u.nickname), - where: fragment("? not like 'internal.%'", u.nickname), - where: fragment("? not like '%/relay'", u.ap_id) + where: not u.invisible ) user_count = Repo.aggregate(users_query, :count, :id) diff --git a/test/stats_test.exs b/test/stats_test.exs index 73c7c1495..c1aeb2c7f 100644 --- a/test/stats_test.exs +++ b/test/stats_test.exs @@ -11,7 +11,6 @@ defmodule Pleroma.StatsTest do test "it ignores internal users" do _user = insert(:user, local: true) _internal = insert(:user, local: true, nickname: nil) - _internal = insert(:user, local: true, nickname: "internal.dude") _internal = Pleroma.Web.ActivityPub.Relay.get_actor() assert match?(%{stats: %{user_count: 1}}, Pleroma.Stats.calculate_stat_data()) -- cgit v1.2.3 From 1bcbdc7a9f5df35f42d1ab331bc4b6785e180284 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 22 Apr 2020 21:21:21 +0200 Subject: SideEffects: Use less cryptic syntax. --- lib/pleroma/web/activity_pub/side_effects.ex | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a0f71fd88..5981e7545 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -15,15 +15,17 @@ def handle(object, meta \\ []) # - Add like to object # - Set up notification def handle(%{data: %{"type" => "Like"}} = object, meta) do - Pleroma.Repo.transaction(fn -> - liked_object = Object.get_by_ap_id(object.data["object"]) - Utils.add_like_to_object(object, liked_object) + {:ok, result} = + Pleroma.Repo.transaction(fn -> + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) - Notification.create_notifications(object) + Notification.create_notifications(object) - {:ok, object, meta} - end) - |> (fn {:ok, res} -> res end).() + {:ok, object, meta} + end) + + result end # Nothing to do -- cgit v1.2.3 From 9cf4c4fa73e68f03791c5cc70505b710be39b677 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 23 Apr 2020 14:12:42 +0400 Subject: Remove vapidPublicKey from Nodeinfo --- lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 6947c82b9..c90d4c009 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -126,8 +126,7 @@ def raw_nodeinfo do mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), features: features, restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false), - vapidPublicKey: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) } } end -- cgit v1.2.3 From 7d38197894692306c940b55045b91d563e138284 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 23 Apr 2020 13:33:30 +0200 Subject: CommonAPI: Don't make repeating announces possible --- lib/pleroma/web/common_api/common_api.ex | 23 +++++++++++++---------- lib/pleroma/web/common_api/utils.ex | 18 ------------------ test/web/common_api/common_api_test.exs | 18 +++++++++++++++--- test/web/common_api/common_api_utils_test.exs | 20 -------------------- 4 files changed, 28 insertions(+), 51 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index f50a909aa..d1efe0c36 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -86,8 +86,9 @@ def delete(activity_id, user) do end end - def repeat(id_or_ap_id, user, params \\ %{}) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)}, + def repeat(id, user, params \\ %{}) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)}, object <- Object.normalize(activity), announce_activity <- Utils.get_existing_announce(user.ap_id, object), public <- public_announce?(object, params) do @@ -102,8 +103,9 @@ def repeat(id_or_ap_id, user, params \\ %{}) do end end - def unrepeat(id_or_ap_id, user) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)} do + def unrepeat(id, user) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)} do object = Object.normalize(activity) ActivityPub.unannounce(user, object) else @@ -160,8 +162,9 @@ def favorite_helper(user, id) do end end - def unfavorite(id_or_ap_id, user) do - with {_, %Activity{} = activity} <- {:find_activity, get_by_id_or_ap_id(id_or_ap_id)} do + def unfavorite(id, user) do + with {_, %Activity{data: %{"type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(id)} do object = Object.normalize(activity) ActivityPub.unlike(user, object) else @@ -332,12 +335,12 @@ defp maybe_create_activity_expiration({:ok, activity}, %NaiveDateTime{} = expire defp maybe_create_activity_expiration(result, _), do: result - def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do + def pin(id, %{ap_id: user_ap_id} = user) do with %Activity{ actor: ^user_ap_id, data: %{"type" => "Create"}, object: %Object{data: %{"type" => object_type}} - } = activity <- get_by_id_or_ap_id(id_or_ap_id), + } = activity <- Activity.get_by_id_with_object(id), true <- object_type in ["Note", "Article", "Question"], true <- Visibility.is_public?(activity), {:ok, _user} <- User.add_pinnned_activity(user, activity) do @@ -348,8 +351,8 @@ def pin(id_or_ap_id, %{ap_id: user_ap_id} = user) do end end - def unpin(id_or_ap_id, user) do - with %Activity{} = activity <- get_by_id_or_ap_id(id_or_ap_id), + def unpin(id, user) do + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), {:ok, _user} <- User.remove_pinnned_activity(user, activity) do {:ok, activity} else diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 7eec5aa09..945e63e22 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -22,24 +22,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do require Logger require Pleroma.Constants - # This is a hack for twidere. - def get_by_id_or_ap_id(id) do - activity = - with true <- FlakeId.flake_id?(id), - %Activity{} = activity <- Activity.get_by_id_with_object(id) do - activity - else - _ -> Activity.get_create_by_object_ap_id_with_object(id) - end - - activity && - if activity.data["type"] == "Create" do - activity - else - Activity.get_create_by_object_ap_id_with_object(activity.data["object"]) - end - end - def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do attachments_from_ids_descs(ids, desc) end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 68a29108a..e87193c83 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -283,6 +283,16 @@ test "repeating a status" do {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, user) end + test "can't repeat a repeat" do + user = insert(:user) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + + {:ok, %Activity{} = announce, _} = CommonAPI.repeat(activity.id, other_user) + + refute match?({:ok, %Activity{}, _}, CommonAPI.repeat(announce.id, user)) + end + test "repeating a status privately" do user = insert(:user) other_user = insert(:user) @@ -312,8 +322,8 @@ test "retweeting a status twice returns the status" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) - {:ok, %Activity{} = activity, object} = CommonAPI.repeat(activity.id, user) - {:ok, ^activity, ^object} = CommonAPI.repeat(activity.id, user) + {:ok, %Activity{} = announce, object} = CommonAPI.repeat(activity.id, user) + {:ok, ^announce, ^object} = CommonAPI.repeat(activity.id, user) end test "favoriting a status twice returns ok, but without the like activity" do @@ -387,7 +397,9 @@ test "unpin status", %{user: user, activity: activity} do user = refresh_record(user) - assert {:ok, ^activity} = CommonAPI.unpin(activity.id, user) + id = activity.id + + assert match?({:ok, %{id: ^id}}, CommonAPI.unpin(activity.id, user)) user = refresh_record(user) diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index b21445fe9..18a3b3b87 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -335,26 +335,6 @@ test "for direct posts, a reply" do end end - describe "get_by_id_or_ap_id/1" do - test "get activity by id" do - activity = insert(:note_activity) - %Pleroma.Activity{} = note = Utils.get_by_id_or_ap_id(activity.id) - assert note.id == activity.id - end - - test "get activity by ap_id" do - activity = insert(:note_activity) - %Pleroma.Activity{} = note = Utils.get_by_id_or_ap_id(activity.data["object"]) - assert note.id == activity.id - end - - test "get activity by object when type isn't `Create` " do - activity = insert(:like_activity) - %Pleroma.Activity{} = like = Utils.get_by_id_or_ap_id(activity.id) - assert like.data["object"] == activity.data["object"] - end - end - describe "to_master_date/1" do test "removes microseconds from date (NaiveDateTime)" do assert Utils.to_masto_date(~N[2015-01-23 23:50:07.123]) == "2015-01-23T23:50:07.000Z" -- cgit v1.2.3 From 1e28d34592a5fae0f3403763f1ff86cc393a52b0 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 23 Apr 2020 16:19:49 +0200 Subject: ChatMessage: Correctly ingest emoji tags. --- .../activity_pub/object_validators/chat_message_validator.ex | 2 ++ test/fixtures/create-chat-message.json | 12 ++++++++++++ test/web/activity_pub/transmogrifier/chat_message_test.exs | 1 + 3 files changed, 15 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 8b5bb4fdc..f07045d9d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset + import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] @primary_key false @derive Jason.Encoder @@ -42,6 +43,7 @@ def cast_data(data) do def fix(data) do data + |> fix_emoji() |> Map.put_new("actor", data["attributedTo"]) end diff --git a/test/fixtures/create-chat-message.json b/test/fixtures/create-chat-message.json index 6db5b9f5c..9c23a1c9b 100644 --- a/test/fixtures/create-chat-message.json +++ b/test/fixtures/create-chat-message.json @@ -9,6 +9,18 @@ "to": [ "http://2hu.gensokyo/users/marisa" ], + "tag": [ + { + "icon": { + "type": "Image", + "url": "http://2hu.gensokyo/emoji/Firefox.gif" + }, + "id": "http://2hu.gensokyo/emoji/Firefox.gif", + "name": ":firefox:", + "type": "Emoji", + "updated": "1970-01-01T00:00:00Z" + } + ], "type": "ChatMessage" }, "published": "2018-02-12T14:08:20Z", diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 4d6f24609..a63a31e6e 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -75,6 +75,7 @@ test "it inserts it and creates a chat" do assert object assert object.data["content"] == "You expected a cute girl? Too bad. alert('XSS')" + assert match?(%{"firefox" => _}, object.data["emoji"]) refute Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) -- cgit v1.2.3 From a51cdafc0192b66ce75659b424a690f52c9b2a49 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 23 Apr 2020 16:55:00 +0200 Subject: Docs: Add documentation about chatmessages --- docs/ap_extensions.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/ap_extensions.md diff --git a/docs/ap_extensions.md b/docs/ap_extensions.md new file mode 100644 index 000000000..c4550a1ac --- /dev/null +++ b/docs/ap_extensions.md @@ -0,0 +1,35 @@ +# ChatMessages + +ChatMessages are the messages sent in 1-on-1 chats. They are similar to +`Note`s, but the addresing is done by having a single AP actor in the `to` +field. Addressing multiple actors is not allowed. These messages are always +private, there is no public version of them. They are created with a `Create` +activity. + +Example: + +```json +{ + "actor": "http://2hu.gensokyo/users/raymoo", + "id": "http://2hu.gensokyo/objects/1", + "object": { + "attributedTo": "http://2hu.gensokyo/users/raymoo", + "content": "You expected a cute girl? Too bad.", + "id": "http://2hu.gensokyo/objects/2", + "published": "2020-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "ChatMessage" + }, + "published": "2018-02-12T14:08:20Z", + "to": [ + "http://2hu.gensokyo/users/marisa" + ], + "type": "Create" +} +``` + +This setup does not prevent multi-user chats, but these will have to go through +a `Group`, which will be the recipient of the messages and then `Announce` them +to the users in the `Group`. -- cgit v1.2.3 From 89f38d94c754d33736e2444859d6020b2147eaf7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 23 Apr 2020 21:47:33 +0300 Subject: [#2409] Fixed before-action callback results persistence. --- lib/pleroma/web/web.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index e2416fb2e..08e42a7e5 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -67,10 +67,10 @@ defp skip_plug(conn, plug_modules) do # Executed just before actual controller action, invokes before-action hooks (callbacks) defp action(conn, params) do - with %Plug.Conn{halted: false} <- maybe_drop_authentication_if_oauth_check_ignored(conn), - %Plug.Conn{halted: false} <- maybe_perform_public_or_authenticated_check(conn), - %Plug.Conn{halted: false} <- maybe_perform_authenticated_check(conn), - %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do + with %{halted: false} = conn <- maybe_drop_authentication_if_oauth_check_ignored(conn), + %{halted: false} = conn <- maybe_perform_public_or_authenticated_check(conn), + %{halted: false} = conn <- maybe_perform_authenticated_check(conn), + %{halted: false} = conn <- maybe_halt_on_missing_oauth_scopes_check(conn) do super(conn, params) end end -- cgit v1.2.3 From b429a49504b1df6fa085cccbb3e461cd378b15c4 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 23 Apr 2020 23:44:03 +0200 Subject: mix.exs: Fix for MacOS --- mix.exs | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/mix.exs b/mix.exs index b76aef180..021217400 100644 --- a/mix.exs +++ b/mix.exs @@ -220,32 +220,39 @@ defp aliases do defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i - # Pre-release version, denoted from patch version with a hyphen - {tag, tag_err} = - System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) - - {describe, describe_err} = System.cmd("git", ["describe", "--tags", "--abbrev=8"]) - {commit_hash, commit_hash_err} = System.cmd("git", ["rev-parse", "--short", "HEAD"]) + {_gitpath, git_present} = System.cmd("sh", ["-c", "command -v git"]) git_pre_release = - cond do - tag_err == 0 and describe_err == 0 -> - describe - |> String.trim() - |> String.replace(String.trim(tag), "") - |> String.trim_leading("-") - |> String.trim() + if git_present do + {tag, tag_err} = + System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) - commit_hash_err == 0 -> - "0-g" <> String.trim(commit_hash) + {describe, describe_err} = System.cmd("git", ["describe", "--tags", "--abbrev=8"]) + {commit_hash, commit_hash_err} = System.cmd("git", ["rev-parse", "--short", "HEAD"]) - true -> - "" + # Pre-release version, denoted from patch version with a hyphen + cond do + tag_err == 0 and describe_err == 0 -> + describe + |> String.trim() + |> String.replace(String.trim(tag), "") + |> String.trim_leading("-") + |> String.trim() + + commit_hash_err == 0 -> + "0-g" <> String.trim(commit_hash) + + true -> + "" + end + else + "" end # Branch name as pre-release version component, denoted with a dot branch_name = - with {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), + with true <- git_present, + {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), branch_name <- String.trim(branch_name), branch_name <- System.get_env("PLEROMA_BUILD_BRANCH") || branch_name, true <- -- cgit v1.2.3 From c63d6ba0b2686db847f70cf251f92bfed57c4e5f Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 23 Apr 2020 23:44:30 +0200 Subject: mix.exs: branch_name fallbacks to "" --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 021217400..15a65c0fb 100644 --- a/mix.exs +++ b/mix.exs @@ -266,7 +266,7 @@ defp version(version) do branch_name else - _ -> "stable" + _ -> "" end build_name = -- cgit v1.2.3 From 053c46153076f351c5273c2d6b1fb0843e7b6a6d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 24 Apr 2020 00:26:24 +0200 Subject: mix.exs: proper check on 0, remove else in git_pre_release --- mix.exs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mix.exs b/mix.exs index 15a65c0fb..ebb8bdb08 100644 --- a/mix.exs +++ b/mix.exs @@ -220,10 +220,10 @@ defp aliases do defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i - {_gitpath, git_present} = System.cmd("sh", ["-c", "command -v git"]) + {_cmdgit, cmdgit_err} = System.cmd("sh", ["-c", "command -v git"]) git_pre_release = - if git_present do + if cmdgit_err == 0 do {tag, tag_err} = System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) @@ -243,15 +243,13 @@ defp version(version) do "0-g" <> String.trim(commit_hash) true -> - "" + nil end - else - "" end # Branch name as pre-release version component, denoted with a dot branch_name = - with true <- git_present, + with 0 <- cmdgit_err, {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), branch_name <- String.trim(branch_name), branch_name <- System.get_env("PLEROMA_BUILD_BRANCH") || branch_name, -- cgit v1.2.3 From f362836742aabd5b60b92c1296f2bbb6d83a3d59 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 24 Apr 2020 14:46:59 +0400 Subject: Support validation for inline OpenAPI schema and automatic tests for examples --- .../web/api_spec/operations/app_operation.ex | 60 ++++++++++++++++++++-- .../api_spec/operations/custom_emoji_operation.ex | 40 ++++++++++++++- .../api_spec/operations/domain_block_operation.ex | 31 +++++++++-- .../web/api_spec/schemas/app_create_request.ex | 33 ------------ .../web/api_spec/schemas/app_create_response.ex | 33 ------------ .../web/api_spec/schemas/custom_emojis_response.ex | 42 --------------- .../web/api_spec/schemas/domain_block_request.ex | 20 -------- .../web/api_spec/schemas/domain_blocks_response.ex | 16 ------ lib/pleroma/web/oauth/scopes.ex | 6 +-- test/support/api_spec_helpers.ex | 57 ++++++++++++++++++++ test/support/conn_case.ex | 36 +++++++++++++ test/web/api_spec/app_operation_test.exs | 45 ---------------- test/web/api_spec/schema_examples_test.exs | 43 ++++++++++++++++ .../controllers/app_controller_test.exs | 4 +- .../controllers/custom_emoji_controller_test.exs | 17 +----- .../controllers/domain_block_controller_test.exs | 28 +++------- 16 files changed, 267 insertions(+), 244 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/app_create_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/app_create_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/domain_block_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex create mode 100644 test/support/api_spec_helpers.ex delete mode 100644 test/web/api_spec/app_operation_test.exs create mode 100644 test/web/api_spec/schema_examples_test.exs diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex index 26d8dbd42..035ef2470 100644 --- a/lib/pleroma/web/api_spec/operations/app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -6,8 +6,6 @@ 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 @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -22,9 +20,9 @@ def create_operation do summary: "Create an application", description: "Create a new application to obtain OAuth2 credentials", operationId: "AppController.create", - requestBody: Helpers.request_body("Parameters", AppCreateRequest, required: true), + requestBody: Helpers.request_body("Parameters", create_request(), required: true), responses: %{ - 200 => Operation.response("App", "application/json", AppCreateResponse), + 200 => Operation.response("App", "application/json", create_response()), 422 => Operation.response( "Unprocessable Entity", @@ -93,4 +91,58 @@ def verify_credentials_operation do } } end + + defp create_request do + %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", + default: "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 + + defp create_response do + %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 end diff --git a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex index cf2215823..a117fe460 100644 --- a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex +++ b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex @@ -4,7 +4,8 @@ defmodule Pleroma.Web.ApiSpec.CustomEmojiOperation do alias OpenApiSpex.Operation - alias Pleroma.Web.ApiSpec.Schemas.CustomEmojisResponse + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.CustomEmoji def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -18,8 +19,43 @@ def index_operation do description: "Returns custom emojis that are available on the server.", operationId: "CustomEmojiController.index", responses: %{ - 200 => Operation.response("Custom Emojis", "application/json", CustomEmojisResponse) + 200 => Operation.response("Custom Emojis", "application/json", custom_emojis_resposnse()) } } end + + defp custom_emojis_resposnse do + %Schema{ + title: "CustomEmojisResponse", + description: "Response schema for custom emojis", + type: :array, + items: CustomEmoji, + example: [ + %{ + "category" => "Fun", + "shortcode" => "blank", + "static_url" => "https://lain.com/emoji/blank.png", + "tags" => ["Fun"], + "url" => "https://lain.com/emoji/blank.png", + "visible_in_picker" => false + }, + %{ + "category" => "Gif,Fun", + "shortcode" => "firefox", + "static_url" => "https://lain.com/emoji/Firefox.gif", + "tags" => ["Gif", "Fun"], + "url" => "https://lain.com/emoji/Firefox.gif", + "visible_in_picker" => true + }, + %{ + "category" => "pack:mixed", + "shortcode" => "sadcat", + "static_url" => "https://lain.com/emoji/mixed/sadcat.png", + "tags" => ["pack:mixed"], + "url" => "https://lain.com/emoji/mixed/sadcat.png", + "visible_in_picker" => true + } + ] + } + end end diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex index dd14837c3..3b7f51ceb 100644 --- a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -6,8 +6,6 @@ defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers - alias Pleroma.Web.ApiSpec.Schemas.DomainBlockRequest - alias Pleroma.Web.ApiSpec.Schemas.DomainBlocksResponse def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -22,7 +20,13 @@ def index_operation do security: [%{"oAuth" => ["follow", "read:blocks"]}], operationId: "DomainBlockController.index", responses: %{ - 200 => Operation.response("Domain blocks", "application/json", DomainBlocksResponse) + 200 => + Operation.response("Domain blocks", "application/json", %Schema{ + description: "Response schema for domain blocks", + type: :array, + items: %Schema{type: :string}, + example: ["google.com", "facebook.com"] + }) } } end @@ -40,7 +44,7 @@ def create_operation do - prevent following new users from it (but does not remove existing follows) """, operationId: "DomainBlockController.create", - requestBody: Helpers.request_body("Parameters", DomainBlockRequest, required: true), + requestBody: domain_block_request(), security: [%{"oAuth" => ["follow", "write:blocks"]}], responses: %{ 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) @@ -54,11 +58,28 @@ def delete_operation do summary: "Unblock a domain", description: "Remove a domain block, if it exists in the user's array of blocked domains.", operationId: "DomainBlockController.delete", - requestBody: Helpers.request_body("Parameters", DomainBlockRequest, required: true), + requestBody: domain_block_request(), security: [%{"oAuth" => ["follow", "write:blocks"]}], responses: %{ 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) } } end + + defp domain_block_request do + Helpers.request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + domain: %Schema{type: :string} + }, + required: [:domain] + }, + required: true, + example: %{ + "domain" => "facebook.com" + } + ) + 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 deleted file mode 100644 index 8a83abef3..000000000 --- a/lib/pleroma/web/api_spec/schemas/app_create_request.ex +++ /dev/null @@ -1,33 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.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 deleted file mode 100644 index f290fb031..000000000 --- a/lib/pleroma/web/api_spec/schemas/app_create_response.ex +++ /dev/null @@ -1,33 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.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/api_spec/schemas/custom_emojis_response.ex b/lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex deleted file mode 100644 index 01582a63d..000000000 --- a/lib/pleroma/web/api_spec/schemas/custom_emojis_response.ex +++ /dev/null @@ -1,42 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.CustomEmojisResponse do - alias Pleroma.Web.ApiSpec.Schemas.CustomEmoji - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "CustomEmojisResponse", - description: "Response schema for custom emojis", - type: :array, - items: CustomEmoji, - example: [ - %{ - "category" => "Fun", - "shortcode" => "blank", - "static_url" => "https://lain.com/emoji/blank.png", - "tags" => ["Fun"], - "url" => "https://lain.com/emoji/blank.png", - "visible_in_picker" => true - }, - %{ - "category" => "Gif,Fun", - "shortcode" => "firefox", - "static_url" => "https://lain.com/emoji/Firefox.gif", - "tags" => ["Gif", "Fun"], - "url" => "https://lain.com/emoji/Firefox.gif", - "visible_in_picker" => true - }, - %{ - "category" => "pack:mixed", - "shortcode" => "sadcat", - "static_url" => "https://lain.com/emoji/mixed/sadcat.png", - "tags" => ["pack:mixed"], - "url" => "https://lain.com/emoji/mixed/sadcat.png", - "visible_in_picker" => true - } - ] - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/domain_block_request.ex b/lib/pleroma/web/api_spec/schemas/domain_block_request.ex deleted file mode 100644 index ee9238361..000000000 --- a/lib/pleroma/web/api_spec/schemas/domain_block_request.ex +++ /dev/null @@ -1,20 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.DomainBlockRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "DomainBlockRequest", - type: :object, - properties: %{ - domain: %Schema{type: :string} - }, - required: [:domain], - example: %{ - "domain" => "facebook.com" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex b/lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex deleted file mode 100644 index d895aca4e..000000000 --- a/lib/pleroma/web/api_spec/schemas/domain_blocks_response.ex +++ /dev/null @@ -1,16 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.DomainBlocksResponse do - require OpenApiSpex - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - title: "DomainBlocksResponse", - description: "Response schema for domain blocks", - type: :array, - items: %Schema{type: :string}, - example: ["google.com", "facebook.com"] - }) -end diff --git a/lib/pleroma/web/oauth/scopes.ex b/lib/pleroma/web/oauth/scopes.ex index 1023f16d4..6f06f1431 100644 --- a/lib/pleroma/web/oauth/scopes.ex +++ b/lib/pleroma/web/oauth/scopes.ex @@ -17,12 +17,8 @@ defmodule Pleroma.Web.OAuth.Scopes do """ @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) + parse_scopes(params["scope"] || params["scopes"] || params[:scopes], default) end def parse_scopes(scopes, _default) when is_list(scopes) do diff --git a/test/support/api_spec_helpers.ex b/test/support/api_spec_helpers.ex new file mode 100644 index 000000000..80c69c788 --- /dev/null +++ b/test/support/api_spec_helpers.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Tests.ApiSpecHelpers do + @moduledoc """ + OpenAPI spec test helpers + """ + + import ExUnit.Assertions + + alias OpenApiSpex.Cast.Error + alias OpenApiSpex.Reference + alias OpenApiSpex.Schema + + def assert_schema(value, schema) do + api_spec = Pleroma.Web.ApiSpec.spec() + + case OpenApiSpex.cast_value(value, schema, api_spec) do + {:ok, data} -> + data + + {:error, errors} -> + errors = + Enum.map(errors, fn error -> + message = Error.message(error) + path = Error.path_to_string(error) + "#{message} at #{path}" + end) + + flunk( + "Value does not conform to schema #{schema.title}: #{Enum.join(errors, "\n")}\n#{ + inspect(value) + }" + ) + end + end + + def resolve_schema(%Schema{} = schema), do: schema + + def resolve_schema(%Reference{} = ref) do + schemas = Pleroma.Web.ApiSpec.spec().components.schemas + Reference.resolve_schema(ref, schemas) + end + + def api_operations do + paths = Pleroma.Web.ApiSpec.spec().paths + + Enum.flat_map(paths, fn {_, path_item} -> + path_item + |> Map.take([:delete, :get, :head, :options, :patch, :post, :put, :trace]) + |> Map.values() + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end) + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 064874201..781622476 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -51,6 +51,42 @@ defp oauth_access(scopes, opts \\ []) do %{user: user, token: token, conn: conn} end + defp json_response_and_validate_schema(conn, status \\ nil) do + content_type = + conn + |> Plug.Conn.get_resp_header("content-type") + |> List.first() + |> String.split(";") + |> List.first() + + status = status || conn.status + + %{private: %{open_api_spex: %{operation_id: op_id, operation_lookup: lookup, spec: spec}}} = + conn + + schema = lookup[op_id].responses[status].content[content_type].schema + json = json_response(conn, status) + + case OpenApiSpex.cast_value(json, schema, spec) do + {:ok, _data} -> + json + + {:error, errors} -> + errors = + Enum.map(errors, fn error -> + message = OpenApiSpex.Cast.Error.message(error) + path = OpenApiSpex.Cast.Error.path_to_string(error) + "#{message} at #{path}" + end) + + flunk( + "Response does not conform to schema of #{op_id} operation: #{ + Enum.join(errors, "\n") + }\n#{inspect(json)}" + ) + end + end + defp ensure_federating_or_authenticated(conn, url, user) do initial_setting = Config.get([:instance, :federating]) on_exit(fn -> Config.put([:instance, :federating], initial_setting) end) diff --git a/test/web/api_spec/app_operation_test.exs b/test/web/api_spec/app_operation_test.exs deleted file mode 100644 index 5b96abb44..000000000 --- a/test/web/api_spec/app_operation_test.exs +++ /dev/null @@ -1,45 +0,0 @@ -# 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/api_spec/schema_examples_test.exs b/test/web/api_spec/schema_examples_test.exs new file mode 100644 index 000000000..88b6f07cb --- /dev/null +++ b/test/web/api_spec/schema_examples_test.exs @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SchemaExamplesTest do + use ExUnit.Case, async: true + import Pleroma.Tests.ApiSpecHelpers + + @content_type "application/json" + + for operation <- api_operations() do + describe operation.operationId <> " Request Body" do + if operation.requestBody do + @media_type operation.requestBody.content[@content_type] + @schema resolve_schema(@media_type.schema) + + if @media_type.example do + test "request body media type example matches schema" do + assert_schema(@media_type.example, @schema) + end + end + + if @schema.example do + test "request body schema example matches schema" do + assert_schema(@schema.example, @schema) + end + end + end + end + + for {status, response} <- operation.responses do + describe "#{operation.operationId} - #{status} Response" do + @schema resolve_schema(response.content[@content_type].schema) + + if @schema.example do + test "example matches schema" do + assert_schema(@schema.example, @schema) + end + end + end + end + end +end diff --git a/test/web/mastodon_api/controllers/app_controller_test.exs b/test/web/mastodon_api/controllers/app_controller_test.exs index e7b11d14e..a0b8b126c 100644 --- a/test/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/web/mastodon_api/controllers/app_controller_test.exs @@ -27,7 +27,7 @@ test "apps/verify_credentials", %{conn: conn} do "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) } - assert expected == json_response(conn, 200) + assert expected == json_response_and_validate_schema(conn, 200) end test "creates an oauth app", %{conn: conn} do @@ -55,6 +55,6 @@ test "creates an oauth app", %{conn: conn} do "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) } - assert expected == json_response(conn, 200) + assert expected == json_response_and_validate_schema(conn, 200) end end diff --git a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs index 0b2ffa470..4222556a4 100644 --- a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs +++ b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs @@ -5,15 +5,13 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.CustomEmoji - alias Pleroma.Web.ApiSpec.Schemas.CustomEmojisResponse import OpenApiSpex.TestAssertions test "with tags", %{conn: conn} do assert resp = conn |> get("/api/v1/custom_emojis") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [emoji | _body] = resp assert Map.has_key?(emoji, "shortcode") @@ -23,19 +21,6 @@ test "with tags", %{conn: conn} do assert Map.has_key?(emoji, "category") assert Map.has_key?(emoji, "url") assert Map.has_key?(emoji, "visible_in_picker") - assert_schema(resp, "CustomEmojisResponse", ApiSpec.spec()) assert_schema(emoji, "CustomEmoji", ApiSpec.spec()) end - - test "CustomEmoji example matches schema" do - api_spec = ApiSpec.spec() - schema = CustomEmoji.schema() - assert_schema(schema.example, "CustomEmoji", api_spec) - end - - test "CustomEmojisResponse example matches schema" do - api_spec = ApiSpec.spec() - schema = CustomEmojisResponse.schema() - assert_schema(schema.example, "CustomEmojisResponse", api_spec) - end end diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs index d66190c90..01a24afcf 100644 --- a/test/web/mastodon_api/controllers/domain_block_controller_test.exs +++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -6,11 +6,8 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockControllerTest do use Pleroma.Web.ConnCase alias Pleroma.User - alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.DomainBlocksResponse import Pleroma.Factory - import OpenApiSpex.TestAssertions test "blocking / unblocking a domain" do %{user: user, conn: conn} = oauth_access(["write:blocks"]) @@ -21,7 +18,7 @@ test "blocking / unblocking a domain" do |> put_req_header("content-type", "application/json") |> post("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) - assert %{} = json_response(ret_conn, 200) + assert %{} == json_response_and_validate_schema(ret_conn, 200) user = User.get_cached_by_ap_id(user.ap_id) assert User.blocks?(user, other_user) @@ -30,7 +27,7 @@ test "blocking / unblocking a domain" do |> put_req_header("content-type", "application/json") |> delete("/api/v1/domain_blocks", %{"domain" => "dogwhistle.zone"}) - assert %{} = json_response(ret_conn, 200) + assert %{} == json_response_and_validate_schema(ret_conn, 200) user = User.get_cached_by_ap_id(user.ap_id) refute User.blocks?(user, other_user) end @@ -41,21 +38,10 @@ test "getting a list of domain blocks" do {:ok, user} = User.block_domain(user, "bad.site") {:ok, user} = User.block_domain(user, "even.worse.site") - conn = - conn - |> assign(:user, user) - |> get("/api/v1/domain_blocks") - - domain_blocks = json_response(conn, 200) - - assert "bad.site" in domain_blocks - assert "even.worse.site" in domain_blocks - assert_schema(domain_blocks, "DomainBlocksResponse", ApiSpec.spec()) - end - - test "DomainBlocksResponse example matches schema" do - api_spec = ApiSpec.spec() - schema = DomainBlocksResponse.schema() - assert_schema(schema.example, "DomainBlocksResponse", api_spec) + assert ["even.worse.site", "bad.site"] == + conn + |> assign(:user, user) + |> get("/api/v1/domain_blocks") + |> json_response_and_validate_schema(200) end end -- cgit v1.2.3 From bbf8554c975ea1ba9b5c809a7891ec0fb4a8e537 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 24 Apr 2020 13:48:13 +0200 Subject: ActivitPub: Remove `like` function. We don't need another way to build likes. --- lib/pleroma/web/activity_pub/activity_pub.ex | 30 --------- .../web/activity_pub/activity_pub_controller.ex | 7 +- test/web/activity_pub/activity_pub_test.exs | 77 ++-------------------- test/web/activity_pub/utils_test.exs | 5 +- 4 files changed, 15 insertions(+), 104 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 4a133498e..c67b3335d 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -398,36 +398,6 @@ defp do_unreact_with_emoji(user, reaction_id, options) do end end - # TODO: This is weird, maybe we shouldn't check here if we can make the activity. - @spec like(User.t(), Object.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def like(user, object, activity_id \\ nil, local \\ true) do - with {:ok, result} <- Repo.transaction(fn -> do_like(user, object, activity_id, local) end) do - result - end - end - - defp do_like( - %User{ap_id: ap_id} = user, - %Object{data: %{"id" => _}} = object, - activity_id, - local - ) do - with nil <- get_existing_like(ap_id, object), - like_data <- make_like_data(user, object, activity_id), - {:ok, activity} <- insert(like_data, local), - {:ok, object} <- add_like_to_object(activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - %Activity{} = activity -> - {:ok, activity, object} - - {:error, error} -> - Repo.rollback(error) - end - end - @spec unlike(User.t(), Object.t(), String.t() | nil, boolean()) :: {:ok, Activity.t(), Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()} def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) do diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 8b9eb4a2c..325a714b4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -12,6 +12,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Plugs.EnsureAuthenticatedPlug alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Relay @@ -421,7 +423,10 @@ defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do defp handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do with %Object{} = object <- Object.normalize(params["object"]), - {:ok, activity, _object} <- ActivityPub.like(user, object) do + {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do {:ok, activity} else _ -> {:error, dgettext("errors", "Can't like object")} diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 6410df49b..53176917e 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -994,72 +994,6 @@ test "reverts emoji unreact on error" do end end - describe "like an object" do - test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do - Config.put([:instance, :federating], true) - note_activity = insert(:note_activity) - assert object_activity = Object.normalize(note_activity) - - user = insert(:user) - - {:ok, like_activity, _object} = ActivityPub.like(user, object_activity) - assert called(Federator.publish(like_activity)) - end - - test "returns exist activity if object already liked" do - note_activity = insert(:note_activity) - assert object_activity = Object.normalize(note_activity) - - user = insert(:user) - - {:ok, like_activity, _object} = ActivityPub.like(user, object_activity) - - {:ok, like_activity_exist, _object} = ActivityPub.like(user, object_activity) - assert like_activity == like_activity_exist - end - - test "reverts like activity on error" do - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.like(user, object) - end - - assert Repo.aggregate(Activity, :count, :id) == 1 - assert Repo.get(Object, object.id) == object - end - - test "adds a like activity to the db" do - note_activity = insert(:note_activity) - assert object = Object.normalize(note_activity) - - user = insert(:user) - user_two = insert(:user) - - {:ok, like_activity, object} = ActivityPub.like(user, object) - - assert like_activity.data["actor"] == user.ap_id - assert like_activity.data["type"] == "Like" - assert like_activity.data["object"] == object.data["id"] - assert like_activity.data["to"] == [User.ap_followers(user), note_activity.data["actor"]] - assert like_activity.data["context"] == object.data["context"] - assert object.data["like_count"] == 1 - assert object.data["likes"] == [user.ap_id] - - # Just return the original activity if the user already liked it. - {:ok, same_like_activity, object} = ActivityPub.like(user, object) - - assert like_activity == same_like_activity - assert object.data["likes"] == [user.ap_id] - assert object.data["like_count"] == 1 - - {:ok, _like_activity, object} = ActivityPub.like(user_two, object) - assert object.data["like_count"] == 2 - end - end - describe "unliking" do test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do Config.put([:instance, :federating], true) @@ -1071,7 +1005,8 @@ test "adds a like activity to the db" do {:ok, object} = ActivityPub.unlike(user, object) refute called(Federator.publish()) - {:ok, _like_activity, object} = ActivityPub.like(user, object) + {:ok, _like_activity} = CommonAPI.favorite(user, note_activity.id) + object = Object.get_by_id(object.id) assert object.data["like_count"] == 1 {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object) @@ -1082,10 +1017,10 @@ test "adds a like activity to the db" do test "reverts unliking on error" do note_activity = insert(:note_activity) - object = Object.normalize(note_activity) user = insert(:user) - {:ok, like_activity, object} = ActivityPub.like(user, object) + {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) + object = Object.normalize(note_activity) assert object.data["like_count"] == 1 with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do @@ -1106,7 +1041,9 @@ test "unliking a previously liked object" do {:ok, object} = ActivityPub.unlike(user, object) assert object.data["like_count"] == 0 - {:ok, like_activity, object} = ActivityPub.like(user, object) + {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) + + object = Object.get_by_id(object.id) assert object.data["like_count"] == 1 {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object) diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index e913a5148..b0bfed917 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -224,8 +224,7 @@ test "fetches only Create activities" do object = Object.normalize(activity) {:ok, [vote], object} = CommonAPI.vote(other_user, object, [0]) - vote_object = Object.normalize(vote) - {:ok, _activity, _object} = ActivityPub.like(user, vote_object) + {:ok, _activity} = CommonAPI.favorite(user, activity.id) [fetched_vote] = Utils.get_existing_votes(other_user.ap_id, object) assert fetched_vote.id == vote.id end @@ -346,7 +345,7 @@ test "fetches existing like" do user = insert(:user) refute Utils.get_existing_like(user.ap_id, object) - {:ok, like_activity, _object} = ActivityPub.like(user, object) + {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) assert ^like_activity = Utils.get_existing_like(user.ap_id, object) end -- cgit v1.2.3 From 1df6af2a4c93257f94e2780e4317cfcf9bef7adb Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 24 Apr 2020 13:59:48 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 325a714b4..d625530ec 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -13,9 +13,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.ObjectView + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.UserView -- cgit v1.2.3 From cb12585098e0cc1e2e85d253812e1898e8034b7f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 24 Apr 2020 14:37:53 +0200 Subject: Announcements: Prevent race condition. --- lib/pleroma/web/activity_pub/activity_pub.ex | 1 + test/web/common_api/common_api_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c67b3335d..4cce4f13c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -438,6 +438,7 @@ def announce( defp do_announce(user, object, activity_id, local, public) do with true <- is_announceable?(object, user, public), + object <- Object.get_by_id(object.id), announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index e87193c83..1758662b0 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -48,6 +48,33 @@ test "favoriting race condition" do assert object.data["like_count"] == 20 end + test "repeating race condition" do + user = insert(:user) + users_serial = insert_list(10, :user) + users = insert_list(10, :user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "."}) + + users_serial + |> Enum.map(fn user -> + CommonAPI.repeat(activity.id, user) + end) + + object = Object.get_by_ap_id(activity.data["object"]) + assert object.data["announcement_count"] == 10 + + users + |> Enum.map(fn user -> + Task.async(fn -> + CommonAPI.repeat(activity.id, user) + end) + end) + |> Enum.map(&Task.await/1) + + object = Object.get_by_ap_id(activity.data["object"]) + assert object.data["announcement_count"] == 20 + end + test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) -- cgit v1.2.3 From 6e625a427cdc829714ad0365560d79aa4ee9c2e5 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Dec 2019 09:49:17 +0300 Subject: reply filtering --- CHANGELOG.md | 1 + benchmarks/load_testing/fetcher.ex | 53 +++ docs/API/differences_in_mastoapi_responses.md | 2 +- lib/pleroma/user.ex | 14 + lib/pleroma/user/query.ex | 4 +- lib/pleroma/web/activity_pub/activity_pub.ex | 75 +++- lib/pleroma/web/common_api/activity_draft.ex | 16 +- .../controllers/timeline_controller.ex | 1 + test/web/activity_pub/activity_pub_test.exs | 486 +++++++++++++++++++++ 9 files changed, 635 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 702c58180..affabcd95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,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. +- Mastodon API: Add support for filtering replies in public and friends timelines - Admin API: endpoints for create/update/delete OAuth Apps.
    diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 786929ace..3aa82b48a 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -495,4 +495,57 @@ defp render_long_thread(user) do formatters: formatters() ) end + + def query_replies(user) do + public_params = %{ + "type" => ["Create", "Announce"], + "local_only" => false, + "blocking_user" => user, + "muting_user" => user, + "count" => 20 + } + + Benchee.run(%{ + "Public timeline without reply filtering" => fn -> + ActivityPub.fetch_public_activities(public_params) + end, + "Public timeline with reply filtering - following" => fn -> + public_params + |> Map.put("reply_visibility", "following") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + end, + "Public timeline with reply filtering - self" => fn -> + public_params + |> Map.put("reply_visibility", "self") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + end + }) + + private_params = %{ + "type" => ["Create", "Announce"], + "blocking_user" => user, + "muting_user" => user, + "user" => user, + "count" => 20 + } + + recipients = [user.ap_id | User.following(user)] + + Benchee.run(%{ + "Home timeline without reply filtering" => fn -> + ActivityPub.fetch_activities(recipients, private_params) + end, + "Home timeline with reply filtering - following" => fn -> + private_params = Map.put(private_params, "reply_visibility", "following") + + ActivityPub.fetch_activities(recipients, private_params) + end, + "Home timeline with reply filtering - self" => fn -> + private_params = Map.put(private_params, "reply_visibility", "self") + ActivityPub.fetch_activities(recipients, private_params) + end + }) + end end diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 1059155cf..c97fb8c56 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -14,7 +14,7 @@ Some apps operate under the assumption that no more than 4 attachments can be re Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. - +Adding the parameter `reply_visibility` to the public and friends timelines quieries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. ## Statuses - `visibility`: has an additional possible value `list` diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 477237756..b451202b2 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -832,6 +832,7 @@ def set_cache({:error, err}), do: {:error, err} def set_cache(%User{} = user) do Cachex.put(:user_cache, "ap_id:#{user.ap_id}", user) Cachex.put(:user_cache, "nickname:#{user.nickname}", user) + Cachex.put(:user_cache, "friends_ap_ids:#{user.nickname}", get_user_friends_ap_ids(user)) {:ok, user} end @@ -847,9 +848,22 @@ def update_and_set_cache(changeset) do end end + def get_user_friends_ap_ids(user) do + from(u in User.get_friends_query(user), select: u.ap_id) + |> Repo.all() + end + + @spec get_cached_user_friends_ap_ids(User.t()) :: [String.t()] + def get_cached_user_friends_ap_ids(user) do + Cachex.fetch!(:user_cache, "friends_ap_ids:#{user.ap_id}", fn _ -> + get_user_friends_ap_ids(user) + end) + end + def invalidate_cache(user) do Cachex.del(:user_cache, "ap_id:#{user.ap_id}") Cachex.del(:user_cache, "nickname:#{user.nickname}") + Cachex.del(:user_cache, "friends_ap_ids:#{user.ap_id}") end @spec get_cached_by_ap_id(String.t()) :: User.t() | nil diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index ec88088cf..ac77aab71 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -54,13 +54,13 @@ defmodule Pleroma.User.Query do select: term(), limit: pos_integer() } - | %{} + | map() @ilike_criteria [:nickname, :name, :query] @equal_criteria [:email] @contains_criteria [:ap_id, :nickname] - @spec build(criteria()) :: Query.t() + @spec build(Query.t(), criteria()) :: Query.t() def build(query \\ base_query(), criteria) do prepare_query(query, criteria) end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c67b3335d..8b170b7f8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -270,9 +270,9 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param ), {:ok, activity} <- insert(create_data, local, fake), {:fake, false, activity} <- {:fake, fake, activity}, + {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, _ <- increase_replies_count_if_reply(create_data), _ <- increase_poll_votes_if_vote(create_data), - {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:ok, _actor} <- increase_note_count_if_public(actor, activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -700,12 +700,14 @@ def fetch_activities_for_context_query(context, opts) do do: [opts["user"].ap_id | User.following(opts["user"])] ++ public, else: public + opts = Map.put(opts, "user", opts["user"]) + from(activity in Activity) |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_set_thread_muted_field(opts) |> restrict_blocked(opts) - |> restrict_recipients(recipients, opts["user"]) + |> restrict_recipients(recipients, opts) |> where( [activity], fragment( @@ -740,7 +742,10 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do - opts = Map.drop(opts, ["user"]) + opts = + opts + |> Map.put("reply_user", opts["user"]) + |> Map.delete("user") [Constants.as_public()] |> fetch_activities_query(opts) @@ -976,13 +981,65 @@ defp restrict_tag(query, %{"tag" => tag}) when is_binary(tag) do defp restrict_tag(query, _), do: query - defp restrict_recipients(query, [], _user), do: query + defp reply_recipients(user, "following") do + [user.ap_id | User.get_cached_user_friends_ap_ids(user)] + end + + defp reply_recipients(user, "self"), do: [user.ap_id] + + defp restrict_recipients(query, [], _opts), do: query + + defp restrict_recipients( + query, + recipients, + %{"user" => nil, "reply_user" => user, "reply_visibility" => visibility} + ) + when not is_nil(user) and visibility in ["following", "self"] do + reply_recipients = reply_recipients(user, visibility) - defp restrict_recipients(query, recipients, nil) do - from(activity in query, where: fragment("? && ?", ^recipients, activity.recipients)) + from([activity, object] in query, + where: + fragment( + "? && ? AND (?->>'inReplyTo' IS NULL OR array_remove(?, ?) && ? OR ? = ?)", + ^recipients, + activity.recipients, + object.data, + activity.recipients, + activity.actor, + ^reply_recipients, + activity.actor, + ^user.ap_id + ) + ) end - defp restrict_recipients(query, recipients, user) do + defp restrict_recipients(query, recipients, %{"user" => nil}) do + from(activity in query, + where: fragment("? && ?", ^recipients, activity.recipients) + ) + end + + defp restrict_recipients(query, recipients, %{"user" => user, "reply_visibility" => visibility}) + when visibility in ["following", "self"] do + reply_recipients = reply_recipients(user, visibility) + + from( + [activity, object] in query, + where: + fragment( + "? && ? AND (?->>'inReplyTo' IS NULL OR array_remove(?, ?) && ?)", + ^recipients, + activity.recipients, + object.data, + activity.recipients, + activity.actor, + ^reply_recipients + ), + or_where: activity.actor == ^user.ap_id + ) + end + + defp restrict_recipients(query, recipients, %{"user" => user}) do from( activity in query, where: fragment("? && ?", ^recipients, activity.recipients), @@ -1254,13 +1311,15 @@ def fetch_activities_query(recipients, opts \\ %{}) do skip_thread_containment: Config.get([:instance, :skip_thread_containment]) } + opts = Map.put(opts, "user", opts["user"]) + Activity |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_preload_report_notes(opts) |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) - |> restrict_recipients(recipients, opts["user"]) + |> restrict_recipients(recipients, opts) |> restrict_tag(opts) |> restrict_tag_reject(opts) |> restrict_tag_all(opts) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index c1cd15bb2..244cf2be5 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -84,14 +84,18 @@ defp attachments(%{params: params} = draft) do %__MODULE__{draft | attachments: attachments} end - defp in_reply_to(draft) do - case Map.get(draft.params, "in_reply_to_status_id") do - "" -> draft - nil -> draft - id -> %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} - end + defp in_reply_to(%{params: %{"in_reply_to_status_id" => ""}} = draft), do: draft + + defp in_reply_to(%{params: %{"in_reply_to_status_id" => id}} = draft) when is_binary(id) do + %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} end + defp in_reply_to(%{params: %{"in_reply_to_status_id" => %Activity{} = in_reply_to}} = draft) do + %__MODULE__{draft | in_reply_to: in_reply_to} + end + + defp in_reply_to(draft), do: draft + defp in_reply_to_conversation(draft) do in_reply_to_conversation = Participation.get(draft.params["in_reply_to_conversation_id"]) %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index b3c58005e..a2ac9301e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -100,6 +100,7 @@ def public(%{assigns: %{user: user}} = conn, params) do |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) + |> Map.put("user", user) |> ActivityPub.fetch_public_activities() conn diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 53176917e..8a1638a23 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1910,4 +1910,490 @@ test "old user must be in the new user's `also_known_as` list" do ActivityPub.move(old_user, new_user) end end + + test "doesn't retrieve replies activities with exclude_replies" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "yeah"}) + + {:ok, _reply} = + CommonAPI.post(user, %{"status" => "yeah", "in_reply_to_status_id" => activity.id}) + + [result] = ActivityPub.fetch_public_activities(%{"exclude_replies" => "true"}) + + assert result.id == activity.id + + assert length(ActivityPub.fetch_public_activities()) == 2 + end + + describe "replies filtering with public messages" do + setup :public_messages + + test "public timeline", %{users: %{u1: user}} do + activities_ids = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", false) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.map(& &1.id) + + assert length(activities_ids) == 16 + end + + test "public timeline with reply_visibility `following`", %{ + users: %{u1: user}, + u1: u1, + u2: u2, + u3: u3, + u4: u4, + activities: activities + } do + activities_ids = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", false) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("reply_visibility", "following") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.map(& &1.id) + + assert length(activities_ids) == 14 + + visible_ids = + Map.values(u1) ++ Map.values(u2) ++ Map.values(u4) ++ Map.values(activities) ++ [u3[:r1]] + + assert Enum.all?(visible_ids, &(&1 in activities_ids)) + end + + test "public timeline with reply_visibility `self`", %{ + users: %{u1: user}, + u1: u1, + u2: u2, + u3: u3, + u4: u4, + activities: activities + } do + activities_ids = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", false) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("reply_visibility", "self") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.map(& &1.id) + + assert length(activities_ids) == 10 + visible_ids = Map.values(u1) ++ [u2[:r1], u3[:r1], u4[:r1]] ++ Map.values(activities) + assert Enum.all?(visible_ids, &(&1 in activities_ids)) + end + + test "home timeline", %{ + users: %{u1: user}, + activities: activities, + u1: u1, + u2: u2, + u3: u3, + u4: u4 + } do + params = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + + activities_ids = + ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) + |> Enum.map(& &1.id) + + assert length(activities_ids) == 13 + + visible_ids = + Map.values(u1) ++ + Map.values(u3) ++ + [ + activities[:a1], + activities[:a2], + activities[:a4], + u2[:r1], + u2[:r3], + u4[:r1], + u4[:r2] + ] + + assert Enum.all?(visible_ids, &(&1 in activities_ids)) + end + + test "home timeline with reply_visibility `following`", %{ + users: %{u1: user}, + activities: activities, + u1: u1, + u2: u2, + u3: u3, + u4: u4 + } do + params = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("reply_visibility", "following") + + activities_ids = + ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) + |> Enum.map(& &1.id) + + assert length(activities_ids) == 11 + + visible_ids = + Map.values(u1) ++ + [ + activities[:a1], + activities[:a2], + activities[:a4], + u2[:r1], + u2[:r3], + u3[:r1], + u4[:r1], + u4[:r2] + ] + + assert Enum.all?(visible_ids, &(&1 in activities_ids)) + end + + test "home timeline with reply_visibility `self`", %{ + users: %{u1: user}, + activities: activities, + u1: u1, + u2: u2, + u3: u3, + u4: u4 + } do + params = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("reply_visibility", "self") + + activities_ids = + ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) + |> Enum.map(& &1.id) + + assert length(activities_ids) == 9 + + visible_ids = + Map.values(u1) ++ + [ + activities[:a1], + activities[:a2], + activities[:a4], + u2[:r1], + u3[:r1], + u4[:r1] + ] + + assert Enum.all?(visible_ids, &(&1 in activities_ids)) + end + end + + describe "replies filtering with private messages" do + setup :private_messages + + test "public timeline", %{users: %{u1: user}} do + activities_ids = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", false) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.map(& &1.id) + + assert activities_ids == [] + end + + test "public timeline with default reply_visibility `following`", %{users: %{u1: user}} do + activities_ids = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", false) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("reply_visibility", "following") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.map(& &1.id) + + assert activities_ids == [] + end + + test "public timeline with default reply_visibility `self`", %{users: %{u1: user}} do + activities_ids = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("local_only", false) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("reply_visibility", "self") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + |> Enum.map(& &1.id) + + assert activities_ids == [] + end + + test "home timeline", %{users: %{u1: user}} do + params = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + + activities_ids = + ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) + |> Enum.map(& &1.id) + + assert length(activities_ids) == 12 + end + + test "home timeline with default reply_visibility `following`", %{users: %{u1: user}} do + params = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("reply_visibility", "following") + + activities_ids = + ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) + |> Enum.map(& &1.id) + + assert length(activities_ids) == 12 + end + + test "home timeline with default reply_visibility `self`", %{ + users: %{u1: user}, + activities: activities, + u1: u1, + u2: u2, + u3: u3, + u4: u4 + } do + params = + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("reply_visibility", "self") + + activities_ids = + ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) + |> Enum.map(& &1.id) + + assert length(activities_ids) == 10 + + visible_ids = + Map.values(u1) ++ Map.values(u4) ++ [u2[:r1], u3[:r1]] ++ Map.values(activities) + + assert Enum.all?(visible_ids, &(&1 in activities_ids)) + end + end + + defp public_messages(_) do + [u1, u2, u3, u4] = insert_list(4, :user) + {:ok, u1} = User.follow(u1, u2) + {:ok, u2} = User.follow(u2, u1) + {:ok, u1} = User.follow(u1, u4) + {:ok, u4} = User.follow(u4, u1) + + {:ok, u2} = User.follow(u2, u3) + {:ok, u3} = User.follow(u3, u2) + + {:ok, a1} = CommonAPI.post(u1, %{"status" => "Status"}) + + {:ok, r1_1} = + CommonAPI.post(u2, %{ + "status" => "@#{u1.nickname} reply from u2 to u1", + "in_reply_to_status_id" => a1.id + }) + + {:ok, r1_2} = + CommonAPI.post(u3, %{ + "status" => "@#{u1.nickname} reply from u3 to u1", + "in_reply_to_status_id" => a1.id + }) + + {:ok, r1_3} = + CommonAPI.post(u4, %{ + "status" => "@#{u1.nickname} reply from u4 to u1", + "in_reply_to_status_id" => a1.id + }) + + {:ok, a2} = CommonAPI.post(u2, %{"status" => "Status"}) + + {:ok, r2_1} = + CommonAPI.post(u1, %{ + "status" => "@#{u2.nickname} reply from u1 to u2", + "in_reply_to_status_id" => a2.id + }) + + {:ok, r2_2} = + CommonAPI.post(u3, %{ + "status" => "@#{u2.nickname} reply from u3 to u2", + "in_reply_to_status_id" => a2.id + }) + + {:ok, r2_3} = + CommonAPI.post(u4, %{ + "status" => "@#{u2.nickname} reply from u4 to u2", + "in_reply_to_status_id" => a2.id + }) + + {:ok, a3} = CommonAPI.post(u3, %{"status" => "Status"}) + + {:ok, r3_1} = + CommonAPI.post(u1, %{ + "status" => "@#{u3.nickname} reply from u1 to u3", + "in_reply_to_status_id" => a3.id + }) + + {:ok, r3_2} = + CommonAPI.post(u2, %{ + "status" => "@#{u3.nickname} reply from u2 to u3", + "in_reply_to_status_id" => a3.id + }) + + {:ok, r3_3} = + CommonAPI.post(u4, %{ + "status" => "@#{u3.nickname} reply from u4 to u3", + "in_reply_to_status_id" => a3.id + }) + + {:ok, a4} = CommonAPI.post(u4, %{"status" => "Status"}) + + {:ok, r4_1} = + CommonAPI.post(u1, %{ + "status" => "@#{u4.nickname} reply from u1 to u4", + "in_reply_to_status_id" => a4.id + }) + + {:ok, r4_2} = + CommonAPI.post(u2, %{ + "status" => "@#{u4.nickname} reply from u2 to u4", + "in_reply_to_status_id" => a4.id + }) + + {:ok, r4_3} = + CommonAPI.post(u3, %{ + "status" => "@#{u4.nickname} reply from u3 to u4", + "in_reply_to_status_id" => a4.id + }) + + {:ok, + users: %{u1: u1, u2: u2, u3: u3, u4: u4}, + activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id}, + u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id}, + u2: %{r1: r2_1.id, r2: r2_2.id, r3: r2_3.id}, + u3: %{r1: r3_1.id, r2: r3_2.id, r3: r3_3.id}, + u4: %{r1: r4_1.id, r2: r4_2.id, r3: r4_3.id}} + end + + defp private_messages(_) do + [u1, u2, u3, u4] = insert_list(4, :user) + {:ok, u1} = User.follow(u1, u2) + {:ok, u2} = User.follow(u2, u1) + {:ok, u1} = User.follow(u1, u3) + {:ok, u3} = User.follow(u3, u1) + {:ok, u1} = User.follow(u1, u4) + {:ok, u4} = User.follow(u4, u1) + + {:ok, u2} = User.follow(u2, u3) + {:ok, u3} = User.follow(u3, u2) + + {:ok, a1} = CommonAPI.post(u1, %{"status" => "Status", "visibility" => "private"}) + + {:ok, r1_1} = + CommonAPI.post(u2, %{ + "status" => "@#{u1.nickname} reply from u2 to u1", + "in_reply_to_status_id" => a1.id, + "visibility" => "private" + }) + + {:ok, r1_2} = + CommonAPI.post(u3, %{ + "status" => "@#{u1.nickname} reply from u3 to u1", + "in_reply_to_status_id" => a1.id, + "visibility" => "private" + }) + + {:ok, r1_3} = + CommonAPI.post(u4, %{ + "status" => "@#{u1.nickname} reply from u4 to u1", + "in_reply_to_status_id" => a1.id, + "visibility" => "private" + }) + + {:ok, a2} = CommonAPI.post(u2, %{"status" => "Status", "visibility" => "private"}) + + {:ok, r2_1} = + CommonAPI.post(u1, %{ + "status" => "@#{u2.nickname} reply from u1 to u2", + "in_reply_to_status_id" => a2.id, + "visibility" => "private" + }) + + {:ok, r2_2} = + CommonAPI.post(u3, %{ + "status" => "@#{u2.nickname} reply from u3 to u2", + "in_reply_to_status_id" => a2.id, + "visibility" => "private" + }) + + {:ok, a3} = CommonAPI.post(u3, %{"status" => "Status", "visibility" => "private"}) + + {:ok, r3_1} = + CommonAPI.post(u1, %{ + "status" => "@#{u3.nickname} reply from u1 to u3", + "in_reply_to_status_id" => a3.id, + "visibility" => "private" + }) + + {:ok, r3_2} = + CommonAPI.post(u2, %{ + "status" => "@#{u3.nickname} reply from u2 to u3", + "in_reply_to_status_id" => a3.id, + "visibility" => "private" + }) + + {:ok, a4} = CommonAPI.post(u4, %{"status" => "Status", "visibility" => "private"}) + + {:ok, r4_1} = + CommonAPI.post(u1, %{ + "status" => "@#{u4.nickname} reply from u1 to u4", + "in_reply_to_status_id" => a4.id, + "visibility" => "private" + }) + + {:ok, + users: %{u1: u1, u2: u2, u3: u3, u4: u4}, + activities: %{a1: a1.id, a2: a2.id, a3: a3.id, a4: a4.id}, + u1: %{r1: r1_1.id, r2: r1_2.id, r3: r1_3.id}, + u2: %{r1: r2_1.id, r2: r2_2.id}, + u3: %{r1: r3_1.id, r2: r3_2.id}, + u4: %{r1: r4_1.id}} + end end -- cgit v1.2.3 From be34672d6768bdc9ece96669e07e940a98c9d933 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Dec 2019 10:29:26 +0300 Subject: formatting --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index c97fb8c56..92086136f 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -15,6 +15,7 @@ Some apps operate under the assumption that no more than 4 attachments can be re Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. Adding the parameter `reply_visibility` to the public and friends timelines quieries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. + ## Statuses - `visibility`: has an additional possible value `list` -- cgit v1.2.3 From 1a75ef63b2f6fef96b9bf9d07b4963fb217d4017 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 24 Apr 2020 09:24:08 +0300 Subject: updating benchmarks --- benchmarks/load_testing/activities.ex | 2 +- benchmarks/load_testing/fetcher.ex | 89 +++++++++++++++++------------------ 2 files changed, 43 insertions(+), 48 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index 23ee2b987..2b032943b 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -313,7 +313,7 @@ defp insert_activity("simple_thread", visibility, group, user, friends, non_frie tasks = get_reply_tasks(visibility, group) {:ok, activity} = - CommonAPI.post(user, %{"status" => "Simple status", "visibility" => "unlisted"}) + CommonAPI.post(user, %{"status" => "Simple status", "visibility" => visibility}) acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} insert_replies(tasks, visibility, user, friends, non_friends, acc) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 3aa82b48a..6503deb41 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -41,6 +41,7 @@ defp fetch_timelines(user) do fetch_notifications(user) fetch_favourites(user) fetch_long_thread(user) + fetch_timelines_with_reply_filtering(user) end defp render_views(user) do @@ -496,56 +497,50 @@ defp render_long_thread(user) do ) end - def query_replies(user) do - public_params = %{ - "type" => ["Create", "Announce"], - "local_only" => false, - "blocking_user" => user, - "muting_user" => user, - "count" => 20 - } + defp fetch_timelines_with_reply_filtering(user) do + public_params = opts_for_public_timeline(user) - Benchee.run(%{ - "Public timeline without reply filtering" => fn -> - ActivityPub.fetch_public_activities(public_params) - end, - "Public timeline with reply filtering - following" => fn -> - public_params - |> Map.put("reply_visibility", "following") - |> Map.put("user", user) - |> ActivityPub.fetch_public_activities() - end, - "Public timeline with reply filtering - self" => fn -> - public_params - |> Map.put("reply_visibility", "self") - |> Map.put("user", user) - |> ActivityPub.fetch_public_activities() - end - }) - - private_params = %{ - "type" => ["Create", "Announce"], - "blocking_user" => user, - "muting_user" => user, - "user" => user, - "count" => 20 - } + Benchee.run( + %{ + "Public timeline without reply filtering" => fn -> + ActivityPub.fetch_public_activities(public_params) + end, + "Public timeline with reply filtering - following" => fn -> + public_params + |> Map.put("reply_visibility", "following") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + end, + "Public timeline with reply filtering - self" => fn -> + public_params + |> Map.put("reply_visibility", "self") + |> Map.put("user", user) + |> ActivityPub.fetch_public_activities() + end + }, + formatters: formatters() + ) + + private_params = opts_for_home_timeline(user) recipients = [user.ap_id | User.following(user)] - Benchee.run(%{ - "Home timeline without reply filtering" => fn -> - ActivityPub.fetch_activities(recipients, private_params) - end, - "Home timeline with reply filtering - following" => fn -> - private_params = Map.put(private_params, "reply_visibility", "following") - - ActivityPub.fetch_activities(recipients, private_params) - end, - "Home timeline with reply filtering - self" => fn -> - private_params = Map.put(private_params, "reply_visibility", "self") - ActivityPub.fetch_activities(recipients, private_params) - end - }) + Benchee.run( + %{ + "Home timeline without reply filtering" => fn -> + ActivityPub.fetch_activities(recipients, private_params) + end, + "Home timeline with reply filtering - following" => fn -> + private_params = Map.put(private_params, "reply_visibility", "following") + + ActivityPub.fetch_activities(recipients, private_params) + end, + "Home timeline with reply filtering - self" => fn -> + private_params = Map.put(private_params, "reply_visibility", "self") + ActivityPub.fetch_activities(recipients, private_params) + end + }, + formatters: formatters() + ) end end -- cgit v1.2.3 From 375ab05234a3590c161a2a5a4f715fbc61dafb34 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 24 Apr 2020 09:57:30 +0300 Subject: bench sync --- benchmarks/load_testing/activities.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index 2b032943b..482e42fc1 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -279,7 +279,7 @@ defp insert_activity("like", visibility, group, user, friends, non_friends, opts 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, _activity} <- CommonAPI.favorite(actor, activity_id) do :ok else {:error, _} -> -- cgit v1.2.3 From 8480f84615b696965d3c1ca34b5847af99fbdece Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 24 Apr 2020 10:26:54 +0000 Subject: Update differences_in_mastoapi_responses.md --- docs/API/differences_in_mastoapi_responses.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 92086136f..41ceda26b 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -14,7 +14,7 @@ Some apps operate under the assumption that no more than 4 attachments can be re Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. -Adding the parameter `reply_visibility` to the public and friends timelines quieries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. +Adding the parameter `reply_visibility` to the public and home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. ## Statuses -- cgit v1.2.3 From e2f3030c868ca087915294909dee304f7de1730f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 24 Apr 2020 10:27:51 +0000 Subject: Apply suggestion to CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index affabcd95..ccc6a5bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,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. -- Mastodon API: Add support for filtering replies in public and friends timelines +- Mastodon API: Add support for filtering replies in public and home timelines - Admin API: endpoints for create/update/delete OAuth Apps.
    -- cgit v1.2.3 From 00e62161f64802317d7d789e7eac42c33f0540f5 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 24 Apr 2020 16:52:38 +0300 Subject: [#2409] Tested all auth setup configs in AuthTestControllerTest. Adjusted :skip_plug definitions for some endpoints. --- lib/pleroma/tests/auth_test_controller.ex | 93 ++++++++ lib/pleroma/tests/oauth_test_controller.ex | 31 --- lib/pleroma/web/masto_fe_controller.ex | 5 +- .../mastodon_api/controllers/account_controller.ex | 13 +- .../mastodon_api/controllers/status_controller.ex | 4 +- .../controllers/timeline_controller.ex | 4 +- lib/pleroma/web/oauth/oauth_controller.ex | 5 +- .../pleroma_api/controllers/account_controller.ex | 5 +- lib/pleroma/web/router.ex | 21 +- .../web/twitter_api/twitter_api_controller.ex | 8 +- test/web/auth/auth_test_controller_test.exs | 242 +++++++++++++++++++++ test/web/auth/oauth_test_controller_test.exs | 49 ----- .../mastodon_api/mastodon_api_controller_test.exs | 33 ++- 13 files changed, 392 insertions(+), 121 deletions(-) create mode 100644 lib/pleroma/tests/auth_test_controller.ex delete mode 100644 lib/pleroma/tests/oauth_test_controller.ex create mode 100644 test/web/auth/auth_test_controller_test.exs delete mode 100644 test/web/auth/oauth_test_controller_test.exs diff --git a/lib/pleroma/tests/auth_test_controller.ex b/lib/pleroma/tests/auth_test_controller.ex new file mode 100644 index 000000000..fb04411d9 --- /dev/null +++ b/lib/pleroma/tests/auth_test_controller.ex @@ -0,0 +1,93 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# A test controller reachable only in :test env. +defmodule Pleroma.Tests.AuthTestController do + @moduledoc false + + use Pleroma.Web, :controller + + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.User + + # Serves only with proper OAuth token (:api and :authenticated_api) + # Skipping EnsurePublicOrAuthenticatedPlug has no effect in this case + # + # Suggested use case: all :authenticated_api endpoints (makes no sense for :api endpoints) + plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :do_oauth_check) + + # Via :api, keeps :user if token has requested scopes (if :user is dropped, serves if public) + # Via :authenticated_api, serves if token is present and has requested scopes + # + # Suggested use case: vast majority of :api endpoints (no sense for :authenticated_api ones) + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} + when action == :fallback_oauth_check + ) + + # Keeps :user if present, executes regardless of token / token scopes + # Fails with no :user for :authenticated_api / no user for :api on private instance + # Note: EnsurePublicOrAuthenticatedPlug is not skipped (private instance fails on no :user) + # Note: Basic Auth processing results in :skip_plug call for OAuthScopesPlug + # + # Suggested use: suppressing OAuth checks for other auth mechanisms (like Basic Auth) + # For controller-level use, see :skip_oauth_skip_publicity_check instead + plug( + :skip_plug, + OAuthScopesPlug when action == :skip_oauth_check + ) + + # (Shouldn't be executed since the plug is skipped) + plug(OAuthScopesPlug, %{scopes: ["admin"]} when action == :skip_oauth_check) + + # Via :api, keeps :user if token has requested scopes, and continues with nil :user otherwise + # Via :authenticated_api, serves if token is present and has requested scopes + # + # Suggested use: as :fallback_oauth_check but open with nil :user for :api on private instances + plug( + :skip_plug, + EnsurePublicOrAuthenticatedPlug when action == :fallback_oauth_skip_publicity_check + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read"], fallback: :proceed_unauthenticated} + when action == :fallback_oauth_skip_publicity_check + ) + + # Via :api, keeps :user if present, serves regardless of token presence / scopes / :user presence + # Via :authenticated_api, serves if :user is set (regardless of token presence and its scopes) + # + # Suggested use: making an :api endpoint always accessible (e.g. email confirmation endpoint) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] + when action == :skip_oauth_skip_publicity_check + ) + + # Via :authenticated_api, always fails with 403 (endpoint is insecure) + # Via :api, drops :user if present and serves if public (private instance rejects on no user) + # + # Suggested use: none; please define OAuth rules for all :api / :authenticated_api endpoints + plug(:skip_plug, [] when action == :missing_oauth_check_definition) + + def do_oauth_check(conn, _params), do: conn_state(conn) + + def fallback_oauth_check(conn, _params), do: conn_state(conn) + + def skip_oauth_check(conn, _params), do: conn_state(conn) + + def fallback_oauth_skip_publicity_check(conn, _params), do: conn_state(conn) + + def skip_oauth_skip_publicity_check(conn, _params), do: conn_state(conn) + + def missing_oauth_check_definition(conn, _params), do: conn_state(conn) + + defp conn_state(%{assigns: %{user: %User{} = user}} = conn), + do: json(conn, %{user_id: user.id}) + + defp conn_state(conn), do: json(conn, %{user_id: nil}) +end diff --git a/lib/pleroma/tests/oauth_test_controller.ex b/lib/pleroma/tests/oauth_test_controller.ex deleted file mode 100644 index 58d517f78..000000000 --- a/lib/pleroma/tests/oauth_test_controller.ex +++ /dev/null @@ -1,31 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -# A test controller reachable only in :test env. -# Serves to test OAuth scopes check skipping / enforcement. -defmodule Pleroma.Tests.OAuthTestController do - @moduledoc false - - use Pleroma.Web, :controller - - alias Pleroma.Plugs.OAuthScopesPlug - - plug(:skip_plug, OAuthScopesPlug when action == :skipped_oauth) - - plug(OAuthScopesPlug, %{scopes: ["read"]} when action != :missed_oauth) - - def skipped_oauth(conn, _params) do - noop(conn) - end - - def performed_oauth(conn, _params) do - noop(conn) - end - - def missed_oauth(conn, _params) do - noop(conn) - end - - defp noop(conn), do: json(conn, %{}) -end diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 9a2ec517a..d0d8bc8eb 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -5,12 +5,15 @@ defmodule Pleroma.Web.MastoFEController do use Pleroma.Web, :controller + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings) # Note: :index action handles attempt of unauthenticated access to private instance with redirect + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action == :index) + plug( OAuthScopesPlug, %{scopes: ["read"], fallback: :proceed_unauthenticated} @@ -19,7 +22,7 @@ defmodule Pleroma.Web.MastoFEController do plug( :skip_plug, - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :manifest] + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :manifest ) @doc "GET /web/*path" diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index e3465e659..f39825e08 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do skip_relationships?: 1 ] + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User @@ -26,18 +27,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI - plug(:skip_plug, OAuthScopesPlug when action in [:create, :identity_proofs]) + plug(:skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :create) - plug( - :skip_plug, - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug - when action in [:create, :show, :statuses] - ) + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:show, :statuses]) plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :followers, :following, :endorsements] + when action in [:show, :followers, :following] ) plug( @@ -49,7 +46,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} - when action in [:endorsements, :verify_credentials] + when action in [:verify_credentials, :endorsements, :identity_proofs] ) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index eade83aaf..4fa9a2120 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -24,6 +24,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.ScheduledActivityView + plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :show]) + @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} plug( @@ -77,8 +79,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do %{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark] ) - plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :show]) - @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a plug( diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 040a0b9dd..fb6b18ed5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:public, :hashtag]) + # TODO: Replace with a macro when there is a Phoenix release with the following commit in it: # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e @@ -33,8 +35,6 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do when action in [:public, :hashtag] ) - plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:public, :hashtag]) - plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) # GET /api/v1/timelines/home diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 0121cd661..685269877 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -25,9 +25,10 @@ defmodule Pleroma.Web.OAuth.OAuthController do plug(:fetch_session) plug(:fetch_flash) - plug(RateLimiter, [name: :authentication] when action == :create_authorization) - plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug) + plug(:skip_plug, [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]) + + plug(RateLimiter, [name: :authentication] when action == :create_authorization) action_fallback(Pleroma.Web.OAuth.FallbackController) diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index d6ffdcbe4..237c8157e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2, skip_relationships?: 1] alias Ecto.Changeset + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.User @@ -17,11 +18,9 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do require Pleroma.Constants - plug(:skip_plug, OAuthScopesPlug when action == :confirmation_resend) - plug( :skip_plug, - Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action == :confirmation_resend + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirmation_resend ) plug( diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index db158d366..57efc3314 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -655,11 +655,28 @@ defmodule Pleroma.Web.Router do # Test-only routes needed to test action dispatching and plug chain execution if Pleroma.Config.get(:env) == :test do + @test_actions [ + :do_oauth_check, + :fallback_oauth_check, + :skip_oauth_check, + :fallback_oauth_skip_publicity_check, + :skip_oauth_skip_publicity_check, + :missing_oauth_check_definition + ] + + scope "/test/api", Pleroma.Tests do + pipe_through(:api) + + for action <- @test_actions do + get("/#{action}", AuthTestController, action) + end + end + scope "/test/authenticated_api", Pleroma.Tests do pipe_through(:authenticated_api) - for action <- [:skipped_oauth, :performed_oauth, :missed_oauth] do - get("/#{action}", OAuthTestController, action) + for action <- @test_actions do + get("/#{action}", AuthTestController, action) end end end diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index e4f182b02..c2de26b0b 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do use Pleroma.Web, :controller alias Pleroma.Notification + alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.OAuth.Token @@ -18,7 +19,12 @@ defmodule Pleroma.Web.TwitterAPI.Controller do %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read ) - plug(:skip_plug, OAuthScopesPlug when action in [:confirm_email, :oauth_tokens, :revoke_token]) + plug( + :skip_plug, + [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirm_email + ) + + plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) action_fallback(:errors) diff --git a/test/web/auth/auth_test_controller_test.exs b/test/web/auth/auth_test_controller_test.exs new file mode 100644 index 000000000..fed52b7f3 --- /dev/null +++ b/test/web/auth/auth_test_controller_test.exs @@ -0,0 +1,242 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Tests.AuthTestControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + describe "do_oauth_check" do + test "serves with proper OAuth token (fulfilling requested scopes)" do + %{conn: good_token_conn, user: user} = oauth_access(["read"]) + + assert %{"user_id" => user.id} == + good_token_conn + |> get("/test/authenticated_api/do_oauth_check") + |> json_response(200) + + # Unintended usage (:api) — use with :authenticated_api instead + assert %{"user_id" => user.id} == + good_token_conn + |> get("/test/api/do_oauth_check") + |> json_response(200) + end + + test "fails on no token / missing scope(s)" do + %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"]) + + bad_token_conn + |> get("/test/authenticated_api/do_oauth_check") + |> json_response(403) + + bad_token_conn + |> assign(:token, nil) + |> get("/test/api/do_oauth_check") + |> json_response(403) + end + end + + describe "fallback_oauth_check" do + test "serves with proper OAuth token (fulfilling requested scopes)" do + %{conn: good_token_conn, user: user} = oauth_access(["read"]) + + assert %{"user_id" => user.id} == + good_token_conn + |> get("/test/api/fallback_oauth_check") + |> json_response(200) + + # Unintended usage (:authenticated_api) — use with :api instead + assert %{"user_id" => user.id} == + good_token_conn + |> get("/test/authenticated_api/fallback_oauth_check") + |> json_response(200) + end + + test "for :api on public instance, drops :user and renders on no token / missing scope(s)" do + clear_config([:instance, :public], true) + + %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"]) + + assert %{"user_id" => nil} == + bad_token_conn + |> get("/test/api/fallback_oauth_check") + |> json_response(200) + + assert %{"user_id" => nil} == + bad_token_conn + |> assign(:token, nil) + |> get("/test/api/fallback_oauth_check") + |> json_response(200) + end + + test "for :api on private instance, fails on no token / missing scope(s)" do + clear_config([:instance, :public], false) + + %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"]) + + bad_token_conn + |> get("/test/api/fallback_oauth_check") + |> json_response(403) + + bad_token_conn + |> assign(:token, nil) + |> get("/test/api/fallback_oauth_check") + |> json_response(403) + end + end + + describe "skip_oauth_check" do + test "for :authenticated_api, serves if :user is set (regardless of token / token scopes)" do + user = insert(:user) + + assert %{"user_id" => user.id} == + build_conn() + |> assign(:user, user) + |> get("/test/authenticated_api/skip_oauth_check") + |> json_response(200) + + %{conn: bad_token_conn, user: user} = oauth_access(["irrelevant_scope"]) + + assert %{"user_id" => user.id} == + bad_token_conn + |> get("/test/authenticated_api/skip_oauth_check") + |> json_response(200) + end + + test "serves via :api on public instance if :user is not set" do + clear_config([:instance, :public], true) + + assert %{"user_id" => nil} == + build_conn() + |> get("/test/api/skip_oauth_check") + |> json_response(200) + + build_conn() + |> get("/test/authenticated_api/skip_oauth_check") + |> json_response(403) + end + + test "fails on private instance if :user is not set" do + clear_config([:instance, :public], false) + + build_conn() + |> get("/test/api/skip_oauth_check") + |> json_response(403) + + build_conn() + |> get("/test/authenticated_api/skip_oauth_check") + |> json_response(403) + end + end + + describe "fallback_oauth_skip_publicity_check" do + test "serves with proper OAuth token (fulfilling requested scopes)" do + %{conn: good_token_conn, user: user} = oauth_access(["read"]) + + assert %{"user_id" => user.id} == + good_token_conn + |> get("/test/api/fallback_oauth_skip_publicity_check") + |> json_response(200) + + # Unintended usage (:authenticated_api) + assert %{"user_id" => user.id} == + good_token_conn + |> get("/test/authenticated_api/fallback_oauth_skip_publicity_check") + |> json_response(200) + end + + test "for :api on private / public instance, drops :user and renders on token issue" do + %{conn: bad_token_conn} = oauth_access(["irrelevant_scope"]) + + for is_public <- [true, false] do + clear_config([:instance, :public], is_public) + + assert %{"user_id" => nil} == + bad_token_conn + |> get("/test/api/fallback_oauth_skip_publicity_check") + |> json_response(200) + + assert %{"user_id" => nil} == + bad_token_conn + |> assign(:token, nil) + |> get("/test/api/fallback_oauth_skip_publicity_check") + |> json_response(200) + end + end + end + + describe "skip_oauth_skip_publicity_check" do + test "for :authenticated_api, serves if :user is set (regardless of token / token scopes)" do + user = insert(:user) + + assert %{"user_id" => user.id} == + build_conn() + |> assign(:user, user) + |> get("/test/authenticated_api/skip_oauth_skip_publicity_check") + |> json_response(200) + + %{conn: bad_token_conn, user: user} = oauth_access(["irrelevant_scope"]) + + assert %{"user_id" => user.id} == + bad_token_conn + |> get("/test/authenticated_api/skip_oauth_skip_publicity_check") + |> json_response(200) + end + + test "for :api, serves on private and public instances regardless of whether :user is set" do + user = insert(:user) + + for is_public <- [true, false] do + clear_config([:instance, :public], is_public) + + assert %{"user_id" => nil} == + build_conn() + |> get("/test/api/skip_oauth_skip_publicity_check") + |> json_response(200) + + assert %{"user_id" => user.id} == + build_conn() + |> assign(:user, user) + |> get("/test/api/skip_oauth_skip_publicity_check") + |> json_response(200) + end + end + end + + describe "missing_oauth_check_definition" do + def test_missing_oauth_check_definition_failure(endpoint, expected_error) do + %{conn: conn} = oauth_access(["read", "write", "follow", "push", "admin"]) + + assert %{"error" => expected_error} == + conn + |> get(endpoint) + |> json_response(403) + end + + test "fails if served via :authenticated_api" do + test_missing_oauth_check_definition_failure( + "/test/authenticated_api/missing_oauth_check_definition", + "Security violation: OAuth scopes check was neither handled nor explicitly skipped." + ) + end + + test "fails if served via :api and the instance is private" do + clear_config([:instance, :public], false) + + test_missing_oauth_check_definition_failure( + "/test/api/missing_oauth_check_definition", + "This resource requires authentication." + ) + end + + test "succeeds with dropped :user if served via :api on public instance" do + %{conn: conn} = oauth_access(["read", "write", "follow", "push", "admin"]) + + assert %{"user_id" => nil} == + conn + |> get("/test/api/missing_oauth_check_definition") + |> json_response(200) + end + end +end diff --git a/test/web/auth/oauth_test_controller_test.exs b/test/web/auth/oauth_test_controller_test.exs deleted file mode 100644 index a2f6009ac..000000000 --- a/test/web/auth/oauth_test_controller_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Tests.OAuthTestControllerTest do - use Pleroma.Web.ConnCase - - import Pleroma.Factory - - setup %{conn: conn} do - user = insert(:user) - conn = assign(conn, :user, user) - %{conn: conn, user: user} - end - - test "missed_oauth", %{conn: conn} do - res = - conn - |> get("/test/authenticated_api/missed_oauth") - |> json_response(403) - - assert res == - %{ - "error" => - "Security violation: OAuth scopes check was neither handled nor explicitly skipped." - } - end - - test "skipped_oauth", %{conn: conn} do - conn - |> assign(:token, nil) - |> get("/test/authenticated_api/skipped_oauth") - |> json_response(200) - end - - test "performed_oauth", %{user: user} do - %{conn: good_token_conn} = oauth_access(["read"], user: user) - - good_token_conn - |> get("/test/authenticated_api/performed_oauth") - |> json_response(200) - - %{conn: bad_token_conn} = oauth_access(["follow"], user: user) - - bad_token_conn - |> get("/test/authenticated_api/performed_oauth") - |> json_response(403) - end -end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 75f184242..bb4bc4396 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -7,35 +7,28 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do describe "empty_array/2 (stubs)" do test "GET /api/v1/accounts/:id/identity_proofs" do - %{user: user, conn: conn} = oauth_access(["n/a"]) + %{user: user, conn: conn} = oauth_access(["read:accounts"]) - res = - conn - |> assign(:user, user) - |> get("/api/v1/accounts/#{user.id}/identity_proofs") - |> json_response(200) - - assert res == [] + assert [] == + conn + |> get("/api/v1/accounts/#{user.id}/identity_proofs") + |> json_response(200) end test "GET /api/v1/endorsements" do %{conn: conn} = oauth_access(["read:accounts"]) - res = - conn - |> get("/api/v1/endorsements") - |> json_response(200) - - assert res == [] + assert [] == + conn + |> get("/api/v1/endorsements") + |> json_response(200) end test "GET /api/v1/trends", %{conn: conn} do - res = - conn - |> get("/api/v1/trends") - |> json_response(200) - - assert res == [] + assert [] == + conn + |> get("/api/v1/trends") + |> json_response(200) end end end -- cgit v1.2.3 From d89cd0a19733eec27b79b768df2e30a68bfc6d6b Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 24 Apr 2020 18:25:26 +0200 Subject: Reply Filtering: Refactor. --- benchmarks/load_testing/fetcher.ex | 15 ++- benchmarks/mix/tasks/pleroma/load_testing.ex | 1 + lib/pleroma/web/activity_pub/activity_pub.ex | 112 ++++++++------------- .../controllers/timeline_controller.ex | 3 +- test/web/activity_pub/activity_pub_test.exs | 13 ++- 5 files changed, 68 insertions(+), 76 deletions(-) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 6503deb41..12c30f6f5 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -508,13 +508,13 @@ defp fetch_timelines_with_reply_filtering(user) do "Public timeline with reply filtering - following" => fn -> public_params |> Map.put("reply_visibility", "following") - |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() end, "Public timeline with reply filtering - self" => fn -> public_params |> Map.put("reply_visibility", "self") - |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() end }, @@ -531,12 +531,19 @@ defp fetch_timelines_with_reply_filtering(user) do ActivityPub.fetch_activities(recipients, private_params) end, "Home timeline with reply filtering - following" => fn -> - private_params = Map.put(private_params, "reply_visibility", "following") + private_params = + private_params + |> Map.put("reply_filtering_user", user) + |> Map.put("reply_visibility", "following") ActivityPub.fetch_activities(recipients, private_params) end, "Home timeline with reply filtering - self" => fn -> - private_params = Map.put(private_params, "reply_visibility", "self") + private_params = + private_params + |> Map.put("reply_filtering_user", user) + |> Map.put("reply_visibility", "self") + ActivityPub.fetch_activities(recipients, private_params) end }, diff --git a/benchmarks/mix/tasks/pleroma/load_testing.ex b/benchmarks/mix/tasks/pleroma/load_testing.ex index 72b225f09..388883240 100644 --- a/benchmarks/mix/tasks/pleroma/load_testing.ex +++ b/benchmarks/mix/tasks/pleroma/load_testing.ex @@ -44,6 +44,7 @@ defmodule Mix.Tasks.Pleroma.LoadTesting do ] def run(args) do + Logger.configure(level: :error) Mix.Pleroma.start_pleroma() clean_tables() {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8b170b7f8..9ec31fb03 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -270,9 +270,9 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param ), {:ok, activity} <- insert(create_data, local, fake), {:fake, false, activity} <- {:fake, fake, activity}, - {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, _ <- increase_replies_count_if_reply(create_data), _ <- increase_poll_votes_if_vote(create_data), + {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:ok, _actor} <- increase_note_count_if_public(actor, activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -700,14 +700,12 @@ def fetch_activities_for_context_query(context, opts) do do: [opts["user"].ap_id | User.following(opts["user"])] ++ public, else: public - opts = Map.put(opts, "user", opts["user"]) - from(activity in Activity) |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_set_thread_muted_field(opts) |> restrict_blocked(opts) - |> restrict_recipients(recipients, opts) + |> restrict_recipients(recipients, opts["user"]) |> where( [activity], fragment( @@ -742,10 +740,7 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do - opts = - opts - |> Map.put("reply_user", opts["user"]) - |> Map.delete("user") + opts = Map.drop(opts, ["user"]) [Constants.as_public()] |> fetch_activities_query(opts) @@ -981,65 +976,13 @@ defp restrict_tag(query, %{"tag" => tag}) when is_binary(tag) do defp restrict_tag(query, _), do: query - defp reply_recipients(user, "following") do - [user.ap_id | User.get_cached_user_friends_ap_ids(user)] - end - - defp reply_recipients(user, "self"), do: [user.ap_id] - - defp restrict_recipients(query, [], _opts), do: query - - defp restrict_recipients( - query, - recipients, - %{"user" => nil, "reply_user" => user, "reply_visibility" => visibility} - ) - when not is_nil(user) and visibility in ["following", "self"] do - reply_recipients = reply_recipients(user, visibility) + defp restrict_recipients(query, [], _user), do: query - from([activity, object] in query, - where: - fragment( - "? && ? AND (?->>'inReplyTo' IS NULL OR array_remove(?, ?) && ? OR ? = ?)", - ^recipients, - activity.recipients, - object.data, - activity.recipients, - activity.actor, - ^reply_recipients, - activity.actor, - ^user.ap_id - ) - ) - end - - defp restrict_recipients(query, recipients, %{"user" => nil}) do - from(activity in query, - where: fragment("? && ?", ^recipients, activity.recipients) - ) + defp restrict_recipients(query, recipients, nil) do + from(activity in query, where: fragment("? && ?", ^recipients, activity.recipients)) end - defp restrict_recipients(query, recipients, %{"user" => user, "reply_visibility" => visibility}) - when visibility in ["following", "self"] do - reply_recipients = reply_recipients(user, visibility) - - from( - [activity, object] in query, - where: - fragment( - "? && ? AND (?->>'inReplyTo' IS NULL OR array_remove(?, ?) && ?)", - ^recipients, - activity.recipients, - object.data, - activity.recipients, - activity.actor, - ^reply_recipients - ), - or_where: activity.actor == ^user.ap_id - ) - end - - defp restrict_recipients(query, recipients, %{"user" => user}) do + defp restrict_recipients(query, recipients, user) do from( activity in query, where: fragment("? && ?", ^recipients, activity.recipients), @@ -1104,6 +1047,41 @@ defp restrict_replies(query, %{"exclude_replies" => val}) when val == "true" or ) end + defp restrict_replies(query, %{ + "reply_filtering_user" => user, + "reply_visibility" => "self" + }) do + from( + [activity, object] in query, + where: + fragment( + "?->>'inReplyTo' is null OR ? = ANY(?)", + object.data, + ^user.ap_id, + activity.recipients + ) + ) + end + + defp restrict_replies(query, %{ + "reply_filtering_user" => user, + "reply_visibility" => "following" + }) do + from( + [activity, object] in query, + where: + fragment( + "?->>'inReplyTo' is null OR ? && array_remove(?, ?) OR ? = ?", + object.data, + ^[user.ap_id | User.get_cached_user_friends_ap_ids(user)], + activity.recipients, + activity.actor, + activity.actor, + ^user.ap_id + ) + ) + end + defp restrict_replies(query, _), do: query defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val == "true" or val == "1" do @@ -1311,15 +1289,14 @@ def fetch_activities_query(recipients, opts \\ %{}) do skip_thread_containment: Config.get([:instance, :skip_thread_containment]) } - opts = Map.put(opts, "user", opts["user"]) - Activity |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_preload_report_notes(opts) |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) - |> restrict_recipients(recipients, opts) + |> restrict_recipients(recipients, opts["user"]) + |> restrict_replies(opts) |> restrict_tag(opts) |> restrict_tag_reject(opts) |> restrict_tag_all(opts) @@ -1334,7 +1311,6 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_media(opts) |> restrict_visibility(opts) |> restrict_thread_visibility(opts, config) - |> restrict_replies(opts) |> restrict_reblogs(opts) |> restrict_pinned(opts) |> restrict_muted_reblogs(restrict_muted_reblogs_opts) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index a2ac9301e..403d500e0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -37,6 +37,7 @@ def home(%{assigns: %{user: user}} = conn, params) do |> Map.put("type", ["Create", "Announce"]) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) + |> Map.put("reply_filtering_user", user) |> Map.put("user", user) recipients = [user.ap_id | User.following(user)] @@ -100,7 +101,7 @@ def public(%{assigns: %{user: user}} = conn, params) do |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() conn diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 8a1638a23..edd7dfb22 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1936,7 +1936,7 @@ test "public timeline", %{users: %{u1: user}} do |> Map.put("local_only", false) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1958,7 +1958,7 @@ test "public timeline with reply_visibility `following`", %{ |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_visibility", "following") - |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1985,7 +1985,7 @@ test "public timeline with reply_visibility `self`", %{ |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_visibility", "self") - |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -2008,6 +2008,7 @@ test "home timeline", %{ |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("user", user) + |> Map.put("reply_filtering_user", user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -2046,6 +2047,7 @@ test "home timeline with reply_visibility `following`", %{ |> Map.put("muting_user", user) |> Map.put("user", user) |> Map.put("reply_visibility", "following") + |> Map.put("reply_filtering_user", user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -2084,6 +2086,7 @@ test "home timeline with reply_visibility `self`", %{ |> Map.put("muting_user", user) |> Map.put("user", user) |> Map.put("reply_visibility", "self") + |> Map.put("reply_filtering_user", user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -2131,6 +2134,7 @@ test "public timeline with default reply_visibility `following`", %{users: %{u1: |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_visibility", "following") + |> Map.put("reply_filtering_user", user) |> Map.put("user", user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -2146,6 +2150,7 @@ test "public timeline with default reply_visibility `self`", %{users: %{u1: user |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_visibility", "self") + |> Map.put("reply_filtering_user", user) |> Map.put("user", user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -2176,6 +2181,7 @@ test "home timeline with default reply_visibility `following`", %{users: %{u1: u |> Map.put("muting_user", user) |> Map.put("user", user) |> Map.put("reply_visibility", "following") + |> Map.put("reply_filtering_user", user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -2199,6 +2205,7 @@ test "home timeline with default reply_visibility `self`", %{ |> Map.put("muting_user", user) |> Map.put("user", user) |> Map.put("reply_visibility", "self") + |> Map.put("reply_filtering_user", user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) -- cgit v1.2.3 From b4139cc5472079a34f0256ac9991a0222844d44c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 24 Apr 2020 22:25:27 +0300 Subject: [#2409] Made `GET /api/v1/accounts/:id/favourites` auth-optional, adjusted tests. --- .../mastodon_api/controllers/status_controller.ex | 2 +- .../pleroma_api/controllers/account_controller.ex | 5 ++++- lib/pleroma/web/router.ex | 7 ++++++- .../controllers/account_controller_test.exs | 20 +++++++++++++------- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 4fa9a2120..45601ff59 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -357,7 +357,7 @@ def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "GET /api/v1/favourites" - def favourites(%{assigns: %{user: user}} = conn, params) do + def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do activities = ActivityPub.fetch_favourites( user, diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 237c8157e..be7477867 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -39,7 +39,10 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do ] ) - plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites) + plug( + OAuthScopesPlug, + %{scopes: ["read:favourites"], fallback: :proceed_unauthenticated} when action == :favourites + ) plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 57efc3314..becce3098 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -312,10 +312,14 @@ defmodule Pleroma.Web.Router do post("/scrobble", ScrobbleController, :new_scrobble) end + scope [] do + pipe_through(:api) + get("/accounts/:id/favourites", AccountController, :favourites) + end + scope [] do pipe_through(:authenticated_api) - get("/accounts/:id/favourites", AccountController, :favourites) post("/accounts/:id/subscribe", AccountController, :subscribe) post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) end @@ -404,6 +408,7 @@ defmodule Pleroma.Web.Router do put("/scheduled_statuses/:id", ScheduledActivityController, :update) delete("/scheduled_statuses/:id", ScheduledActivityController, :delete) + # Unlike `GET /api/v1/accounts/:id/favourites`, demands authentication get("/favourites", StatusController, :favourites) get("/bookmarks", StatusController, :bookmarks) diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index ae5334015..6b671a667 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -151,15 +151,18 @@ test "returns list of statuses favorited by specified user", %{ assert like["id"] == activity.id end - test "does not return favorites for specified user_id when user is not logged in", %{ + test "returns favorites for specified user_id when requester is not logged in", %{ user: user } do activity = insert(:note_activity) CommonAPI.favorite(user, activity.id) - build_conn() - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(403) + response = + build_conn() + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(200) + + assert length(response) == 1 end test "returns favorited DM only when user is logged in and he is one of recipients", %{ @@ -185,9 +188,12 @@ test "returns favorited DM only when user is logged in and he is one of recipien assert length(response) == 1 end - build_conn() - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(403) + response = + build_conn() + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") + |> json_response(200) + + assert length(response) == 0 end test "does not return others' favorited DM when user is not one of recipients", %{ -- cgit v1.2.3 From 0d05e1fe397d75d4381d9059bd1c049ab7030085 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 25 Apr 2020 18:24:10 +0300 Subject: [#1706] Prevented error on unresolved activity actors for timeline actions. --- lib/pleroma/web/admin_api/views/status_view.ex | 18 +++--------------- lib/pleroma/web/mastodon_api/views/status_view.ex | 13 ++++++++++--- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex index 360ddc22c..3637dee24 100644 --- a/lib/pleroma/web/admin_api/views/status_view.ex +++ b/lib/pleroma/web/admin_api/views/status_view.ex @@ -8,15 +8,16 @@ defmodule Pleroma.Web.AdminAPI.StatusView do require Pleroma.Constants alias Pleroma.User + alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", opts) do safe_render_many(opts.activities, __MODULE__, "show.json", opts) end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do - user = get_user(activity.data["actor"]) + user = StatusView.get_user(activity.data["actor"]) - Pleroma.Web.MastodonAPI.StatusView.render("show.json", opts) + StatusView.render("show.json", opts) |> Map.merge(%{account: merge_account_views(user)}) end @@ -26,17 +27,4 @@ defp merge_account_views(%User{} = user) do end defp merge_account_views(_), do: %{} - - defp get_user(ap_id) do - cond do - user = User.get_cached_by_ap_id(ap_id) -> - user - - user = User.get_by_guessed_nickname(ap_id) -> - user - - true -> - User.error_user(ap_id) - end - 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 b5850e1ae..b0c53acd9 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -45,7 +45,7 @@ defp get_replied_to_activities(activities) do end) end - defp get_user(ap_id) do + def get_user(ap_id, fake_record_fallback \\ true) do cond do user = User.get_cached_by_ap_id(ap_id) -> user @@ -53,8 +53,11 @@ defp get_user(ap_id) do user = User.get_by_guessed_nickname(ap_id) -> user - true -> + fake_record_fallback -> + # TODO: refactor (fake records is never a good idea) User.error_user(ap_id) + + true -> nil end end @@ -97,7 +100,11 @@ def render("index.json", opts) do UserRelationship.view_relationships_option(nil, []) true -> - actors = Enum.map(activities ++ parent_activities, &get_user(&1.data["actor"])) + # Note: unresolved users are filtered out + actors = + (activities ++ parent_activities) + |> Enum.map(&get_user(&1.data["actor"], false)) + |> Enum.filter(& &1) UserRelationship.view_relationships_option(reading_user, actors, source_mutes_only: opts[:skip_relationships] -- cgit v1.2.3 From e16437ff191f17b7ec59504d3c38e582ba76eedc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 25 Apr 2020 18:42:08 +0300 Subject: [#1706] Formatting fix. --- lib/pleroma/web/mastodon_api/views/status_view.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index b0c53acd9..1d9082c09 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -57,7 +57,8 @@ def get_user(ap_id, fake_record_fallback \\ true) do # TODO: refactor (fake records is never a good idea) User.error_user(ap_id) - true -> nil + true -> + nil end end -- cgit v1.2.3 From 1bd9749a8f31e5f087b0d0ca75b13f4baf461997 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 26 Apr 2020 00:28:57 -0500 Subject: Let blob: pass CSP --- docs/configuration/hardening.md | 2 +- lib/pleroma/plugs/http_security_plug.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration/hardening.md b/docs/configuration/hardening.md index b54c28850..d3bfc4e4a 100644 --- a/docs/configuration/hardening.md +++ b/docs/configuration/hardening.md @@ -36,7 +36,7 @@ content-security-policy: default-src 'none'; base-uri 'self'; frame-ancestors 'none'; - img-src 'self' data: https:; + img-src 'self' data: blob: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 81e6b4f2a..6462797b6 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -75,7 +75,7 @@ defp csp_string do "default-src 'none'", "base-uri 'self'", "frame-ancestors 'none'", - "img-src 'self' data: https:", + "img-src 'self' data: blob: https:", "media-src 'self' https:", "style-src 'self' 'unsafe-inline'", "font-src 'self'", -- cgit v1.2.3 From 66acfa6882152de9f7b5181e708de02bb97d42a8 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 27 Apr 2020 10:28:05 +0300 Subject: descriptions that module names are shortened --- config/description.exs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/config/description.exs b/config/description.exs index 7fac1e561..62e4cda7a 100644 --- a/config/description.exs +++ b/config/description.exs @@ -28,7 +28,8 @@ %{ key: :filters, type: {:list, :module}, - description: "List of filter modules for uploads", + description: + "List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom MRF module you need to use full name.", suggestions: Generator.list_modules_in_dir( "lib/pleroma/upload/filter", @@ -681,7 +682,8 @@ %{ key: :federation_publisher_modules, type: {:list, :module}, - description: "List of modules for federation publishing", + description: + "List of modules for federation publishing. Module names are shortened (removed leading `Pleroma.Web.` part), but on adding custom MRF module you need to use full name.", suggestions: [ Pleroma.Web.ActivityPub.Publisher ] @@ -694,7 +696,8 @@ %{ key: :rewrite_policy, type: [:module, {:list, :module}], - description: "A list of MRF policies enabled", + description: + "A list of enabled MRF policies. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom MRF module you need to use full name.", suggestions: Generator.list_modules_in_dir( "lib/pleroma/web/activity_pub/mrf", @@ -1975,7 +1978,8 @@ %{ key: :parsers, type: {:list, :module}, - description: "List of Rich Media parsers.", + description: + "List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom MRF module you need to use full name.", suggestions: [ Pleroma.Web.RichMedia.Parsers.MetaTagsParser, Pleroma.Web.RichMedia.Parsers.OEmbed, @@ -1987,7 +1991,8 @@ key: :ttl_setters, label: "TTL setters", type: {:list, :module}, - description: "List of rich media TTL setters.", + description: + "List of rich media TTL setters. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parser.` part), but on adding custom MRF module you need to use full name.", suggestions: [ Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl ] @@ -2674,6 +2679,8 @@ %{ key: :scrub_policy, type: {:list, :module}, + description: + "Module names are shortened (removed leading `Pleroma.HTML.` part), but on adding custom MRF module you need to use full name.", suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default] } ] -- cgit v1.2.3 From d2bbea1a8076401645600ceb953dd66ec023b3ad Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 12:19:27 +0200 Subject: ChatControllerTest: Use new schema testing functions. --- .../controllers/chat_controller_test.exs | 58 +++------------------- 1 file changed, 8 insertions(+), 50 deletions(-) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 07b698013..84d7b543e 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,14 +5,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat - alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse alias Pleroma.Web.CommonAPI - import OpenApiSpex.TestAssertions import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages" do @@ -27,11 +21,10 @@ test "it posts a message to the chat", %{conn: conn, user: user} do conn |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{"content" => "Hallo!!"}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() - assert_schema(result, "ChatMessageResponse", ApiSpec.spec()) end end @@ -50,18 +43,16 @@ test "it paginates", %{conn: conn, user: user} do result = conn |> get("/api/v1/pleroma/chats/#{chat.id}/messages") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 20 - assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) result = conn |> get("/api/v1/pleroma/chats/#{chat.id}/messages?max_id=#{List.last(result)["id"]}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 10 - assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) end test "it returns the messages for a given chat", %{conn: conn, user: user} do @@ -78,7 +69,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do result = conn |> get("/api/v1/pleroma/chats/#{chat.id}/messages") - |> json_response(200) + |> json_response_and_validate_schema(200) result |> Enum.each(fn message -> @@ -86,7 +77,6 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end) assert length(result) == 3 - assert_schema(result, "ChatMessagesResponse", ApiSpec.spec()) # Trying to get the chat of a different user result = @@ -107,10 +97,9 @@ test "it creates or returns a chat", %{conn: conn} do result = conn |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert result["id"] - assert_schema(result, "ChatResponse", ApiSpec.spec()) end end @@ -126,19 +115,16 @@ test "it paginates", %{conn: conn, user: user} do result = conn |> get("/api/v1/pleroma/chats") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 20 - assert_schema(result, "ChatsResponse", ApiSpec.spec()) result = conn |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 10 - - assert_schema(result, "ChatsResponse", ApiSpec.spec()) end test "it return a list of chats the current user is participating in, in descending order of updates", @@ -160,7 +146,7 @@ test "it return a list of chats the current user is participating in, in descend result = conn |> get("/api/v1/pleroma/chats") - |> json_response(200) + |> json_response_and_validate_schema(200) ids = Enum.map(result, & &1["id"]) @@ -169,34 +155,6 @@ test "it return a list of chats the current user is participating in, in descend chat_3.id |> to_string(), chat_1.id |> to_string() ] - - assert_schema(result, "ChatsResponse", ApiSpec.spec()) - end - end - - describe "schemas" do - test "Chat example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatResponse.schema() - assert_schema(schema.example, "ChatResponse", api_spec) - end - - test "Chats example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatsResponse.schema() - assert_schema(schema.example, "ChatsResponse", api_spec) - end - - test "ChatMessage example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatMessageResponse.schema() - assert_schema(schema.example, "ChatMessageResponse", api_spec) - end - - test "ChatsMessage example matches schema" do - api_spec = ApiSpec.spec() - schema = ChatMessagesResponse.schema() - assert_schema(schema.example, "ChatMessagesResponse", api_spec) end end end -- cgit v1.2.3 From 15ba3700af76c44e63bf8881021f3ee2a5a7dafd Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 12:45:59 +0200 Subject: Chat Schemas: Inline unimportant Schemas. --- .../web/api_spec/operations/chat_operation.ex | 113 ++++++++++++++++++++- .../web/api_spec/schemas/chat_messages_response.ex | 41 -------- lib/pleroma/web/api_spec/schemas/chats_response.ex | 69 ------------- 3 files changed, 108 insertions(+), 115 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/chat_messages_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/chats_response.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index dc99bd773..6f55cbd59 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -7,9 +7,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatsResponse + alias OpenApiSpex.Schema @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -34,7 +33,11 @@ def create_operation do ], responses: %{ 200 => - Operation.response("The created or existing chat", "application/json", ChatResponse) + Operation.response( + "The created or existing chat", + "application/json", + ChatResponse + ) }, security: [ %{ @@ -55,7 +58,7 @@ def index_operation do Operation.parameter(:max_id, :query, :string, "Return only chats before this id") ], responses: %{ - 200 => Operation.response("The chats of the user", "application/json", ChatsResponse) + 200 => Operation.response("The chats of the user", "application/json", chats_response()) }, security: [ %{ @@ -78,7 +81,11 @@ def messages_operation do ], responses: %{ 200 => - Operation.response("The messages in the chat", "application/json", ChatMessagesResponse) + Operation.response( + "The messages in the chat", + "application/json", + chat_messages_response() + ) }, security: [ %{ @@ -112,4 +119,100 @@ def post_chat_message_operation do ] } end + + def chats_response() do + %Schema{ + title: "ChatsResponse", + description: "Response schema for multiple Chats", + type: :array, + items: ChatResponse, + example: [ + %{ + "recipient" => "https://dontbulling.me/users/lain", + "recipient_account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + ] + } + end + + def chat_messages_response() do + %Schema{ + title: "ChatMessagesResponse", + description: "Response schema for multiple ChatMessages", + type: :array, + items: ChatMessageResponse, + example: [ + %{ + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "created_at" => "2020-04-21T15:11:46.000Z", + "content" => "Check this out :firefox:", + "id" => "13", + "chat_id" => "1", + "actor" => "https://dontbulling.me/users/lain" + }, + %{ + "actor" => "https://dontbulling.me/users/lain", + "content" => "Whats' up?", + "id" => "12", + "chat_id" => "1", + "emojis" => [], + "created_at" => "2020-04-21T15:06:45.000Z" + } + ] + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex b/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex deleted file mode 100644 index 302bdec95..000000000 --- a/lib/pleroma/web/api_spec/schemas/chat_messages_response.ex +++ /dev/null @@ -1,41 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessagesResponse do - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatMessagesResponse", - description: "Response schema for multiple ChatMessages", - type: :array, - items: ChatMessageResponse, - example: [ - %{ - "emojis" => [ - %{ - "static_url" => "https://dontbulling.me/emoji/Firefox.gif", - "visible_in_picker" => false, - "shortcode" => "firefox", - "url" => "https://dontbulling.me/emoji/Firefox.gif" - } - ], - "created_at" => "2020-04-21T15:11:46.000Z", - "content" => "Check this out :firefox:", - "id" => "13", - "chat_id" => "1", - "actor" => "https://dontbulling.me/users/lain" - }, - %{ - "actor" => "https://dontbulling.me/users/lain", - "content" => "Whats' up?", - "id" => "12", - "chat_id" => "1", - "emojis" => [], - "created_at" => "2020-04-21T15:06:45.000Z" - } - ] - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/chats_response.ex b/lib/pleroma/web/api_spec/schemas/chats_response.ex deleted file mode 100644 index 3349e0691..000000000 --- a/lib/pleroma/web/api_spec/schemas/chats_response.ex +++ /dev/null @@ -1,69 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatsResponse do - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatsResponse", - description: "Response schema for multiple Chats", - type: :array, - items: ChatResponse, - example: [ - %{ - "recipient" => "https://dontbulling.me/users/lain", - "recipient_account" => %{ - "pleroma" => %{ - "is_admin" => false, - "confirmation_pending" => false, - "hide_followers_count" => false, - "is_moderator" => false, - "hide_favorites" => true, - "ap_id" => "https://dontbulling.me/users/lain", - "hide_follows_count" => false, - "hide_follows" => false, - "background_image" => nil, - "skip_thread_containment" => false, - "hide_followers" => false, - "relationship" => %{}, - "tags" => [] - }, - "avatar" => - "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", - "following_count" => 0, - "header_static" => "https://originalpatchou.li/images/banner.png", - "source" => %{ - "sensitive" => false, - "note" => "lain", - "pleroma" => %{ - "discoverable" => false, - "actor_type" => "Person" - }, - "fields" => [] - }, - "statuses_count" => 1, - "locked" => false, - "created_at" => "2020-04-16T13:40:15.000Z", - "display_name" => "lain", - "fields" => [], - "acct" => "lain@dontbulling.me", - "id" => "9u6Qw6TAZANpqokMkK", - "emojis" => [], - "avatar_static" => - "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", - "username" => "lain", - "followers_count" => 0, - "header" => "https://originalpatchou.li/images/banner.png", - "bot" => false, - "note" => "lain", - "url" => "https://dontbulling.me/users/lain" - }, - "id" => "1", - "unread" => 2 - } - ] - }) -end -- cgit v1.2.3 From e62f8542a1933ba71dfd236741ad3afc76b89f22 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 13:48:09 +0200 Subject: Docs: Add chat motivation and api description. --- docs/API/chats.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/API/chats.md diff --git a/docs/API/chats.md b/docs/API/chats.md new file mode 100644 index 000000000..39f493b47 --- /dev/null +++ b/docs/API/chats.md @@ -0,0 +1,197 @@ +# Chats + +Chats are a way to represent an IM-style conversation between two actors. They are not the same as direct messages and they are not `Status`es, even though they have a lot in common. + +## Why Chats? + +There are no 'visibility levels' in ActivityPub, their definition is purely a Mastodon convention. Direct Messaging between users on the fediverse has mostly been modeled by using ActivityPub addressing following Mastodon conventions on normal `Note` objects. In this case, a 'direct message' would be a message that has no followers addressed and also does not address the special public actor, but just the recipients in the `to` field. It would still be a `Note` and is presented with other `Note`s as a `Status` in the API. + +This is an awkward setup for a few reasons: + +- As DMs generally still follow the usual `Status` conventions, it is easy to accidentally pull somebody into a DM thread by mentioning them. (e.g. "I hate @badguy so much") +- It is possible to go from a publicly addressed `Status` to a DM reply, back to public, then to a 'followers only' reply, and so on. This can be become very confusing, as it is unclear which user can see which part of the conversation. +- The standard `Status` format of implicit addressing also leads to rather ugly results if you try to display the messages as a chat, because all the recipients are always mentioned by name in the message. +- As direct messages are posted with the same api call (and usually same frontend component) as public messages, accidentally making a public message private or vice versa can happen easily. Client bugs can also lead to this, accidentally making private messages public. + +As a measure to improve this situation, the `Conversation` concept and related Pleroma extensions were introduced. While it made it possible to work around a few of the issues, many of the problems remained and it didn't see much adoption because it was too complicated to use correctly. + +## Chats explained +For this reasons, Chats are a new and different entity, both in the API as well as in ActivityPub. A quick overview: + +- Chats are meant to represent an instant message conversation between two actors. For now these are only 1-on-1 conversations, but the other actor can be a group in the future. +- Chat messages have the ActivityPub type `ChatMessage`. They are not `Note`s. Servers that don't understand them will just drop them. +- The only addressing allowed in `ChatMessage`s is one single ActivityPub actor in the `to` field. +- There's always only one Chat between two actors. If you start chatting with someone and later start a 'new' Chat, the old Chat will be continued. +- `ChatMessage`s are posted with a different api, making it very hard to accidentally send a message to the wrong person. +- `ChatMessage`s don't show up in the existing timelines. +- Chats can never go from private to public. They are always private between the two actors. + +## Caveats + +- Chats are NOT E2E encrypted (yet). Security is still the same as email. + +## API + +In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. The actors in the API are generally given by their ActivityPub id to make it easier to support later `Group` scenarios. + +This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`. + +### Creating or getting a chat. + +To create or get an existing Chat for a certain recipient (identified by AP ID) +you can call: + +`POST /api/v1/pleroma/chats/by-ap-id/{ap_id}` + +The ap_id of the recipients needs to be www-form encoded, so + +``` +https://originalpatchou.li/user/lambda +``` + +would become + +``` +https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda +``` + +The full call would then be + +``` +POST /api/v1/pleroma/chats/by-ap-id/https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda +``` + +There will only ever be ONE Chat for you and a given recipient, so this call +will return the same Chat if you already have one with that user. + +Returned data: + +```json +{ + "recipient" : "https://dontbulling.me/users/lain", + "recipient_account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2 +} +``` + +### Getting a list of Chats + +`GET /api/v1/pleroma/chats` + +This will return a list of chats that you have been involved in, sorted by their +last update (so new chats will be at the top). + +Returned data: + +```json +[ + { + "recipient" : "https://dontbulling.me/users/lain", + "recipient_account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 2 + } +] +``` + +The recipient of messages that are sent to this chat is given by their AP ID. +The usual pagination options are implemented. + +### Getting the messages for a Chat + +For a given Chat id, you can get the associated messages with + +`GET /api/v1/pleroma/chats/{id}/messages` + +This will return all messages, sorted by most recent to least recent. The usual +pagination options are implemented + +Returned data: + +```json +[ + { + "actor": "https://dontbulling.me/users/lain", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13" + }, + { + "actor": "https://dontbulling.me/users/lain", + "chat_id": "1", + "content": "Whats' up?", + "created_at": "2020-04-21T15:06:45.000Z", + "emojis": [], + "id": "12" + } +] +``` + +### Posting a chat message + +Posting a chat message for given Chat id works like this: + +`POST /api/v1/pleroma/chats/{id}/messages` + +Parameters: +- content: The text content of the message + +Currently, no formatting beyond basic escaping and emoji is implemented, as well as no +attachments. This will most probably change. + +Returned data: + +```json +{ + "actor": "https://dontbulling.me/users/lain", + "chat_id": "1", + "content": "Check this out :firefox:", + "created_at": "2020-04-21T15:11:46.000Z", + "emojis": [ + { + "shortcode": "firefox", + "static_url": "https://dontbulling.me/emoji/Firefox.gif", + "url": "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker": false + } + ], + "id": "13" +} +``` + +### Notifications + +There's a new `pleroma:chat_mention` notification, which has this form: + +```json +{ + "id": "someid", + "type": "pleroma:chat_mention", + "account": { ... } // User account of the sender, + "chat_message": { + "chat_id": "1", + "id": "10", + "content": "Hello", + "actor": "https://dontbulling.me/users/lain" + }, + "created_at": "somedate" +} +``` -- cgit v1.2.3 From 00e956528b392689326d5f5527543a422a874bcc Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 14:02:11 +0200 Subject: Credo fixes. --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 6f55cbd59..546bc4d9b 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -4,11 +4,11 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation + alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse alias Pleroma.Web.ApiSpec.Schemas.ChatResponse - alias OpenApiSpex.Schema @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do @@ -120,7 +120,7 @@ def post_chat_message_operation do } end - def chats_response() do + def chats_response do %Schema{ title: "ChatsResponse", description: "Response schema for multiple Chats", @@ -182,7 +182,7 @@ def chats_response() do } end - def chat_messages_response() do + def chat_messages_response do %Schema{ title: "ChatMessagesResponse", description: "Response schema for multiple ChatMessages", -- cgit v1.2.3 From 3635a9c9c25db16be292c5f56c27ab5d5f5affb5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 14:28:08 +0200 Subject: InstanceController: Add extensions to `/api/v1/instance` --- .../web/mastodon_api/views/instance_view.ex | 58 +++++++++++++++++++++- lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 47 ++---------------- .../controllers/instance_controller_test.exs | 4 ++ 3 files changed, 64 insertions(+), 45 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 67214dbea..a329ffc28 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -5,10 +5,13 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do use Pleroma.Web, :view + alias Pleroma.Config + alias Pleroma.Web.ActivityPub.MRF + @mastodon_api_level "2.7.2" def render("show.json", _) do - instance = Pleroma.Config.get(:instance) + instance = Config.get(:instance) %{ uri: Pleroma.Web.base_url(), @@ -29,7 +32,58 @@ def render("show.json", _) do upload_limit: Keyword.get(instance, :upload_limit), avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), background_upload_limit: Keyword.get(instance, :background_upload_limit), - banner_upload_limit: Keyword.get(instance, :banner_upload_limit) + banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + pleroma: %{ + metadata: %{ + features: features(), + federation: federation() + }, + vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) + } } end + + def features do + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions" + ] + |> Enum.filter(& &1) + end + + def federation do + quarantined = Config.get([:instance, :quarantined_instances], []) + + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index f9a5ddcc0..721b599d4 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.MastodonAPI.InstanceView def schemas(conn, _params) do response = %{ @@ -34,51 +34,12 @@ def schemas(conn, _params) do def raw_nodeinfo do stats = Stats.get_stats() - quarantined = Config.get([:instance, :quarantined_instances], []) - staff_accounts = User.all_superusers() |> Enum.map(fn u -> u.ap_id end) - federation_response = - if Config.get([:instance, :mrf_transparency]) do - {:ok, data} = MRF.describe() - - data - |> Map.merge(%{quarantined_instances: quarantined}) - else - %{} - end - |> Map.put(:enabled, Config.get([:instance, :federating])) - - features = - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - "multifetch", - "pleroma:api/v1/notifications:include_types_filter", - if Config.get([:media_proxy, :enabled]) do - "media_proxy" - end, - if Config.get([:gopher, :enabled]) do - "gopher" - end, - if Config.get([:chat, :enabled]) do - "chat" - end, - if Config.get([:instance, :allow_relay]) do - "relay" - end, - if Config.get([:instance, :safe_dm_mentions]) do - "safe_dm_mentions" - end, - "pleroma_emoji_reactions" - ] - |> Enum.filter(& &1) + features = InstanceView.features() + federation = InstanceView.federation() %{ version: "2.0", @@ -106,7 +67,7 @@ def raw_nodeinfo do enabled: false }, staffAccounts: staff_accounts, - federation: federation_response, + federation: federation, pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 2737dcaba..2c7fd9fd0 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -34,6 +34,10 @@ test "get instance information", %{conn: conn} do "banner_upload_limit" => _ } = result + assert result["pleroma"]["metadata"]["features"] + assert result["pleroma"]["metadata"]["federation"] + assert result["pleroma"]["vapid_public_key"] + assert email == from_config_email end -- cgit v1.2.3 From 4cadaf7e96bed51545d82deb86b5554cd009020e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 14:33:21 +0200 Subject: Docs: Add `/api/v1/instance` information --- docs/API/differences_in_mastoapi_responses.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 41ceda26b..d0a776ebf 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -191,3 +191,17 @@ Has theses additional parameters (which are the same as in Pleroma-API): - `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. + +## Instance + +`GET /api/v1/instance` has additional fields + +- `max_toot_chars`: The maximum characters per post +- `poll_limits`: The limits of polls +- `upload_limit`: The maximum upload file size +- `avatar_upload_limit`: The same for avatars +- `background_upload_limit`: The same for backgrounds +- `banner_upload_limit`: The same for banners +- `pleroma.metadata.features`: A list of supported features +- `pleroma.metadata.federation`: The federation restrictions of this instance +- `vapid_public_key`: The public key needed for push messages -- cgit v1.2.3 From 5a3a5abc0c3315cba1ed3694e8a2876da8a5d294 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 14:35:12 +0200 Subject: Changelog: Add info about `/api/v1/instance` changes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc6a5bd4..b98d4fc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added +- Instance: Extend `/api/v1/instance` with Pleroma-specific information. - 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. @@ -17,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added `:reject_deletes` group to SimplePolicy
    API Changes +- Mastodon API: Extended `/api/v1/instance`. - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. - Mastodon API: Add support for filtering replies in public and home timelines -- cgit v1.2.3 From 49e673dfea0a0cc94bba9691ce171b60f8a2fd75 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 16:08:03 +0200 Subject: ChatView: Add actor_account_id --- lib/pleroma/web/api_spec/schemas/chat_message_response.ex | 2 ++ lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 2 ++ test/web/pleroma_api/views/chat_message_view_test.exs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex index e94c00369..9459d210b 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do properties: %{ id: %Schema{type: :string}, actor: %Schema{type: :string, description: "The ActivityPub id of the actor"}, + actor_account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, content: %Schema{type: :string}, created_at: %Schema{type: :string, format: :datetime}, @@ -21,6 +22,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do }, example: %{ "actor" => "https://dontbulling.me/users/lain", + "actor_account_id" => "someflakeid", "chat_id" => "1", "content" => "hey you again", "created_at" => "2020-04-21T15:06:45.000Z", diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index b40ab92a0..5b740cc44 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do alias Pleroma.Chat alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.User def render( "show.json", @@ -21,6 +22,7 @@ def render( content: chat_message["content"], chat_id: chat_id |> to_string(), actor: chat_message["actor"], + actor_account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, created_at: Utils.to_masto_date(chat_message["published"]), emojis: StatusView.build_emojis(chat_message["emoji"]) } diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index 115335f10..7e3aeefab 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -26,6 +26,7 @@ test "it displays a chat message" do assert chat_message[:id] == object.id |> to_string() assert chat_message[:content] == "kippis :firefox:" assert chat_message[:actor] == user.ap_id + assert chat_message[:actor_account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) @@ -39,6 +40,7 @@ test "it displays a chat message" do assert chat_message_two[:id] == object.id |> to_string() assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:actor] == recipient.ap_id + assert chat_message_two[:actor_account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] end end -- cgit v1.2.3 From ad82a216ff0676507a118e610209bd4259456b3c Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 17:48:34 +0200 Subject: Chat API: Align more to Pleroma/Mastodon API. --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 13 ++++++------- lib/pleroma/web/api_spec/schemas/chat_message_response.ex | 6 ++---- lib/pleroma/web/api_spec/schemas/chat_response.ex | 11 ++++------- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 5 ++--- lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 3 +-- lib/pleroma/web/pleroma_api/views/chat_view.ex | 3 +-- lib/pleroma/web/router.ex | 2 +- test/web/pleroma_api/controllers/chat_controller_test.exs | 4 ++-- test/web/pleroma_api/views/chat_message_view_test.exs | 6 ++---- test/web/pleroma_api/views/chat_view_test.exs | 3 +-- 10 files changed, 22 insertions(+), 34 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 546bc4d9b..59539e890 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -23,12 +23,12 @@ def create_operation do operationId: "ChatController.create", parameters: [ Operation.parameter( - :ap_id, + :id, :path, :string, - "The ActivityPub id of the recipient of this chat.", + "The account id of the recipient of this chat", required: true, - example: "https://lain.com/users/lain" + example: "someflakeid" ) ], responses: %{ @@ -128,8 +128,7 @@ def chats_response do items: ChatResponse, example: [ %{ - "recipient" => "https://dontbulling.me/users/lain", - "recipient_account" => %{ + "account" => %{ "pleroma" => %{ "is_admin" => false, "confirmation_pending" => false, @@ -202,10 +201,10 @@ def chat_messages_response do "content" => "Check this out :firefox:", "id" => "13", "chat_id" => "1", - "actor" => "https://dontbulling.me/users/lain" + "actor_id" => "someflakeid" }, %{ - "actor" => "https://dontbulling.me/users/lain", + "actor_id" => "someflakeid", "content" => "Whats' up?", "id" => "12", "chat_id" => "1", diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex index 9459d210b..b7a662cbb 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -13,16 +13,14 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do type: :object, properties: %{ id: %Schema{type: :string}, - actor: %Schema{type: :string, description: "The ActivityPub id of the actor"}, - actor_account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, + account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, content: %Schema{type: :string}, created_at: %Schema{type: :string, format: :datetime}, emojis: %Schema{type: :array} }, example: %{ - "actor" => "https://dontbulling.me/users/lain", - "actor_account_id" => "someflakeid", + "account_id" => "someflakeid", "chat_id" => "1", "content" => "hey you again", "created_at" => "2020-04-21T15:06:45.000Z", diff --git a/lib/pleroma/web/api_spec/schemas/chat_response.ex b/lib/pleroma/web/api_spec/schemas/chat_response.ex index a80f4d173..aa435165d 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_response.ex @@ -12,15 +12,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatResponse do description: "Response schema for a Chat", type: :object, properties: %{ - id: %Schema{type: :string}, - recipient: %Schema{type: :string}, - # TODO: Make this reference the account structure. - recipient_account: %Schema{type: :object}, - unread: %Schema{type: :integer} + id: %Schema{type: :string, nullable: false}, + account: %Schema{type: :object, nullable: false}, + unread: %Schema{type: :integer, nullable: false} }, example: %{ - "recipient" => "https://dontbulling.me/users/lain", - "recipient_account" => %{ + "account" => %{ "pleroma" => %{ "is_admin" => false, "confirmation_pending" => false, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 771ad6217..8654f4295 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -99,9 +99,8 @@ def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do end def create(%{assigns: %{user: user}} = conn, params) do - recipient = params[:ap_id] - - with {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do + with %User{ap_id: recipient} <- User.get_by_id(params[:id]), + {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index 5b740cc44..28f12d9b0 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -21,8 +21,7 @@ def render( id: id |> to_string(), content: chat_message["content"], chat_id: chat_id |> to_string(), - actor: chat_message["actor"], - actor_account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, + account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, created_at: Utils.to_masto_date(chat_message["published"]), emojis: StatusView.build_emojis(chat_message["emoji"]) } diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 1e9ef4356..bc3af5ef5 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -14,8 +14,7 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do %{ id: chat.id |> to_string(), - recipient: chat.recipient, - recipient_account: AccountView.render("show.json", Map.put(opts, :user, recipient)), + account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread } end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0c56318ee..aad2e3b98 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -275,7 +275,7 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:authenticated_api) - post("/chats/by-ap-id/:ap_id", ChatController, :create) + post("/chats/by-account-id/:id", ChatController, :create) get("/chats", ChatController, :index) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 84d7b543e..b1044574b 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -88,7 +88,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end end - describe "POST /api/v1/pleroma/chats/by-ap-id/:id" do + describe "POST /api/v1/pleroma/chats/by-account-id/:id" do setup do: oauth_access(["write:statuses"]) test "it creates or returns a chat", %{conn: conn} do @@ -96,7 +96,7 @@ test "it creates or returns a chat", %{conn: conn} do result = conn - |> post("/api/v1/pleroma/chats/by-ap-id/#{URI.encode_www_form(other_user.ap_id)}") + |> post("/api/v1/pleroma/chats/by-account-id/#{other_user.id}") |> json_response_and_validate_schema(200) assert result["id"] diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index 7e3aeefab..5c4c8b0d5 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -25,8 +25,7 @@ test "it displays a chat message" do assert chat_message[:id] == object.id |> to_string() assert chat_message[:content] == "kippis :firefox:" - assert chat_message[:actor] == user.ap_id - assert chat_message[:actor_account_id] == user.id + assert chat_message[:account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) @@ -39,8 +38,7 @@ test "it displays a chat message" do assert chat_message_two[:id] == object.id |> to_string() assert chat_message_two[:content] == "gkgkgk" - assert chat_message_two[:actor] == recipient.ap_id - assert chat_message_two[:actor_account_id] == recipient.id + assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] end end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 725da5ff8..1ac3483d1 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -21,8 +21,7 @@ test "it represents a chat" do assert represented_chat == %{ id: "#{chat.id}", - recipient: recipient.ap_id, - recipient_account: AccountView.render("show.json", user: recipient), + account: AccountView.render("show.json", user: recipient), unread: 0 } end -- cgit v1.2.3 From b550ef56119b9f735cf3fe279a5457e36ab92951 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 17:52:16 +0200 Subject: Docs: Align chat api changes with docs. --- docs/API/chats.md | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 39f493b47..24c4b4d06 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -32,33 +32,21 @@ For this reasons, Chats are a new and different entity, both in the API as well ## API -In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. The actors in the API are generally given by their ActivityPub id to make it easier to support later `Group` scenarios. +In general, the way to send a `ChatMessage` is to first create a `Chat`, then post a message to that `Chat`. `Group`s will later be supported by making them a sub-type of `Account`. This is the overview of using the API. The API is also documented via OpenAPI, so you can view it and play with it by pointing SwaggerUI or a similar OpenAPI tool to `https://yourinstance.tld/api/openapi`. ### Creating or getting a chat. -To create or get an existing Chat for a certain recipient (identified by AP ID) +To create or get an existing Chat for a certain recipient (identified by Account ID) you can call: -`POST /api/v1/pleroma/chats/by-ap-id/{ap_id}` +`POST /api/v1/pleroma/chats/by-account-id/{account_id}` -The ap_id of the recipients needs to be www-form encoded, so +The account id is the normal FlakeId of the usre ``` -https://originalpatchou.li/user/lambda -``` - -would become - -``` -https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda -``` - -The full call would then be - -``` -POST /api/v1/pleroma/chats/by-ap-id/https%3A%2F%2Foriginalpatchou.li%2Fuser%2Flambda +POST /api/v1/pleroma/chats/by-account-id/someflakeid ``` There will only ever be ONE Chat for you and a given recipient, so this call @@ -68,8 +56,7 @@ Returned data: ```json { - "recipient" : "https://dontbulling.me/users/lain", - "recipient_account": { + "account": { "id": "someflakeid", "username": "somenick", ... @@ -91,8 +78,7 @@ Returned data: ```json [ { - "recipient" : "https://dontbulling.me/users/lain", - "recipient_account": { + "account": { "id": "someflakeid", "username": "somenick", ... @@ -120,7 +106,7 @@ Returned data: ```json [ { - "actor": "https://dontbulling.me/users/lain", + "account_id": "someflakeid", "chat_id": "1", "content": "Check this out :firefox:", "created_at": "2020-04-21T15:11:46.000Z", @@ -135,7 +121,7 @@ Returned data: "id": "13" }, { - "actor": "https://dontbulling.me/users/lain", + "account_id": "someflakeid", "chat_id": "1", "content": "Whats' up?", "created_at": "2020-04-21T15:06:45.000Z", @@ -161,7 +147,7 @@ Returned data: ```json { - "actor": "https://dontbulling.me/users/lain", + "account_id": "someflakeid", "chat_id": "1", "content": "Check this out :firefox:", "created_at": "2020-04-21T15:11:46.000Z", @@ -190,7 +176,7 @@ There's a new `pleroma:chat_mention` notification, which has this form: "chat_id": "1", "id": "10", "content": "Hello", - "actor": "https://dontbulling.me/users/lain" + "account_id": "someflakeid" }, "created_at": "somedate" } -- cgit v1.2.3 From 3d040b1a87da66ed53a763f781477bd4f5a146d3 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Apr 2020 17:55:29 +0200 Subject: Credo fixes. --- lib/pleroma/web/pleroma_api/views/chat_message_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index 28f12d9b0..a821479ab 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -6,9 +6,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.User def render( "show.json", -- cgit v1.2.3 From a626cb682cc8fd6cad91484db064ed22646960af Mon Sep 17 00:00:00 2001 From: fence Date: Mon, 27 Apr 2020 17:55:33 +0200 Subject: secure mongoose auth endpoint --- .../web/mongooseim/mongoose_im_controller.ex | 33 ++++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 04d823b36..744cf5227 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -26,21 +26,36 @@ def user_exists(conn, %{"user" => username}) do end def check_password(conn, %{"user" => username, "pass" => password}) do - with %User{password_hash: password_hash} <- - Repo.get_by(User, nickname: username, local: true), - true <- Pbkdf2.checkpw(password, password_hash) do - conn - |> json(true) - else - false -> + user = Repo.get_by(User, nickname: username, local: true) + + case User.account_status(user) do + :deactivated -> conn - |> put_status(:forbidden) + |> put_status(:not_found) |> json(false) - _ -> + :confirmation_pending -> conn |> put_status(:not_found) |> json(false) + + _ -> + with %User{password_hash: password_hash} <- + user, + true <- Pbkdf2.checkpw(password, password_hash) do + conn + |> json(true) + else + false -> + conn + |> put_status(:forbidden) + |> json(false) + + _ -> + conn + |> put_status(:not_found) + |> json(false) + end end end end -- cgit v1.2.3 From 5c7cc109172c84b991fad7eebbdd51e75f0c5382 Mon Sep 17 00:00:00 2001 From: fence Date: Mon, 27 Apr 2020 18:31:00 +0200 Subject: add tests for deactivated users for mongoose auth --- lib/pleroma/web/mongooseim/mongoose_im_controller.ex | 7 ++++++- test/web/mongooseim/mongoose_im_controller_test.exs | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 744cf5227..c15b4bfb8 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -27,8 +27,13 @@ def user_exists(conn, %{"user" => username}) do def check_password(conn, %{"user" => username, "pass" => password}) do user = Repo.get_by(User, nickname: username, local: true) + + state = case user do + nil -> nil + _ -> User.account_status(user) + end - case User.account_status(user) do + case state do :deactivated -> conn |> put_status(:not_found) diff --git a/test/web/mongooseim/mongoose_im_controller_test.exs b/test/web/mongooseim/mongoose_im_controller_test.exs index 291ae54fc..5987111e5 100644 --- a/test/web/mongooseim/mongoose_im_controller_test.exs +++ b/test/web/mongooseim/mongoose_im_controller_test.exs @@ -34,6 +34,7 @@ test "/user_exists", %{conn: conn} do test "/check_password", %{conn: conn} do user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) + _deactivated_user = insert(:user, nickname: "konata", local: false, deactivated: true) res = conn @@ -49,6 +50,14 @@ test "/check_password", %{conn: conn} do assert res == false + res = + conn + |> get(mongoose_im_path(conn, :check_password), user: "konata", pass: "1337") + |> json_response(404) + + assert res == false + + res = conn |> get(mongoose_im_path(conn, :check_password), user: "nobody", pass: "cool") -- cgit v1.2.3 From 2efc00b3cf5413ae7f8e8411ee1372343ee2618a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 27 Apr 2020 20:46:52 +0400 Subject: Use `json_response_and_validate_schema/2` in tests to validate OpenAPI schema --- .../web/api_spec/operations/account_operation.ex | 27 +- lib/pleroma/web/api_spec/schemas/account.ex | 2 +- lib/pleroma/web/controller_helper.ex | 5 +- .../mastodon_api/controllers/account_controller.ex | 4 +- test/support/conn_case.ex | 21 +- .../account_controller/update_credentials_test.exs | 60 +-- .../controllers/account_controller_test.exs | 569 ++++++++++----------- 7 files changed, 330 insertions(+), 358 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index fcf030037..bf8d21059 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias OpenApiSpex.Reference alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse alias Pleroma.Web.ApiSpec.Schemas.AccountFollowsRequest @@ -38,7 +39,10 @@ def create_operation do operationId: "AccountController.create", requestBody: request_body("Parameters", AccountCreateRequest, required: true), responses: %{ - 200 => Operation.response("Account", "application/json", AccountCreateResponse) + 200 => Operation.response("Account", "application/json", AccountCreateResponse), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError), + 429 => Operation.response("Error", "application/json", ApiError) } } end @@ -65,7 +69,8 @@ def update_credentials_operation do security: [%{"oAuth" => ["write:accounts"]}], requestBody: request_body("Parameters", AccountUpdateCredentialsRequest, required: true), responses: %{ - 200 => Operation.response("Account", "application/json", Account) + 200 => Operation.response("Account", "application/json", Account), + 403 => Operation.response("Error", "application/json", ApiError) } } end @@ -102,7 +107,8 @@ def show_operation do description: "View information about a profile.", parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], responses: %{ - 200 => Operation.response("Account", "application/json", Account) + 200 => Operation.response("Account", "application/json", Account), + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -140,7 +146,8 @@ def statuses_operation do ) ] ++ pagination_params(), responses: %{ - 200 => Operation.response("Statuses", "application/json", StatusesResponse) + 200 => Operation.response("Statuses", "application/json", StatusesResponse), + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -204,7 +211,9 @@ def follow_operation do ) ], responses: %{ - 200 => Operation.response("Relationship", "application/json", AccountRelationship) + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -218,7 +227,9 @@ def unfollow_operation do description: "Unfollow the given account", parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], responses: %{ - 200 => Operation.response("Relationship", "application/json", AccountRelationship) + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -298,7 +309,9 @@ def follows_operation do security: [%{"oAuth" => ["follow", "write:follows"]}], requestBody: request_body("Parameters", AccountFollowsRequest, required: true), responses: %{ - 200 => Operation.response("Account", "application/json", AccountRelationship) + 200 => Operation.response("Account", "application/json", AccountRelationship), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index f57015254..d128feb30 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -41,7 +41,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ allow_following_move: %Schema{type: :boolean}, - background_image: %Schema{type: :boolean, nullable: true}, + background_image: %Schema{type: :string, nullable: true}, chat_token: %Schema{type: :string}, confirmation_pending: %Schema{type: :boolean}, hide_favorites: %Schema{type: :boolean}, diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 4780081b2..eb97ae975 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -82,8 +82,9 @@ def add_link_headers(conn, activities, extra_params) do end end - def assign_account_by_id(%{params: %{"id" => id}} = conn, _) do - case Pleroma.User.get_cached_by_id(id) do + def assign_account_by_id(conn, _) do + # TODO: use `conn.params[:id]` only after moving to OpenAPI + case Pleroma.User.get_cached_by_id(conn.params[:id] || conn.params["id"]) do %Pleroma.User{} = account -> assign(conn, :account, account) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 93df79645..b1513001b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -26,6 +26,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug(:skip_plug, OAuthScopesPlug when action == :identity_proofs) plug( @@ -83,8 +85,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(RateLimiter, [name: :app_account_creation] when action == :create) plug(:assign_account_by_id when action in @needs_account) - plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) - action_fallback(Pleroma.Web.MastodonAPI.FallbackController) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 8099461cc..fa30a0c41 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -56,7 +56,14 @@ defp request_content_type(%{conn: conn}) do [conn: conn] end - defp json_response_and_validate_schema(conn, status \\ nil) do + defp json_response_and_validate_schema( + %{ + private: %{ + open_api_spex: %{operation_id: op_id, operation_lookup: lookup, spec: spec} + } + } = conn, + status + ) do content_type = conn |> Plug.Conn.get_resp_header("content-type") @@ -64,10 +71,12 @@ defp json_response_and_validate_schema(conn, status \\ nil) do |> String.split(";") |> List.first() - status = status || conn.status + status = Plug.Conn.Status.code(status) - %{private: %{open_api_spex: %{operation_id: op_id, operation_lookup: lookup, spec: spec}}} = - conn + unless lookup[op_id].responses[status] do + err = "Response schema not found for #{conn.status} #{conn.method} #{conn.request_path}" + flunk(err) + end schema = lookup[op_id].responses[status].content[content_type].schema json = json_response(conn, status) @@ -92,6 +101,10 @@ defp json_response_and_validate_schema(conn, status \\ nil) do end end + defp json_response_and_validate_schema(conn, _status) do + flunk("Response schema not found for #{conn.method} #{conn.request_path} #{conn.status}") + end + defp ensure_federating_or_authenticated(conn, url, user) do initial_setting = Config.get([:instance, :federating]) on_exit(fn -> Config.put([:instance, :federating], initial_setting) 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 a3356c12f..fdb6d4c5d 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 @@ -26,7 +26,7 @@ test "sets user settings in a generic way", %{conn: conn} do } }) - assert user_data = json_response(res_conn, 200) + assert user_data = json_response_and_validate_schema(res_conn, 200) assert user_data["pleroma"]["settings_store"] == %{"pleroma_fe" => %{"theme" => "bla"}} user = Repo.get(User, user_data["id"]) @@ -42,7 +42,7 @@ test "sets user settings in a generic way", %{conn: conn} do } }) - assert user_data = json_response(res_conn, 200) + assert user_data = json_response_and_validate_schema(res_conn, 200) assert user_data["pleroma"]["settings_store"] == %{ @@ -63,7 +63,7 @@ test "sets user settings in a generic way", %{conn: conn} do } }) - assert user_data = json_response(res_conn, 200) + assert user_data = json_response_and_validate_schema(res_conn, 200) assert user_data["pleroma"]["settings_store"] == %{ @@ -80,7 +80,7 @@ test "updates the user's bio", %{conn: conn} do "note" => "I drink #cofe with @#{user2.nickname}\n\nsuya.." }) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["note"] == ~s(I drink #cofe with %{"pleroma" => %{"discoverable" => true}}} = conn |> patch("/api/v1/accounts/update_credentials", %{discoverable: "true"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert %{"source" => %{"pleroma" => %{"discoverable" => false}}} = conn |> patch("/api/v1/accounts/update_credentials", %{discoverable: "false"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) end test "updates the user's hide_followers_count and hide_follows_count", %{conn: conn} do @@ -138,7 +138,7 @@ test "updates the user's hide_followers_count and hide_follows_count", %{conn: c hide_follows_count: "true" }) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["pleroma"]["hide_followers_count"] == true assert user_data["pleroma"]["hide_follows_count"] == true end @@ -147,7 +147,7 @@ test "updates the user's skip_thread_containment option", %{user: user, conn: co response = conn |> patch("/api/v1/accounts/update_credentials", %{skip_thread_containment: "true"}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert response["pleroma"]["skip_thread_containment"] == true assert refresh_record(user).skip_thread_containment @@ -156,28 +156,28 @@ test "updates the user's skip_thread_containment option", %{user: user, conn: co test "updates the user's hide_follows status", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{hide_follows: "true"}) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["pleroma"]["hide_follows"] == true end test "updates the user's hide_favorites status", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{hide_favorites: "true"}) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["pleroma"]["hide_favorites"] == true end test "updates the user's show_role status", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{show_role: "false"}) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["source"]["pleroma"]["show_role"] == false end test "updates the user's no_rich_text status", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{no_rich_text: "true"}) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["source"]["pleroma"]["no_rich_text"] == true end @@ -185,7 +185,7 @@ test "updates the user's name", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{"display_name" => "markorepairs"}) - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["display_name"] == "markorepairs" end @@ -198,7 +198,7 @@ test "updates the user's avatar", %{user: user, conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) - assert user_response = json_response(conn, 200) + assert user_response = json_response_and_validate_schema(conn, 200) assert user_response["avatar"] != User.avatar_url(user) end @@ -211,7 +211,7 @@ test "updates the user's banner", %{user: user, conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header}) - assert user_response = json_response(conn, 200) + assert user_response = json_response_and_validate_schema(conn, 200) assert user_response["header"] != User.banner_url(user) end @@ -227,7 +227,7 @@ test "updates the user's background", %{conn: conn} do "pleroma_background_image" => new_header }) - assert user_response = json_response(conn, 200) + assert user_response = json_response_and_validate_schema(conn, 200) assert user_response["pleroma"]["background_image"] end @@ -244,9 +244,9 @@ test "requires 'write:accounts' permission" do if token == token1 do assert %{"error" => "Insufficient permissions: write:accounts."} == - json_response(conn, 403) + json_response_and_validate_schema(conn, 403) else - assert json_response(conn, 200) + assert json_response_and_validate_schema(conn, 200) end end end @@ -261,11 +261,11 @@ test "updates profile emojos", %{user: user, conn: conn} do "display_name" => name }) - assert json_response(ret_conn, 200) + assert json_response_and_validate_schema(ret_conn, 200) conn = get(conn, "/api/v1/accounts/#{user.id}") - assert user_data = json_response(conn, 200) + assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["note"] == note assert user_data["display_name"] == name @@ -281,7 +281,7 @@ test "update fields", %{conn: conn} do account_data = conn |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert account_data["fields"] == [ %{"name" => "foo", "value" => "bar"}, @@ -314,7 +314,7 @@ test "update fields via x-www-form-urlencoded", %{conn: conn} do conn |> put_req_header("content-type", "application/x-www-form-urlencoded") |> patch("/api/v1/accounts/update_credentials", fields) - |> json_response(200) + |> json_response_and_validate_schema(200) assert account["fields"] == [ %{"name" => "foo", "value" => "bar"}, @@ -339,7 +339,7 @@ test "update fields with empty name", %{conn: conn} do account = conn |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert account["fields"] == [ %{"name" => "foo", "value" => ""} @@ -358,14 +358,14 @@ test "update fields when invalid request", %{conn: conn} do assert %{"error" => "Invalid request"} == conn |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(403) + |> json_response_and_validate_schema(403) fields = [%{"name" => long_name, "value" => "bar"}] assert %{"error" => "Invalid request"} == conn |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(403) + |> json_response_and_validate_schema(403) Pleroma.Config.put([:instance, :max_account_fields], 1) @@ -377,7 +377,7 @@ test "update fields when invalid request", %{conn: conn} do assert %{"error" => "Invalid request"} == conn |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) - |> json_response(403) + |> json_response_and_validate_schema(403) end 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 d885b5e08..ba70ba66c 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -10,54 +10,46 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor - alias Pleroma.Web.ApiSpec alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.Token - import OpenApiSpex.TestAssertions import Pleroma.Factory describe "account fetching" do setup do: clear_config([:instance, :limit_to_local_content]) test "works by id" do - user = insert(:user) + %User{id: user_id} = insert(:user) - conn = - build_conn() - |> get("/api/v1/accounts/#{user.id}") - - assert %{"id" => id} = json_response(conn, 200) - assert id == to_string(user.id) + assert %{"id" => ^user_id} = + build_conn() + |> get("/api/v1/accounts/#{user_id}") + |> json_response_and_validate_schema(200) - conn = - build_conn() - |> get("/api/v1/accounts/-1") - - assert %{"error" => "Can't find user"} = json_response(conn, 404) + assert %{"error" => "Can't find user"} = + build_conn() + |> get("/api/v1/accounts/-1") + |> json_response_and_validate_schema(404) end test "works by nickname" do user = insert(:user) - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") - - assert %{"id" => id} = json_response(conn, 200) - assert id == user.id + assert %{"id" => user_id} = + build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + |> json_response_and_validate_schema(200) end test "works by nickname for remote users" do Config.put([:instance, :limit_to_local_content], false) - user = insert(:user, nickname: "user@example.com", local: false) - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") + user = insert(:user, nickname: "user@example.com", local: false) - assert %{"id" => id} = json_response(conn, 200) - assert id == user.id + assert %{"id" => user_id} = + build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + |> json_response_and_validate_schema(200) end test "respects limit_to_local_content == :all for remote user nicknames" do @@ -65,11 +57,9 @@ test "respects limit_to_local_content == :all for remote user nicknames" do user = insert(:user, nickname: "user@example.com", local: false) - conn = - build_conn() - |> get("/api/v1/accounts/#{user.nickname}") - - assert json_response(conn, 404) + assert build_conn() + |> get("/api/v1/accounts/#{user.nickname}") + |> json_response_and_validate_schema(404) end test "respects limit_to_local_content == :unauthenticated for remote user nicknames" do @@ -82,7 +72,7 @@ test "respects limit_to_local_content == :unauthenticated for remote user nickna build_conn() |> get("/api/v1/accounts/#{user.nickname}") - assert json_response(conn, 404) + assert json_response_and_validate_schema(conn, 404) conn = build_conn() @@ -90,7 +80,7 @@ test "respects limit_to_local_content == :unauthenticated for remote user nickna |> assign(:token, insert(:oauth_token, user: reading_user, scopes: ["read:accounts"])) |> get("/api/v1/accounts/#{user.nickname}") - assert %{"id" => id} = json_response(conn, 200) + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) assert id == user.id end @@ -101,21 +91,21 @@ test "accounts fetches correct account for nicknames beginning with numbers", %{ user_one = insert(:user, %{id: 1212}) user_two = insert(:user, %{nickname: "#{user_one.id}garbage"}) - resp_one = + acc_one = conn |> get("/api/v1/accounts/#{user_one.id}") + |> json_response_and_validate_schema(:ok) - resp_two = + acc_two = conn |> get("/api/v1/accounts/#{user_two.nickname}") + |> json_response_and_validate_schema(:ok) - resp_three = + acc_three = conn |> get("/api/v1/accounts/#{user_two.id}") + |> json_response_and_validate_schema(:ok) - acc_one = json_response(resp_one, 200) - acc_two = json_response(resp_two, 200) - acc_three = json_response(resp_three, 200) refute acc_one == acc_two assert acc_two == acc_three end @@ -123,23 +113,19 @@ test "accounts fetches correct account for nicknames beginning with numbers", %{ test "returns 404 when user is invisible", %{conn: conn} do user = insert(:user, %{invisible: true}) - resp = - conn - |> get("/api/v1/accounts/#{user.nickname}") - |> json_response(404) - - assert %{"error" => "Can't find user"} = resp + assert %{"error" => "Can't find user"} = + conn + |> get("/api/v1/accounts/#{user.nickname}") + |> json_response_and_validate_schema(404) end test "returns 404 for internal.fetch actor", %{conn: conn} do %User{nickname: "internal.fetch"} = InternalFetchActor.get_actor() - resp = - conn - |> get("/api/v1/accounts/internal.fetch") - |> json_response(404) - - assert %{"error" => "Can't find user"} = resp + assert %{"error" => "Can't find user"} = + conn + |> get("/api/v1/accounts/internal.fetch") + |> json_response_and_validate_schema(404) end end @@ -157,27 +143,25 @@ defp local_and_remote_users do setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/accounts/#{local.id}") - - assert json_response(res_conn, :not_found) == %{ - "error" => "Can't find user" - } - - res_conn = get(conn, "/api/v1/accounts/#{remote.id}") + assert %{"error" => "Can't find user"} == + conn + |> get("/api/v1/accounts/#{local.id}") + |> json_response_and_validate_schema(:not_found) - assert json_response(res_conn, :not_found) == %{ - "error" => "Can't find user" - } + assert %{"error" => "Can't find user"} == + conn + |> get("/api/v1/accounts/#{remote.id}") + |> json_response_and_validate_schema(:not_found) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -189,22 +173,22 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert json_response(res_conn, :not_found) == %{ + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Can't find user" } res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -215,11 +199,11 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert json_response(res_conn, :not_found) == %{ + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Can't find user" } end @@ -228,10 +212,10 @@ test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -247,28 +231,37 @@ test "respects blocks", %{user: user_one, conn: conn} do {:ok, activity} = CommonAPI.post(user_two, %{"status" => "User one sux0rz"}) {:ok, repeat, _} = CommonAPI.repeat(activity.id, user_three) - assert resp = get(conn, "/api/v1/accounts/#{user_two.id}/statuses") |> json_response(200) + assert resp = + conn + |> get("/api/v1/accounts/#{user_two.id}/statuses") + |> json_response_and_validate_schema(200) + assert [%{"id" => id}] = resp - assert_schema(resp, "StatusesResponse", ApiSpec.spec()) assert id == activity.id # Even a blocked user will deliver the full user timeline, there would be # no point in looking at a blocked users timeline otherwise - assert resp = get(conn, "/api/v1/accounts/#{user_two.id}/statuses") |> json_response(200) + assert resp = + conn + |> get("/api/v1/accounts/#{user_two.id}/statuses") + |> json_response_and_validate_schema(200) + assert [%{"id" => id}] = resp assert id == activity.id - assert_schema(resp, "StatusesResponse", ApiSpec.spec()) # Third user's timeline includes the repeat when viewed by unauthenticated user - resp = get(build_conn(), "/api/v1/accounts/#{user_three.id}/statuses") |> json_response(200) + resp = + build_conn() + |> get("/api/v1/accounts/#{user_three.id}/statuses") + |> json_response_and_validate_schema(200) + assert [%{"id" => id}] = resp assert id == repeat.id - assert_schema(resp, "StatusesResponse", ApiSpec.spec()) # When viewing a third user's timeline, the blocked users' statuses will NOT be shown resp = get(conn, "/api/v1/accounts/#{user_three.id}/statuses") - assert [] = json_response(resp, 200) + assert [] == json_response_and_validate_schema(resp, 200) end test "gets users statuses", %{conn: conn} do @@ -289,34 +282,36 @@ test "gets users statuses", %{conn: conn} do {:ok, private_activity} = CommonAPI.post(user_one, %{"status" => "private", "visibility" => "private"}) - resp = get(conn, "/api/v1/accounts/#{user_one.id}/statuses") |> json_response(200) + # TODO!!! + resp = + conn + |> get("/api/v1/accounts/#{user_one.id}/statuses") + |> json_response_and_validate_schema(200) + assert [%{"id" => id}] = resp assert id == to_string(activity.id) - assert_schema(resp, "StatusesResponse", ApiSpec.spec()) resp = conn |> assign(:user, user_two) |> assign(:token, insert(:oauth_token, user: user_two, scopes: ["read:statuses"])) |> get("/api/v1/accounts/#{user_one.id}/statuses") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [%{"id" => id_one}, %{"id" => id_two}] = resp assert id_one == to_string(direct_activity.id) assert id_two == to_string(activity.id) - assert_schema(resp, "StatusesResponse", ApiSpec.spec()) resp = conn |> assign(:user, user_three) |> assign(:token, insert(:oauth_token, user: user_three, scopes: ["read:statuses"])) |> get("/api/v1/accounts/#{user_one.id}/statuses") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [%{"id" => id_one}, %{"id" => id_two}] = resp assert id_one == to_string(private_activity.id) assert id_two == to_string(activity.id) - assert_schema(resp, "StatusesResponse", ApiSpec.spec()) end test "unimplemented pinned statuses feature", %{conn: conn} do @@ -325,7 +320,7 @@ test "unimplemented pinned statuses feature", %{conn: conn} do conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?pinned=true") - assert json_response(conn, 200) == [] + assert json_response_and_validate_schema(conn, 200) == [] end test "gets an users media", %{conn: conn} do @@ -340,61 +335,48 @@ test "gets an users media", %{conn: conn} do {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: user.ap_id) - {:ok, image_post} = CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]}) + {:ok, %{id: image_post_id}} = + CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]}) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?only_media=true") - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(image_post.id) - assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) + assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200) conn = get(build_conn(), "/api/v1/accounts/#{user.id}/statuses?only_media=1") - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(image_post.id) - assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) + assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200) end test "gets a user's statuses without reblogs", %{user: user, conn: conn} do - {:ok, post} = CommonAPI.post(user, %{"status" => "HI!!!"}) - {:ok, _, _} = CommonAPI.repeat(post.id, user) + {:ok, %{id: post_id}} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, _, _} = CommonAPI.repeat(post_id, user) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=true") - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(post.id) - assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) + assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=1") - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(post.id) - assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) + assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200) end test "filters user's statuses by a hashtag", %{user: user, conn: conn} do - {:ok, post} = CommonAPI.post(user, %{"status" => "#hashtag"}) + {:ok, %{id: post_id}} = CommonAPI.post(user, %{"status" => "#hashtag"}) {:ok, _post} = CommonAPI.post(user, %{"status" => "hashtag"}) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?tagged=hashtag") - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(post.id) - assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) + assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200) end test "the user views their own timelines and excludes direct messages", %{ user: user, conn: conn } do - {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + {:ok, %{id: public_activity_id}} = + CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + {:ok, _direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_visibilities[]=direct") - - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(public_activity.id) - assert_schema(json_response(conn, 200), "StatusesResponse", ApiSpec.spec()) + assert [%{"id" => ^public_activity_id}] = json_response_and_validate_schema(conn, 200) end end @@ -414,29 +396,25 @@ defp local_and_remote_activities(%{local: local, remote: remote}) do setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") - - assert json_response(res_conn, :not_found) == %{ - "error" => "Can't find user" - } - - res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") + assert %{"error" => "Can't find user"} == + conn + |> get("/api/v1/accounts/#{local.id}/statuses") + |> json_response_and_validate_schema(:not_found) - assert json_response(res_conn, :not_found) == %{ - "error" => "Can't find user" - } + assert %{"error" => "Can't find user"} == + conn + |> get("/api/v1/accounts/#{remote.id}/statuses") + |> json_response_and_validate_schema(:not_found) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end end @@ -447,27 +425,23 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") - - assert json_response(res_conn, :not_found) == %{ - "error" => "Can't find user" - } + assert %{"error" => "Can't find user"} == + conn + |> get("/api/v1/accounts/#{local.id}/statuses") + |> json_response_and_validate_schema(:not_found) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end end @@ -479,26 +453,22 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") - - assert json_response(res_conn, :not_found) == %{ - "error" => "Can't find user" - } + assert %{"error" => "Can't find user"} == + conn + |> get("/api/v1/accounts/#{remote.id}/statuses") + |> json_response_and_validate_schema(:not_found) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") - assert length(json_response(res_conn, 200)) == 1 - assert_schema(json_response(res_conn, 200), "StatusesResponse", ApiSpec.spec()) + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end end @@ -507,13 +477,11 @@ test "if user is authenticated", %{local: local, remote: remote} do test "getting followers", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, user} = User.follow(user, other_user) + {:ok, %{id: user_id}} = User.follow(user, other_user) conn = get(conn, "/api/v1/accounts/#{other_user.id}/followers") - assert [%{"id" => id}] = json_response(conn, 200) - assert id == to_string(user.id) - assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) + assert [%{"id" => ^user_id}] = json_response_and_validate_schema(conn, 200) end test "getting followers, hide_followers", %{user: user, conn: conn} do @@ -522,7 +490,7 @@ test "getting followers, hide_followers", %{user: user, conn: conn} do conn = get(conn, "/api/v1/accounts/#{other_user.id}/followers") - assert [] == json_response(conn, 200) + assert [] == json_response_and_validate_schema(conn, 200) end test "getting followers, hide_followers, same user requesting" do @@ -536,40 +504,31 @@ test "getting followers, hide_followers, same user requesting" do |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"])) |> get("/api/v1/accounts/#{other_user.id}/followers") - refute [] == json_response(conn, 200) - assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) + refute [] == json_response_and_validate_schema(conn, 200) end test "getting followers, pagination", %{user: user, conn: conn} do - follower1 = insert(:user) - follower2 = insert(:user) - follower3 = insert(:user) - {:ok, _} = User.follow(follower1, user) - {:ok, _} = User.follow(follower2, user) - {:ok, _} = User.follow(follower3, user) - - res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?since_id=#{follower1.id}") + {:ok, %User{id: follower1_id}} = :user |> insert() |> User.follow(user) + {:ok, %User{id: follower2_id}} = :user |> insert() |> User.follow(user) + {:ok, %User{id: follower3_id}} = :user |> insert() |> User.follow(user) - assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) - assert id3 == follower3.id - assert id2 == follower2.id - assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) - - res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?max_id=#{follower3.id}") + assert [%{"id" => ^follower3_id}, %{"id" => ^follower2_id}] = + conn + |> get("/api/v1/accounts/#{user.id}/followers?since_id=#{follower1_id}") + |> json_response_and_validate_schema(200) - assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) - assert id2 == follower2.id - assert id1 == follower1.id + assert [%{"id" => ^follower2_id}, %{"id" => ^follower1_id}] = + conn + |> get("/api/v1/accounts/#{user.id}/followers?max_id=#{follower3_id}") + |> json_response_and_validate_schema(200) - res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3.id}") + res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3_id}") - assert [%{"id" => id2}] = json_response(res_conn, 200) - assert id2 == follower2.id + assert [%{"id" => ^follower2_id}] = json_response_and_validate_schema(res_conn, 200) assert [link_header] = get_resp_header(res_conn, "link") - assert link_header =~ ~r/min_id=#{follower2.id}/ - assert link_header =~ ~r/max_id=#{follower2.id}/ - assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) + assert link_header =~ ~r/min_id=#{follower2_id}/ + assert link_header =~ ~r/max_id=#{follower2_id}/ end end @@ -582,9 +541,8 @@ test "getting following", %{user: user, conn: conn} do conn = get(conn, "/api/v1/accounts/#{user.id}/following") - assert [%{"id" => id}] = json_response(conn, 200) + assert [%{"id" => id}] = json_response_and_validate_schema(conn, 200) assert id == to_string(other_user.id) - assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) end test "getting following, hide_follows, other user requesting" do @@ -598,8 +556,7 @@ test "getting following, hide_follows, other user requesting" do |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"])) |> get("/api/v1/accounts/#{user.id}/following") - assert [] == json_response(conn, 200) - assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) + assert [] == json_response_and_validate_schema(conn, 200) end test "getting following, hide_follows, same user requesting" do @@ -613,7 +570,7 @@ test "getting following, hide_follows, same user requesting" do |> assign(:token, insert(:oauth_token, user: user, scopes: ["read:accounts"])) |> get("/api/v1/accounts/#{user.id}/following") - refute [] == json_response(conn, 200) + refute [] == json_response_and_validate_schema(conn, 200) end test "getting following, pagination", %{user: user, conn: conn} do @@ -626,28 +583,25 @@ test "getting following, pagination", %{user: user, conn: conn} do res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?since_id=#{following1.id}") - assert [%{"id" => id3}, %{"id" => id2}] = json_response(res_conn, 200) + assert [%{"id" => id3}, %{"id" => id2}] = json_response_and_validate_schema(res_conn, 200) assert id3 == following3.id assert id2 == following2.id - assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?max_id=#{following3.id}") - assert [%{"id" => id2}, %{"id" => id1}] = json_response(res_conn, 200) + assert [%{"id" => id2}, %{"id" => id1}] = json_response_and_validate_schema(res_conn, 200) assert id2 == following2.id assert id1 == following1.id - assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}") - assert [%{"id" => id2}] = json_response(res_conn, 200) + assert [%{"id" => id2}] = json_response_and_validate_schema(res_conn, 200) assert id2 == following2.id assert [link_header] = get_resp_header(res_conn, "link") assert link_header =~ ~r/min_id=#{following2.id}/ assert link_header =~ ~r/max_id=#{following2.id}/ - assert_schema(json_response(res_conn, 200), "AccountsResponse", ApiSpec.spec()) end end @@ -655,40 +609,37 @@ test "getting following, pagination", %{user: user, conn: conn} do setup do: oauth_access(["follow"]) test "following / unfollowing a user", %{conn: conn} do - other_user = insert(:user) - - ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/follow") - - assert %{"id" => _id, "following" => true} = json_response(ret_conn, 200) - assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) + %{id: other_user_id, nickname: other_user_nickname} = insert(:user) - ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/unfollow") - - assert %{"id" => _id, "following" => false} = json_response(ret_conn, 200) - assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "following" => true} = + conn + |> post("/api/v1/accounts/#{other_user_id}/follow") + |> json_response_and_validate_schema(200) - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/follows", %{"uri" => other_user.nickname}) + assert %{"id" => _id, "following" => false} = + conn + |> post("/api/v1/accounts/#{other_user_id}/unfollow") + |> json_response_and_validate_schema(200) - assert %{"id" => id} = json_response(conn, 200) - assert id == to_string(other_user.id) - assert_schema(json_response(conn, 200), "Account", ApiSpec.spec()) + assert %{"id" => ^other_user_id} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/follows", %{"uri" => other_user_nickname}) + |> json_response_and_validate_schema(200) end test "cancelling follow request", %{conn: conn} do %{id: other_user_id} = insert(:user, %{locked: true}) - resp = conn |> post("/api/v1/accounts/#{other_user_id}/follow") |> json_response(:ok) - - assert %{"id" => ^other_user_id, "following" => false, "requested" => true} = resp - assert_schema(resp, "AccountRelationship", ApiSpec.spec()) - - resp = conn |> post("/api/v1/accounts/#{other_user_id}/unfollow") |> json_response(:ok) + assert %{"id" => ^other_user_id, "following" => false, "requested" => true} = + conn + |> post("/api/v1/accounts/#{other_user_id}/follow") + |> json_response_and_validate_schema(:ok) - assert %{"id" => ^other_user_id, "following" => false, "requested" => false} = resp - assert_schema(resp, "AccountRelationship", ApiSpec.spec()) + assert %{"id" => ^other_user_id, "following" => false, "requested" => false} = + conn + |> post("/api/v1/accounts/#{other_user_id}/unfollow") + |> json_response_and_validate_schema(:ok) end test "following without reblogs" do @@ -698,50 +649,53 @@ test "following without reblogs" do ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow?reblogs=false") - assert %{"showing_reblogs" => false} = json_response(ret_conn, 200) - assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) + assert %{"showing_reblogs" => false} = json_response_and_validate_schema(ret_conn, 200) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) - {:ok, reblog, _} = CommonAPI.repeat(activity.id, followed) - - ret_conn = get(conn, "/api/v1/timelines/home") + {:ok, %{id: reblog_id}, _} = CommonAPI.repeat(activity.id, followed) - assert [] == json_response(ret_conn, 200) - - ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow?reblogs=true") - - assert %{"showing_reblogs" => true} = json_response(ret_conn, 200) - assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) + assert [] == + conn + |> get("/api/v1/timelines/home") + |> json_response(200) - conn = get(conn, "/api/v1/timelines/home") + assert %{"showing_reblogs" => true} = + conn + |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=true") + |> json_response_and_validate_schema(200) - expected_activity_id = reblog.id - assert [%{"id" => ^expected_activity_id}] = json_response(conn, 200) + assert [%{"id" => ^reblog_id}] = + conn + |> get("/api/v1/timelines/home") + |> json_response(200) end test "following / unfollowing errors", %{user: user, conn: conn} do # self follow conn_res = post(conn, "/api/v1/accounts/#{user.id}/follow") - assert %{"error" => "Can not follow yourself"} = json_response(conn_res, 400) + + assert %{"error" => "Can not follow yourself"} = + json_response_and_validate_schema(conn_res, 400) # self unfollow user = User.get_cached_by_id(user.id) conn_res = post(conn, "/api/v1/accounts/#{user.id}/unfollow") - assert %{"error" => "Can not unfollow yourself"} = json_response(conn_res, 400) + + assert %{"error" => "Can not unfollow yourself"} = + json_response_and_validate_schema(conn_res, 400) # self follow via uri user = User.get_cached_by_id(user.id) - conn_res = - conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/v1/follows", %{"uri" => user.nickname}) - - assert %{"error" => "Can not follow yourself"} = json_response(conn_res, 400) + assert %{"error" => "Can not follow yourself"} = + conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v1/follows", %{"uri" => user.nickname}) + |> json_response_and_validate_schema(400) # follow non existing user conn_res = post(conn, "/api/v1/accounts/doesntexist/follow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn_res, 404) # follow non existing user via uri conn_res = @@ -749,11 +703,11 @@ test "following / unfollowing errors", %{user: user, conn: conn} do |> put_req_header("content-type", "multipart/form-data") |> post("/api/v1/follows", %{"uri" => "doesntexist"}) - assert %{"error" => "Record not found"} = json_response(conn_res, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn_res, 404) # unfollow non existing user conn_res = post(conn, "/api/v1/accounts/doesntexist/unfollow") - assert %{"error" => "Record not found"} = json_response(conn_res, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn_res, 404) end end @@ -763,21 +717,16 @@ test "following / unfollowing errors", %{user: user, conn: conn} do test "with notifications", %{conn: conn} do other_user = insert(:user) - ret_conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{other_user.id}/mute") - - response = json_response(ret_conn, 200) - - assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = response - assert_schema(response, "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/accounts/#{other_user.id}/mute") + |> json_response_and_validate_schema(200) conn = post(conn, "/api/v1/accounts/#{other_user.id}/unmute") - response = json_response(conn, 200) - assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response - assert_schema(response, "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = + json_response_and_validate_schema(conn, 200) end test "without notifications", %{conn: conn} do @@ -788,16 +737,13 @@ test "without notifications", %{conn: conn} do |> put_req_header("content-type", "multipart/form-data") |> post("/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"}) - response = json_response(ret_conn, 200) - - assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = response - assert_schema(response, "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = + json_response_and_validate_schema(ret_conn, 200) conn = post(conn, "/api/v1/accounts/#{other_user.id}/unmute") - response = json_response(conn, 200) - assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response - assert_schema(response, "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = + json_response_and_validate_schema(conn, 200) end end @@ -810,17 +756,13 @@ test "without notifications", %{conn: conn} do [conn: conn, user: user, activity: activity] end - test "returns pinned statuses", %{conn: conn, user: user, activity: activity} do - {:ok, _} = CommonAPI.pin(activity.id, user) + test "returns pinned statuses", %{conn: conn, user: user, activity: %{id: activity_id}} do + {:ok, _} = CommonAPI.pin(activity_id, user) - result = - conn - |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - |> json_response(200) - - id_str = to_string(activity.id) - - assert [%{"id" => ^id_str, "pinned" => true}] = result + assert [%{"id" => ^activity_id, "pinned" => true}] = + conn + |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") + |> json_response_and_validate_schema(200) end end @@ -830,13 +772,11 @@ test "blocking / unblocking a user" do ret_conn = post(conn, "/api/v1/accounts/#{other_user.id}/block") - assert %{"id" => _id, "blocking" => true} = json_response(ret_conn, 200) - assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "blocking" => true} = json_response_and_validate_schema(ret_conn, 200) conn = post(conn, "/api/v1/accounts/#{other_user.id}/unblock") - assert %{"id" => _id, "blocking" => false} = json_response(conn, 200) - assert_schema(json_response(ret_conn, 200), "AccountRelationship", ApiSpec.spec()) + assert %{"id" => _id, "blocking" => false} = json_response_and_validate_schema(conn, 200) end describe "create account by app" do @@ -863,15 +803,15 @@ test "Account registration via Application", %{conn: conn} do scopes: "read, write, follow" }) - %{ - "client_id" => client_id, - "client_secret" => client_secret, - "id" => _, - "name" => "client_name", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "vapid_key" => _, - "website" => nil - } = json_response(conn, 200) + assert %{ + "client_id" => client_id, + "client_secret" => client_secret, + "id" => _, + "name" => "client_name", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "vapid_key" => _, + "website" => nil + } = json_response_and_validate_schema(conn, 200) conn = post(conn, "/oauth/token", %{ @@ -906,7 +846,7 @@ test "Account registration via Application", %{conn: conn} do "created_at" => _created_at, "scope" => _scope, "token_type" => "Bearer" - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) token_from_db = Repo.get_by(Token, token: token) assert token_from_db @@ -926,7 +866,9 @@ test "returns error when user already registred", %{conn: conn, valid_params: va |> put_req_header("content-type", "application/json") |> post("/api/v1/accounts", valid_params) - assert json_response(res, 400) == %{"error" => "{\"email\":[\"has already been taken\"]}"} + assert json_response_and_validate_schema(res, 400) == %{ + "error" => "{\"email\":[\"has already been taken\"]}" + } end test "returns bad_request if missing required params", %{ @@ -941,7 +883,7 @@ test "returns bad_request if missing required params", %{ |> put_req_header("content-type", "application/json") res = post(conn, "/api/v1/accounts", valid_params) - assert json_response(res, 200) + assert json_response_and_validate_schema(res, 200) [{127, 0, 0, 1}, {127, 0, 0, 2}, {127, 0, 0, 3}, {127, 0, 0, 4}] |> Stream.zip(Map.delete(valid_params, :email)) @@ -950,7 +892,7 @@ test "returns bad_request if missing required params", %{ conn |> Map.put(:remote_ip, ip) |> post("/api/v1/accounts", Map.delete(valid_params, attr)) - |> json_response(400) + |> json_response_and_validate_schema(400) assert res == %{ "error" => "Missing field: #{attr}.", @@ -983,14 +925,16 @@ test "returns bad_request if missing email params when :account_activation_requi |> Map.put(:remote_ip, {127, 0, 0, 5}) |> post("/api/v1/accounts", Map.delete(valid_params, :email)) - assert json_response(res, 400) == %{"error" => "Missing parameters"} + assert json_response_and_validate_schema(res, 400) == %{"error" => "Missing parameters"} res = conn |> Map.put(:remote_ip, {127, 0, 0, 6}) |> post("/api/v1/accounts", Map.put(valid_params, :email, "")) - assert json_response(res, 400) == %{"error" => "{\"email\":[\"can't be blank\"]}"} + assert json_response_and_validate_schema(res, 400) == %{ + "error" => "{\"email\":[\"can't be blank\"]}" + } end test "allow registration without an email", %{conn: conn, valid_params: valid_params} do @@ -1003,7 +947,7 @@ test "allow registration without an email", %{conn: conn, valid_params: valid_pa |> Map.put(:remote_ip, {127, 0, 0, 7}) |> post("/api/v1/accounts", Map.delete(valid_params, :email)) - assert json_response(res, 200) + assert json_response_and_validate_schema(res, 200) end test "allow registration with an empty email", %{conn: conn, valid_params: valid_params} do @@ -1016,7 +960,7 @@ test "allow registration with an empty email", %{conn: conn, valid_params: valid |> Map.put(:remote_ip, {127, 0, 0, 8}) |> post("/api/v1/accounts", Map.put(valid_params, :email, "")) - assert json_response(res, 200) + assert json_response_and_validate_schema(res, 200) end test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_params} do @@ -1026,7 +970,7 @@ test "returns forbidden if token is invalid", %{conn: conn, valid_params: valid_ |> put_req_header("content-type", "multipart/form-data") |> post("/api/v1/accounts", valid_params) - assert json_response(res, 403) == %{"error" => "Invalid credentials"} + assert json_response_and_validate_schema(res, 403) == %{"error" => "Invalid credentials"} end test "registration from trusted app" do @@ -1056,7 +1000,7 @@ test "registration from trusted app" do password: "some_password", confirm: "some_password" }) - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{ "access_token" => access_token, @@ -1069,7 +1013,7 @@ test "registration from trusted app" do build_conn() |> Plug.Conn.put_req_header("authorization", "Bearer " <> access_token) |> get("/api/v1/accounts/verify_credentials") - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{ "acct" => "Lain", @@ -1125,7 +1069,7 @@ test "respects rate limit setting", %{conn: conn} do "created_at" => _created_at, "scope" => _scope, "token_type" => "Bearer" - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) token_from_db = Repo.get_by(Token, token: token) assert token_from_db @@ -1143,7 +1087,9 @@ test "respects rate limit setting", %{conn: conn} do agreement: true }) - assert json_response(conn, :too_many_requests) == %{"error" => "Throttled"} + assert json_response_and_validate_schema(conn, :too_many_requests) == %{ + "error" => "Throttled" + } end end @@ -1151,16 +1097,13 @@ test "respects rate limit setting", %{conn: conn} do test "returns lists to which the account belongs" do %{user: user, conn: conn} = oauth_access(["read:lists"]) other_user = insert(:user) - assert {:ok, %Pleroma.List{} = list} = Pleroma.List.create("Test List", user) + assert {:ok, %Pleroma.List{id: list_id} = list} = Pleroma.List.create("Test List", user) {:ok, %{following: _following}} = Pleroma.List.follow(list, other_user) - res = - conn - |> get("/api/v1/accounts/#{other_user.id}/lists") - |> json_response(200) - - assert res == [%{"id" => to_string(list.id), "title" => "Test List"}] - assert_schema(res, "ListsResponse", ApiSpec.spec()) + assert [%{"id" => list_id, "title" => "Test List"}] = + conn + |> get("/api/v1/accounts/#{other_user.id}/lists") + |> json_response_and_validate_schema(200) end end @@ -1169,7 +1112,7 @@ test "verify_credentials" do %{user: user, conn: conn} = oauth_access(["read:accounts"]) conn = get(conn, "/api/v1/accounts/verify_credentials") - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) assert %{"id" => id, "source" => %{"privacy" => "public"}} = response assert response["pleroma"]["chat_token"] @@ -1182,7 +1125,9 @@ test "verify_credentials default scope unlisted" do conn = get(conn, "/api/v1/accounts/verify_credentials") - assert %{"id" => id, "source" => %{"privacy" => "unlisted"}} = json_response(conn, 200) + assert %{"id" => id, "source" => %{"privacy" => "unlisted"}} = + json_response_and_validate_schema(conn, 200) + assert id == to_string(user.id) end @@ -1192,7 +1137,9 @@ test "locked accounts" do conn = get(conn, "/api/v1/accounts/verify_credentials") - assert %{"id" => id, "source" => %{"privacy" => "private"}} = json_response(conn, 200) + assert %{"id" => id, "source" => %{"privacy" => "private"}} = + json_response_and_validate_schema(conn, 200) + assert id == to_string(user.id) end end @@ -1207,18 +1154,18 @@ test "returns the relationships for the current user", %{user: user, conn: conn} assert [%{"id" => ^other_user_id}] = conn |> get("/api/v1/accounts/relationships?id=#{other_user.id}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [%{"id" => ^other_user_id}] = conn |> get("/api/v1/accounts/relationships?id[]=#{other_user.id}") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "returns an empty list on a bad request", %{conn: conn} do conn = get(conn, "/api/v1/accounts/relationships", %{}) - assert [] = json_response(conn, 200) + assert [] = json_response_and_validate_schema(conn, 200) end end @@ -1231,8 +1178,7 @@ test "getting a list of mutes" do conn = get(conn, "/api/v1/mutes") other_user_id = to_string(other_user.id) - assert [%{"id" => ^other_user_id}] = json_response(conn, 200) - assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) + assert [%{"id" => ^other_user_id}] = json_response_and_validate_schema(conn, 200) end test "getting a list of blocks" do @@ -1247,7 +1193,6 @@ test "getting a list of blocks" do |> get("/api/v1/blocks") other_user_id = to_string(other_user.id) - assert [%{"id" => ^other_user_id}] = json_response(conn, 200) - assert_schema(json_response(conn, 200), "AccountsResponse", ApiSpec.spec()) + assert [%{"id" => ^other_user_id}] = json_response_and_validate_schema(conn, 200) end end -- cgit v1.2.3 From cc1e2e8d0f5fa27d051a0a21740a8052b95ce1a5 Mon Sep 17 00:00:00 2001 From: fence Date: Mon, 27 Apr 2020 19:11:03 +0200 Subject: requested changes to mongoose_im_controller.ex --- .../web/mongooseim/mongoose_im_controller.ex | 41 ++++++---------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index c15b4bfb8..7123153c5 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do plug(RateLimiter, [name: :authentication, params: ["user"]] when action == :check_password) def user_exists(conn, %{"user" => username}) do - with %User{} <- Repo.get_by(User, nickname: username, local: true) do + with %User{} <- Repo.get_by(User, nickname: username, local: true, deactivated: false) do conn |> json(true) else @@ -26,41 +26,22 @@ def user_exists(conn, %{"user" => username}) do end def check_password(conn, %{"user" => username, "pass" => password}) do - user = Repo.get_by(User, nickname: username, local: true) - - state = case user do - nil -> nil - _ -> User.account_status(user) - end - - case state do - :deactivated -> + with %User{password_hash: password_hash, deactivated: false} <- + Repo.get_by(User, nickname: username, local: true), + true <- Pbkdf2.checkpw(password, password_hash) do + conn + |> json(true) + else + false -> conn - |> put_status(:not_found) + |> put_status(:forbidden) |> json(false) - :confirmation_pending -> + _ -> conn |> put_status(:not_found) |> json(false) - - _ -> - with %User{password_hash: password_hash} <- - user, - true <- Pbkdf2.checkpw(password, password_hash) do - conn - |> json(true) - else - false -> - conn - |> put_status(:forbidden) - |> json(false) - - _ -> - conn - |> put_status(:not_found) - |> json(false) - end end end end + -- cgit v1.2.3 From 935ca2c1329fd4b4f2b40a5ed7f2c1fa5f95b9bd Mon Sep 17 00:00:00 2001 From: fence Date: Mon, 27 Apr 2020 19:16:05 +0200 Subject: requested changes to mongoose test --- test/web/mongooseim/mongoose_im_controller_test.exs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/web/mongooseim/mongoose_im_controller_test.exs b/test/web/mongooseim/mongoose_im_controller_test.exs index 5987111e5..2b4d124af 100644 --- a/test/web/mongooseim/mongoose_im_controller_test.exs +++ b/test/web/mongooseim/mongoose_im_controller_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MongooseIMController do test "/user_exists", %{conn: conn} do _user = insert(:user, nickname: "lain") _remote_user = insert(:user, nickname: "alice", local: false) + _deactivated_user = insert(:user, nickname: "konata", deactivated: true) res = conn @@ -30,11 +31,18 @@ test "/user_exists", %{conn: conn} do |> json_response(404) assert res == false + + res = + conn + |> get(mongoose_im_path(conn, :user_exists), user: "konata") + |> json_response(404) + + assert res == false end test "/check_password", %{conn: conn} do user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) - _deactivated_user = insert(:user, nickname: "konata", local: false, deactivated: true) + _deactivated_user = insert(:user, nickname: "konata", deactivated: true) res = conn @@ -52,7 +60,7 @@ test "/check_password", %{conn: conn} do res = conn - |> get(mongoose_im_path(conn, :check_password), user: "konata", pass: "1337") + |> get(mongoose_im_path(conn, :check_password), user: "konata", pass: "cool") |> json_response(404) assert res == false -- cgit v1.2.3 From d607b4d84002ff14f51713f1ac74a4971e2dffac Mon Sep 17 00:00:00 2001 From: fence Date: Mon, 27 Apr 2020 19:32:58 +0200 Subject: mongooseim test: explicitly set password for the deactivated used --- test/web/mongooseim/mongoose_im_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mongooseim/mongoose_im_controller_test.exs b/test/web/mongooseim/mongoose_im_controller_test.exs index 2b4d124af..d17f8dbb4 100644 --- a/test/web/mongooseim/mongoose_im_controller_test.exs +++ b/test/web/mongooseim/mongoose_im_controller_test.exs @@ -42,7 +42,7 @@ test "/user_exists", %{conn: conn} do test "/check_password", %{conn: conn} do user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) - _deactivated_user = insert(:user, nickname: "konata", deactivated: true) + _deactivated_user = insert(:user, nickname: "konata", deactivated: true, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) res = conn -- cgit v1.2.3 From dda65f7799e9dfa2e7b87389848eeee10993a858 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 27 Apr 2020 22:55:05 +0400 Subject: Move single used schemas to operation schema --- .../web/api_spec/operations/account_operation.ex | 376 +++++++++++++++++++-- .../api_spec/operations/custom_emoji_operation.ex | 35 +- lib/pleroma/web/api_spec/schemas/account.ex | 4 +- .../web/api_spec/schemas/account_create_request.ex | 60 ---- .../api_spec/schemas/account_create_response.ex | 27 -- lib/pleroma/web/api_spec/schemas/account_emoji.ex | 29 -- .../api_spec/schemas/account_field_attribute.ex | 24 -- .../api_spec/schemas/account_follows_request.ex | 18 - .../web/api_spec/schemas/account_mute_request.ex | 24 -- .../schemas/account_relationships_response.ex | 58 ---- .../schemas/account_update_credentials_request.ex | 125 ------- .../web/api_spec/schemas/accounts_response.ex | 13 - lib/pleroma/web/api_spec/schemas/api_error.ex | 19 ++ lib/pleroma/web/api_spec/schemas/custom_emoji.ex | 30 -- lib/pleroma/web/api_spec/schemas/emoji.ex | 29 ++ lib/pleroma/web/api_spec/schemas/list.ex | 23 -- lib/pleroma/web/api_spec/schemas/lists_response.ex | 16 - lib/pleroma/web/api_spec/schemas/poll.ex | 4 +- lib/pleroma/web/api_spec/schemas/status.ex | 4 +- .../web/api_spec/schemas/statuses_response.ex | 13 - .../mastodon_api/controllers/account_controller.ex | 11 +- test/web/api_spec/account_operation_test.exs | 141 -------- .../controllers/custom_emoji_controller_test.exs | 3 - 23 files changed, 442 insertions(+), 644 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/account_create_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_create_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_emoji.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_field_attribute.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_follows_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_mute_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_relationships_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/accounts_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/api_error.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/custom_emoji.ex create mode 100644 lib/pleroma/web/api_spec/schemas/emoji.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/list.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/lists_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/statuses_response.ex delete mode 100644 test/web/api_spec/account_operation_test.exs diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index bf8d21059..2efe6e901 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -7,18 +7,11 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias OpenApiSpex.Reference alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account - alias Pleroma.Web.ApiSpec.Schemas.ApiError - alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse - alias Pleroma.Web.ApiSpec.Schemas.AccountFollowsRequest - alias Pleroma.Web.ApiSpec.Schemas.AccountMuteRequest alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship - alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse - alias Pleroma.Web.ApiSpec.Schemas.AccountsResponse - alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest + alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.BooleanLike - alias Pleroma.Web.ApiSpec.Schemas.ListsResponse - alias Pleroma.Web.ApiSpec.Schemas.StatusesResponse + alias Pleroma.Web.ApiSpec.Schemas.Status alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope import Pleroma.Web.ApiSpec.Helpers @@ -37,9 +30,9 @@ def create_operation do description: "Creates a user and account records. Returns an account access token for the app that initiated the request. The app should save this token for later, and should wait for the user to confirm their account by clicking a link in their email inbox.", operationId: "AccountController.create", - requestBody: request_body("Parameters", AccountCreateRequest, required: true), + requestBody: request_body("Parameters", create_request(), required: true), responses: %{ - 200 => Operation.response("Account", "application/json", AccountCreateResponse), + 200 => Operation.response("Account", "application/json", create_response()), 400 => Operation.response("Error", "application/json", ApiError), 403 => Operation.response("Error", "application/json", ApiError), 429 => Operation.response("Error", "application/json", ApiError) @@ -67,7 +60,7 @@ def update_credentials_operation do description: "Update the user's display and preferences.", operationId: "AccountController.update_credentials", security: [%{"oAuth" => ["write:accounts"]}], - requestBody: request_body("Parameters", AccountUpdateCredentialsRequest, required: true), + requestBody: request_body("Parameters", update_creadentials_request(), required: true), responses: %{ 200 => Operation.response("Account", "application/json", Account), 403 => Operation.response("Error", "application/json", ApiError) @@ -94,7 +87,7 @@ def relationships_operation do ) ], responses: %{ - 200 => Operation.response("Account", "application/json", AccountRelationshipsResponse) + 200 => Operation.response("Account", "application/json", array_of_relationships()) } } end @@ -146,7 +139,7 @@ def statuses_operation do ) ] ++ pagination_params(), responses: %{ - 200 => Operation.response("Statuses", "application/json", StatusesResponse), + 200 => Operation.response("Statuses", "application/json", array_of_statuses()), 404 => Operation.response("Error", "application/json", ApiError) } } @@ -163,7 +156,7 @@ def followers_operation do parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), responses: %{ - 200 => Operation.response("Accounts", "application/json", AccountsResponse) + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } } end @@ -178,7 +171,7 @@ def following_operation do "Accounts which the given account is following, if network is not hidden by the account owner.", parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), - responses: %{200 => Operation.response("Accounts", "application/json", AccountsResponse)} + responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} } end @@ -190,7 +183,7 @@ def lists_operation do security: [%{"oAuth" => ["read:lists"]}], description: "User lists that you have added this account to.", parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], - responses: %{200 => Operation.response("Lists", "application/json", ListsResponse)} + responses: %{200 => Operation.response("Lists", "application/json", array_of_lists())} } end @@ -240,7 +233,7 @@ def mute_operation do summary: "Mute", operationId: "AccountController.mute", security: [%{"oAuth" => ["follow", "write:mutes"]}], - requestBody: request_body("Parameters", AccountMuteRequest), + requestBody: request_body("Parameters", mute_request()), description: "Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline).", parameters: [ @@ -307,7 +300,7 @@ def follows_operation do summary: "Follows", operationId: "AccountController.follows", security: [%{"oAuth" => ["follow", "write:follows"]}], - requestBody: request_body("Parameters", AccountFollowsRequest, required: true), + requestBody: request_body("Parameters", follows_request(), required: true), responses: %{ 200 => Operation.response("Account", "application/json", AccountRelationship), 400 => Operation.response("Error", "application/json", ApiError), @@ -324,7 +317,7 @@ def mutes_operation do description: "Accounts the user has muted.", security: [%{"oAuth" => ["follow", "read:mutes"]}], responses: %{ - 200 => Operation.response("Accounts", "application/json", AccountsResponse) + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } } end @@ -337,7 +330,7 @@ def blocks_operation do description: "View your blocks. See also accounts/:id/{block,unblock}", security: [%{"oAuth" => ["read:blocks"]}], responses: %{ - 200 => Operation.response("Accounts", "application/json", AccountsResponse) + 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } } end @@ -366,4 +359,343 @@ def identity_proofs_operation do } } end + + defp create_request do + %Schema{ + title: "AccountCreateRequest", + description: "POST body for creating an account", + type: :object, + properties: %{ + reason: %Schema{ + type: :string, + description: + "Text that will be reviewed by moderators if registrations require manual approval" + }, + username: %Schema{type: :string, description: "The desired username for the account"}, + email: %Schema{ + type: :string, + description: + "The email address to be used for login. Required when `account_activation_required` is enabled.", + format: :email + }, + password: %Schema{ + type: :string, + description: "The password to be used for login", + format: :password + }, + agreement: %Schema{ + type: :boolean, + description: + "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." + }, + locale: %Schema{ + type: :string, + description: "The language of the confirmation email that will be sent" + }, + # Pleroma-specific properties: + fullname: %Schema{type: :string, description: "Full name"}, + bio: %Schema{type: :string, description: "Bio", default: ""}, + captcha_solution: %Schema{ + type: :string, + description: "Provider-specific captcha solution" + }, + captcha_token: %Schema{type: :string, description: "Provider-specific captcha token"}, + captcha_answer_data: %Schema{type: :string, description: "Provider-specific captcha data"}, + token: %Schema{ + type: :string, + description: "Invite token required when the registrations aren't public" + } + }, + required: [:username, :password, :agreement], + example: %{ + "username" => "cofe", + "email" => "cofe@example.com", + "password" => "secret", + "agreement" => "true", + "bio" => "☕️" + } + } + end + + defp create_response do + %Schema{ + title: "AccountCreateResponse", + description: "Response schema for an account", + type: :object, + properties: %{ + token_type: %Schema{type: :string}, + access_token: %Schema{type: :string}, + scope: %Schema{type: :array, items: %Schema{type: :string}}, + created_at: %Schema{type: :integer, format: :"date-time"} + }, + example: %{ + "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", + "created_at" => 1_585_918_714, + "scope" => ["read", "write", "follow", "push"], + "token_type" => "Bearer" + } + } + end + + defp update_creadentials_request do + %Schema{ + title: "AccountUpdateCredentialsRequest", + description: "POST body for creating an account", + type: :object, + properties: %{ + bot: %Schema{ + type: :boolean, + description: "Whether the account has a bot flag." + }, + display_name: %Schema{ + type: :string, + description: "The display name to use for the profile." + }, + note: %Schema{type: :string, description: "The account bio."}, + avatar: %Schema{ + type: :string, + description: "Avatar image encoded using multipart/form-data", + format: :binary + }, + header: %Schema{ + type: :string, + description: "Header image encoded using multipart/form-data", + format: :binary + }, + locked: %Schema{ + type: :boolean, + description: "Whether manual approval of follow requests is required." + }, + fields_attributes: %Schema{ + oneOf: [ + %Schema{type: :array, items: attribute_field()}, + %Schema{type: :object, additionalProperties: %Schema{type: attribute_field()}} + ] + }, + # NOTE: `source` field is not supported + # + # source: %Schema{ + # type: :object, + # properties: %{ + # privacy: %Schema{type: :string}, + # sensitive: %Schema{type: :boolean}, + # language: %Schema{type: :string} + # } + # }, + + # Pleroma-specific fields + no_rich_text: %Schema{ + type: :boolean, + description: "html tags are stripped from all statuses requested from the API" + }, + hide_followers: %Schema{type: :boolean, description: "user's followers will be hidden"}, + hide_follows: %Schema{type: :boolean, description: "user's follows will be hidden"}, + hide_followers_count: %Schema{ + type: :boolean, + description: "user's follower count will be hidden" + }, + hide_follows_count: %Schema{ + type: :boolean, + description: "user's follow count will be hidden" + }, + hide_favorites: %Schema{ + type: :boolean, + description: "user's favorites timeline will be hidden" + }, + show_role: %Schema{ + type: :boolean, + description: "user's role (e.g admin, moderator) will be exposed to anyone in the + API" + }, + default_scope: VisibilityScope, + pleroma_settings_store: %Schema{ + type: :object, + description: "Opaque user settings to be saved on the backend." + }, + skip_thread_containment: %Schema{ + type: :boolean, + description: "Skip filtering out broken threads" + }, + allow_following_move: %Schema{ + type: :boolean, + description: "Allows automatically follow moved following accounts" + }, + pleroma_background_image: %Schema{ + type: :string, + description: "Sets the background image of the user.", + format: :binary + }, + discoverable: %Schema{ + type: :boolean, + description: + "Discovery of this account in search results and other services is allowed." + }, + actor_type: ActorType + }, + example: %{ + bot: false, + display_name: "cofe", + note: "foobar", + fields_attributes: [%{name: "foo", value: "bar"}], + no_rich_text: false, + hide_followers: true, + hide_follows: false, + hide_followers_count: false, + hide_follows_count: false, + hide_favorites: false, + show_role: false, + default_scope: "private", + pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, + skip_thread_containment: false, + allow_following_move: false, + discoverable: false, + actor_type: "Person" + } + } + end + + defp array_of_accounts do + %Schema{ + title: "ArrayOfAccounts", + type: :array, + items: Account + } + end + + defp array_of_relationships do + %Schema{ + title: "ArrayOfRelationships", + description: "Response schema for account relationships", + type: :array, + items: AccountRelationship, + example: [ + %{ + "id" => "1", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => false, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => false, + "subscribing" => false, + "endorsed" => true + }, + %{ + "id" => "2", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => false, + "blocked_by" => true, + "muting" => true, + "muting_notifications" => false, + "requested" => true, + "domain_blocking" => false, + "subscribing" => false, + "endorsed" => false + }, + %{ + "id" => "3", + "following" => true, + "showing_reblogs" => true, + "followed_by" => true, + "blocking" => true, + "blocked_by" => false, + "muting" => true, + "muting_notifications" => false, + "requested" => false, + "domain_blocking" => true, + "subscribing" => true, + "endorsed" => false + } + ] + } + end + + defp follows_request do + %Schema{ + title: "AccountFollowsRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + uri: %Schema{type: :string, format: :uri} + }, + required: [:uri] + } + end + + defp mute_request do + %Schema{ + title: "AccountMuteRequest", + description: "POST body for muting an account", + type: :object, + properties: %{ + notifications: %Schema{ + type: :boolean, + description: "Mute notifications in addition to statuses? Defaults to true.", + default: true + } + }, + example: %{ + "notifications" => true + } + } + end + + defp list do + %Schema{ + title: "List", + description: "Response schema for a list", + type: :object, + properties: %{ + id: %Schema{type: :string}, + title: %Schema{type: :string} + }, + example: %{ + "id" => "123", + "title" => "my list" + } + } + end + + defp array_of_lists do + %Schema{ + title: "ArrayOfLists", + description: "Response schema for lists", + type: :array, + items: list(), + example: [ + %{"id" => "123", "title" => "my list"}, + %{"id" => "1337", "title" => "anotehr list"} + ] + } + end + + defp array_of_statuses do + %Schema{ + title: "ArrayOfStatuses", + type: :array, + items: Status + } + end + + defp attribute_field do + %Schema{ + title: "AccountAttributeField", + description: "Request schema for account custom fields", + type: :object, + properties: %{ + name: %Schema{type: :string}, + value: %Schema{type: :string} + }, + required: [:name, :value], + example: %{ + "name" => "Website", + "value" => "https://pleroma.com" + } + } + end end diff --git a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex index a117fe460..2f812ac77 100644 --- a/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex +++ b/lib/pleroma/web/api_spec/operations/custom_emoji_operation.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ApiSpec.CustomEmojiOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.CustomEmoji + alias Pleroma.Web.ApiSpec.Schemas.Emoji def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -19,17 +19,17 @@ def index_operation do description: "Returns custom emojis that are available on the server.", operationId: "CustomEmojiController.index", responses: %{ - 200 => Operation.response("Custom Emojis", "application/json", custom_emojis_resposnse()) + 200 => Operation.response("Custom Emojis", "application/json", resposnse()) } } end - defp custom_emojis_resposnse do + defp resposnse do %Schema{ title: "CustomEmojisResponse", description: "Response schema for custom emojis", type: :array, - items: CustomEmoji, + items: custom_emoji(), example: [ %{ "category" => "Fun", @@ -58,4 +58,31 @@ defp custom_emojis_resposnse do ] } end + + defp custom_emoji do + %Schema{ + title: "CustomEmoji", + description: "Schema for a CustomEmoji", + allOf: [ + Emoji, + %Schema{ + type: :object, + properties: %{ + category: %Schema{type: :string}, + tags: %Schema{type: :array} + } + } + ], + example: %{ + "category" => "Fun", + "shortcode" => "aaaa", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/original/aaaa.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/007/118/static/aaaa.png", + "visible_in_picker" => true, + "tags" => ["Gif", "Fun"] + } + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index d128feb30..d54e2158d 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -4,10 +4,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji alias Pleroma.Web.ApiSpec.Schemas.AccountField alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.ActorType + alias Pleroma.Web.ApiSpec.Schemas.Emoji alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope @@ -24,7 +24,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do bot: %Schema{type: :boolean}, created_at: %Schema{type: :string, format: "date-time"}, display_name: %Schema{type: :string}, - emojis: %Schema{type: :array, items: AccountEmoji}, + emojis: %Schema{type: :array, items: Emoji}, fields: %Schema{type: :array, items: AccountField}, follow_requests_count: %Schema{type: :integer}, followers_count: %Schema{type: :integer}, diff --git a/lib/pleroma/web/api_spec/schemas/account_create_request.ex b/lib/pleroma/web/api_spec/schemas/account_create_request.ex deleted file mode 100644 index 49fa12159..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_create_request.ex +++ /dev/null @@ -1,60 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountCreateRequest", - description: "POST body for creating an account", - type: :object, - properties: %{ - reason: %Schema{ - type: :string, - description: - "Text that will be reviewed by moderators if registrations require manual approval" - }, - username: %Schema{type: :string, description: "The desired username for the account"}, - email: %Schema{ - type: :string, - description: - "The email address to be used for login. Required when `account_activation_required` is enabled.", - format: :email - }, - password: %Schema{ - type: :string, - description: "The password to be used for login", - format: :password - }, - agreement: %Schema{ - type: :boolean, - description: - "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." - }, - locale: %Schema{ - type: :string, - description: "The language of the confirmation email that will be sent" - }, - # Pleroma-specific properties: - fullname: %Schema{type: :string, description: "Full name"}, - bio: %Schema{type: :string, description: "Bio", default: ""}, - captcha_solution: %Schema{type: :string, description: "Provider-specific captcha solution"}, - captcha_token: %Schema{type: :string, description: "Provider-specific captcha token"}, - captcha_answer_data: %Schema{type: :string, description: "Provider-specific captcha data"}, - token: %Schema{ - type: :string, - description: "Invite token required when the registrations aren't public" - } - }, - required: [:username, :password, :agreement], - example: %{ - "username" => "cofe", - "email" => "cofe@example.com", - "password" => "secret", - "agreement" => "true", - "bio" => "☕️" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_create_response.ex b/lib/pleroma/web/api_spec/schemas/account_create_response.ex deleted file mode 100644 index 2237351a2..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_create_response.ex +++ /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.Web.ApiSpec.Schemas.AccountCreateResponse do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountCreateResponse", - description: "Response schema for an account", - type: :object, - properties: %{ - token_type: %Schema{type: :string}, - access_token: %Schema{type: :string}, - scope: %Schema{type: :array, items: %Schema{type: :string}}, - created_at: %Schema{type: :integer, format: :"date-time"} - }, - example: %{ - "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", - "created_at" => 1_585_918_714, - "scope" => ["read", "write", "follow", "push"], - "token_type" => "Bearer" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_emoji.ex b/lib/pleroma/web/api_spec/schemas/account_emoji.ex deleted file mode 100644 index 6c1d4d95c..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_emoji.ex +++ /dev/null @@ -1,29 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountEmoji do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountEmoji", - description: "Response schema for account custom fields", - type: :object, - properties: %{ - shortcode: %Schema{type: :string}, - url: %Schema{type: :string, format: :uri}, - static_url: %Schema{type: :string, format: :uri}, - visible_in_picker: %Schema{type: :boolean} - }, - example: %{ - "shortcode" => "fatyoshi", - "url" => - "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", - "static_url" => - "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", - "visible_in_picker" => true - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex b/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex deleted file mode 100644 index 89e483655..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_field_attribute.ex +++ /dev/null @@ -1,24 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountAttributeField do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountAttributeField", - description: "Request schema for account custom fields", - type: :object, - properties: %{ - name: %Schema{type: :string}, - value: %Schema{type: :string} - }, - required: [:name, :value], - example: %{ - "name" => "Website", - "value" => "https://pleroma.com" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_follows_request.ex b/lib/pleroma/web/api_spec/schemas/account_follows_request.ex deleted file mode 100644 index 19dce0cb2..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_follows_request.ex +++ /dev/null @@ -1,18 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountFollowsRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountFollowsRequest", - description: "POST body for muting an account", - type: :object, - properties: %{ - uri: %Schema{type: :string, format: :uri} - }, - required: [:uri] - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_mute_request.ex b/lib/pleroma/web/api_spec/schemas/account_mute_request.ex deleted file mode 100644 index a61f6d04c..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_mute_request.ex +++ /dev/null @@ -1,24 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountMuteRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountMuteRequest", - description: "POST body for muting an account", - type: :object, - properties: %{ - notifications: %Schema{ - type: :boolean, - description: "Mute notifications in addition to statuses? Defaults to true.", - default: true - } - }, - example: %{ - "notifications" => true - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex b/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex deleted file mode 100644 index 960e14db1..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_relationships_response.ex +++ /dev/null @@ -1,58 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountRelationshipsResponse", - description: "Response schema for account relationships", - type: :array, - items: Pleroma.Web.ApiSpec.Schemas.AccountRelationship, - example: [ - %{ - "id" => "1", - "following" => true, - "showing_reblogs" => true, - "followed_by" => true, - "blocking" => false, - "blocked_by" => true, - "muting" => false, - "muting_notifications" => false, - "requested" => false, - "domain_blocking" => false, - "subscribing" => false, - "endorsed" => true - }, - %{ - "id" => "2", - "following" => true, - "showing_reblogs" => true, - "followed_by" => true, - "blocking" => false, - "blocked_by" => true, - "muting" => true, - "muting_notifications" => false, - "requested" => true, - "domain_blocking" => false, - "subscribing" => false, - "endorsed" => false - }, - %{ - "id" => "3", - "following" => true, - "showing_reblogs" => true, - "followed_by" => true, - "blocking" => true, - "blocked_by" => false, - "muting" => true, - "muting_notifications" => false, - "requested" => false, - "domain_blocking" => true, - "subscribing" => true, - "endorsed" => false - } - ] - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex b/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex deleted file mode 100644 index 35220c78a..000000000 --- a/lib/pleroma/web/api_spec/schemas/account_update_credentials_request.ex +++ /dev/null @@ -1,125 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest do - alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.AccountAttributeField - alias Pleroma.Web.ApiSpec.Schemas.ActorType - alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountUpdateCredentialsRequest", - description: "POST body for creating an account", - type: :object, - properties: %{ - bot: %Schema{ - type: :boolean, - description: "Whether the account has a bot flag." - }, - display_name: %Schema{ - type: :string, - description: "The display name to use for the profile." - }, - note: %Schema{type: :string, description: "The account bio."}, - avatar: %Schema{ - type: :string, - description: "Avatar image encoded using multipart/form-data", - format: :binary - }, - header: %Schema{ - type: :string, - description: "Header image encoded using multipart/form-data", - format: :binary - }, - locked: %Schema{ - type: :boolean, - description: "Whether manual approval of follow requests is required." - }, - fields_attributes: %Schema{ - oneOf: [ - %Schema{type: :array, items: AccountAttributeField}, - %Schema{type: :object, additionalProperties: %Schema{type: AccountAttributeField}} - ] - }, - # NOTE: `source` field is not supported - # - # source: %Schema{ - # type: :object, - # properties: %{ - # privacy: %Schema{type: :string}, - # sensitive: %Schema{type: :boolean}, - # language: %Schema{type: :string} - # } - # }, - - # Pleroma-specific fields - no_rich_text: %Schema{ - type: :boolean, - description: "html tags are stripped from all statuses requested from the API" - }, - hide_followers: %Schema{type: :boolean, description: "user's followers will be hidden"}, - hide_follows: %Schema{type: :boolean, description: "user's follows will be hidden"}, - hide_followers_count: %Schema{ - type: :boolean, - description: "user's follower count will be hidden" - }, - hide_follows_count: %Schema{ - type: :boolean, - description: "user's follow count will be hidden" - }, - hide_favorites: %Schema{ - type: :boolean, - description: "user's favorites timeline will be hidden" - }, - show_role: %Schema{ - type: :boolean, - description: "user's role (e.g admin, moderator) will be exposed to anyone in the - API" - }, - default_scope: VisibilityScope, - pleroma_settings_store: %Schema{ - type: :object, - description: "Opaque user settings to be saved on the backend." - }, - skip_thread_containment: %Schema{ - type: :boolean, - description: "Skip filtering out broken threads" - }, - allow_following_move: %Schema{ - type: :boolean, - description: "Allows automatically follow moved following accounts" - }, - pleroma_background_image: %Schema{ - type: :string, - description: "Sets the background image of the user.", - format: :binary - }, - discoverable: %Schema{ - type: :boolean, - description: "Discovery of this account in search results and other services is allowed." - }, - actor_type: ActorType - }, - example: %{ - bot: false, - display_name: "cofe", - note: "foobar", - fields_attributes: [%{name: "foo", value: "bar"}], - no_rich_text: false, - hide_followers: true, - hide_follows: false, - hide_followers_count: false, - hide_follows_count: false, - hide_favorites: false, - show_role: false, - default_scope: "private", - pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, - skip_thread_containment: false, - allow_following_move: false, - discoverable: false, - actor_type: "Person" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/accounts_response.ex b/lib/pleroma/web/api_spec/schemas/accounts_response.ex deleted file mode 100644 index b714f59e7..000000000 --- a/lib/pleroma/web/api_spec/schemas/accounts_response.ex +++ /dev/null @@ -1,13 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.AccountsResponse do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "AccountsResponse", - type: :array, - items: Pleroma.Web.ApiSpec.Schemas.Account - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/api_error.ex b/lib/pleroma/web/api_spec/schemas/api_error.ex new file mode 100644 index 000000000..5815df94c --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/api_error.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ApiError do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ApiError", + description: "Response schema for API error", + type: :object, + properties: %{error: %Schema{type: :string}}, + example: %{ + "error" => "Something went wrong" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/custom_emoji.ex b/lib/pleroma/web/api_spec/schemas/custom_emoji.ex deleted file mode 100644 index 5531b2081..000000000 --- a/lib/pleroma/web/api_spec/schemas/custom_emoji.ex +++ /dev/null @@ -1,30 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.CustomEmoji do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "CustomEmoji", - description: "Response schema for an CustomEmoji", - type: :object, - properties: %{ - shortcode: %Schema{type: :string}, - url: %Schema{type: :string}, - static_url: %Schema{type: :string}, - visible_in_picker: %Schema{type: :boolean}, - category: %Schema{type: :string}, - tags: %Schema{type: :array} - }, - example: %{ - "shortcode" => "aaaa", - "url" => "https://files.mastodon.social/custom_emojis/images/000/007/118/original/aaaa.png", - "static_url" => - "https://files.mastodon.social/custom_emojis/images/000/007/118/static/aaaa.png", - "visible_in_picker" => true - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/emoji.ex b/lib/pleroma/web/api_spec/schemas/emoji.ex new file mode 100644 index 000000000..26f35e648 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/emoji.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Emoji do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Emoji", + description: "Response schema for an emoji", + type: :object, + properties: %{ + shortcode: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + static_url: %Schema{type: :string, format: :uri}, + visible_in_picker: %Schema{type: :boolean} + }, + example: %{ + "shortcode" => "fatyoshi", + "url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", + "static_url" => + "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", + "visible_in_picker" => true + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex deleted file mode 100644 index f85fac2b8..000000000 --- a/lib/pleroma/web/api_spec/schemas/list.ex +++ /dev/null @@ -1,23 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.List do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "List", - description: "Response schema for a list", - type: :object, - properties: %{ - id: %Schema{type: :string}, - title: %Schema{type: :string} - }, - example: %{ - "id" => "123", - "title" => "my list" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/lists_response.ex b/lib/pleroma/web/api_spec/schemas/lists_response.ex deleted file mode 100644 index 132454579..000000000 --- a/lib/pleroma/web/api_spec/schemas/lists_response.ex +++ /dev/null @@ -1,16 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ListsResponse do - alias Pleroma.Web.ApiSpec.Schemas.List - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ListsResponse", - description: "Response schema for lists", - type: :array, - items: List - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex index 5fc9e889f..0474b550b 100644 --- a/lib/pleroma/web/api_spec/schemas/poll.ex +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + alias Pleroma.Web.ApiSpec.Schemas.Emoji alias Pleroma.Web.ApiSpec.Schemas.FlakeID require OpenApiSpex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do multiple: %Schema{type: :boolean}, votes_count: %Schema{type: :integer}, voted: %Schema{type: :boolean}, - emojis: %Schema{type: :array, items: AccountEmoji}, + emojis: %Schema{type: :array, items: Emoji}, options: %Schema{ type: :array, items: %Schema{ diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index bf5f04691..aef0588d4 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account - alias Pleroma.Web.ApiSpec.Schemas.AccountEmoji + alias Pleroma.Web.ApiSpec.Schemas.Emoji alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.Poll alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope @@ -41,7 +41,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do }, content: %Schema{type: :string, format: :html}, created_at: %Schema{type: :string, format: "date-time"}, - emojis: %Schema{type: :array, items: AccountEmoji}, + emojis: %Schema{type: :array, items: Emoji}, favourited: %Schema{type: :boolean}, favourites_count: %Schema{type: :integer}, id: FlakeID, diff --git a/lib/pleroma/web/api_spec/schemas/statuses_response.ex b/lib/pleroma/web/api_spec/schemas/statuses_response.ex deleted file mode 100644 index fb7c7e0aa..000000000 --- a/lib/pleroma/web/api_spec/schemas/statuses_response.ex +++ /dev/null @@ -1,13 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.StatusesResponse do - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "StatusesResponse", - type: :array, - items: Pleroma.Web.ApiSpec.Schemas.Status - }) -end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index b1513001b..37adeec5f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -104,8 +104,7 @@ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do :fullname ]) |> Map.put(:nickname, params.username) - |> Map.put(:fullname, params.fullname || params.username) - |> Map.put(:bio, params.bio || "") + |> Map.put(:fullname, Map.get(params, :fullname, params.username)) |> Map.put(:confirm, params.password) |> Map.put(:trusted_app, app.trusted) @@ -158,7 +157,6 @@ def update_credentials(%{assigns: %{user: original_user}, body_params: params} = params = params - |> Map.from_struct() |> Enum.filter(fn {_, value} -> not is_nil(value) end) |> Enum.into(%{}) @@ -217,11 +215,8 @@ defp normalize_fields_attributes(fields) do Enum.map(fields, fn {_, v} -> v end) else Enum.map(fields, fn - %Pleroma.Web.ApiSpec.Schemas.AccountAttributeField{} = field -> - %{"name" => field.name, "value" => field.value} - - field -> - field + %{} = field -> %{"name" => field.name, "value" => field.value} + field -> field end) end end diff --git a/test/web/api_spec/account_operation_test.exs b/test/web/api_spec/account_operation_test.exs deleted file mode 100644 index 892ade71c..000000000 --- a/test/web/api_spec/account_operation_test.exs +++ /dev/null @@ -1,141 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.AccountOperationTest do - use Pleroma.Web.ConnCase - - alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.Account - alias Pleroma.Web.ApiSpec.Schemas.AccountCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.AccountCreateResponse - alias Pleroma.Web.ApiSpec.Schemas.AccountRelationshipsResponse - alias Pleroma.Web.ApiSpec.Schemas.AccountUpdateCredentialsRequest - - import OpenApiSpex.TestAssertions - import Pleroma.Factory - - test "Account example matches schema" do - api_spec = ApiSpec.spec() - schema = Account.schema() - assert_schema(schema.example, "Account", api_spec) - end - - test "AccountCreateRequest example matches schema" do - api_spec = ApiSpec.spec() - schema = AccountCreateRequest.schema() - assert_schema(schema.example, "AccountCreateRequest", api_spec) - end - - test "AccountCreateResponse example matches schema" do - api_spec = ApiSpec.spec() - schema = AccountCreateResponse.schema() - assert_schema(schema.example, "AccountCreateResponse", api_spec) - end - - test "AccountUpdateCredentialsRequest example matches schema" do - api_spec = ApiSpec.spec() - schema = AccountUpdateCredentialsRequest.schema() - assert_schema(schema.example, "AccountUpdateCredentialsRequest", api_spec) - end - - test "AccountController produces a AccountCreateResponse", %{conn: conn} do - api_spec = ApiSpec.spec() - app_token = insert(:oauth_token, user: nil) - - json = - conn - |> put_req_header("authorization", "Bearer " <> app_token.token) - |> put_req_header("content-type", "application/json") - |> post( - "/api/v1/accounts", - %{ - username: "foo", - email: "bar@example.org", - password: "qwerty", - agreement: true - } - ) - |> json_response(200) - - assert_schema(json, "AccountCreateResponse", api_spec) - end - - test "AccountUpdateCredentialsRequest produces an Account", %{conn: conn} do - api_spec = ApiSpec.spec() - token = insert(:oauth_token, scopes: ["read", "write"]) - - json = - conn - |> put_req_header("authorization", "Bearer " <> token.token) - |> put_req_header("content-type", "application/json") - |> patch( - "/api/v1/accounts/update_credentials", - %{ - hide_followers_count: "true", - hide_follows_count: "true", - skip_thread_containment: "true", - hide_follows: "true", - pleroma_settings_store: %{"pleroma-fe" => %{"key" => "val"}}, - note: "foobar", - fields_attributes: [%{name: "foo", value: "bar"}] - } - ) - |> json_response(200) - - assert_schema(json, "Account", api_spec) - end - - test "AccountRelationshipsResponse example matches schema" do - api_spec = ApiSpec.spec() - schema = AccountRelationshipsResponse.schema() - assert_schema(schema.example, "AccountRelationshipsResponse", api_spec) - end - - test "/api/v1/accounts/relationships produces AccountRelationshipsResponse", %{ - conn: conn - } do - token = insert(:oauth_token, scopes: ["read", "write"]) - other_user = insert(:user) - {:ok, _user} = Pleroma.User.follow(token.user, other_user) - api_spec = ApiSpec.spec() - - assert [relationship] = - conn - |> put_req_header("authorization", "Bearer " <> token.token) - |> get("/api/v1/accounts/relationships?id=#{other_user.id}") - |> json_response(:ok) - - assert_schema([relationship], "AccountRelationshipsResponse", api_spec) - end - - test "/api/v1/accounts/:id produces Account", %{ - conn: conn - } do - user = insert(:user) - api_spec = ApiSpec.spec() - - assert resp = - conn - |> get("/api/v1/accounts/#{user.id}") - |> json_response(:ok) - - assert_schema(resp, "Account", api_spec) - end - - test "/api/v1/accounts/:id/statuses produces StatusesResponse", %{ - conn: conn - } do - user = insert(:user) - Pleroma.Web.CommonAPI.post(user, %{"status" => "foobar"}) - - api_spec = ApiSpec.spec() - - assert resp = - conn - |> get("/api/v1/accounts/#{user.id}/statuses") - |> json_response(:ok) - - assert_schema(resp, "StatusesResponse", api_spec) - end -end diff --git a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs index 4222556a4..ab0027f90 100644 --- a/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs +++ b/test/web/mastodon_api/controllers/custom_emoji_controller_test.exs @@ -4,8 +4,6 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiControllerTest do use Pleroma.Web.ConnCase, async: true - alias Pleroma.Web.ApiSpec - import OpenApiSpex.TestAssertions test "with tags", %{conn: conn} do assert resp = @@ -21,6 +19,5 @@ test "with tags", %{conn: conn} do assert Map.has_key?(emoji, "category") assert Map.has_key?(emoji, "url") assert Map.has_key?(emoji, "visible_in_picker") - assert_schema(emoji, "CustomEmoji", ApiSpec.spec()) end end -- cgit v1.2.3 From 5ff20793e739daa962cdc1623c01dc6ec1ff8a61 Mon Sep 17 00:00:00 2001 From: fence Date: Tue, 28 Apr 2020 01:29:31 +0200 Subject: formating --- lib/pleroma/web/mongooseim/mongoose_im_controller.ex | 1 - test/web/mongooseim/mongoose_im_controller_test.exs | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 7123153c5..1ed6ee521 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -44,4 +44,3 @@ def check_password(conn, %{"user" => username, "pass" => password}) do end end end - diff --git a/test/web/mongooseim/mongoose_im_controller_test.exs b/test/web/mongooseim/mongoose_im_controller_test.exs index d17f8dbb4..1ac2f2c27 100644 --- a/test/web/mongooseim/mongoose_im_controller_test.exs +++ b/test/web/mongooseim/mongoose_im_controller_test.exs @@ -42,7 +42,13 @@ test "/user_exists", %{conn: conn} do test "/check_password", %{conn: conn} do user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) - _deactivated_user = insert(:user, nickname: "konata", deactivated: true, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) + + _deactivated_user = + insert(:user, + nickname: "konata", + deactivated: true, + password_hash: Comeonin.Pbkdf2.hashpwsalt("cool") + ) res = conn @@ -65,7 +71,6 @@ test "/check_password", %{conn: conn} do assert res == false - res = conn |> get(mongoose_im_path(conn, :check_password), user: "nobody", pass: "cool") -- cgit v1.2.3 From 270c3fe446a374202b6d64ce487f7df29ecb1c14 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 28 Apr 2020 06:45:59 +0300 Subject: fix markdown format --- docs/API/differences_in_mastoapi_responses.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 0a7520f9e..a56a74064 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -185,11 +185,11 @@ Post here request with `grant_type=refresh_token` to obtain new access token. Re 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 registrations 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. ## Markers -- cgit v1.2.3 From ea5142b94bcd1a02571776440dc828dd08a2c3d6 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 28 Apr 2020 09:32:43 +0300 Subject: convert markdown content to html --- lib/pleroma/web/activity_pub/transmogrifier.ex | 15 +- test/fixtures/tesla_mock/bittube-video.json | 1 - test/fixtures/tesla_mock/craigmaloney.json | 112 ++++++++++++ test/fixtures/tesla_mock/hanimated.json | 1 - test/fixtures/tesla_mock/peertube-social.json | 234 +++++++++++++++++++++++++ test/support/http_request_mock.ex | 8 +- test/web/activity_pub/transmogrifier_test.exs | 19 +- 7 files changed, 375 insertions(+), 15 deletions(-) delete mode 100644 test/fixtures/tesla_mock/bittube-video.json create mode 100644 test/fixtures/tesla_mock/craigmaloney.json delete mode 100644 test/fixtures/tesla_mock/hanimated.json create mode 100644 test/fixtures/tesla_mock/peertube-social.json diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 17e3c203a..91933dc0f 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do A module to handle coding from internal to wire ActivityPub and back. """ alias Pleroma.Activity + alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship alias Pleroma.Object alias Pleroma.Object.Containment @@ -35,7 +36,6 @@ def fix_object(object, options \\ []) do |> fix_actor |> fix_url |> fix_attachments - |> fix_media_type |> fix_context |> fix_in_reply_to(options) |> fix_emoji @@ -44,6 +44,7 @@ def fix_object(object, options \\ []) do |> fix_addressing |> fix_summary |> fix_type(options) + |> fix_content end def fix_summary(%{"summary" => nil} = object) do @@ -358,11 +359,17 @@ def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) def fix_type(object, _), do: object - defp fix_media_type(%{"mediaType" => _} = object) do - Map.put(object, "mediaType", "text/html") + defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = object) + when is_binary(content) do + html_content = + content + |> Earmark.as_html!(%Earmark.Options{renderer: EarmarkRenderer}) + |> Pleroma.HTML.filter_tags() + + Map.merge(object, %{"content" => html_content, "mediaType" => "text/html"}) end - defp fix_media_type(object), do: object + defp fix_content(object), do: object defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do with true <- id =~ "follows", diff --git a/test/fixtures/tesla_mock/bittube-video.json b/test/fixtures/tesla_mock/bittube-video.json deleted file mode 100644 index be839862f..000000000 --- a/test/fixtures/tesla_mock/bittube-video.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"Video","id":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b","name":"Implications of 5G Rollout Simply Explained","duration":"PT428S","uuid":"2aad7dfb-5c75-4ee6-a9ed-08436af0558b","tag":[{"type":"Hashtag","name":"5g"},{"type":"Hashtag","name":"big brother"},{"type":"Hashtag","name":"facial recognition"},{"type":"Hashtag","name":"smart device"}],"category":{"identifier":"15","name":"Science & Technology"},"language":{"identifier":"en","name":"English"},"views":5,"sensitive":false,"waitTranscoding":true,"state":1,"commentsEnabled":true,"downloadEnabled":true,"published":"2020-04-12T11:55:44.805Z","originallyPublishedAt":null,"updated":"2020-04-13T02:01:24.279Z","mediaType":"text/markdown","content":null,"support":null,"subtitleLanguage":[],"icon":{"type":"Image","url":"https://bittube.video/static/thumbnails/2aad7dfb-5c75-4ee6-a9ed-08436af0558b.jpg","mediaType":"image/jpeg","width":223,"height":122},"url":[{"type":"Link","mediaType":"text/html","href":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b"},{"type":"Link","mediaType":"video/mp4","href":"https://bittube.video/static/webseed/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.mp4","height":240,"size":17158094,"fps":30},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://bittube.video/static/torrents/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.torrent","height":240},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fbittube.video%2Fstatic%2Ftorrents%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.torrent&xt=urn:btih:16c8f60d788a29e7ff195de44b4a1558b41dc6c3&dn=Implications+of+5G+Rollout+Simply+Explained&tr=wss%3A%2F%2Fbittube.video%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fbittube.video%2Ftracker%2Fannounce&ws=https%3A%2F%2Fbittube.video%2Fstatic%2Fwebseed%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-240.mp4","height":240},{"type":"Link","mediaType":"video/mp4","href":"https://bittube.video/static/webseed/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.mp4","height":0,"size":5215186,"fps":0},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://bittube.video/static/torrents/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.torrent","height":0},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fbittube.video%2Fstatic%2Ftorrents%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.torrent&xt=urn:btih:8a043b09291f2947423ce96d1cd0e977662d6de8&dn=Implications+of+5G+Rollout+Simply+Explained&tr=wss%3A%2F%2Fbittube.video%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fbittube.video%2Ftracker%2Fannounce&ws=https%3A%2F%2Fbittube.video%2Fstatic%2Fwebseed%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-0.mp4","height":0},{"type":"Link","mediaType":"video/mp4","href":"https://bittube.video/static/webseed/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.mp4","height":360,"size":22813140,"fps":30},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://bittube.video/static/torrents/2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.torrent","height":360},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fbittube.video%2Fstatic%2Ftorrents%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.torrent&xt=urn:btih:d121f7493998d4204b3d33d00da7fea1c9a42484&dn=Implications+of+5G+Rollout+Simply+Explained&tr=wss%3A%2F%2Fbittube.video%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fbittube.video%2Ftracker%2Fannounce&ws=https%3A%2F%2Fbittube.video%2Fstatic%2Fwebseed%2F2aad7dfb-5c75-4ee6-a9ed-08436af0558b-360.mp4","height":360}],"likes":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/likes","dislikes":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/dislikes","shares":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/announces","comments":"https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b/comments","attributedTo":[{"type":"Person","id":"https://bittube.video/accounts/hanimated.moh"},{"type":"Group","id":"https://bittube.video/video-channels/hanimated.moh_channel"}],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://bittube.video/accounts/hanimated.moh/followers"],"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017","pt":"https://joinpeertube.org/ns#","sc":"http://schema.org#","Hashtag":"as:Hashtag","uuid":"sc:identifier","category":"sc:category","licence":"sc:license","subtitleLanguage":"sc:subtitleLanguage","sensitive":"as:sensitive","language":"sc:inLanguage","expires":"sc:expires","CacheFile":"pt:CacheFile","Infohash":"pt:Infohash","originallyPublishedAt":"sc:datePublished","views":{"@type":"sc:Number","@id":"pt:views"},"state":{"@type":"sc:Number","@id":"pt:state"},"size":{"@type":"sc:Number","@id":"pt:size"},"fps":{"@type":"sc:Number","@id":"pt:fps"},"startTimestamp":{"@type":"sc:Number","@id":"pt:startTimestamp"},"stopTimestamp":{"@type":"sc:Number","@id":"pt:stopTimestamp"},"position":{"@type":"sc:Number","@id":"pt:position"},"commentsEnabled":{"@type":"sc:Boolean","@id":"pt:commentsEnabled"},"downloadEnabled":{"@type":"sc:Boolean","@id":"pt:downloadEnabled"},"waitTranscoding":{"@type":"sc:Boolean","@id":"pt:waitTranscoding"},"support":{"@type":"sc:Text","@id":"pt:support"}},{"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"playlists":{"@id":"pt:playlists","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}]} diff --git a/test/fixtures/tesla_mock/craigmaloney.json b/test/fixtures/tesla_mock/craigmaloney.json new file mode 100644 index 000000000..56ea9c7c3 --- /dev/null +++ b/test/fixtures/tesla_mock/craigmaloney.json @@ -0,0 +1,112 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "CacheFile": "pt:CacheFile", + "Hashtag": "as:Hashtag", + "Infohash": "pt:Infohash", + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017", + "category": "sc:category", + "commentsEnabled": { + "@id": "pt:commentsEnabled", + "@type": "sc:Boolean" + }, + "downloadEnabled": { + "@id": "pt:downloadEnabled", + "@type": "sc:Boolean" + }, + "expires": "sc:expires", + "fps": { + "@id": "pt:fps", + "@type": "sc:Number" + }, + "language": "sc:inLanguage", + "licence": "sc:license", + "originallyPublishedAt": "sc:datePublished", + "position": { + "@id": "pt:position", + "@type": "sc:Number" + }, + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org#", + "sensitive": "as:sensitive", + "size": { + "@id": "pt:size", + "@type": "sc:Number" + }, + "startTimestamp": { + "@id": "pt:startTimestamp", + "@type": "sc:Number" + }, + "state": { + "@id": "pt:state", + "@type": "sc:Number" + }, + "stopTimestamp": { + "@id": "pt:stopTimestamp", + "@type": "sc:Number" + }, + "subtitleLanguage": "sc:subtitleLanguage", + "support": { + "@id": "pt:support", + "@type": "sc:Text" + }, + "uuid": "sc:identifier", + "views": { + "@id": "pt:views", + "@type": "sc:Number" + }, + "waitTranscoding": { + "@id": "pt:waitTranscoding", + "@type": "sc:Boolean" + } + }, + { + "comments": { + "@id": "as:comments", + "@type": "@id" + }, + "dislikes": { + "@id": "as:dislikes", + "@type": "@id" + }, + "likes": { + "@id": "as:likes", + "@type": "@id" + }, + "playlists": { + "@id": "pt:playlists", + "@type": "@id" + }, + "shares": { + "@id": "as:shares", + "@type": "@id" + } + } + ], + "endpoints": { + "sharedInbox": "https://peertube.social/inbox" + }, + "followers": "https://peertube.social/accounts/craigmaloney/followers", + "following": "https://peertube.social/accounts/craigmaloney/following", + "icon": { + "mediaType": "image/png", + "type": "Image", + "url": "https://peertube.social/lazy-static/avatars/87bd694b-95bc-4066-83f4-bddfcd2b9caa.png" + }, + "id": "https://peertube.social/accounts/craigmaloney", + "inbox": "https://peertube.social/accounts/craigmaloney/inbox", + "name": "Craig Maloney", + "outbox": "https://peertube.social/accounts/craigmaloney/outbox", + "playlists": "https://peertube.social/accounts/craigmaloney/playlists", + "preferredUsername": "craigmaloney", + "publicKey": { + "id": "https://peertube.social/accounts/craigmaloney#main-key", + "owner": "https://peertube.social/accounts/craigmaloney", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9qvGIYUW01yc8CCsrwxK\n5OXlV5s7EbNWY8tJr/p1oGuELZwAnG2XKxtdbvgcCT+YxL5uRXIdCFIIIKrzRFr/\nHfS0mOgNT9u3gu+SstCNgtatciT0RVP77yiC3b2NHq1NRRvvVhzQb4cpIWObIxqh\nb2ypDClTc7XaKtgmQCbwZlGyZMT+EKz/vustD6BlpGsglRkm7iES6s1PPGb1BU+n\nS94KhbS2DOFiLcXCVWt0QarokIIuKznp4+xP1axKyP+SkT5AHx08Nd5TYFb2C1Jl\nz0WD/1q0mAN62m7QrA3SQPUgB+wWD+S3Nzf7FwNPiP4srbBgxVEUnji/r9mQ6BXC\nrQIDAQAB\n-----END PUBLIC KEY-----" + }, + "summary": null, + "type": "Person", + "url": "https://peertube.social/accounts/craigmaloney" +} diff --git a/test/fixtures/tesla_mock/hanimated.json b/test/fixtures/tesla_mock/hanimated.json deleted file mode 100644 index 564deebd9..000000000 --- a/test/fixtures/tesla_mock/hanimated.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"Person","id":"https://bittube.video/accounts/hanimated.moh","following":"https://bittube.video/accounts/hanimated.moh/following","followers":"https://bittube.video/accounts/hanimated.moh/followers","playlists":"https://bittube.video/accounts/hanimated.moh/playlists","inbox":"https://bittube.video/accounts/hanimated.moh/inbox","outbox":"https://bittube.video/accounts/hanimated.moh/outbox","preferredUsername":"hanimated.moh","url":"https://bittube.video/accounts/hanimated.moh","name":"Nosat","endpoints":{"sharedInbox":"https://bittube.video/inbox"},"publicKey":{"id":"https://bittube.video/accounts/hanimated.moh#main-key","owner":"https://bittube.video/accounts/hanimated.moh","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwuoQT+4uyAboQcf/okCM\nFqUS/LuqFc2888OSKZFAz00Op/dyOB/pkr1+QLxbl8ZGiUWhmnmhNwmmd3tbhSsC\nvLv9Mz/YaWQPYLfRS/s/7iIxdniC4lo/YgicOrzcvetHmk1feOg5vb5/yc+bgUSm\nOk+L4azqXP9GmZyofzvufT65bUmzQRFXP19eL55YZWvZDaC81QAfRXsqtCqbehtF\nQNOjGhnl6a7Kfe8KprRDPV/3WvvFjftnNO2qenIIOFLLeznkQ0ELP6lyb9pvv/1C\n2/GRh2BwmgVlCTw1kTxLSdj80BFX5P8AudSiIx079lVkhamEhzsNLkMpQFqWAAlg\nrQIDAQAB\n-----END PUBLIC KEY-----"},"icon":{"type":"Image","mediaType":"image/jpeg","url":"https://bittube.video/lazy-static/avatars/84b8acc3-e48b-4642-a9f4-360a4499579b.jpg"},"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017","pt":"https://joinpeertube.org/ns#","sc":"http://schema.org#","Hashtag":"as:Hashtag","uuid":"sc:identifier","category":"sc:category","licence":"sc:license","subtitleLanguage":"sc:subtitleLanguage","sensitive":"as:sensitive","language":"sc:inLanguage","expires":"sc:expires","CacheFile":"pt:CacheFile","Infohash":"pt:Infohash","originallyPublishedAt":"sc:datePublished","views":{"@type":"sc:Number","@id":"pt:views"},"state":{"@type":"sc:Number","@id":"pt:state"},"size":{"@type":"sc:Number","@id":"pt:size"},"fps":{"@type":"sc:Number","@id":"pt:fps"},"startTimestamp":{"@type":"sc:Number","@id":"pt:startTimestamp"},"stopTimestamp":{"@type":"sc:Number","@id":"pt:stopTimestamp"},"position":{"@type":"sc:Number","@id":"pt:position"},"commentsEnabled":{"@type":"sc:Boolean","@id":"pt:commentsEnabled"},"downloadEnabled":{"@type":"sc:Boolean","@id":"pt:downloadEnabled"},"waitTranscoding":{"@type":"sc:Boolean","@id":"pt:waitTranscoding"},"support":{"@type":"sc:Text","@id":"pt:support"}},{"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"playlists":{"@id":"pt:playlists","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}],"summary":null} diff --git a/test/fixtures/tesla_mock/peertube-social.json b/test/fixtures/tesla_mock/peertube-social.json new file mode 100644 index 000000000..0e996ba35 --- /dev/null +++ b/test/fixtures/tesla_mock/peertube-social.json @@ -0,0 +1,234 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "CacheFile": "pt:CacheFile", + "Hashtag": "as:Hashtag", + "Infohash": "pt:Infohash", + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017", + "category": "sc:category", + "commentsEnabled": { + "@id": "pt:commentsEnabled", + "@type": "sc:Boolean" + }, + "downloadEnabled": { + "@id": "pt:downloadEnabled", + "@type": "sc:Boolean" + }, + "expires": "sc:expires", + "fps": { + "@id": "pt:fps", + "@type": "sc:Number" + }, + "language": "sc:inLanguage", + "licence": "sc:license", + "originallyPublishedAt": "sc:datePublished", + "position": { + "@id": "pt:position", + "@type": "sc:Number" + }, + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org#", + "sensitive": "as:sensitive", + "size": { + "@id": "pt:size", + "@type": "sc:Number" + }, + "startTimestamp": { + "@id": "pt:startTimestamp", + "@type": "sc:Number" + }, + "state": { + "@id": "pt:state", + "@type": "sc:Number" + }, + "stopTimestamp": { + "@id": "pt:stopTimestamp", + "@type": "sc:Number" + }, + "subtitleLanguage": "sc:subtitleLanguage", + "support": { + "@id": "pt:support", + "@type": "sc:Text" + }, + "uuid": "sc:identifier", + "views": { + "@id": "pt:views", + "@type": "sc:Number" + }, + "waitTranscoding": { + "@id": "pt:waitTranscoding", + "@type": "sc:Boolean" + } + }, + { + "comments": { + "@id": "as:comments", + "@type": "@id" + }, + "dislikes": { + "@id": "as:dislikes", + "@type": "@id" + }, + "likes": { + "@id": "as:likes", + "@type": "@id" + }, + "playlists": { + "@id": "pt:playlists", + "@type": "@id" + }, + "shares": { + "@id": "as:shares", + "@type": "@id" + } + } + ], + "attributedTo": [ + { + "id": "https://peertube.social/accounts/craigmaloney", + "type": "Person" + }, + { + "id": "https://peertube.social/video-channels/9909c7d9-6b5b-4aae-9164-c1af7229c91c", + "type": "Group" + } + ], + "category": { + "identifier": "15", + "name": "Science & Technology" + }, + "cc": [ + "https://peertube.social/accounts/craigmaloney/followers" + ], + "comments": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/comments", + "commentsEnabled": true, + "content": "Support this and our other Michigan!/usr/group videos and meetings. Learn more at http://mug.org/membership\n\nTwenty Years in Jail: FreeBSD's Jails, Then and Now\n\nJails started as a limited virtualization system, but over the last two years they've...", + "dislikes": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/dislikes", + "downloadEnabled": true, + "duration": "PT5151S", + "icon": { + "height": 122, + "mediaType": "image/jpeg", + "type": "Image", + "url": "https://peertube.social/static/thumbnails/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe.jpg", + "width": 223 + }, + "id": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe", + "language": { + "identifier": "en", + "name": "English" + }, + "licence": { + "identifier": "1", + "name": "Attribution" + }, + "likes": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/likes", + "mediaType": "text/markdown", + "name": "Twenty Years in Jail: FreeBSD's Jails, Then and Now", + "originallyPublishedAt": "2019-08-13T00:00:00.000Z", + "published": "2020-02-12T01:06:08.054Z", + "sensitive": false, + "shares": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe/announces", + "state": 1, + "subtitleLanguage": [], + "support": "Learn more at http://mug.org", + "tag": [ + { + "name": "linux", + "type": "Hashtag" + }, + { + "name": "mug.org", + "type": "Hashtag" + }, + { + "name": "open", + "type": "Hashtag" + }, + { + "name": "oss", + "type": "Hashtag" + }, + { + "name": "source", + "type": "Hashtag" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Video", + "updated": "2020-02-15T15:01:09.474Z", + "url": [ + { + "href": "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe", + "mediaType": "text/html", + "type": "Link" + }, + { + "fps": 30, + "height": 240, + "href": "https://peertube.social/static/webseed/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.mp4", + "mediaType": "video/mp4", + "size": 119465800, + "type": "Link" + }, + { + "height": 240, + "href": "https://peertube.social/static/torrents/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.torrent", + "mediaType": "application/x-bittorrent", + "type": "Link" + }, + { + "height": 240, + "href": "magnet:?xs=https%3A%2F%2Fpeertube.social%2Fstatic%2Ftorrents%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.torrent&xt=urn:btih:b3365331a8543bf48d09add56d7fe4b1cbbb5659&dn=Twenty+Years+in+Jail%3A+FreeBSD's+Jails%2C+Then+and+Now&tr=wss%3A%2F%2Fpeertube.social%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.social%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.social%2Fstatic%2Fwebseed%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-240.mp4", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "type": "Link" + }, + { + "fps": 30, + "height": 360, + "href": "https://peertube.social/static/webseed/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.mp4", + "mediaType": "video/mp4", + "size": 143930318, + "type": "Link" + }, + { + "height": 360, + "href": "https://peertube.social/static/torrents/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.torrent", + "mediaType": "application/x-bittorrent", + "type": "Link" + }, + { + "height": 360, + "href": "magnet:?xs=https%3A%2F%2Fpeertube.social%2Fstatic%2Ftorrents%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.torrent&xt=urn:btih:0d37b23c98cb0d89e28b5dc8f49b3c97a041e569&dn=Twenty+Years+in+Jail%3A+FreeBSD's+Jails%2C+Then+and+Now&tr=wss%3A%2F%2Fpeertube.social%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.social%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.social%2Fstatic%2Fwebseed%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-360.mp4", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "type": "Link" + }, + { + "fps": 30, + "height": 480, + "href": "https://peertube.social/static/webseed/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.mp4", + "mediaType": "video/mp4", + "size": 130530754, + "type": "Link" + }, + { + "height": 480, + "href": "https://peertube.social/static/torrents/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.torrent", + "mediaType": "application/x-bittorrent", + "type": "Link" + }, + { + "height": 480, + "href": "magnet:?xs=https%3A%2F%2Fpeertube.social%2Fstatic%2Ftorrents%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.torrent&xt=urn:btih:3a13ff822ad9494165eff6167183ddaaabc1372a&dn=Twenty+Years+in+Jail%3A+FreeBSD's+Jails%2C+Then+and+Now&tr=wss%3A%2F%2Fpeertube.social%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.social%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.social%2Fstatic%2Fwebseed%2F278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe-480.mp4", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "type": "Link" + } + ], + "uuid": "278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe", + "views": 2, + "waitTranscoding": false +} diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 54dde0432..9624cb0f7 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -308,19 +308,19 @@ def get("https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" }} end - def get("https://bittube.video/accounts/hanimated.moh", _, _, _) do + def get("https://peertube.social/accounts/craigmaloney", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/hanimated.json") + body: File.read!("test/fixtures/tesla_mock/craigmaloney.json") }} end - def get("https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b", _, _, _) do + def get("https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe", _, _, _) do {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/bittube-video.json") + body: File.read!("test/fixtures/tesla_mock/peertube-social.json") }} end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index de9663fa9..0404aae6a 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1221,11 +1221,20 @@ test "it rejects activities without a valid ID" do :error = Transmogrifier.handle_incoming(data) end - test "it remaps mediaType of object" do - {:ok, object} = - Fetcher.fetch_object_from_id( - "https://bittube.video/videos/watch/2aad7dfb-5c75-4ee6-a9ed-08436af0558b" - ) + test "it converts content of object to html" do + object_id = "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe" + + {:ok, %{"content" => content_markdown}} = + Fetcher.fetch_and_contain_remote_object_from_id(object_id) + + {:ok, %Pleroma.Object{data: %{"content" => content}} = object} = + Fetcher.fetch_object_from_id(object_id) + + assert content_markdown == + "Support this and our other Michigan!/usr/group videos and meetings. Learn more at http://mug.org/membership\n\nTwenty Years in Jail: FreeBSD's Jails, Then and Now\n\nJails started as a limited virtualization system, but over the last two years they've..." + + assert content == + "

    Support this and our other Michigan!/usr/group videos and meetings. Learn more at http://mug.org/membership

    Twenty Years in Jail: FreeBSD’s Jails, Then and Now

    Jails started as a limited virtualization system, but over the last two years they’ve…

    " assert object.data["mediaType"] == "text/html" end -- cgit v1.2.3 From 906cf53ab94742327d073f56255f695c91339295 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 13:38:02 +0200 Subject: Recipient Type: Cast all elements as ObjectIDs. --- .../activity_pub/object_validators/types/recipients.ex | 15 +++++++++++++-- .../object_validators/types/recipients_test.exs | 12 ++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex index 5a3040842..48fe61e1a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -1,13 +1,24 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do use Ecto.Type - def type, do: {:array, :string} + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + + def type, do: {:array, ObjectID} def cast(object) when is_binary(object) do cast([object]) end - def cast([_ | _] = data), do: {:ok, data} + def cast(data) when is_list(data) do + data + |> Enum.reduce({:ok, []}, fn element, acc -> + case {acc, ObjectID.cast(element)} do + {:error, _} -> :error + {_, :error} -> :error + {{:ok, list}, {:ok, id}} -> {:ok, [id | list]} + end + end) + end def cast(_) do :error diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs index 2f9218774..f278f039b 100644 --- a/test/web/activity_pub/object_validators/types/recipients_test.exs +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -2,11 +2,23 @@ defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients use Pleroma.DataCase + test "it asserts that all elements of the list are object ids" do + list = ["https://lain.com/users/lain", "invalid"] + + assert :error == Recipients.cast(list) + end + test "it works with a list" do list = ["https://lain.com/users/lain"] assert {:ok, list} == Recipients.cast(list) end + test "it works with a list with whole objects" do + list = ["https://lain.com/users/lain", %{"id" => "https://gensokyo.2hu/users/raymoo"}] + resulting_list = ["https://gensokyo.2hu/users/raymoo", "https://lain.com/users/lain"] + assert {:ok, resulting_list} == Recipients.cast(list) + end + test "it turns a single string into a list" do recipient = "https://lain.com/users/lain" -- cgit v1.2.3 From f8e56d4271f8c495316d304dd0de7f0a63eb0645 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 13:43:58 +0200 Subject: SideEffects: Use Object.normalize to get the object. --- lib/pleroma/web/activity_pub/side_effects.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index ebe3071b0..a2b4da8d6 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -30,8 +30,8 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do result end - def handle(%{data: %{"type" => "Create", "object" => object_id}} = activity, meta) do - object = Object.get_by_ap_id(object_id) + def handle(%{data: %{"type" => "Create"}} = activity, meta) do + object = Object.normalize(activity, false) {:ok, _object} = handle_object_creation(object) -- cgit v1.2.3 From 560f2c1979ca4d49f18abd6de6aac49875bfc771 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 28 Apr 2020 16:50:37 +0400 Subject: Add OpenAPI spec for ReportController --- .../web/api_spec/operations/report_operation.ex | 78 ++++++++++++++++++++++ lib/pleroma/web/common_api/common_api.ex | 10 ++- lib/pleroma/web/common_api/utils.ex | 3 +- .../mastodon_api/controllers/report_controller.ex | 6 +- test/web/admin_api/admin_api_controller_test.exs | 40 +++++------ test/web/admin_api/views/report_view_test.exs | 18 ++--- test/web/common_api/common_api_test.exs | 30 ++++----- .../controllers/report_controller_test.exs | 24 ++++--- 8 files changed, 148 insertions(+), 61 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/report_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex new file mode 100644 index 000000000..da4d50703 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["reports"], + summary: "File a report", + description: "Report problematic users to your moderators", + operationId: "ReportController.create", + security: [%{"oAuth" => ["follow", "write:reports"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Report", "application/json", create_response()), + 400 => Operation.response("Report", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "ReportCreateRequest", + description: "POST body for creating a report", + type: :object, + properties: %{ + account_id: %Schema{type: :string, description: "ID of the account to report"}, + status_ids: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Array of Statuses to attach to the report, for context" + }, + comment: %Schema{ + type: :string, + description: "Reason for the report" + }, + forward: %Schema{ + type: :boolean, + default: false, + description: + "If the account is remote, should the report be forwarded to the remote admin?" + } + }, + required: [:account_id], + example: %{ + "account_id" => "123", + "status_ids" => ["1337"], + "comment" => "bad status!", + "forward" => "false" + } + } + end + + defp create_response do + %Schema{ + title: "ReportResponse", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Report ID"}, + action_taken: %Schema{type: :boolean, description: "Is action taken?"} + }, + example: %{ + "id" => "123", + "action_taken" => false + } + } + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index d1efe0c36..53ce7d425 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -380,9 +380,9 @@ def thread_muted?(user, activity) do ThreadMute.exists?(user.id, activity.data["context"]) end - def report(user, %{"account_id" => account_id} = data) do - with {:ok, account} <- get_reported_account(account_id), - {:ok, {content_html, _, _}} <- make_report_content_html(data["comment"]), + def report(user, data) do + with {:ok, account} <- get_reported_account(data.account_id), + {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), {:ok, statuses} <- get_report_statuses(account, data) do ActivityPub.flag(%{ context: Utils.generate_context_id(), @@ -390,13 +390,11 @@ def report(user, %{"account_id" => account_id} = data) do account: account, statuses: statuses, content: content_html, - forward: data["forward"] || false + forward: Map.get(data, :forward, false) }) end end - def report(_user, _params), do: {:error, dgettext("errors", "Valid `account_id` required")} - defp get_reported_account(account_id) do case User.get_cached_by_id(account_id) do %User{} = account -> {:ok, account} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 945e63e22..6540fa5d1 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -504,7 +504,8 @@ def make_report_content_html(comment) do end end - def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do + def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids}) + when is_list(status_ids) do {:ok, Activity.all_by_actor_and_id(actor, status_ids)} end diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex index f5782be13..85bd52106 100644 --- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -9,12 +9,14 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) - plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation + @doc "POST /api/v1/reports" - def create(%{assigns: %{user: user}} = conn, params) do + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do render(conn, "show.json", activity: activity) end diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index f80dbf8dd..1862a9589 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1347,9 +1347,9 @@ test "returns report by its id", %{conn: conn} do {:ok, %{id: report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) response = @@ -1374,16 +1374,16 @@ test "returns 404 when report id is invalid", %{conn: conn} do {:ok, %{id: report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) {:ok, %{id: second_report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel very offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel very offended", + status_ids: [activity.id] }) %{ @@ -1523,9 +1523,9 @@ test "returns reports", %{conn: conn} do {:ok, %{id: report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) response = @@ -1547,15 +1547,15 @@ test "returns reports with specified state", %{conn: conn} do {:ok, %{id: first_report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) {:ok, %{id: second_report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I don't like this user" + account_id: target_user.id, + comment: "I don't like this user" }) CommonAPI.update_report_state(second_report_id, "closed") @@ -3431,9 +3431,9 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do {:ok, %{id: report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ diff --git a/test/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index 5db6629f2..8cfa1dcfa 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -15,7 +15,7 @@ test "renders a report" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.report(user, %{"account_id" => other_user.id}) + {:ok, activity} = CommonAPI.report(user, %{account_id: other_user.id}) expected = %{ content: nil, @@ -48,7 +48,7 @@ test "includes reported statuses" do {:ok, activity} = CommonAPI.post(other_user, %{"status" => "toot"}) {:ok, report_activity} = - CommonAPI.report(user, %{"account_id" => other_user.id, "status_ids" => [activity.id]}) + CommonAPI.report(user, %{account_id: other_user.id, status_ids: [activity.id]}) other_user = Pleroma.User.get_by_id(other_user.id) @@ -81,7 +81,7 @@ test "renders report's state" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.report(user, %{"account_id" => other_user.id}) + {:ok, activity} = CommonAPI.report(user, %{account_id: other_user.id}) {:ok, activity} = CommonAPI.update_report_state(activity.id, "closed") assert %{state: "closed"} = @@ -94,8 +94,8 @@ test "renders report description" do {:ok, activity} = CommonAPI.report(user, %{ - "account_id" => other_user.id, - "comment" => "posts are too good for this instance" + account_id: other_user.id, + comment: "posts are too good for this instance" }) assert %{content: "posts are too good for this instance"} = @@ -108,8 +108,8 @@ test "sanitizes report description" do {:ok, activity} = CommonAPI.report(user, %{ - "account_id" => other_user.id, - "comment" => "" + account_id: other_user.id, + comment: "" }) data = Map.put(activity.data, "content", "") @@ -125,8 +125,8 @@ test "doesn't error out when the user doesn't exists" do {:ok, activity} = CommonAPI.report(user, %{ - "account_id" => other_user.id, - "comment" => "" + account_id: other_user.id, + comment: "" }) Pleroma.User.delete(other_user) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 1758662b0..c6ccc02c4 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -485,9 +485,9 @@ test "creates a report" do comment = "foobar" report_data = %{ - "account_id" => target_user.id, - "comment" => comment, - "status_ids" => [activity.id] + account_id: target_user.id, + comment: comment, + status_ids: [activity.id] } note_obj = %{ @@ -517,9 +517,9 @@ test "updates report state" do {:ok, %Activity{id: report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) {:ok, report} = CommonAPI.update_report_state(report_id, "resolved") @@ -538,9 +538,9 @@ test "does not update report state when state is unsupported" do {:ok, %Activity{id: report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) assert CommonAPI.update_report_state(report_id, "test") == {:error, "Unsupported state"} @@ -552,16 +552,16 @@ test "updates state of multiple reports" do {:ok, %Activity{id: first_report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel offended", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] }) {:ok, %Activity{id: second_report_id}} = CommonAPI.report(reporter, %{ - "account_id" => target_user.id, - "comment" => "I feel very offended!", - "status_ids" => [activity.id] + account_id: target_user.id, + comment: "I feel very offended!", + status_ids: [activity.id] }) {:ok, report_ids} = diff --git a/test/web/mastodon_api/controllers/report_controller_test.exs b/test/web/mastodon_api/controllers/report_controller_test.exs index 34ec8119e..21b037237 100644 --- a/test/web/mastodon_api/controllers/report_controller_test.exs +++ b/test/web/mastodon_api/controllers/report_controller_test.exs @@ -22,8 +22,9 @@ defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do test "submit a basic report", %{conn: conn, target_user: target_user} do assert %{"action_taken" => false, "id" => _} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/reports", %{"account_id" => target_user.id}) - |> json_response(200) + |> json_response_and_validate_schema(200) end test "submit a report with statuses and comment", %{ @@ -33,23 +34,25 @@ test "submit a report with statuses and comment", %{ } do assert %{"action_taken" => false, "id" => _} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/reports", %{ "account_id" => target_user.id, "status_ids" => [activity.id], "comment" => "bad status!", "forward" => "false" }) - |> json_response(200) + |> json_response_and_validate_schema(200) end test "account_id is required", %{ conn: conn, activity: activity } do - assert %{"error" => "Valid `account_id` required"} = + assert %{"error" => "Missing field: account_id."} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/reports", %{"status_ids" => [activity.id]}) - |> json_response(400) + |> json_response_and_validate_schema(400) end test "comment must be up to the size specified in the config", %{ @@ -63,17 +66,21 @@ test "comment must be up to the size specified in the config", %{ assert ^error = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/reports", %{"account_id" => target_user.id, "comment" => comment}) - |> json_response(400) + |> json_response_and_validate_schema(400) end test "returns error when account is not exist", %{ conn: conn, activity: activity } do - conn = post(conn, "/api/v1/reports", %{"status_ids" => [activity.id], "account_id" => "foo"}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/reports", %{"status_ids" => [activity.id], "account_id" => "foo"}) - assert json_response(conn, 400) == %{"error" => "Account not found"} + assert json_response_and_validate_schema(conn, 400) == %{"error" => "Account not found"} end test "doesn't fail if an admin has no email", %{conn: conn, target_user: target_user} do @@ -81,7 +88,8 @@ test "doesn't fail if an admin has no email", %{conn: conn, target_user: target_ assert %{"action_taken" => false, "id" => _} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/reports", %{"account_id" => target_user.id}) - |> json_response(200) + |> json_response_and_validate_schema(200) end end -- cgit v1.2.3 From 6aa116eca7d6ef6567dcef03b8c776bd2134bf3f Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 16:26:19 +0200 Subject: Create activity handling: Flip it and reverse it Both objects and create activities will now go through the common pipeline and will be validated. Objects are now created as a side effect of the Create activity, rolling back a transaction if it's not possible to insert the object. --- lib/pleroma/notification.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 7 ++++ lib/pleroma/web/activity_pub/object_validator.ex | 4 +-- .../object_validators/chat_message_validator.ex | 2 +- .../create_chat_message_validator.ex | 27 ++++++++++++-- .../object_validators/types/safe_text.ex | 25 +++++++++++++ lib/pleroma/web/activity_pub/pipeline.ex | 12 ++++--- lib/pleroma/web/activity_pub/side_effects.ex | 41 +++++++++++++--------- .../transmogrifier/chat_message_handling.ex | 28 +++++++++------ lib/pleroma/web/common_api/common_api.ex | 10 +++--- lib/pleroma/web/common_api/utils.ex | 2 +- .../object_validators/types/safe_text_test.exs | 23 ++++++++++++ test/web/activity_pub/side_effects_test.exs | 21 ++++++----- .../transmogrifier/chat_message_test.exs | 3 +- 14 files changed, 155 insertions(+), 52 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex create mode 100644 test/web/activity_pub/object_validators/types/safe_text_test.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 73e19bf97..d96c12440 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -275,7 +275,7 @@ def dismiss(%{id: user_id} = _user, id) do end def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do - object = Object.normalize(activity) + object = Object.normalize(activity, false) if object && object.data["type"] == "Answer" do {:ok, []} diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 69ac06f6b..ecb13d76a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -126,7 +126,14 @@ def increase_poll_votes_if_vote(%{ def increase_poll_votes_if_vote(_create_data), do: :noop + @object_types ["ChatMessage"] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} + def persist(%{"type" => type} = object, meta) when type in @object_types do + with {:ok, object} <- Object.create(object) do + {:ok, object, meta} + end + end + def persist(object, meta) do with local <- Keyword.fetch!(meta, :local), {recipients, _, _} <- get_recipients(object), diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 03db681ec..a4da9242a 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -23,7 +23,7 @@ def validate(%{"type" => "Like"} = object, meta) do object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object |> Map.from_struct()) + object = stringify_keys(object) {:ok, object, meta} end end @@ -41,7 +41,7 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do def validate(%{"type" => "Create"} = object, meta) do with {:ok, object} <- object - |> CreateChatMessageValidator.cast_and_validate() + |> CreateChatMessageValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index f07045d9d..e87c1ac2e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do field(:id, Types.ObjectID, primary_key: true) field(:to, Types.Recipients, default: []) field(:type, :string) - field(:content, :string) + field(:content, Types.SafeText) field(:actor, Types.ObjectID) field(:published, Types.DateTime) field(:emoji, :map, default: %{}) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index ce52d5623..21c7a5ba4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -33,8 +33,31 @@ def cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end - # No validation yet - def cast_and_validate(data) do + def cast_and_validate(data, meta \\ []) do cast_data(data) + |> validate_data(meta) + end + + def validate_data(cng, meta \\ []) do + cng + |> validate_required([:id, :actor, :to, :type, :object]) + |> validate_inclusion(:type, ["Create"]) + |> validate_recipients_match(meta) + end + + def validate_recipients_match(cng, meta) do + object_recipients = meta[:object_data]["to"] || [] + + cng + |> validate_change(:to, fn :to, recipients -> + activity_set = MapSet.new(recipients) + object_set = MapSet.new(object_recipients) + + if MapSet.equal?(activity_set, object_set) do + [] + else + [{:to, "Recipients don't match with object recipients"}] + end + end) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex new file mode 100644 index 000000000..822e8d2c1 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.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.ActivityPub.ObjectValidators.Types.SafeText do + use Ecto.Type + + alias Pleroma.HTML + + def type, do: :string + + def cast(str) when is_binary(str) do + {:ok, HTML.strip_tags(str)} + end + + def cast(_), do: :error + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 7ccee54c9..4213ba751 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -4,20 +4,22 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Activity + alias Pleroma.Object 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()} + @spec common_pipeline(map(), keyword()) :: + {:ok, Activity.t() | Object.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}} <- + {_, {:ok, activity, meta}} <- {:persist_object, ActivityPub.persist(mrfd_object, meta)}, - {_, {:ok, %Activity{} = activity, meta}} <- + {_, {:ok, activity, meta}} <- {:execute_side_effects, SideEffects.handle(activity, meta)}, {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do {:ok, activity, meta} @@ -27,7 +29,9 @@ def common_pipeline(object, meta) do end end - defp maybe_federate(activity, meta) do + defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} + + defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do if local do Federator.publish(activity) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a2b4da8d6..794a46267 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -8,7 +8,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Chat alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -30,14 +32,17 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do result end + # Tasks this handles + # - Actually create object + # - Rollback if we couldn't create it + # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do - object = Object.normalize(activity, false) - - {:ok, _object} = handle_object_creation(object) - - Notification.create_notifications(activity) - - {:ok, activity, meta} + with {:ok, _object, _meta} <- handle_object_creation(meta[:object_data], meta) do + Notification.create_notifications(activity) + {:ok, activity, meta} + else + e -> Repo.rollback(e) + end end # Nothing to do @@ -45,18 +50,20 @@ def handle(object, meta) do {:ok, object, meta} end - def handle_object_creation(%{data: %{"type" => "ChatMessage"}} = object) do - actor = User.get_cached_by_ap_id(object.data["actor"]) - recipient = User.get_cached_by_ap_id(hd(object.data["to"])) + def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + actor = User.get_cached_by_ap_id(object.data["actor"]) + recipient = User.get_cached_by_ap_id(hd(object.data["to"])) - [[actor, recipient], [recipient, actor]] - |> Enum.each(fn [user, other_user] -> - if user.local do - Chat.bump_or_create(user.id, other_user.ap_id) - end - end) + [[actor, recipient], [recipient, actor]] + |> Enum.each(fn [user, other_user] -> + if user.local do + Chat.bump_or_create(user.id, other_user.ap_id) + end + end) - {:ok, object} + {:ok, object, meta} + end end # Nothing to do diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index cfe3b767b..043d847d1 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -3,31 +3,39 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do - alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.Pipeline def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data, + %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options ) do + # Create has to be run inside a transaction because the object is created as a side effect. + # If this does not work, we need to roll back creating the activity. + case Repo.transaction(fn -> do_handle_incoming(data) end) do + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + end + end + + def do_handle_incoming( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data + ) do with {_, {:ok, cast_data_sym}} <- {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, cast_data = ObjectValidator.stringify_keys(cast_data_sym), {_, {:ok, object_cast_data_sym}} <- {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), - # For now, just strip HTML - stripped_content = Pleroma.HTML.strip_tags(object_cast_data["content"]), - object_cast_data = object_cast_data |> Map.put("content", stripped_content), - {_, true} <- {:to_fields_match, cast_data["to"] == object_cast_data["to"]}, - {_, {:ok, validated_object, _meta}} <- - {:validate_object, ObjectValidator.validate(object_cast_data, %{})}, - {_, {:ok, _created_object}} <- {:persist_object, Object.create(validated_object)}, {_, {:ok, activity, _meta}} <- - {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do + {:common_pipeline, + Pipeline.common_pipeline(cast_data, local: false, object_data: object_cast_data)} do {:ok, activity} else e -> diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 5eb221668..c39d1cee6 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -38,13 +38,15 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do recipient.ap_id, content |> Formatter.html_escape("text/plain") )}, - {_, {:ok, chat_message_object}} <- - {:create_object, Object.create(chat_message_data)}, {_, {:ok, create_activity_data, _meta}} <- {:build_create_activity, - Builder.create(user, chat_message_object.data["id"], [recipient.ap_id])}, + Builder.create(user, chat_message_data["id"], [recipient.ap_id])}, {_, {:ok, %Activity{} = activity, _meta}} <- - {:common_pipeline, Pipeline.common_pipeline(create_activity_data, local: true)} do + {:common_pipeline, + Pipeline.common_pipeline(create_activity_data, + local: true, + object_data: chat_message_data + )} do {:ok, activity} else {:content_length, false} -> {:error, :content_too_long} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 945e63e22..4afdf80af 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -425,7 +425,7 @@ def maybe_notify_mentioned_recipients( %Activity{data: %{"to" => _to, "type" => type} = data} = activity ) when type == "Create" do - object = Object.normalize(activity) + object = Object.normalize(activity, false) object_data = cond do diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs new file mode 100644 index 000000000..59ed0a1fe --- /dev/null +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeTextTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText + + test "it lets normal text go through" do + text = "hey how are you" + assert {:ok, text} == SafeText.cast(text) + end + + test "it removes html tags from text" do + text = "hey look xss " + assert {:ok, "hey look xss alert('foo')"} == SafeText.cast(text) + end + + test "errors for non-text" do + assert :error == SafeText.cast(1) + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 2889a577c..19abac6a6 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -47,14 +47,14 @@ test "notifies the recipient" do recipient = insert(:user, local: true) {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") - {:ok, chat_message_object} = Object.create(chat_message_data) {:ok, create_activity_data, _meta} = - Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) end @@ -64,14 +64,17 @@ test "it creates a Chat for the local users and bumps the unread count" do recipient = insert(:user, local: true) {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") - {:ok, chat_message_object} = Object.create(chat_message_data) {:ok, create_activity_data, _meta} = - Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + # An object is created + assert Object.get_by_ap_id(chat_message_data["id"]) # The remote user won't get a chat chat = Chat.get(author.id, recipient.ap_id) @@ -85,14 +88,14 @@ test "it creates a Chat for the local users and bumps the unread count" do recipient = insert(:user, local: true) {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") - {:ok, chat_message_object} = Object.create(chat_message_data) {:ok, create_activity_data, _meta} = - Builder.create(author, chat_message_object.data["id"], [recipient.ap_id]) + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = SideEffects.handle(create_activity) + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) # Both users are local and get the chat chat = Chat.get(author.id, recipient.ap_id) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index a63a31e6e..ceaee614c 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -55,7 +55,8 @@ test "it rejects messages where the `to` field of activity and object don't matc data |> Map.put("to", author.ap_id) - {:error, _} = Transmogrifier.handle_incoming(data) + assert match?({:error, _}, Transmogrifier.handle_incoming(data)) + refute Object.get_by_ap_id(data["object"]["id"]) end test "it inserts it and creates a chat" do -- cgit v1.2.3 From abd09282292f7e902c77b158ae3d86e9bfd5b986 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 16:45:28 +0200 Subject: CreateChatMessageValidator: Validate object existence --- .../object_validators/create_chat_message_validator.ex | 14 +++++++++++++- test/web/activity_pub/object_validator_test.exs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 21c7a5ba4..dfc91bf71 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -5,10 +5,10 @@ # NOTES # - Can probably be a generic create validator # - doesn't embed, will only get the object id -# - object has to be validated first, maybe with some meta info from the surrounding create defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do use Ecto.Schema + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @@ -43,6 +43,18 @@ def validate_data(cng, meta \\ []) do |> validate_required([:id, :actor, :to, :type, :object]) |> validate_inclusion(:type, ["Create"]) |> validate_recipients_match(meta) + |> validate_object_nonexistence() + end + + def validate_object_nonexistence(cng) do + cng + |> validate_change(:object, fn :object, object_id -> + if Object.get_cached_by_ap_id(object_id) do + [{:object, "The object to create already exists"}] + else + [] + end + end) end def validate_recipients_match(cng, meta) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index bc2317e55..baa4b2585 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,6 +1,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -9,6 +10,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory + describe "chat message create activities" do + test "it is invalid if the object already exists" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") + object = Object.normalize(activity, false) + + {:ok, create_data, _} = Builder.create(user, object.data["id"], [recipient.ap_id]) + + {:error, cng} = ObjectValidator.validate(create_data, []) + + assert {:object, {"The object to create already exists", []}} in cng.errors + end + end + describe "chat messages" do setup do clear_config([:instance, :remote_limit]) -- cgit v1.2.3 From 4b3298133b78ad67b61b07e2f267e5587ceef7bf Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 28 Apr 2020 10:13:58 -0500 Subject: Document DELETE /api/v1/notifications/destroy_multiple --- docs/API/differences_in_mastoapi_responses.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 921995510..289f85930 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -120,6 +120,18 @@ Accepts additional parameters: - `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`. - `include_types`: will include the notifications for activities with the given types. The parameter accepts an array of types (`mention`, `follow`, `reblog`, `favourite`, `move`, `pleroma:emoji_reaction`). Usage example: `GET /api/v1/notifications?include_types[]=mention&include_types[]=reblog`. +## DELETE `/api/v1/notifications/destroy_multiple` + +An endpoint to delete multiple statuses by IDs. + +Required parameters: + +- `ids`: array of activity ids + +Usage example: `DELETE /api/v1/notifications/destroy_multiple/?ids[]=1&ids[]=2`. + +Returns on success: 200 OK `{}` + ## POST `/api/v1/statuses` Additional parameters can be added to the JSON body/Form data: -- cgit v1.2.3 From dedffd100c231aa69d7a7f7cd7126b90a84fc1ec Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Apr 2020 17:29:54 +0200 Subject: Pipeline: Unify, refactor, DRY. --- lib/pleroma/web/activity_pub/builder.ex | 4 +-- lib/pleroma/web/activity_pub/object_validator.ex | 18 +++++++++---- .../transmogrifier/chat_message_handling.ex | 31 +++++----------------- lib/pleroma/web/common_api/common_api.ex | 5 ++-- test/web/activity_pub/object_validator_test.exs | 2 +- 5 files changed, 24 insertions(+), 36 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 7576ed278..7f9c071b3 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -11,13 +11,13 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - def create(actor, object_id, recipients) do + def create(actor, object, recipients) do {:ok, %{ "id" => Utils.generate_activity_id(), "actor" => actor.ap_id, "to" => recipients, - "object" => object_id, + "object" => object, "type" => "Create", "published" => DateTime.utc_now() |> DateTime.to_iso8601() }, []} diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index a4da9242a..bada3509d 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -38,16 +38,24 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do end end - def validate(%{"type" => "Create"} = object, meta) do - with {:ok, object} <- - object + def validate(%{"type" => "Create", "object" => object} = create_activity, meta) do + with {:ok, object_data} <- cast_and_apply(object), + meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), + {:ok, create_activity} <- + create_activity |> CreateChatMessageValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} + create_activity = stringify_keys(create_activity) + {:ok, create_activity, meta} end end + def cast_and_apply(%{"type" => "ChatMessage"} = object) do + ChatMessageValidator.cast_and_apply(object) + end + + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} + def stringify_keys(%{__struct__: _} = object) do object |> Map.from_struct() diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index 043d847d1..d9c36e313 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -4,9 +4,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do alias Pleroma.Repo - alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.Pipeline def handle_incoming( @@ -15,30 +12,14 @@ def handle_incoming( ) do # Create has to be run inside a transaction because the object is created as a side effect. # If this does not work, we need to roll back creating the activity. - case Repo.transaction(fn -> do_handle_incoming(data) end) do - {:ok, value} -> - value + case Repo.transaction(fn -> Pipeline.common_pipeline(data, local: false) end) do + {:ok, {:ok, activity, _}} -> + {:ok, activity} - {:error, e} -> - {:error, e} - end - end + {:ok, e} -> + e - def do_handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object_data} = data - ) do - with {_, {:ok, cast_data_sym}} <- - {:casting_data, data |> CreateChatMessageValidator.cast_and_apply()}, - cast_data = ObjectValidator.stringify_keys(cast_data_sym), - {_, {:ok, object_cast_data_sym}} <- - {:casting_object_data, object_data |> ChatMessageValidator.cast_and_apply()}, - object_cast_data = ObjectValidator.stringify_keys(object_cast_data_sym), - {_, {:ok, activity, _meta}} <- - {:common_pipeline, - Pipeline.common_pipeline(cast_data, local: false, object_data: object_cast_data)} do - {:ok, activity} - else - e -> + {:error, e} -> {:error, e} end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c39d1cee6..ef86ec1e4 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -40,12 +40,11 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do )}, {_, {:ok, create_activity_data, _meta}} <- {:build_create_activity, - Builder.create(user, chat_message_data["id"], [recipient.ap_id])}, + Builder.create(user, chat_message_data, [recipient.ap_id])}, {_, {:ok, %Activity{} = activity, _meta}} <- {:common_pipeline, Pipeline.common_pipeline(create_activity_data, - local: true, - object_data: chat_message_data + local: true )} do {:ok, activity} else diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index baa4b2585..41f67964a 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -17,7 +17,7 @@ test "it is invalid if the object already exists" do {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") object = Object.normalize(activity, false) - {:ok, create_data, _} = Builder.create(user, object.data["id"], [recipient.ap_id]) + {:ok, create_data, _} = Builder.create(user, object.data, [recipient.ap_id]) {:error, cng} = ObjectValidator.validate(create_data, []) -- cgit v1.2.3 From 4c0e53367acd74de04de070a5e33380f5e457163 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 28 Apr 2020 20:04:25 +0300 Subject: [#2349] Post-merge fix. --- lib/pleroma/web/api_spec/operations/account_operation.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 2efe6e901..64e2e43c4 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -294,13 +294,13 @@ def unblock_operation do } end - def follows_operation do + def follow_by_uri_operation do %Operation{ tags: ["accounts"], - summary: "Follows", + summary: "Follow by URI", operationId: "AccountController.follows", security: [%{"oAuth" => ["follow", "write:follows"]}], - requestBody: request_body("Parameters", follows_request(), required: true), + requestBody: request_body("Parameters", follow_by_uri_request(), required: true), responses: %{ 200 => Operation.response("Account", "application/json", AccountRelationship), 400 => Operation.response("Error", "application/json", ApiError), @@ -615,7 +615,7 @@ defp array_of_relationships do } end - defp follows_request do + defp follow_by_uri_request do %Schema{ title: "AccountFollowsRequest", description: "POST body for muting an account", -- cgit v1.2.3 From 5238ae3dd3bfba9ff84d5f47e2419227fc7c5d9a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 28 Apr 2020 21:27:54 +0400 Subject: Add OpenAPI spec for NotificationController --- lib/pleroma/web/api_spec/helpers.ex | 8 + .../web/api_spec/operations/account_operation.ex | 4 +- .../api_spec/operations/domain_block_operation.ex | 8 +- .../api_spec/operations/notification_operation.ex | 202 +++++++++++++++++++++ .../controllers/notification_controller.ex | 24 ++- lib/pleroma/web/router.ex | 2 +- .../controllers/notification_controller_test.exs | 181 +++++++++--------- 7 files changed, 325 insertions(+), 104 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/notification_operation.ex diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index ce40fb9e8..df0804486 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -46,4 +46,12 @@ def pagination_params do ) ] end + + def empty_object_response do + Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}}) + end + + def empty_array_response do + Operation.response("Empty array", "application/json", %Schema{type: :array, example: []}) + end end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 2efe6e901..6fb6e627b 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -343,7 +343,7 @@ def endorsements_operation do description: "Not implemented", security: [%{"oAuth" => ["read:accounts"]}], responses: %{ - 200 => Operation.response("Empry array", "application/json", %Schema{type: :array}) + 200 => empty_array_response() } } end @@ -355,7 +355,7 @@ def identity_proofs_operation do operationId: "AccountController.identity_proofs", description: "Not implemented", responses: %{ - 200 => Operation.response("Empry array", "application/json", %Schema{type: :array}) + 200 => empty_array_response() } } end diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex index 3b7f51ceb..049bcf931 100644 --- a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ApiSpec.DomainBlockOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.Helpers def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -46,9 +46,7 @@ def create_operation do operationId: "DomainBlockController.create", requestBody: domain_block_request(), security: [%{"oAuth" => ["follow", "write:blocks"]}], - responses: %{ - 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) - } + responses: %{200 => empty_object_response()} } end @@ -67,7 +65,7 @@ def delete_operation do end defp domain_block_request do - Helpers.request_body( + request_body( "Parameters", %Schema{ type: :object, diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex new file mode 100644 index 000000000..c6514f3f2 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -0,0 +1,202 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.NotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Notifications"], + summary: "Get all notifications", + description: + "Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.", + operationId: "NotificationController.index", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: + [ + Operation.parameter( + :exclude_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Array of types to exclude" + ), + Operation.parameter( + :account_id, + :query, + %Schema{type: :string}, + "Return only notifications received from this account" + ), + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the notifications for activities with the given visibilities" + ), + Operation.parameter( + :include_types, + :query, + %Schema{type: :array, items: notification_type()}, + "Include the notifications for activities with the given types" + ), + Operation.parameter( + :with_muted, + :query, + BooleanLike, + "Include the notifications from muted users" + ) + ] ++ pagination_params(), + responses: %{ + 200 => + Operation.response("Array of notifications", "application/json", %Schema{ + type: :array, + items: notification() + }), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Notifications"], + summary: "Get a single notification", + description: "View information about a notification with a given ID.", + operationId: "NotificationController.show", + security: [%{"oAuth" => ["read:notifications"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Notification", "application/json", notification()) + } + } + end + + def clear_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss all notifications", + description: "Clear all notifications from the server.", + operationId: "NotificationController.clear", + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss", + parameters: [id_param()], + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def dismiss_via_body_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss a single notification", + deprecated: true, + description: "Clear a single notification from the server.", + operationId: "NotificationController.dismiss_via_body", + requestBody: + request_body( + "Parameters", + %Schema{type: :object, properties: %{id: %Schema{type: :string}}}, + required: true + ), + security: [%{"oAuth" => ["write:notifications"]}], + responses: %{200 => empty_object_response()} + } + end + + def destroy_multiple_operation do + %Operation{ + tags: ["Notifications"], + summary: "Dismiss multiple notifications", + operationId: "NotificationController.destroy_multiple", + security: [%{"oAuth" => ["write:notifications"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Array of notification IDs to dismiss", + required: true + ) + ], + responses: %{200 => empty_object_response()} + } + end + + defp notification do + %Schema{ + title: "Notification", + description: "Response schema for a notification", + type: :object, + properties: %{ + id: %Schema{type: :string}, + type: notification_type(), + created_at: %Schema{type: :string, format: :"date-time"}, + account: %Schema{ + allOf: [Account], + description: "The account that performed the action that generated the notification." + }, + status: %Schema{ + allOf: [Status], + description: + "Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.", + nullable: true + } + }, + example: %{ + "id" => "34975861", + "type" => "mention", + "created_at" => "2019-11-23T07:49:02.064Z", + "account" => Account.schema().example, + "status" => Status.schema().example + } + } + end + + defp notification_type do + %Schema{ + type: :string, + enum: ["follow", "favourite", "reblog", "mention", "poll", "pleroma:emoji_reaction", "move"], + description: """ + The type of event that resulted in the notification. + + - `follow` - Someone followed you + - `mention` - Someone mentioned you in their status + - `reblog` - Someone boosted one of your statuses + - `favourite` - Someone favourited one of your statuses + - `poll` - A poll you have voted in or created has ended + - `move` - Someone moved their account + - `pleroma:emoji_reaction` - Someone reacted with emoji to your status + """ + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Notification ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 7fb536b09..dcb421756 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -13,6 +13,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do @oauth_read_actions [:show, :index] + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug( OAuthScopesPlug, %{scopes: ["read:notifications"]} when action in @oauth_read_actions @@ -22,14 +24,16 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation + # GET /api/v1/notifications - def index(conn, %{"account_id" => account_id} = params) do + def index(conn, %{account_id: account_id} = params) do case Pleroma.User.get_cached_by_id(account_id) do %{ap_id: account_ap_id} -> params = params - |> Map.delete("account_id") - |> Map.put("account_ap_id", account_ap_id) + |> Map.delete(:account_id) + |> Map.put(:account_ap_id, account_ap_id) index(conn, params) @@ -41,6 +45,7 @@ def index(conn, %{"account_id" => account_id} = params) do end def index(%{assigns: %{user: user}} = conn, params) do + params = Map.new(params, fn {k, v} -> {to_string(k), v} end) notifications = MastodonAPI.get_notifications(user, params) conn @@ -53,7 +58,7 @@ def index(%{assigns: %{user: user}} = conn, params) do end # GET /api/v1/notifications/:id - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def show(%{assigns: %{user: user}} = conn, %{id: id}) do with {:ok, notification} <- Notification.get(user, id) do render(conn, "show.json", notification: notification, for: user) else @@ -71,8 +76,8 @@ def clear(%{assigns: %{user: user}} = conn, _params) do end # POST /api/v1/notifications/:id/dismiss - # POST /api/v1/notifications/dismiss (deprecated) - def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + + def dismiss(%{assigns: %{user: user}} = conn, %{id: id} = _params) do with {:ok, _notif} <- Notification.dismiss(user, id) do json(conn, %{}) else @@ -83,8 +88,13 @@ def dismiss(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do end end + # POST /api/v1/notifications/dismiss (deprecated) + def dismiss_via_body(%{body_params: params} = conn, _) do + dismiss(conn, params) + end + # DELETE /api/v1/notifications/destroy_multiple - def destroy_multiple(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do + def destroy_multiple(%{assigns: %{user: user}} = conn, %{ids: ids} = _params) do Notification.destroy_multiple(user, ids) json(conn, %{}) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 153802a43..fe984b06c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -344,7 +344,7 @@ defmodule Pleroma.Web.Router do post("/notifications/clear", NotificationController, :clear) delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple) # Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead - post("/notifications/dismiss", NotificationController, :dismiss) + post("/notifications/dismiss", NotificationController, :dismiss_via_body) 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 8c815b415..db380f76a 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -25,7 +25,7 @@ test "does NOT render account/pleroma/relationship if this is disabled by defaul conn |> assign(:user, user) |> get("/api/v1/notifications") - |> json_response(200) + |> json_response_and_validate_schema(200) assert Enum.all?(response, fn n -> get_in(n, ["account", "pleroma", "relationship"]) == %{} @@ -50,7 +50,9 @@ test "list of notifications" do user.ap_id }\" rel=\"ugc\">@#{user.nickname}
    " - assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) + assert [%{"status" => %{"content" => response}} | _rest] = + json_response_and_validate_schema(conn, 200) + assert response == expected_response end @@ -69,7 +71,7 @@ test "getting a single notification" do user.ap_id }\" rel=\"ugc\">@#{user.nickname}" - assert %{"status" => %{"content" => response}} = json_response(conn, 200) + assert %{"status" => %{"content" => response}} = json_response_and_validate_schema(conn, 200) assert response == expected_response end @@ -84,9 +86,10 @@ test "dismissing a single notification (deprecated endpoint)" do conn = conn |> assign(:user, user) - |> post("/api/v1/notifications/dismiss", %{"id" => notification.id}) + |> put_req_header("content-type", "application/json") + |> post("/api/v1/notifications/dismiss", %{"id" => to_string(notification.id)}) - assert %{} = json_response(conn, 200) + assert %{} = json_response_and_validate_schema(conn, 200) end test "dismissing a single notification" do @@ -102,7 +105,7 @@ test "dismissing a single notification" do |> assign(:user, user) |> post("/api/v1/notifications/#{notification.id}/dismiss") - assert %{} = json_response(conn, 200) + assert %{} = json_response_and_validate_schema(conn, 200) end test "clearing all notifications" do @@ -115,11 +118,11 @@ test "clearing all notifications" do ret_conn = post(conn, "/api/v1/notifications/clear") - assert %{} = json_response(ret_conn, 200) + assert %{} = json_response_and_validate_schema(ret_conn, 200) ret_conn = get(conn, "/api/v1/notifications") - assert all = json_response(ret_conn, 200) + assert all = json_response_and_validate_schema(ret_conn, 200) assert all == [] end @@ -143,7 +146,7 @@ test "paginates notifications using min_id, since_id, max_id, and limit" do result = conn |> get("/api/v1/notifications?limit=2&min_id=#{notification1_id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result @@ -151,7 +154,7 @@ test "paginates notifications using min_id, since_id, max_id, and limit" do result = conn |> get("/api/v1/notifications?limit=2&since_id=#{notification1_id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result @@ -159,7 +162,7 @@ test "paginates notifications using min_id, since_id, max_id, and limit" do result = conn |> get("/api/v1/notifications?limit=2&max_id=#{notification4_id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result end @@ -181,36 +184,28 @@ test "filters notifications for mentions" do {:ok, private_activity} = CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "private"}) - conn_res = - get(conn, "/api/v1/notifications", %{ - exclude_visibilities: ["public", "unlisted", "private"] - }) + query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "private"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) + assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200) assert id == direct_activity.id - conn_res = - get(conn, "/api/v1/notifications", %{ - exclude_visibilities: ["public", "unlisted", "direct"] - }) + query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "direct"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) + assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200) assert id == private_activity.id - conn_res = - get(conn, "/api/v1/notifications", %{ - exclude_visibilities: ["public", "private", "direct"] - }) + query = params_to_query(%{exclude_visibilities: ["public", "private", "direct"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) + assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200) assert id == unlisted_activity.id - conn_res = - get(conn, "/api/v1/notifications", %{ - exclude_visibilities: ["unlisted", "private", "direct"] - }) + query = params_to_query(%{exclude_visibilities: ["unlisted", "private", "direct"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) + assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200) assert id == public_activity.id end @@ -237,8 +232,8 @@ test "filters notifications for Like activities" do activity_ids = conn - |> get("/api/v1/notifications", %{exclude_visibilities: ["direct"]}) - |> json_response(200) + |> get("/api/v1/notifications?exclude_visibilities[]=direct") + |> json_response_and_validate_schema(200) |> Enum.map(& &1["status"]["id"]) assert public_activity.id in activity_ids @@ -248,8 +243,8 @@ test "filters notifications for Like activities" do activity_ids = conn - |> get("/api/v1/notifications", %{exclude_visibilities: ["unlisted"]}) - |> json_response(200) + |> get("/api/v1/notifications?exclude_visibilities[]=unlisted") + |> json_response_and_validate_schema(200) |> Enum.map(& &1["status"]["id"]) assert public_activity.id in activity_ids @@ -259,8 +254,8 @@ test "filters notifications for Like activities" do activity_ids = conn - |> get("/api/v1/notifications", %{exclude_visibilities: ["private"]}) - |> json_response(200) + |> get("/api/v1/notifications?exclude_visibilities[]=private") + |> json_response_and_validate_schema(200) |> Enum.map(& &1["status"]["id"]) assert public_activity.id in activity_ids @@ -270,8 +265,8 @@ test "filters notifications for Like activities" do activity_ids = conn - |> get("/api/v1/notifications", %{exclude_visibilities: ["public"]}) - |> json_response(200) + |> get("/api/v1/notifications?exclude_visibilities[]=public") + |> json_response_and_validate_schema(200) |> Enum.map(& &1["status"]["id"]) refute public_activity.id in activity_ids @@ -295,8 +290,8 @@ test "filters notifications for Announce activities" do activity_ids = conn - |> get("/api/v1/notifications", %{exclude_visibilities: ["unlisted"]}) - |> json_response(200) + |> get("/api/v1/notifications?exclude_visibilities[]=unlisted") + |> json_response_and_validate_schema(200) |> Enum.map(& &1["status"]["id"]) assert public_activity.id in activity_ids @@ -319,25 +314,27 @@ test "filters notifications using exclude_types" do reblog_notification_id = get_notification_id_by_activity(reblog_activity) follow_notification_id = get_notification_id_by_activity(follow_activity) - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["mention", "favourite", "reblog"]}) + query = params_to_query(%{exclude_types: ["mention", "favourite", "reblog"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"id" => ^follow_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200) - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["favourite", "reblog", "follow"]}) + query = params_to_query(%{exclude_types: ["favourite", "reblog", "follow"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"id" => ^mention_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^mention_notification_id}] = + json_response_and_validate_schema(conn_res, 200) - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["reblog", "follow", "mention"]}) + query = params_to_query(%{exclude_types: ["reblog", "follow", "mention"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"id" => ^favorite_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^favorite_notification_id}] = + json_response_and_validate_schema(conn_res, 200) - conn_res = - get(conn, "/api/v1/notifications", %{exclude_types: ["follow", "mention", "favourite"]}) + query = params_to_query(%{exclude_types: ["follow", "mention", "favourite"]}) + conn_res = get(conn, "/api/v1/notifications?" <> query) - assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200) end test "filters notifications using include_types" do @@ -355,32 +352,34 @@ test "filters notifications using include_types" do reblog_notification_id = get_notification_id_by_activity(reblog_activity) follow_notification_id = get_notification_id_by_activity(follow_activity) - conn_res = get(conn, "/api/v1/notifications", %{include_types: ["follow"]}) + conn_res = get(conn, "/api/v1/notifications?include_types[]=follow") - assert [%{"id" => ^follow_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200) - conn_res = get(conn, "/api/v1/notifications", %{include_types: ["mention"]}) + conn_res = get(conn, "/api/v1/notifications?include_types[]=mention") - assert [%{"id" => ^mention_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^mention_notification_id}] = + json_response_and_validate_schema(conn_res, 200) - conn_res = get(conn, "/api/v1/notifications", %{include_types: ["favourite"]}) + conn_res = get(conn, "/api/v1/notifications?include_types[]=favourite") - assert [%{"id" => ^favorite_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^favorite_notification_id}] = + json_response_and_validate_schema(conn_res, 200) - conn_res = get(conn, "/api/v1/notifications", %{include_types: ["reblog"]}) + conn_res = get(conn, "/api/v1/notifications?include_types[]=reblog") - assert [%{"id" => ^reblog_notification_id}] = json_response(conn_res, 200) + assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200) - result = conn |> get("/api/v1/notifications") |> json_response(200) + result = conn |> get("/api/v1/notifications") |> json_response_and_validate_schema(200) assert length(result) == 4 + query = params_to_query(%{include_types: ["follow", "mention", "favourite", "reblog"]}) + result = conn - |> get("/api/v1/notifications", %{ - include_types: ["follow", "mention", "favourite", "reblog"] - }) - |> json_response(200) + |> get("/api/v1/notifications?" <> query) + |> json_response_and_validate_schema(200) assert length(result) == 4 end @@ -402,7 +401,7 @@ test "destroy multiple" do result = conn |> get("/api/v1/notifications") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^notification2_id}, %{"id" => ^notification1_id}] = result @@ -414,22 +413,19 @@ test "destroy multiple" do result = conn2 |> get("/api/v1/notifications") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result - conn_destroy = - conn - |> delete("/api/v1/notifications/destroy_multiple", %{ - "ids" => [notification1_id, notification2_id] - }) + query = params_to_query(%{ids: [notification1_id, notification2_id]}) + conn_destroy = delete(conn, "/api/v1/notifications/destroy_multiple?" <> query) - assert json_response(conn_destroy, 200) == %{} + assert json_response_and_validate_schema(conn_destroy, 200) == %{} result = conn2 |> get("/api/v1/notifications") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result end @@ -443,13 +439,13 @@ test "doesn't see notifications after muting user with notifications" do ret_conn = get(conn, "/api/v1/notifications") - assert length(json_response(ret_conn, 200)) == 1 + assert length(json_response_and_validate_schema(ret_conn, 200)) == 1 {:ok, _user_relationships} = User.mute(user, user2) conn = get(conn, "/api/v1/notifications") - assert json_response(conn, 200) == [] + assert json_response_and_validate_schema(conn, 200) == [] end test "see notifications after muting user without notifications" do @@ -461,13 +457,13 @@ test "see notifications after muting user without notifications" do ret_conn = get(conn, "/api/v1/notifications") - assert length(json_response(ret_conn, 200)) == 1 + assert length(json_response_and_validate_schema(ret_conn, 200)) == 1 {:ok, _user_relationships} = User.mute(user, user2, false) conn = get(conn, "/api/v1/notifications") - assert length(json_response(conn, 200)) == 1 + assert length(json_response_and_validate_schema(conn, 200)) == 1 end test "see notifications after muting user with notifications and with_muted parameter" do @@ -479,13 +475,13 @@ test "see notifications after muting user with notifications and with_muted para ret_conn = get(conn, "/api/v1/notifications") - assert length(json_response(ret_conn, 200)) == 1 + assert length(json_response_and_validate_schema(ret_conn, 200)) == 1 {:ok, _user_relationships} = User.mute(user, user2) - conn = get(conn, "/api/v1/notifications", %{"with_muted" => "true"}) + conn = get(conn, "/api/v1/notifications?with_muted=true") - assert length(json_response(conn, 200)) == 1 + assert length(json_response_and_validate_schema(conn, 200)) == 1 end @tag capture_log: true @@ -512,7 +508,7 @@ test "see move notifications" do conn = get(conn, "/api/v1/notifications") - assert length(json_response(conn, 200)) == 1 + assert length(json_response_and_validate_schema(conn, 200)) == 1 end describe "link headers" do @@ -538,10 +534,10 @@ test "preserves parameters in link headers" do conn = conn |> assign(:user, user) - |> get("/api/v1/notifications", %{media_only: true}) + |> get("/api/v1/notifications?limit=5") assert [link_header] = get_resp_header(conn, "link") - assert link_header =~ ~r/media_only=true/ + assert link_header =~ ~r/limit=5/ assert link_header =~ ~r/min_id=#{notification2.id}/ assert link_header =~ ~r/max_id=#{notification1.id}/ end @@ -560,14 +556,14 @@ test "account_id" do assert [%{"account" => %{"id" => ^account_id}}] = conn |> assign(:user, user) - |> get("/api/v1/notifications", %{account_id: account_id}) - |> json_response(200) + |> get("/api/v1/notifications?account_id=#{account_id}") + |> json_response_and_validate_schema(200) assert %{"error" => "Account is not found"} = conn |> assign(:user, user) - |> get("/api/v1/notifications", %{account_id: "cofe"}) - |> json_response(404) + |> get("/api/v1/notifications?account_id=cofe") + |> json_response_and_validate_schema(404) end end @@ -577,4 +573,11 @@ defp get_notification_id_by_activity(%{id: id}) do |> Map.get(:id) |> to_string() end + + defp params_to_query(%{} = params) do + Enum.map_join(params, "&", fn + {k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}") + {k, v} -> k <> "=" <> v + end) + end end -- cgit v1.2.3 From 7bd187bc5e2e589f3ba639bbc0ab2feea905a9b0 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Apr 2020 08:13:10 +0300 Subject: added test --- test/web/activity_pub/transmogrifier_test.exs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 0404aae6a..0800305ce 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1221,6 +1221,17 @@ test "it rejects activities without a valid ID" do :error = Transmogrifier.handle_incoming(data) end + test "skip converting the content when it is nil" do + object_id = "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe" + + {:ok, object} = Fetcher.fetch_and_contain_remote_object_from_id(object_id) + + result = + Pleroma.Web.ActivityPub.Transmogrifier.fix_object(Map.merge(object, %{"content" => nil})) + + assert result["content"] == nil + end + test "it converts content of object to html" do object_id = "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe" -- cgit v1.2.3 From 67659afe487def6bd4e0ccfbf8d015fda2a8ac61 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 13:34:43 +0200 Subject: ChatOperation: Refactor. --- .../web/api_spec/operations/chat_operation.ex | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 59539e890..88b9db048 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -5,11 +5,12 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + import Pleroma.Web.ApiSpec.Helpers + @spec open_api_operation(atom) :: Operation.t() def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -52,11 +53,7 @@ def index_operation do tags: ["chat"], summary: "Get a list of chats that you participated in", operationId: "ChatController.index", - parameters: [ - Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), - Operation.parameter(:min_id, :query, :string, "Return only chats after this id"), - Operation.parameter(:max_id, :query, :string, "Return only chats before this id") - ], + parameters: pagination_params(), responses: %{ 200 => Operation.response("The chats of the user", "application/json", chats_response()) }, @@ -73,12 +70,9 @@ def messages_operation do tags: ["chat"], summary: "Get the most recent messages of the chat", operationId: "ChatController.messages", - parameters: [ - Operation.parameter(:id, :path, :string, "The ID of the Chat"), - Operation.parameter(:limit, :query, :integer, "How many results to return", example: 20), - Operation.parameter(:min_id, :query, :string, "Return only messages after this id"), - Operation.parameter(:max_id, :query, :string, "Return only messages before this id") - ], + parameters: + [Operation.parameter(:id, :path, :string, "The ID of the Chat")] ++ + pagination_params(), responses: %{ 200 => Operation.response( @@ -103,7 +97,7 @@ def post_chat_message_operation do parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], - requestBody: Helpers.request_body("Parameters", ChatMessageCreateRequest, required: true), + requestBody: request_body("Parameters", ChatMessageCreateRequest, required: true), responses: %{ 200 => Operation.response( -- cgit v1.2.3 From e055b8d2036e18a95d84f6f1db08fc465fe9975d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 13:45:50 +0200 Subject: Pipeline: Always run common_pipeline in a transaction for now. --- lib/pleroma/web/activity_pub/pipeline.ex | 11 +++++ .../transmogrifier/chat_message_handling.ex | 12 ++--- lib/pleroma/web/common_api/common_api.ex | 52 +++++++++------------- .../transmogrifier/chat_message_test.exs | 1 + 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 4213ba751..d5abb7567 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator @@ -14,6 +15,16 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + end + end + + def do_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)}, diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex index d9c36e313..b1cc93481 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex @@ -3,24 +3,18 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do - alias Pleroma.Repo alias Pleroma.Web.ActivityPub.Pipeline def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options ) do - # Create has to be run inside a transaction because the object is created as a side effect. - # If this does not work, we need to roll back creating the activity. - case Repo.transaction(fn -> Pipeline.common_pipeline(data, local: false) end) do - {:ok, {:ok, activity, _}} -> + case Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _} -> {:ok, activity} - {:ok, e} -> + e -> e - - {:error, e} -> - {:error, e} end end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index ef86ec1e4..359045f48 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.FollowingRelationship alias Pleroma.Formatter alias Pleroma.Object - alias Pleroma.Repo alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.UserRelationship @@ -26,36 +25,27 @@ defmodule Pleroma.Web.CommonAPI do require Logger def post_chat_message(%User{} = user, %User{} = recipient, content) do - transaction = - Repo.transaction(fn -> - with {_, true} <- - {:content_length, - String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, - {_, {:ok, chat_message_data, _meta}} <- - {:build_object, - Builder.chat_message( - user, - recipient.ap_id, - content |> Formatter.html_escape("text/plain") - )}, - {_, {:ok, create_activity_data, _meta}} <- - {:build_create_activity, - Builder.create(user, chat_message_data, [recipient.ap_id])}, - {_, {:ok, %Activity{} = activity, _meta}} <- - {:common_pipeline, - Pipeline.common_pipeline(create_activity_data, - local: true - )} do - {:ok, activity} - else - {:content_length, false} -> {:error, :content_too_long} - e -> e - end - end) - - case transaction do - {:ok, value} -> value - error -> error + with {_, true} <- + {:content_length, + String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, + {_, {:ok, chat_message_data, _meta}} <- + {:build_object, + Builder.chat_message( + user, + recipient.ap_id, + content |> Formatter.html_escape("text/plain") + )}, + {_, {:ok, create_activity_data, _meta}} <- + {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])}, + {_, {:ok, %Activity{} = activity, _meta}} <- + {:common_pipeline, + Pipeline.common_pipeline(create_activity_data, + local: true + )} do + {:ok, activity} + else + {:content_length, false} -> {:error, :content_too_long} + e -> e end end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index ceaee614c..c5600e84e 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -68,6 +68,7 @@ test "it inserts it and creates a chat" do recipient = insert(:user, ap_id: List.first(data["to"]), local: true) {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) + assert activity.local == false assert activity.actor == author.ap_id assert activity.recipients == [recipient.ap_id, author.ap_id] -- cgit v1.2.3 From 53e3063bd041409da83483e8f5c47030bf346123 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 13:52:23 +0200 Subject: Transmogrifier: Remove ChatMessageHandling module. --- lib/pleroma/web/activity_pub/side_effects.ex | 13 ++++--------- lib/pleroma/web/activity_pub/transmogrifier.ex | 14 ++++++++++---- .../transmogrifier/chat_message_handling.ex | 20 -------------------- 3 files changed, 14 insertions(+), 33 deletions(-) delete mode 100644 lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 794a46267..e394c75d7 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -19,17 +19,12 @@ def handle(object, meta \\ []) # - Add like to object # - Set up notification def handle(%{data: %{"type" => "Like"}} = object, meta) do - {:ok, result} = - Pleroma.Repo.transaction(fn -> - liked_object = Object.get_by_ap_id(object.data["object"]) - Utils.add_like_to_object(object, liked_object) + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) - Notification.create_notifications(object) + Notification.create_notifications(object) - {:ok, object, meta} - end) - - result + {:ok, object, meta} end # Tasks this handles diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 66975cf7d..3c2fe73a3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -16,7 +16,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Pipeline - alias Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator @@ -646,9 +645,16 @@ def handle_incoming( def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, - options - ), - do: ChatMessageHandling.handle_incoming(data, options) + _options + ) do + case Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _} -> + {:ok, activity} + + e -> + e + end + end def handle_incoming(%{"type" => "Like"} = data, _options) do with {_, {:ok, cast_data_sym}} <- diff --git a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex b/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex deleted file mode 100644 index b1cc93481..000000000 --- a/lib/pleroma/web/activity_pub/transmogrifier/chat_message_handling.ex +++ /dev/null @@ -1,20 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageHandling do - alias Pleroma.Web.ActivityPub.Pipeline - - def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, - _options - ) do - case Pipeline.common_pipeline(data, local: false) do - {:ok, activity, _} -> - {:ok, activity} - - e -> - e - end - end -end -- cgit v1.2.3 From a88734a0a22810bcc47c17fc9120ef7881d670d8 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 14:25:33 +0200 Subject: Transmogrifier: Fetch missing actors for chatmessages. --- lib/pleroma/web/activity_pub/object_validator.ex | 9 ++++- .../create_chat_message_validator.ex | 2 ++ lib/pleroma/web/activity_pub/transmogrifier.ex | 8 ++--- .../transmogrifier/chat_message_test.exs | 40 +++++++++++++++++++--- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index bada3509d..50904ed59 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -67,8 +68,14 @@ def stringify_keys(object) do |> Map.new(fn {key, val} -> {to_string(key), val} end) end + def fetch_actor(object) do + with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do + User.get_or_fetch_by_ap_id(actor) + end + end + def fetch_actor_and_object(object) do - User.get_or_fetch_by_ap_id(object["actor"]) + fetch_actor(object) Object.normalize(object["object"]) :ok end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index dfc91bf71..88e903182 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @primary_key false @@ -42,6 +43,7 @@ def validate_data(cng, meta \\ []) do cng |> validate_required([:id, :actor, :to, :type, :object]) |> validate_inclusion(:type, ["Create"]) + |> validate_actor_presence() |> validate_recipients_match(meta) |> validate_object_nonexistence() end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 3c2fe73a3..6dbd3f588 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -647,10 +647,10 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options ) do - case Pipeline.common_pipeline(data, local: false) do - {:ok, activity, _} -> - {:ok, activity} - + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + else e -> e end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index c5600e84e..85644d787 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -26,8 +26,15 @@ test "it rejects messages that don't contain content" do data |> Map.put("object", object) - _author = insert(:user, ap_id: data["actor"], local: false) - _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + _author = + insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + + _recipient = + insert(:user, + ap_id: List.first(data["to"]), + local: true, + last_refreshed_at: DateTime.utc_now() + ) {:error, _} = Transmogrifier.handle_incoming(data) end @@ -37,8 +44,15 @@ test "it rejects messages that don't concern local users" do File.read!("test/fixtures/create-chat-message.json") |> Poison.decode!() - _author = insert(:user, ap_id: data["actor"], local: false) - _recipient = insert(:user, ap_id: List.first(data["to"]), local: false) + _author = + insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + + _recipient = + insert(:user, + ap_id: List.first(data["to"]), + local: false, + last_refreshed_at: DateTime.utc_now() + ) {:error, _} = Transmogrifier.handle_incoming(data) end @@ -59,12 +73,28 @@ test "it rejects messages where the `to` field of activity and object don't matc refute Object.get_by_ap_id(data["object"]["id"]) end + test "it fetches the actor if they aren't in our system" do + Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) + + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + |> Map.put("actor", "http://mastodon.example.org/users/admin") + |> put_in(["object", "actor"], "http://mastodon.example.org/users/admin") + + _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + + {:ok, %Activity{} = _activity} = Transmogrifier.handle_incoming(data) + end + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") |> Poison.decode!() - author = insert(:user, ap_id: data["actor"], local: false) + author = + insert(:user, ap_id: data["actor"], local: false, last_refreshed_at: DateTime.utc_now()) + recipient = insert(:user, ap_id: List.first(data["to"]), local: true) {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) -- cgit v1.2.3 From 20587aa931262a5479c98f13450311a135c5d356 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 14:53:53 +0200 Subject: Chat message creation: Check actor. --- .../object_validators/create_chat_message_validator.ex | 14 ++++++++++++++ test/web/activity_pub/object_validator_test.exs | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 88e903182..fc582400b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -45,6 +45,7 @@ def validate_data(cng, meta \\ []) do |> validate_inclusion(:type, ["Create"]) |> validate_actor_presence() |> validate_recipients_match(meta) + |> validate_actors_match(meta) |> validate_object_nonexistence() end @@ -59,6 +60,19 @@ def validate_object_nonexistence(cng) do end) end + def validate_actors_match(cng, meta) do + object_actor = meta[:object_data]["actor"] + + cng + |> validate_change(:actor, fn :actor, actor -> + if actor == object_actor do + [] + else + [{:actor, "Actor doesn't match with object actor"}] + end + end) + end + def validate_recipients_match(cng, meta) do object_recipients = meta[:object_data]["to"] || [] diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 41f67964a..475b7bb21 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -23,6 +23,19 @@ test "it is invalid if the object already exists" do assert {:object, {"The object to create already exists", []}} in cng.errors end + + test "it is invalid if the object data has a different `to` or `actor` field" do + user = insert(:user) + recipient = insert(:user) + {:ok, object_data, _} = Builder.chat_message(recipient, user.ap_id, "Hey") + + {:ok, create_data, _} = Builder.create(user, object_data, [recipient.ap_id]) + + {:error, cng} = ObjectValidator.validate(create_data, []) + + assert {:to, {"Recipients don't match with object recipients", []}} in cng.errors + assert {:actor, {"Actor doesn't match with object actor", []}} in cng.errors + end end describe "chat messages" do -- cgit v1.2.3 From b8056e69e0a2505fc466dd5742b0986b7c1895ae Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:08:08 +0200 Subject: Object Validator Types: Add Recipients. --- .../object_validators/types/recipients.ex | 34 ++++++++++++++++++++++ .../object_validators/types/recipients_test.exs | 27 +++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/types/recipients.ex create mode 100644 test/web/activity_pub/object_validators/types/recipients_test.exs diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex new file mode 100644 index 000000000..48fe61e1a --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -0,0 +1,34 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do + use Ecto.Type + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + + def type, do: {:array, ObjectID} + + def cast(object) when is_binary(object) do + cast([object]) + end + + def cast(data) when is_list(data) do + data + |> Enum.reduce({:ok, []}, fn element, acc -> + case {acc, ObjectID.cast(element)} do + {:error, _} -> :error + {_, :error} -> :error + {{:ok, list}, {:ok, id}} -> {:ok, [id | list]} + end + end) + end + + def cast(_) do + :error + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs new file mode 100644 index 000000000..f278f039b --- /dev/null +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -0,0 +1,27 @@ +defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do + alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients + use Pleroma.DataCase + + test "it asserts that all elements of the list are object ids" do + list = ["https://lain.com/users/lain", "invalid"] + + assert :error == Recipients.cast(list) + end + + test "it works with a list" do + list = ["https://lain.com/users/lain"] + assert {:ok, list} == Recipients.cast(list) + end + + test "it works with a list with whole objects" do + list = ["https://lain.com/users/lain", %{"id" => "https://gensokyo.2hu/users/raymoo"}] + resulting_list = ["https://gensokyo.2hu/users/raymoo", "https://lain.com/users/lain"] + assert {:ok, resulting_list} == Recipients.cast(list) + end + + test "it turns a single string into a list" do + recipient = "https://lain.com/users/lain" + + assert {:ok, [recipient]} == Recipients.cast(recipient) + end +end -- cgit v1.2.3 From 78c864cbeed8fcdbe80e2842377d4fabc9362f3c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:08:36 +0200 Subject: LikeValidator: Use Recipients Type. --- lib/pleroma/web/activity_pub/object_validators/like_validator.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index 49546ceaa..eeb0da192 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -19,8 +19,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do field(:object, Types.ObjectID) field(:actor, Types.ObjectID) field(:context, :string) - field(:to, {:array, :string}) - field(:cc, {:array, :string}) + field(:to, Types.Recipients) + field(:cc, Types.Recipients) end def cast_and_validate(data) do -- cgit v1.2.3 From 503de4b8df0bfc34008c3c856edc488633290f0e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:09:51 +0200 Subject: ObjectValidator: Add validation for `Delete`s. --- lib/pleroma/web/activity_pub/builder.ex | 16 ++++++ lib/pleroma/web/activity_pub/object_validator.ex | 17 ++++++ .../object_validators/common_validations.ex | 20 +++++++ .../object_validators/delete_validator.ex | 64 +++++++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 67 ++++++++++++++++++++++ 5 files changed, 184 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/delete_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 429a510b8..5cc46c3ea 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,22 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()} + def delete(actor, object_id) do + object = Object.normalize(object_id) + + to = (object.data["to"] || []) ++ (object.data["cc"] || []) + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object_id, + "to" => to, + "type" => "Delete" + }, []} + end + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index dc4bce059..f476c6f72 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,10 +12,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Delete"} = object, meta) do + with {:ok, object} <- + object + |> DeleteValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Like"} = object, meta) do with {:ok, object} <- object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do @@ -24,6 +35,12 @@ def validate(%{"type" => "Like"} = object, meta) do end end + def stringify_keys(%{__struct__: _} = object) do + object + |> Map.from_struct() + |> stringify_keys + end + def stringify_keys(object) do object |> Map.new(fn {key, val} -> {to_string(key), val} end) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index b479c3918..e115d9526 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -8,6 +8,26 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do alias Pleroma.Object alias Pleroma.User + def validate_recipients_presence(cng, fields \\ [:to, :cc]) do + non_empty = + fields + |> Enum.map(fn field -> get_field(cng, field) end) + |> Enum.any?(fn + [] -> false + _ -> true + end) + + if non_empty do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "no recipients in any field") + end) + end + end + def validate_actor_presence(cng, field_name \\ :actor) do cng |> validate_change(field_name, fn field_name, actor -> diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex new file mode 100644 index 000000000..8dd5c19ad --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, Types.ObjectID) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + field(:object, Types.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Delete"]) + |> validate_same_domain() + |> validate_object_presence() + |> validate_recipients_presence() + end + + def validate_same_domain(cng) do + actor_domain = + cng + |> get_field(:actor) + |> URI.parse() + |> (& &1.host).() + + object_domain = + cng + |> get_field(:object) + |> URI.parse() + |> (& &1.host).() + + if object_domain != actor_domain do + cng + |> add_error(:actor, "is not allowed to delete object") + else + cng + end + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3c5c3696e..64b9ee1ec 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,6 +1,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils @@ -8,6 +9,72 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory + describe "deletes" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{"status" => "cancel me daddy"}) + + {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"]) + + %{user: user, valid_post_delete: valid_post_delete} + end + + test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do + assert match?({:ok, _, _}, ObjectValidator.validate(valid_post_delete, [])) + end + + test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do + no_id = + valid_post_delete + |> Map.delete("id") + + {:error, cng} = ObjectValidator.validate(no_id, []) + + assert {:id, {"can't be blank", [validation: :required]}} in cng.errors + end + + test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post_delete} do + missing_object = + valid_post_delete + |> Map.put("object", "http://does.not/exist") + + {:error, cng} = ObjectValidator.validate(missing_object, []) + + assert {:object, {"can't find object", []}} in cng.errors + end + + test "it's invalid if the actor of the object and the actor of delete are from different domains", + %{valid_post_delete: valid_post_delete} do + valid_other_actor = + valid_post_delete + |> Map.put("actor", valid_post_delete["actor"] <> "1") + + assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, [])) + + invalid_other_actor = + valid_post_delete + |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") + + {:error, cng} = ObjectValidator.validate(invalid_other_actor, []) + + assert {:actor, {"is not allowed to delete object", []}} in cng.errors + end + + test "it's invalid if all the recipient fields are empty", %{ + valid_post_delete: valid_post_delete + } do + empty_recipients = + valid_post_delete + |> Map.put("to", []) + |> Map.put("cc", []) + + {:error, cng} = ObjectValidator.validate(empty_recipients, []) + + assert {:to, {"no recipients in any field", []}} in cng.errors + assert {:cc, {"no recipients in any field", []}} in cng.errors + end + end + describe "likes" do setup do user = insert(:user) -- cgit v1.2.3 From 8148d76ec5f85083aae9895e79f8d249e1267482 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 29 Apr 2020 14:30:07 +0400 Subject: Document `captcha_answer_data` parameter --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 289f85930..041563de5 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -202,4 +202,5 @@ Has theses additional parameters (which are the same as in Pleroma-API): - `bio`: optional - `captcha_solution`: optional, contains provider-specific captcha solution, - `captcha_token`: optional, contains provider-specific captcha token +- `captcha_answer_data`: optional, contains provider-specific captcha data - `token`: invite token required when the registrations aren't public. -- cgit v1.2.3 From 2f77842bd3c07d915cd341f7d3ba1f9053cc3a18 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 29 Apr 2020 14:31:13 +0400 Subject: Fix account registration when captcha is enabled but not provided --- lib/pleroma/web/twitter_api/twitter_api.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index cf1d9c74c..a6ef9a310 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -47,9 +47,9 @@ defp validate_captcha(params) do :ok else Pleroma.Captcha.validate( - params.captcha_token, - params.captcha_solution, - params.captcha_answer_data + params[:captcha_token], + params[:captcha_solution], + params[:captcha_answer_data] ) end end -- cgit v1.2.3 From 7b0c8f0fde495e50c5434575c452addeace39e60 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 29 Apr 2020 20:48:08 +0400 Subject: Add tests for account registration with captcha enabled and improve errors --- lib/pleroma/captcha/captcha.ex | 99 +++++++++++-------- lib/pleroma/captcha/kocaptcha.ex | 5 +- lib/pleroma/captcha/native.ex | 5 +- .../mastodon_api/controllers/account_controller.ex | 24 +---- lib/pleroma/web/twitter_api/twitter_api.ex | 108 +++++++++++++-------- test/captcha_test.exs | 7 +- test/support/captcha_mock.ex | 6 +- .../controllers/account_controller_test.exs | 88 ++++++++++++++++- test/web/twitter_api/twitter_api_test.exs | 41 ++++---- 9 files changed, 250 insertions(+), 133 deletions(-) diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index cf75c3adc..e17dc2426 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -3,8 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha do - import Pleroma.Web.Gettext - alias Calendar.DateTime alias Plug.Crypto.KeyGenerator alias Plug.Crypto.MessageEncryptor @@ -37,19 +35,14 @@ def validate(token, captcha, answer_data) do @doc false def handle_call(:new, _from, state) do - enabled = Pleroma.Config.get([__MODULE__, :enabled]) - - if !enabled do + if not enabled?() do {:reply, %{type: :none}, state} else new_captcha = method().new() - secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) - # This make salt a little different for two keys - token = new_captcha[:token] - secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") - sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") + {secret, sign_secret} = secret_pair(new_captcha[:token]) + # Basically copy what Phoenix.Token does here, add the time to # the actual data and make it a binary to then encrypt it encrypted_captcha_answer = @@ -71,44 +64,68 @@ def handle_call(:new, _from, state) do @doc false def handle_call({:validate, token, captcha, answer_data}, _from, state) do + {:reply, do_validate(token, captcha, answer_data), state} + end + + def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], false) + + defp seconds_valid, do: Pleroma.Config.get!([__MODULE__, :seconds_valid]) + + defp secret_pair(token) do secret_key_base = Pleroma.Config.get!([Pleroma.Web.Endpoint, :secret_key_base]) secret = KeyGenerator.generate(secret_key_base, token <> "_encrypt") sign_secret = KeyGenerator.generate(secret_key_base, token <> "_sign") + {secret, sign_secret} + end + + defp do_validate(token, captcha, answer_data) do + with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data), + :ok <- validate_expiration(at), + :ok <- validate_usage(token), + :ok <- method().validate(token, captcha, answer_md5), + {:ok, _} <- mark_captcha_as_used(token) do + :ok + end + end + + defp validate_answer_data(token, answer_data) do + {secret, sign_secret} = secret_pair(token) + + with false <- is_nil(answer_data), + {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), + %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do + {:ok, %{at: at, answer_data: answer_md5}} + else + _ -> {:error, :invalid_answer_data} + end + end + + defp validate_expiration(created_at) do # If the time found is less than (current_time-seconds_valid) then the time has already passed # Later we check that the time found is more than the presumed invalidatation time, that means # that the data is still valid and the captcha can be checked - seconds_valid = Pleroma.Config.get!([Pleroma.Captcha, :seconds_valid]) - valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid) - - result = - with false <- is_nil(answer_data), - {:ok, data} <- MessageEncryptor.decrypt(answer_data, secret, sign_secret), - %{at: at, answer_data: answer_md5} <- :erlang.binary_to_term(data) do - try do - if DateTime.before?(at, valid_if_after), - do: throw({:error, dgettext("errors", "CAPTCHA expired")}) - - if not is_nil(Cachex.get!(:used_captcha_cache, token)), - do: throw({:error, dgettext("errors", "CAPTCHA already used")}) - - res = method().validate(token, captcha, answer_md5) - # Throw if an error occurs - if res != :ok, do: throw(res) - - # Mark this captcha as used - {:ok, _} = - Cachex.put(:used_captcha_cache, token, true, ttl: :timer.seconds(seconds_valid)) - - :ok - catch - :throw, e -> e - end - else - _ -> {:error, dgettext("errors", "Invalid answer data")} - end - - {:reply, result, state} + + valid_if_after = DateTime.subtract!(DateTime.now_utc(), seconds_valid()) + + if DateTime.before?(created_at, valid_if_after) do + {:error, :expired} + else + :ok + end + end + + defp validate_usage(token) do + if is_nil(Cachex.get!(:used_captcha_cache, token)) do + :ok + else + {:error, :already_used} + end + end + + defp mark_captcha_as_used(token) do + ttl = seconds_valid() |> :timer.seconds() + Cachex.put(:used_captcha_cache, token, true, ttl: ttl) end defp method, do: Pleroma.Config.get!([__MODULE__, :method]) diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index 06ceb20b6..6bc2fa158 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha.Kocaptcha do - import Pleroma.Web.Gettext alias Pleroma.Captcha.Service @behaviour Service @@ -13,7 +12,7 @@ def new do case Tesla.get(endpoint <> "/new") do {:error, _} -> - %{error: dgettext("errors", "Kocaptcha service unavailable")} + %{error: :kocaptcha_service_unavailable} {:ok, res} -> json_resp = Jason.decode!(res.body) @@ -33,6 +32,6 @@ def validate(_token, captcha, answer_data) do if not is_nil(captcha) and :crypto.hash(:md5, captcha) |> Base.encode16() == String.upcase(answer_data), do: :ok, - else: {:error, dgettext("errors", "Invalid CAPTCHA")} + else: {:error, :invalid} end end diff --git a/lib/pleroma/captcha/native.ex b/lib/pleroma/captcha/native.ex index 06c479ca9..a90631d61 100644 --- a/lib/pleroma/captcha/native.ex +++ b/lib/pleroma/captcha/native.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Captcha.Native do - import Pleroma.Web.Gettext alias Pleroma.Captcha.Service @behaviour Service @@ -11,7 +10,7 @@ defmodule Pleroma.Captcha.Native do def new do case Captcha.get() do :error -> - %{error: dgettext("errors", "Captcha error")} + %{error: :captcha_error} {:ok, answer_data, img_binary} -> %{ @@ -25,7 +24,7 @@ def new do @impl Service def validate(_token, captcha, captcha) when not is_nil(captcha), do: :ok - def validate(_token, _captcha, _answer), do: {:error, dgettext("errors", "Invalid CAPTCHA")} + def validate(_token, _captcha, _answer), do: {:error, :invalid} defp token do 10 diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1eedf02d6..61b0e2f63 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -94,24 +94,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @doc "POST /api/v1/accounts" def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do - params = - params - |> Map.take([ - :email, - :bio, - :captcha_solution, - :captcha_token, - :captcha_answer_data, - :token, - :password, - :fullname - ]) - |> Map.put(:nickname, params.username) - |> Map.put(:fullname, Map.get(params, :fullname, params.username)) - |> Map.put(:confirm, params.password) - |> Map.put(:trusted_app, app.trusted) - with :ok <- validate_email_param(params), + :ok <- TwitterAPI.validate_captcha(app, params), {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do json(conn, %{ @@ -121,7 +105,7 @@ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do created_at: Token.Utils.format_created_at(token) }) else - {:error, errors} -> json_response(conn, :bad_request, errors) + {:error, error} -> json_response(conn, :bad_request, %{error: error}) end end @@ -133,11 +117,11 @@ def create(conn, _) do render_error(conn, :forbidden, "Invalid credentials") end - defp validate_email_param(%{:email => email}) when not is_nil(email), do: :ok + defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok defp validate_email_param(_) do case Pleroma.Config.get([:instance, :account_activation_required]) do - true -> {:error, %{"error" => "Missing parameters"}} + true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")} _ -> :ok end end diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index a6ef9a310..5cfb385ac 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -3,54 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.TwitterAPI.TwitterAPI do + import Pleroma.Web.Gettext + alias Pleroma.Emails.Mailer alias Pleroma.Emails.UserEmail alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserInviteToken - require Pleroma.Constants - def register_user(params, opts \\ []) do params = params - |> Map.take([ - :nickname, - :password, - :captcha_solution, - :captcha_token, - :captcha_answer_data, - :token, - :email, - :trusted_app - ]) - |> Map.put(:bio, User.parse_bio(params[:bio] || "")) - |> Map.put(:name, params.fullname) - |> Map.put(:password_confirmation, params[:confirm]) - - case validate_captcha(params) do - :ok -> - if Pleroma.Config.get([:instance, :registrations_open]) do - create_user(params, opts) - else - create_user_with_invite(params, opts) - end + |> Map.take([:email, :token, :password]) + |> Map.put(:bio, params |> Map.get(:bio, "") |> User.parse_bio()) + |> Map.put(:nickname, params[:username]) + |> Map.put(:name, Map.get(params, :fullname, params[:username])) + |> Map.put(:password_confirmation, params[:password]) - {:error, error} -> - # I have no idea how this error handling works - {:error, %{error: Jason.encode!(%{captcha: [error]})}} - end - end - - defp validate_captcha(params) do - if params[:trusted_app] || not Pleroma.Config.get([Pleroma.Captcha, :enabled]) do - :ok + if Pleroma.Config.get([:instance, :registrations_open]) do + create_user(params, opts) else - Pleroma.Captcha.validate( - params[:captcha_token], - params[:captcha_solution], - params[:captcha_answer_data] - ) + create_user_with_invite(params, opts) end end @@ -75,16 +48,17 @@ defp create_user(params, opts) do {:error, changeset} -> errors = - Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) |> Jason.encode!() - {:error, %{error: errors}} + {:error, errors} end end def password_reset(nickname_or_email) do with true <- is_binary(nickname_or_email), - %User{local: true, email: email} = user when not is_nil(email) <- + %User{local: true, email: email} = user when is_binary(email) <- User.get_by_nickname_or_email(nickname_or_email), {:ok, token_record} <- Pleroma.PasswordResetToken.create_token(user) do user @@ -106,4 +80,58 @@ def password_reset(nickname_or_email) do {:error, "unknown user"} end end + + def validate_captcha(app, params) do + if app.trusted || not Pleroma.Captcha.enabled?() do + :ok + else + do_validate_captcha(params) + end + end + + defp do_validate_captcha(params) do + with :ok <- validate_captcha_presence(params), + :ok <- + Pleroma.Captcha.validate( + params[:captcha_token], + params[:captcha_solution], + params[:captcha_answer_data] + ) do + :ok + else + {:error, :captcha_error} -> + captcha_error(dgettext("errors", "CAPTCHA Error")) + + {:error, :invalid} -> + captcha_error(dgettext("errors", "Invalid CAPTCHA")) + + {:error, :kocaptcha_service_unavailable} -> + captcha_error(dgettext("errors", "Kocaptcha service unavailable")) + + {:error, :expired} -> + captcha_error(dgettext("errors", "CAPTCHA expired")) + + {:error, :already_used} -> + captcha_error(dgettext("errors", "CAPTCHA already used")) + + {:error, :invalid_answer_data} -> + captcha_error(dgettext("errors", "Invalid answer data")) + + {:error, error} -> + captcha_error(error) + end + end + + defp validate_captcha_presence(params) do + [:captcha_solution, :captcha_token, :captcha_answer_data] + |> Enum.find_value(:ok, fn key -> + unless is_binary(params[key]) do + error = dgettext("errors", "Invalid CAPTCHA (Missing parameter: %{name})", name: key) + {:error, error} + end + end) + end + + # For some reason FE expects error message to be a serialized JSON + defp captcha_error(error), do: {:error, Jason.encode!(%{captcha: [error]})} end diff --git a/test/captcha_test.exs b/test/captcha_test.exs index ac1d846e8..1ab9019ab 100644 --- a/test/captcha_test.exs +++ b/test/captcha_test.exs @@ -61,7 +61,7 @@ test "new and validate" do assert is_binary(answer) assert :ok = Native.validate(token, answer, answer) - assert {:error, "Invalid CAPTCHA"} == Native.validate(token, answer, answer <> "foobar") + assert {:error, :invalid} == Native.validate(token, answer, answer <> "foobar") end end @@ -78,6 +78,7 @@ test "validate" do assert is_binary(answer) assert :ok = Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", answer) + Cachex.del(:used_captcha_cache, token) end test "doesn't validate invalid answer" do @@ -92,7 +93,7 @@ test "doesn't validate invalid answer" do assert is_binary(answer) - assert {:error, "Invalid answer data"} = + assert {:error, :invalid_answer_data} = Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", answer <> "foobar") end @@ -108,7 +109,7 @@ test "nil answer_data" do assert is_binary(answer) - assert {:error, "Invalid answer data"} = + assert {:error, :invalid_answer_data} = Captcha.validate(token, "63615261b77f5354fb8c4e4986477555", nil) end end diff --git a/test/support/captcha_mock.ex b/test/support/captcha_mock.ex index 6dae94edf..7b0c1d5af 100644 --- a/test/support/captcha_mock.ex +++ b/test/support/captcha_mock.ex @@ -6,12 +6,16 @@ defmodule Pleroma.Captcha.Mock do alias Pleroma.Captcha.Service @behaviour Service + @solution "63615261b77f5354fb8c4e4986477555" + + def solution, do: @solution + @impl Service def new, do: %{ type: :mock, token: "afa1815e14e29355e6c8f6b143a39fa2", - answer_data: "63615261b77f5354fb8c4e4986477555", + answer_data: @solution, url: "https://example.org/captcha.png" } diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index ba70ba66c..b9da7e924 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -925,7 +925,8 @@ test "returns bad_request if missing email params when :account_activation_requi |> Map.put(:remote_ip, {127, 0, 0, 5}) |> post("/api/v1/accounts", Map.delete(valid_params, :email)) - assert json_response_and_validate_schema(res, 400) == %{"error" => "Missing parameters"} + assert json_response_and_validate_schema(res, 400) == + %{"error" => "Missing parameter: email"} res = conn @@ -1093,6 +1094,91 @@ test "respects rate limit setting", %{conn: conn} do end end + describe "create account with enabled captcha" do + setup %{conn: conn} do + app_token = insert(:oauth_token, user: nil) + + conn = + conn + |> put_req_header("authorization", "Bearer " <> app_token.token) + |> put_req_header("content-type", "multipart/form-data") + + [conn: conn] + end + + setup do: clear_config([Pleroma.Captcha, :enabled], true) + + test "creates an account and returns 200 if captcha is valid", %{conn: conn} do + %{token: token, answer_data: answer_data} = Pleroma.Captcha.new() + + params = %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + agreement: true, + captcha_solution: Pleroma.Captcha.Mock.solution(), + captcha_token: token, + captcha_answer_data: answer_data + } + + assert %{ + "access_token" => access_token, + "created_at" => _, + "scope" => ["read"], + "token_type" => "Bearer" + } = + conn + |> post("/api/v1/accounts", params) + |> json_response_and_validate_schema(:ok) + + assert Token |> Repo.get_by(token: access_token) |> Repo.preload(:user) |> Map.get(:user) + + Cachex.del(:used_captcha_cache, token) + end + + test "returns 400 if any captcha field is not provided", %{conn: conn} do + captcha_fields = [:captcha_solution, :captcha_token, :captcha_answer_data] + + valid_params = %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + agreement: true, + captcha_solution: "xx", + captcha_token: "xx", + captcha_answer_data: "xx" + } + + for field <- captcha_fields do + expected = %{ + "error" => "{\"captcha\":[\"Invalid CAPTCHA (Missing parameter: #{field})\"]}" + } + + assert expected == + conn + |> post("/api/v1/accounts", Map.delete(valid_params, field)) + |> json_response_and_validate_schema(:bad_request) + end + end + + test "returns an error if captcha is invalid", %{conn: conn} do + params = %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + agreement: true, + captcha_solution: "cofe", + captcha_token: "cofe", + captcha_answer_data: "cofe" + } + + assert %{"error" => "{\"captcha\":[\"Invalid answer data\"]}"} == + conn + |> post("/api/v1/accounts", params) + |> json_response_and_validate_schema(:bad_request) + end + end + describe "GET /api/v1/accounts/:id/lists - account_lists" do test "returns lists to which the account belongs" do %{user: user, conn: conn} = oauth_access(["read:lists"]) diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 7926a0757..368533292 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -18,7 +18,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do test "it registers a new user and returns the user." do data = %{ - :nickname => "lain", + :username => "lain", :email => "lain@wired.jp", :fullname => "lain iwakura", :password => "bear", @@ -35,7 +35,7 @@ test "it registers a new user and returns the user." do test "it registers a new user with empty string in bio and returns the user." do data = %{ - :nickname => "lain", + :username => "lain", :email => "lain@wired.jp", :fullname => "lain iwakura", :bio => "", @@ -60,7 +60,7 @@ test "it sends confirmation email if :account_activation_required is specified i end data = %{ - :nickname => "lain", + :username => "lain", :email => "lain@wired.jp", :fullname => "lain iwakura", :bio => "", @@ -87,7 +87,7 @@ test "it sends confirmation email if :account_activation_required is specified i test "it registers a new user and parses mentions in the bio" do data1 = %{ - :nickname => "john", + :username => "john", :email => "john@gmail.com", :fullname => "John Doe", :bio => "test", @@ -98,7 +98,7 @@ test "it registers a new user and parses mentions in the bio" do {:ok, user1} = TwitterAPI.register_user(data1) data2 = %{ - :nickname => "lain", + :username => "lain", :email => "lain@wired.jp", :fullname => "lain iwakura", :bio => "@john test", @@ -123,7 +123,7 @@ test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite() data = %{ - :nickname => "vinny", + :username => "vinny", :email => "pasta@pizza.vs", :fullname => "Vinny Vinesauce", :bio => "streamer", @@ -145,7 +145,7 @@ test "returns user on success" do test "returns error on invalid token" do data = %{ - :nickname => "GrimReaper", + :username => "GrimReaper", :email => "death@reapers.afterlife", :fullname => "Reaper Grim", :bio => "Your time has come", @@ -165,7 +165,7 @@ test "returns error on expired token" do UserInviteToken.update_invite!(invite, used: true) data = %{ - :nickname => "GrimReaper", + :username => "GrimReaper", :email => "death@reapers.afterlife", :fullname => "Reaper Grim", :bio => "Your time has come", @@ -186,7 +186,7 @@ test "returns error on expired token" do setup do data = %{ - :nickname => "vinny", + :username => "vinny", :email => "pasta@pizza.vs", :fullname => "Vinny Vinesauce", :bio => "streamer", @@ -250,7 +250,7 @@ test "returns user on success, after him registration fails" do UserInviteToken.update_invite!(invite, uses: 99) data = %{ - :nickname => "vinny", + :username => "vinny", :email => "pasta@pizza.vs", :fullname => "Vinny Vinesauce", :bio => "streamer", @@ -269,7 +269,7 @@ test "returns user on success, after him registration fails" do AccountView.render("show.json", %{user: fetched_user}) data = %{ - :nickname => "GrimReaper", + :username => "GrimReaper", :email => "death@reapers.afterlife", :fullname => "Reaper Grim", :bio => "Your time has come", @@ -292,7 +292,7 @@ test "returns user on success" do {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) data = %{ - :nickname => "vinny", + :username => "vinny", :email => "pasta@pizza.vs", :fullname => "Vinny Vinesauce", :bio => "streamer", @@ -317,7 +317,7 @@ test "error after max uses" do UserInviteToken.update_invite!(invite, uses: 99) data = %{ - :nickname => "vinny", + :username => "vinny", :email => "pasta@pizza.vs", :fullname => "Vinny Vinesauce", :bio => "streamer", @@ -335,7 +335,7 @@ test "error after max uses" do AccountView.render("show.json", %{user: fetched_user}) data = %{ - :nickname => "GrimReaper", + :username => "GrimReaper", :email => "death@reapers.afterlife", :fullname => "Reaper Grim", :bio => "Your time has come", @@ -355,7 +355,7 @@ test "returns error on overdue date" do UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100}) data = %{ - :nickname => "GrimReaper", + :username => "GrimReaper", :email => "death@reapers.afterlife", :fullname => "Reaper Grim", :bio => "Your time has come", @@ -377,7 +377,7 @@ test "returns error on with overdue date and after max" do UserInviteToken.update_invite!(invite, uses: 100) data = %{ - :nickname => "GrimReaper", + :username => "GrimReaper", :email => "death@reapers.afterlife", :fullname => "Reaper Grim", :bio => "Your time has come", @@ -395,16 +395,15 @@ test "returns error on with overdue date and after max" do test "it returns the error on registration problems" do data = %{ - :nickname => "lain", + :username => "lain", :email => "lain@wired.jp", :fullname => "lain iwakura", - :bio => "close the world.", - :password => "bear" + :bio => "close the world." } - {:error, error_object} = TwitterAPI.register_user(data) + {:error, error} = TwitterAPI.register_user(data) - assert is_binary(error_object[:error]) + assert is_binary(error) refute User.get_cached_by_nickname("lain") end -- cgit v1.2.3 From 39a78998d0a729323406497332f9402301994811 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 29 Apr 2020 21:01:16 +0400 Subject: Change Pleroma.CaptchaTest to be a regular module instead of GenServer --- lib/pleroma/application.ex | 1 - lib/pleroma/captcha/captcha.ex | 58 ++++++++++-------------------------------- 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index a00938c04..308d8cffa 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -73,7 +73,6 @@ def start(_type, _args) do Pleroma.Repo, Config.TransferTask, Pleroma.Emoji, - Pleroma.Captcha, Pleroma.Plugs.RateLimiter.Supervisor ] ++ cachex_children() ++ diff --git a/lib/pleroma/captcha/captcha.ex b/lib/pleroma/captcha/captcha.ex index e17dc2426..6ab754b6f 100644 --- a/lib/pleroma/captcha/captcha.ex +++ b/lib/pleroma/captcha/captcha.ex @@ -7,36 +7,12 @@ defmodule Pleroma.Captcha do alias Plug.Crypto.KeyGenerator alias Plug.Crypto.MessageEncryptor - use GenServer - - @doc false - def start_link(_) do - GenServer.start_link(__MODULE__, [], name: __MODULE__) - end - - @doc false - def init(_) do - {:ok, nil} - end - @doc """ Ask the configured captcha service for a new captcha """ def new do - GenServer.call(__MODULE__, :new) - end - - @doc """ - Ask the configured captcha service to validate the captcha - """ - def validate(token, captcha, answer_data) do - GenServer.call(__MODULE__, {:validate, token, captcha, answer_data}) - end - - @doc false - def handle_call(:new, _from, state) do if not enabled?() do - {:reply, %{type: :none}, state} + %{type: :none} else new_captcha = method().new() @@ -53,18 +29,22 @@ def handle_call(:new, _from, state) do |> :erlang.term_to_binary() |> MessageEncryptor.encrypt(secret, sign_secret) - { - :reply, - # Replace the answer with the encrypted answer - %{new_captcha | answer_data: encrypted_captcha_answer}, - state - } + # Replace the answer with the encrypted answer + %{new_captcha | answer_data: encrypted_captcha_answer} end end - @doc false - def handle_call({:validate, token, captcha, answer_data}, _from, state) do - {:reply, do_validate(token, captcha, answer_data), state} + @doc """ + Ask the configured captcha service to validate the captcha + """ + def validate(token, captcha, answer_data) do + with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data), + :ok <- validate_expiration(at), + :ok <- validate_usage(token), + :ok <- method().validate(token, captcha, answer_md5), + {:ok, _} <- mark_captcha_as_used(token) do + :ok + end end def enabled?, do: Pleroma.Config.get([__MODULE__, :enabled], false) @@ -79,16 +59,6 @@ defp secret_pair(token) do {secret, sign_secret} end - defp do_validate(token, captcha, answer_data) do - with {:ok, %{at: at, answer_data: answer_md5}} <- validate_answer_data(token, answer_data), - :ok <- validate_expiration(at), - :ok <- validate_usage(token), - :ok <- method().validate(token, captcha, answer_md5), - {:ok, _} <- mark_captcha_as_used(token) do - :ok - end - end - defp validate_answer_data(token, answer_data) do {secret, sign_secret} = secret_pair(token) -- cgit v1.2.3 From 528ea779a61d12f74ee9669bbd28783bf66aa5cc Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 17:56:24 +0000 Subject: Apply suggestion to docs/API/chats.md --- docs/API/chats.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 24c4b4d06..26e83570e 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -99,7 +99,7 @@ For a given Chat id, you can get the associated messages with `GET /api/v1/pleroma/chats/{id}/messages` This will return all messages, sorted by most recent to least recent. The usual -pagination options are implemented +pagination options are implemented. Returned data: -- cgit v1.2.3 From 89a6c340812a53daf00a203dacd8e12a25eb7ad2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 18:14:34 +0000 Subject: Apply suggestion to lib/pleroma/chat.ex --- lib/pleroma/chat.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index b8545063a..6b1f832ce 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -29,7 +29,7 @@ def creation_cng(struct, params) do |> validate_change(:recipient, fn :recipient, recipient -> case User.get_cached_by_ap_id(recipient) do - nil -> [recipient: "must a an existing user"] + nil -> [recipient: "must be an existing user"] _ -> [] end end) -- cgit v1.2.3 From 589ce1e96bcaba0bd2d864d3528992f10f4cf5f7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:47:16 +0000 Subject: Apply suggestion to lib/pleroma/web/activity_pub/transmogrifier.ex --- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 6dbd3f588..d3a2e0362 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -650,9 +650,6 @@ def handle_incoming( with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} - else - e -> - e end end -- cgit v1.2.3 From 145d35ff70a59efcff881315d5f1f7a0248a34be Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:49:03 +0000 Subject: Apply suggestion to lib/pleroma/web/pleroma_api/controllers/chat_controller.ex --- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8654f4295..175257921 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -31,7 +31,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do %{scopes: ["read:statuses"]} when action in [:messages, :index] ) - plug(OpenApiSpex.Plug.CastAndValidate) + plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation -- cgit v1.2.3 From b68d56c8168f27f63e157d43558e22f7c221c0e2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Apr 2020 19:49:13 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/schemas/chat_message_response.ex --- lib/pleroma/web/api_spec/schemas/chat_message_response.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex index b7a662cbb..707c9808b 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, content: %Schema{type: :string}, - created_at: %Schema{type: :string, format: :datetime}, + created_at: %Schema{type: :string, format: :"date-time"}, emojis: %Schema{type: :array} }, example: %{ -- cgit v1.2.3 From 3ead44a9aa14daebfb63de089d047f4fe9ee3bdd Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 29 Apr 2020 15:49:48 -0500 Subject: Update AdminFE build in preparation for the 2.0.3 release --- priv/static/adminfe/app.796ca6d4.css | Bin 0 -> 12837 bytes priv/static/adminfe/app.85534e14.css | Bin 12836 -> 0 bytes priv/static/adminfe/chunk-0558.af0d89cd.css | Bin 0 -> 4748 bytes priv/static/adminfe/chunk-0778.d9e7180a.css | Bin 0 -> 2340 bytes priv/static/adminfe/chunk-0961.d3692214.css | Bin 0 -> 2044 bytes priv/static/adminfe/chunk-0d8f.d85f5a29.css | Bin 3433 -> 0 bytes priv/static/adminfe/chunk-136a.f1130f8e.css | Bin 4946 -> 0 bytes priv/static/adminfe/chunk-13e9.98eaadba.css | Bin 1071 -> 0 bytes priv/static/adminfe/chunk-15fa.5a5f973d.css | Bin 4748 -> 0 bytes priv/static/adminfe/chunk-22d2.813009b9.css | Bin 0 -> 6282 bytes priv/static/adminfe/chunk-2b9c.feb61a2b.css | Bin 5580 -> 0 bytes priv/static/adminfe/chunk-3384.2278f87c.css | Bin 0 -> 5550 bytes priv/static/adminfe/chunk-4011.c4799067.css | Bin 0 -> 23982 bytes priv/static/adminfe/chunk-46ef.145de4f9.css | Bin 1790 -> 0 bytes priv/static/adminfe/chunk-4ffb.dd09fe2e.css | Bin 745 -> 0 bytes priv/static/adminfe/chunk-6b68.0cc00484.css | Bin 0 -> 692 bytes priv/static/adminfe/chunk-6e81.0e80d020.css | Bin 0 -> 745 bytes priv/static/adminfe/chunk-7637.941c4edb.css | Bin 0 -> 1347 bytes priv/static/adminfe/chunk-876c.90dffac4.css | Bin 2044 -> 0 bytes priv/static/adminfe/chunk-87b3.3c6ede9c.css | Bin 9575 -> 0 bytes priv/static/adminfe/chunk-88c9.184084df.css | Bin 5731 -> 0 bytes priv/static/adminfe/chunk-970d.f59cca8c.css | Bin 0 -> 6173 bytes priv/static/adminfe/chunk-cf57.26596375.css | Bin 3244 -> 0 bytes priv/static/adminfe/chunk-d38a.cabdc22e.css | Bin 0 -> 3332 bytes priv/static/adminfe/chunk-e458.f88bafea.css | Bin 0 -> 3156 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.203f69f8.js | Bin 0 -> 187722 bytes priv/static/adminfe/static/js/app.203f69f8.js.map | Bin 0 -> 416278 bytes priv/static/adminfe/static/js/app.d898cc2b.js | Bin 185128 -> 0 bytes priv/static/adminfe/static/js/app.d898cc2b.js.map | Bin 410154 -> 0 bytes priv/static/adminfe/static/js/chunk-0558.75954137.js | Bin 0 -> 7919 bytes .../adminfe/static/js/chunk-0558.75954137.js.map | Bin 0 -> 17438 bytes priv/static/adminfe/static/js/chunk-0778.b17650df.js | Bin 0 -> 9756 bytes .../adminfe/static/js/chunk-0778.b17650df.js.map | Bin 0 -> 32393 bytes priv/static/adminfe/static/js/chunk-0961.ef33e81b.js | Bin 0 -> 5112 bytes .../adminfe/static/js/chunk-0961.ef33e81b.js.map | Bin 0 -> 19744 bytes priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js | Bin 33538 -> 0 bytes .../adminfe/static/js/chunk-0d8f.6d50ff86.js.map | Bin 116201 -> 0 bytes priv/static/adminfe/static/js/chunk-136a.c4719e3e.js | Bin 19553 -> 0 bytes .../adminfe/static/js/chunk-136a.c4719e3e.js.map | Bin 69090 -> 0 bytes priv/static/adminfe/static/js/chunk-13e9.79da1569.js | Bin 9528 -> 0 bytes .../adminfe/static/js/chunk-13e9.79da1569.js.map | Bin 40125 -> 0 bytes priv/static/adminfe/static/js/chunk-15fa.34070731.js | Bin 7919 -> 0 bytes .../adminfe/static/js/chunk-15fa.34070731.js.map | Bin 17438 -> 0 bytes priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js | Bin 0 -> 30624 bytes .../adminfe/static/js/chunk-22d2.a0cf7976.js.map | Bin 0 -> 103450 bytes priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js | Bin 28194 -> 0 bytes .../adminfe/static/js/chunk-2b9c.cf321c74.js.map | Bin 95810 -> 0 bytes priv/static/adminfe/static/js/chunk-3384.458ffaf1.js | Bin 0 -> 23953 bytes .../adminfe/static/js/chunk-3384.458ffaf1.js.map | Bin 0 -> 85906 bytes priv/static/adminfe/static/js/chunk-4011.67fb1692.js | Bin 0 -> 117521 bytes .../adminfe/static/js/chunk-4011.67fb1692.js.map | Bin 0 -> 397967 bytes priv/static/adminfe/static/js/chunk-46ef.671cac7d.js | Bin 7765 -> 0 bytes .../adminfe/static/js/chunk-46ef.671cac7d.js.map | Bin 26170 -> 0 bytes priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js | Bin 2080 -> 0 bytes .../adminfe/static/js/chunk-4ffb.0e8f3772.js.map | Bin 9090 -> 0 bytes priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js | Bin 0 -> 14790 bytes .../adminfe/static/js/chunk-6b68.fbc0f684.js.map | Bin 0 -> 40172 bytes priv/static/adminfe/static/js/chunk-6e81.3733ace2.js | Bin 0 -> 2080 bytes .../adminfe/static/js/chunk-6e81.3733ace2.js.map | Bin 0 -> 9090 bytes priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js | Bin 0 -> 10877 bytes .../adminfe/static/js/chunk-7637.8f5fb36e.js.map | Bin 0 -> 44563 bytes priv/static/adminfe/static/js/chunk-876c.e4ceccca.js | Bin 5112 -> 0 bytes .../adminfe/static/js/chunk-876c.e4ceccca.js.map | Bin 19744 -> 0 bytes priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js | Bin 103449 -> 0 bytes .../adminfe/static/js/chunk-87b3.3c11ef09.js.map | Bin 358904 -> 0 bytes priv/static/adminfe/static/js/chunk-88c9.e3583744.js | Bin 24234 -> 0 bytes .../adminfe/static/js/chunk-88c9.e3583744.js.map | Bin 92387 -> 0 bytes priv/static/adminfe/static/js/chunk-970d.2457e066.js | Bin 0 -> 26608 bytes .../adminfe/static/js/chunk-970d.2457e066.js.map | Bin 0 -> 100000 bytes priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js | Bin 29728 -> 0 bytes .../adminfe/static/js/chunk-cf57.3e45f57f.js.map | Bin 89855 -> 0 bytes priv/static/adminfe/static/js/chunk-d38a.a851004a.js | Bin 0 -> 20205 bytes .../adminfe/static/js/chunk-d38a.a851004a.js.map | Bin 0 -> 81345 bytes priv/static/adminfe/static/js/chunk-e458.4e5aad44.js | Bin 0 -> 16756 bytes .../adminfe/static/js/chunk-e458.4e5aad44.js.map | Bin 0 -> 55666 bytes priv/static/adminfe/static/js/runtime.1b4f6ce0.js | Bin 0 -> 4032 bytes priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map | Bin 0 -> 16879 bytes priv/static/adminfe/static/js/runtime.cb26bbd1.js | Bin 3969 -> 0 bytes priv/static/adminfe/static/js/runtime.cb26bbd1.js.map | Bin 16759 -> 0 bytes 80 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/app.796ca6d4.css delete mode 100644 priv/static/adminfe/app.85534e14.css create mode 100644 priv/static/adminfe/chunk-0558.af0d89cd.css create mode 100644 priv/static/adminfe/chunk-0778.d9e7180a.css create mode 100644 priv/static/adminfe/chunk-0961.d3692214.css delete mode 100644 priv/static/adminfe/chunk-0d8f.d85f5a29.css delete mode 100644 priv/static/adminfe/chunk-136a.f1130f8e.css delete mode 100644 priv/static/adminfe/chunk-13e9.98eaadba.css delete mode 100644 priv/static/adminfe/chunk-15fa.5a5f973d.css create mode 100644 priv/static/adminfe/chunk-22d2.813009b9.css delete mode 100644 priv/static/adminfe/chunk-2b9c.feb61a2b.css create mode 100644 priv/static/adminfe/chunk-3384.2278f87c.css create mode 100644 priv/static/adminfe/chunk-4011.c4799067.css delete mode 100644 priv/static/adminfe/chunk-46ef.145de4f9.css delete mode 100644 priv/static/adminfe/chunk-4ffb.dd09fe2e.css create mode 100644 priv/static/adminfe/chunk-6b68.0cc00484.css create mode 100644 priv/static/adminfe/chunk-6e81.0e80d020.css create mode 100644 priv/static/adminfe/chunk-7637.941c4edb.css delete mode 100644 priv/static/adminfe/chunk-876c.90dffac4.css delete mode 100644 priv/static/adminfe/chunk-87b3.3c6ede9c.css delete mode 100644 priv/static/adminfe/chunk-88c9.184084df.css create mode 100644 priv/static/adminfe/chunk-970d.f59cca8c.css delete mode 100644 priv/static/adminfe/chunk-cf57.26596375.css create mode 100644 priv/static/adminfe/chunk-d38a.cabdc22e.css create mode 100644 priv/static/adminfe/chunk-e458.f88bafea.css create mode 100644 priv/static/adminfe/static/js/app.203f69f8.js create mode 100644 priv/static/adminfe/static/js/app.203f69f8.js.map delete mode 100644 priv/static/adminfe/static/js/app.d898cc2b.js delete mode 100644 priv/static/adminfe/static/js/app.d898cc2b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0558.75954137.js create mode 100644 priv/static/adminfe/static/js/chunk-0558.75954137.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0778.b17650df.js create mode 100644 priv/static/adminfe/static/js/chunk-0778.b17650df.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0961.ef33e81b.js create mode 100644 priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js delete mode 100644 priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-136a.c4719e3e.js delete mode 100644 priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-13e9.79da1569.js delete mode 100644 priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-15fa.34070731.js delete mode 100644 priv/static/adminfe/static/js/chunk-15fa.34070731.js.map create mode 100644 priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js create mode 100644 priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js delete mode 100644 priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map create mode 100644 priv/static/adminfe/static/js/chunk-3384.458ffaf1.js create mode 100644 priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map create mode 100644 priv/static/adminfe/static/js/chunk-4011.67fb1692.js create mode 100644 priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-46ef.671cac7d.js delete mode 100644 priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js delete mode 100644 priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map create mode 100644 priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js create mode 100644 priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map create mode 100644 priv/static/adminfe/static/js/chunk-6e81.3733ace2.js create mode 100644 priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js create mode 100644 priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-876c.e4ceccca.js delete mode 100644 priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js delete mode 100644 priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-88c9.e3583744.js delete mode 100644 priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map create mode 100644 priv/static/adminfe/static/js/chunk-970d.2457e066.js create mode 100644 priv/static/adminfe/static/js/chunk-970d.2457e066.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js delete mode 100644 priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map create mode 100644 priv/static/adminfe/static/js/chunk-d38a.a851004a.js create mode 100644 priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-e458.4e5aad44.js create mode 100644 priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map create mode 100644 priv/static/adminfe/static/js/runtime.1b4f6ce0.js create mode 100644 priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.cb26bbd1.js delete mode 100644 priv/static/adminfe/static/js/runtime.cb26bbd1.js.map diff --git a/priv/static/adminfe/app.796ca6d4.css b/priv/static/adminfe/app.796ca6d4.css new file mode 100644 index 000000000..1b83a8a39 Binary files /dev/null and b/priv/static/adminfe/app.796ca6d4.css differ diff --git a/priv/static/adminfe/app.85534e14.css b/priv/static/adminfe/app.85534e14.css deleted file mode 100644 index 473ec1b86..000000000 Binary files a/priv/static/adminfe/app.85534e14.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0558.af0d89cd.css b/priv/static/adminfe/chunk-0558.af0d89cd.css new file mode 100644 index 000000000..30bf7de23 Binary files /dev/null and b/priv/static/adminfe/chunk-0558.af0d89cd.css differ diff --git a/priv/static/adminfe/chunk-0778.d9e7180a.css b/priv/static/adminfe/chunk-0778.d9e7180a.css new file mode 100644 index 000000000..9d730019a Binary files /dev/null and b/priv/static/adminfe/chunk-0778.d9e7180a.css differ diff --git a/priv/static/adminfe/chunk-0961.d3692214.css b/priv/static/adminfe/chunk-0961.d3692214.css new file mode 100644 index 000000000..c0074e6f7 Binary files /dev/null and b/priv/static/adminfe/chunk-0961.d3692214.css differ diff --git a/priv/static/adminfe/chunk-0d8f.d85f5a29.css b/priv/static/adminfe/chunk-0d8f.d85f5a29.css deleted file mode 100644 index 931620872..000000000 Binary files a/priv/static/adminfe/chunk-0d8f.d85f5a29.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-136a.f1130f8e.css b/priv/static/adminfe/chunk-136a.f1130f8e.css deleted file mode 100644 index f492b37d0..000000000 Binary files a/priv/static/adminfe/chunk-136a.f1130f8e.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-13e9.98eaadba.css b/priv/static/adminfe/chunk-13e9.98eaadba.css deleted file mode 100644 index 9f377eee2..000000000 Binary files a/priv/static/adminfe/chunk-13e9.98eaadba.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-15fa.5a5f973d.css b/priv/static/adminfe/chunk-15fa.5a5f973d.css deleted file mode 100644 index 30bf7de23..000000000 Binary files a/priv/static/adminfe/chunk-15fa.5a5f973d.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-22d2.813009b9.css b/priv/static/adminfe/chunk-22d2.813009b9.css new file mode 100644 index 000000000..f0a98583e Binary files /dev/null and b/priv/static/adminfe/chunk-22d2.813009b9.css differ diff --git a/priv/static/adminfe/chunk-2b9c.feb61a2b.css b/priv/static/adminfe/chunk-2b9c.feb61a2b.css deleted file mode 100644 index f54eca1f5..000000000 Binary files a/priv/static/adminfe/chunk-2b9c.feb61a2b.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-3384.2278f87c.css b/priv/static/adminfe/chunk-3384.2278f87c.css new file mode 100644 index 000000000..96e3273eb Binary files /dev/null and b/priv/static/adminfe/chunk-3384.2278f87c.css differ diff --git a/priv/static/adminfe/chunk-4011.c4799067.css b/priv/static/adminfe/chunk-4011.c4799067.css new file mode 100644 index 000000000..1fb099c0c Binary files /dev/null and b/priv/static/adminfe/chunk-4011.c4799067.css differ diff --git a/priv/static/adminfe/chunk-46ef.145de4f9.css b/priv/static/adminfe/chunk-46ef.145de4f9.css deleted file mode 100644 index deb5249ac..000000000 Binary files a/priv/static/adminfe/chunk-46ef.145de4f9.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-4ffb.dd09fe2e.css b/priv/static/adminfe/chunk-4ffb.dd09fe2e.css deleted file mode 100644 index da819ca09..000000000 Binary files a/priv/static/adminfe/chunk-4ffb.dd09fe2e.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-6b68.0cc00484.css b/priv/static/adminfe/chunk-6b68.0cc00484.css new file mode 100644 index 000000000..7061b3d03 Binary files /dev/null and b/priv/static/adminfe/chunk-6b68.0cc00484.css differ diff --git a/priv/static/adminfe/chunk-6e81.0e80d020.css b/priv/static/adminfe/chunk-6e81.0e80d020.css new file mode 100644 index 000000000..da819ca09 Binary files /dev/null and b/priv/static/adminfe/chunk-6e81.0e80d020.css differ diff --git a/priv/static/adminfe/chunk-7637.941c4edb.css b/priv/static/adminfe/chunk-7637.941c4edb.css new file mode 100644 index 000000000..be1d183a9 Binary files /dev/null and b/priv/static/adminfe/chunk-7637.941c4edb.css differ diff --git a/priv/static/adminfe/chunk-876c.90dffac4.css b/priv/static/adminfe/chunk-876c.90dffac4.css deleted file mode 100644 index c0074e6f7..000000000 Binary files a/priv/static/adminfe/chunk-876c.90dffac4.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-87b3.3c6ede9c.css b/priv/static/adminfe/chunk-87b3.3c6ede9c.css deleted file mode 100644 index f0e6bf4ee..000000000 Binary files a/priv/static/adminfe/chunk-87b3.3c6ede9c.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-88c9.184084df.css b/priv/static/adminfe/chunk-88c9.184084df.css deleted file mode 100644 index f3299f33b..000000000 Binary files a/priv/static/adminfe/chunk-88c9.184084df.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-970d.f59cca8c.css b/priv/static/adminfe/chunk-970d.f59cca8c.css new file mode 100644 index 000000000..15511f12f Binary files /dev/null and b/priv/static/adminfe/chunk-970d.f59cca8c.css differ diff --git a/priv/static/adminfe/chunk-cf57.26596375.css b/priv/static/adminfe/chunk-cf57.26596375.css deleted file mode 100644 index 9f72b88c1..000000000 Binary files a/priv/static/adminfe/chunk-cf57.26596375.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-d38a.cabdc22e.css b/priv/static/adminfe/chunk-d38a.cabdc22e.css new file mode 100644 index 000000000..4a2bf472b Binary files /dev/null and b/priv/static/adminfe/chunk-d38a.cabdc22e.css differ diff --git a/priv/static/adminfe/chunk-e458.f88bafea.css b/priv/static/adminfe/chunk-e458.f88bafea.css new file mode 100644 index 000000000..085bdf076 Binary files /dev/null and b/priv/static/adminfe/chunk-e458.f88bafea.css differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 3651c1cf0..a236dd0f7 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.203f69f8.js b/priv/static/adminfe/static/js/app.203f69f8.js new file mode 100644 index 000000000..d06fdf71d Binary files /dev/null and b/priv/static/adminfe/static/js/app.203f69f8.js differ diff --git a/priv/static/adminfe/static/js/app.203f69f8.js.map b/priv/static/adminfe/static/js/app.203f69f8.js.map new file mode 100644 index 000000000..eb78cd464 Binary files /dev/null and b/priv/static/adminfe/static/js/app.203f69f8.js.map differ diff --git a/priv/static/adminfe/static/js/app.d898cc2b.js b/priv/static/adminfe/static/js/app.d898cc2b.js deleted file mode 100644 index 9d60db06b..000000000 Binary files a/priv/static/adminfe/static/js/app.d898cc2b.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.d898cc2b.js.map b/priv/static/adminfe/static/js/app.d898cc2b.js.map deleted file mode 100644 index 1c4ec7590..000000000 Binary files a/priv/static/adminfe/static/js/app.d898cc2b.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0558.75954137.js b/priv/static/adminfe/static/js/chunk-0558.75954137.js new file mode 100644 index 000000000..7b29707fa Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0558.75954137.js differ diff --git a/priv/static/adminfe/static/js/chunk-0558.75954137.js.map b/priv/static/adminfe/static/js/chunk-0558.75954137.js.map new file mode 100644 index 000000000..e9e2affb6 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0558.75954137.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0778.b17650df.js b/priv/static/adminfe/static/js/chunk-0778.b17650df.js new file mode 100644 index 000000000..1a174cc1e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0778.b17650df.js differ diff --git a/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map b/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map new file mode 100644 index 000000000..1f96c3236 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js b/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js new file mode 100644 index 000000000..e090bb93c Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js differ diff --git a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map b/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map new file mode 100644 index 000000000..97c6a4b54 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js b/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js deleted file mode 100644 index 4b0945f57..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map b/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map deleted file mode 100644 index da24cbef5..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0d8f.6d50ff86.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js b/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js deleted file mode 100644 index 0c2f1a52e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map b/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map deleted file mode 100644 index 4b137fd49..000000000 Binary files a/priv/static/adminfe/static/js/chunk-136a.c4719e3e.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-13e9.79da1569.js b/priv/static/adminfe/static/js/chunk-13e9.79da1569.js deleted file mode 100644 index b98177b82..000000000 Binary files a/priv/static/adminfe/static/js/chunk-13e9.79da1569.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map b/priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map deleted file mode 100644 index 118a47034..000000000 Binary files a/priv/static/adminfe/static/js/chunk-13e9.79da1569.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.34070731.js b/priv/static/adminfe/static/js/chunk-15fa.34070731.js deleted file mode 100644 index 937908d00..000000000 Binary files a/priv/static/adminfe/static/js/chunk-15fa.34070731.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-15fa.34070731.js.map b/priv/static/adminfe/static/js/chunk-15fa.34070731.js.map deleted file mode 100644 index d3830be7c..000000000 Binary files a/priv/static/adminfe/static/js/chunk-15fa.34070731.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js new file mode 100644 index 000000000..903f553b0 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js differ diff --git a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map new file mode 100644 index 000000000..68735ed26 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js b/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js deleted file mode 100644 index f06da0268..000000000 Binary files a/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map b/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map deleted file mode 100644 index 1ec750dd1..000000000 Binary files a/priv/static/adminfe/static/js/chunk-2b9c.cf321c74.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js b/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js new file mode 100644 index 000000000..eb2b55d37 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js differ diff --git a/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map b/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map new file mode 100644 index 000000000..0bb577aab Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-4011.67fb1692.js b/priv/static/adminfe/static/js/chunk-4011.67fb1692.js new file mode 100644 index 000000000..775ed26f1 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4011.67fb1692.js differ diff --git a/priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map b/priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map new file mode 100644 index 000000000..6df398cbc Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js deleted file mode 100644 index 805cdea13..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map b/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map deleted file mode 100644 index f6b420bb2..000000000 Binary files a/priv/static/adminfe/static/js/chunk-46ef.671cac7d.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js b/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js deleted file mode 100644 index 5a7aa9f59..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map b/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map deleted file mode 100644 index 7c020768c..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4ffb.0e8f3772.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js new file mode 100644 index 000000000..bfdf936f8 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js differ diff --git a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map new file mode 100644 index 000000000..d1d728b80 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js b/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js new file mode 100644 index 000000000..c888ce03f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map b/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map new file mode 100644 index 000000000..63128dd67 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js new file mode 100644 index 000000000..b38644b98 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js differ diff --git a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map new file mode 100644 index 000000000..ddd53f1cd Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js b/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js deleted file mode 100644 index 841ceb9dc..000000000 Binary files a/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map b/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map deleted file mode 100644 index 88976a4fe..000000000 Binary files a/priv/static/adminfe/static/js/chunk-876c.e4ceccca.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js deleted file mode 100644 index 3899ff190..000000000 Binary files a/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map b/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map deleted file mode 100644 index 6c6a85667..000000000 Binary files a/priv/static/adminfe/static/js/chunk-87b3.3c11ef09.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-88c9.e3583744.js b/priv/static/adminfe/static/js/chunk-88c9.e3583744.js deleted file mode 100644 index 0070fc30a..000000000 Binary files a/priv/static/adminfe/static/js/chunk-88c9.e3583744.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map b/priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map deleted file mode 100644 index 20e503d0c..000000000 Binary files a/priv/static/adminfe/static/js/chunk-88c9.e3583744.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-970d.2457e066.js b/priv/static/adminfe/static/js/chunk-970d.2457e066.js new file mode 100644 index 000000000..0f99d835e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-970d.2457e066.js differ diff --git a/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map b/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map new file mode 100644 index 000000000..6896407b0 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js b/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js deleted file mode 100644 index 2b4fd918f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map b/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map deleted file mode 100644 index 6457630bd..000000000 Binary files a/priv/static/adminfe/static/js/chunk-cf57.3e45f57f.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js new file mode 100644 index 000000000..c302af310 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js differ diff --git a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map new file mode 100644 index 000000000..6779f6dc1 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js b/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js new file mode 100644 index 000000000..a02c83110 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js differ diff --git a/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map b/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map new file mode 100644 index 000000000..e623af23d Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.1b4f6ce0.js b/priv/static/adminfe/static/js/runtime.1b4f6ce0.js new file mode 100644 index 000000000..6558531ba Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.1b4f6ce0.js differ diff --git a/priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map b/priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map new file mode 100644 index 000000000..9295ac636 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.cb26bbd1.js b/priv/static/adminfe/static/js/runtime.cb26bbd1.js deleted file mode 100644 index 7180cc6e3..000000000 Binary files a/priv/static/adminfe/static/js/runtime.cb26bbd1.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.cb26bbd1.js.map b/priv/static/adminfe/static/js/runtime.cb26bbd1.js.map deleted file mode 100644 index 631198682..000000000 Binary files a/priv/static/adminfe/static/js/runtime.cb26bbd1.js.map and /dev/null differ -- cgit v1.2.3 From 8cf3a32463856f91a4e64a5cd33b5538b67c25c9 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 30 Apr 2020 00:49:59 +0300 Subject: Add exlude_replies to OpenAPI spec for account timelines --- lib/pleroma/web/api_spec/operations/account_operation.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 64e2e43c4..d3e8bd484 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -131,6 +131,7 @@ def statuses_operation do "Include statuses from muted acccounts." ), Operation.parameter(:exclude_reblogs, :query, BooleanLike, "Exclude reblogs"), + Operation.parameter(:exclude_replies, :query, BooleanLike, "Exclude replies"), Operation.parameter( :exclude_visibilities, :query, -- cgit v1.2.3 From ad2182bbd231b475c5bfc70485f35ad1f8841912 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 11:38:26 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex --- lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex index 4dafcda43..8e1b7af14 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest do properties: %{ content: %Schema{type: :string, description: "The content of your message"} }, + required: [:content], example: %{ "content" => "Hey wanna buy feet pics?" } -- cgit v1.2.3 From 64bb72f98a91261158b36e63f6c9634ac9f423a6 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 13:57:47 +0200 Subject: Typo fix. --- lib/pleroma/web/activity_pub/utils.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 2d685ecc0..1a3b0b3c1 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -512,7 +512,7 @@ def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do #### Announce-related helpers @doc """ - Retruns an existing announce activity if the notice has already been announced + Returns an existing announce activity if the notice has already been announced """ @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do -- cgit v1.2.3 From 42ce7c5164326aa577bc7bd18e98c5d0a9d6fea5 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 14:13:08 +0200 Subject: ObjectValidator: Add actor fetcher. --- lib/pleroma/web/activity_pub/object_validator.ex | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index f476c6f72..016f6e7a2 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -46,8 +46,14 @@ def stringify_keys(object) do |> Map.new(fn {key, val} -> {to_string(key), val} end) end + def fetch_actor(object) do + with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do + User.get_or_fetch_by_ap_id(actor) + end + end + def fetch_actor_and_object(object) do - User.get_or_fetch_by_ap_id(object["actor"]) + fetch_actor(object) Object.normalize(object["object"]) :ok end -- cgit v1.2.3 From bd219ba7e884d694cc1c8747f0b48cd646821222 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 14:14:00 +0200 Subject: Transmogrifier Tests: Extract deletion tests. --- .../transmogrifier/delete_handling_test.exs | 106 +++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 77 --------------- 2 files changed, 106 insertions(+), 77 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/delete_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs new file mode 100644 index 000000000..c15de5a95 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + import ExUnit.CaptureLog + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + test "it works for incoming deletes" do + activity = insert(:note_activity) + deleting_user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Poison.decode!() + + object = + data["object"] + |> Map.put("id", activity.data["object"]) + + data = + data + |> Map.put("object", object) + |> Map.put("actor", deleting_user.ap_id) + + {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} = + Transmogrifier.handle_incoming(data) + + assert id == data["id"] + + # We delete the Create activity because base our timelines on it. + # This should be changed after we unify objects and activities + refute Activity.get_by_id(activity.id) + assert actor == deleting_user.ap_id + + # Objects are replaced by a tombstone object. + object = Object.normalize(activity.data["object"]) + assert object.data["type"] == "Tombstone" + end + + test "it fails for incoming deletes with spoofed origin" do + activity = insert(:note_activity) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Poison.decode!() + + object = + data["object"] + |> Map.put("id", activity.data["object"]) + + data = + data + |> Map.put("object", object) + + assert capture_log(fn -> + :error = Transmogrifier.handle_incoming(data) + end) =~ + "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}" + + assert Activity.get_by_id(activity.id) + end + + @tag capture_log: true + test "it works for incoming user deletes" do + %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin") + + data = + File.read!("test/fixtures/mastodon-delete-user.json") + |> Poison.decode!() + + {:ok, _} = Transmogrifier.handle_incoming(data) + ObanHelpers.perform_all() + + refute User.get_cached_by_ap_id(ap_id) + end + + test "it fails for incoming user deletes with spoofed origin" do + %{ap_id: ap_id} = insert(:user) + + data = + File.read!("test/fixtures/mastodon-delete-user.json") + |> Poison.decode!() + |> Map.put("actor", ap_id) + + assert capture_log(fn -> + assert :error == Transmogrifier.handle_incoming(data) + end) =~ "Object containment failed" + + assert User.get_cached_by_ap_id(ap_id) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 6057e360a..64e56d378 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -822,83 +822,6 @@ test "it works for incoming update activities which lock the account" do assert user.locked == true end - test "it works for incoming deletes" do - activity = insert(:note_activity) - deleting_user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("id", activity.data["object"]) - - data = - data - |> Map.put("object", object) - |> Map.put("actor", deleting_user.ap_id) - - {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} = - Transmogrifier.handle_incoming(data) - - assert id == data["id"] - refute Activity.get_by_id(activity.id) - assert actor == deleting_user.ap_id - end - - test "it fails for incoming deletes with spoofed origin" do - activity = insert(:note_activity) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("id", activity.data["object"]) - - data = - data - |> Map.put("object", object) - - assert capture_log(fn -> - :error = Transmogrifier.handle_incoming(data) - end) =~ - "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}" - - assert Activity.get_by_id(activity.id) - end - - @tag capture_log: true - test "it works for incoming user deletes" do - %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin") - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Poison.decode!() - - {:ok, _} = Transmogrifier.handle_incoming(data) - ObanHelpers.perform_all() - - refute User.get_cached_by_ap_id(ap_id) - end - - test "it fails for incoming user deletes with spoofed origin" do - %{ap_id: ap_id} = insert(:user) - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Poison.decode!() - |> Map.put("actor", ap_id) - - assert capture_log(fn -> - assert :error == Transmogrifier.handle_incoming(data) - end) =~ "Object containment failed" - - assert User.get_cached_by_ap_id(ap_id) - end - test "it works for incoming unannounces with an existing notice" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) -- cgit v1.2.3 From 5839e67eb86d6d14b21222247ce8e113c3b26637 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 6 Feb 2020 18:01:12 +0300 Subject: return data only for updated emoji --- CHANGELOG.md | 7 +- docs/API/pleroma_api.md | 2 +- .../controllers/emoji_api_controller.ex | 89 ++++++----- test/instance_static/add/shortcode.png | Bin 0 -> 95 bytes .../controllers/emoji_api_controller_test.exs | 162 ++++++++++++--------- 5 files changed, 147 insertions(+), 113 deletions(-) create mode 100644 test/instance_static/add/shortcode.png diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f1bcf57..a220c14f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,13 +125,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    API Changes -- **Breaking** EmojiReactions: Change endpoints and responses to align with Mastodon -- **Breaking** Admin API: `PATCH /api/pleroma/admin/users/:nickname/force_password_reset` is now `PATCH /api/pleroma/admin/users/force_password_reset` (accepts `nicknames` array in the request body) +- **Breaking:** EmojiReactions: Change endpoints and responses to align with Mastodon +- **Breaking:** Admin API: `PATCH /api/pleroma/admin/users/:nickname/force_password_reset` is now `PATCH /api/pleroma/admin/users/force_password_reset` (accepts `nicknames` array in the request body) - **Breaking:** Admin API: Return link alongside with token on password reset - **Breaking:** Admin API: `PUT /api/pleroma/admin/reports/:id` is now `PATCH /api/pleroma/admin/reports`, see admin_api.md for details - **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string. -- **Breaking** replying to reports is now "report notes", enpoint changed from `POST /api/pleroma/admin/reports/:id/respond` to `POST /api/pleroma/admin/reports/:id/notes` +- **Breaking:** replying to reports is now "report notes", endpoint changed from `POST /api/pleroma/admin/reports/:id/respond` to `POST /api/pleroma/admin/reports/:id/notes` - Mastodon API: stopped sanitizing display names, field names and subject fields since they are supposed to be treated as plaintext +- **Breaking:** Pleroma API: `/api/pleroma/emoji/packs/:name/update_file` endpoint returns only updated emoji data. - Admin API: Return `total` when querying for reports - Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`) - Admin API: Return link alongside with token on password reset diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 90c43c356..a7c7731ce 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -357,7 +357,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * if the `action` is `update`, changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file (from the current filename to `new_filename`) * if the `action` is `remove`, removes the emoji named `shortcode` and it's associated file -* Response: JSON, updated "files" section of the pack and 200 status, 409 if the trying to use a shortcode +* Response: JSON, emoji shortcode with filename which was added/updated/deleted and 200 status, 409 if the trying to use a shortcode that is already taken, 400 if there was an error with the shortcode, filename or file (additional info in the "error" part of the response JSON) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index e01825b48..981bac4fa 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -385,23 +385,35 @@ defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do json(conn, new_data) end - defp get_filename(%{"filename" => filename}), do: filename + defp get_filename(%Plug.Upload{filename: filename}), do: filename + defp get_filename(url) when is_binary(url), do: Path.basename(url) - defp get_filename(%{"file" => file}) do - case file do - %Plug.Upload{filename: filename} -> filename - url when is_binary(url) -> Path.basename(url) + defp empty?(str), do: String.trim(str) == "" + + defp update_pack_file(updated_full_pack, pack_file_p) do + content = Jason.encode!(updated_full_pack, pretty: true) + + File.write!(pack_file_p, content) + end + + defp create_subdirs(file_path) do + if String.contains?(file_path, "/") do + file_path + |> Path.dirname() + |> File.mkdir_p!() end end - defp empty?(str), do: String.trim(str) == "" + defp pack_info(pack_name) do + dir = Path.join(emoji_dir_path(), pack_name) + json_path = Path.join(dir, "pack.json") - defp update_file_and_send(conn, updated_full_pack, pack_file_p) do - # Write the emoji pack file - File.write!(pack_file_p, Jason.encode!(updated_full_pack, pretty: true)) + json = + json_path + |> File.read!() + |> Jason.decode!() - # Return the modified file list - json(conn, updated_full_pack["files"]) + {dir, json_path, json} end @doc """ @@ -422,23 +434,25 @@ defp update_file_and_send(conn, updated_full_pack, pack_file_p) do # Add def update_file( conn, - %{"pack_name" => pack_name, "action" => "add", "shortcode" => shortcode} = params + %{"pack_name" => pack_name, "action" => "add"} = params ) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") + shortcode = + if params["shortcode"] do + params["shortcode"] + else + filename = get_filename(params["file"]) + Path.basename(filename, Path.extname(filename)) + end - full_pack = Jason.decode!(File.read!(pack_file_p)) + {pack_dir, pack_file_p, full_pack} = pack_info(pack_name) with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - filename <- get_filename(params), + filename <- params["filename"] || get_filename(params["file"]), false <- empty?(shortcode), - false <- empty?(filename) do - file_path = Path.join(pack_dir, filename) - + false <- empty?(filename), + file_path <- Path.join(pack_dir, filename) do # If the name contains directories, create them - if String.contains?(file_path, "/") do - File.mkdir_p!(Path.dirname(file_path)) - end + create_subdirs(file_path) case params["file"] do %Plug.Upload{path: upload_path} -> @@ -451,8 +465,11 @@ def update_file( File.write!(file_path, file_contents) end - updated_full_pack = put_in(full_pack, ["files", shortcode], filename) - update_file_and_send(conn, updated_full_pack, pack_file_p) + full_pack + |> put_in(["files", shortcode], filename) + |> update_pack_file(pack_file_p) + + json(conn, %{shortcode => filename}) else {:has_shortcode, _} -> conn @@ -472,10 +489,7 @@ def update_file(conn, %{ "action" => "remove", "shortcode" => shortcode }) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) + {pack_dir, pack_file_p, full_pack} = pack_info(pack_name) if Map.has_key?(full_pack["files"], shortcode) do {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) @@ -494,7 +508,8 @@ def update_file(conn, %{ end end - update_file_and_send(conn, updated_full_pack, pack_file_p) + update_pack_file(updated_full_pack, pack_file_p) + json(conn, %{shortcode => full_pack["files"][shortcode]}) else conn |> put_status(:bad_request) @@ -507,10 +522,7 @@ def update_file( conn, %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params ) do - pack_dir = Path.join(emoji_dir_path(), pack_name) - pack_file_p = Path.join(pack_dir, "pack.json") - - full_pack = Jason.decode!(File.read!(pack_file_p)) + {pack_dir, pack_file_p, full_pack} = pack_info(pack_name) with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params, @@ -522,9 +534,7 @@ def update_file( new_emoji_file_path = Path.join(pack_dir, new_filename) # If the name contains directories, create them - if String.contains?(new_emoji_file_path, "/") do - File.mkdir_p!(Path.dirname(new_emoji_file_path)) - end + create_subdirs(new_emoji_file_path) # Move/Rename the old filename to a new filename # These are probably on the same filesystem, so just rename should work @@ -540,8 +550,11 @@ def update_file( end # Then, put in the new shortcode with the new path - updated_full_pack = put_in(updated_full_pack, ["files", new_shortcode], new_filename) - update_file_and_send(conn, updated_full_pack, pack_file_p) + updated_full_pack + |> put_in(["files", new_shortcode], new_filename) + |> update_pack_file(pack_file_p) + + json(conn, %{new_shortcode => new_filename}) else {:has_shortcode, _} -> conn diff --git a/test/instance_static/add/shortcode.png b/test/instance_static/add/shortcode.png new file mode 100644 index 000000000..8f50fa023 Binary files /dev/null and b/test/instance_static/add/shortcode.png differ diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 4246eb400..6844601d7 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -295,96 +295,116 @@ test "when the fallback source doesn't have all the files", ctx do end end - test "updating pack files" do - pack_file = "#{@emoji_dir_path}/test_pack/pack.json" - original_content = File.read!(pack_file) + describe "update_file/2" do + setup do + pack_file = "#{@emoji_dir_path}/test_pack/pack.json" + original_content = File.read!(pack_file) - on_exit(fn -> - File.write!(pack_file, original_content) + on_exit(fn -> + File.write!(pack_file, original_content) + end) - File.rm_rf!("#{@emoji_dir_path}/test_pack/blank_url.png") - File.rm_rf!("#{@emoji_dir_path}/test_pack/dir") - File.rm_rf!("#{@emoji_dir_path}/test_pack/dir_2") - end) + admin = insert(:user, is_admin: true) + %{conn: conn} = oauth_access(["admin:write"], user: admin) + {:ok, conn: conn} + end - admin = insert(:user, is_admin: true) - %{conn: conn} = oauth_access(["admin:write"], user: admin) + test "update file without shortcode", %{conn: conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_dir_path}/test_pack/shortcode.png") end) + + assert conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "file" => %Plug.Upload{ + filename: "shortcode.png", + path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" + } + }) + |> json_response(200) == %{"shortcode" => "shortcode.png"} + end + + test "updating pack files", %{conn: conn} do + on_exit(fn -> + File.rm_rf!("#{@emoji_dir_path}/test_pack/blank_url.png") + File.rm_rf!("#{@emoji_dir_path}/test_pack/dir") + File.rm_rf!("#{@emoji_dir_path}/test_pack/dir_2") + end) - same_name = %{ - "action" => "add", - "shortcode" => "blank", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_dir_path}/test_pack/blank.png" + same_name = %{ + "action" => "add", + "shortcode" => "blank", + "filename" => "dir/blank.png", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_dir_path}/test_pack/blank.png" + } } - } - different_name = %{same_name | "shortcode" => "blank_2"} + different_name = %{same_name | "shortcode" => "blank_2"} - assert (conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), same_name) - |> json_response(:conflict))["error"] =~ "already exists" + assert (conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), same_name) + |> json_response(:conflict))["error"] =~ "already exists" - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), different_name) - |> json_response(200) == %{"blank" => "blank.png", "blank_2" => "dir/blank.png"} + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), different_name) + |> json_response(200) == %{"blank_2" => "dir/blank.png"} - assert File.exists?("#{@emoji_dir_path}/test_pack/dir/blank.png") + assert File.exists?("#{@emoji_dir_path}/test_pack/dir/blank.png") - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ - "action" => "update", - "shortcode" => "blank_2", - "new_shortcode" => "blank_3", - "new_filename" => "dir_2/blank_3.png" - }) - |> json_response(200) == %{"blank" => "blank.png", "blank_3" => "dir_2/blank_3.png"} + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + "action" => "update", + "shortcode" => "blank_2", + "new_shortcode" => "blank_3", + "new_filename" => "dir_2/blank_3.png" + }) + |> json_response(200) == %{"blank_3" => "dir_2/blank_3.png"} - refute File.exists?("#{@emoji_dir_path}/test_pack/dir/") - assert File.exists?("#{@emoji_dir_path}/test_pack/dir_2/blank_3.png") + refute File.exists?("#{@emoji_dir_path}/test_pack/dir/") + assert File.exists?("#{@emoji_dir_path}/test_pack/dir_2/blank_3.png") - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ - "action" => "remove", - "shortcode" => "blank_3" - }) - |> json_response(200) == %{"blank" => "blank.png"} + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + "action" => "remove", + "shortcode" => "blank_3" + }) + |> json_response(200) == %{"blank_3" => "dir_2/blank_3.png"} - refute File.exists?("#{@emoji_dir_path}/test_pack/dir_2/") + refute File.exists?("#{@emoji_dir_path}/test_pack/dir_2/") - mock(fn - %{ - method: :get, - url: "https://test-blank/blank_url.png" - } -> - text(File.read!("#{@emoji_dir_path}/test_pack/blank.png")) - end) + mock(fn + %{ + method: :get, + url: "https://test-blank/blank_url.png" + } -> + text(File.read!("#{@emoji_dir_path}/test_pack/blank.png")) + end) - # The name should be inferred from the URL ending - from_url = %{ - "action" => "add", - "shortcode" => "blank_url", - "file" => "https://test-blank/blank_url.png" - } + # The name should be inferred from the URL ending + from_url = %{ + "action" => "add", + "shortcode" => "blank_url", + "file" => "https://test-blank/blank_url.png" + } - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), from_url) - |> json_response(200) == %{ - "blank" => "blank.png", - "blank_url" => "blank_url.png" - } + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), from_url) + |> json_response(200) == %{ + "blank_url" => "blank_url.png" + } - assert File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + assert File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ - "action" => "remove", - "shortcode" => "blank_url" - }) - |> json_response(200) == %{"blank" => "blank.png"} + assert conn + |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + "action" => "remove", + "shortcode" => "blank_url" + }) + |> json_response(200) == %{"blank_url" => "blank_url.png"} - refute File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + refute File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + end end test "creating and deleting a pack" do -- cgit v1.2.3 From db184a8eb495865334f47a24f8c5b1fec65450b6 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 14:37:14 +0200 Subject: DeleteValidator: Mastodon sends unaddressed deletes. --- .../web/activity_pub/object_validators/delete_validator.ex | 1 - test/web/activity_pub/object_validator_test.exs | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index 8dd5c19ad..0eb31451c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -32,7 +32,6 @@ def validate_data(cng) do |> validate_inclusion(:type, ["Delete"]) |> validate_same_domain() |> validate_object_presence() - |> validate_recipients_presence() end def validate_same_domain(cng) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 64b9ee1ec..ab26d3501 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -59,20 +59,6 @@ test "it's invalid if the actor of the object and the actor of delete are from d assert {:actor, {"is not allowed to delete object", []}} in cng.errors end - - test "it's invalid if all the recipient fields are empty", %{ - valid_post_delete: valid_post_delete - } do - empty_recipients = - valid_post_delete - |> Map.put("to", []) - |> Map.put("cc", []) - - {:error, cng} = ObjectValidator.validate(empty_recipients, []) - - assert {:to, {"no recipients in any field", []}} in cng.errors - assert {:cc, {"no recipients in any field", []}} in cng.errors - end end describe "likes" do -- cgit v1.2.3 From 342f55fb92c723acf7f53de2dae390b72051c94b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 28 Mar 2020 13:34:32 +0300 Subject: refactor emoji api with fixes --- lib/pleroma/emoji/pack.ex | 509 ++++++++++++ .../controllers/emoji_api_controller.ex | 612 ++++----------- lib/pleroma/web/router.ex | 3 +- test/instance_static/emoji/pack_bad_sha/blank.png | Bin 0 -> 95 bytes test/instance_static/emoji/pack_bad_sha/pack.json | 13 + .../emoji/pack_bad_sha/pack_bad_sha.zip | Bin 0 -> 256 bytes test/instance_static/emoji/test_pack/pack.json | 14 +- .../emoji/test_pack_nonshared/pack.json | 5 +- .../controllers/emoji_api_controller_test.exs | 866 ++++++++++++++------- 9 files changed, 1285 insertions(+), 737 deletions(-) create mode 100644 lib/pleroma/emoji/pack.ex create mode 100644 test/instance_static/emoji/pack_bad_sha/blank.png create mode 100644 test/instance_static/emoji/pack_bad_sha/pack.json create mode 100644 test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex new file mode 100644 index 000000000..21ed12c78 --- /dev/null +++ b/lib/pleroma/emoji/pack.ex @@ -0,0 +1,509 @@ +defmodule Pleroma.Emoji.Pack do + @derive {Jason.Encoder, only: [:files, :pack]} + defstruct files: %{}, + pack_file: nil, + path: nil, + pack: %{}, + name: nil + + @type t() :: %__MODULE__{ + files: %{String.t() => Path.t()}, + pack_file: Path.t(), + path: Path.t(), + pack: map(), + name: String.t() + } + + alias Pleroma.Emoji + + @spec emoji_path() :: Path.t() + def emoji_path do + static = Pleroma.Config.get!([:instance, :static_dir]) + Path.join(static, "emoji") + end + + @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} + def create(name) when byte_size(name) > 0 do + dir = Path.join(emoji_path(), name) + + with :ok <- File.mkdir(dir) do + %__MODULE__{ + pack_file: Path.join(dir, "pack.json") + } + |> save_pack() + end + end + + def create(_), do: {:error, :empty_values} + + @spec show(String.t()) :: {:ok, t()} | {:loaded, nil} | {:error, :empty_values} + def show(name) when byte_size(name) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, pack} <- validate_pack(pack) do + {:ok, pack} + end + end + + def show(_), do: {:error, :empty_values} + + @spec delete(String.t()) :: + {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} + def delete(name) when byte_size(name) > 0 do + emoji_path() + |> Path.join(name) + |> File.rm_rf() + end + + def delete(_), do: {:error, :empty_values} + + @spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def add_file(name, shortcode, filename, file) + when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(filename) > 0 do + with {_, nil} <- {:exists, Emoji.get(shortcode)}, + {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)} do + file_path = Path.join(pack.path, filename) + + create_subdirs(file_path) + + case file do + %Plug.Upload{path: upload_path} -> + # Copy the uploaded file from the temporary directory + File.copy!(upload_path, file_path) + + url when is_binary(url) -> + # Download and write the file + file_contents = Tesla.get!(url).body + File.write!(file_path, file_contents) + end + + files = Map.put(pack.files, shortcode, filename) + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def add_file(_, _, _, _), do: {:error, :empty_values} + + defp create_subdirs(file_path) do + if String.contains?(file_path, "/") do + file_path + |> Path.dirname() + |> File.mkdir_p!() + end + end + + @spec remove_file(String.t(), String.t()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def remove_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, {filename, files}} when not is_nil(filename) <- + {:exists, Map.pop(pack.files, shortcode)}, + emoji <- Path.join(pack.path, filename), + {_, true} <- {:exists, File.exists?(emoji)} do + emoji_dir = Path.dirname(emoji) + + File.rm!(emoji) + + if String.contains?(filename, "/") and File.ls!(emoji_dir) == [] do + File.rmdir!(emoji_dir) + end + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def remove_file(_, _), do: {:error, :empty_values} + + @spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) :: + {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} + def update_file(name, shortcode, new_shortcode, new_filename, force) + when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(new_shortcode) > 0 and + byte_size(new_filename) > 0 do + with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, + {_, {filename, files}} when not is_nil(filename) <- + {:exists, Map.pop(pack.files, shortcode)}, + {_, true} <- {:not_used, force or is_nil(Emoji.get(new_shortcode))} do + old_path = Path.join(pack.path, filename) + old_dir = Path.dirname(old_path) + new_path = Path.join(pack.path, new_filename) + + create_subdirs(new_path) + + :ok = File.rename(old_path, new_path) + + if String.contains?(filename, "/") and File.ls!(old_dir) == [] do + File.rmdir!(old_dir) + end + + files = Map.put(files, new_shortcode, new_filename) + + updated_pack = %{pack | files: files} + + case save_pack(updated_pack) do + :ok -> + Emoji.reload() + {:ok, updated_pack} + + e -> + e + end + end + end + + def update_file(_, _, _, _, _), do: {:error, :empty_values} + + @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, atom()} + def import_from_filesystem do + emoji_path = emoji_path() + + with {:ok, %{access: :read_write}} <- File.stat(emoji_path), + {:ok, results} <- File.ls(emoji_path) do + names = + results + |> Enum.map(&Path.join(emoji_path, &1)) + |> Enum.reject(fn path -> + File.dir?(path) and File.exists?(Path.join(path, "pack.json")) + end) + |> Enum.map(&write_pack_contents/1) + |> Enum.filter(& &1) + + {:ok, names} + else + {:ok, %{access: _}} -> {:error, :not_writable} + e -> e + end + end + + defp write_pack_contents(path) do + pack = %__MODULE__{ + files: files_from_path(path), + path: path, + pack_file: Path.join(path, "pack.json") + } + + case save_pack(pack) do + :ok -> Path.basename(path) + _ -> nil + end + end + + defp files_from_path(path) do + txt_path = Path.join(path, "emoji.txt") + + if File.exists?(txt_path) do + # There's an emoji.txt file, it's likely from a pack installed by the pack manager. + # Make a pack.json file from the contents of that emoji.txt file + + # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 + + # Create a map of shortcodes to filenames from emoji.txt + File.read!(txt_path) + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.map(fn line -> + case String.split(line, ~r/,\s*/) do + # This matches both strings with and without tags + # and we don't care about tags here + [name, file | _] -> + file_dir_name = Path.dirname(file) + + file = + if String.ends_with?(path, file_dir_name) do + Path.basename(file) + else + file + end + + {name, file} + + _ -> + nil + end + end) + |> Enum.filter(& &1) + |> Enum.into(%{}) + else + # If there's no emoji.txt, assume all files + # that are of certain extensions from the config are emojis and import them all + pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) + Emoji.Loader.make_shortcode_to_file_map(path, pack_extensions) + end + end + + @spec list_remote_packs(String.t()) :: {:ok, map()} + def list_remote_packs(url) do + uri = + url + |> String.trim() + |> URI.parse() + + with {_, true} <- {:shareable, shareable_packs_available?(uri)} do + packs = + uri + |> URI.merge("/api/pleroma/emoji/packs") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + + {:ok, packs} + end + end + + @spec list_local_packs() :: {:ok, map()} + def list_local_packs do + emoji_path = emoji_path() + + # Create the directory first if it does not exist. This is probably the first request made + # with the API so it should be sufficient + with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do + packs = + results + |> Enum.map(&load_pack/1) + |> Enum.filter(& &1) + |> Enum.map(&validate_pack/1) + |> Map.new() + + {:ok, packs} + end + end + + defp validate_pack(pack) do + if downloadable?(pack) do + archive = fetch_archive(pack) + archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() + + info = + pack.pack + |> Map.put("can-download", true) + |> Map.put("download-sha256", archive_sha) + + {pack.name, Map.put(pack, :pack, info)} + else + info = Map.put(pack.pack, "can-download", false) + {pack.name, Map.put(pack, :pack, info)} + end + end + + defp downloadable?(pack) do + # If the pack is set as shared, check if it can be downloaded + # That means that when asked, the pack can be packed and sent to the remote + # Otherwise, they'd have to download it from external-src + pack.pack["share-files"] && + Enum.all?(pack.files, fn {_, file} -> + File.exists?(Path.join(pack.path, file)) + end) + end + + @spec download(String.t()) :: {:ok, binary()} + def download(name) do + with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)}, + {_, true} <- {:can_download?, downloadable?(pack)} do + {:ok, fetch_archive(pack)} + end + end + + defp fetch_archive(pack) do + hash = :crypto.hash(:md5, File.read!(pack.pack_file)) + + case Cachex.get!(:emoji_packs_cache, pack.name) do + %{hash: ^hash, pack_data: archive} -> + archive + + _ -> + create_archive_and_cache(pack, hash) + end + end + + defp create_archive_and_cache(pack, hash) do + files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] + + {:ok, {_, result}} = + :zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)]) + + ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) + overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) + + Cachex.put!( + :emoji_packs_cache, + pack.name, + # if pack.json MD5 changes, the cache is not valid anymore + %{hash: hash, pack_data: result}, + # Add a minute to cache time for every file in the pack + ttl: overall_ttl + ) + + result + end + + @spec download_from_source(String.t(), String.t(), String.t()) :: :ok + def download_from_source(name, url, as) do + uri = + url + |> String.trim() + |> URI.parse() + + with {_, true} <- {:shareable, shareable_packs_available?(uri)} do + # TODO: why do we load all packs, if we know the name of pack we need + remote_pack = + uri + |> URI.merge("/api/pleroma/emoji/packs/#{name}") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + + result = + case remote_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + url: + URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/download_shared") |> to_string() + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + url: src, + fallback: true + }} + + _ -> + {:error, + "The pack was not set as shared and there is no fallback src to download from"} + end + + with {:ok, %{sha: sha, url: url} = pinfo} <- result, + %{body: archive} <- Tesla.get!(url), + {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, archive)} do + local_name = as || name + + path = Path.join(emoji_path(), local_name) + + pack = %__MODULE__{ + name: local_name, + path: path, + files: remote_pack["files"], + pack_file: Path.join(path, "pack.json") + } + + File.mkdir_p!(pack.path) + + files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) + # Fallback cannot contain a pack.json file + files = if pinfo[:fallback], do: files, else: ['pack.json' | files] + + {:ok, _} = :zip.unzip(archive, cwd: to_charlist(pack.path), file_list: files) + + # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 + # in it to depend on itself + if pinfo[:fallback] do + save_pack(pack) + end + + :ok + end + end + end + + defp save_pack(pack), do: File.write(pack.pack_file, Jason.encode!(pack, pretty: true)) + + @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()} + def save_metadata(metadata, %__MODULE__{} = pack) do + pack = Map.put(pack, :pack, metadata) + + with :ok <- save_pack(pack) do + {:ok, pack} + end + end + + @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()} + def update_metadata(name, data) do + pack = load_pack(name) + + fb_sha_changed? = + not is_nil(data["fallback-src"]) and data["fallback-src"] != pack.pack["fallback-src"] + + with {_, true} <- {:update?, fb_sha_changed?}, + {:ok, %{body: zip}} <- Tesla.get(data["fallback-src"]), + {:ok, f_list} <- :zip.unzip(zip, [:memory]), + {_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do + fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16() + + data + |> Map.put("fallback-src-sha256", fallback_sha) + |> save_metadata(pack) + else + {:update?, _} -> save_metadata(data, pack) + e -> e + end + end + + # Check if all files from the pack.json are in the archive + defp has_all_files?(files, f_list) do + Enum.all?(files, fn {_, from_manifest} -> + List.keyfind(f_list, to_charlist(from_manifest), 0) + end) + end + + @spec load_pack(String.t()) :: t() | nil + def load_pack(name) do + pack_file = Path.join([emoji_path(), name, "pack.json"]) + + if File.exists?(pack_file) do + pack_file + |> File.read!() + |> from_json() + |> Map.put(:pack_file, pack_file) + |> Map.put(:path, Path.dirname(pack_file)) + |> Map.put(:name, name) + end + end + + defp from_json(json) do + map = Jason.decode!(json) + + struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) + end + + defp shareable_packs_available?(uri) do + uri + |> URI.merge("/.well-known/nodeinfo") + |> to_string() + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> Map.get("links") + |> List.last() + |> Map.get("href") + # Get the actual nodeinfo address and fetch it + |> Tesla.get!() + |> Map.get(:body) + |> Jason.decode!() + |> get_in(["metadata", "features"]) + |> Enum.member?("shareable_emoji_packs") + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index 981bac4fa..9fa857474 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -1,18 +1,15 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do use Pleroma.Web, :controller - alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug - alias Pleroma.Plugs.OAuthScopesPlug - - require Logger + alias Pleroma.Emoji.Pack plug( - OAuthScopesPlug, + Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"], admin: true} when action in [ :create, :delete, - :save_from, + :download_from, :import_from_fs, :update_file, :update_metadata @@ -21,17 +18,10 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do plug( :skip_plug, - [OAuthScopesPlug, ExpectPublicOrAuthenticatedCheckPlug] + [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] when action in [:download_shared, :list_packs, :list_from] ) - defp emoji_dir_path do - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) - end - @doc """ Lists packs from the remote instance. @@ -39,17 +29,13 @@ defp emoji_dir_path do be done by the server """ def list_from(conn, %{"instance_address" => address}) do - address = String.trim(address) - - if shareable_packs_available(address) do - list_resp = - "#{address}/api/pleroma/emoji/packs" |> Tesla.get!() |> Map.get(:body) |> Jason.decode!() - - json(conn, list_resp) + with {:ok, packs} <- Pack.list_remote_packs(address) do + json(conn, packs) else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) + {:shareable, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) end end @@ -60,113 +46,44 @@ def list_from(conn, %{"instance_address" => address}) do a map of "pack directory name" to pack.json contents. """ def list_packs(conn, _params) do - # Create the directory first if it does not exist. This is probably the first request made - # with the API so it should be sufficient - with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_dir_path())}, - {:ls, {:ok, results}} <- {:ls, File.ls(emoji_dir_path())} do - pack_infos = - results - |> Enum.filter(&has_pack_json?/1) - |> Enum.map(&load_pack/1) - # Check if all the files are in place and can be sent - |> Enum.map(&validate_pack/1) - # Transform into a map of pack-name => pack-data - |> Enum.into(%{}) - - json(conn, pack_infos) + emoji_path = + Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + + with {:ok, packs} <- Pack.list_local_packs() do + json(conn, packs) else {:create_dir, {:error, e}} -> conn |> put_status(:internal_server_error) - |> json(%{error: "Failed to create the emoji pack directory at #{emoji_dir_path()}: #{e}"}) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"}) {:ls, {:error, e}} -> conn |> put_status(:internal_server_error) |> json(%{ - error: - "Failed to get the contents of the emoji pack directory at #{emoji_dir_path()}: #{e}" + error: "Failed to get the contents of the emoji pack directory at #{emoji_path}: #{e}" }) end end - defp has_pack_json?(file) do - dir_path = Path.join(emoji_dir_path(), file) - # Filter to only use the pack.json packs - File.dir?(dir_path) and File.exists?(Path.join(dir_path, "pack.json")) - end - - defp load_pack(pack_name) do - pack_path = Path.join(emoji_dir_path(), pack_name) - pack_file = Path.join(pack_path, "pack.json") - - {pack_name, Jason.decode!(File.read!(pack_file))} - end - - defp validate_pack({name, pack}) do - pack_path = Path.join(emoji_dir_path(), name) - - if can_download?(pack, pack_path) do - archive_for_sha = make_archive(name, pack, pack_path) - archive_sha = :crypto.hash(:sha256, archive_for_sha) |> Base.encode16() + def show(conn, %{"name" => name}) do + name = String.trim(name) - pack = - pack - |> put_in(["pack", "can-download"], true) - |> put_in(["pack", "download-sha256"], archive_sha) - - {name, pack} + with {:ok, pack} <- Pack.show(name) do + json(conn, pack) else - {name, put_in(pack, ["pack", "can-download"], false)} - end - end - - defp can_download?(pack, pack_path) do - # If the pack is set as shared, check if it can be downloaded - # That means that when asked, the pack can be packed and sent to the remote - # Otherwise, they'd have to download it from external-src - pack["pack"]["share-files"] && - Enum.all?(pack["files"], fn {_, path} -> - File.exists?(Path.join(pack_path, path)) - end) - end - - defp create_archive_and_cache(name, pack, pack_dir, md5) do - files = - ['pack.json'] ++ - (pack["files"] |> Enum.map(fn {_, path} -> to_charlist(path) end)) - - {:ok, {_, zip_result}} = :zip.zip('#{name}.zip', files, [:memory, cwd: to_charlist(pack_dir)]) - - cache_seconds_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) - cache_ms = :timer.seconds(cache_seconds_per_file * Enum.count(files)) - - Cachex.put!( - :emoji_packs_cache, - name, - # if pack.json MD5 changes, the cache is not valid anymore - %{pack_json_md5: md5, pack_data: zip_result}, - # Add a minute to cache time for every file in the pack - ttl: cache_ms - ) - - Logger.debug("Created an archive for the '#{name}' emoji pack, \ -keeping it in cache for #{div(cache_ms, 1000)}s") - - zip_result - end - - defp make_archive(name, pack, pack_dir) do - # Having a different pack.json md5 invalidates cache - pack_file_md5 = :crypto.hash(:md5, File.read!(Path.join(pack_dir, "pack.json"))) - - case Cachex.get!(:emoji_packs_cache, name) do - %{pack_file_md5: ^pack_file_md5, pack_data: zip_result} -> - Logger.debug("Using cache for the '#{name}' shared emoji pack") - zip_result + {:loaded, _} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) - _ -> - create_archive_and_cache(name, pack, pack_dir, pack_file_md5) + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) end end @@ -175,21 +92,15 @@ defp make_archive(name, pack, pack_dir) do to download packs that the instance shares. """ def download_shared(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) - pack_file = Path.join(pack_dir, "pack.json") - - with {_, true} <- {:exists?, File.exists?(pack_file)}, - pack = Jason.decode!(File.read!(pack_file)), - {_, true} <- {:can_download?, can_download?(pack, pack_dir)} do - zip_result = make_archive(name, pack, pack_dir) - send_download(conn, {:binary, zip_result}, filename: "#{name}.zip") + with {:ok, archive} <- Pack.download(name) do + send_download(conn, {:binary, archive}, filename: "#{name}.zip") else {:can_download?, _} -> conn |> put_status(:forbidden) |> json(%{ - error: "Pack #{name} cannot be downloaded from this instance, either pack sharing\ - was disabled for this pack or some files are missing" + error: + "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" }) {:exists?, _} -> @@ -199,22 +110,6 @@ def download_shared(conn, %{"name" => name}) do end end - defp shareable_packs_available(address) do - "#{address}/.well-known/nodeinfo" - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get("links") - |> List.last() - |> Map.get("href") - # Get the actual nodeinfo address and fetch it - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> get_in(["metadata", "features"]) - |> Enum.member?("shareable_emoji_packs") - end - @doc """ An admin endpoint to request downloading and storing a pack named `pack_name` from the instance `instance_address`. @@ -222,74 +117,24 @@ defp shareable_packs_available(address) do If the requested instance's admin chose to share the pack, it will be downloaded from that instance, otherwise it will be downloaded from the fallback source, if there is one. """ - def save_from(conn, %{"instance_address" => address, "pack_name" => name} = data) do - address = String.trim(address) - - if shareable_packs_available(address) do - full_pack = - "#{address}/api/pleroma/emoji/packs/list" - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get(name) - - pack_info_res = - case full_pack["pack"] do - %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> - {:ok, - %{ - sha: sha, - uri: "#{address}/api/pleroma/emoji/packs/download_shared/#{name}" - }} - - %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> - {:ok, - %{ - sha: sha, - uri: src, - fallback: true - }} - - _ -> - {:error, - "The pack was not set as shared and there is no fallback src to download from"} - end - - with {:ok, %{sha: sha, uri: uri} = pinfo} <- pack_info_res, - %{body: emoji_archive} <- Tesla.get!(uri), - {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, emoji_archive)} do - local_name = data["as"] || name - pack_dir = Path.join(emoji_dir_path(), local_name) - File.mkdir_p!(pack_dir) - - files = Enum.map(full_pack["files"], fn {_, path} -> to_charlist(path) end) - # Fallback cannot contain a pack.json file - files = if pinfo[:fallback], do: files, else: ['pack.json'] ++ files - - {:ok, _} = :zip.unzip(emoji_archive, cwd: to_charlist(pack_dir), file_list: files) - - # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 - # in it to depend on itself - if pinfo[:fallback] do - pack_file_path = Path.join(pack_dir, "pack.json") - - File.write!(pack_file_path, Jason.encode!(full_pack, pretty: true)) - end - - json(conn, "ok") - else - {:error, e} -> - conn |> put_status(:internal_server_error) |> json(%{error: e}) - - {:checksum, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) - end + def download_from(conn, %{"instance_address" => address, "pack_name" => name} = params) do + with :ok <- Pack.download_from_source(name, address, params["as"]) do + json(conn, "ok") else - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) + {:shareable, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + + {:checksum, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) + + {:error, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: e}) end end @@ -297,23 +142,27 @@ def save_from(conn, %{"instance_address" => address, "pack_name" => name} = data Creates an empty pack named `name` which then can be updated via the admin UI. """ def create(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) + name = String.trim(name) - if not File.exists?(pack_dir) do - File.mkdir_p!(pack_dir) - - pack_file_p = Path.join(pack_dir, "pack.json") + with :ok <- Pack.create(name) do + json(conn, "ok") + else + {:error, :eexist} -> + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) - File.write!( - pack_file_p, - Jason.encode!(%{pack: %{}, files: %{}}, pretty: true) - ) + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) - conn |> json("ok") - else - conn - |> put_status(:conflict) - |> json(%{error: "A pack named \"#{name}\" already exists"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while creating pack." + ) end end @@ -321,11 +170,20 @@ def create(conn, %{"name" => name}) do Deletes the pack `name` and all it's files. """ def delete(conn, %{"name" => name}) do - pack_dir = Path.join(emoji_dir_path(), name) + name = String.trim(name) + + with {:ok, deleted} when deleted != [] <- Pack.delete(name) do + json(conn, "ok") + else + {:ok, []} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) - case File.rm_rf(pack_dir) do - {:ok, _} -> - conn |> json("ok") + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) {:error, _, _} -> conn @@ -340,82 +198,23 @@ def delete(conn, %{"name" => name}) do `new_data` is the new metadata for the pack, that will replace the old metadata. """ def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do - pack_file_p = Path.join([emoji_dir_path(), name, "pack.json"]) - - full_pack = Jason.decode!(File.read!(pack_file_p)) - - # The new fallback-src is in the new data and it's not the same as it was in the old data - should_update_fb_sha = - not is_nil(new_data["fallback-src"]) and - new_data["fallback-src"] != full_pack["pack"]["fallback-src"] - - with {_, true} <- {:should_update?, should_update_fb_sha}, - %{body: pack_arch} <- Tesla.get!(new_data["fallback-src"]), - {:ok, flist} <- :zip.unzip(pack_arch, [:memory]), - {_, true} <- {:has_all_files?, has_all_files?(full_pack, flist)} do - fallback_sha = :crypto.hash(:sha256, pack_arch) |> Base.encode16() - - new_data = Map.put(new_data, "fallback-src-sha256", fallback_sha) - update_metadata_and_send(conn, full_pack, new_data, pack_file_p) + with {:ok, pack} <- Pack.update_metadata(name, new_data) do + json(conn, pack.pack) else - {:should_update?, _} -> - update_metadata_and_send(conn, full_pack, new_data, pack_file_p) - {:has_all_files?, _} -> conn |> put_status(:bad_request) |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) - end - end - - # Check if all files from the pack.json are in the archive - defp has_all_files?(%{"files" => files}, flist) do - Enum.all?(files, fn {_, from_manifest} -> - Enum.find(flist, fn {from_archive, _} -> - to_string(from_archive) == from_manifest - end) - end) - end - - defp update_metadata_and_send(conn, full_pack, new_data, pack_file_p) do - full_pack = Map.put(full_pack, "pack", new_data) - File.write!(pack_file_p, Jason.encode!(full_pack, pretty: true)) - - # Send new data back with fallback sha filled - json(conn, new_data) - end - - defp get_filename(%Plug.Upload{filename: filename}), do: filename - defp get_filename(url) when is_binary(url), do: Path.basename(url) - - defp empty?(str), do: String.trim(str) == "" - - defp update_pack_file(updated_full_pack, pack_file_p) do - content = Jason.encode!(updated_full_pack, pretty: true) - - File.write!(pack_file_p, content) - end - defp create_subdirs(file_path) do - if String.contains?(file_path, "/") do - file_path - |> Path.dirname() - |> File.mkdir_p!() + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating pack metadata." + ) end end - defp pack_info(pack_name) do - dir = Path.join(emoji_dir_path(), pack_name) - json_path = Path.join(dir, "pack.json") - - json = - json_path - |> File.read!() - |> Jason.decode!() - - {dir, json_path, json} - end - @doc """ Updates a file in a pack. @@ -436,50 +235,33 @@ def update_file( conn, %{"pack_name" => pack_name, "action" => "add"} = params ) do - shortcode = - if params["shortcode"] do - params["shortcode"] - else - filename = get_filename(params["file"]) - Path.basename(filename, Path.extname(filename)) - end - - {pack_dir, pack_file_p, full_pack} = pack_info(pack_name) - - with {_, false} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - filename <- params["filename"] || get_filename(params["file"]), - false <- empty?(shortcode), - false <- empty?(filename), - file_path <- Path.join(pack_dir, filename) do - # If the name contains directories, create them - create_subdirs(file_path) - - case params["file"] do - %Plug.Upload{path: upload_path} -> - # Copy the uploaded file from the temporary directory - File.copy!(upload_path, file_path) - - url when is_binary(url) -> - # Download and write the file - file_contents = Tesla.get!(url).body - File.write!(file_path, file_contents) - end - - full_pack - |> put_in(["files", shortcode], filename) - |> update_pack_file(pack_file_p) - - json(conn, %{shortcode => filename}) + filename = params["filename"] || get_filename(params["file"]) + shortcode = params["shortcode"] || Path.basename(filename, Path.extname(filename)) + + with {:ok, pack} <- Pack.add_file(pack_name, shortcode, filename, params["file"]) do + json(conn, pack.files) else - {:has_shortcode, _} -> + {:exists, _} -> conn |> put_status(:conflict) |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) - true -> + {:loaded, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{pack_name}\" is not found"}) + + {:error, :empty_values} -> conn |> put_status(:bad_request) - |> json(%{error: "shortcode or filename cannot be empty"}) + |> json(%{error: "pack name, shortcode or filename cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while adding file to pack." + ) end end @@ -489,87 +271,74 @@ def update_file(conn, %{ "action" => "remove", "shortcode" => shortcode }) do - {pack_dir, pack_file_p, full_pack} = pack_info(pack_name) - - if Map.has_key?(full_pack["files"], shortcode) do - {emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) - - emoji_file_path = Path.join(pack_dir, emoji_file_path) - - # Delete the emoji file - File.rm!(emoji_file_path) + with {:ok, pack} <- Pack.remove_file(pack_name, shortcode) do + json(conn, pack.files) + else + {:exists, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - # If the old directory has no more files, remove it - if String.contains?(emoji_file_path, "/") do - dir = Path.dirname(emoji_file_path) + {:loaded, _} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{pack_name}\" is not found"}) - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name or shortcode cannot be empty"}) - update_pack_file(updated_full_pack, pack_file_p) - json(conn, %{shortcode => full_pack["files"][shortcode]}) - else - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while removing file from pack." + ) end end # Update def update_file( conn, - %{"pack_name" => pack_name, "action" => "update", "shortcode" => shortcode} = params + %{"pack_name" => name, "action" => "update", "shortcode" => shortcode} = params ) do - {pack_dir, pack_file_p, full_pack} = pack_info(pack_name) - - with {_, true} <- {:has_shortcode, Map.has_key?(full_pack["files"], shortcode)}, - %{"new_shortcode" => new_shortcode, "new_filename" => new_filename} <- params, - false <- empty?(new_shortcode), - false <- empty?(new_filename) do - # First, remove the old shortcode, saving the old path - {old_emoji_file_path, updated_full_pack} = pop_in(full_pack, ["files", shortcode]) - old_emoji_file_path = Path.join(pack_dir, old_emoji_file_path) - new_emoji_file_path = Path.join(pack_dir, new_filename) - - # If the name contains directories, create them - create_subdirs(new_emoji_file_path) - - # Move/Rename the old filename to a new filename - # These are probably on the same filesystem, so just rename should work - :ok = File.rename(old_emoji_file_path, new_emoji_file_path) - - # If the old directory has no more files, remove it - if String.contains?(old_emoji_file_path, "/") do - dir = Path.dirname(old_emoji_file_path) - - if Enum.empty?(File.ls!(dir)) do - File.rmdir!(dir) - end - end - - # Then, put in the new shortcode with the new path - updated_full_pack - |> put_in(["files", new_shortcode], new_filename) - |> update_pack_file(pack_file_p) - - json(conn, %{new_shortcode => new_filename}) + new_shortcode = params["new_shortcode"] + new_filename = params["new_filename"] + force = params["force"] == true + + with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do + json(conn, pack.files) else - {:has_shortcode, _} -> + {:exists, _} -> conn |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - true -> + {:not_used, _} -> + conn + |> put_status(:conflict) + |> json(%{ + error: + "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" + }) + + {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + |> json(%{error: "pack \"#{name}\" is not found"}) - _ -> + {:error, :empty_values} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_file were not specified"}) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating file in pack." + ) end end @@ -589,23 +358,12 @@ def update_file(conn, %{"action" => action}) do neither, all the files with specific configured extenstions will be assumed to be emojis and stored in the new `pack.json` file. """ + def import_from_fs(conn, _params) do - emoji_path = emoji_dir_path() - - with {:ok, %{access: :read_write}} <- File.stat(emoji_path), - {:ok, results} <- File.ls(emoji_path) do - imported_pack_names = - results - |> Enum.filter(fn file -> - dir_path = Path.join(emoji_path, file) - # Find the directories that do NOT have pack.json - File.dir?(dir_path) and not File.exists?(Path.join(dir_path, "pack.json")) - end) - |> Enum.map(&write_pack_json_contents/1) - - json(conn, imported_pack_names) + with {:ok, names} <- Pack.import_from_filesystem() do + json(conn, names) else - {:ok, %{access: _}} -> + {:error, :not_writable} -> conn |> put_status(:internal_server_error) |> json(%{error: "Error: emoji pack directory must be writable"}) @@ -617,44 +375,6 @@ def import_from_fs(conn, _params) do end end - defp write_pack_json_contents(dir) do - dir_path = Path.join(emoji_dir_path(), dir) - emoji_txt_path = Path.join(dir_path, "emoji.txt") - - files_for_pack = files_for_pack(emoji_txt_path, dir_path) - pack_json_contents = Jason.encode!(%{pack: %{}, files: files_for_pack}) - - File.write!(Path.join(dir_path, "pack.json"), pack_json_contents) - - dir - end - - defp files_for_pack(emoji_txt_path, dir_path) do - if File.exists?(emoji_txt_path) do - # There's an emoji.txt file, it's likely from a pack installed by the pack manager. - # Make a pack.json file from the contents of that emoji.txt fileh - - # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 - - # Create a map of shortcodes to filenames from emoji.txt - File.read!(emoji_txt_path) - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.map(fn line -> - case String.split(line, ~r/,\s*/) do - # This matches both strings with and without tags - # and we don't care about tags here - [name, file | _] -> {name, file} - _ -> nil - end - end) - |> Enum.filter(fn x -> not is_nil(x) end) - |> Enum.into(%{}) - else - # If there's no emoji.txt, assume all files - # that are of certain extensions from the config are emojis and import them all - pack_extensions = Pleroma.Config.get!([:emoji, :pack_extensions]) - Pleroma.Emoji.Loader.make_shortcode_to_file_map(dir_path, pack_extensions) - end - end + defp get_filename(%Plug.Upload{filename: filename}), do: filename + defp get_filename(url) when is_binary(url), do: Path.basename(url) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index becce3098..0fcb517cf 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -221,12 +221,13 @@ defmodule Pleroma.Web.Router do delete("/:name", EmojiAPIController, :delete) # Note: /download_from downloads and saves to instance, not to requester - post("/download_from", EmojiAPIController, :save_from) + post("/download_from", EmojiAPIController, :download_from) end # Pack info / downloading scope "/packs" do get("/", EmojiAPIController, :list_packs) + get("/:name", EmojiAPIController, :show) get("/:name/download_shared/", EmojiAPIController, :download_shared) get("/list_from", EmojiAPIController, :list_from) diff --git a/test/instance_static/emoji/pack_bad_sha/blank.png b/test/instance_static/emoji/pack_bad_sha/blank.png new file mode 100644 index 000000000..8f50fa023 Binary files /dev/null and b/test/instance_static/emoji/pack_bad_sha/blank.png differ diff --git a/test/instance_static/emoji/pack_bad_sha/pack.json b/test/instance_static/emoji/pack_bad_sha/pack.json new file mode 100644 index 000000000..35caf4298 --- /dev/null +++ b/test/instance_static/emoji/pack_bad_sha/pack.json @@ -0,0 +1,13 @@ +{ + "pack": { + "license": "Test license", + "homepage": "https://pleroma.social", + "description": "Test description", + "can-download": true, + "share-files": true, + "download-sha256": "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238" + }, + "files": { + "blank": "blank.png" + } +} \ No newline at end of file diff --git a/test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip b/test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip new file mode 100644 index 000000000..148446c64 Binary files /dev/null and b/test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip differ diff --git a/test/instance_static/emoji/test_pack/pack.json b/test/instance_static/emoji/test_pack/pack.json index 5a8ee75f9..481891b08 100644 --- a/test/instance_static/emoji/test_pack/pack.json +++ b/test/instance_static/emoji/test_pack/pack.json @@ -1,13 +1,11 @@ { + "files": { + "blank": "blank.png" + }, "pack": { - "license": "Test license", - "homepage": "https://pleroma.social", "description": "Test description", - + "homepage": "https://pleroma.social", + "license": "Test license", "share-files": true - }, - - "files": { - "blank": "blank.png" } -} +} \ No newline at end of file diff --git a/test/instance_static/emoji/test_pack_nonshared/pack.json b/test/instance_static/emoji/test_pack_nonshared/pack.json index b96781f81..93d643a5f 100644 --- a/test/instance_static/emoji/test_pack_nonshared/pack.json +++ b/test/instance_static/emoji/test_pack_nonshared/pack.json @@ -3,14 +3,11 @@ "license": "Test license", "homepage": "https://pleroma.social", "description": "Test description", - "fallback-src": "https://nonshared-pack", "fallback-src-sha256": "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF", - "share-files": false }, - "files": { "blank": "blank.png" } -} +} \ No newline at end of file diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 6844601d7..6a0d7dd11 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -8,212 +8,309 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do import Tesla.Mock import Pleroma.Factory - @emoji_dir_path Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) + @emoji_path Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) - test "shared & non-shared pack information in list_packs is ok" do - conn = build_conn() - resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) - - assert Map.has_key?(resp, "test_pack") + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) - pack = resp["test_pack"] + admin_conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) - assert Map.has_key?(pack["pack"], "download-sha256") - assert pack["pack"]["can-download"] + Pleroma.Emoji.reload() + {:ok, %{admin_conn: admin_conn}} + end - assert pack["files"] == %{"blank" => "blank.png"} + test "GET /api/pleroma/emoji/packs", %{conn: conn} do + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) - # Non-shared pack + shared = resp["test_pack"] + assert shared["files"] == %{"blank" => "blank.png"} + assert Map.has_key?(shared["pack"], "download-sha256") + assert shared["pack"]["can-download"] + assert shared["pack"]["share-files"] - assert Map.has_key?(resp, "test_pack_nonshared") + non_shared = resp["test_pack_nonshared"] + assert non_shared["pack"]["share-files"] == false + assert non_shared["pack"]["can-download"] == false + end - pack = resp["test_pack_nonshared"] + describe "POST /api/pleroma/emoji/packs/list_from" do + test "shareable instance", %{admin_conn: admin_conn, conn: conn} do + resp = + conn + |> get("/api/pleroma/emoji/packs") + |> json_response(200) - refute pack["pack"]["shared"] - refute pack["pack"]["can-download"] - end + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - test "listing remote packs" do - conn = build_conn() + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - resp = - build_conn() - |> get(emoji_api_path(conn, :list_packs)) - |> json_response(200) + %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} -> + json(resp) + end) - mock(fn - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + assert admin_conn + |> post("/api/pleroma/emoji/packs/list_from", %{ + instance_address: "https://example.com" + }) + |> json_response(200) == resp + end - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + test "non shareable instance", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} -> - json(resp) - end) + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: []}}) + end) - assert conn - |> post(emoji_api_path(conn, :list_from), %{instance_address: "https://example.com"}) - |> json_response(200) == resp + assert admin_conn + |> post("/api/pleroma/emoji/packs/list_from", %{ + instance_address: "https://example.com" + }) + |> json_response(500) == %{ + "error" => "The requested instance does not support sharing emoji packs" + } + end end - test "downloading a shared pack from download_shared" do - conn = build_conn() + describe "GET /api/pleroma/emoji/packs/:name/download_shared" do + test "download shared pack", %{conn: conn} do + resp = + conn + |> get("/api/pleroma/emoji/packs/test_pack/download_shared") + |> response(200) - resp = - conn - |> get(emoji_api_path(conn, :download_shared, "test_pack")) - |> response(200) + {:ok, arch} = :zip.unzip(resp, [:memory]) + + assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end) + assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end) + end - {:ok, arch} = :zip.unzip(resp, [:memory]) + test "non existing pack", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/test_pack_for_import/download_shared") + |> json_response(:not_found) == %{ + "error" => "Pack test_pack_for_import does not exist" + } + end - assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end) - assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end) + test "non downloadable pack", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/test_pack_nonshared/download_shared") + |> json_response(:forbidden) == %{ + "error" => + "Pack test_pack_nonshared cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" + } + end end - test "downloading shared & unshared packs from another instance, deleting them" do - on_exit(fn -> - File.rm_rf!("#{@emoji_dir_path}/test_pack2") - File.rm_rf!("#{@emoji_dir_path}/test_pack_nonshared2") - end) + describe "POST /api/pleroma/emoji/packs/download_from" do + test "shared pack from remote and non shared from fallback-src", %{ + admin_conn: admin_conn, + conn: conn + } do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - mock(fn - %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]}) + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: []}}) + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack" + } -> + conn + |> get("/api/pleroma/emoji/packs/test_pack") + |> json_response(200) + |> json() - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack/download_shared" + } -> + conn + |> get("/api/pleroma/emoji/packs/test_pack/download_shared") + |> response(200) + |> text() - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack_nonshared" + } -> + conn + |> get("/api/pleroma/emoji/packs/test_pack_nonshared") + |> json_response(200) + |> json() - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/list" - } -> - conn = build_conn() + %{ + method: :get, + url: "https://nonshared-pack" + } -> + text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip")) + end) - conn - |> get(emoji_api_path(conn, :list_packs)) - |> json_response(200) - |> json() + assert admin_conn + |> post("/api/pleroma/emoji/packs/download_from", %{ + instance_address: "https://example.com", + pack_name: "test_pack", + as: "test_pack2" + }) + |> json_response(200) == "ok" - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/download_shared/test_pack" - } -> - conn = build_conn() + assert File.exists?("#{@emoji_path}/test_pack2/pack.json") + assert File.exists?("#{@emoji_path}/test_pack2/blank.png") - conn - |> get(emoji_api_path(conn, :download_shared, "test_pack")) - |> response(200) - |> text() + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_pack2") + |> json_response(200) == "ok" - %{ - method: :get, - url: "https://nonshared-pack" - } -> - text(File.read!("#{@emoji_dir_path}/test_pack_nonshared/nonshared.zip")) - end) + refute File.exists?("#{@emoji_path}/test_pack2") - admin = insert(:user, is_admin: true) + assert admin_conn + |> post( + "/api/pleroma/emoji/packs/download_from", + %{ + instance_address: "https://example.com", + pack_name: "test_pack_nonshared", + as: "test_pack_nonshared2" + } + ) + |> json_response(200) == "ok" - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, insert(:oauth_admin_token, user: admin, scopes: ["admin:write"])) - - assert (conn - |> put_req_header("content-type", "application/json") - |> post( - emoji_api_path( - conn, - :save_from - ), - %{ - instance_address: "https://old-instance", - pack_name: "test_pack", - as: "test_pack2" - } - |> Jason.encode!() - ) - |> json_response(500))["error"] =~ "does not support" - - assert conn - |> put_req_header("content-type", "application/json") - |> post( - emoji_api_path( - conn, - :save_from - ), - %{ - instance_address: "https://example.com", - pack_name: "test_pack", - as: "test_pack2" + assert File.exists?("#{@emoji_path}/test_pack_nonshared2/pack.json") + assert File.exists?("#{@emoji_path}/test_pack_nonshared2/blank.png") + + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_pack_nonshared2") + |> json_response(200) == "ok" + + refute File.exists?("#{@emoji_path}/test_pack_nonshared2") + end + + test "nonshareable instance", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: []}}) + end) + + assert admin_conn + |> post( + "/api/pleroma/emoji/packs/download_from", + %{ + instance_address: "https://old-instance", + pack_name: "test_pack", + as: "test_pack2" + } + ) + |> json_response(500) == %{ + "error" => "The requested instance does not support sharing emoji packs" } - |> Jason.encode!() - ) - |> json_response(200) == "ok" + end - assert File.exists?("#{@emoji_dir_path}/test_pack2/pack.json") - assert File.exists?("#{@emoji_dir_path}/test_pack2/blank.png") + test "checksum fail", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - assert conn - |> delete(emoji_api_path(conn, :delete, "test_pack2")) - |> json_response(200) == "ok" + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - refute File.exists?("#{@emoji_dir_path}/test_pack2") + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha" + } -> + %Tesla.Env{ + status: 200, + body: Pleroma.Emoji.Pack.load_pack("pack_bad_sha") |> Jason.encode!() + } - # non-shared, downloaded from the fallback URL + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha/download_shared" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip") + } + end) - assert conn - |> put_req_header("content-type", "application/json") - |> post( - emoji_api_path( - conn, - :save_from - ), - %{ + assert admin_conn + |> post("/api/pleroma/emoji/packs/download_from", %{ instance_address: "https://example.com", - pack_name: "test_pack_nonshared", - as: "test_pack_nonshared2" + pack_name: "pack_bad_sha", + as: "pack_bad_sha2" + }) + |> json_response(:internal_server_error) == %{ + "error" => "SHA256 for the pack doesn't match the one sent by the server" } - |> Jason.encode!() - ) - |> json_response(200) == "ok" + end + + test "other error", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - assert File.exists?("#{@emoji_dir_path}/test_pack_nonshared2/pack.json") - assert File.exists?("#{@emoji_dir_path}/test_pack_nonshared2/blank.png") + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack" + } -> + %Tesla.Env{ + status: 200, + body: %{"test_pack" => Pleroma.Emoji.Pack.load_pack("test_pack")} |> Jason.encode!() + } - assert conn - |> delete(emoji_api_path(conn, :delete, "test_pack_nonshared2")) - |> json_response(200) == "ok" + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack/download_shared" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/instance_static/emoji/test_pack/pack.json") + } + end) - refute File.exists?("#{@emoji_dir_path}/test_pack_nonshared2") + assert admin_conn + |> post("/api/pleroma/emoji/packs/download_from", %{ + instance_address: "https://example.com", + pack_name: "test_pack", + as: "test_pack2" + }) + |> json_response(:internal_server_error) == %{ + "error" => + "The pack was not set as shared and there is no fallback src to download from" + } + end end describe "updating pack metadata" do setup do - pack_file = "#{@emoji_dir_path}/test_pack/pack.json" + pack_file = "#{@emoji_path}/test_pack/pack.json" original_content = File.read!(pack_file) on_exit(fn -> File.write!(pack_file, original_content) end) - admin = insert(:user, is_admin: true) - %{conn: conn} = oauth_access(["admin:write"], user: admin) - {:ok, - admin: admin, - conn: conn, pack_file: pack_file, new_data: %{ "license" => "Test license changed", @@ -224,11 +321,9 @@ test "downloading shared & unshared packs from another instance, deleting them" end test "for a pack without a fallback source", ctx do - conn = ctx[:conn] - - assert conn + assert ctx[:admin_conn] |> post( - emoji_api_path(conn, :update_metadata, "test_pack"), + "/api/pleroma/emoji/packs/test_pack/update_metadata", %{ "new_data" => ctx[:new_data] } @@ -244,7 +339,7 @@ test "for a pack with a fallback source", ctx do method: :get, url: "https://nonshared-pack" } -> - text(File.read!("#{@emoji_dir_path}/test_pack_nonshared/nonshared.zip")) + text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip")) end) new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") @@ -256,11 +351,9 @@ test "for a pack with a fallback source", ctx do "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF" ) - conn = ctx[:conn] - - assert conn + assert ctx[:admin_conn] |> post( - emoji_api_path(conn, :update_metadata, "test_pack"), + "/api/pleroma/emoji/packs/test_pack/update_metadata", %{ "new_data" => new_data } @@ -282,201 +375,418 @@ test "when the fallback source doesn't have all the files", ctx do new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") - conn = ctx[:conn] - - assert (conn - |> post( - emoji_api_path(conn, :update_metadata, "test_pack"), - %{ - "new_data" => new_data - } - ) - |> json_response(:bad_request))["error"] =~ "does not have all" + assert ctx[:admin_conn] + |> post( + "/api/pleroma/emoji/packs/test_pack/update_metadata", + %{ + "new_data" => new_data + } + ) + |> json_response(:bad_request) == %{ + "error" => "The fallback archive does not have all files specified in pack.json" + } end end - describe "update_file/2" do + describe "POST /api/pleroma/emoji/packs/:pack_name/update_file" do setup do - pack_file = "#{@emoji_dir_path}/test_pack/pack.json" + pack_file = "#{@emoji_path}/test_pack/pack.json" original_content = File.read!(pack_file) on_exit(fn -> File.write!(pack_file, original_content) end) - admin = insert(:user, is_admin: true) - %{conn: conn} = oauth_access(["admin:write"], user: admin) - {:ok, conn: conn} + :ok + end + + test "create shortcode exists", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "shortcode" => "blank", + "filename" => "dir/blank.png", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response(:conflict) == %{ + "error" => "An emoji with the \"blank\" shortcode already exists" + } end - test "update file without shortcode", %{conn: conn} do - on_exit(fn -> File.rm_rf!("#{@emoji_dir_path}/test_pack/shortcode.png") end) + test "don't rewrite old emoji", %{admin_conn: admin_conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir/") end) - assert conn + assert admin_conn |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ "action" => "add", + "shortcode" => "blank2", + "filename" => "dir/blank.png", "file" => %Plug.Upload{ - filename: "shortcode.png", - path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(200) == %{"shortcode" => "shortcode.png"} + |> json_response(200) == %{"blank" => "blank.png", "blank2" => "dir/blank.png"} + + assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") + + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "update", + "shortcode" => "blank", + "new_shortcode" => "blank2", + "new_filename" => "dir_2/blank_3.png" + }) + |> json_response(:conflict) == %{ + "error" => + "New shortcode \"blank2\" is already used. If you want to override emoji use 'force' option" + } end - test "updating pack files", %{conn: conn} do - on_exit(fn -> - File.rm_rf!("#{@emoji_dir_path}/test_pack/blank_url.png") - File.rm_rf!("#{@emoji_dir_path}/test_pack/dir") - File.rm_rf!("#{@emoji_dir_path}/test_pack/dir_2") - end) + test "rewrite old emoji with force option", %{admin_conn: admin_conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir_2/") end) - same_name = %{ - "action" => "add", - "shortcode" => "blank", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_dir_path}/test_pack/blank.png" - } - } + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "shortcode" => "blank2", + "filename" => "dir/blank.png", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response(200) == %{"blank" => "blank.png", "blank2" => "dir/blank.png"} - different_name = %{same_name | "shortcode" => "blank_2"} + assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") - assert (conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), same_name) - |> json_response(:conflict))["error"] =~ "already exists" + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "update", + "shortcode" => "blank2", + "new_shortcode" => "blank3", + "new_filename" => "dir_2/blank_3.png", + "force" => true + }) + |> json_response(200) == %{ + "blank" => "blank.png", + "blank3" => "dir_2/blank_3.png" + } - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), different_name) - |> json_response(200) == %{"blank_2" => "dir/blank.png"} + assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") + end - assert File.exists?("#{@emoji_dir_path}/test_pack/dir/blank.png") + test "with empty filename", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "shortcode" => "blank2", + "filename" => "", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response(:bad_request) == %{ + "error" => "pack name, shortcode or filename cannot be empty" + } + end - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + test "add file with not loaded pack", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ + "action" => "add", + "shortcode" => "blank2", + "filename" => "dir/blank.png", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response(:bad_request) == %{ + "error" => "pack \"not_loaded\" is not found" + } + end + + test "remove file with not loaded pack", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ + "action" => "remove", + "shortcode" => "blank3" + }) + |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"} + end + + test "remove file with empty shortcode", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ + "action" => "remove", + "shortcode" => "" + }) + |> json_response(:bad_request) == %{ + "error" => "pack name or shortcode cannot be empty" + } + end + + test "update file with not loaded pack", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ + "action" => "update", + "shortcode" => "blank4", + "new_shortcode" => "blank3", + "new_filename" => "dir_2/blank_3.png" + }) + |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"} + end + + test "new with shortcode as file with update", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "shortcode" => "blank4", + "filename" => "dir/blank.png", + "file" => %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response(200) == %{"blank" => "blank.png", "blank4" => "dir/blank.png"} + + assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") + + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ "action" => "update", - "shortcode" => "blank_2", - "new_shortcode" => "blank_3", + "shortcode" => "blank4", + "new_shortcode" => "blank3", "new_filename" => "dir_2/blank_3.png" }) - |> json_response(200) == %{"blank_3" => "dir_2/blank_3.png"} + |> json_response(200) == %{"blank3" => "dir_2/blank_3.png", "blank" => "blank.png"} - refute File.exists?("#{@emoji_dir_path}/test_pack/dir/") - assert File.exists?("#{@emoji_dir_path}/test_pack/dir_2/blank_3.png") + refute File.exists?("#{@emoji_path}/test_pack/dir/") + assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ "action" => "remove", - "shortcode" => "blank_3" + "shortcode" => "blank3" }) - |> json_response(200) == %{"blank_3" => "dir_2/blank_3.png"} + |> json_response(200) == %{"blank" => "blank.png"} + + refute File.exists?("#{@emoji_path}/test_pack/dir_2/") - refute File.exists?("#{@emoji_dir_path}/test_pack/dir_2/") + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir") end) + end + test "new with shortcode from url", %{admin_conn: admin_conn} do mock(fn %{ method: :get, url: "https://test-blank/blank_url.png" } -> - text(File.read!("#{@emoji_dir_path}/test_pack/blank.png")) + text(File.read!("#{@emoji_path}/test_pack/blank.png")) end) - # The name should be inferred from the URL ending - from_url = %{ - "action" => "add", - "shortcode" => "blank_url", - "file" => "https://test-blank/blank_url.png" - } - - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), from_url) + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "shortcode" => "blank_url", + "file" => "https://test-blank/blank_url.png" + }) |> json_response(200) == %{ - "blank_url" => "blank_url.png" + "blank_url" => "blank_url.png", + "blank" => "blank.png" } - assert File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + assert File.exists?("#{@emoji_path}/test_pack/blank_url.png") - assert conn - |> post(emoji_api_path(conn, :update_file, "test_pack"), %{ + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/blank_url.png") end) + end + + test "new without shortcode", %{admin_conn: admin_conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/shortcode.png") end) + + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "add", + "file" => %Plug.Upload{ + filename: "shortcode.png", + path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" + } + }) + |> json_response(200) == %{"shortcode" => "shortcode.png", "blank" => "blank.png"} + end + + test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ "action" => "remove", - "shortcode" => "blank_url" + "shortcode" => "blank2" + }) + |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"} + end + + test "update non existing emoji", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "update", + "shortcode" => "blank2", + "new_shortcode" => "blank3", + "new_filename" => "dir_2/blank_3.png" }) - |> json_response(200) == %{"blank_url" => "blank_url.png"} + |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"} + end + + test "update with empty shortcode", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "update", + "shortcode" => "blank", + "new_filename" => "dir_2/blank_3.png" + }) + |> json_response(:bad_request) == %{ + "error" => "new_shortcode or new_filename cannot be empty" + } + end - refute File.exists?("#{@emoji_dir_path}/test_pack/blank_url.png") + test "undefined action", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ + "action" => "undefined" + }) + |> json_response(:bad_request) == %{ + "error" => "Unknown action: undefined" + } end end - test "creating and deleting a pack" do - on_exit(fn -> - File.rm_rf!("#{@emoji_dir_path}/test_created") - end) + describe "PUT /api/pleroma/emoji/packs/:name" do + test "creating and deleting a pack", %{admin_conn: admin_conn} do + assert admin_conn + |> put("/api/pleroma/emoji/packs/test_created") + |> json_response(200) == "ok" - admin = insert(:user, is_admin: true) - %{conn: conn} = oauth_access(["admin:write"], user: admin) - - assert conn - |> put_req_header("content-type", "application/json") - |> put( - emoji_api_path( - conn, - :create, - "test_created" - ) - ) - |> json_response(200) == "ok" + assert File.exists?("#{@emoji_path}/test_created/pack.json") + + assert Jason.decode!(File.read!("#{@emoji_path}/test_created/pack.json")) == %{ + "pack" => %{}, + "files" => %{} + } - assert File.exists?("#{@emoji_dir_path}/test_created/pack.json") + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_created") + |> json_response(200) == "ok" - assert Jason.decode!(File.read!("#{@emoji_dir_path}/test_created/pack.json")) == %{ - "pack" => %{}, - "files" => %{} - } + refute File.exists?("#{@emoji_path}/test_created/pack.json") + end + + test "if pack exists", %{admin_conn: admin_conn} do + path = Path.join(@emoji_path, "test_created") + File.mkdir(path) + pack_file = Jason.encode!(%{files: %{}, pack: %{}}) + File.write!(Path.join(path, "pack.json"), pack_file) + + assert admin_conn + |> put("/api/pleroma/emoji/packs/test_created") + |> json_response(:conflict) == %{ + "error" => "A pack named \"test_created\" already exists" + } + + on_exit(fn -> File.rm_rf(path) end) + end - assert conn - |> delete(emoji_api_path(conn, :delete, "test_created")) - |> json_response(200) == "ok" + test "with empty name", %{admin_conn: admin_conn} do + assert admin_conn + |> put("/api/pleroma/emoji/packs/ ") + |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} + end + end + + test "deleting nonexisting pack", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/non_existing") + |> json_response(:not_found) == %{"error" => "Pack non_existing does not exist"} + end - refute File.exists?("#{@emoji_dir_path}/test_created/pack.json") + test "deleting with empty name", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/ ") + |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} end - test "filesystem import" do + test "filesystem import", %{admin_conn: admin_conn, conn: conn} do on_exit(fn -> - File.rm!("#{@emoji_dir_path}/test_pack_for_import/emoji.txt") - File.rm!("#{@emoji_dir_path}/test_pack_for_import/pack.json") + File.rm!("#{@emoji_path}/test_pack_for_import/emoji.txt") + File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") end) - conn = build_conn() - resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) refute Map.has_key?(resp, "test_pack_for_import") - admin = insert(:user, is_admin: true) - %{conn: conn} = oauth_access(["admin:write"], user: admin) - - assert conn - |> post(emoji_api_path(conn, :import_from_fs)) + assert admin_conn + |> post("/api/pleroma/emoji/packs/import_from_fs") |> json_response(200) == ["test_pack_for_import"] - resp = conn |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} - File.rm!("#{@emoji_dir_path}/test_pack_for_import/pack.json") - refute File.exists?("#{@emoji_dir_path}/test_pack_for_import/pack.json") + File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") + refute File.exists?("#{@emoji_path}/test_pack_for_import/pack.json") - emoji_txt_content = "blank, blank.png, Fun\n\nblank2, blank.png" + emoji_txt_content = """ + blank, blank.png, Fun + blank2, blank.png + foo, /emoji/test_pack_for_import/blank.png + bar + """ - File.write!("#{@emoji_dir_path}/test_pack_for_import/emoji.txt", emoji_txt_content) + File.write!("#{@emoji_path}/test_pack_for_import/emoji.txt", emoji_txt_content) - assert conn - |> post(emoji_api_path(conn, :import_from_fs)) + assert admin_conn + |> post("/api/pleroma/emoji/packs/import_from_fs") |> json_response(200) == ["test_pack_for_import"] - resp = build_conn() |> get(emoji_api_path(conn, :list_packs)) |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) assert resp["test_pack_for_import"]["files"] == %{ "blank" => "blank.png", - "blank2" => "blank.png" + "blank2" => "blank.png", + "foo" => "blank.png" } end + + describe "GET /api/pleroma/emoji/packs/:name" do + test "shows pack.json", %{conn: conn} do + assert %{ + "files" => %{"blank" => "blank.png"}, + "pack" => %{ + "can-download" => true, + "description" => "Test description", + "download-sha256" => _, + "homepage" => "https://pleroma.social", + "license" => "Test license", + "share-files" => true + } + } = + conn + |> get("/api/pleroma/emoji/packs/test_pack") + |> json_response(200) + end + + test "non existing pack", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/non_existing") + |> json_response(:not_found) == %{"error" => "Pack non_existing does not exist"} + end + + test "error name", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/ ") + |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} + end + end end -- cgit v1.2.3 From 95759310abb597275335936efa4a59615b16da6e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 28 Mar 2020 13:55:17 +0300 Subject: docs update --- docs/API/pleroma_api.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index a7c7731ce..49b75f5f9 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -330,6 +330,13 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: None * Response: JSON, "ok" and 200 status and the JSON hashmap of "pack name" to "pack contents" +## `GET /api/pleroma/emoji/packs/:name` +### Get pack.json for the pack +* Method `GET` +* Authentication: not required +* Params: None +* Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist + ## `PUT /api/pleroma/emoji/packs/:name` ### Creates an empty custom emoji pack * Method `PUT` @@ -349,15 +356,17 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Method `POST` * Authentication: required * Params: - * if the `action` is `add`, adds an emoji named `shortcode` to the pack `pack_name`, - that means that the emoji file needs to be uploaded with the request + * if the `action` is `add`, adds an emoji file to the pack `pack_name`, (thus requiring it to be a multipart request) and be named `file`. + If `shortcode` is not specified, `shortcode` will be used from filename. There can also be an optional `filename` that will be the new emoji file name (if it's not there, the name will be taken from the uploaded file). * if the `action` is `update`, changes emoji shortcode - (from `shortcode` to `new_shortcode` or moves the file (from the current filename to `new_filename`) + (from `shortcode` to `new_shortcode` or moves the file (from the current filename to `new_filename`). + If new_shortcode is used in another emoji 409 status will be returned. If you want to override shortcode + pass `force` option with `true` value. * if the `action` is `remove`, removes the emoji named `shortcode` and it's associated file -* Response: JSON, emoji shortcode with filename which was added/updated/deleted and 200 status, 409 if the trying to use a shortcode +* Response: JSON, updated "files" section of the pack and 200 status, 409 if the trying to use a shortcode that is already taken, 400 if there was an error with the shortcode, filename or file (additional info in the "error" part of the response JSON) @@ -385,7 +394,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Method `POST` * Authentication: required * Params: - * `instance_address`: the address of the instance to download from + * `instance_address`: the address of the instance to get packs from * Response: JSON with the pack list, same as if the request was made to that instance's list endpoint directly + 200 status -- cgit v1.2.3 From f3070ddae5f4f7deda8365158e3750e5a575c222 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 28 Mar 2020 15:00:48 +0300 Subject: removing entry from changelog --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a220c14f6..a8589bbdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,14 +125,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    API Changes -- **Breaking:** EmojiReactions: Change endpoints and responses to align with Mastodon -- **Breaking:** Admin API: `PATCH /api/pleroma/admin/users/:nickname/force_password_reset` is now `PATCH /api/pleroma/admin/users/force_password_reset` (accepts `nicknames` array in the request body) +- **Breaking** EmojiReactions: Change endpoints and responses to align with Mastodon +- **Breaking** Admin API: `PATCH /api/pleroma/admin/users/:nickname/force_password_reset` is now `PATCH /api/pleroma/admin/users/force_password_reset` (accepts `nicknames` array in the request body) - **Breaking:** Admin API: Return link alongside with token on password reset - **Breaking:** Admin API: `PUT /api/pleroma/admin/reports/:id` is now `PATCH /api/pleroma/admin/reports`, see admin_api.md for details - **Breaking:** `/api/pleroma/admin/users/invite_token` now uses `POST`, changed accepted params and returns full invite in json instead of only token string. -- **Breaking:** replying to reports is now "report notes", endpoint changed from `POST /api/pleroma/admin/reports/:id/respond` to `POST /api/pleroma/admin/reports/:id/notes` +- **Breaking** replying to reports is now "report notes", endpoint changed from `POST /api/pleroma/admin/reports/:id/respond` to `POST /api/pleroma/admin/reports/:id/notes` - Mastodon API: stopped sanitizing display names, field names and subject fields since they are supposed to be treated as plaintext -- **Breaking:** Pleroma API: `/api/pleroma/emoji/packs/:name/update_file` endpoint returns only updated emoji data. - Admin API: Return `total` when querying for reports - Mastodon API: Return `pleroma.direct_conversation_id` when creating a direct message (`POST /api/v1/statuses`) - Admin API: Return link alongside with token on password reset -- cgit v1.2.3 From ddb757f7434c7216eec1b6ba4fa8b0b7a54157c1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 28 Mar 2020 21:15:14 +0300 Subject: emoji api packs changes in routes with docs update --- docs/API/pleroma_api.md | 118 +++++----- lib/pleroma/emoji/pack.ex | 26 +-- .../controllers/emoji_api_controller.ex | 158 ++++--------- lib/pleroma/web/router.ex | 23 +- .../controllers/emoji_api_controller_test.exs | 254 ++++++++------------- 5 files changed, 227 insertions(+), 352 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 49b75f5f9..65d22980b 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -323,27 +323,47 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: None * Response: JSON, returns a list of Mastodon Conversation entities that were marked as read (200 - healthy, 503 unhealthy). -## `GET /api/pleroma/emoji/packs` -### Lists the custom emoji packs on the server +## `GET /api/pleroma/emoji/packs/import` +### Imports packs from filesystem * Method `GET` -* Authentication: not required +* Authentication: required * Params: None -* Response: JSON, "ok" and 200 status and the JSON hashmap of "pack name" to "pack contents" +* Response: JSON, returns a list of imported packs. -## `GET /api/pleroma/emoji/packs/:name` -### Get pack.json for the pack +## `GET /api/pleroma/emoji/packs/remote` +### Make request to another instance for packs list * Method `GET` -* Authentication: not required -* Params: None -* Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist +* Authentication: required +* Params: + * `url`: url of the instance to get packs from +* Response: JSON with the pack list, hashmap with pack name and pack contents -## `PUT /api/pleroma/emoji/packs/:name` -### Creates an empty custom emoji pack -* Method `PUT` +## `POST /api/pleroma/emoji/packs/download` +### Download pack from another instance +* Method `POST` +* Authentication: required +* Params: + * `url`: url of the instance to download from + * `name`: pack to download from that instance +* Response: JSON, "ok" with 200 status if the pack was downloaded, or 500 if there were + errors downloading the pack + +## `POST /api/pleroma/emoji/packs/:name` +### Creates an empty pack +* Method `POST` * Authentication: required * Params: None * Response: JSON, "ok" and 200 status or 409 if the pack with that name already exists +## `PATCH /api/pleroma/emoji/packs/:name` +### Updates (replaces) pack metadata +* Method `POST` +* Authentication: required +* Params: + * `metadata`: metadata to replace the old one +* Response: JSON, updated "metadata" section of the pack and 200 status or 400 if there was a + problem with the new metadata (the error is specified in the "error" part of the response JSON) + ## `DELETE /api/pleroma/emoji/packs/:name` ### Delete a custom emoji pack * Method `DELETE` @@ -351,54 +371,50 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: None * Response: JSON, "ok" and 200 status or 500 if there was an error deleting the pack -## `POST /api/pleroma/emoji/packs/:name/update_file` -### Update a file in a custom emoji pack +## `POST /api/pleroma/emoji/packs/:name/files` +### Add new file to the pack * Method `POST` * Authentication: required * Params: - * if the `action` is `add`, adds an emoji file to the pack `pack_name`, - (thus requiring it to be a multipart request) and be named `file`. - If `shortcode` is not specified, `shortcode` will be used from filename. - There can also be an optional `filename` that will be the new emoji file name - (if it's not there, the name will be taken from the uploaded file). - * if the `action` is `update`, changes emoji shortcode - (from `shortcode` to `new_shortcode` or moves the file (from the current filename to `new_filename`). - If new_shortcode is used in another emoji 409 status will be returned. If you want to override shortcode - pass `force` option with `true` value. - * if the `action` is `remove`, removes the emoji named `shortcode` and it's associated file -* Response: JSON, updated "files" section of the pack and 200 status, 409 if the trying to use a shortcode - that is already taken, 400 if there was an error with the shortcode, filename or file (additional info - in the "error" part of the response JSON) - -## `POST /api/pleroma/emoji/packs/:name/update_metadata` -### Updates (replaces) pack metadata -* Method `POST` -* Authentication: required -* Params: - * `new_data`: new metadata to replace the old one -* Response: JSON, updated "metadata" section of the pack and 200 status or 400 if there was a - problem with the new metadata (the error is specified in the "error" part of the response JSON) + * `file`: uploaded file or link to remote file. + * `shortcode`: (*optional*) shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename. + * `filename`: (*optional*) new emoji file name. If not specified will be taken from original filename. +* Response: JSON, list of files for updated pack (hasmap -> shortcode => filename) with status 200, either error status with error message. -## `POST /api/pleroma/emoji/packs/download_from` -### Requests the instance to download the pack from another instance -* Method `POST` +## `PATCH /api/pleroma/emoji/packs/:name/files` +### Update emoji file from pack +* Method `PATCH` * Authentication: required * Params: - * `instance_address`: the address of the instance to download from - * `pack_name`: the pack to download from that instance -* Response: JSON, "ok" and 200 status if the pack was downloaded, or 500 if there were - errors downloading the pack - -## `POST /api/pleroma/emoji/packs/list_from` -### Requests the instance to list the packs from another instance -* Method `POST` + * `shortcode`: emoji file shortcode + * `new_shortcode`: new emoji file shortcode + * `new_filename`: new filename for emoji file + * `force`: (*optional*) with true value to overwrite existing emoji with new shortcode +* Response: JSON, list with updated files for updated pack (hasmap -> shortcode => filename) with status 200, either error status with error message. + +## `DELETE /api/pleroma/emoji/packs/:name/files` +### Delete emoji file from pack +* Method `DELETE` * Authentication: required * Params: - * `instance_address`: the address of the instance to get packs from -* Response: JSON with the pack list, same as if the request was made to that instance's - list endpoint directly + 200 status + * `shortcode`: emoji file shortcode +* Response: JSON, list with updated files for updated pack (hasmap -> shortcode => filename) with status 200, either error status with error message. + +## `GET /api/pleroma/emoji/packs` +### Lists local custom emoji packs +* Method `GET` +* Authentication: not required +* Params: None +* Response: JSON, "ok" and 200 status and the JSON hashmap of pack name to pack contents + +## `GET /api/pleroma/emoji/packs/:name` +### Get pack.json for the pack +* Method `GET` +* Authentication: not required +* Params: None +* Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist -## `GET /api/pleroma/emoji/packs/:name/download_shared` +## `GET /api/pleroma/emoji/packs/:name/archive` ### Requests a local pack from the instance * Method `GET` * Authentication: not required diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 21ed12c78..eb50e52fa 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -102,9 +102,9 @@ defp create_subdirs(file_path) do end end - @spec remove_file(String.t(), String.t()) :: + @spec delete_file(String.t(), String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} - def remove_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do + def delete_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, {_, {filename, files}} when not is_nil(filename) <- {:exists, Map.pop(pack.files, shortcode)}, @@ -131,7 +131,7 @@ def remove_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcod end end - def remove_file(_, _), do: {:error, :empty_values} + def delete_file(_, _), do: {:error, :empty_values} @spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} @@ -249,8 +249,8 @@ defp files_from_path(path) do end end - @spec list_remote_packs(String.t()) :: {:ok, map()} - def list_remote_packs(url) do + @spec list_remote(String.t()) :: {:ok, map()} + def list_remote(url) do uri = url |> String.trim() @@ -269,8 +269,8 @@ def list_remote_packs(url) do end end - @spec list_local_packs() :: {:ok, map()} - def list_local_packs do + @spec list_local() :: {:ok, map()} + def list_local do emoji_path = emoji_path() # Create the directory first if it does not exist. This is probably the first request made @@ -315,8 +315,8 @@ defp downloadable?(pack) do end) end - @spec download(String.t()) :: {:ok, binary()} - def download(name) do + @spec get_archive(String.t()) :: {:ok, binary()} + def get_archive(name) do with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)}, {_, true} <- {:can_download?, downloadable?(pack)} do {:ok, fetch_archive(pack)} @@ -356,15 +356,14 @@ defp create_archive_and_cache(pack, hash) do result end - @spec download_from_source(String.t(), String.t(), String.t()) :: :ok - def download_from_source(name, url, as) do + @spec download(String.t(), String.t(), String.t()) :: :ok + def download(name, url, as) do uri = url |> String.trim() |> URI.parse() with {_, true} <- {:shareable, shareable_packs_available?(uri)} do - # TODO: why do we load all packs, if we know the name of pack we need remote_pack = uri |> URI.merge("/api/pleroma/emoji/packs/#{name}") @@ -379,8 +378,7 @@ def download_from_source(name, url, as) do {:ok, %{ sha: sha, - url: - URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/download_shared") |> to_string() + url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string() }} %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index 9fa857474..83a7f03e8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -7,12 +7,15 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"], admin: true} when action in [ + :import, + :remote, + :download, :create, + :update, :delete, - :download_from, - :import_from_fs, + :add_file, :update_file, - :update_metadata + :delete_file ] ) @@ -22,14 +25,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do when action in [:download_shared, :list_packs, :list_from] ) - @doc """ - Lists packs from the remote instance. - - Since JS cannot ask remote instances for their packs due to CPS, it has to - be done by the server - """ - def list_from(conn, %{"instance_address" => address}) do - with {:ok, packs} <- Pack.list_remote_packs(address) do + def remote(conn, %{"url" => url}) do + with {:ok, packs} <- Pack.list_remote(url) do json(conn, packs) else {:shareable, _} -> @@ -39,20 +36,14 @@ def list_from(conn, %{"instance_address" => address}) do end end - @doc """ - Lists the packs available on the instance as JSON. - - The information is public and does not require authentication. The format is - a map of "pack directory name" to pack.json contents. - """ - def list_packs(conn, _params) do + def list(conn, _params) do emoji_path = Path.join( Pleroma.Config.get!([:instance, :static_dir]), "emoji" ) - with {:ok, packs} <- Pack.list_local_packs() do + with {:ok, packs} <- Pack.list_local() do json(conn, packs) else {:create_dir, {:error, e}} -> @@ -87,12 +78,8 @@ def show(conn, %{"name" => name}) do end end - @doc """ - An endpoint for other instances (via admin UI) or users (via browser) - to download packs that the instance shares. - """ - def download_shared(conn, %{"name" => name}) do - with {:ok, archive} <- Pack.download(name) do + def archive(conn, %{"name" => name}) do + with {:ok, archive} <- Pack.get_archive(name) do send_download(conn, {:binary, archive}, filename: "#{name}.zip") else {:can_download?, _} -> @@ -110,15 +97,8 @@ def download_shared(conn, %{"name" => name}) do end end - @doc """ - An admin endpoint to request downloading and storing a pack named `pack_name` from the instance - `instance_address`. - - If the requested instance's admin chose to share the pack, it will be downloaded - from that instance, otherwise it will be downloaded from the fallback source, if there is one. - """ - def download_from(conn, %{"instance_address" => address, "pack_name" => name} = params) do - with :ok <- Pack.download_from_source(name, address, params["as"]) do + def download(conn, %{"url" => url, "name" => name} = params) do + with :ok <- Pack.download(name, url, params["as"]) do json(conn, "ok") else {:shareable, _} -> @@ -138,9 +118,6 @@ def download_from(conn, %{"instance_address" => address, "pack_name" => name} = end end - @doc """ - Creates an empty pack named `name` which then can be updated via the admin UI. - """ def create(conn, %{"name" => name}) do name = String.trim(name) @@ -166,9 +143,6 @@ def create(conn, %{"name" => name}) do end end - @doc """ - Deletes the pack `name` and all it's files. - """ def delete(conn, %{"name" => name}) do name = String.trim(name) @@ -192,13 +166,8 @@ def delete(conn, %{"name" => name}) do end end - @doc """ - An endpoint to update `pack_names`'s metadata. - - `new_data` is the new metadata for the pack, that will replace the old metadata. - """ - def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do - with {:ok, pack} <- Pack.update_metadata(name, new_data) do + def update(conn, %{"name" => name, "metadata" => metadata}) do + with {:ok, pack} <- Pack.update_metadata(name, metadata) do json(conn, pack.pack) else {:has_all_files?, _} -> @@ -215,30 +184,11 @@ def update_metadata(conn, %{"pack_name" => name, "new_data" => new_data}) do end end - @doc """ - Updates a file in a pack. - - Updating can mean three things: - - - `add` adds an emoji named `shortcode` to the pack `pack_name`, - that means that the emoji file needs to be uploaded with the request - (thus requiring it to be a multipart request) and be named `file`. - There can also be an optional `filename` that will be the new emoji file name - (if it's not there, the name will be taken from the uploaded file). - - `update` changes emoji shortcode (from `shortcode` to `new_shortcode` or moves the file - (from the current filename to `new_filename`) - - `remove` removes the emoji named `shortcode` and it's associated file - """ - - # Add - def update_file( - conn, - %{"pack_name" => pack_name, "action" => "add"} = params - ) do + def add_file(conn, %{"name" => name} = params) do filename = params["filename"] || get_filename(params["file"]) shortcode = params["shortcode"] || Path.basename(filename, Path.extname(filename)) - with {:ok, pack} <- Pack.add_file(pack_name, shortcode, filename, params["file"]) do + with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params["file"]) do json(conn, pack.files) else {:exists, _} -> @@ -249,7 +199,7 @@ def update_file( {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "pack \"#{pack_name}\" is not found"}) + |> json(%{error: "pack \"#{name}\" is not found"}) {:error, :empty_values} -> conn @@ -265,13 +215,12 @@ def update_file( end end - # Remove - def update_file(conn, %{ - "pack_name" => pack_name, - "action" => "remove", - "shortcode" => shortcode - }) do - with {:ok, pack} <- Pack.remove_file(pack_name, shortcode) do + def update_file(conn, %{"name" => name, "shortcode" => shortcode} = params) do + new_shortcode = params["new_shortcode"] + new_filename = params["new_filename"] + force = params["force"] == true + + with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do json(conn, pack.files) else {:exists, _} -> @@ -279,35 +228,35 @@ def update_file(conn, %{ |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + {:not_used, _} -> + conn + |> put_status(:conflict) + |> json(%{ + error: + "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" + }) + {:loaded, _} -> conn |> put_status(:bad_request) - |> json(%{error: "pack \"#{pack_name}\" is not found"}) + |> json(%{error: "pack \"#{name}\" is not found"}) {:error, :empty_values} -> conn |> put_status(:bad_request) - |> json(%{error: "pack name or shortcode cannot be empty"}) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) {:error, _} -> render_error( conn, :internal_server_error, - "Unexpected error occurred while removing file from pack." + "Unexpected error occurred while updating file in pack." ) end end - # Update - def update_file( - conn, - %{"pack_name" => name, "action" => "update", "shortcode" => shortcode} = params - ) do - new_shortcode = params["new_shortcode"] - new_filename = params["new_filename"] - force = params["force"] == true - - with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do + def delete_file(conn, %{"name" => name, "shortcode" => shortcode}) do + with {:ok, pack} <- Pack.delete_file(name, shortcode) do json(conn, pack.files) else {:exists, _} -> @@ -315,14 +264,6 @@ def update_file( |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - {:not_used, _} -> - conn - |> put_status(:conflict) - |> json(%{ - error: - "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" - }) - {:loaded, _} -> conn |> put_status(:bad_request) @@ -331,35 +272,18 @@ def update_file( {:error, :empty_values} -> conn |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + |> json(%{error: "pack name or shortcode cannot be empty"}) {:error, _} -> render_error( conn, :internal_server_error, - "Unexpected error occurred while updating file in pack." + "Unexpected error occurred while removing file from pack." ) end end - def update_file(conn, %{"action" => action}) do - conn - |> put_status(:bad_request) - |> json(%{error: "Unknown action: #{action}"}) - end - - @doc """ - Imports emoji from the filesystem. - - Importing means checking all the directories in the - `$instance_static/emoji/` for directories which do not have - `pack.json`. If one has an emoji.txt file, that file will be used - to create a `pack.json` file with it's contents. If the directory has - neither, all the files with specific configured extenstions will be - assumed to be emojis and stored in the new `pack.json` file. - """ - - def import_from_fs(conn, _params) do + def import_from_filesystem(conn, _params) do with {:ok, names} <- Pack.import_from_filesystem() do json(conn, names) else diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0fcb517cf..a7e1f2f57 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -214,25 +214,24 @@ defmodule Pleroma.Web.Router do scope "/packs" do pipe_through(:admin_api) - post("/import_from_fs", EmojiAPIController, :import_from_fs) - post("/:pack_name/update_file", EmojiAPIController, :update_file) - post("/:pack_name/update_metadata", EmojiAPIController, :update_metadata) - put("/:name", EmojiAPIController, :create) + get("/import", EmojiAPIController, :import_from_filesystem) + get("/remote", EmojiAPIController, :remote) + post("/download", EmojiAPIController, :download) + + post("/:name", EmojiAPIController, :create) + patch("/:name", EmojiAPIController, :update) delete("/:name", EmojiAPIController, :delete) - # Note: /download_from downloads and saves to instance, not to requester - post("/download_from", EmojiAPIController, :download_from) + post("/:name/files", EmojiAPIController, :add_file) + patch("/:name/files", EmojiAPIController, :update_file) + delete("/:name/files", EmojiAPIController, :delete_file) end # Pack info / downloading scope "/packs" do - get("/", EmojiAPIController, :list_packs) + get("/", EmojiAPIController, :list) get("/:name", EmojiAPIController, :show) - get("/:name/download_shared/", EmojiAPIController, :download_shared) - get("/list_from", EmojiAPIController, :list_from) - - # Deprecated: POST /api/pleroma/emoji/packs/list_from (use GET instead) - post("/list_from", EmojiAPIController, :list_from) + get("/:name/archive", EmojiAPIController, :archive) end end diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index 6a0d7dd11..d343256fe 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -41,7 +41,7 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do assert non_shared["pack"]["can-download"] == false end - describe "POST /api/pleroma/emoji/packs/list_from" do + describe "GET /api/pleroma/emoji/packs/remote" do test "shareable instance", %{admin_conn: admin_conn, conn: conn} do resp = conn @@ -60,8 +60,8 @@ test "shareable instance", %{admin_conn: admin_conn, conn: conn} do end) assert admin_conn - |> post("/api/pleroma/emoji/packs/list_from", %{ - instance_address: "https://example.com" + |> get("/api/pleroma/emoji/packs/remote", %{ + url: "https://example.com" }) |> json_response(200) == resp end @@ -76,20 +76,18 @@ test "non shareable instance", %{admin_conn: admin_conn} do end) assert admin_conn - |> post("/api/pleroma/emoji/packs/list_from", %{ - instance_address: "https://example.com" - }) + |> get("/api/pleroma/emoji/packs/remote", %{url: "https://example.com"}) |> json_response(500) == %{ "error" => "The requested instance does not support sharing emoji packs" } end end - describe "GET /api/pleroma/emoji/packs/:name/download_shared" do + describe "GET /api/pleroma/emoji/packs/:name/archive" do test "download shared pack", %{conn: conn} do resp = conn - |> get("/api/pleroma/emoji/packs/test_pack/download_shared") + |> get("/api/pleroma/emoji/packs/test_pack/archive") |> response(200) {:ok, arch} = :zip.unzip(resp, [:memory]) @@ -100,7 +98,7 @@ test "download shared pack", %{conn: conn} do test "non existing pack", %{conn: conn} do assert conn - |> get("/api/pleroma/emoji/packs/test_pack_for_import/download_shared") + |> get("/api/pleroma/emoji/packs/test_pack_for_import/archive") |> json_response(:not_found) == %{ "error" => "Pack test_pack_for_import does not exist" } @@ -108,7 +106,7 @@ test "non existing pack", %{conn: conn} do test "non downloadable pack", %{conn: conn} do assert conn - |> get("/api/pleroma/emoji/packs/test_pack_nonshared/download_shared") + |> get("/api/pleroma/emoji/packs/test_pack_nonshared/archive") |> json_response(:forbidden) == %{ "error" => "Pack test_pack_nonshared cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" @@ -116,7 +114,7 @@ test "non downloadable pack", %{conn: conn} do end end - describe "POST /api/pleroma/emoji/packs/download_from" do + describe "POST /api/pleroma/emoji/packs/download" do test "shared pack from remote and non shared from fallback-src", %{ admin_conn: admin_conn, conn: conn @@ -139,10 +137,10 @@ test "shared pack from remote and non shared from fallback-src", %{ %{ method: :get, - url: "https://example.com/api/pleroma/emoji/packs/test_pack/download_shared" + url: "https://example.com/api/pleroma/emoji/packs/test_pack/archive" } -> conn - |> get("/api/pleroma/emoji/packs/test_pack/download_shared") + |> get("/api/pleroma/emoji/packs/test_pack/archive") |> response(200) |> text() @@ -163,9 +161,9 @@ test "shared pack from remote and non shared from fallback-src", %{ end) assert admin_conn - |> post("/api/pleroma/emoji/packs/download_from", %{ - instance_address: "https://example.com", - pack_name: "test_pack", + |> post("/api/pleroma/emoji/packs/download", %{ + url: "https://example.com", + name: "test_pack", as: "test_pack2" }) |> json_response(200) == "ok" @@ -181,10 +179,10 @@ test "shared pack from remote and non shared from fallback-src", %{ assert admin_conn |> post( - "/api/pleroma/emoji/packs/download_from", + "/api/pleroma/emoji/packs/download", %{ - instance_address: "https://example.com", - pack_name: "test_pack_nonshared", + url: "https://example.com", + name: "test_pack_nonshared", as: "test_pack_nonshared2" } ) @@ -211,10 +209,10 @@ test "nonshareable instance", %{admin_conn: admin_conn} do assert admin_conn |> post( - "/api/pleroma/emoji/packs/download_from", + "/api/pleroma/emoji/packs/download", %{ - instance_address: "https://old-instance", - pack_name: "test_pack", + url: "https://old-instance", + name: "test_pack", as: "test_pack2" } ) @@ -242,7 +240,7 @@ test "checksum fail", %{admin_conn: admin_conn} do %{ method: :get, - url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha/download_shared" + url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha/archive" } -> %Tesla.Env{ status: 200, @@ -251,9 +249,9 @@ test "checksum fail", %{admin_conn: admin_conn} do end) assert admin_conn - |> post("/api/pleroma/emoji/packs/download_from", %{ - instance_address: "https://example.com", - pack_name: "pack_bad_sha", + |> post("/api/pleroma/emoji/packs/download", %{ + url: "https://example.com", + name: "pack_bad_sha", as: "pack_bad_sha2" }) |> json_response(:internal_server_error) == %{ @@ -275,23 +273,14 @@ test "other error", %{admin_conn: admin_conn} do } -> %Tesla.Env{ status: 200, - body: %{"test_pack" => Pleroma.Emoji.Pack.load_pack("test_pack")} |> Jason.encode!() - } - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/test_pack/download_shared" - } -> - %Tesla.Env{ - status: 200, - body: File.read!("test/instance_static/emoji/test_pack/pack.json") + body: Pleroma.Emoji.Pack.load_pack("test_pack") |> Jason.encode!() } end) assert admin_conn - |> post("/api/pleroma/emoji/packs/download_from", %{ - instance_address: "https://example.com", - pack_name: "test_pack", + |> post("/api/pleroma/emoji/packs/download", %{ + url: "https://example.com", + name: "test_pack", as: "test_pack2" }) |> json_response(:internal_server_error) == %{ @@ -301,7 +290,7 @@ test "other error", %{admin_conn: admin_conn} do end end - describe "updating pack metadata" do + describe "PATCH /api/pleroma/emoji/packs/:name" do setup do pack_file = "#{@emoji_path}/test_pack/pack.json" original_content = File.read!(pack_file) @@ -322,12 +311,7 @@ test "other error", %{admin_conn: admin_conn} do test "for a pack without a fallback source", ctx do assert ctx[:admin_conn] - |> post( - "/api/pleroma/emoji/packs/test_pack/update_metadata", - %{ - "new_data" => ctx[:new_data] - } - ) + |> patch("/api/pleroma/emoji/packs/test_pack", %{"metadata" => ctx[:new_data]}) |> json_response(200) == ctx[:new_data] assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == ctx[:new_data] @@ -352,12 +336,7 @@ test "for a pack with a fallback source", ctx do ) assert ctx[:admin_conn] - |> post( - "/api/pleroma/emoji/packs/test_pack/update_metadata", - %{ - "new_data" => new_data - } - ) + |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) |> json_response(200) == new_data_with_sha assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == new_data_with_sha @@ -376,19 +355,14 @@ test "when the fallback source doesn't have all the files", ctx do new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") assert ctx[:admin_conn] - |> post( - "/api/pleroma/emoji/packs/test_pack/update_metadata", - %{ - "new_data" => new_data - } - ) + |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) |> json_response(:bad_request) == %{ "error" => "The fallback archive does not have all files specified in pack.json" } end end - describe "POST /api/pleroma/emoji/packs/:pack_name/update_file" do + describe "POST/PATCH/DELETE /api/pleroma/emoji/packs/:name/files" do setup do pack_file = "#{@emoji_path}/test_pack/pack.json" original_content = File.read!(pack_file) @@ -402,11 +376,10 @@ test "when the fallback source doesn't have all the files", ctx do test "create shortcode exists", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "shortcode" => "blank", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + filename: "dir/blank.png", + file: %Plug.Upload{ filename: "blank.png", path: "#{@emoji_path}/test_pack/blank.png" } @@ -420,11 +393,10 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir/") end) assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "shortcode" => "blank2", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + filename: "dir/blank.png", + file: %Plug.Upload{ filename: "blank.png", path: "#{@emoji_path}/test_pack/blank.png" } @@ -434,11 +406,10 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "update", - "shortcode" => "blank", - "new_shortcode" => "blank2", - "new_filename" => "dir_2/blank_3.png" + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + new_shortcode: "blank2", + new_filename: "dir_2/blank_3.png" }) |> json_response(:conflict) == %{ "error" => @@ -450,11 +421,10 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir_2/") end) assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "shortcode" => "blank2", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + filename: "dir/blank.png", + file: %Plug.Upload{ filename: "blank.png", path: "#{@emoji_path}/test_pack/blank.png" } @@ -464,12 +434,11 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "update", - "shortcode" => "blank2", - "new_shortcode" => "blank3", - "new_filename" => "dir_2/blank_3.png", - "force" => true + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png", + force: true }) |> json_response(200) == %{ "blank" => "blank.png", @@ -481,11 +450,10 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do test "with empty filename", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "shortcode" => "blank2", - "filename" => "", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + filename: "", + file: %Plug.Upload{ filename: "blank.png", path: "#{@emoji_path}/test_pack/blank.png" } @@ -497,11 +465,10 @@ test "with empty filename", %{admin_conn: admin_conn} do test "add file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ - "action" => "add", - "shortcode" => "blank2", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/not_loaded/files", %{ + shortcode: "blank2", + filename: "dir/blank.png", + file: %Plug.Upload{ filename: "blank.png", path: "#{@emoji_path}/test_pack/blank.png" } @@ -513,19 +480,13 @@ test "add file with not loaded pack", %{admin_conn: admin_conn} do test "remove file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ - "action" => "remove", - "shortcode" => "blank3" - }) + |> delete("/api/pleroma/emoji/packs/not_loaded/files", %{shortcode: "blank3"}) |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"} end test "remove file with empty shortcode", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ - "action" => "remove", - "shortcode" => "" - }) + |> delete("/api/pleroma/emoji/packs/not_loaded/files", %{shortcode: ""}) |> json_response(:bad_request) == %{ "error" => "pack name or shortcode cannot be empty" } @@ -533,22 +494,20 @@ test "remove file with empty shortcode", %{admin_conn: admin_conn} do test "update file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/not_loaded/update_file", %{ - "action" => "update", - "shortcode" => "blank4", - "new_shortcode" => "blank3", - "new_filename" => "dir_2/blank_3.png" + |> patch("/api/pleroma/emoji/packs/not_loaded/files", %{ + shortcode: "blank4", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png" }) |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"} end test "new with shortcode as file with update", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "shortcode" => "blank4", - "filename" => "dir/blank.png", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank4", + filename: "dir/blank.png", + file: %Plug.Upload{ filename: "blank.png", path: "#{@emoji_path}/test_pack/blank.png" } @@ -558,11 +517,10 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "update", - "shortcode" => "blank4", - "new_shortcode" => "blank3", - "new_filename" => "dir_2/blank_3.png" + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank4", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png" }) |> json_response(200) == %{"blank3" => "dir_2/blank_3.png", "blank" => "blank.png"} @@ -570,10 +528,7 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "remove", - "shortcode" => "blank3" - }) + |> delete("/api/pleroma/emoji/packs/test_pack/files", %{shortcode: "blank3"}) |> json_response(200) == %{"blank" => "blank.png"} refute File.exists?("#{@emoji_path}/test_pack/dir_2/") @@ -591,10 +546,9 @@ test "new with shortcode from url", %{admin_conn: admin_conn} do end) assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "shortcode" => "blank_url", - "file" => "https://test-blank/blank_url.png" + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank_url", + file: "https://test-blank/blank_url.png" }) |> json_response(200) == %{ "blank_url" => "blank_url.png", @@ -610,9 +564,8 @@ test "new without shortcode", %{admin_conn: admin_conn} do on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/shortcode.png") end) assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "add", - "file" => %Plug.Upload{ + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + file: %Plug.Upload{ filename: "shortcode.png", path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" } @@ -622,51 +575,36 @@ test "new without shortcode", %{admin_conn: admin_conn} do test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "remove", - "shortcode" => "blank2" - }) + |> delete("/api/pleroma/emoji/packs/test_pack/files", %{shortcode: "blank2"}) |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"} end test "update non existing emoji", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "update", - "shortcode" => "blank2", - "new_shortcode" => "blank3", - "new_filename" => "dir_2/blank_3.png" + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png" }) |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"} end test "update with empty shortcode", %{admin_conn: admin_conn} do assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "update", - "shortcode" => "blank", - "new_filename" => "dir_2/blank_3.png" + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + new_filename: "dir_2/blank_3.png" }) |> json_response(:bad_request) == %{ "error" => "new_shortcode or new_filename cannot be empty" } end - - test "undefined action", %{admin_conn: admin_conn} do - assert admin_conn - |> post("/api/pleroma/emoji/packs/test_pack/update_file", %{ - "action" => "undefined" - }) - |> json_response(:bad_request) == %{ - "error" => "Unknown action: undefined" - } - end end - describe "PUT /api/pleroma/emoji/packs/:name" do + describe "POST/DELETE /api/pleroma/emoji/packs/:name" do test "creating and deleting a pack", %{admin_conn: admin_conn} do assert admin_conn - |> put("/api/pleroma/emoji/packs/test_created") + |> post("/api/pleroma/emoji/packs/test_created") |> json_response(200) == "ok" assert File.exists?("#{@emoji_path}/test_created/pack.json") @@ -690,7 +628,7 @@ test "if pack exists", %{admin_conn: admin_conn} do File.write!(Path.join(path, "pack.json"), pack_file) assert admin_conn - |> put("/api/pleroma/emoji/packs/test_created") + |> post("/api/pleroma/emoji/packs/test_created") |> json_response(:conflict) == %{ "error" => "A pack named \"test_created\" already exists" } @@ -700,7 +638,7 @@ test "if pack exists", %{admin_conn: admin_conn} do test "with empty name", %{admin_conn: admin_conn} do assert admin_conn - |> put("/api/pleroma/emoji/packs/ ") + |> post("/api/pleroma/emoji/packs/ ") |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} end end @@ -728,7 +666,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do refute Map.has_key?(resp, "test_pack_for_import") assert admin_conn - |> post("/api/pleroma/emoji/packs/import_from_fs") + |> get("/api/pleroma/emoji/packs/import") |> json_response(200) == ["test_pack_for_import"] resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) @@ -747,7 +685,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do File.write!("#{@emoji_path}/test_pack_for_import/emoji.txt", emoji_txt_content) assert admin_conn - |> post("/api/pleroma/emoji/packs/import_from_fs") + |> get("/api/pleroma/emoji/packs/import") |> json_response(200) == ["test_pack_for_import"] resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) -- cgit v1.2.3 From 9855018425f073dec11e35e624185c6e939f33fb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 28 Mar 2020 21:21:23 +0300 Subject: changelog entry --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8589bbdc..65dd1b9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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] + +### Changed +
    + API Changes +- **Breaking:** Emoji API: changed methods and renamed routes. +
    + ### Removed - **Breaking:** removed `with_move` parameter from notifications timeline. -- cgit v1.2.3 From 36abeedf9fdd5f90c2c03a4366f8d2cc563f9386 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 09:09:27 +0300 Subject: error rename --- lib/pleroma/emoji/pack.ex | 2 +- lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index eb50e52fa..242344374 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -188,7 +188,7 @@ def import_from_filesystem do {:ok, names} else - {:ok, %{access: _}} -> {:error, :not_writable} + {:ok, %{access: _}} -> {:error, :no_read_write} e -> e end end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index 83a7f03e8..7af9d38a1 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -287,7 +287,7 @@ def import_from_filesystem(conn, _params) do with {:ok, names} <- Pack.import_from_filesystem() do json(conn, names) else - {:error, :not_writable} -> + {:error, :no_read_write} -> conn |> put_status(:internal_server_error) |> json(%{error: "Error: emoji pack directory must be writable"}) -- cgit v1.2.3 From 1c1b7e22afd25c5d1c4ff71d03a08ee39149fca1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 30 Mar 2020 10:07:37 +0300 Subject: list of options for pack metadata --- docs/API/pleroma_api.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 65d22980b..dc39c8b0b 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -361,6 +361,12 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Authentication: required * Params: * `metadata`: metadata to replace the old one + * `license`: Pack license + * `homepage`: Pack home page url + * `description`: Pack description + * `fallback-src`: Fallback url to download pack from + * `fallback-src-sha256`: SHA256 encoded for fallback pack archive + * `share-files`: is pack allowed for sharing (boolean) * Response: JSON, updated "metadata" section of the pack and 200 status or 400 if there was a problem with the new metadata (the error is specified in the "error" part of the response JSON) -- cgit v1.2.3 From 1fd40532aeac2c6988e2b439af1350cbf59cd697 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 31 Mar 2020 11:38:37 +0300 Subject: docs fix --- docs/API/pleroma_api.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index dc39c8b0b..0c4d5c797 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -345,6 +345,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: * `url`: url of the instance to download from * `name`: pack to download from that instance + * `as`: (*optional*) name how to save pack * Response: JSON, "ok" with 200 status if the pack was downloaded, or 500 if there were errors downloading the pack @@ -357,7 +358,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa ## `PATCH /api/pleroma/emoji/packs/:name` ### Updates (replaces) pack metadata -* Method `POST` +* Method `PATCH` * Authentication: required * Params: * `metadata`: metadata to replace the old one -- cgit v1.2.3 From 631e8c1febb32910a8e44c7c39790c9364620567 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 1 Apr 2020 13:57:27 +0300 Subject: docs update --- docs/API/pleroma_api.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 0c4d5c797..b927be026 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -383,10 +383,10 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Method `POST` * Authentication: required * Params: - * `file`: uploaded file or link to remote file. + * `file`: file needs to be uploaded with the multipart request or link to remote file. * `shortcode`: (*optional*) shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename. * `filename`: (*optional*) new emoji file name. If not specified will be taken from original filename. -* Response: JSON, list of files for updated pack (hasmap -> shortcode => filename) with status 200, either error status with error message. +* Response: JSON, list of files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. ## `PATCH /api/pleroma/emoji/packs/:name/files` ### Update emoji file from pack @@ -397,7 +397,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * `new_shortcode`: new emoji file shortcode * `new_filename`: new filename for emoji file * `force`: (*optional*) with true value to overwrite existing emoji with new shortcode -* Response: JSON, list with updated files for updated pack (hasmap -> shortcode => filename) with status 200, either error status with error message. +* Response: JSON, list with updated files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. ## `DELETE /api/pleroma/emoji/packs/:name/files` ### Delete emoji file from pack @@ -405,7 +405,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Authentication: required * Params: * `shortcode`: emoji file shortcode -* Response: JSON, list with updated files for updated pack (hasmap -> shortcode => filename) with status 200, either error status with error message. +* Response: JSON, list with updated files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. ## `GET /api/pleroma/emoji/packs` ### Lists local custom emoji packs @@ -422,7 +422,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist ## `GET /api/pleroma/emoji/packs/:name/archive` -### Requests a local pack from the instance +### Requests a local pack archive from the instance * Method `GET` * Authentication: not required * Params: None -- cgit v1.2.3 From 4dc5302f455e56d3c2cb669e8a70f52457690a86 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 15:26:23 +0200 Subject: Transmogrifier: Handle incoming deletes for non-user objects. --- lib/pleroma/web/activity_pub/object_validator.ex | 3 ++- lib/pleroma/web/activity_pub/side_effects.ex | 12 +++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 29 +++------------------- test/web/activity_pub/side_effects_test.exs | 23 +++++++++++++++++ .../transmogrifier/delete_handling_test.exs | 6 ++--- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 016f6e7a2..32f606917 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,8 +11,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5981e7545..93698a834 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -28,6 +28,18 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do result end + # Tasks this handles: + # - Delete create activity + # - Replace object with Tombstone + # - Set up notification + def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do + with %Object{} = deleted_object <- Object.normalize(deleted_object), + {:ok, _, _} <- Object.delete(deleted_object) do + Notification.create_notifications(object) + {:ok, object, meta} + end + end + # Nothing to do def handle(object, meta) do {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 09119137b..855aab8d4 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -729,36 +729,13 @@ def handle_incoming( end end - # TODO: We presently assume that any actor on the same origin domain as the object being - # deleted has the rights to delete that object. A better way to validate whether or not - # the object should be deleted is to refetch the object URI, which should return either - # an error or a tombstone. This would allow us to verify that a deletion actually took - # place. def handle_incoming( - %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data, + %{"type" => "Delete"} = data, _options ) do - object_id = Utils.get_ap_id(object_id) - - with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_obj_helper(object_id), - :ok <- Containment.contain_origin(actor.ap_id, object.data), - {:ok, activity} <- - ActivityPub.delete(object, local: false, activity_id: id, actor: actor.ap_id) do + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} - else - nil -> - case User.get_cached_by_ap_id(object_id) do - %User{ap_id: ^actor} = user -> - User.delete(user) - - nil -> - :error - end - - _e -> - :error end end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 0b6b55156..eec9488e7 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase + alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -15,6 +16,28 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Pleroma.Factory + describe "delete objects" do + setup do + user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) + object = Object.normalize(post) + {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"]) + {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true) + %{user: user, delete: delete, post: post, object: object} + end + + test "it handles object deletions", %{delete: delete, post: post, object: object} do + # In object deletions, the object is replaced by a tombstone and the + # create activity is deleted + + {:ok, _delete, _} = SideEffects.handle(delete) + + object = Object.get_by_id(object.id) + assert object.data["type"] == "Tombstone" + refute Activity.get_by_id(post.id) + end + end + describe "like objects" do setup do poster = insert(:user) diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs index c15de5a95..64c908a05 100644 --- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs @@ -68,7 +68,7 @@ test "it fails for incoming deletes with spoofed origin" do |> Map.put("object", object) assert capture_log(fn -> - :error = Transmogrifier.handle_incoming(data) + {:error, _} = Transmogrifier.handle_incoming(data) end) =~ "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}" @@ -97,9 +97,7 @@ test "it fails for incoming user deletes with spoofed origin" do |> Poison.decode!() |> Map.put("actor", ap_id) - assert capture_log(fn -> - assert :error == Transmogrifier.handle_incoming(data) - end) =~ "Object containment failed" + assert match?({:error, _}, Transmogrifier.handle_incoming(data)) assert User.get_cached_by_ap_id(ap_id) end -- cgit v1.2.3 From 1fb383f368b861d7aea77770ba7be6e3dfe3468e Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 15:42:30 +0200 Subject: DeleteValidator: Deleting a user is valid. --- lib/pleroma/web/activity_pub/builder.ex | 15 +++++++++++++-- .../activity_pub/object_validators/common_validations.ex | 11 +++++++++++ .../activity_pub/object_validators/delete_validator.ex | 2 +- test/web/activity_pub/object_validator_test.exs | 7 ++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 5cc46c3ea..1345a3a3e 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -12,9 +12,20 @@ defmodule Pleroma.Web.ActivityPub.Builder do @spec delete(User.t(), String.t()) :: {:ok, map(), keyword()} def delete(actor, object_id) do - object = Object.normalize(object_id) + object = Object.normalize(object_id, false) - to = (object.data["to"] || []) ++ (object.data["cc"] || []) + user = !object && User.get_cached_by_ap_id(object_id) + + to = + case {object, user} do + {%Object{}, _} -> + # We are deleting an object, address everyone who was originally mentioned + (object.data["to"] || []) ++ (object.data["cc"] || []) + + {_, %User{follower_address: follower_address}} -> + # We are deleting a user, address the followers of that user + [follower_address] + end {:ok, %{ diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index e115d9526..d9a629a34 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -49,4 +49,15 @@ def validate_object_presence(cng, field_name \\ :object) do end end) end + + def validate_object_or_user_presence(cng, field_name \\ :object) do + cng + |> validate_change(field_name, fn field_name, object -> + if Object.get_cached_by_ap_id(object) || User.get_cached_by_ap_id(object) do + [] + else + [{field_name, "can't find object"}] + end + end) + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index 0eb31451c..fa1713b50 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -31,7 +31,7 @@ def validate_data(cng) do |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) |> validate_same_domain() - |> validate_object_presence() + |> validate_object_or_user_presence() end def validate_same_domain(cng) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index ab26d3501..83b21a9bc 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -15,14 +15,19 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do {:ok, post_activity} = CommonAPI.post(user, %{"status" => "cancel me daddy"}) {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"]) + {:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id) - %{user: user, valid_post_delete: valid_post_delete} + %{user: user, valid_post_delete: valid_post_delete, valid_user_delete: valid_user_delete} end test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do assert match?({:ok, _, _}, ObjectValidator.validate(valid_post_delete, [])) end + test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do + assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, [])) + end + test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do no_id = valid_post_delete -- cgit v1.2.3 From 417eed4a2b10b0a1fd916839ddb03d0345966123 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 15:57:27 +0200 Subject: SideEffects: Handle deletions. --- lib/pleroma/web/activity_pub/side_effects.ex | 22 ++++++++++++++++++++-- test/web/activity_pub/side_effects_test.exs | 14 +++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 93698a834..ac1d4c222 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do """ alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -33,10 +34,27 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Replace object with Tombstone # - Set up notification def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do - with %Object{} = deleted_object <- Object.normalize(deleted_object), - {:ok, _, _} <- Object.delete(deleted_object) do + deleted_object = + Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) + + result = + case deleted_object do + %Object{} -> + with {:ok, _, _} <- Object.delete(deleted_object) do + :ok + end + + %User{} -> + with {:ok, _} <- User.delete(deleted_object) do + :ok + end + end + + if result == :ok do Notification.create_notifications(object) {:ok, object, meta} + else + {:error, result} end end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index eec9488e7..b3d0addc7 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -3,12 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.SideEffectsTest do + use Oban.Testing, repo: Pleroma.Repo use Pleroma.DataCase alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Tests.ObanHelpers alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects @@ -22,8 +25,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) object = Object.normalize(post) {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"]) + {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id) {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true) - %{user: user, delete: delete, post: post, object: object} + {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true) + %{user: user, delete: delete, post: post, object: object, delete_user: delete_user} end test "it handles object deletions", %{delete: delete, post: post, object: object} do @@ -36,6 +41,13 @@ test "it handles object deletions", %{delete: delete, post: post, object: object assert object.data["type"] == "Tombstone" refute Activity.get_by_id(post.id) end + + test "it handles user deletions", %{delete_user: delete, user: user} do + {:ok, _delete, _} = SideEffects.handle(delete) + ObanHelpers.perform_all() + + refute User.get_cached_by_ap_id(user.ap_id) + end end describe "like objects" do -- cgit v1.2.3 From c9bfa51ea9c0048ffa4c0d3e28c196da2f38e384 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 15:58:37 +0200 Subject: Credo fixes. --- test/web/activity_pub/side_effects_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b3d0addc7..fffe0ca38 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -10,8 +10,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo - alias Pleroma.User alias Pleroma.Tests.ObanHelpers + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects -- cgit v1.2.3 From fdd8e7f27697a7128e4e92020cdff6389c999acc Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 16:15:38 +0200 Subject: CommonAPI: Use common pipeline for deletions. --- lib/pleroma/web/activity_pub/side_effects.ex | 6 ++++-- lib/pleroma/web/common_api/common_api.ex | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index ac1d4c222..ef58fa399 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -30,7 +30,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do end # Tasks this handles: - # - Delete create activity + # - Delete and unpins the create activity # - Replace object with Tombstone # - Set up notification def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do @@ -40,7 +40,9 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, result = case deleted_object do %Object{} -> - with {:ok, _, _} <- Object.delete(deleted_object) do + with {:ok, _, activity} <- Object.delete(deleted_object), + %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do + User.remove_pinnned_activity(user, activity) :ok end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index d1efe0c36..7cb8e47d0 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -77,8 +77,8 @@ def delete(activity_id, user) do {:find_activity, Activity.get_by_id_with_object(activity_id)}, %Object{} = object <- Object.normalize(activity), true <- User.superuser?(user) || user.ap_id == object.data["actor"], - {:ok, _} <- unpin(activity_id, user), - {:ok, delete} <- ActivityPub.delete(object) do + {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), + {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do {:ok, delete} else {:find_activity, _} -> {:error, :not_found} -- cgit v1.2.3 From 14c667219334c492ae0549ad0f1e062085d7d412 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 16:49:41 +0200 Subject: AP C2S: Use common pipelin for deletes. --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index d625530ec..e68d0763e 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -414,7 +414,8 @@ defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do with %Object{} = object <- Object.normalize(params["object"]), true <- user.is_moderator || user.ap_id == object.data["actor"], - {:ok, delete} <- ActivityPub.delete(object) do + {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), + {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do {:ok, delete} else _ -> {:error, dgettext("errors", "Can't delete object")} -- cgit v1.2.3 From 4a487e4d0b744edac0d83306b80e4bd0cdef5358 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 30 Apr 2020 17:50:57 +0300 Subject: fix for auth check --- lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index 7af9d38a1..d276b96a4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"], admin: true} when action in [ - :import, + :import_from_filesystem, :remote, :download, :create, @@ -22,7 +22,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do plug( :skip_plug, [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] - when action in [:download_shared, :list_packs, :list_from] + when action in [:archive, :show, :list] ) def remote(conn, %{"url" => url}) do -- cgit v1.2.3 From 2c4844237f294d27f58737f9694f77b1cfcb10e7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 30 Apr 2020 18:19:51 +0300 Subject: Refactoring of :if_func / :unless_func plug options (general availability). Added tests for Pleroma.Web.Plug. --- lib/pleroma/plugs/ensure_authenticated_plug.ex | 17 +--- lib/pleroma/plugs/federating_plug.ex | 3 + .../web/activity_pub/activity_pub_controller.ex | 2 +- lib/pleroma/web/feed/user_controller.ex | 2 +- lib/pleroma/web/ostatus/ostatus_controller.ex | 2 +- lib/pleroma/web/static_fe/static_fe_controller.ex | 2 +- lib/pleroma/web/web.ex | 10 ++- test/plugs/ensure_authenticated_plug_test.exs | 4 +- test/web/plugs/plug_test.exs | 91 ++++++++++++++++++++++ 9 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 test/web/plugs/plug_test.exs diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 9c8f5597f..9d5176e2b 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -19,22 +19,7 @@ def perform(%{assigns: %{user: %User{}}} = conn, _) do conn end - def perform(conn, options) do - perform = - cond do - options[:if_func] -> options[:if_func].() - options[:unless_func] -> !options[:unless_func].() - true -> true - end - - if perform do - fail(conn) - else - conn - end - end - - def fail(conn) do + def perform(conn, _) do conn |> render_error(:forbidden, "Invalid credentials.") |> halt() diff --git a/lib/pleroma/plugs/federating_plug.ex b/lib/pleroma/plugs/federating_plug.ex index 7d947339f..09038f3c6 100644 --- a/lib/pleroma/plugs/federating_plug.ex +++ b/lib/pleroma/plugs/federating_plug.ex @@ -19,6 +19,9 @@ def call(conn, _opts) do def federating?, do: Pleroma.Config.get([:instance, :federating]) + # Definition for the use in :if_func / :unless_func plug options + def federating?(_conn), do: federating?() + defp fail(conn) do conn |> put_status(404) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index d625530ec..a909516be 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do plug( EnsureAuthenticatedPlug, - [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions + [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions ) plug( diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index e27f85929..1b72e23dc 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -27,7 +27,7 @@ def feed_redirect(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do with %{halted: false} = conn <- Pleroma.Plugs.EnsureAuthenticatedPlug.call(conn, - unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 ) do ActivityPubController.call(conn, :user) end diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 6fd3cfce5..6971cd9f8 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Router plug(Pleroma.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 ) plug( diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 7a35238d7..c3efb6651 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -18,7 +18,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do plug(:assign_id) plug(Pleroma.Plugs.EnsureAuthenticatedPlug, - unless_func: &Pleroma.Web.FederatingPlug.federating?/0 + unless_func: &Pleroma.Web.FederatingPlug.federating?/1 ) @page_keys ["max_id", "min_id", "limit", "since_id", "order"] diff --git a/lib/pleroma/web/web.ex b/lib/pleroma/web/web.ex index 08e42a7e5..4f9281851 100644 --- a/lib/pleroma/web/web.ex +++ b/lib/pleroma/web/web.ex @@ -200,11 +200,17 @@ def skip_plug(conn) do @impl Plug @doc """ - If marked as skipped, returns `conn`, otherwise calls `perform/2`. + Before-plug hook that + * ensures the plug is not skipped + * processes `:if_func` / `:unless_func` functional pre-run conditions + * adds plug to the list of called plugs and calls `perform/2` if checks are passed + Note: multiple invocations of the same plug (with different or same options) are allowed. """ def call(%Plug.Conn{} = conn, options) do - if PlugHelper.plug_skipped?(conn, __MODULE__) do + if PlugHelper.plug_skipped?(conn, __MODULE__) || + (options[:if_func] && !options[:if_func].(conn)) || + (options[:unless_func] && options[:unless_func].(conn)) do conn else conn = diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs index 689fe757f..4e6142aab 100644 --- a/test/plugs/ensure_authenticated_plug_test.exs +++ b/test/plugs/ensure_authenticated_plug_test.exs @@ -27,8 +27,8 @@ test "it continues if a user is assigned", %{conn: conn} do describe "with :if_func / :unless_func options" do setup do %{ - true_fn: fn -> true end, - false_fn: fn -> false end + true_fn: fn _conn -> true end, + false_fn: fn _conn -> false end } end diff --git a/test/web/plugs/plug_test.exs b/test/web/plugs/plug_test.exs new file mode 100644 index 000000000..943e484e7 --- /dev/null +++ b/test/web/plugs/plug_test.exs @@ -0,0 +1,91 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PlugTest do + @moduledoc "Tests for the functionality added via `use Pleroma.Web, :plug`" + + alias Pleroma.Plugs.ExpectAuthenticatedCheckPlug + alias Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug + alias Pleroma.Plugs.PlugHelper + + import Mock + + use Pleroma.Web.ConnCase + + describe "when plug is skipped, " do + setup_with_mocks( + [ + {ExpectPublicOrAuthenticatedCheckPlug, [:passthrough], []} + ], + %{conn: conn} + ) do + conn = ExpectPublicOrAuthenticatedCheckPlug.skip_plug(conn) + %{conn: conn} + end + + test "it neither adds plug to called plugs list nor calls `perform/2`, " <> + "regardless of :if_func / :unless_func options", + %{conn: conn} do + for opts <- [%{}, %{if_func: fn _ -> true end}, %{unless_func: fn _ -> false end}] do + ret_conn = ExpectPublicOrAuthenticatedCheckPlug.call(conn, opts) + + refute called(ExpectPublicOrAuthenticatedCheckPlug.perform(:_, :_)) + refute PlugHelper.plug_called?(ret_conn, ExpectPublicOrAuthenticatedCheckPlug) + end + end + end + + describe "when plug is NOT skipped, " do + setup_with_mocks([{ExpectAuthenticatedCheckPlug, [:passthrough], []}]) do + :ok + end + + test "with no pre-run checks, adds plug to called plugs list and calls `perform/2`", %{ + conn: conn + } do + ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{}) + + assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_)) + assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug) + end + + test "when :if_func option is given, calls the plug only if provided function evals tru-ish", + %{conn: conn} do + ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> false end}) + + refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_)) + refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug) + + ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{if_func: fn _ -> true end}) + + assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_)) + assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug) + end + + test "if :unless_func option is given, calls the plug only if provided function evals falsy", + %{conn: conn} do + ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> true end}) + + refute called(ExpectAuthenticatedCheckPlug.perform(:_, :_)) + refute PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug) + + ret_conn = ExpectAuthenticatedCheckPlug.call(conn, %{unless_func: fn _ -> false end}) + + assert called(ExpectAuthenticatedCheckPlug.perform(ret_conn, :_)) + assert PlugHelper.plug_called?(ret_conn, ExpectAuthenticatedCheckPlug) + end + + test "allows a plug to be called multiple times (even if it's in called plugs list)", %{ + conn: conn + } do + conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value1}) + assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value1})) + + assert PlugHelper.plug_called?(conn, ExpectAuthenticatedCheckPlug) + + conn = ExpectAuthenticatedCheckPlug.call(conn, %{an_option: :value2}) + assert called(ExpectAuthenticatedCheckPlug.perform(conn, %{an_option: :value2})) + end + end +end -- cgit v1.2.3 From bef34568f0d005baabca266b99ac0f6e620e6899 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Thu, 30 Apr 2020 15:02:35 +0300 Subject: Dismiss the follow request notification on rejection --- lib/pleroma/notification.ex | 10 ++++++++++ lib/pleroma/web/common_api/common_api.ex | 2 ++ test/notification_test.exs | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index aaa675253..9a109dfab 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -261,6 +261,16 @@ def destroy_multiple(%{id: user_id} = _user, ids) do |> Repo.delete_all() end + def dismiss(%Pleroma.Activity{} = activity) do + Notification + |> where([n], n.activity_id == ^activity.id) + |> Repo.delete_all() + |> case do + {_, notifications} -> {:ok, notifications} + _ -> {:error, "Cannot dismiss notification"} + end + end + def dismiss(%{id: user_id} = _user, id) do notification = Repo.get(Notification, id) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index d1efe0c36..4112e441a 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation alias Pleroma.FollowingRelationship + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User @@ -61,6 +62,7 @@ def reject_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), + {:ok, _notifications} <- Notification.dismiss(follow_activity), {:ok, _activity} <- ActivityPub.reject(%{ to: [follower.ap_id], diff --git a/test/notification_test.exs b/test/notification_test.exs index 6ad824c57..0e9ffcb18 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -362,6 +362,16 @@ test "it doesn't create a notification for follow-unfollow-follow chains" do notification_id = notification.id assert [%{id: ^notification_id}] = Notification.for_user(followed_user) end + + test "dismisses the notification on follow request rejection" do + clear_config([:notifications, :enable_follow_request_notifications], true) + user = insert(:user, locked: true) + follower = insert(:user) + {:ok, _, _, _follow_activity} = CommonAPI.follow(follower, user) + assert [notification] = Notification.for_user(user) + {:ok, _follower} = CommonAPI.reject_follow_request(follower, user) + assert [] = Notification.for_user(user) + end end describe "get notification" do -- cgit v1.2.3 From 143353432a562c49f4432e74a549321c5b43650d Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 17:52:29 +0200 Subject: StreamerTest: Separate deletion test. --- test/web/streamer/streamer_test.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 8b8d8af6c..3c0f240f5 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -210,6 +210,12 @@ test "it sends to public" do Worker.push_to_socket(topics, "public", activity) Task.await(task) + end + + test "works for deletions" do + user = insert(:user) + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"}) task = Task.async(fn -> -- cgit v1.2.3 From 4500fdc04c528331f7289745dc08a34ce18d4da7 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 17:53:02 +0200 Subject: DeleteValidator: Add internal helper field after validation. --- .../activity_pub/object_validators/delete_validator.ex | 16 ++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index fa1713b50..951cc1414 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do use Ecto.Schema + alias Pleroma.Activity alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @@ -18,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do field(:actor, Types.ObjectID) field(:to, Types.Recipients, default: []) field(:cc, Types.Recipients, default: []) + field(:deleted_activity_id) field(:object, Types.ObjectID) end @@ -26,12 +28,26 @@ def cast_data(data) do |> cast(data, __schema__(:fields)) end + def add_deleted_activity_id(cng) do + object = + cng + |> get_field(:object) + + with %Activity{id: id} <- Activity.get_create_by_object_ap_id(object) do + cng + |> put_change(:deleted_activity_id, id) + else + _ -> cng + end + end + def validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) |> validate_same_domain() |> validate_object_or_user_presence() + |> add_deleted_activity_id() end def validate_same_domain(cng) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 83b21a9bc..9e0589722 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -21,7 +21,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do end test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do - assert match?({:ok, _, _}, ObjectValidator.validate(valid_post_delete, [])) + {:ok, valid_post_delete_u, _} = ObjectValidator.validate(valid_post_delete, []) + + assert valid_post_delete_u["deleted_activity_id"] end test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do -- cgit v1.2.3 From c832d96fc9fc0b93befdf3a7064a8c9236e96d07 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 17:58:09 +0200 Subject: SideEffects: Stream out deletes. --- lib/pleroma/web/activity_pub/side_effects.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index ef58fa399..d260e0069 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.ActivityPub def handle(object, meta \\ []) @@ -40,9 +41,12 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, result = case deleted_object do %Object{} -> - with {:ok, _, activity} <- Object.delete(deleted_object), + with {:ok, deleted_object, activity} <- Object.delete(deleted_object), %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do User.remove_pinnned_activity(user, activity) + + ActivityPub.stream_out(object) + ActivityPub.stream_out_participations(deleted_object, user) :ok end -- cgit v1.2.3 From 315b773dd9fa185aef75b115efd90ac92113e6c3 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 17:58:31 +0200 Subject: ObjectValidator: Refactor. --- test/web/activity_pub/object_validator_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 9e0589722..1d3646487 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -21,9 +21,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do end test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do - {:ok, valid_post_delete_u, _} = ObjectValidator.validate(valid_post_delete, []) + {:ok, valid_post_delete, _} = ObjectValidator.validate(valid_post_delete, []) - assert valid_post_delete_u["deleted_activity_id"] + assert valid_post_delete["deleted_activity_id"] end test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do -- cgit v1.2.3 From 3d0dc58e2e0a84cb46df5339596205f7baceb0a4 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 18:10:36 +0200 Subject: SideEffectsTest: Test streaming. --- test/web/activity_pub/side_effects_test.exs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index fffe0ca38..f5c57d887 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -18,6 +18,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Web.CommonAPI import Pleroma.Factory + import Mock describe "delete objects" do setup do @@ -33,9 +34,16 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do test "it handles object deletions", %{delete: delete, post: post, object: object} do # In object deletions, the object is replaced by a tombstone and the - # create activity is deleted + # create activity is deleted. - {:ok, _delete, _} = SideEffects.handle(delete) + with_mock Pleroma.Web.ActivityPub.ActivityPub, + stream_out: fn _ -> nil end, + stream_out_participations: fn _, _ -> nil end do + {:ok, delete, _} = SideEffects.handle(delete) + user = User.get_cached_by_ap_id(object.data["actor"]) + assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete)) + assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user)) + end object = Object.get_by_id(object.id) assert object.data["type"] == "Tombstone" -- cgit v1.2.3 From ab60ee17765ee9d7dcb69cbf9c0630b97d4f5a93 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 18:19:39 +0200 Subject: SideEffects: On deletion, reduce the User note count. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ test/web/activity_pub/side_effects_test.exs | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index d260e0069..4fec3a797 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -34,6 +34,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Delete and unpins the create activity # - Replace object with Tombstone # - Set up notification + # - Reduce the user note count def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) @@ -45,6 +46,7 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do User.remove_pinnned_activity(user, activity) + {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) :ok diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index f5c57d887..06b3400d8 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -32,15 +32,16 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do %{user: user, delete: delete, post: post, object: object, delete_user: delete_user} end - test "it handles object deletions", %{delete: delete, post: post, object: object} do + test "it handles object deletions", %{delete: delete, post: post, object: object, user: user} do # In object deletions, the object is replaced by a tombstone and the # create activity is deleted. - with_mock Pleroma.Web.ActivityPub.ActivityPub, + with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough], stream_out: fn _ -> nil end, stream_out_participations: fn _, _ -> nil end do {:ok, delete, _} = SideEffects.handle(delete) user = User.get_cached_by_ap_id(object.data["actor"]) + assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete)) assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user)) end @@ -48,6 +49,9 @@ test "it handles object deletions", %{delete: delete, post: post, object: object object = Object.get_by_id(object.id) assert object.data["type"] == "Tombstone" refute Activity.get_by_id(post.id) + + user = User.get_by_id(user.id) + assert user.note_count == 0 end test "it handles user deletions", %{delete_user: delete, user: user} do -- cgit v1.2.3 From 60db58a1c6a2f139960d3db19cba08a496e6ccf4 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 18:38:37 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 4fec3a797..cf31de120 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -8,8 +8,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) -- cgit v1.2.3 From f1523f9acd61021d5026e8f8f991fa5c21f23b1f Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 30 Apr 2020 18:55:25 +0200 Subject: Increase tests on AP C2S Related: https://git.pleroma.social/pleroma/pleroma/-/issues/954 --- .../activity_pub/activity_pub_controller_test.exs | 70 ++++++++++++++++------ 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index eca526604..6b5913f95 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -765,51 +765,87 @@ test "it requires authentication if instance is NOT federating", %{ end end - describe "POST /users/:nickname/outbox" do - test "it rejects posts from other users / unauthenticated users", %{conn: conn} do - data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!() + describe "POST /users/:nickname/outbox (C2S)" do + setup do + [ + activity: %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Create", + "object" => %{"type" => "Note", "content" => "AP C2S test"}, + "to" => "https://www.w3.org/ns/activitystreams#Public", + "cc" => [] + } + ] + end + + test "it rejects posts from other users / unauthenticated users", %{ + conn: conn, + activity: activity + } do user = insert(:user) other_user = insert(:user) conn = put_req_header(conn, "content-type", "application/activity+json") conn - |> post("/users/#{user.nickname}/outbox", data) + |> post("/users/#{user.nickname}/outbox", activity) |> json_response(403) conn |> assign(:user, other_user) - |> post("/users/#{user.nickname}/outbox", data) + |> post("/users/#{user.nickname}/outbox", activity) |> json_response(403) end - test "it inserts an incoming create activity into the database", %{conn: conn} do - data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!() + test "it inserts an incoming create activity into the database", %{ + conn: conn, + activity: activity + } do user = insert(:user) - conn = + result = conn |> assign(:user, user) |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", data) - - result = json_response(conn, 201) + |> post("/users/#{user.nickname}/outbox", activity) + |> json_response(201) assert Activity.get_by_ap_id(result["id"]) + assert result["object"] + assert %Object{data: object} = Object.normalize(result["object"]) + assert object["content"] == activity["object"]["content"] end - test "it rejects an incoming activity with bogus type", %{conn: conn} do - data = File.read!("test/fixtures/activitypub-client-post-activity.json") |> Poison.decode!() + test "it inserts an incoming sensitive activity into the database", %{ + conn: conn, + activity: activity + } do user = insert(:user) + object = Map.put(activity["object"], "sensitive", true) + activity = Map.put(activity, "object", object) - data = - data - |> Map.put("type", "BadType") + result = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", activity) + |> json_response(201) + + assert Activity.get_by_ap_id(result["id"]) + assert result["object"] + assert %Object{data: object} = Object.normalize(result["object"]) + assert object["sensitive"] == activity["object"]["sensitive"] + assert object["content"] == activity["object"]["content"] + end + + test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do + user = insert(:user) + activity = Map.put(activity, "type", "BadType") conn = conn |> assign(:user, user) |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", data) + |> post("/users/#{user.nickname}/outbox", activity) assert json_response(conn, 400) end -- cgit v1.2.3 From 500f5ec14eb02cd1c5a07970a557756b590caab0 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 19:47:13 +0200 Subject: SideEffects: On deletion, reduce the reply count cache --- lib/pleroma/web/activity_pub/side_effects.ex | 6 ++++++ test/web/activity_pub/side_effects_test.exs | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index cf31de120..39b0f384b 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -35,6 +35,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Replace object with Tombstone # - Set up notification # - Reduce the user note count + # - TODO: Reduce the reply count def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) @@ -47,6 +48,11 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, User.remove_pinnned_activity(user, activity) {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) + + if in_reply_to = deleted_object.data["inReplyTo"] do + Object.decrease_replies_count(in_reply_to) + end + ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) :ok diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 06b3400d8..ce34eed4c 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -23,19 +23,25 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do describe "delete objects" do setup do user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "hey"}) + other_user = insert(:user) + + {:ok, op} = CommonAPI.post(other_user, %{"status" => "big oof"}) + {:ok, post} = CommonAPI.post(user, %{"status" => "hey", "in_reply_to_id" => op}) object = Object.normalize(post) {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"]) {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id) {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true) {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true) - %{user: user, delete: delete, post: post, object: object, delete_user: delete_user} + %{user: user, delete: delete, post: post, object: object, delete_user: delete_user, op: op} end - test "it handles object deletions", %{delete: delete, post: post, object: object, user: user} do - # In object deletions, the object is replaced by a tombstone and the - # create activity is deleted. - + test "it handles object deletions", %{ + delete: delete, + post: post, + object: object, + user: user, + op: op + } do with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough], stream_out: fn _ -> nil end, stream_out_participations: fn _, _ -> nil end do @@ -52,6 +58,10 @@ test "it handles object deletions", %{delete: delete, post: post, object: object user = User.get_by_id(user.id) assert user.note_count == 0 + + object = Object.normalize(op.data["object"], false) + + assert object.data["repliesCount"] == 0 end test "it handles user deletions", %{delete_user: delete, user: user} do -- cgit v1.2.3 From 5da08c2b73f9ce1f369434fbd2c11092007e4910 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 19:53:30 +0200 Subject: SideEffects: Fix comment --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- test/user_test.exs | 28 +--------------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 39b0f384b..139e609f4 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -35,7 +35,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Replace object with Tombstone # - Set up notification # - Reduce the user note count - # - TODO: Reduce the reply count + # - Reduce the reply count def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) diff --git a/test/user_test.exs b/test/user_test.exs index 347c5be72..23afc605c 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -15,7 +15,6 @@ defmodule Pleroma.UserTest do use Pleroma.DataCase use Oban.Testing, repo: Pleroma.Repo - import Mock import Pleroma.Factory import ExUnit.CaptureLog @@ -1131,7 +1130,7 @@ test ".delete_user_activities deletes all create activities", %{user: user} do User.delete_user_activities(user) - # TODO: Remove favorites, repeats, delete activities. + # TODO: Test removal favorites, repeats, delete activities. refute Activity.get_by_id(activity.id) end @@ -1180,31 +1179,6 @@ test "it deletes a user, all follow relationships and all activities", %{user: u refute Activity.get_by_id(like_two.id) refute Activity.get_by_id(repeat.id) end - - test_with_mock "it sends out User Delete activity", - %{user: user}, - Pleroma.Web.ActivityPub.Publisher, - [:passthrough], - [] do - Pleroma.Config.put([:instance, :federating], true) - - {:ok, follower} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin") - {:ok, _} = User.follow(follower, user) - - {:ok, job} = User.delete(user) - {:ok, _user} = ObanHelpers.perform(job) - - assert ObanHelpers.member?( - %{ - "op" => "publish_one", - "params" => %{ - "inbox" => "http://mastodon.example.org/inbox", - "id" => "pleroma:fakeid" - } - }, - all_enqueued(worker: Pleroma.Workers.PublisherWorker) - ) - end end test "get_public_key_for_ap_id fetches a user that's not in the db" do -- cgit v1.2.3 From 3b443cbc1dd79b0450e17192aa51a00282b54d2e Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 20:08:25 +0200 Subject: User: Use common pipeline to delete user activities --- lib/pleroma/user.ex | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b451202b2..c780f99eb 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -29,7 +29,9 @@ defmodule Pleroma.User do alias Pleroma.UserRelationship alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils @@ -1427,8 +1429,6 @@ def perform(:force_password_reset, user), do: force_password_reset(user) @spec perform(atom(), User.t()) :: {:ok, User.t()} def perform(:delete, %User{} = user) do - {:ok, _user} = ActivityPub.delete(user) - # Remove all relationships user |> get_followers() @@ -1531,21 +1531,23 @@ def follow_import(%User{} = follower, followed_identifiers) }) end - def delete_user_activities(%User{ap_id: ap_id}) do + def delete_user_activities(%User{ap_id: ap_id} = user) do ap_id |> Activity.Queries.by_actor() |> RepoStreamer.chunk_stream(50) - |> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end) + |> Stream.each(fn activities -> + Enum.each(activities, fn activity -> delete_activity(activity, user) end) + end) |> Stream.run() end - defp delete_activity(%{data: %{"type" => "Create"}} = activity) do - activity - |> Object.normalize() - |> ActivityPub.delete() + defp delete_activity(%{data: %{"type" => "Create", "object" => object}}, user) do + {:ok, delete_data, _} = Builder.delete(user, object) + + Pipeline.common_pipeline(delete_data, local: true) end - defp delete_activity(%{data: %{"type" => "Like"}} = activity) do + defp delete_activity(%{data: %{"type" => "Like"}} = activity, _user) do object = Object.normalize(activity) activity.actor @@ -1553,7 +1555,7 @@ defp delete_activity(%{data: %{"type" => "Like"}} = activity) do |> ActivityPub.unlike(object) end - defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do + defp delete_activity(%{data: %{"type" => "Announce"}} = activity, _user) do object = Object.normalize(activity) activity.actor @@ -1561,7 +1563,7 @@ defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do |> ActivityPub.unannounce(object) end - defp delete_activity(_activity), do: "Doing nothing" + defp delete_activity(_activity, _user), do: "Doing nothing" def html_filter_policy(%User{no_rich_text: true}) do Pleroma.HTML.Scrubber.TwitterText -- cgit v1.2.3 From 999d639873b70f75c340dbac3360d25bca27a998 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 20:13:47 +0200 Subject: ActivityPub: Remove `delete` function. This is handled by the common pipeline now. --- lib/pleroma/web/activity_pub/activity_pub.ex | 61 ------------ test/web/activity_pub/activity_pub_test.exs | 137 --------------------------- 2 files changed, 198 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1f4a09370..51f002129 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -519,67 +519,6 @@ defp do_unfollow(follower, followed, activity_id, local) do end end - @spec delete(User.t() | Object.t(), keyword()) :: {:ok, User.t() | Object.t()} | {:error, any()} - def delete(entity, options \\ []) do - with {:ok, result} <- Repo.transaction(fn -> do_delete(entity, options) end) do - result - end - end - - defp do_delete(%User{ap_id: ap_id, follower_address: follower_address} = user, _) do - with data <- %{ - "to" => [follower_address], - "type" => "Delete", - "actor" => ap_id, - "object" => %{"type" => "Person", "id" => ap_id} - }, - {:ok, activity} <- insert(data, true, true, true), - :ok <- maybe_federate(activity) do - {:ok, user} - end - end - - defp do_delete(%Object{data: %{"id" => id, "actor" => actor}} = object, options) do - local = Keyword.get(options, :local, true) - activity_id = Keyword.get(options, :activity_id, nil) - actor = Keyword.get(options, :actor, actor) - - user = User.get_cached_by_ap_id(actor) - to = (object.data["to"] || []) ++ (object.data["cc"] || []) - - with create_activity <- Activity.get_create_by_object_ap_id(id), - data <- - %{ - "type" => "Delete", - "actor" => actor, - "object" => id, - "to" => to, - "deleted_activity_id" => create_activity && create_activity.id - } - |> maybe_put("id", activity_id), - {:ok, activity} <- insert(data, local, false), - {:ok, object, _create_activity} <- Object.delete(object), - stream_out_participations(object, user), - _ <- decrease_replies_count_if_reply(object), - {:ok, _actor} <- decrease_note_count_if_public(user, object), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - {:error, error} -> - Repo.rollback(error) - end - end - - defp do_delete(%Object{data: %{"type" => "Tombstone", "id" => ap_id}}, _) do - activity = - ap_id - |> Activity.Queries.by_object_id() - |> Activity.Queries.by_type("Delete") - |> Repo.one() - - {:ok, activity} - end - @spec block(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | {:error, any()} def block(blocker, blocked, activity_id \\ nil, local \\ true) do diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index edd7dfb22..b93ee708e 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1331,143 +1331,6 @@ test "creates an undo activity for the last block" do end end - describe "deletion" do - setup do: clear_config([:instance, :rewrite_policy]) - - test "it reverts deletion on error" do - note = insert(:note_activity) - object = Object.normalize(note) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.delete(object) - end - - assert Repo.aggregate(Activity, :count, :id) == 1 - assert Repo.get(Object, object.id) == object - assert Activity.get_by_id(note.id) == note - end - - test "it creates a delete activity and deletes the original object" do - note = insert(:note_activity) - object = Object.normalize(note) - {:ok, delete} = ActivityPub.delete(object) - - assert delete.data["type"] == "Delete" - assert delete.data["actor"] == note.data["actor"] - assert delete.data["object"] == object.data["id"] - - assert Activity.get_by_id(delete.id) != nil - - assert Repo.get(Object, object.id).data["type"] == "Tombstone" - end - - test "it doesn't fail when an activity was already deleted" do - {:ok, delete} = insert(:note_activity) |> Object.normalize() |> ActivityPub.delete() - - assert {:ok, ^delete} = delete |> Object.normalize() |> ActivityPub.delete() - end - - test "decrements user note count only for public activities" do - user = insert(:user, note_count: 10) - - {:ok, a1} = - CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "yeah", - "visibility" => "public" - }) - - {:ok, a2} = - CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "yeah", - "visibility" => "unlisted" - }) - - {:ok, a3} = - CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "yeah", - "visibility" => "private" - }) - - {:ok, a4} = - CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "yeah", - "visibility" => "direct" - }) - - {:ok, _} = Object.normalize(a1) |> ActivityPub.delete() - {:ok, _} = Object.normalize(a2) |> ActivityPub.delete() - {:ok, _} = Object.normalize(a3) |> ActivityPub.delete() - {:ok, _} = Object.normalize(a4) |> ActivityPub.delete() - - user = User.get_cached_by_id(user.id) - assert user.note_count == 10 - end - - test "it creates a delete activity and checks that it is also sent to users mentioned by the deleted object" do - user = insert(:user) - note = insert(:note_activity) - object = Object.normalize(note) - - {:ok, object} = - object - |> Object.change(%{ - data: %{ - "actor" => object.data["actor"], - "id" => object.data["id"], - "to" => [user.ap_id], - "type" => "Note" - } - }) - |> Object.update_and_set_cache() - - {:ok, delete} = ActivityPub.delete(object) - - assert user.ap_id in delete.data["to"] - end - - test "decreases reply count" do - user = insert(:user) - user2 = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"}) - reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id} - ap_id = activity.data["id"] - - {:ok, public_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public")) - {:ok, unlisted_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted")) - {:ok, private_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private")) - {:ok, direct_reply} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct")) - - _ = CommonAPI.delete(direct_reply.id, user2) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) - assert object.data["repliesCount"] == 2 - - _ = CommonAPI.delete(private_reply.id, user2) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) - assert object.data["repliesCount"] == 2 - - _ = CommonAPI.delete(public_reply.id, user2) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) - assert object.data["repliesCount"] == 1 - - _ = CommonAPI.delete(unlisted_reply.id, user2) - assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) - assert object.data["repliesCount"] == 0 - end - - test "it passes delete activity through MRF before deleting the object" do - Pleroma.Config.put([:instance, :rewrite_policy], Pleroma.Web.ActivityPub.MRF.DropPolicy) - - note = insert(:note_activity) - object = Object.normalize(note) - - {:error, {:reject, _}} = ActivityPub.delete(object) - - assert Activity.get_by_id(note.id) - assert Repo.get(Object, object.id).data["type"] == object.data["type"] - end - end - describe "timeline post-processing" do test "it filters broken threads" do user1 = insert(:user) -- cgit v1.2.3 From 32b8386edeec3e9b24123c3ccc81a22f1edd5a1c Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Apr 2020 21:23:18 +0200 Subject: DeleteValidator: Don't federate local deletions of remote objects. Closes #1497 --- lib/pleroma/web/activity_pub/object_validator.ex | 8 +-- .../object_validators/delete_validator.ex | 20 ++++-- lib/pleroma/web/activity_pub/pipeline.ex | 4 +- test/web/activity_pub/object_validator_test.exs | 17 ++++- test/web/common_api/common_api_test.exs | 80 ++++++++++++++++++++++ 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 32f606917..479f922f5 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -19,11 +19,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do def validate(object, meta) def validate(%{"type" => "Delete"} = object, meta) do - with {:ok, object} <- - object - |> DeleteValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do + with cng <- DeleteValidator.cast_and_validate(object), + do_not_federate <- DeleteValidator.do_not_federate?(cng), + {:ok, object} <- Ecto.Changeset.apply_action(cng, :insert) do object = stringify_keys(object) + meta = Keyword.put(meta, :do_not_federate, do_not_federate) {:ok, object, meta} end end diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index 951cc1414..a2eff7b69 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @@ -45,12 +46,17 @@ def validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) - |> validate_same_domain() + |> validate_actor_presence() + |> validate_deletion_rights() |> validate_object_or_user_presence() |> add_deleted_activity_id() end - def validate_same_domain(cng) do + def do_not_federate?(cng) do + !same_domain?(cng) + end + + defp same_domain?(cng) do actor_domain = cng |> get_field(:actor) @@ -63,11 +69,17 @@ def validate_same_domain(cng) do |> URI.parse() |> (& &1.host).() - if object_domain != actor_domain do + object_domain == actor_domain + end + + def validate_deletion_rights(cng) do + actor = User.get_cached_by_ap_id(get_field(cng, :actor)) + + if User.superuser?(actor) || same_domain?(cng) do cng - |> add_error(:actor, "is not allowed to delete object") else cng + |> add_error(:actor, "is not allowed to delete object") end end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 7ccee54c9..017e39abb 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -29,7 +29,9 @@ def common_pipeline(object, meta) do defp maybe_federate(activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do - if local do + do_not_federate = meta[:do_not_federate] + + if !do_not_federate && local do Federator.publish(activity) {:ok, :federated} else diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 1d3646487..412db09ff 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -52,9 +52,11 @@ test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post test "it's invalid if the actor of the object and the actor of delete are from different domains", %{valid_post_delete: valid_post_delete} do + valid_user = insert(:user) + valid_other_actor = valid_post_delete - |> Map.put("actor", valid_post_delete["actor"] <> "1") + |> Map.put("actor", valid_user.ap_id) assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, [])) @@ -66,6 +68,19 @@ test "it's invalid if the actor of the object and the actor of delete are from d assert {:actor, {"is not allowed to delete object", []}} in cng.errors end + + test "it's valid if the actor of the object is a local superuser", + %{valid_post_delete: valid_post_delete} do + user = + insert(:user, local: true, is_moderator: true, ap_id: "https://gensokyo.2hu/users/raymoo") + + valid_other_actor = + valid_post_delete + |> Map.put("actor", user.ap_id) + + {:ok, _, meta} = ObjectValidator.validate(valid_other_actor, []) + assert meta[:do_not_federate] + end end describe "likes" do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 1758662b0..32d91ce02 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -9,11 +9,13 @@ defmodule Pleroma.Web.CommonAPITest do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.CommonAPI import Pleroma.Factory + import Mock require Pleroma.Constants @@ -21,6 +23,84 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + describe "deletion" do + test "it allows users to delete their posts" do + user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, delete} = CommonAPI.delete(post.id, user) + assert delete.local + assert called(Pleroma.Web.Federator.publish(delete)) + end + + refute Activity.get_by_id(post.id) + end + + test "it does not allow a user to delete their posts" do + user = insert(:user) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + + assert {:error, "Could not delete"} = CommonAPI.delete(post.id, other_user) + assert Activity.get_by_id(post.id) + end + + test "it allows moderators to delete other user's posts" do + user = insert(:user) + moderator = insert(:user, is_moderator: true) + + {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + + assert {:ok, delete} = CommonAPI.delete(post.id, moderator) + assert delete.local + + refute Activity.get_by_id(post.id) + end + + test "it allows admins to delete other user's posts" do + user = insert(:user) + moderator = insert(:user, is_admin: true) + + {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + + assert {:ok, delete} = CommonAPI.delete(post.id, moderator) + assert delete.local + + refute Activity.get_by_id(post.id) + end + + test "superusers deleting non-local posts won't federate the delete" do + # This is the user of the ingested activity + _user = + insert(:user, + local: false, + ap_id: "http://mastodon.example.org/users/admin", + last_refreshed_at: NaiveDateTime.utc_now() + ) + + moderator = insert(:user, is_admin: true) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Jason.decode!() + + {:ok, post} = Transmogrifier.handle_incoming(data) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, delete} = CommonAPI.delete(post.id, moderator) + assert delete.local + refute called(Pleroma.Web.Federator.publish(:_)) + end + + refute Activity.get_by_id(post.id) + end + end + test "favoriting race condition" do user = insert(:user) users_serial = insert_list(10, :user) -- cgit v1.2.3 From 92efb888c7b25692af205b1a4dbce0ae689c439b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 1 May 2020 09:51:41 +0300 Subject: Made follow request notifications non-optional (removed config switch). --- CHANGELOG.md | 2 +- config/config.exs | 2 -- config/description.exs | 14 -------------- lib/pleroma/notification.ex | 11 +---------- test/notification_test.exs | 20 +------------------- 5 files changed, 3 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65dd1b9c2..97704917d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. - Mix task to create trusted OAuth App. -- Notifications: Added `follow_request` notification type (configurable, see `[:notifications, :enable_follow_request_notifications]` setting). +- Notifications: Added `follow_request` notification type. - Added `:reject_deletes` group to SimplePolicy
    API Changes diff --git a/config/config.exs b/config/config.exs index 2e538c4be..a6c6d6f99 100644 --- a/config/config.exs +++ b/config/config.exs @@ -562,8 +562,6 @@ inactivity_threshold: 7 } -config :pleroma, :notifications, enable_follow_request_notifications: false - config :pleroma, :oauth2, token_expires_in: 600, issue_new_refresh_token: true, diff --git a/config/description.exs b/config/description.exs index 7fac1e561..9d8e3b93c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2273,20 +2273,6 @@ } ] }, - %{ - group: :pleroma, - key: :notifications, - type: :group, - description: "Notification settings", - children: [ - %{ - key: :enable_follow_request_notifications, - type: :boolean, - description: - "Enables notifications on new follow requests (causes issues with older PleromaFE versions)." - } - ] - }, %{ group: :pleroma, key: Pleroma.Emails.UserEmail, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 9a109dfab..98289af08 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -293,17 +293,8 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act end end - def create_notifications(%Activity{data: %{"type" => "Follow"}} = activity) do - if Pleroma.Config.get([:notifications, :enable_follow_request_notifications]) || - Activity.follow_accepted?(activity) do - do_create_notifications(activity) - else - {:ok, []} - end - end - def create_notifications(%Activity{data: %{"type" => type}} = activity) - when type in ["Like", "Announce", "Move", "EmojiReact"] do + when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do do_create_notifications(activity) end diff --git a/test/notification_test.exs b/test/notification_test.exs index 0e9ffcb18..601a6c0ca 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -312,9 +312,7 @@ test "it creates `follow` notification for approved Follow activity" do }) end - test "if `follow_request` notifications are enabled, " <> - "it creates `follow_request` notification for pending Follow activity" do - clear_config([:notifications, :enable_follow_request_notifications], true) + test "it creates `follow_request` notification for pending Follow activity" do user = insert(:user) followed_user = insert(:user, locked: true) @@ -333,21 +331,6 @@ test "if `follow_request` notifications are enabled, " <> assert %{type: "follow"} = NotificationView.render("show.json", render_opts) end - test "if `follow_request` notifications are disabled, " <> - "it does NOT create `follow*` notification for pending Follow activity" do - clear_config([:notifications, :enable_follow_request_notifications], false) - user = insert(:user) - followed_user = insert(:user, locked: true) - - {:ok, _, _, _activity} = CommonAPI.follow(user, followed_user) - refute FollowingRelationship.following?(user, followed_user) - assert [] = Notification.for_user(followed_user) - - # After request is accepted, no new notifications are generated: - assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user) - assert [] = Notification.for_user(followed_user) - end - test "it doesn't create a notification for follow-unfollow-follow chains" do user = insert(:user) followed_user = insert(:user, locked: false) @@ -364,7 +347,6 @@ test "it doesn't create a notification for follow-unfollow-follow chains" do end test "dismisses the notification on follow request rejection" do - clear_config([:notifications, :enable_follow_request_notifications], true) user = insert(:user, locked: true) follower = insert(:user) {:ok, _, _, _follow_activity} = CommonAPI.follow(follower, user) -- cgit v1.2.3 From ecf37b46d2c06c701da390eba65239984afe683f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 1 May 2020 14:31:24 +0300 Subject: pagination fix for service users filters --- lib/pleroma/user/query.ex | 11 ++++--- lib/pleroma/web/admin_api/admin_api_controller.ex | 29 ++++------------- lib/pleroma/web/admin_api/search.ex | 1 + test/web/admin_api/admin_api_controller_test.exs | 38 +++++++++++++++++++++-- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index ac77aab71..3a3b04793 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -45,6 +45,7 @@ defmodule Pleroma.User.Query do is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), + exclude_service_users: boolean(), followers: User.t(), friends: User.t(), recipients_from_activity: [String.t()], @@ -88,6 +89,10 @@ defp compose_query({key, value}, query) where(query, [u], ilike(field(u, ^key), ^"%#{value}%")) end + defp compose_query({:exclude_service_users, _}, query) do + where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch")) + end + defp compose_query({key, value}, query) when key in @equal_criteria and not_empty_string(value) do where(query, [u], ^[{key, value}]) @@ -98,7 +103,7 @@ defp compose_query({key, values}, query) when key in @contains_criteria and is_l end defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do - Enum.reduce(tags, query, &prepare_tag_criteria/2) + where(query, [u], fragment("? && ?", u.tags, ^tags)) end defp compose_query({:is_admin, _}, query) do @@ -192,10 +197,6 @@ defp compose_query({:limit, limit}, query) do defp compose_query(_unsupported_param, query), do: query - defp prepare_tag_criteria(tag, query) do - or_where(query, [u], fragment("? = any(?)", ^tag, u.tags)) - end - defp location_query(query, local) do where(query, [u], u.local == ^local) |> where([u], not is_nil(u.nickname)) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 816c11e01..bfcc81cb8 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -392,29 +392,12 @@ def list_users(conn, params) do email: params["email"] } - with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)), - {:ok, users, count} <- filter_service_users(users, count), - do: - conn - |> json( - AccountView.render("index.json", - users: users, - count: count, - page_size: page_size - ) - ) - end - - defp filter_service_users(users, count) do - filtered_users = Enum.reject(users, &service_user?/1) - count = if Enum.any?(users, &service_user?/1), do: length(filtered_users), else: count - - {:ok, filtered_users, count} - end - - defp service_user?(user) do - String.match?(user.ap_id, ~r/.*\/relay$/) or - String.match?(user.ap_id, ~r/.*\/internal\/fetch$/) + with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do + json( + conn, + AccountView.render("index.json", users: users, count: count, page_size: page_size) + ) + end end @filters ~w(local external active deactivated is_admin is_moderator) diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex index 29cea1f44..c28efadd5 100644 --- a/lib/pleroma/web/admin_api/search.ex +++ b/lib/pleroma/web/admin_api/search.ex @@ -21,6 +21,7 @@ def user(params \\ %{}) do query = params |> Map.drop([:page, :page_size]) + |> Map.put(:exclude_service_users, true) |> User.Query.build() |> order_by([u], u.nickname) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index f80dbf8dd..e3af01089 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -18,6 +18,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.ReportNote alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI @@ -737,6 +738,39 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do } end + test "pagination works correctly with service users", %{conn: conn} do + service1 = insert(:user, ap_id: Web.base_url() <> "/relay") + service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch") + insert_list(25, :user) + + assert %{"count" => 26, "page_size" => 10, "users" => users1} = + conn + |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users1) == 10 + assert service1 not in [users1] + assert service2 not in [users1] + + assert %{"count" => 26, "page_size" => 10, "users" => users2} = + conn + |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users2) == 10 + assert service1 not in [users2] + assert service2 not in [users2] + + assert %{"count" => 26, "page_size" => 10, "users" => users3} = + conn + |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users3) == 6 + assert service1 not in [users3] + assert service2 not in [users3] + end + test "renders empty array for the second page", %{conn: conn} do insert(:user) @@ -3526,7 +3560,7 @@ test "errors", %{conn: conn} do end test "success", %{conn: conn} do - base_url = Pleroma.Web.base_url() + base_url = Web.base_url() app_name = "Trusted app" response = @@ -3547,7 +3581,7 @@ test "success", %{conn: conn} do end test "with trusted", %{conn: conn} do - base_url = Pleroma.Web.base_url() + base_url = Web.base_url() app_name = "Trusted app" response = -- cgit v1.2.3 From 5f42e6629d862f0a8dcbbd1527998685b6932d52 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 1 May 2020 13:34:47 +0200 Subject: DeleteValidator: Only allow deletion of certain types. --- .../object_validators/common_validations.ex | 48 ++++++++++++++-------- .../object_validators/delete_validator.ex | 12 +++++- lib/pleroma/web/activity_pub/side_effects.ex | 1 + test/web/activity_pub/object_validator_test.exs | 19 +++++++++ 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index d9a629a34..4e6ee2034 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -28,7 +28,9 @@ def validate_recipients_presence(cng, fields \\ [:to, :cc]) do end end - def validate_actor_presence(cng, field_name \\ :actor) do + def validate_actor_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :actor) + cng |> validate_change(field_name, fn field_name, actor -> if User.get_cached_by_ap_id(actor) do @@ -39,25 +41,39 @@ def validate_actor_presence(cng, field_name \\ :actor) do end) end - def validate_object_presence(cng, field_name \\ :object) do + def validate_object_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :object) + allowed_types = Keyword.get(options, :allowed_types, false) + cng - |> validate_change(field_name, fn field_name, object -> - if Object.get_cached_by_ap_id(object) do - [] - else - [{field_name, "can't find object"}] + |> validate_change(field_name, fn field_name, object_id -> + object = Object.get_cached_by_ap_id(object_id) + + cond do + !object -> + [{field_name, "can't find object"}] + + object && allowed_types && object.data["type"] not in allowed_types -> + [{field_name, "object not in allowed types"}] + + true -> + [] end end) end - def validate_object_or_user_presence(cng, field_name \\ :object) do - cng - |> validate_change(field_name, fn field_name, object -> - if Object.get_cached_by_ap_id(object) || User.get_cached_by_ap_id(object) do - [] - else - [{field_name, "can't find object"}] - end - end) + def validate_object_or_user_presence(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :object) + options = Keyword.put(options, :field_name, field_name) + + actor_cng = + cng + |> validate_actor_presence(options) + + object_cng = + cng + |> validate_object_presence(options) + + if actor_cng.valid?, do: actor_cng, else: object_cng end end diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index a2eff7b69..256ac70b6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -42,13 +42,23 @@ def add_deleted_activity_id(cng) do end end + @deletable_types ~w{ + Answer + Article + Audio + Event + Note + Page + Question + Video + } def validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) |> validate_actor_presence() |> validate_deletion_rights() - |> validate_object_or_user_presence() + |> validate_object_or_user_presence(allowed_types: @deletable_types) |> add_deleted_activity_id() end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 139e609f4..52bd5179f 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -36,6 +36,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Set up notification # - Reduce the user note count # - Reduce the reply count + # - Stream out the activity def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 412db09ff..7ab1c8ffb 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,6 +1,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -26,6 +27,24 @@ test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} assert valid_post_delete["deleted_activity_id"] end + test "it is invalid if the object isn't in a list of certain types", %{ + valid_post_delete: valid_post_delete + } do + object = Object.get_by_ap_id(valid_post_delete["object"]) + + data = + object.data + |> Map.put("type", "Like") + + {:ok, _object} = + object + |> Ecto.Changeset.change(%{data: data}) + |> Object.update_and_set_cache() + + {:error, cng} = ObjectValidator.validate(valid_post_delete, []) + assert {:object, {"object not in allowed types", []}} in cng.errors + end + test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, [])) end -- cgit v1.2.3 From 51f1dbf0a2bf6b61fdef0be56fd8f20a40827100 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 1 May 2020 14:05:25 +0200 Subject: User deletion mix task: Use common pipeline. --- lib/mix/tasks/pleroma/user.ex | 7 +++++-- test/tasks/user_test.exs | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 40dd9bdc0..da140ac86 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -8,6 +8,8 @@ defmodule Mix.Tasks.Pleroma.User do alias Ecto.Changeset alias Pleroma.User alias Pleroma.UserInviteToken + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline @shortdoc "Manages Pleroma users" @moduledoc File.read!("docs/administration/CLI_tasks/user.md") @@ -96,8 +98,9 @@ def run(["new", nickname, email | rest]) do def run(["rm", nickname]) do start_pleroma() - with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do - User.perform(:delete, user) + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, delete_data, _} <- Builder.delete(user, user.ap_id), + {:ok, _delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do shell_info("User #{nickname} deleted.") else _ -> shell_error("No local user #{nickname}") diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 8df835b56..ab56f07c1 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -4,14 +4,17 @@ defmodule Mix.Tasks.Pleroma.UserTest do alias Pleroma.Repo + alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Token use Pleroma.DataCase + use Oban.Testing, repo: Pleroma.Repo - import Pleroma.Factory import ExUnit.CaptureIO + import Mock + import Pleroma.Factory setup_all do Mix.shell(Mix.Shell.Process) @@ -87,12 +90,17 @@ test "user is not created" do test "user is deleted" do user = insert(:user) - Mix.Tasks.Pleroma.User.run(["rm", user.nickname]) + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + Mix.Tasks.Pleroma.User.run(["rm", user.nickname]) + ObanHelpers.perform_all() - assert_received {:mix_shell, :info, [message]} - assert message =~ " deleted" + assert_received {:mix_shell, :info, [message]} + assert message =~ " deleted" + refute User.get_by_nickname(user.nickname) - refute User.get_by_nickname(user.nickname) + assert called(Pleroma.Web.Federator.publish(:_)) + end end test "no user to delete" do -- cgit v1.2.3 From ebbd9c7f369f986b7a66f66eddab91537c490c79 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 1 May 2020 14:22:39 +0200 Subject: AdminAPIController: Refactor. --- lib/pleroma/web/admin_api/admin_api_controller.ex | 14 ++------------ test/web/admin_api/admin_api_controller_test.exs | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 816c11e01..c09584fd1 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -133,18 +133,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do action_fallback(:errors) - def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do - user = User.get_cached_by_nickname(nickname) - User.delete(user) - - ModerationLog.insert_log(%{ - actor: admin, - subject: [user], - action: "delete" - }) - - conn - |> json(nickname) + def user_delete(conn, %{"nickname" => nickname}) do + user_delete(conn, %{"nicknames" => [nickname]}) end def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index f80dbf8dd..c92715fab 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -156,7 +156,7 @@ test "single user", %{admin: admin, conn: conn} do assert ModerationLog.get_log_entry_message(log_entry) == "@#{admin.nickname} deleted users: @#{user.nickname}" - assert json_response(conn, 200) == user.nickname + assert json_response(conn, 200) == [user.nickname] end test "multiple users", %{admin: admin, conn: conn} do -- cgit v1.2.3 From 1ead5f49b8da941399fa2afadd40cd8beb8ccf8d Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 1 May 2020 14:30:39 +0200 Subject: AdminApiController: Use common pipeline for user deletion. --- lib/pleroma/web/admin_api/admin_api_controller.ex | 13 +++++++++-- test/web/admin_api/admin_api_controller_test.exs | 28 +++++++++++++++-------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index c09584fd1..9a12da027 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -17,6 +17,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.User alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI.AccountView @@ -138,8 +140,15 @@ def user_delete(conn, %{"nickname" => nickname}) do end def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - User.delete(users) + users = + nicknames + |> Enum.map(&User.get_cached_by_nickname/1) + + users + |> Enum.each(fn user -> + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + end) ModerationLog.insert_log(%{ actor: admin, diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index c92715fab..35001ab4a 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -6,8 +6,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do use Pleroma.Web.ConnCase use Oban.Testing, repo: Pleroma.Repo - import Pleroma.Factory import ExUnit.CaptureLog + import Mock + import Pleroma.Factory alias Pleroma.Activity alias Pleroma.Config @@ -146,17 +147,26 @@ test "GET /api/pleroma/admin/users/:nickname requires " <> test "single user", %{admin: admin, conn: conn} do user = insert(:user) - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") - log_entry = Repo.one(ModerationLog) + ObanHelpers.perform_all() - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deleted users: @#{user.nickname}" + refute User.get_by_nickname(user.nickname) + + log_entry = Repo.one(ModerationLog) - assert json_response(conn, 200) == [user.nickname] + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deleted users: @#{user.nickname}" + + assert json_response(conn, 200) == [user.nickname] + + assert called(Pleroma.Web.Federator.publish(:_)) + end end test "multiple users", %{admin: admin, conn: conn} do -- cgit v1.2.3 From aea781cbd8fb43f906c6022a8d2e0bf896008203 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 1 May 2020 16:31:05 +0300 Subject: credo fix --- test/web/admin_api/admin_api_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index e3af01089..d798412e3 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -18,8 +18,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.ReportNote alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.Web alias Pleroma.UserInviteToken + alias Pleroma.Web alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI alias Pleroma.Web.MediaProxy -- cgit v1.2.3 From a912f72a3674f80fe665db466295192b4dab82a9 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 1 May 2020 15:54:38 +0200 Subject: Signature: Handle non-ap ids in key ids. Mastodon and Gab sometimes send the format `acct:name@server`. --- lib/pleroma/signature.ex | 18 +++++++++++++++--- test/signature_test.exs | 18 ++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 6b0b2c969..d01728361 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Signature do alias Pleroma.Keys alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectValidators.Types def key_id_to_actor_id(key_id) do uri = @@ -21,12 +22,23 @@ def key_id_to_actor_id(key_id) do uri end - URI.to_string(uri) + maybe_ap_id = URI.to_string(uri) + + case Types.ObjectID.cast(maybe_ap_id) do + {:ok, ap_id} -> + {:ok, ap_id} + + _ -> + case Pleroma.Web.WebFinger.finger(maybe_ap_id) do + %{"ap_id" => ap_id} -> {:ok, ap_id} + _ -> {:error, maybe_ap_id} + end + end end def fetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), - actor_id <- key_id_to_actor_id(kid), + {:ok, actor_id} <- key_id_to_actor_id(kid), {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} else @@ -37,7 +49,7 @@ def fetch_public_key(conn) do def refetch_public_key(conn) do with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), - actor_id <- key_id_to_actor_id(kid), + {:ok, actor_id} <- key_id_to_actor_id(kid), {:ok, _user} <- ActivityPub.make_user_from_ap_id(actor_id), {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do {:ok, public_key} diff --git a/test/signature_test.exs b/test/signature_test.exs index d5a2a62c4..a7a75aa4d 100644 --- a/test/signature_test.exs +++ b/test/signature_test.exs @@ -44,7 +44,8 @@ test "it returns key" do test "it returns error when not found user" do assert capture_log(fn -> - assert Signature.fetch_public_key(make_fake_conn("test-ap_id")) == {:error, :error} + assert Signature.fetch_public_key(make_fake_conn("https://test-ap-id")) == + {:error, :error} end) =~ "[error] Could not decode user" end @@ -64,7 +65,7 @@ test "it returns key" do test "it returns error when not found user" do assert capture_log(fn -> - {:error, _} = Signature.refetch_public_key(make_fake_conn("test-ap_id")) + {:error, _} = Signature.refetch_public_key(make_fake_conn("https://test-ap_id")) end) =~ "[error] Could not decode user" end end @@ -100,12 +101,21 @@ test "it returns error" do describe "key_id_to_actor_id/1" do test "it properly deduces the actor id for misskey" do assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") == - "https://example.com/users/1234" + {:ok, "https://example.com/users/1234"} end test "it properly deduces the actor id for mastodon and pleroma" do assert Signature.key_id_to_actor_id("https://example.com/users/1234#main-key") == - "https://example.com/users/1234" + {:ok, "https://example.com/users/1234"} + end + + test "it calls webfinger for 'acct:' accounts" do + with_mock(Pleroma.Web.WebFinger, + finger: fn _ -> %{"ap_id" => "https://gensokyo.2hu/users/raymoo"} end + ) do + assert Signature.key_id_to_actor_id("acct:raymoo@gensokyo.2hu") == + {:ok, "https://gensokyo.2hu/users/raymoo"} + end end end -- cgit v1.2.3 From 3453e54e6b00ca2aced07746ad4cfc22ebc404fb Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 1 May 2020 15:58:47 +0200 Subject: MappedSignatureToIdentityPlug: Fix. --- lib/pleroma/plugs/mapped_signature_to_identity_plug.ex | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex index 84b7c5d83..f44d4dee5 100644 --- a/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex +++ b/lib/pleroma/plugs/mapped_signature_to_identity_plug.ex @@ -13,8 +13,9 @@ defmodule Pleroma.Web.Plugs.MappedSignatureToIdentityPlug do def init(options), do: options defp key_id_from_conn(conn) do - with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn) do - Signature.key_id_to_actor_id(key_id) + with %{"keyId" => key_id} <- HTTPSignatures.signature_for_conn(conn), + {:ok, ap_id} <- Signature.key_id_to_actor_id(key_id) do + ap_id else _ -> nil -- cgit v1.2.3 From 57b31e79c213e57b32b604c97a6d1555ed73bcdb Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 1 May 2020 17:59:29 +0300 Subject: Updated PleromaFE bundle to prevent notifications loading issue due to unsupported parameter (`with_move`). --- priv/static/index.html | 2 +- .../static/static/css/app.1055039ce3f2fe4dd110.css | Bin 2381 -> 0 bytes .../static/css/app.1055039ce3f2fe4dd110.css.map | 1 - .../static/static/css/app.613cef07981cd95ccceb.css | Bin 0 -> 2007 bytes .../static/css/app.613cef07981cd95ccceb.css.map | 1 + .../css/vendors~app.18fea621d430000acc27.css | Bin 0 -> 4695 bytes .../css/vendors~app.18fea621d430000acc27.css.map | 1 + .../css/vendors~app.b2603a50868c68a1c192.css | Bin 4710 -> 0 bytes .../css/vendors~app.b2603a50868c68a1c192.css.map | 1 - priv/static/static/font/fontello.1587147224637.eot | Bin 22444 -> 0 bytes priv/static/static/font/fontello.1587147224637.svg | 118 --------------------- priv/static/static/font/fontello.1587147224637.ttf | Bin 22276 -> 0 bytes .../static/static/font/fontello.1587147224637.woff | Bin 13656 -> 0 bytes .../static/font/fontello.1587147224637.woff2 | Bin 11544 -> 0 bytes priv/static/static/font/fontello.1588344944597.eot | Bin 0 -> 22444 bytes priv/static/static/font/fontello.1588344944597.svg | 118 +++++++++++++++++++++ priv/static/static/font/fontello.1588344944597.ttf | Bin 0 -> 22276 bytes .../static/static/font/fontello.1588344944597.woff | Bin 0 -> 13656 bytes .../static/font/fontello.1588344944597.woff2 | Bin 0 -> 11536 bytes priv/static/static/fontello.1587147224637.css | Bin 3296 -> 0 bytes priv/static/static/fontello.1588344944597.css | Bin 0 -> 3296 bytes priv/static/static/js/2.0bcc7512986083cd9ecf.js | Bin 0 -> 2190 bytes .../static/static/js/2.0bcc7512986083cd9ecf.js.map | Bin 0 -> 7763 bytes priv/static/static/js/2.f158cbd2b8770e467dfe.js | Bin 2169 -> 0 bytes .../static/static/js/2.f158cbd2b8770e467dfe.js.map | Bin 7927 -> 0 bytes priv/static/static/js/app.3de9191d7fd30b4bf68c.js | Bin 0 -> 1071665 bytes .../static/js/app.3de9191d7fd30b4bf68c.js.map | Bin 0 -> 1626532 bytes priv/static/static/js/app.def6476e8bc9b214218b.js | Bin 1045614 -> 0 bytes .../static/js/app.def6476e8bc9b214218b.js.map | Bin 1658859 -> 0 bytes .../static/js/vendors~app.5b7c43d835cad9e56363.js | Bin 0 -> 411232 bytes .../js/vendors~app.5b7c43d835cad9e56363.js.map | Bin 0 -> 1737936 bytes .../static/js/vendors~app.c5bbd3734647f0cc7eef.js | Bin 377395 -> 0 bytes .../js/vendors~app.c5bbd3734647f0cc7eef.js.map | Bin 1737985 -> 0 bytes priv/static/static/static-fe.css | Bin 2715 -> 0 bytes priv/static/sw-pleroma.js | Bin 31329 -> 31752 bytes priv/static/sw-pleroma.js.map | Bin 142358 -> 143966 bytes 36 files changed, 121 insertions(+), 121 deletions(-) delete mode 100644 priv/static/static/css/app.1055039ce3f2fe4dd110.css delete mode 100644 priv/static/static/css/app.1055039ce3f2fe4dd110.css.map create mode 100644 priv/static/static/css/app.613cef07981cd95ccceb.css create mode 100644 priv/static/static/css/app.613cef07981cd95ccceb.css.map create mode 100644 priv/static/static/css/vendors~app.18fea621d430000acc27.css create mode 100644 priv/static/static/css/vendors~app.18fea621d430000acc27.css.map delete mode 100644 priv/static/static/css/vendors~app.b2603a50868c68a1c192.css delete mode 100644 priv/static/static/css/vendors~app.b2603a50868c68a1c192.css.map delete mode 100644 priv/static/static/font/fontello.1587147224637.eot delete mode 100644 priv/static/static/font/fontello.1587147224637.svg delete mode 100644 priv/static/static/font/fontello.1587147224637.ttf delete mode 100644 priv/static/static/font/fontello.1587147224637.woff delete mode 100644 priv/static/static/font/fontello.1587147224637.woff2 create mode 100644 priv/static/static/font/fontello.1588344944597.eot create mode 100644 priv/static/static/font/fontello.1588344944597.svg create mode 100644 priv/static/static/font/fontello.1588344944597.ttf create mode 100644 priv/static/static/font/fontello.1588344944597.woff create mode 100644 priv/static/static/font/fontello.1588344944597.woff2 delete mode 100644 priv/static/static/fontello.1587147224637.css create mode 100644 priv/static/static/fontello.1588344944597.css create mode 100644 priv/static/static/js/2.0bcc7512986083cd9ecf.js create mode 100644 priv/static/static/js/2.0bcc7512986083cd9ecf.js.map delete mode 100644 priv/static/static/js/2.f158cbd2b8770e467dfe.js delete mode 100644 priv/static/static/js/2.f158cbd2b8770e467dfe.js.map create mode 100644 priv/static/static/js/app.3de9191d7fd30b4bf68c.js create mode 100644 priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map delete mode 100644 priv/static/static/js/app.def6476e8bc9b214218b.js delete mode 100644 priv/static/static/js/app.def6476e8bc9b214218b.js.map create mode 100644 priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js create mode 100644 priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map delete mode 100644 priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js delete mode 100644 priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js.map delete mode 100644 priv/static/static/static-fe.css diff --git a/priv/static/index.html b/priv/static/index.html index 66c9b53de..6af441737 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/css/app.1055039ce3f2fe4dd110.css b/priv/static/static/css/app.1055039ce3f2fe4dd110.css deleted file mode 100644 index 1867ca81a..000000000 Binary files a/priv/static/static/css/app.1055039ce3f2fe4dd110.css and /dev/null differ diff --git a/priv/static/static/css/app.1055039ce3f2fe4dd110.css.map b/priv/static/static/css/app.1055039ce3f2fe4dd110.css.map deleted file mode 100644 index 861ee8313..000000000 --- a/priv/static/static/css/app.1055039ce3f2fe4dd110.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./src/hocs/with_load_more/with_load_more.scss","webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_subscription/with_subscription.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACTA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACxFA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.1055039ce3f2fe4dd110.css","sourcesContent":[".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}",".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n overflow-y: auto;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher .tabs .tab-wrapper {\n height: 28px;\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tabs .tab-wrapper .tab {\n width: 100%;\n min-width: 1px;\n position: relative;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding: 6px 1em;\n padding-bottom: 99px;\n margin-bottom: -93px;\n white-space: nowrap;\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tabs .tab-wrapper .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tabs .tab-wrapper .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tabs .tab-wrapper .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tabs .tab-wrapper .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 7;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}",".with-subscription-loading {\n padding: 10px;\n text-align: center;\n}\n.with-subscription-loading .error {\n font-size: 14px;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/app.613cef07981cd95ccceb.css b/priv/static/static/css/app.613cef07981cd95ccceb.css new file mode 100644 index 000000000..c1d5f8188 Binary files /dev/null and b/priv/static/static/css/app.613cef07981cd95ccceb.css differ diff --git a/priv/static/static/css/app.613cef07981cd95ccceb.css.map b/priv/static/static/css/app.613cef07981cd95ccceb.css.map new file mode 100644 index 000000000..556e0bb0b --- /dev/null +++ b/priv/static/static/css/app.613cef07981cd95ccceb.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/hocs/with_load_more/with_load_more.scss","webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_subscription/with_subscription.scss"],"names":[],"mappings":"AAAA,uBAAuB,aAAa,kBAAkB,qBAAqB,sBAAsB,qCAAqC,8BAA8B,e;ACApK,cAAc,oBAAoB,aAAa,0BAA0B,sBAAsB,wBAAwB,kBAAkB,cAAc,eAAe,gCAAgC,aAAa,wCAAwC,0BAA0B,aAAa,gBAAgB,oBAAoB,oBAAoB,aAAa,kBAAkB,WAAW,kBAAkB,gBAAgB,gBAAgB,sBAAsB,uDAAuD,cAAc,WAAW,kBAAkB,cAAc,wBAAwB,yBAAyB,wCAAwC,iCAAiC,YAAY,kBAAkB,oBAAoB,aAAa,kBAAkB,cAAc,sCAAsC,WAAW,cAAc,kBAAkB,4BAA4B,6BAA6B,gBAAgB,oBAAoB,oBAAoB,mBAAmB,cAAc,8BAA8B,yBAAyB,qCAAqC,mDAAmD,UAAU,yDAAyD,UAAU,6CAA6C,uBAAuB,UAAU,cAAc,oCAAoC,0CAA0C,gBAAgB,mBAAmB,gBAAgB,qDAAqD,WAAW,kBAAkB,OAAO,QAAQ,SAAS,UAAU,wBAAwB,yBAAyB,wC;ACAtlD,2BAA2B,aAAa,kBAAkB,kCAAkC,e","file":"static/css/app.613cef07981cd95ccceb.css","sourcesContent":[".with-load-more-footer{padding:10px;text-align:center;border-top:1px solid;border-top-color:#222;border-top-color:var(--border, #222)}.with-load-more-footer .error{font-size:14px}",".tab-switcher{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.tab-switcher .contents{-ms-flex:1 0 auto;flex:1 0 auto;min-height:0px}.tab-switcher .contents .hidden{display:none}.tab-switcher .contents.scrollable-tabs{-ms-flex-preferred-size:0;flex-basis:0;overflow-y:auto}.tab-switcher .tabs{display:-ms-flexbox;display:flex;position:relative;width:100%;overflow-y:hidden;overflow-x:auto;padding-top:5px;box-sizing:border-box}.tab-switcher .tabs::after,.tab-switcher .tabs::before{display:block;content:\"\";-ms-flex:1 1 auto;flex:1 1 auto;border-bottom:1px solid;border-bottom-color:#222;border-bottom-color:var(--border, #222)}.tab-switcher .tabs .tab-wrapper{height:28px;position:relative;display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto}.tab-switcher .tabs .tab-wrapper .tab{width:100%;min-width:1px;position:relative;border-bottom-left-radius:0;border-bottom-right-radius:0;padding:6px 1em;padding-bottom:99px;margin-bottom:-93px;white-space:nowrap;color:#b9b9ba;color:var(--tabText, #b9b9ba);background-color:#182230;background-color:var(--tab, #182230)}.tab-switcher .tabs .tab-wrapper .tab:not(.active){z-index:4}.tab-switcher .tabs .tab-wrapper .tab:not(.active):hover{z-index:6}.tab-switcher .tabs .tab-wrapper .tab.active{background:transparent;z-index:5;color:#b9b9ba;color:var(--tabActiveText, #b9b9ba)}.tab-switcher .tabs .tab-wrapper .tab img{max-height:26px;vertical-align:top;margin-top:-5px}.tab-switcher .tabs .tab-wrapper:not(.active)::after{content:\"\";position:absolute;left:0;right:0;bottom:0;z-index:7;border-bottom:1px solid;border-bottom-color:#222;border-bottom-color:var(--border, #222)}",".with-subscription-loading{padding:10px;text-align:center}.with-subscription-loading .error{font-size:14px}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/vendors~app.18fea621d430000acc27.css b/priv/static/static/css/vendors~app.18fea621d430000acc27.css new file mode 100644 index 000000000..ef783cbb3 Binary files /dev/null and b/priv/static/static/css/vendors~app.18fea621d430000acc27.css differ diff --git a/priv/static/static/css/vendors~app.18fea621d430000acc27.css.map b/priv/static/static/css/vendors~app.18fea621d430000acc27.css.map new file mode 100644 index 000000000..057d67d6a --- /dev/null +++ b/priv/static/static/css/vendors~app.18fea621d430000acc27.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./node_modules/cropperjs/dist/cropper.css"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA,wCAAwC;AACxC;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA","file":"static/css/vendors~app.18fea621d430000acc27.css","sourcesContent":["/*!\n * Cropper.js v1.5.6\n * https://fengyuanchen.github.io/cropperjs\n *\n * Copyright 2015-present Chen Fengyuan\n * Released under the MIT license\n *\n * Date: 2019-10-04T04:33:44.164Z\n */\n\n.cropper-container {\n direction: ltr;\n font-size: 0;\n line-height: 0;\n position: relative;\n -ms-touch-action: none;\n touch-action: none;\n -webkit-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.cropper-container img {\n display: block;\n height: 100%;\n image-orientation: 0deg;\n max-height: none !important;\n max-width: none !important;\n min-height: 0 !important;\n min-width: 0 !important;\n width: 100%;\n}\n\n.cropper-wrap-box,\n.cropper-canvas,\n.cropper-drag-box,\n.cropper-crop-box,\n.cropper-modal {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n}\n\n.cropper-wrap-box,\n.cropper-canvas {\n overflow: hidden;\n}\n\n.cropper-drag-box {\n background-color: #fff;\n opacity: 0;\n}\n\n.cropper-modal {\n background-color: #000;\n opacity: 0.5;\n}\n\n.cropper-view-box {\n display: block;\n height: 100%;\n outline: 1px solid #39f;\n outline-color: rgba(51, 153, 255, 0.75);\n overflow: hidden;\n width: 100%;\n}\n\n.cropper-dashed {\n border: 0 dashed #eee;\n display: block;\n opacity: 0.5;\n position: absolute;\n}\n\n.cropper-dashed.dashed-h {\n border-bottom-width: 1px;\n border-top-width: 1px;\n height: calc(100% / 3);\n left: 0;\n top: calc(100% / 3);\n width: 100%;\n}\n\n.cropper-dashed.dashed-v {\n border-left-width: 1px;\n border-right-width: 1px;\n height: 100%;\n left: calc(100% / 3);\n top: 0;\n width: calc(100% / 3);\n}\n\n.cropper-center {\n display: block;\n height: 0;\n left: 50%;\n opacity: 0.75;\n position: absolute;\n top: 50%;\n width: 0;\n}\n\n.cropper-center::before,\n.cropper-center::after {\n background-color: #eee;\n content: ' ';\n display: block;\n position: absolute;\n}\n\n.cropper-center::before {\n height: 1px;\n left: -3px;\n top: 0;\n width: 7px;\n}\n\n.cropper-center::after {\n height: 7px;\n left: 0;\n top: -3px;\n width: 1px;\n}\n\n.cropper-face,\n.cropper-line,\n.cropper-point {\n display: block;\n height: 100%;\n opacity: 0.1;\n position: absolute;\n width: 100%;\n}\n\n.cropper-face {\n background-color: #fff;\n left: 0;\n top: 0;\n}\n\n.cropper-line {\n background-color: #39f;\n}\n\n.cropper-line.line-e {\n cursor: ew-resize;\n right: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-n {\n cursor: ns-resize;\n height: 5px;\n left: 0;\n top: -3px;\n}\n\n.cropper-line.line-w {\n cursor: ew-resize;\n left: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-s {\n bottom: -3px;\n cursor: ns-resize;\n height: 5px;\n left: 0;\n}\n\n.cropper-point {\n background-color: #39f;\n height: 5px;\n opacity: 0.75;\n width: 5px;\n}\n\n.cropper-point.point-e {\n cursor: ew-resize;\n margin-top: -3px;\n right: -3px;\n top: 50%;\n}\n\n.cropper-point.point-n {\n cursor: ns-resize;\n left: 50%;\n margin-left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-w {\n cursor: ew-resize;\n left: -3px;\n margin-top: -3px;\n top: 50%;\n}\n\n.cropper-point.point-s {\n bottom: -3px;\n cursor: s-resize;\n left: 50%;\n margin-left: -3px;\n}\n\n.cropper-point.point-ne {\n cursor: nesw-resize;\n right: -3px;\n top: -3px;\n}\n\n.cropper-point.point-nw {\n cursor: nwse-resize;\n left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-sw {\n bottom: -3px;\n cursor: nesw-resize;\n left: -3px;\n}\n\n.cropper-point.point-se {\n bottom: -3px;\n cursor: nwse-resize;\n height: 20px;\n opacity: 1;\n right: -3px;\n width: 20px;\n}\n\n@media (min-width: 768px) {\n .cropper-point.point-se {\n height: 15px;\n width: 15px;\n }\n}\n\n@media (min-width: 992px) {\n .cropper-point.point-se {\n height: 10px;\n width: 10px;\n }\n}\n\n@media (min-width: 1200px) {\n .cropper-point.point-se {\n height: 5px;\n opacity: 0.75;\n width: 5px;\n }\n}\n\n.cropper-point.point-se::before {\n background-color: #39f;\n bottom: -50%;\n content: ' ';\n display: block;\n height: 200%;\n opacity: 0;\n position: absolute;\n right: -50%;\n width: 200%;\n}\n\n.cropper-invisible {\n opacity: 0;\n}\n\n.cropper-bg {\n background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');\n}\n\n.cropper-hide {\n display: block;\n height: 0;\n position: absolute;\n width: 0;\n}\n\n.cropper-hidden {\n display: none !important;\n}\n\n.cropper-move {\n cursor: move;\n}\n\n.cropper-crop {\n cursor: crosshair;\n}\n\n.cropper-disabled .cropper-drag-box,\n.cropper-disabled .cropper-face,\n.cropper-disabled .cropper-line,\n.cropper-disabled .cropper-point {\n cursor: not-allowed;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/vendors~app.b2603a50868c68a1c192.css b/priv/static/static/css/vendors~app.b2603a50868c68a1c192.css deleted file mode 100644 index a2e625f5e..000000000 Binary files a/priv/static/static/css/vendors~app.b2603a50868c68a1c192.css and /dev/null differ diff --git a/priv/static/static/css/vendors~app.b2603a50868c68a1c192.css.map b/priv/static/static/css/vendors~app.b2603a50868c68a1c192.css.map deleted file mode 100644 index e7013b291..000000000 --- a/priv/static/static/css/vendors~app.b2603a50868c68a1c192.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./node_modules/cropperjs/dist/cropper.css"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA,wCAAwC;AACxC;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA","file":"static/css/vendors~app.b2603a50868c68a1c192.css","sourcesContent":["/*!\n * Cropper.js v1.4.3\n * https://fengyuanchen.github.io/cropperjs\n *\n * Copyright 2015-present Chen Fengyuan\n * Released under the MIT license\n *\n * Date: 2018-10-24T13:07:11.429Z\n */\n\n.cropper-container {\n direction: ltr;\n font-size: 0;\n line-height: 0;\n position: relative;\n -ms-touch-action: none;\n touch-action: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.cropper-container img {\n display: block;\n height: 100%;\n image-orientation: 0deg;\n max-height: none !important;\n max-width: none !important;\n min-height: 0 !important;\n min-width: 0 !important;\n width: 100%;\n}\n\n.cropper-wrap-box,\n.cropper-canvas,\n.cropper-drag-box,\n.cropper-crop-box,\n.cropper-modal {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n}\n\n.cropper-wrap-box,\n.cropper-canvas {\n overflow: hidden;\n}\n\n.cropper-drag-box {\n background-color: #fff;\n opacity: 0;\n}\n\n.cropper-modal {\n background-color: #000;\n opacity: .5;\n}\n\n.cropper-view-box {\n display: block;\n height: 100%;\n outline-color: rgba(51, 153, 255, 0.75);\n outline: 1px solid #39f;\n overflow: hidden;\n width: 100%;\n}\n\n.cropper-dashed {\n border: 0 dashed #eee;\n display: block;\n opacity: .5;\n position: absolute;\n}\n\n.cropper-dashed.dashed-h {\n border-bottom-width: 1px;\n border-top-width: 1px;\n height: calc(100% / 3);\n left: 0;\n top: calc(100% / 3);\n width: 100%;\n}\n\n.cropper-dashed.dashed-v {\n border-left-width: 1px;\n border-right-width: 1px;\n height: 100%;\n left: calc(100% / 3);\n top: 0;\n width: calc(100% / 3);\n}\n\n.cropper-center {\n display: block;\n height: 0;\n left: 50%;\n opacity: .75;\n position: absolute;\n top: 50%;\n width: 0;\n}\n\n.cropper-center:before,\n.cropper-center:after {\n background-color: #eee;\n content: ' ';\n display: block;\n position: absolute;\n}\n\n.cropper-center:before {\n height: 1px;\n left: -3px;\n top: 0;\n width: 7px;\n}\n\n.cropper-center:after {\n height: 7px;\n left: 0;\n top: -3px;\n width: 1px;\n}\n\n.cropper-face,\n.cropper-line,\n.cropper-point {\n display: block;\n height: 100%;\n opacity: .1;\n position: absolute;\n width: 100%;\n}\n\n.cropper-face {\n background-color: #fff;\n left: 0;\n top: 0;\n}\n\n.cropper-line {\n background-color: #39f;\n}\n\n.cropper-line.line-e {\n cursor: ew-resize;\n right: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-n {\n cursor: ns-resize;\n height: 5px;\n left: 0;\n top: -3px;\n}\n\n.cropper-line.line-w {\n cursor: ew-resize;\n left: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-s {\n bottom: -3px;\n cursor: ns-resize;\n height: 5px;\n left: 0;\n}\n\n.cropper-point {\n background-color: #39f;\n height: 5px;\n opacity: .75;\n width: 5px;\n}\n\n.cropper-point.point-e {\n cursor: ew-resize;\n margin-top: -3px;\n right: -3px;\n top: 50%;\n}\n\n.cropper-point.point-n {\n cursor: ns-resize;\n left: 50%;\n margin-left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-w {\n cursor: ew-resize;\n left: -3px;\n margin-top: -3px;\n top: 50%;\n}\n\n.cropper-point.point-s {\n bottom: -3px;\n cursor: s-resize;\n left: 50%;\n margin-left: -3px;\n}\n\n.cropper-point.point-ne {\n cursor: nesw-resize;\n right: -3px;\n top: -3px;\n}\n\n.cropper-point.point-nw {\n cursor: nwse-resize;\n left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-sw {\n bottom: -3px;\n cursor: nesw-resize;\n left: -3px;\n}\n\n.cropper-point.point-se {\n bottom: -3px;\n cursor: nwse-resize;\n height: 20px;\n opacity: 1;\n right: -3px;\n width: 20px;\n}\n\n@media (min-width: 768px) {\n .cropper-point.point-se {\n height: 15px;\n width: 15px;\n }\n}\n\n@media (min-width: 992px) {\n .cropper-point.point-se {\n height: 10px;\n width: 10px;\n }\n}\n\n@media (min-width: 1200px) {\n .cropper-point.point-se {\n height: 5px;\n opacity: .75;\n width: 5px;\n }\n}\n\n.cropper-point.point-se:before {\n background-color: #39f;\n bottom: -50%;\n content: ' ';\n display: block;\n height: 200%;\n opacity: 0;\n position: absolute;\n right: -50%;\n width: 200%;\n}\n\n.cropper-invisible {\n opacity: 0;\n}\n\n.cropper-bg {\n background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');\n}\n\n.cropper-hide {\n display: block;\n height: 0;\n position: absolute;\n width: 0;\n}\n\n.cropper-hidden {\n display: none !important;\n}\n\n.cropper-move {\n cursor: move;\n}\n\n.cropper-crop {\n cursor: crosshair;\n}\n\n.cropper-disabled .cropper-drag-box,\n.cropper-disabled .cropper-face,\n.cropper-disabled .cropper-line,\n.cropper-disabled .cropper-point {\n cursor: not-allowed;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1587147224637.eot b/priv/static/static/font/fontello.1587147224637.eot deleted file mode 100644 index 523e14f27..000000000 Binary files a/priv/static/static/font/fontello.1587147224637.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1587147224637.svg b/priv/static/static/font/fontello.1587147224637.svg deleted file mode 100644 index b905a0f6c..000000000 --- a/priv/static/static/font/fontello.1587147224637.svg +++ /dev/null @@ -1,118 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1587147224637.ttf b/priv/static/static/font/fontello.1587147224637.ttf deleted file mode 100644 index ec6f7f9b4..000000000 Binary files a/priv/static/static/font/fontello.1587147224637.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1587147224637.woff b/priv/static/static/font/fontello.1587147224637.woff deleted file mode 100644 index da56c9221..000000000 Binary files a/priv/static/static/font/fontello.1587147224637.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1587147224637.woff2 b/priv/static/static/font/fontello.1587147224637.woff2 deleted file mode 100644 index 6192c0f22..000000000 Binary files a/priv/static/static/font/fontello.1587147224637.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1588344944597.eot b/priv/static/static/font/fontello.1588344944597.eot new file mode 100644 index 000000000..6b4850215 Binary files /dev/null and b/priv/static/static/font/fontello.1588344944597.eot differ diff --git a/priv/static/static/font/fontello.1588344944597.svg b/priv/static/static/font/fontello.1588344944597.svg new file mode 100644 index 000000000..b905a0f6c --- /dev/null +++ b/priv/static/static/font/fontello.1588344944597.svg @@ -0,0 +1,118 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588344944597.ttf b/priv/static/static/font/fontello.1588344944597.ttf new file mode 100644 index 000000000..b990cea9a Binary files /dev/null and b/priv/static/static/font/fontello.1588344944597.ttf differ diff --git a/priv/static/static/font/fontello.1588344944597.woff b/priv/static/static/font/fontello.1588344944597.woff new file mode 100644 index 000000000..93d102c6f Binary files /dev/null and b/priv/static/static/font/fontello.1588344944597.woff differ diff --git a/priv/static/static/font/fontello.1588344944597.woff2 b/priv/static/static/font/fontello.1588344944597.woff2 new file mode 100644 index 000000000..bc4d4dada Binary files /dev/null and b/priv/static/static/font/fontello.1588344944597.woff2 differ diff --git a/priv/static/static/fontello.1587147224637.css b/priv/static/static/fontello.1587147224637.css deleted file mode 100644 index 48e6a5b3c..000000000 Binary files a/priv/static/static/fontello.1587147224637.css and /dev/null differ diff --git a/priv/static/static/fontello.1588344944597.css b/priv/static/static/fontello.1588344944597.css new file mode 100644 index 000000000..000c1207a Binary files /dev/null and b/priv/static/static/fontello.1588344944597.css differ diff --git a/priv/static/static/js/2.0bcc7512986083cd9ecf.js b/priv/static/static/js/2.0bcc7512986083cd9ecf.js new file mode 100644 index 000000000..680c9f82a Binary files /dev/null and b/priv/static/static/js/2.0bcc7512986083cd9ecf.js differ diff --git a/priv/static/static/js/2.0bcc7512986083cd9ecf.js.map b/priv/static/static/js/2.0bcc7512986083cd9ecf.js.map new file mode 100644 index 000000000..488843d6a Binary files /dev/null and b/priv/static/static/js/2.0bcc7512986083cd9ecf.js.map differ diff --git a/priv/static/static/js/2.f158cbd2b8770e467dfe.js b/priv/static/static/js/2.f158cbd2b8770e467dfe.js deleted file mode 100644 index 24f80fe7b..000000000 Binary files a/priv/static/static/js/2.f158cbd2b8770e467dfe.js and /dev/null differ diff --git a/priv/static/static/js/2.f158cbd2b8770e467dfe.js.map b/priv/static/static/js/2.f158cbd2b8770e467dfe.js.map deleted file mode 100644 index 94ca6f090..000000000 Binary files a/priv/static/static/js/2.f158cbd2b8770e467dfe.js.map and /dev/null differ diff --git a/priv/static/static/js/app.3de9191d7fd30b4bf68c.js b/priv/static/static/js/app.3de9191d7fd30b4bf68c.js new file mode 100644 index 000000000..d8d350a50 Binary files /dev/null and b/priv/static/static/js/app.3de9191d7fd30b4bf68c.js differ diff --git a/priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map b/priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map new file mode 100644 index 000000000..0643ca253 Binary files /dev/null and b/priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map differ diff --git a/priv/static/static/js/app.def6476e8bc9b214218b.js b/priv/static/static/js/app.def6476e8bc9b214218b.js deleted file mode 100644 index 1e6ced42d..000000000 Binary files a/priv/static/static/js/app.def6476e8bc9b214218b.js and /dev/null differ diff --git a/priv/static/static/js/app.def6476e8bc9b214218b.js.map b/priv/static/static/js/app.def6476e8bc9b214218b.js.map deleted file mode 100644 index a03cad258..000000000 Binary files a/priv/static/static/js/app.def6476e8bc9b214218b.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js b/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js new file mode 100644 index 000000000..0b69788e9 Binary files /dev/null and b/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js differ diff --git a/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map b/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map new file mode 100644 index 000000000..798010b89 Binary files /dev/null and b/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map differ diff --git a/priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js b/priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js deleted file mode 100644 index 8964180cd..000000000 Binary files a/priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js.map b/priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js.map deleted file mode 100644 index fab720d23..000000000 Binary files a/priv/static/static/js/vendors~app.c5bbd3734647f0cc7eef.js.map and /dev/null differ diff --git a/priv/static/static/static-fe.css b/priv/static/static/static-fe.css deleted file mode 100644 index db61ff266..000000000 Binary files a/priv/static/static/static-fe.css and /dev/null differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 92361720e..d9c1e6285 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index 5d9874693..c704cb951 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ -- cgit v1.2.3 From d5cdc907e3fda14c2ce78ddbb124739441330ecc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 1 May 2020 18:45:24 +0300 Subject: Restricted embedding of relationships where applicable (statuses / notifications / accounts rendering). Added support for :skip_notifications for accounts listing (index.json). Adjusted tests. --- benchmarks/load_testing/fetcher.ex | 21 +++++------------ config/config.exs | 2 -- lib/mix/tasks/pleroma/benchmark.ex | 3 +-- lib/pleroma/web/admin_api/admin_api_controller.ex | 6 ++--- lib/pleroma/web/admin_api/views/report_view.ex | 9 ++++---- lib/pleroma/web/admin_api/views/status_view.ex | 6 +++-- lib/pleroma/web/chat_channel.ex | 8 ++++++- lib/pleroma/web/controller_helper.ex | 11 ++------- .../mastodon_api/controllers/search_controller.ex | 2 +- lib/pleroma/web/mastodon_api/views/account_view.ex | 7 ++++-- .../web/mastodon_api/views/notification_view.ex | 4 ++++ lib/pleroma/web/mastodon_api/views/status_view.ex | 6 +++++ .../controllers/pleroma_api_controller.ex | 8 ++++++- .../controllers/notification_controller_test.exs | 4 +--- .../controllers/status_controller_test.exs | 2 +- .../controllers/timeline_controller_test.exs | 26 +++++++++++++++------- .../mastodon_api/views/notification_view_test.exs | 25 +++++++++++++++------ test/web/mastodon_api/views/status_view_test.exs | 14 +++++------- 18 files changed, 94 insertions(+), 70 deletions(-) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 12c30f6f5..0de4924bc 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -387,56 +387,47 @@ defp render_timelines(user) do favourites = ActivityPub.fetch_favourites(user) - output_relationships = - !!Pleroma.Config.get([:extensions, :output_relationships_in_statuses_by_default]) - Benchee.run( %{ "Rendering home timeline" => fn -> StatusView.render("index.json", %{ activities: home_activities, for: user, - as: :activity, - skip_relationships: !output_relationships + as: :activity }) end, "Rendering direct timeline" => fn -> StatusView.render("index.json", %{ activities: direct_activities, for: user, - as: :activity, - skip_relationships: !output_relationships + as: :activity }) end, "Rendering public timeline" => fn -> StatusView.render("index.json", %{ activities: public_activities, for: user, - as: :activity, - skip_relationships: !output_relationships + as: :activity }) end, "Rendering tag timeline" => fn -> StatusView.render("index.json", %{ activities: tag_activities, for: user, - as: :activity, - skip_relationships: !output_relationships + as: :activity }) end, "Rendering notifications" => fn -> Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ notifications: notifications, - for: user, - skip_relationships: !output_relationships + for: user }) end, "Rendering favourites timeline" => fn -> StatusView.render("index.json", %{ activities: favourites, for: user, - as: :activity, - skip_relationships: !output_relationships + as: :activity }) end }, diff --git a/config/config.exs b/config/config.exs index 2e538c4be..d698e6028 100644 --- a/config/config.exs +++ b/config/config.exs @@ -240,8 +240,6 @@ extended_nickname_format: true, cleanup_attachments: false -config :pleroma, :extensions, output_relationships_in_statuses_by_default: true - config :pleroma, :feed, post_title: %{ max_length: 100, diff --git a/lib/mix/tasks/pleroma/benchmark.ex b/lib/mix/tasks/pleroma/benchmark.ex index 6ab7fe8ef..dd2b9c8f2 100644 --- a/lib/mix/tasks/pleroma/benchmark.ex +++ b/lib/mix/tasks/pleroma/benchmark.ex @@ -67,8 +67,7 @@ def run(["render_timeline", nickname | _] = args) do Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ activities: activities, for: user, - as: :activity, - skip_relationships: true + as: :activity }) end }, diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 816c11e01..e0e1a2ceb 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -280,7 +280,7 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do conn |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) + |> render("index.json", %{activities: activities, as: :activity}) end def list_user_statuses(conn, %{"nickname" => nickname} = params) do @@ -299,7 +299,7 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do conn |> put_view(StatusView) - |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) + |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} end @@ -834,7 +834,7 @@ def list_statuses(%{assigns: %{user: _admin}} = conn, params) do conn |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) + |> render("index.json", %{activities: activities, as: :activity}) end def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index d50969b2a..215e31100 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -7,8 +7,10 @@ defmodule Pleroma.Web.AdminAPI.ReportView do alias Pleroma.HTML alias Pleroma.User + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{reports: reports}) do @@ -41,8 +43,7 @@ def render("show.json", %{report: report, user: user, account: account, statuses statuses: StatusView.render("index.json", %{ activities: statuses, - as: :activity, - skip_relationships: false + as: :activity }), state: report.data["state"], notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}) @@ -72,8 +73,8 @@ def render("show_note.json", %{ end defp merge_account_views(%User{} = user) do - Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) - |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) + MastodonAPI.AccountView.render("show.json", %{user: user, skip_relationships: true}) + |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) end defp merge_account_views(_), do: %{} diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex index 3637dee24..a76fad990 100644 --- a/lib/pleroma/web/admin_api/views/status_view.ex +++ b/lib/pleroma/web/admin_api/views/status_view.ex @@ -8,6 +8,8 @@ defmodule Pleroma.Web.AdminAPI.StatusView do require Pleroma.Constants alias Pleroma.User + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.MastodonAPI alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", opts) do @@ -22,8 +24,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} end defp merge_account_views(%User{} = user) do - Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user}) - |> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user})) + MastodonAPI.AccountView.render("show.json", %{user: user, skip_relationships: true}) + |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) end defp merge_account_views(_), do: %{} diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index 38ec774f7..3df8dc0f1 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -22,7 +22,13 @@ def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} if String.length(text) in 1..Pleroma.Config.get([:instance, :chat_limit]) do author = User.get_cached_by_nickname(user_name) - author = Pleroma.Web.MastodonAPI.AccountView.render("show.json", user: author) + + author = + Pleroma.Web.MastodonAPI.AccountView.render("show.json", + user: author, + skip_relationships: true + ) + message = ChatChannelState.add_message(%{text: text, author: author}) broadcast!(socket, "new_msg", message) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index eb97ae975..f0b4c087a 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -5,8 +5,6 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller - alias Pleroma.Config - # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] @@ -106,13 +104,8 @@ def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) - @doc "Whether to skip rendering `[:account][:pleroma][:relationship]`for statuses/notifications" + @doc "Whether to skip `account.pleroma.relationship` rendering for statuses/notifications" def skip_relationships?(params) do - if Config.get([:extensions, :output_relationships_in_statuses_by_default]) do - false - else - # BREAKING: older PleromaFE versions do not send this param but _do_ expect relationships. - not truthy_param?(params["with_relationships"]) - end + not truthy_param?(params["with_relationships"]) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index cd49da6ad..85a316762 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -86,7 +86,7 @@ defp resource_search(_, "accounts", query, options) do users: accounts, for: options[:for_user], as: :user, - skip_relationships: false + skip_relationships: true ) end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b4b61e74c..6d17c2d02 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -13,15 +13,16 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MediaProxy def render("index.json", %{users: users} = opts) do + opts = Map.merge(%{skip_relationships: false}, opts) + reading_user = opts[:for] - # Note: :skip_relationships option is currently intentionally not supported for accounts relationships_opt = cond do Map.has_key?(opts, :relationships) -> opts[:relationships] - is_nil(reading_user) -> + is_nil(reading_user) || opts[:skip_relationships] -> UserRelationship.view_relationships_option(nil, []) true -> @@ -158,6 +159,8 @@ def render("relationships.json", %{user: user, targets: targets} = opts) do end defp do_render("show.json", %{user: user} = opts) do + opts = Map.merge(%{skip_relationships: false}, opts) + user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) display_name = user.name || user.nickname diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 4da1ab67f..e518bdedb 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{notifications: notifications, for: reading_user} = opts) do + opts = Map.merge(%{skip_relationships: true}, opts) + activities = Enum.map(notifications, & &1.activity) parent_activities = @@ -71,6 +73,8 @@ def render( for: reading_user } = opts ) do + opts = Map.merge(%{skip_relationships: true}, opts) + actor = User.get_cached_by_ap_id(activity.data["actor"]) parent_activity_fn = fn -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 24167f66f..0bcc84d44 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -76,6 +76,8 @@ defp reblogged?(activity, user) do end def render("index.json", opts) do + opts = Map.merge(%{skip_relationships: true}, opts) + reading_user = opts[:for] # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list @@ -125,6 +127,8 @@ def render( "show.json", %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts ) do + opts = Map.merge(%{skip_relationships: true}, opts) + user = get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) activity_object = Object.normalize(activity) @@ -198,6 +202,8 @@ def render( end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + opts = Map.merge(%{skip_relationships: true}, opts) + object = Object.normalize(activity) user = get_user(activity.data["actor"]) diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 2c1874051..f3ac17a66 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -66,7 +66,13 @@ def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} %{ name: emoji, count: length(users), - accounts: AccountView.render("index.json", %{users: users, for: user, as: :user}), + accounts: + AccountView.render("index.json", %{ + users: users, + for: user, + as: :user, + skip_relationships: true + }), me: !!(user && user.ap_id in user_ap_ids) } end diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index db380f76a..e2d98ef3e 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -12,9 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do import Pleroma.Factory - test "does NOT render account/pleroma/relationship if this is disabled by default" do - clear_config([:extensions, :output_relationships_in_statuses_by_default], false) - + test "does NOT render account/pleroma/relationship by default" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 85068edd0..00e026087 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1058,7 +1058,7 @@ test "replaces missing description with an empty string", %{conn: conn, user: us end test "bookmarks" do - bookmarks_uri = "/api/v1/bookmarks?with_relationships=true" + bookmarks_uri = "/api/v1/bookmarks" %{conn: conn} = oauth_access(["write:bookmarks", "read:bookmarks"]) author = insert(:user) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..b8bb83af7 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -20,12 +20,10 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do describe "home" do setup do: oauth_access(["read:statuses"]) - test "does NOT render account/pleroma/relationship if this is disabled by default", %{ + test "does NOT render account/pleroma/relationship by default", %{ user: user, conn: conn } do - clear_config([:extensions, :output_relationships_in_statuses_by_default], false) - other_user = insert(:user) {:ok, _} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) @@ -41,7 +39,7 @@ test "does NOT render account/pleroma/relationship if this is disabled by defaul end) end - test "the home timeline", %{user: user, conn: conn} do + test "embeds account relationships with `with_relationships=true`", %{user: user, conn: conn} do uri = "/api/v1/timelines/home?with_relationships=true" following = insert(:user, nickname: "followed") @@ -69,13 +67,19 @@ test "the home timeline", %{user: user, conn: conn} do } } }, - "account" => %{"pleroma" => %{"relationship" => %{"following" => true}}} + "account" => %{ + "pleroma" => %{ + "relationship" => %{"following" => true} + } + } }, %{ "content" => "post", "account" => %{ "acct" => "followed", - "pleroma" => %{"relationship" => %{"following" => true}} + "pleroma" => %{ + "relationship" => %{"following" => true} + } } } ] = json_response(ret_conn, :ok) @@ -95,13 +99,19 @@ test "the home timeline", %{user: user, conn: conn} do } } }, - "account" => %{"pleroma" => %{"relationship" => %{"following" => true}}} + "account" => %{ + "pleroma" => %{ + "relationship" => %{"following" => true} + } + } }, %{ "content" => "post", "account" => %{ "acct" => "followed", - "pleroma" => %{"relationship" => %{"following" => true}} + "pleroma" => %{ + "relationship" => %{"following" => true} + } } } ] = json_response(ret_conn, :ok) diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c3ec9dfec..e1f9c3ac4 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -42,7 +42,12 @@ test "Mention notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "mention", - account: AccountView.render("show.json", %{user: user, for: mentioned_user}), + account: + AccountView.render("show.json", %{ + user: user, + for: mentioned_user, + skip_relationships: true + }), status: StatusView.render("show.json", %{activity: activity, for: mentioned_user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -62,7 +67,8 @@ test "Favourite notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "favourite", - account: AccountView.render("show.json", %{user: another_user, for: user}), + account: + AccountView.render("show.json", %{user: another_user, for: user, skip_relationships: true}), status: StatusView.render("show.json", %{activity: create_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -82,7 +88,8 @@ test "Reblog notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "reblog", - account: AccountView.render("show.json", %{user: another_user, for: user}), + account: + AccountView.render("show.json", %{user: another_user, for: user, skip_relationships: true}), status: StatusView.render("show.json", %{activity: reblog_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -100,7 +107,8 @@ test "Follow notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "follow", - account: AccountView.render("show.json", %{user: follower, for: followed}), + account: + AccountView.render("show.json", %{user: follower, for: followed, skip_relationships: true}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -143,8 +151,10 @@ test "Move notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "move", - account: AccountView.render("show.json", %{user: old_user, for: follower}), - target: AccountView.render("show.json", %{user: new_user, for: follower}), + account: + AccountView.render("show.json", %{user: old_user, for: follower, skip_relationships: true}), + target: + AccountView.render("show.json", %{user: new_user, for: follower, skip_relationships: true}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -169,7 +179,8 @@ test "EmojiReact notification" do pleroma: %{is_seen: false}, type: "pleroma:emoji_reaction", emoji: "☕", - account: AccountView.render("show.json", %{user: other_user, for: user}), + account: + AccountView.render("show.json", %{user: other_user, for: user, skip_relationships: true}), status: StatusView.render("show.json", %{activity: activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 6791c2fb0..91d4ded2c 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -555,7 +555,7 @@ test "a rich media card with all relevant data renders correctly" do end end - test "embeds a relationship in the account" do + test "does not embed a relationship in the account" do user = insert(:user) other_user = insert(:user) @@ -566,11 +566,10 @@ test "embeds a relationship in the account" do result = StatusView.render("show.json", %{activity: activity, for: other_user}) - assert result[:account][:pleroma][:relationship] == - AccountView.render("relationship.json", %{user: other_user, target: user}) + assert result[:account][:pleroma][:relationship] == %{} end - test "embeds a relationship in the account in reposts" do + test "does not embed a relationship in the account in reposts" do user = insert(:user) other_user = insert(:user) @@ -583,11 +582,8 @@ test "embeds a relationship in the account in reposts" do result = StatusView.render("show.json", %{activity: activity, for: user}) - assert result[:account][:pleroma][:relationship] == - AccountView.render("relationship.json", %{user: user, target: other_user}) - - assert result[:reblog][:account][:pleroma][:relationship] == - AccountView.render("relationship.json", %{user: user, target: user}) + assert result[:account][:pleroma][:relationship] == %{} + assert result[:reblog][:account][:pleroma][:relationship] == %{} end test "visibility/list" do -- cgit v1.2.3 From 85105f7aaeaaa241a9d524ab27e77d6284036051 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 1 May 2020 21:33:34 +0300 Subject: OpenAPI: Remove max pagination limit from the spec In an ideal world clients wouldn't try to request more than the max hardcoded limit, but SubwayTooter does. --- lib/pleroma/web/api_spec/helpers.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index df0804486..183df43ee 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -41,8 +41,8 @@ def pagination_params do Operation.parameter( :limit, :query, - %Schema{type: :integer, default: 20, maximum: 40}, - "Limit" + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" ) ] end -- cgit v1.2.3 From e7b1df7252aed1f4d41a6f4a9bd13050b86e7009 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 1 May 2020 22:50:40 +0300 Subject: Fix sporadic test compilation failure caused by unused alias --- test/web/mastodon_api/controllers/suggestion_controller_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/web/mastodon_api/controllers/suggestion_controller_test.exs b/test/web/mastodon_api/controllers/suggestion_controller_test.exs index 8d0e70db8..f120bd0cd 100644 --- a/test/web/mastodon_api/controllers/suggestion_controller_test.exs +++ b/test/web/mastodon_api/controllers/suggestion_controller_test.exs @@ -5,8 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Config - setup do: oauth_access(["read"]) test "returns empty result", %{conn: conn} do -- cgit v1.2.3 From c18ef452b05355cfd573e989cad776376c4b4757 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 1 May 2020 22:48:30 +0300 Subject: OpenAPI: Add `follow_request` to notification types Closes #1731 --- lib/pleroma/web/api_spec/operations/notification_operation.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index c6514f3f2..64adc5319 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -178,7 +178,16 @@ defp notification do defp notification_type do %Schema{ type: :string, - enum: ["follow", "favourite", "reblog", "mention", "poll", "pleroma:emoji_reaction", "move"], + enum: [ + "follow", + "favourite", + "reblog", + "mention", + "poll", + "pleroma:emoji_reaction", + "move", + "follow_request" + ], description: """ The type of event that resulted in the notification. -- cgit v1.2.3 From c6ddfa8f9594377e6e0b424759d1fdbda9c9a005 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 1 May 2020 21:15:43 +0200 Subject: static-fe.css: Restore and move to /priv/static/static-fe --- lib/pleroma/constants.ex | 5 +++++ lib/pleroma/plugs/instance_static.ex | 7 +++---- lib/pleroma/web/endpoint.ex | 5 +++-- lib/pleroma/web/templates/layout/static_fe.html.eex | 2 +- priv/static/static-fe/static-fe.css | Bin 0 -> 2715 bytes 5 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 priv/static/static-fe/static-fe.css diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 4ba39b53f..3a9eec5ea 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -20,4 +20,9 @@ defmodule Pleroma.Constants do "deleted_activity_id" ] ) + + const(static_only_files, + do: + ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc) + ) end diff --git a/lib/pleroma/plugs/instance_static.ex b/lib/pleroma/plugs/instance_static.ex index 927fa2663..7516f75c3 100644 --- a/lib/pleroma/plugs/instance_static.ex +++ b/lib/pleroma/plugs/instance_static.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.InstanceStatic do + require Pleroma.Constants + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration. @@ -21,9 +23,6 @@ def file_path(path) do end end - @only ~w(index.html robots.txt static emoji packs sounds images instance favicon.png sw.js - sw-pleroma.js) - def init(opts) do opts |> Keyword.put(:from, "__unconfigured_instance_static_plug") @@ -31,7 +30,7 @@ def init(opts) do |> Plug.Static.init() end - for only <- @only do + for only <- Pleroma.Constants.static_only_files() do at = Plug.Router.Utils.split("/") def call(%{request_path: "/" <> unquote(only) <> _} = conn, opts) do diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 72cb3ee27..226d42c2c 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.Endpoint do use Phoenix.Endpoint, otp_app: :pleroma + require Pleroma.Constants + socket("/socket", Pleroma.Web.UserSocket) plug(Pleroma.Plugs.SetLocalePlug) @@ -34,8 +36,7 @@ defmodule Pleroma.Web.Endpoint do Plug.Static, at: "/", from: :pleroma, - only: - ~w(index.html robots.txt static finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc), + only: Pleroma.Constants.static_only_files(), # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex index 819632cec..dc0ee2a5c 100644 --- a/lib/pleroma/web/templates/layout/static_fe.html.eex +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -5,7 +5,7 @@ <%= Pleroma.Config.get([:instance, :name]) %> <%= Phoenix.HTML.raw(assigns[:meta] || "") %> - +
    diff --git a/priv/static/static-fe/static-fe.css b/priv/static/static-fe/static-fe.css new file mode 100644 index 000000000..db61ff266 Binary files /dev/null and b/priv/static/static-fe/static-fe.css differ -- cgit v1.2.3 From f1bba5c7871c2319bece90b95d10498f7559edc9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 2 May 2020 14:37:40 +0300 Subject: PleromaFE bundle supporting follow request notifications. https://git.pleroma.social/pleroma/pleroma-fe/-/commit/5f90b6a384583a00769eeca3a6c6e2deec8bdd24 --- priv/static/index.html | 2 +- priv/static/static/font/fontello.1588344944597.eot | Bin 22444 -> 0 bytes priv/static/static/font/fontello.1588344944597.svg | 118 -------------------- priv/static/static/font/fontello.1588344944597.ttf | Bin 22276 -> 0 bytes .../static/static/font/fontello.1588344944597.woff | Bin 13656 -> 0 bytes .../static/font/fontello.1588344944597.woff2 | Bin 11536 -> 0 bytes priv/static/static/font/fontello.1588419330867.eot | Bin 0 -> 22752 bytes priv/static/static/font/fontello.1588419330867.svg | 122 +++++++++++++++++++++ priv/static/static/font/fontello.1588419330867.ttf | Bin 0 -> 22584 bytes .../static/static/font/fontello.1588419330867.woff | Bin 0 -> 13836 bytes .../static/font/fontello.1588419330867.woff2 | Bin 0 -> 11712 bytes priv/static/static/fontello.1588344944597.css | Bin 3296 -> 0 bytes priv/static/static/fontello.1588419330867.css | Bin 0 -> 3378 bytes priv/static/static/fontello.json | 14 ++- priv/static/static/js/2.0bcc7512986083cd9ecf.js | Bin 2190 -> 0 bytes .../static/static/js/2.0bcc7512986083cd9ecf.js.map | Bin 7763 -> 0 bytes priv/static/static/js/2.1c407059cd79fca99e19.js | Bin 0 -> 2190 bytes .../static/static/js/2.1c407059cd79fca99e19.js.map | Bin 0 -> 7763 bytes priv/static/static/js/app.3de9191d7fd30b4bf68c.js | Bin 1071665 -> 0 bytes .../static/js/app.3de9191d7fd30b4bf68c.js.map | Bin 1626532 -> 0 bytes priv/static/static/js/app.fa89b90e606f4facd209.js | Bin 0 -> 1075836 bytes .../static/js/app.fa89b90e606f4facd209.js.map | Bin 0 -> 1635217 bytes .../static/js/vendors~app.5b7c43d835cad9e56363.js | Bin 411232 -> 0 bytes .../js/vendors~app.5b7c43d835cad9e56363.js.map | Bin 1737936 -> 0 bytes .../static/js/vendors~app.8aa781e6dd81307f544b.js | Bin 0 -> 411233 bytes .../js/vendors~app.8aa781e6dd81307f544b.js.map | Bin 0 -> 1737947 bytes priv/static/sw-pleroma.js | Bin 31752 -> 31752 bytes 27 files changed, 136 insertions(+), 120 deletions(-) delete mode 100644 priv/static/static/font/fontello.1588344944597.eot delete mode 100644 priv/static/static/font/fontello.1588344944597.svg delete mode 100644 priv/static/static/font/fontello.1588344944597.ttf delete mode 100644 priv/static/static/font/fontello.1588344944597.woff delete mode 100644 priv/static/static/font/fontello.1588344944597.woff2 create mode 100644 priv/static/static/font/fontello.1588419330867.eot create mode 100644 priv/static/static/font/fontello.1588419330867.svg create mode 100644 priv/static/static/font/fontello.1588419330867.ttf create mode 100644 priv/static/static/font/fontello.1588419330867.woff create mode 100644 priv/static/static/font/fontello.1588419330867.woff2 delete mode 100644 priv/static/static/fontello.1588344944597.css create mode 100644 priv/static/static/fontello.1588419330867.css delete mode 100644 priv/static/static/js/2.0bcc7512986083cd9ecf.js delete mode 100644 priv/static/static/js/2.0bcc7512986083cd9ecf.js.map create mode 100644 priv/static/static/js/2.1c407059cd79fca99e19.js create mode 100644 priv/static/static/js/2.1c407059cd79fca99e19.js.map delete mode 100644 priv/static/static/js/app.3de9191d7fd30b4bf68c.js delete mode 100644 priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map create mode 100644 priv/static/static/js/app.fa89b90e606f4facd209.js create mode 100644 priv/static/static/js/app.fa89b90e606f4facd209.js.map delete mode 100644 priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js delete mode 100644 priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map create mode 100644 priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js create mode 100644 priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map diff --git a/priv/static/index.html b/priv/static/index.html index 6af441737..4fac5c100 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588344944597.eot b/priv/static/static/font/fontello.1588344944597.eot deleted file mode 100644 index 6b4850215..000000000 Binary files a/priv/static/static/font/fontello.1588344944597.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1588344944597.svg b/priv/static/static/font/fontello.1588344944597.svg deleted file mode 100644 index b905a0f6c..000000000 --- a/priv/static/static/font/fontello.1588344944597.svg +++ /dev/null @@ -1,118 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588344944597.ttf b/priv/static/static/font/fontello.1588344944597.ttf deleted file mode 100644 index b990cea9a..000000000 Binary files a/priv/static/static/font/fontello.1588344944597.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1588344944597.woff b/priv/static/static/font/fontello.1588344944597.woff deleted file mode 100644 index 93d102c6f..000000000 Binary files a/priv/static/static/font/fontello.1588344944597.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1588344944597.woff2 b/priv/static/static/font/fontello.1588344944597.woff2 deleted file mode 100644 index bc4d4dada..000000000 Binary files a/priv/static/static/font/fontello.1588344944597.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1588419330867.eot b/priv/static/static/font/fontello.1588419330867.eot new file mode 100644 index 000000000..7f8c61e38 Binary files /dev/null and b/priv/static/static/font/fontello.1588419330867.eot differ diff --git a/priv/static/static/font/fontello.1588419330867.svg b/priv/static/static/font/fontello.1588419330867.svg new file mode 100644 index 000000000..71f81f435 --- /dev/null +++ b/priv/static/static/font/fontello.1588419330867.svg @@ -0,0 +1,122 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588419330867.ttf b/priv/static/static/font/fontello.1588419330867.ttf new file mode 100644 index 000000000..7dc4f108b Binary files /dev/null and b/priv/static/static/font/fontello.1588419330867.ttf differ diff --git a/priv/static/static/font/fontello.1588419330867.woff b/priv/static/static/font/fontello.1588419330867.woff new file mode 100644 index 000000000..2bf4cbc16 Binary files /dev/null and b/priv/static/static/font/fontello.1588419330867.woff differ diff --git a/priv/static/static/font/fontello.1588419330867.woff2 b/priv/static/static/font/fontello.1588419330867.woff2 new file mode 100644 index 000000000..a31bf3f29 Binary files /dev/null and b/priv/static/static/font/fontello.1588419330867.woff2 differ diff --git a/priv/static/static/fontello.1588344944597.css b/priv/static/static/fontello.1588344944597.css deleted file mode 100644 index 000c1207a..000000000 Binary files a/priv/static/static/fontello.1588344944597.css and /dev/null differ diff --git a/priv/static/static/fontello.1588419330867.css b/priv/static/static/fontello.1588419330867.css new file mode 100644 index 000000000..198eff184 Binary files /dev/null and b/priv/static/static/fontello.1588419330867.css differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json index 5a7086a23..5963b68b4 100755 --- a/priv/static/static/fontello.json +++ b/priv/static/static/fontello.json @@ -345,6 +345,18 @@ "css": "link", "code": 59427, "src": "fontawesome" + }, + { + "uid": "8b80d36d4ef43889db10bc1f0dc9a862", + "css": "user", + "code": 59428, + "src": "fontawesome" + }, + { + "uid": "12f4ece88e46abd864e40b35e05b11cd", + "css": "ok", + "code": 59431, + "src": "fontawesome" } ] -} +} \ No newline at end of file diff --git a/priv/static/static/js/2.0bcc7512986083cd9ecf.js b/priv/static/static/js/2.0bcc7512986083cd9ecf.js deleted file mode 100644 index 680c9f82a..000000000 Binary files a/priv/static/static/js/2.0bcc7512986083cd9ecf.js and /dev/null differ diff --git a/priv/static/static/js/2.0bcc7512986083cd9ecf.js.map b/priv/static/static/js/2.0bcc7512986083cd9ecf.js.map deleted file mode 100644 index 488843d6a..000000000 Binary files a/priv/static/static/js/2.0bcc7512986083cd9ecf.js.map and /dev/null differ diff --git a/priv/static/static/js/2.1c407059cd79fca99e19.js b/priv/static/static/js/2.1c407059cd79fca99e19.js new file mode 100644 index 000000000..14018d92a Binary files /dev/null and b/priv/static/static/js/2.1c407059cd79fca99e19.js differ diff --git a/priv/static/static/js/2.1c407059cd79fca99e19.js.map b/priv/static/static/js/2.1c407059cd79fca99e19.js.map new file mode 100644 index 000000000..cfee79ea8 Binary files /dev/null and b/priv/static/static/js/2.1c407059cd79fca99e19.js.map differ diff --git a/priv/static/static/js/app.3de9191d7fd30b4bf68c.js b/priv/static/static/js/app.3de9191d7fd30b4bf68c.js deleted file mode 100644 index d8d350a50..000000000 Binary files a/priv/static/static/js/app.3de9191d7fd30b4bf68c.js and /dev/null differ diff --git a/priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map b/priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map deleted file mode 100644 index 0643ca253..000000000 Binary files a/priv/static/static/js/app.3de9191d7fd30b4bf68c.js.map and /dev/null differ diff --git a/priv/static/static/js/app.fa89b90e606f4facd209.js b/priv/static/static/js/app.fa89b90e606f4facd209.js new file mode 100644 index 000000000..a2cbcc337 Binary files /dev/null and b/priv/static/static/js/app.fa89b90e606f4facd209.js differ diff --git a/priv/static/static/js/app.fa89b90e606f4facd209.js.map b/priv/static/static/js/app.fa89b90e606f4facd209.js.map new file mode 100644 index 000000000..5722844a9 Binary files /dev/null and b/priv/static/static/js/app.fa89b90e606f4facd209.js.map differ diff --git a/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js b/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js deleted file mode 100644 index 0b69788e9..000000000 Binary files a/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map b/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map deleted file mode 100644 index 798010b89..000000000 Binary files a/priv/static/static/js/vendors~app.5b7c43d835cad9e56363.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js b/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js new file mode 100644 index 000000000..1d62bb0a4 Binary files /dev/null and b/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js differ diff --git a/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map b/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map new file mode 100644 index 000000000..ce0c86939 Binary files /dev/null and b/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index d9c1e6285..88244a549 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ -- cgit v1.2.3 From 2d07ed77477ba7b62b2cfc524f91829937e2fdb3 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 2 May 2020 18:28:04 +0300 Subject: [#1732] Made AP C2S :followers and :following endpoints serve on no auth (as for related :api pipeline endpoints). --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 3 ++- lib/pleroma/web/router.ex | 1 + test/web/activity_pub/activity_pub_controller_test.exs | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index d625530ec..f607931ab 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -37,9 +37,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do [unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions ) + # Note: :following and :followers must be served even without authentication (as via :api) plug( EnsureAuthenticatedPlug - when action in [:read_inbox, :update_outbox, :whoami, :upload_media, :following, :followers] + when action in [:read_inbox, :update_outbox, :whoami, :upload_media] ) plug( diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 83287a83d..5b00243e9 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -585,6 +585,7 @@ defmodule Pleroma.Web.Router do post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) + # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 6b5913f95..a8f1f0e26 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -1055,12 +1055,12 @@ test "it works for more than 10 users", %{conn: conn} do assert result["totalItems"] == 15 end - test "returns 403 if requester is not logged in", %{conn: conn} do + test "does not require authentication", %{conn: conn} do user = insert(:user) conn |> get("/users/#{user.nickname}/followers") - |> json_response(403) + |> json_response(200) end end @@ -1152,12 +1152,12 @@ test "it works for more than 10 users", %{conn: conn} do assert result["totalItems"] == 15 end - test "returns 403 if requester is not logged in", %{conn: conn} do + test "does not require authentication", %{conn: conn} do user = insert(:user) conn |> get("/users/#{user.nickname}/following") - |> json_response(403) + |> json_response(200) end end -- cgit v1.2.3 From e55876409b523d81bc19db876bc90f29ba80a47c Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 29 Apr 2020 14:26:31 +0300 Subject: Deactivate local users on deletion instead of deleting the record Prevents the possibility of re-registration, which allowed to read DMs of the deleted account. Also includes a migration that tries to find any already deleted accounts and insert skeletons for them. Closes pleroma/pleroma#1687 --- lib/pleroma/user.ex | 11 +++++- .../controllers/pleroma_api_controller.ex | 5 ++- ...28221338_insert_skeletons_for_deleted_users.exs | 45 ++++++++++++++++++++++ test/tasks/user_test.exs | 2 +- test/user_test.exs | 14 +------ test/web/activity_pub/transmogrifier_test.exs | 3 +- 6 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b451202b2..99358ddaf 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1445,8 +1445,15 @@ def perform(:delete, %User{} = user) do end) delete_user_activities(user) - invalidate_cache(user) - Repo.delete(user) + + if user.local do + user + |> change(%{deactivated: true, email: nil}) + |> update_and_set_cache() + else + invalidate_cache(user) + Repo.delete(user) + end end def perform(:deactivate_async, user, status), do: deactivate(user, status) diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 2c1874051..1bdb3aa4d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -61,7 +61,10 @@ def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} else users = Enum.map(user_ap_ids, &User.get_cached_by_ap_id/1) - |> Enum.filter(& &1) + |> Enum.filter(fn + %{deactivated: false} -> true + _ -> false + end) %{ name: emoji, diff --git a/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs b/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs new file mode 100644 index 000000000..11d9a70ba --- /dev/null +++ b/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs @@ -0,0 +1,45 @@ +defmodule Pleroma.Repo.Migrations.InsertSkeletonsForDeletedUsers do + use Ecto.Migration + + alias Pleroma.User + alias Pleroma.Repo + + import Ecto.Query + + def change do + Application.ensure_all_started(:flake_id) + + local_ap_id = + User.Query.build(%{local: true}) + |> select([u], u.ap_id) + |> limit(1) + |> Repo.one() + + unless local_ap_id == nil do + # Hack to get instance base url because getting it from Phoenix + # would require starting the whole application + instance_uri = + local_ap_id + |> URI.parse() + |> Map.put(:query, nil) + |> Map.put(:path, nil) + |> URI.to_string() + + {:ok, %{rows: ap_ids}} = + Ecto.Adapters.SQL.query( + Repo, + "select distinct unnest(nonexistent_locals.recipients) from activities, lateral (select array_agg(recipient) as recipients from unnest(activities.recipients) as recipient where recipient similar to '#{ + instance_uri + }/users/[A-Za-z0-9]*' and not(recipient in (select ap_id from users where local = true))) nonexistent_locals;", + [], + timeout: :infinity + ) + + ap_ids + |> Enum.each(fn [ap_id] -> + Ecto.Changeset.change(%User{}, deactivated: true, ap_id: ap_id) + |> Repo.insert() + end) + end + end +end diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 8df835b56..0f6ffb2b1 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -92,7 +92,7 @@ test "user is deleted" do assert_received {:mix_shell, :info, [message]} assert message =~ " deleted" - refute User.get_by_nickname(user.nickname) + assert %{deactivated: true} = User.get_by_nickname(user.nickname) end test "no user to delete" do diff --git a/test/user_test.exs b/test/user_test.exs index 347c5be72..bff337d3e 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1135,16 +1135,7 @@ test ".delete_user_activities deletes all create activities", %{user: user} do refute Activity.get_by_id(activity.id) end - test "it deletes deactivated user" do - {:ok, user} = insert(:user, deactivated: true) |> User.set_cache() - - {:ok, job} = User.delete(user) - {:ok, _user} = ObanHelpers.perform(job) - - refute User.get_by_id(user.id) - end - - test "it deletes a user, all follow relationships and all activities", %{user: user} do + test "it deactivates a user, all follow relationships and all activities", %{user: user} do follower = insert(:user) {:ok, follower} = User.follow(follower, user) @@ -1164,8 +1155,7 @@ test "it deletes a user, all follow relationships and all activities", %{user: u follower = User.get_cached_by_id(follower.id) refute User.following?(follower, user) - refute User.get_by_id(user.id) - assert {:ok, nil} == Cachex.get(:user_cache, "ap_id:#{user.ap_id}") + assert %{deactivated: true} = User.get_by_id(user.id) user_activities = user.ap_id diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 10d86ee45..36e1e7bd1 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -872,7 +872,8 @@ test "it fails for incoming deletes with spoofed origin" do @tag capture_log: true test "it works for incoming user deletes" do - %{ap_id: ap_id} = insert(:user, ap_id: "http://mastodon.example.org/users/admin") + %{ap_id: ap_id} = + insert(:user, ap_id: "http://mastodon.example.org/users/admin", local: false) data = File.read!("test/fixtures/mastodon-delete-user.json") -- cgit v1.2.3 From 66a8e1312dc82fa755a635984f89a5314917d209 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Mon, 27 Apr 2020 17:41:38 +0300 Subject: Mastodon API: do not create a following relationship if the corresponding follow request doesn't exist when calling `POST /api/v1/follow_requests/:id/authorize` --- CHANGELOG.md | 1 + lib/pleroma/web/common_api/common_api.ex | 4 ++-- test/web/common_api/common_api_test.exs | 8 ++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97704917d..54a0561b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Logger configuration through AdminFE - HTTP Basic Authentication permissions issue - ObjectAgePolicy didn't filter out old messages +- Mastodon API: do not create a following relationship if the corresponding follow request doesn't exist when calling `POST /api/v1/follow_requests/:id/authorize` ### Added - NodeInfo: ObjectAgePolicy settings to the `federation` list. diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 4618b4bbf..f9db97d24 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -43,8 +43,8 @@ def unfollow(follower, unfollowed) do end def accept_follow_request(follower, followed) do - with {:ok, follower} <- User.follow(follower, followed), - %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), + {:ok, follower} <- User.follow(follower, followed), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept), {:ok, _activity} <- diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index c6ccc02c4..bc0c1a791 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -697,6 +697,14 @@ test "after rejection, it sets all existing pending follow request states to 're assert Repo.get(Activity, follow_activity_two.id).data["state"] == "reject" assert Repo.get(Activity, follow_activity_three.id).data["state"] == "pending" end + + test "doesn't create a following relationship if the corresponding follow request doesn't exist" do + user = insert(:user, locked: true) + not_follower = insert(:user) + CommonAPI.accept_follow_request(not_follower, user) + + assert Pleroma.FollowingRelationship.following?(not_follower, user) == false + end end describe "vote/3" do -- cgit v1.2.3 From 9a92e5a351b7066f42fb5f4d2951f5ef4e4c2a6d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 1 May 2020 00:28:28 +0300 Subject: Reword changelog entry for follow relationship bug --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a0561b3..9279c1af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,11 +37,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Filtering of push notifications on activities from blocked domains ## [unreleased-patch] +### Security +- Mastodon API: Fix `POST /api/v1/follow_requests/:id/authorize` allowing to force a follow from a local user even if they didn't request to follow + ### Fixed - Logger configuration through AdminFE - HTTP Basic Authentication permissions issue - ObjectAgePolicy didn't filter out old messages -- Mastodon API: do not create a following relationship if the corresponding follow request doesn't exist when calling `POST /api/v1/follow_requests/:id/authorize` ### Added - NodeInfo: ObjectAgePolicy settings to the `federation` list. -- cgit v1.2.3 From d589f3dcfb961fa92bac8c8d140000de498353ff Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 1 May 2020 00:33:04 +0300 Subject: CHANGELOG.md: Add entry for re-registration ban --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9279c1af0..d1e7be74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased-patch] ### Security +- Disallow re-registration of previously deleted users, which allowed viewing direct messages addressed to them - Mastodon API: Fix `POST /api/v1/follow_requests/:id/authorize` allowing to force a follow from a local user even if they didn't request to follow ### Fixed -- cgit v1.2.3 From 095635453ac58b9e01a32ad226c0b61466c16da0 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 2 May 2020 18:10:50 +0000 Subject: Replace deprecated Roma by Fedi. --- docs/clients.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index 1eae0f0c6..7f98dc7b1 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -49,11 +49,11 @@ Feel free to contact us to be added to this list! - Platforms: Android - Features: Streaming Ready -### Roma -- Homepage: -- Source Code: [iOS](https://github.com/roma-apps/roma-ios), [Android](https://github.com/roma-apps/roma-android) +### Fedi +- Homepage: +- Source Code: Proprietary, but free - Platforms: iOS, Android -- Features: No Streaming +- Features: Pleroma-specific features like Reactions ### Tusky - Homepage: -- cgit v1.2.3 From 370e313e2df19e4579c154388336dd5e09bff7bf Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 2 May 2020 13:28:10 -0500 Subject: Only update follower/following stats for actor types of users and bots. --- lib/pleroma/web/activity_pub/activity_pub.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1f4a09370..31304c340 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1532,9 +1532,14 @@ defp normalize_counter(_), do: 0 defp maybe_update_follow_information(data) do with {:enabled, true} <- {:enabled, Config.get([:instance, :external_user_synchronization])}, - {:ok, info} <- fetch_follow_information_for_user(data) do + {:ok, info} <- fetch_follow_information_for_user(data), + {:ok, actor_type} <- Map.fetch(data, :actor_type) do info = Map.merge(data[:info] || %{}, info) - Map.put(data, :info, info) + + cond do + actor_type in ["Person", "Service"] -> Map.put(data, :info, info) + true -> data + end else {:enabled, false} -> data -- cgit v1.2.3 From f20a1a27ef93c494e671b67603b320249073e011 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Sun, 3 May 2020 12:19:01 +0200 Subject: DeleteValidator: Improve code readability --- .../web/activity_pub/object_validators/delete_validator.ex | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index 256ac70b6..68ab08605 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -67,19 +67,17 @@ def do_not_federate?(cng) do end defp same_domain?(cng) do - actor_domain = + actor_uri = cng |> get_field(:actor) |> URI.parse() - |> (& &1.host).() - object_domain = + object_uri = cng |> get_field(:object) |> URI.parse() - |> (& &1.host).() - object_domain == actor_domain + object_uri.host == actor_uri.host end def validate_deletion_rights(cng) do -- cgit v1.2.3 From 4dfc617cdf1c2579f4f941dcd0fa5c728178df06 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 12:51:28 +0200 Subject: Transmogrifier: Don't fetch actor that's guaranteed to be there. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 +-- .../transmogrifier/delete_handling_test.exs | 30 +++++----------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 855aab8d4..1e031a015 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -733,8 +733,7 @@ def handle_incoming( %{"type" => "Delete"} = data, _options ) do - with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), - {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} end end diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs index 64c908a05..c141e25bc 100644 --- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs @@ -13,7 +13,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.DeleteHandlingTest do alias Pleroma.Web.ActivityPub.Transmogrifier import Pleroma.Factory - import ExUnit.CaptureLog setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -27,22 +26,15 @@ test "it works for incoming deletes" do data = File.read!("test/fixtures/mastodon-delete.json") |> Poison.decode!() - - object = - data["object"] - |> Map.put("id", activity.data["object"]) - - data = - data - |> Map.put("object", object) |> Map.put("actor", deleting_user.ap_id) + |> put_in(["object", "id"], activity.data["object"]) {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} = Transmogrifier.handle_incoming(data) assert id == data["id"] - # We delete the Create activity because base our timelines on it. + # We delete the Create activity because we base our timelines on it. # This should be changed after we unify objects and activities refute Activity.get_by_id(activity.id) assert actor == deleting_user.ap_id @@ -54,25 +46,15 @@ test "it works for incoming deletes" do test "it fails for incoming deletes with spoofed origin" do activity = insert(:note_activity) + %{ap_id: ap_id} = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo") data = File.read!("test/fixtures/mastodon-delete.json") |> Poison.decode!() + |> Map.put("actor", ap_id) + |> put_in(["object", "id"], activity.data["object"]) - object = - data["object"] - |> Map.put("id", activity.data["object"]) - - data = - data - |> Map.put("object", object) - - assert capture_log(fn -> - {:error, _} = Transmogrifier.handle_incoming(data) - end) =~ - "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}" - - assert Activity.get_by_id(activity.id) + assert match?({:error, _}, Transmogrifier.handle_incoming(data)) end @tag capture_log: true -- cgit v1.2.3 From 6c337489f4db28f78be940bef01ef3a80e279ffc Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 13:01:19 +0200 Subject: Various testing fixes in relation to user deletion. --- test/web/activity_pub/side_effects_test.exs | 2 +- test/web/activity_pub/transmogrifier/delete_handling_test.exs | 2 +- test/web/admin_api/admin_api_controller_test.exs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index ce34eed4c..a9598d7b3 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -68,7 +68,7 @@ test "it handles user deletions", %{delete_user: delete, user: user} do {:ok, _delete, _} = SideEffects.handle(delete) ObanHelpers.perform_all() - refute User.get_cached_by_ap_id(user.ap_id) + assert User.get_cached_by_ap_id(user.ap_id).deactivated end end diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs index c141e25bc..f235a8e63 100644 --- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs @@ -68,7 +68,7 @@ test "it works for incoming user deletes" do {:ok, _} = Transmogrifier.handle_incoming(data) ObanHelpers.perform_all() - refute User.get_cached_by_ap_id(ap_id) + assert User.get_cached_by_ap_id(ap_id).deactivated end test "it fails for incoming user deletes with spoofed origin" do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index bf054a12e..0daf29ffb 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -156,7 +156,7 @@ test "single user", %{admin: admin, conn: conn} do ObanHelpers.perform_all() - refute User.get_by_nickname(user.nickname) + assert User.get_by_nickname(user.nickname).deactivated log_entry = Repo.one(ModerationLog) -- cgit v1.2.3 From 1974d0cc423efefcbdadd68442d0fbed8f3ee4ab Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 13:02:57 +0200 Subject: DeleteValidator: The deleted activity id is an object id --- lib/pleroma/web/activity_pub/object_validators/delete_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index 68ab08605..e06de3dff 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do field(:actor, Types.ObjectID) field(:to, Types.Recipients, default: []) field(:cc, Types.Recipients, default: []) - field(:deleted_activity_id) + field(:deleted_activity_id, Types.ObjectID) field(:object, Types.ObjectID) end -- cgit v1.2.3 From a7966f2080a0e9b3c2b35efa7ea647c1bdef2a2d Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 13:48:01 +0200 Subject: Webfinger: Request account info with the acct scheme --- lib/pleroma/web/web_finger/web_finger.ex | 6 ++++-- test/support/http_request_mock.ex | 14 +++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index 7ffd0e51b..442b25165 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -194,13 +194,15 @@ def finger(account) do URI.parse(account).host end + encoded_account = URI.encode("acct:#{account}") + address = case find_lrdd_template(domain) do {:ok, template} -> - String.replace(template, "{uri}", URI.encode(account)) + String.replace(template, "{uri}", encoded_account) _ -> - "https://#{domain}/.well-known/webfinger?resource=acct:#{account}" + "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}" end with response <- diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 9624cb0f7..3a95e92da 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -211,7 +211,7 @@ def get( end def get( - "https://squeet.me/xrd/?uri=lain@squeet.me", + "https://squeet.me/xrd/?uri=acct:lain@squeet.me", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] @@ -870,7 +870,7 @@ def get( end def get( - "https://social.heldscal.la/.well-known/webfinger?resource=shp@social.heldscal.la", + "https://social.heldscal.la/.well-known/webfinger?resource=acct:shp@social.heldscal.la", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] @@ -883,7 +883,7 @@ def get( end def get( - "https://social.heldscal.la/.well-known/webfinger?resource=invalid_content@social.heldscal.la", + "https://social.heldscal.la/.well-known/webfinger?resource=acct:invalid_content@social.heldscal.la", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] @@ -900,7 +900,7 @@ def get("http://framatube.org/.well-known/host-meta", _, _, _) do end def get( - "http://framatube.org/main/xrd?uri=framasoft@framatube.org", + "http://framatube.org/main/xrd?uri=acct:framasoft@framatube.org", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] @@ -959,7 +959,7 @@ def get("http://gerzilla.de/.well-known/host-meta", _, _, _) do end def get( - "https://gerzilla.de/xrd/?uri=kaniini@gerzilla.de", + "https://gerzilla.de/xrd/?uri=acct:kaniini@gerzilla.de", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] @@ -1155,7 +1155,7 @@ def get("http://404.site" <> _, _, _, _) do end def get( - "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=lain@zetsubou.xn--q9jyb4c", + "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:lain@zetsubou.xn--q9jyb4c", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] @@ -1168,7 +1168,7 @@ def get( end def get( - "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=https://zetsubou.xn--q9jyb4c/users/lain", + "https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource=acct:https://zetsubou.xn--q9jyb4c/users/lain", _, _, [{"accept", "application/xrd+xml,application/jrd+json"}] -- cgit v1.2.3 From a35b76431ce7c7bd7ed62374d781778922f0fe2f Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 14:58:24 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 50904ed59..20c7cceb6 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) -- cgit v1.2.3 From 9249742f13445f47167d4b352751c49caf48aa8f Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 3 May 2020 15:28:24 +0200 Subject: Types.Recipients: Simplify reducer. --- .../web/activity_pub/object_validators/types/recipients.ex | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex index 48fe61e1a..408e0f6ee 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex @@ -11,11 +11,13 @@ def cast(object) when is_binary(object) do def cast(data) when is_list(data) do data - |> Enum.reduce({:ok, []}, fn element, acc -> - case {acc, ObjectID.cast(element)} do - {:error, _} -> :error - {_, :error} -> :error - {{:ok, list}, {:ok, id}} -> {:ok, [id | list]} + |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, {:ok, [id | list]}} + + _ -> + {:halt, :error} end end) end -- cgit v1.2.3 From 651935f1379a1ed3c89e473803251310c13ea571 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 11:08:00 +0200 Subject: Schemas: Refactor to our naming scheme. --- .../web/api_spec/operations/chat_operation.ex | 12 ++-- lib/pleroma/web/api_spec/schemas/chat.ex | 70 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/chat_message.ex | 38 ++++++++++++ .../web/api_spec/schemas/chat_message_response.ex | 38 ------------ lib/pleroma/web/api_spec/schemas/chat_response.ex | 70 ---------------------- 5 files changed, 114 insertions(+), 114 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/chat.ex create mode 100644 lib/pleroma/web/api_spec/schemas/chat_message.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/chat_response.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 88b9db048..fc9d4608a 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse - alias Pleroma.Web.ApiSpec.Schemas.ChatResponse + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + alias Pleroma.Web.ApiSpec.Schemas.Chat import Pleroma.Web.ApiSpec.Helpers @@ -37,7 +37,7 @@ def create_operation do Operation.response( "The created or existing chat", "application/json", - ChatResponse + Chat ) }, security: [ @@ -103,7 +103,7 @@ def post_chat_message_operation do Operation.response( "The newly created ChatMessage", "application/json", - ChatMessageResponse + ChatMessage ) }, security: [ @@ -119,7 +119,7 @@ def chats_response do title: "ChatsResponse", description: "Response schema for multiple Chats", type: :array, - items: ChatResponse, + items: Chat, example: [ %{ "account" => %{ @@ -180,7 +180,7 @@ def chat_messages_response do title: "ChatMessagesResponse", description: "Response schema for multiple ChatMessages", type: :array, - items: ChatMessageResponse, + items: ChatMessage, example: [ %{ "emojis" => [ diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex new file mode 100644 index 000000000..4d385d6ab --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -0,0 +1,70 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Chat do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Chat", + description: "Response schema for a Chat", + type: :object, + properties: %{ + id: %Schema{type: :string, nullable: false}, + account: %Schema{type: :object, nullable: false}, + unread: %Schema{type: :integer, nullable: false} + }, + example: %{ + "account" => %{ + "pleroma" => %{ + "is_admin" => false, + "confirmation_pending" => false, + "hide_followers_count" => false, + "is_moderator" => false, + "hide_favorites" => true, + "ap_id" => "https://dontbulling.me/users/lain", + "hide_follows_count" => false, + "hide_follows" => false, + "background_image" => nil, + "skip_thread_containment" => false, + "hide_followers" => false, + "relationship" => %{}, + "tags" => [] + }, + "avatar" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "following_count" => 0, + "header_static" => "https://originalpatchou.li/images/banner.png", + "source" => %{ + "sensitive" => false, + "note" => "lain", + "pleroma" => %{ + "discoverable" => false, + "actor_type" => "Person" + }, + "fields" => [] + }, + "statuses_count" => 1, + "locked" => false, + "created_at" => "2020-04-16T13:40:15.000Z", + "display_name" => "lain", + "fields" => [], + "acct" => "lain@dontbulling.me", + "id" => "9u6Qw6TAZANpqokMkK", + "emojis" => [], + "avatar_static" => + "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", + "username" => "lain", + "followers_count" => 0, + "header" => "https://originalpatchou.li/images/banner.png", + "bot" => false, + "note" => "lain", + "url" => "https://dontbulling.me/users/lain" + }, + "id" => "1", + "unread" => 2 + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex new file mode 100644 index 000000000..7c93b0c83 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ChatMessage", + description: "Response schema for a ChatMessage", + type: :object, + properties: %{ + id: %Schema{type: :string}, + account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, + chat_id: %Schema{type: :string}, + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :"date-time"}, + emojis: %Schema{type: :array} + }, + example: %{ + "account_id" => "someflakeid", + "chat_id" => "1", + "content" => "hey you again", + "created_at" => "2020-04-21T15:06:45.000Z", + "emojis" => [ + %{ + "static_url" => "https://dontbulling.me/emoji/Firefox.gif", + "visible_in_picker" => false, + "shortcode" => "firefox", + "url" => "https://dontbulling.me/emoji/Firefox.gif" + } + ], + "id" => "14" + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex b/lib/pleroma/web/api_spec/schemas/chat_message_response.ex deleted file mode 100644 index 707c9808b..000000000 --- a/lib/pleroma/web/api_spec/schemas/chat_message_response.ex +++ /dev/null @@ -1,38 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageResponse do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatMessageResponse", - description: "Response schema for a ChatMessage", - type: :object, - properties: %{ - id: %Schema{type: :string}, - account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, - chat_id: %Schema{type: :string}, - content: %Schema{type: :string}, - created_at: %Schema{type: :string, format: :"date-time"}, - emojis: %Schema{type: :array} - }, - example: %{ - "account_id" => "someflakeid", - "chat_id" => "1", - "content" => "hey you again", - "created_at" => "2020-04-21T15:06:45.000Z", - "emojis" => [ - %{ - "static_url" => "https://dontbulling.me/emoji/Firefox.gif", - "visible_in_picker" => false, - "shortcode" => "firefox", - "url" => "https://dontbulling.me/emoji/Firefox.gif" - } - ], - "id" => "14" - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/chat_response.ex b/lib/pleroma/web/api_spec/schemas/chat_response.ex deleted file mode 100644 index aa435165d..000000000 --- a/lib/pleroma/web/api_spec/schemas/chat_response.ex +++ /dev/null @@ -1,70 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatResponse do - alias OpenApiSpex.Schema - - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatResponse", - description: "Response schema for a Chat", - type: :object, - properties: %{ - id: %Schema{type: :string, nullable: false}, - account: %Schema{type: :object, nullable: false}, - unread: %Schema{type: :integer, nullable: false} - }, - example: %{ - "account" => %{ - "pleroma" => %{ - "is_admin" => false, - "confirmation_pending" => false, - "hide_followers_count" => false, - "is_moderator" => false, - "hide_favorites" => true, - "ap_id" => "https://dontbulling.me/users/lain", - "hide_follows_count" => false, - "hide_follows" => false, - "background_image" => nil, - "skip_thread_containment" => false, - "hide_followers" => false, - "relationship" => %{}, - "tags" => [] - }, - "avatar" => - "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", - "following_count" => 0, - "header_static" => "https://originalpatchou.li/images/banner.png", - "source" => %{ - "sensitive" => false, - "note" => "lain", - "pleroma" => %{ - "discoverable" => false, - "actor_type" => "Person" - }, - "fields" => [] - }, - "statuses_count" => 1, - "locked" => false, - "created_at" => "2020-04-16T13:40:15.000Z", - "display_name" => "lain", - "fields" => [], - "acct" => "lain@dontbulling.me", - "id" => "9u6Qw6TAZANpqokMkK", - "emojis" => [], - "avatar_static" => - "https://dontbulling.me/media/065a4dd3c6740dab13ff9c71ec7d240bb9f8be9205c9e7467fb2202117da1e32.jpg", - "username" => "lain", - "followers_count" => 0, - "header" => "https://originalpatchou.li/images/banner.png", - "bot" => false, - "note" => "lain", - "url" => "https://dontbulling.me/users/lain" - }, - "id" => "1", - "unread" => 2 - } - }) -end -- cgit v1.2.3 From dcf535fe770b638b68928f238f4d8d1cfd410524 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 11:32:11 +0200 Subject: Credo fixes. --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index fc9d4608a..ad05f5ac7 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.ChatMessage alias Pleroma.Web.ApiSpec.Schemas.Chat + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage + alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest import Pleroma.Web.ApiSpec.Helpers -- cgit v1.2.3 From 7dd47bee82c9f4a5e3b4ce6d74c5a22cac596b52 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 12:22:31 +0200 Subject: Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522285efe..92dd6f0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again - Fix follower/blocks import when nicknames starts with @ - Filtering of push notifications on activities from blocked domains +- Resolving Peertube accounts with Webfinger ## [unreleased-patch] ### Security -- cgit v1.2.3 From 57e6f2757afef8941fe3576dbe5e2014d2569c33 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 12:47:23 +0200 Subject: ChatOperation: Make simple schema into inline schema --- .../web/api_spec/operations/chat_operation.ex | 18 ++++++++++++++++-- .../api_spec/schemas/chat_message_create_request.ex | 21 --------------------- 2 files changed, 16 insertions(+), 23 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index ad05f5ac7..e8b5eff1f 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Chat alias Pleroma.Web.ApiSpec.Schemas.ChatMessage - alias Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest import Pleroma.Web.ApiSpec.Helpers @@ -97,7 +96,7 @@ def post_chat_message_operation do parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], - requestBody: request_body("Parameters", ChatMessageCreateRequest, required: true), + requestBody: request_body("Parameters", chat_message_create(), required: true), responses: %{ 200 => Operation.response( @@ -208,4 +207,19 @@ def chat_messages_response do ] } end + + def chat_message_create do + %Schema{ + title: "ChatMessageCreateRequest", + description: "POST body for creating an chat message", + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The content of your message"} + }, + required: [:content], + example: %{ + "content" => "Hey wanna buy feet pics?" + } + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex b/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex deleted file mode 100644 index 8e1b7af14..000000000 --- a/lib/pleroma/web/api_spec/schemas/chat_message_create_request.ex +++ /dev/null @@ -1,21 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessageCreateRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "ChatMessageCreateRequest", - description: "POST body for creating an chat message", - type: :object, - properties: %{ - content: %Schema{type: :string, description: "The content of your message"} - }, - required: [:content], - example: %{ - "content" => "Hey wanna buy feet pics?" - } - }) -end -- cgit v1.2.3 From 30590cf46b88d0008c9a7163b8339aa9376f2378 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 12:53:40 +0200 Subject: CommonAPI: Refactor for readability --- lib/pleroma/web/common_api/common_api.ex | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 1eda0b2f2..e428cc17d 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -26,9 +26,7 @@ defmodule Pleroma.Web.CommonAPI do require Logger def post_chat_message(%User{} = user, %User{} = recipient, content) do - with {_, true} <- - {:content_length, - String.length(content) <= Pleroma.Config.get([:instance, :chat_limit])}, + with :ok <- validate_chat_content_length(content), {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( @@ -44,9 +42,14 @@ def post_chat_message(%User{} = user, %User{} = recipient, content) do local: true )} do {:ok, activity} + end + end + + defp validate_chat_content_length(content) do + if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do + :ok else - {:content_length, false} -> {:error, :content_too_long} - e -> e + {:error, :content_too_long} end end -- cgit v1.2.3 From b04328c3dec4812dbaf3cd89baa2b888d7bb7fbf Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 13:10:36 +0200 Subject: ChatController: Add mark_as_read --- lib/pleroma/chat.ex | 6 ++++++ .../web/api_spec/operations/chat_operation.ex | 22 +++++++++++++++++++++ .../web/pleroma_api/controllers/chat_controller.ex | 11 ++++++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 23 ++++++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 6b1f832ce..6008196e4 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -60,4 +60,10 @@ def bump_or_create(user_id, recipient) do conflict_target: [:user_id, :recipient] ) end + + def mark_as_read(chat) do + chat + |> change(%{unread: 0}) + |> Repo.update() + end end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index e8b5eff1f..0fe0e07b2 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -16,6 +16,28 @@ def open_api_operation(action) do apply(__MODULE__, operation, []) end + def mark_as_read_operation do + %Operation{ + tags: ["chat"], + summary: "Mark all messages in the chat as read", + operationId: "ChatController.mark_as_read", + parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")], + responses: %{ + 200 => + Operation.response( + "The updated chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] + } + end + def create_operation do %Operation{ tags: ["chat"], diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 175257921..bedae73bd 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create] + %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create, :mark_as_read] ) plug( @@ -51,6 +51,15 @@ def post_chat_message( end end + def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do + with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), + {:ok, chat} <- Chat.mark_as_read(chat) do + conn + |> put_view(ChatView) + |> render("show.json", chat: chat) + end + end + def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3a5063d4a..d6803e8ac 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -293,6 +293,7 @@ defmodule Pleroma.Web.Router do get("/chats", ChatController, :index) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) + post("/chats/:id/read", ChatController, :mark_as_read) end scope [] do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index b1044574b..cdb2683c8 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -9,6 +9,29 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory + describe "POST /api/v1/pleroma/chats/:id/read" do + setup do: oauth_access(["write:statuses"]) + + test "it marks all messages in a chat as read", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + + assert chat.unread == 1 + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/read") + |> json_response_and_validate_schema(200) + + assert result["unread"] == 0 + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + assert chat.unread == 0 + end + end + describe "POST /api/v1/pleroma/chats/:id/messages" do setup do: oauth_access(["write:statuses"]) -- cgit v1.2.3 From 7ff2a7dae2fa651cea579aeca40e2c030d19fcd5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 13:12:21 +0200 Subject: Docs: Add Chat mark_as_read docs --- docs/API/chats.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 26e83570e..8d925989c 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -66,6 +66,27 @@ Returned data: } ``` +### Marking a chat as read + +To set the `unread` count of a chat to 0, call + +`POST /api/v1/pleroma/chats/:id/read` + +Returned data: + +```json +{ + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id" : "1", + "unread" : 0 +} +``` + + ### Getting a list of Chats `GET /api/v1/pleroma/chats` -- cgit v1.2.3 From ec24c70db80665aaaf4d7794ea62e9f9e6676bec Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 14:22:54 +0200 Subject: ActivityPub: Don't fetch `Application` follower counts. --- lib/pleroma/web/activity_pub/activity_pub.ex | 32 +++++++++++------- test/web/activity_pub/activity_pub_test.exs | 50 +++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 31304c340..1c21d78af 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1530,26 +1530,34 @@ def fetch_follow_information_for_user(user) do defp normalize_counter(counter) when is_integer(counter), do: counter defp normalize_counter(_), do: 0 - defp maybe_update_follow_information(data) do + def maybe_update_follow_information(user_data) do with {:enabled, true} <- {:enabled, Config.get([:instance, :external_user_synchronization])}, - {:ok, info} <- fetch_follow_information_for_user(data), - {:ok, actor_type} <- Map.fetch(data, :actor_type) do - info = Map.merge(data[:info] || %{}, info) - - cond do - actor_type in ["Person", "Service"] -> Map.put(data, :info, info) - true -> data - end + {_, true} <- {:user_type_check, user_data[:type] in ["Person", "Service"]}, + {_, true} <- + {:collections_available, + !!(user_data[:following_address] && user_data[:follower_address])}, + {:ok, info} <- + fetch_follow_information_for_user(user_data) do + info = Map.merge(user_data[:info] || %{}, info) + + user_data + |> Map.put(:info, info) else + {:user_type_check, false} -> + user_data + + {:collections_available, false} -> + user_data + {:enabled, false} -> - data + user_data e -> Logger.error( - "Follower/Following counter update for #{data.ap_id} failed.\n" <> inspect(e) + "Follower/Following counter update for #{user_data.ap_id} failed.\n" <> inspect(e) ) - data + user_data end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index edd7dfb22..84ead93bb 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -18,9 +18,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Federator + import ExUnit.CaptureLog + import Mock import Pleroma.Factory import Tesla.Mock - import Mock setup do mock(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -2403,4 +2404,51 @@ defp private_messages(_) do u3: %{r1: r3_1.id, r2: r3_2.id}, u4: %{r1: r4_1.id}} end + + describe "maybe_update_follow_information/1" do + setup do + clear_config([:instance, :external_user_synchronization], true) + + user = %{ + local: false, + ap_id: "https://gensokyo.2hu/users/raymoo", + following_address: "https://gensokyo.2hu/users/following", + follower_address: "https://gensokyo.2hu/users/followers", + type: "Person" + } + + %{user: user} + end + + test "logs an error when it can't fetch the info", %{user: user} do + assert capture_log(fn -> + ActivityPub.maybe_update_follow_information(user) + end) =~ "Follower/Following counter update for #{user.ap_id} failed" + end + + test "just returns the input if the user type is Application", %{ + user: user + } do + user = + user + |> Map.put(:type, "Application") + + refute capture_log(fn -> + assert ^user = ActivityPub.maybe_update_follow_information(user) + end) =~ "Follower/Following counter update for #{user.ap_id} failed" + end + + test "it just returns the input if the user has no following/follower addresses", %{ + user: user + } do + user = + user + |> Map.put(:following_address, nil) + |> Map.put(:follower_address, nil) + + refute capture_log(fn -> + assert ^user = ActivityPub.maybe_update_follow_information(user) + end) =~ "Follower/Following counter update for #{user.ap_id} failed" + end + end end -- cgit v1.2.3 From 13ab8defc0c5f9728c00ce49044dd60c02c7e81f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 14:34:19 +0200 Subject: Pipeline: Move transctioning to common pipeline. --- lib/pleroma/web/activity_pub/pipeline.ex | 23 +++++++++++++++++++---- lib/pleroma/web/activity_pub/side_effects.ex | 13 ++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 7ccee54c9..d5abb7567 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -4,20 +4,33 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Repo 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()} + @spec common_pipeline(map(), keyword()) :: + {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + {:ok, value} -> + value + + {:error, e} -> + {:error, e} + end + end + + def do_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}} <- + {_, {:ok, activity, meta}} <- {:persist_object, ActivityPub.persist(mrfd_object, meta)}, - {_, {:ok, %Activity{} = activity, meta}} <- + {_, {:ok, activity, meta}} <- {:execute_side_effects, SideEffects.handle(activity, meta)}, {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do {:ok, activity, meta} @@ -27,7 +40,9 @@ def common_pipeline(object, meta) do end end - defp maybe_federate(activity, meta) do + defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} + + defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do if local do Federator.publish(activity) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5981e7545..6a8f1af96 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -15,17 +15,12 @@ def handle(object, meta \\ []) # - Add like to object # - Set up notification def handle(%{data: %{"type" => "Like"}} = object, meta) do - {:ok, result} = - Pleroma.Repo.transaction(fn -> - liked_object = Object.get_by_ap_id(object.data["object"]) - Utils.add_like_to_object(object, liked_object) + liked_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_like_to_object(object, liked_object) - Notification.create_notifications(object) + Notification.create_notifications(object) - {:ok, object, meta} - end) - - result + {:ok, object, meta} end # Nothing to do -- cgit v1.2.3 From 335aabc39c6384216d2eaa3036cea541141d8025 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 14:45:28 +0200 Subject: Transmogrifier tests: Extract like tests. --- test/support/factory.ex | 1 + .../transmogrifier/like_handling_test.exs | 78 ++++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 56 ---------------- 3 files changed, 79 insertions(+), 56 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/like_handling_test.exs diff --git a/test/support/factory.ex b/test/support/factory.ex index f0b797fd4..495764782 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -32,6 +32,7 @@ def user_factory do password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), bio: sequence(:bio, &"Tester Number #{&1}"), last_digest_emailed_at: NaiveDateTime.utc_now(), + last_refreshed_at: NaiveDateTime.utc_now(), notification_settings: %Pleroma.User.NotificationSetting{} } diff --git a/test/web/activity_pub/transmogrifier/like_handling_test.exs b/test/web/activity_pub/transmogrifier/like_handling_test.exs new file mode 100644 index 000000000..54a5c1dbc --- /dev/null +++ b/test/web/activity_pub/transmogrifier/like_handling_test.exs @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.LikeHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "it works for incoming likes" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + + data = + File.read!("test/fixtures/mastodon-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + _actor = insert(:user, ap_id: data["actor"], local: false) + + {: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" + assert data["id"] == "http://mastodon.example.org/users/admin#likes/2" + assert data["object"] == activity.data["object"] + end + + test "it works for incoming misskey likes, turning them into EmojiReacts" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + + data = + File.read!("test/fixtures/misskey-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + _actor = insert(:user, ap_id: data["actor"], local: false) + + {:ok, %Activity{data: activity_data, local: false}} = Transmogrifier.handle_incoming(data) + + assert activity_data["actor"] == data["actor"] + assert activity_data["type"] == "EmojiReact" + assert activity_data["id"] == data["id"] + assert activity_data["object"] == activity.data["object"] + assert activity_data["content"] == "🍮" + end + + test "it works for incoming misskey likes that contain unicode emojis, turning them into EmojiReacts" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + + data = + File.read!("test/fixtures/misskey-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("_misskey_reaction", "⭐") + + _actor = insert(:user, ap_id: data["actor"], local: false) + + {:ok, %Activity{data: activity_data, local: false}} = Transmogrifier.handle_incoming(data) + + assert activity_data["actor"] == data["actor"] + assert activity_data["type"] == "EmojiReact" + assert activity_data["id"] == data["id"] + assert activity_data["object"] == activity.data["object"] + assert activity_data["content"] == "⭐" + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 36e1e7bd1..23efa4be6 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -325,62 +325,6 @@ test "it cleans up incoming notices which are not really DMs" do assert object_data["cc"] == to end - test "it works for incoming likes" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {: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" - assert data["id"] == "http://mastodon.example.org/users/admin#likes/2" - assert data["object"] == activity.data["object"] - end - - test "it works for incoming misskey likes, turning them into EmojiReacts" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/misskey-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == data["actor"] - assert data["type"] == "EmojiReact" - assert data["id"] == data["id"] - assert data["object"] == activity.data["object"] - assert data["content"] == "🍮" - end - - test "it works for incoming misskey likes that contain unicode emojis, turning them into EmojiReacts" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/misskey-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - |> Map.put("_misskey_reaction", "⭐") - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == data["actor"] - assert data["type"] == "EmojiReact" - assert data["id"] == data["id"] - assert data["object"] == activity.data["object"] - assert data["content"] == "⭐" - end - test "it works for incoming emoji reactions" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) -- cgit v1.2.3 From e03c301ebeea6687ee7f19d447232864b182d581 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 17:08:31 +0200 Subject: LikeValidator: Fix up missing recipients. --- .../object_validators/like_validator.ex | 34 ++++++++++++++++++++-- test/web/activity_pub/object_validator_test.exs | 13 +++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index 49546ceaa..d9ee07995 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do use Ecto.Schema + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils @@ -19,8 +20,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do field(:object, Types.ObjectID) field(:actor, Types.ObjectID) field(:context, :string) - field(:to, {:array, :string}) - field(:cc, {:array, :string}) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) end def cast_and_validate(data) do @@ -31,7 +32,34 @@ def cast_and_validate(data) do def cast_data(data) do %__MODULE__{} - |> cast(data, [:id, :type, :object, :actor, :context, :to, :cc]) + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> fix_after_cast() + end + + def fix_after_cast(cng) do + cng + |> fix_recipients() + end + + def fix_recipients(cng) do + to = get_field(cng, :to) || [] + cc = get_field(cng, :cc) || [] + object = get_field(cng, :object) + + with {[], []} <- {to, cc}, + %Object{data: %{"actor" => actor}} <- Object.get_cached_by_ap_id(object), + {:ok, actor} <- Types.ObjectID.cast(actor) do + cng + |> put_change(:to, [actor]) + else + _ -> + cng + end end def validate_data(data_cng) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 3c5c3696e..9e9e41c6b 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -36,6 +36,19 @@ test "is valid for a valid object", %{valid_like: valid_like} do assert LikeValidator.cast_and_validate(valid_like).valid? end + test "sets the 'to' field to the object actor if no recipients are given", %{ + valid_like: valid_like, + user: user + } do + without_recipients = + valid_like + |> Map.delete("to") + + {:ok, object, _meta} = ObjectValidator.validate(without_recipients, []) + + assert object["to"] == [user.ap_id] + end + test "it errors when the actor is missing or not known", %{valid_like: valid_like} do without_actor = Map.delete(valid_like, "actor") -- cgit v1.2.3 From 0f9bed022fab80f59353597dde82896ef954a678 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 17:18:17 +0200 Subject: LikeValidator: Fix up context. --- .../activity_pub/object_validators/like_validator.ex | 18 ++++++++++++++++-- test/web/activity_pub/object_validator_test.exs | 13 +++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index d9ee07995..1bce739bd 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -44,11 +44,25 @@ def changeset(struct, data) do def fix_after_cast(cng) do cng |> fix_recipients() + |> fix_context() + end + + def fix_context(cng) do + object = get_field(cng, :object) + + with nil <- get_field(cng, :context), + %Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do + cng + |> put_change(:context, context) + else + _ -> + cng + end end def fix_recipients(cng) do - to = get_field(cng, :to) || [] - cc = get_field(cng, :cc) || [] + to = get_field(cng, :to) + cc = get_field(cng, :cc) object = get_field(cng, :object) with {[], []} <- {to, cc}, diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 9e9e41c6b..93989e28a 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -49,6 +49,19 @@ test "sets the 'to' field to the object actor if no recipients are given", %{ assert object["to"] == [user.ap_id] end + test "sets the context field to the context of the object if no context is given", %{ + valid_like: valid_like, + post_activity: post_activity + } do + without_context = + valid_like + |> Map.delete("context") + + {:ok, object, _meta} = ObjectValidator.validate(without_context, []) + + assert object["context"] == post_activity.data["context"] + end + test "it errors when the actor is missing or not known", %{valid_like: valid_like} do without_actor = Map.delete(valid_like, "actor") -- cgit v1.2.3 From 3559dd108518dfa740878cecdc86c0357b265857 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 4 May 2020 17:18:38 +0200 Subject: Transmogrifier: Rely on LikeValidator. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 55 ++------------------------ 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index c966ec960..581e7040b 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -15,7 +15,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do 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 @@ -658,16 +657,9 @@ def handle_incoming( end 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 = ObjectValidator.stringify_keys(Map.from_struct(cast_data_sym)), - :ok <- ObjectValidator.fetch_actor_and_object(cast_data), - {_, {:ok, cast_data}} <- {:ensure_context_presence, ensure_context_presence(cast_data)}, - {_, {:ok, cast_data}} <- - {:ensure_recipients_presence, ensure_recipients_presence(cast_data)}, - {_, {:ok, activity, _meta}} <- - {:common_pipeline, Pipeline.common_pipeline(cast_data, local: false)} do + with :ok <- ObjectValidator.fetch_actor_and_object(data), + {:ok, activity, _meta} <- + Pipeline.common_pipeline(data, local: false) do {:ok, activity} else e -> {:error, e} @@ -1296,45 +1288,4 @@ 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 ensure_context_presence(%{"context" => context} = data) when is_binary(context), - do: {:ok, data} - - 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 - _ -> - {:error, :no_context} - end - end - - defp ensure_context_presence(_) do - {:error, :no_context} - end - - defp ensure_recipients_presence(%{"to" => [_ | _], "cc" => [_ | _]} = data), - do: {:ok, data} - - defp ensure_recipients_presence(%{"object" => object} = data) do - case Object.normalize(object) do - %{data: %{"actor" => actor}} -> - data = - data - |> Map.put("to", [actor]) - |> Map.put("cc", data["cc"] || []) - - {:ok, data} - - nil -> - {:error, :no_object} - - _ -> - {:error, :no_actor} - end - end - - defp ensure_recipients_presence(_) do - {:error, :no_object} - end end -- cgit v1.2.3 From d08c63500b5deca268ebc24833be4cb3279bdaaa Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 4 May 2020 20:16:18 +0400 Subject: Ignore unexpected query params --- lib/pleroma/web/api_spec/cast_and_validate.ex | 120 +++++++++++++++++++++ .../mastodon_api/controllers/account_controller.ex | 2 +- .../web/mastodon_api/controllers/app_controller.ex | 2 +- .../controllers/custom_emoji_controller.ex | 2 +- .../controllers/domain_block_controller.ex | 2 +- .../controllers/notification_controller.ex | 2 +- .../mastodon_api/controllers/report_controller.ex | 2 +- 7 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 lib/pleroma/web/api_spec/cast_and_validate.ex diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex new file mode 100644 index 000000000..f36cf7a55 --- /dev/null +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -0,0 +1,120 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.CastAndValidate do + @moduledoc """ + This plug is based on [`OpenApiSpex.Plug.CastAndValidate`] + (https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex). + The main difference is ignoring unexpected query params + instead of throwing an error. Also, the default rendering + error module is `Pleroma.Web.ApiSpec.RenderError`. + """ + + @behaviour Plug + + alias Plug.Conn + + @impl Plug + def init(opts) do + opts + |> Map.new() + |> Map.put_new(:render_error, Pleroma.Web.ApiSpec.RenderError) + end + + @impl Plug + def call(%{private: %{open_api_spex: private_data}} = conn, %{ + operation_id: operation_id, + render_error: render_error + }) do + spec = private_data.spec + operation = private_data.operation_lookup[operation_id] + + content_type = + case Conn.get_req_header(conn, "content-type") do + [header_value | _] -> + header_value + |> String.split(";") + |> List.first() + + _ -> + nil + end + + private_data = Map.put(private_data, :operation_id, operation_id) + conn = Conn.put_private(conn, :open_api_spex, private_data) + + case cast_and_validate(spec, operation, conn, content_type) do + {:ok, conn} -> + conn + + {:error, reason} -> + opts = render_error.init(reason) + + conn + |> render_error.call(opts) + |> Plug.Conn.halt() + end + end + + def call( + %{ + private: %{ + phoenix_controller: controller, + phoenix_action: action, + open_api_spex: private_data + } + } = conn, + opts + ) do + operation = + case private_data.operation_lookup[{controller, action}] do + nil -> + operation_id = controller.open_api_operation(action).operationId + operation = private_data.operation_lookup[operation_id] + + operation_lookup = + private_data.operation_lookup + |> Map.put({controller, action}, operation) + + OpenApiSpex.Plug.Cache.adapter().put( + private_data.spec_module, + {private_data.spec, operation_lookup} + ) + + operation + + operation -> + operation + end + + if operation.operationId do + call(conn, Map.put(opts, :operation_id, operation.operationId)) + else + raise "operationId was not found in action API spec" + end + end + + def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts) + + defp cast_and_validate(spec, operation, conn, content_type) do + case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do + {:ok, conn} -> + {:ok, conn} + + # Remove unexpected query params and cast/validate again + {:error, errors} -> + query_params = + Enum.reduce(errors, conn.query_params, fn + %{reason: :unexpected_field, name: name, path: [name]}, params -> + Map.delete(params, name) + + _, params -> + params + end) + + conn = %Conn{conn | query_params: query_params} + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 61b0e2f63..8458cbdd5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI - plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :create) diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 408e11474..a516b6c20 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.AppController do plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials) - plug(OpenApiSpex.Plug.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate) @local_mastodon_name "Mastodon-Local" diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex index 000ad743f..c5f47c5df 100644 --- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do use Pleroma.Web, :controller - plug(OpenApiSpex.Plug.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug( :skip_plug, diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index c4fa383f2..825b231ab 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.DomainBlockController do alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User - plug(OpenApiSpex.Plug.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.DomainBlockOperation plug( diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index a14c86893..596b85617 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do @oauth_read_actions [:show, :index] - plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug( OAuthScopesPlug, diff --git a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex index f65c5c62b..405167108 100644 --- a/lib/pleroma/web/mastodon_api/controllers/report_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/report_controller.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.ReportController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ReportOperation -- cgit v1.2.3 From bfbff7d82673d6128a18e73dcc91f70ee669c2ac Mon Sep 17 00:00:00 2001 From: minibikini Date: Mon, 4 May 2020 16:38:23 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/cast_and_validate.ex --- lib/pleroma/web/api_spec/cast_and_validate.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index f36cf7a55..cd02403c1 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -1,5 +1,6 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors +# Copyright © 2019-2020 Moxley Stratton, Mike Buhot , MPL-2.0 +# Copyright © 2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ApiSpec.CastAndValidate do -- cgit v1.2.3 From 4b9ab67aa8bdf7fdf7390080932fee2e5879a5e4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 4 May 2020 21:46:25 +0400 Subject: Ignore unexpected ENUM values in query string --- lib/pleroma/web/api_spec/cast_and_validate.ex | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index cd02403c1..b94517c52 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -110,6 +110,10 @@ defp cast_and_validate(spec, operation, conn, content_type) do %{reason: :unexpected_field, name: name, path: [name]}, params -> Map.delete(params, name) + %{reason: :invalid_enum, name: nil, path: path, value: value}, params -> + path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string() + update_in(params, path, &List.delete(&1, value)) + _, params -> params end) @@ -118,4 +122,11 @@ defp cast_and_validate(spec, operation, conn, content_type) do OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) end end + + defp list_items_to_string(list) do + Enum.map(list, fn + i when is_atom(i) -> to_string(i) + i -> i + end) + end end -- cgit v1.2.3 From f070b5569ca0eafdca79f1f3e3b6b5025f3f8fc9 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 4 May 2020 22:33:05 +0400 Subject: Add a config option to enable strict validation --- config/config.exs | 2 ++ lib/pleroma/web/api_spec/cast_and_validate.ex | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index a6c6d6f99..ca9bbab64 100644 --- a/config/config.exs +++ b/config/config.exs @@ -653,6 +653,8 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index b94517c52..bd9026237 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -7,9 +7,10 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do @moduledoc """ This plug is based on [`OpenApiSpex.Plug.CastAndValidate`] (https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex). - The main difference is ignoring unexpected query params - instead of throwing an error. Also, the default rendering - error module is `Pleroma.Web.ApiSpec.RenderError`. + The main difference is ignoring unexpected query params instead of throwing + an error and a config option (`[Pleroma.Web.ApiSpec.CastAndValidate, :strict]`) + to disable this behavior. Also, the default rendering error module + is `Pleroma.Web.ApiSpec.RenderError`. """ @behaviour Plug @@ -45,7 +46,7 @@ def call(%{private: %{open_api_spex: private_data}} = conn, %{ private_data = Map.put(private_data, :operation_id, operation_id) conn = Conn.put_private(conn, :open_api_spex, private_data) - case cast_and_validate(spec, operation, conn, content_type) do + case cast_and_validate(spec, operation, conn, content_type, strict?()) do {:ok, conn} -> conn @@ -98,7 +99,11 @@ def call( def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts) - defp cast_and_validate(spec, operation, conn, content_type) do + defp cast_and_validate(spec, operation, conn, content_type, true = _strict) do + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) + end + + defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do {:ok, conn} -> {:ok, conn} @@ -129,4 +134,6 @@ defp list_items_to_string(list) do i -> i end) end + + defp strict?, do: Pleroma.Config.get([__MODULE__, :strict], false) end -- cgit v1.2.3 From e55fd530bc9a6ab42e475efe689e239963906928 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 4 May 2020 22:33:34 +0400 Subject: Render better errors for ENUM validation --- lib/pleroma/web/api_spec/render_error.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/web/api_spec/render_error.ex b/lib/pleroma/web/api_spec/render_error.ex index b5877ca9c..d476b8ef3 100644 --- a/lib/pleroma/web/api_spec/render_error.ex +++ b/lib/pleroma/web/api_spec/render_error.ex @@ -17,6 +17,9 @@ def init(opts), do: opts def call(conn, errors) do errors = Enum.map(errors, fn + %{name: nil, reason: :invalid_enum} = err -> + %OpenApiSpex.Cast.Error{err | name: err.value} + %{name: nil} = err -> %OpenApiSpex.Cast.Error{err | name: List.last(err.path)} -- cgit v1.2.3 From 1cb89aac1eef7711aa7950fe03e02e24bc665317 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 4 May 2020 22:35:28 +0400 Subject: Enable strict validation mode in dev and test environments --- config/dev.exs | 2 ++ config/test.exs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/config/dev.exs b/config/dev.exs index 7e1e3b4be..4faaeff5b 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -52,6 +52,8 @@ hostname: "localhost", pool_size: 10 +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true + if File.exists?("./config/dev.secret.exs") do import_config "dev.secret.exs" else diff --git a/config/test.exs b/config/test.exs index 040e67e4a..cbf775109 100644 --- a/config/test.exs +++ b/config/test.exs @@ -96,6 +96,8 @@ config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else -- cgit v1.2.3 From 2ab52d52248224c61b857f2fc54adff80343e3c0 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Mon, 4 May 2020 22:41:14 +0300 Subject: Fix inconsistency in language for activating settings --- config/description.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/description.exs b/config/description.exs index 7fac1e561..62f17c92f 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2247,6 +2247,7 @@ children: [ %{ key: :active, + label: "Enabled", type: :boolean, description: "Globally enable or disable digest emails" }, -- cgit v1.2.3 From bf0e41f0daa5809db53ed4a9130ade63952e8da0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 4 May 2020 23:32:53 +0200 Subject: Transmogrifier.set_sensitive/1: Keep sensitive set to true --- CHANGELOG.md | 1 + lib/pleroma/web/activity_pub/transmogrifier.ex | 4 ++++ .../activity_pub/activity_pub_controller_test.exs | 22 +++++++++++++++------- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522285efe..cdb8a2080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Logger configuration through AdminFE - HTTP Basic Authentication permissions issue - ObjectAgePolicy didn't filter out old messages +- Transmogrifier: Keep object sensitive settings for outgoing representation (AP C2S) ### Added - NodeInfo: ObjectAgePolicy settings to the `federation` list. diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 581e7040b..3a4d364e7 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1195,6 +1195,10 @@ def set_conversation(object) do Map.put(object, "conversation", object["context"]) end + def set_sensitive(%{"sensitive" => true} = object) do + object + end + def set_sensitive(object) do tags = object["tag"] || [] Map.put(object, "sensitive", "nsfw" in tags) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index a8f1f0e26..5c8d20ac4 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -820,21 +820,29 @@ test "it inserts an incoming sensitive activity into the database", %{ activity: activity } do user = insert(:user) + conn = assign(conn, :user, user) object = Map.put(activity["object"], "sensitive", true) activity = Map.put(activity, "object", object) - result = + response = conn - |> assign(:user, user) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/outbox", activity) |> json_response(201) - assert Activity.get_by_ap_id(result["id"]) - assert result["object"] - assert %Object{data: object} = Object.normalize(result["object"]) - assert object["sensitive"] == activity["object"]["sensitive"] - assert object["content"] == activity["object"]["content"] + assert Activity.get_by_ap_id(response["id"]) + assert response["object"] + assert %Object{data: response_object} = Object.normalize(response["object"]) + assert response_object["sensitive"] == true + assert response_object["content"] == activity["object"]["content"] + + representation = + conn + |> put_req_header("accept", "application/activity+json") + |> get(response["id"]) + |> json_response(200) + + assert representation["object"]["sensitive"] == true end test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do -- cgit v1.2.3 From 8bed6ea922dbc1cfb8166fea6ce344d3618b3d52 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 09:25:09 +0200 Subject: User, Webfinger: Remove OStatus vestiges Mainly the `magic_key` field --- lib/pleroma/user.ex | 2 -- lib/pleroma/web/web_finger/web_finger.ex | 39 +--------------------- .../20200505072231_remove_magic_key_field.exs | 9 +++++ test/web/web_finger/web_finger_test.exs | 4 +-- 4 files changed, 12 insertions(+), 42 deletions(-) create mode 100644 priv/repo/migrations/20200505072231_remove_magic_key_field.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 99358ddaf..2c343eb22 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -113,7 +113,6 @@ defmodule Pleroma.User do field(:is_admin, :boolean, default: false) field(:show_role, :boolean, default: true) field(:settings, :map, default: nil) - field(:magic_key, :string, default: nil) field(:uri, Types.Uri, default: nil) field(:hide_followers_count, :boolean, default: false) field(:hide_follows_count, :boolean, default: false) @@ -387,7 +386,6 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :banner, :locked, :last_refreshed_at, - :magic_key, :uri, :follower_address, :following_address, diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index 7ffd0e51b..b26453828 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -86,53 +86,19 @@ def represent_user(user, "XML") do |> XmlBuilder.to_doc() end - defp get_magic_key("data:application/magic-public-key," <> magic_key) do - {:ok, magic_key} - end - - defp get_magic_key(nil) do - Logger.debug("Undefined magic key.") - {:ok, nil} - end - - defp get_magic_key(_) do - {:error, "Missing magic key data."} - end - defp webfinger_from_xml(doc) do - with magic_key <- XML.string_from_xpath(~s{//Link[@rel="magic-public-key"]/@href}, doc), - {:ok, magic_key} <- get_magic_key(magic_key), - topic <- - XML.string_from_xpath( - ~s{//Link[@rel="http://schemas.google.com/g/2010#updates-from"]/@href}, - doc - ), - subject <- XML.string_from_xpath("//Subject", doc), - subscribe_address <- - XML.string_from_xpath( - ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template}, - doc - ), + with subject <- XML.string_from_xpath("//Subject", doc), ap_id <- XML.string_from_xpath( ~s{//Link[@rel="self" and @type="application/activity+json"]/@href}, doc ) do data = %{ - "magic_key" => magic_key, - "topic" => topic, "subject" => subject, - "subscribe_address" => subscribe_address, "ap_id" => ap_id } {:ok, data} - else - {:error, e} -> - {:error, e} - - e -> - {:error, e} end end @@ -146,9 +112,6 @@ defp webfinger_from_json(doc) do {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> Map.put(data, "ap_id", link["href"]) - {_, "http://ostatus.org/schema/1.0/subscribe"} -> - Map.put(data, "subscribe_address", link["template"]) - _ -> Logger.debug("Unhandled type: #{inspect(link["type"])}") data diff --git a/priv/repo/migrations/20200505072231_remove_magic_key_field.exs b/priv/repo/migrations/20200505072231_remove_magic_key_field.exs new file mode 100644 index 000000000..2635e671b --- /dev/null +++ b/priv/repo/migrations/20200505072231_remove_magic_key_field.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.RemoveMagicKeyField do + use Ecto.Migration + + def change do + alter table(:users) do + remove(:magic_key, :string) + end + end +end diff --git a/test/web/web_finger/web_finger_test.exs b/test/web/web_finger/web_finger_test.exs index 4b4282727..ce17f83d6 100644 --- a/test/web/web_finger/web_finger_test.exs +++ b/test/web/web_finger/web_finger_test.exs @@ -67,10 +67,10 @@ test "it work for AP-only user" do assert data["magic_key"] == nil assert data["salmon"] == nil - assert data["topic"] == "https://mstdn.jp/users/kPherox.atom" + assert data["topic"] == nil assert data["subject"] == "acct:kPherox@mstdn.jp" assert data["ap_id"] == "https://mstdn.jp/users/kPherox" - assert data["subscribe_address"] == "https://mstdn.jp/authorize_interaction?acct={uri}" + assert data["subscribe_address"] == nil end test "it works for friendica" do -- cgit v1.2.3 From f897da21158796eb3962e50add312d62165160fc Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 09:36:38 +0200 Subject: WebFinger: Add back in subscribe_address. It's used for remote following. --- lib/pleroma/web/web_finger/web_finger.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index b26453828..d0775fa28 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -88,6 +88,11 @@ def represent_user(user, "XML") do defp webfinger_from_xml(doc) do with subject <- XML.string_from_xpath("//Subject", doc), + subscribe_address <- + XML.string_from_xpath( + ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template}, + doc + ), ap_id <- XML.string_from_xpath( ~s{//Link[@rel="self" and @type="application/activity+json"]/@href}, @@ -95,6 +100,7 @@ defp webfinger_from_xml(doc) do ) do data = %{ "subject" => subject, + "subscribe_address" => subscribe_address, "ap_id" => ap_id } -- cgit v1.2.3 From 6a2905ccf08f89bd988b1bcd0788566930fbf17e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 09:55:33 +0200 Subject: WebFinger Test: Add back test. --- test/web/web_finger/web_finger_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/web_finger/web_finger_test.exs b/test/web/web_finger/web_finger_test.exs index ce17f83d6..f4884e0a2 100644 --- a/test/web/web_finger/web_finger_test.exs +++ b/test/web/web_finger/web_finger_test.exs @@ -70,7 +70,7 @@ test "it work for AP-only user" do assert data["topic"] == nil assert data["subject"] == "acct:kPherox@mstdn.jp" assert data["ap_id"] == "https://mstdn.jp/users/kPherox" - assert data["subscribe_address"] == nil + assert data["subscribe_address"] == "https://mstdn.jp/authorize_interaction?acct={uri}" end test "it works for friendica" do -- cgit v1.2.3 From 6400998820084c7b81a53bbeb705b0eb2c0a0e1b Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 10:12:37 +0200 Subject: AP C2S: Restrict creation to `Note`s for now. --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 5 ++++- test/web/activity_pub/activity_pub_controller_test.exs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index f607931ab..504eed4f4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -396,7 +396,10 @@ def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ |> json(err) end - defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do + defp handle_user_activity( + %User{} = user, + %{"type" => "Create", "object" => %{"type" => "Note"}} = params + ) do object = params["object"] |> Map.merge(Map.take(params, ["to", "cc"])) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index a8f1f0e26..9a085ffc5 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -815,6 +815,21 @@ test "it inserts an incoming create activity into the database", %{ assert object["content"] == activity["object"]["content"] end + test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do + user = insert(:user) + + activity = + activity + |> put_in(["object", "type"], "Benis") + + _result = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", activity) + |> json_response(400) + end + test "it inserts an incoming sensitive activity into the database", %{ conn: conn, activity: activity -- cgit v1.2.3 From f21f53829339115e9a6cc9066d09026345047b43 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 10:38:59 +0200 Subject: LikeValidator: Add defaults for recipients back in. --- lib/pleroma/web/activity_pub/object_validators/like_validator.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index d835b052e..034f25492 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -20,8 +20,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do field(:object, Types.ObjectID) field(:actor, Types.ObjectID) field(:context, :string) - field(:to, Types.Recipients) - field(:cc, Types.Recipients) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) end def cast_and_validate(data) do -- cgit v1.2.3 From 142bf0957c64f76b9b511200544b1ccbcef5ba16 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 11:20:53 +0200 Subject: Transmogrifier: Extract EmojiReact tests. --- .../transmogrifier/emoji_react_handling_test.exs | 55 ++++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 37 --------------- 2 files changed, 55 insertions(+), 37 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs new file mode 100644 index 000000000..9f4f6b296 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs @@ -0,0 +1,55 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "it works for incoming emoji reactions" do + user = insert(:user) + other_user = insert(:user, local: false) + {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + + data = + File.read!("test/fixtures/emoji-reaction.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("actor", other_user.ap_id) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == other_user.ap_id + assert data["type"] == "EmojiReact" + assert data["id"] == "http://mastodon.example.org/users/admin#reactions/2" + assert data["object"] == activity.data["object"] + assert data["content"] == "👌" + end + + test "it reject invalid emoji reactions" do + user = insert(:user) + other_user = insert(:user, local: false) + {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + + data = + File.read!("test/fixtures/emoji-reaction-too-long.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("actor", other_user.ap_id) + + assert :error = Transmogrifier.handle_incoming(data) + + data = + File.read!("test/fixtures/emoji-reaction-no-emoji.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("actor", other_user.ap_id) + + assert :error = Transmogrifier.handle_incoming(data) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 23efa4be6..c1c3ff9d2 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -325,43 +325,6 @@ test "it cleans up incoming notices which are not really DMs" do assert object_data["cc"] == to end - test "it works for incoming emoji reactions" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/emoji-reaction.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "EmojiReact" - assert data["id"] == "http://mastodon.example.org/users/admin#reactions/2" - assert data["object"] == activity.data["object"] - assert data["content"] == "👌" - end - - test "it reject invalid emoji reactions" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/emoji-reaction-too-long.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - assert :error = Transmogrifier.handle_incoming(data) - - data = - File.read!("test/fixtures/emoji-reaction-no-emoji.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - assert :error = Transmogrifier.handle_incoming(data) - end - test "it works for incoming emoji reaction undos" do user = insert(:user) -- cgit v1.2.3 From ad771546d886171ea8c3e7694fad393eaa5a2017 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 12:11:46 +0200 Subject: Transmogrifier: Move emoji reactions to common pipeline. --- lib/pleroma/web/activity_pub/builder.ex | 12 ++++ lib/pleroma/web/activity_pub/object_validator.ex | 11 +++ .../object_validators/emoji_react_validator.ex | 81 ++++++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 12 ++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 23 +----- test/web/activity_pub/object_validator_test.exs | 41 +++++++++++ test/web/activity_pub/side_effects_test.exs | 27 ++++++++ .../transmogrifier/emoji_react_handling_test.exs | 10 ++- 8 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 429a510b8..2a763645c 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,18 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} + def emoji_react(actor, object, emoji) do + with {:ok, data, meta} <- like(actor, object) do + data = + data + |> Map.put("content", emoji) + |> Map.put("type", "EmojiReact") + + {:ok, data, meta} + end + end + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index dc4bce059..8246558ed 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) @@ -24,6 +25,16 @@ def validate(%{"type" => "Like"} = object, meta) do end end + def validate(%{"type" => "EmojiReact"} = object, meta) do + with {:ok, object} <- + object + |> EmojiReactValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object |> Map.from_struct()) + {:ok, object, meta} + end + end + def stringify_keys(object) do object |> Map.new(fn {key, val} -> {to_string(key), val} end) diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex new file mode 100644 index 000000000..e87519c59 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -0,0 +1,81 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:context, :string) + field(:content, :string) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> fix_after_cast() + end + + def fix_after_cast(cng) do + cng + |> fix_context() + end + + def fix_context(cng) do + object = get_field(cng, :object) + + with nil <- get_field(cng, :context), + %Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do + cng + |> put_change(:context, context) + else + _ -> + cng + end + end + + def validate_emoji(cng) do + content = get_field(cng, :content) + + if Pleroma.Emoji.is_unicode_emoji?(content) do + cng + else + cng + |> add_error(:content, "must be a single character emoji") + end + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["EmojiReact"]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc, :content]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_emoji() + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 6a8f1af96..b15343c07 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -23,6 +23,18 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do {:ok, object, meta} end + # Tasks this handles: + # - Add reaction to object + # - Set up notification + def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do + reacted_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_emoji_reaction_to_object(object, reacted_object) + + Notification.create_notifications(object) + + {:ok, object, meta} + end + # Nothing to do def handle(object, meta) do {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 581e7040b..81e763f88 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -656,7 +656,7 @@ def handle_incoming( |> handle_incoming(options) end - def handle_incoming(%{"type" => "Like"} = data, _options) do + def handle_incoming(%{"type" => type} = data, _options) when type in ["Like", "EmojiReact"] do with :ok <- ObjectValidator.fetch_actor_and_object(data), {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do @@ -666,27 +666,6 @@ def handle_incoming(%{"type" => "Like"} = data, _options) do end end - def handle_incoming( - %{ - "type" => "EmojiReact", - "object" => object_id, - "actor" => _actor, - "id" => id, - "content" => emoji - } = 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.react_with_emoji(actor, object, emoji, activity_id: id, local: false) do - {:ok, activity} - else - _e -> :error - end - end - def handle_incoming( %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data, _options diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 93989e28a..a7ad8e646 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,6 +1,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils @@ -8,6 +9,46 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory + describe "EmojiReacts" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) + + object = Pleroma.Object.get_by_ap_id(post_activity.data["object"]) + + {:ok, valid_emoji_react, []} = Builder.emoji_react(user, object, "👌") + + %{user: user, post_activity: post_activity, valid_emoji_react: valid_emoji_react} + end + + test "it validates a valid EmojiReact", %{valid_emoji_react: valid_emoji_react} do + assert {:ok, _, _} = ObjectValidator.validate(valid_emoji_react, []) + end + + test "it is not valid without a 'content' field", %{valid_emoji_react: valid_emoji_react} do + without_content = + valid_emoji_react + |> Map.delete("content") + + {:error, cng} = ObjectValidator.validate(without_content, []) + + refute cng.valid? + assert {:content, {"can't be blank", [validation: :required]}} in cng.errors + end + + test "it is not valid with a non-emoji content field", %{valid_emoji_react: valid_emoji_react} do + without_emoji_content = + valid_emoji_react + |> Map.put("content", "x") + + {:error, cng} = ObjectValidator.validate(without_emoji_content, []) + + refute cng.valid? + + assert {:content, {"must be a single character emoji", []}} in cng.errors + end + end + describe "likes" do setup do user = insert(:user) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 0b6b55156..9271d5ba1 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -15,6 +15,33 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Pleroma.Factory + describe "EmojiReact objects" do + setup do + poster = insert(:user) + user = insert(:user) + + {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) + + {:ok, emoji_react_data, []} = Builder.emoji_react(user, post.object, "👌") + {:ok, emoji_react, _meta} = ActivityPub.persist(emoji_react_data, local: true) + + %{emoji_react: emoji_react, user: user, poster: poster} + end + + test "adds the reaction to the object", %{emoji_react: emoji_react, user: user} do + {:ok, emoji_react, _} = SideEffects.handle(emoji_react) + object = Object.get_by_ap_id(emoji_react.data["object"]) + + assert object.data["reaction_count"] == 1 + assert ["👌", [user.ap_id]] in object.data["reactions"] + end + + test "creates a notification", %{emoji_react: emoji_react, poster: poster} do + {:ok, emoji_react, _} = SideEffects.handle(emoji_react) + assert Repo.get_by(Notification, user_id: poster.id, activity_id: emoji_react.id) + end + end + describe "like objects" do setup do poster = insert(:user) diff --git a/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs index 9f4f6b296..6988e3e0a 100644 --- a/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Object alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI @@ -29,6 +30,11 @@ test "it works for incoming emoji reactions" do assert data["id"] == "http://mastodon.example.org/users/admin#reactions/2" assert data["object"] == activity.data["object"] assert data["content"] == "👌" + + object = Object.get_by_ap_id(data["object"]) + + assert object.data["reaction_count"] == 1 + assert match?([["👌", _]], object.data["reactions"]) end test "it reject invalid emoji reactions" do @@ -42,7 +48,7 @@ test "it reject invalid emoji reactions" do |> Map.put("object", activity.data["object"]) |> Map.put("actor", other_user.ap_id) - assert :error = Transmogrifier.handle_incoming(data) + assert {:error, _} = Transmogrifier.handle_incoming(data) data = File.read!("test/fixtures/emoji-reaction-no-emoji.json") @@ -50,6 +56,6 @@ test "it reject invalid emoji reactions" do |> Map.put("object", activity.data["object"]) |> Map.put("actor", other_user.ap_id) - assert :error = Transmogrifier.handle_incoming(data) + assert {:error, _} = Transmogrifier.handle_incoming(data) end end -- cgit v1.2.3 From db55dc944581b1d4b50d1608b2e991050ea29bb3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 12:28:28 +0200 Subject: ActivityPub: Remove `react_with_emoji`. --- lib/pleroma/web/activity_pub/activity_pub.ex | 24 -------- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- lib/pleroma/web/common_api/common_api.ex | 6 +- .../controllers/pleroma_api_controller.ex | 2 +- test/notification_test.exs | 2 +- test/web/activity_pub/activity_pub_test.exs | 71 +--------------------- test/web/activity_pub/transmogrifier_test.exs | 4 +- test/web/common_api/common_api_test.exs | 4 +- .../mastodon_api/views/notification_view_test.exs | 2 +- test/web/mastodon_api/views/status_view_test.exs | 6 +- .../controllers/pleroma_api_controller_test.exs | 10 +-- 11 files changed, 23 insertions(+), 110 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1c21d78af..4c6ac9241 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -349,30 +349,6 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do end end - @spec react_with_emoji(User.t(), Object.t(), String.t(), keyword()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def react_with_emoji(user, object, emoji, options \\ []) do - with {:ok, result} <- - Repo.transaction(fn -> do_react_with_emoji(user, object, emoji, options) end) do - result - end - end - - defp do_react_with_emoji(user, object, emoji, options) do - with local <- Keyword.get(options, :local, true), - activity_id <- Keyword.get(options, :activity_id, nil), - true <- Pleroma.Emoji.is_unicode_emoji?(emoji), - reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id), - {:ok, activity} <- insert(reaction_data, local), - {:ok, object} <- add_emoji_reaction_to_object(activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - false -> {:error, false} - {:error, error} -> Repo.rollback(error) - end - end - @spec unreact_with_emoji(User.t(), String.t(), keyword()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} def unreact_with_emoji(user, reaction_id, options \\ []) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 8246558ed..d730cb062 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,8 +11,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator + 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/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index f9db97d24..192c84eda 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -177,8 +177,10 @@ def unfavorite(id, user) do def react_with_emoji(id, user, emoji) do with %Activity{} = activity <- Activity.get_by_id(id), - object <- Object.normalize(activity) do - ActivityPub.react_with_emoji(user, object, emoji) + object <- Object.normalize(activity), + {:ok, emoji_react, _} <- Builder.emoji_react(user, object, emoji), + {:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do + {:ok, activity} else _ -> {:error, dgettext("errors", "Could not add reaction emoji")} diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 1bdb3aa4d..6688d7caf 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -86,7 +86,7 @@ def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} end def react_with_emoji(%{assigns: %{user: user}} = conn, %{"id" => activity_id, "emoji" => emoji}) do - with {:ok, _activity, _object} <- CommonAPI.react_with_emoji(activity_id, user, emoji), + with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji), activity <- Activity.get_by_id(activity_id) do conn |> put_view(StatusView) diff --git a/test/notification_test.exs b/test/notification_test.exs index 601a6c0ca..bd562c85c 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -24,7 +24,7 @@ test "creates a notification for an emoji reaction" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "yeah"}) - {:ok, activity, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") {:ok, [notification]} = Notification.create_notifications(activity) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 84ead93bb..1ac4f9896 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -874,71 +874,6 @@ test "returns reblogs for users for whom reblogs have not been muted" do end end - describe "react to an object" do - test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do - Config.put([:instance, :federating], true) - user = insert(:user) - reactor = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"}) - assert object = Object.normalize(activity) - - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥") - - assert called(Federator.publish(reaction_activity)) - end - - test "adds an emoji reaction activity to the db" do - user = insert(:user) - reactor = insert(:user) - third_user = insert(:user) - fourth_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"}) - assert object = Object.normalize(activity) - - {:ok, reaction_activity, object} = ActivityPub.react_with_emoji(reactor, object, "🔥") - - assert reaction_activity - - assert reaction_activity.data["actor"] == reactor.ap_id - assert reaction_activity.data["type"] == "EmojiReact" - assert reaction_activity.data["content"] == "🔥" - assert reaction_activity.data["object"] == object.data["id"] - assert reaction_activity.data["to"] == [User.ap_followers(reactor), activity.data["actor"]] - assert reaction_activity.data["context"] == object.data["context"] - assert object.data["reaction_count"] == 1 - assert object.data["reactions"] == [["🔥", [reactor.ap_id]]] - - {:ok, _reaction_activity, object} = ActivityPub.react_with_emoji(third_user, object, "☕") - - assert object.data["reaction_count"] == 2 - assert object.data["reactions"] == [["🔥", [reactor.ap_id]], ["☕", [third_user.ap_id]]] - - {:ok, _reaction_activity, object} = ActivityPub.react_with_emoji(fourth_user, object, "🔥") - - assert object.data["reaction_count"] == 3 - - assert object.data["reactions"] == [ - ["🔥", [fourth_user.ap_id, reactor.ap_id]], - ["☕", [third_user.ap_id]] - ] - end - - test "reverts emoji reaction on error" do - [user, reactor] = insert_list(2, :user) - - {:ok, activity} = CommonAPI.post(user, %{"status" => "Status"}) - object = Object.normalize(activity) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.react_with_emoji(reactor, object, "😀") - end - - object = Object.get_by_ap_id(object.data["id"]) - refute object.data["reaction_count"] - refute object.data["reactions"] - end - end - describe "unreacting to an object" do test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do Config.put([:instance, :federating], true) @@ -947,7 +882,7 @@ test "reverts emoji reaction on error" do {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"}) assert object = Object.normalize(activity) - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥") + {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, reactor, "🔥") assert called(Federator.publish(reaction_activity)) @@ -963,7 +898,7 @@ test "adds an undo activity to the db" do {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"}) assert object = Object.normalize(activity) - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥") + {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, reactor, "🔥") {:ok, unreaction_activity, _object} = ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"]) @@ -981,7 +916,7 @@ test "reverts emoji unreact on error" do {:ok, activity} = CommonAPI.post(user, %{"status" => "Status"}) object = Object.normalize(activity) - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "😀") + {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, reactor, "😀") with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do assert {:error, :reverted} = diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index c1c3ff9d2..7deac2909 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -329,7 +329,7 @@ test "it works for incoming emoji reaction undos" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - {:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌") + {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, user, "👌") data = File.read!("test/fixtures/mastodon-undo-like.json") @@ -562,7 +562,7 @@ test "it strips internal likes" do test "it strips internal reactions" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, user, "📢") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "📢") %{object: object} = Activity.get_by_id_with_object(activity.id) assert Map.has_key?(object.data, "reactions") diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index bc0c1a791..74171fcd9 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -278,7 +278,7 @@ test "reacting to a status with an emoji" do {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) - {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍") + {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍") assert reaction.data["actor"] == user.ap_id assert reaction.data["content"] == "👍" @@ -293,7 +293,7 @@ test "unreacting to a status with an emoji" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) - {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍") + {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍") {:ok, unreaction, _} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c3ec9dfec..0806269a2 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -156,7 +156,7 @@ test "EmojiReact notification" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) - {:ok, _activity, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") activity = Repo.get(Activity, activity.id) diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 6791c2fb0..b64370c3f 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -32,9 +32,9 @@ test "has an emoji reaction list" do third_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "dae cofe??"}) - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, user, "☕") - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, third_user, "🍵") - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "☕") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "🍵") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") activity = Repo.get(Activity, activity.id) status = StatusView.render("show.json", activity: activity) diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index 61a1689b9..593aa92b2 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -41,7 +41,7 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do other_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) - {:ok, activity, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") result = conn @@ -71,8 +71,8 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do assert result == [] - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, doomed_user, "🎅") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, doomed_user, "🎅") User.perform(:delete, doomed_user) @@ -109,8 +109,8 @@ test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do assert result == [] - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") - {:ok, _, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") result = conn -- cgit v1.2.3 From cc922e7d8ccbf22a0f7e0898a6ff4639123f0c7f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 14:44:29 +0400 Subject: Document configuration for Pleroma.Web.ApiSpec.CastAndValidate --- config/description.exs | 14 ++++++++++++++ docs/configuration/cheatsheet.md | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 9d8e3b93c..72bb4d436 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3194,5 +3194,19 @@ ] } ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.ApiSpec.CastAndValidate, + type: :group, + children: [ + %{ + key: :strict, + type: :boolean, + description: + "Enables strict input validation (useful in development, not recommended in production)", + suggestions: [false] + } + ] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 681ab6b93..705c4c15e 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -924,4 +924,8 @@ Restrict access for unauthenticated users to timelines (public and federate), us * `remote` * `activities` - statuses * `local` - * `remote` \ No newline at end of file + * `remote` + +## Pleroma.Web.ApiSpec.CastAndValidate + +* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`. -- cgit v1.2.3 From d20152700470c9b84a9404193ff08dd6d90b97a3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 11:17:44 +0000 Subject: Apply suggestion to lib/pleroma/web/web_finger/web_finger.ex --- lib/pleroma/web/web_finger/web_finger.ex | 34 +++++++++++++++----------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index d0775fa28..84ece1be2 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -87,25 +87,23 @@ def represent_user(user, "XML") do end defp webfinger_from_xml(doc) do - with subject <- XML.string_from_xpath("//Subject", doc), - subscribe_address <- - XML.string_from_xpath( - ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template}, - doc - ), - ap_id <- - XML.string_from_xpath( - ~s{//Link[@rel="self" and @type="application/activity+json"]/@href}, - doc - ) do - data = %{ - "subject" => subject, - "subscribe_address" => subscribe_address, - "ap_id" => ap_id - } + subject = XML.string_from_xpath("//Subject", doc) - {:ok, data} - end + subscribe_address = + ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template} + |> XML.string_from_xpath(doc) + + ap_id = + ~s{//Link[@rel="self" and @type="application/activity+json"]/@href} + |> XML.string_from_xpath(doc) + + data = %{ + "subject" => subject, + "subscribe_address" => subscribe_address, + "ap_id" => ap_id + } + + {:ok, data} end defp webfinger_from_json(doc) do -- cgit v1.2.3 From 8b2457bdbf8791923701b0b015f6ddf2e7c89bf7 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 13:25:34 +0200 Subject: Transmogrifier tests: Extract Undo handling --- .../transmogrifier/undo_handling_test.exs | 185 +++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 161 ------------------ 2 files changed, 185 insertions(+), 161 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/undo_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs new file mode 100644 index 000000000..a9ebfdb18 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -0,0 +1,185 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "it works for incoming emoji reaction undos" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌") + + data = + File.read!("test/fixtures/mastodon-undo-like.json") + |> Poison.decode!() + |> Map.put("object", reaction_activity.data["id"]) + |> Map.put("actor", user.ap_id) + + {:ok, activity} = Transmogrifier.handle_incoming(data) + + assert activity.actor == user.ap_id + assert activity.data["id"] == data["id"] + assert activity.data["type"] == "Undo" + end + + test "it returns an error for incoming unlikes wihout a like activity" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + + data = + File.read!("test/fixtures/mastodon-undo-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + assert Transmogrifier.handle_incoming(data) == :error + end + + test "it works for incoming unlikes with an existing like activity" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + + like_data = + File.read!("test/fixtures/mastodon-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + _liker = insert(:user, ap_id: like_data["actor"], local: false) + + {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) + + data = + File.read!("test/fixtures/mastodon-undo-like.json") + |> Poison.decode!() + |> Map.put("object", like_data) + |> Map.put("actor", like_data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["type"] == "Undo" + assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" + assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" + end + + test "it works for incoming unlikes with an existing like activity and a compact object" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + + like_data = + File.read!("test/fixtures/mastodon-like.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + _liker = insert(:user, ap_id: like_data["actor"], local: false) + + {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) + + data = + File.read!("test/fixtures/mastodon-undo-like.json") + |> Poison.decode!() + |> Map.put("object", like_data["id"]) + |> Map.put("actor", like_data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["type"] == "Undo" + assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" + assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" + end + + test "it works for incoming unannounces with an existing notice" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + + announce_data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + _announcer = insert(:user, ap_id: announce_data["actor"], local: false) + + {:ok, %Activity{data: announce_data, local: false}} = + Transmogrifier.handle_incoming(announce_data) + + data = + File.read!("test/fixtures/mastodon-undo-announce.json") + |> Poison.decode!() + |> Map.put("object", announce_data) + |> Map.put("actor", announce_data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Undo" + assert object_data = data["object"] + assert object_data["type"] == "Announce" + assert object_data["object"] == activity.data["object"] + + assert object_data["id"] == + "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" + end + + test "it works for incomming unfollows with an existing follow" do + user = insert(:user) + + follow_data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + _follower = insert(:user, ap_id: follow_data["actor"], local: false) + + {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data) + + data = + File.read!("test/fixtures/mastodon-unfollow-activity.json") + |> Poison.decode!() + |> Map.put("object", follow_data) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Undo" + assert data["object"]["type"] == "Follow" + assert data["object"]["object"] == user.ap_id + assert data["actor"] == "http://mastodon.example.org/users/admin" + + refute User.following?(User.get_cached_by_ap_id(data["actor"]), user) + end + + test "it works for incoming unblocks with an existing block" do + user = insert(:user) + + block_data = + File.read!("test/fixtures/mastodon-block-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + _blocker = insert(:user, ap_id: block_data["actor"], local: false) + + {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data) + + data = + File.read!("test/fixtures/mastodon-unblock-activity.json") + |> Poison.decode!() + |> Map.put("object", block_data) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + assert data["type"] == "Undo" + assert data["object"]["type"] == "Block" + assert data["object"]["object"] == user.ap_id + assert data["actor"] == "http://mastodon.example.org/users/admin" + + blocker = User.get_cached_by_ap_id(data["actor"]) + + refute User.blocks?(blocker, user) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 23efa4be6..a315ff42d 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -362,87 +362,6 @@ test "it reject invalid emoji reactions" do assert :error = Transmogrifier.handle_incoming(data) end - test "it works for incoming emoji reaction undos" do - user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - {:ok, reaction_activity, _object} = CommonAPI.react_with_emoji(activity.id, user, "👌") - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", reaction_activity.data["id"]) - |> Map.put("actor", user.ap_id) - - {:ok, activity} = Transmogrifier.handle_incoming(data) - - assert activity.actor == user.ap_id - assert activity.data["id"] == data["id"] - assert activity.data["type"] == "Undo" - end - - test "it returns an error for incoming unlikes wihout a like activity" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - assert Transmogrifier.handle_incoming(data) == :error - end - - test "it works for incoming unlikes with an existing like activity" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) - - like_data = - File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", like_data) - |> Map.put("actor", like_data["actor"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Undo" - assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" - assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" - end - - test "it works for incoming unlikes with an existing like activity and a compact object" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) - - like_data = - File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", like_data["id"]) - |> Map.put("actor", like_data["actor"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Undo" - assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" - assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" - end - test "it works for incoming announces" do data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() @@ -844,60 +763,6 @@ test "it fails for incoming user deletes with spoofed origin" do assert User.get_cached_by_ap_id(ap_id) end - test "it works for incoming unannounces with an existing notice" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) - - announce_data = - File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: announce_data, local: false}} = - Transmogrifier.handle_incoming(announce_data) - - data = - File.read!("test/fixtures/mastodon-undo-announce.json") - |> Poison.decode!() - |> Map.put("object", announce_data) - |> Map.put("actor", announce_data["actor"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Undo" - assert object_data = data["object"] - assert object_data["type"] == "Announce" - assert object_data["object"] == activity.data["object"] - - assert object_data["id"] == - "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" - end - - test "it works for incomming unfollows with an existing follow" do - user = insert(:user) - - follow_data = - File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() - |> Map.put("object", user.ap_id) - - {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(follow_data) - - data = - File.read!("test/fixtures/mastodon-unfollow-activity.json") - |> Poison.decode!() - |> Map.put("object", follow_data) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Undo" - assert data["object"]["type"] == "Follow" - assert data["object"]["object"] == user.ap_id - assert data["actor"] == "http://mastodon.example.org/users/admin" - - refute User.following?(User.get_cached_by_ap_id(data["actor"]), user) - end - test "it works for incoming follows to locked account" do pending_follower = insert(:user, ap_id: "http://mastodon.example.org/users/admin") user = insert(:user, locked: true) @@ -967,32 +832,6 @@ test "incoming blocks successfully tear down any follow relationship" do refute User.following?(blocked, blocker) end - test "it works for incoming unblocks with an existing block" do - user = insert(:user) - - block_data = - File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() - |> Map.put("object", user.ap_id) - - {:ok, %Activity{data: _, local: false}} = Transmogrifier.handle_incoming(block_data) - - data = - File.read!("test/fixtures/mastodon-unblock-activity.json") - |> Poison.decode!() - |> Map.put("object", block_data) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - assert data["type"] == "Undo" - assert data["object"]["type"] == "Block" - assert data["object"]["object"] == user.ap_id - assert data["actor"] == "http://mastodon.example.org/users/admin" - - blocker = User.get_cached_by_ap_id(data["actor"]) - - refute User.blocks?(blocker, user) - end - test "it works for incoming accepts which were pre-accepted" do follower = insert(:user) followed = insert(:user) -- cgit v1.2.3 From f1da8882f971f932b65f655b6457759387dafe51 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 14:17:47 +0200 Subject: UndoValidator: Add UndoValidator. --- lib/pleroma/web/activity_pub/builder.ex | 13 +++++ lib/pleroma/web/activity_pub/object_validator.ex | 9 ++++ .../object_validators/common_validations.ex | 3 +- .../object_validators/undo_validator.ex | 62 ++++++++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 41 ++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/undo_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 429a510b8..380d8f565 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,19 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + @spec undo(User.t(), Activity.t()) :: {:ok, map(), keyword()} + def undo(actor, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "type" => "Undo", + "object" => object.data["id"], + "to" => object.data["to"] || [], + "cc" => object.data["cc"] || [] + }, []} + end + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index dc4bce059..b6937d2e1 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -12,10 +12,19 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Undo"} = object, meta) do + with {:ok, object} <- + object |> UndoValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object |> Map.from_struct()) + {:ok, object, meta} + end + end + def validate(%{"type" => "Like"} = object, meta) do with {:ok, object} <- object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index b479c3918..067ee4f9a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do import Ecto.Changeset + alias Pleroma.Activity alias Pleroma.Object alias Pleroma.User @@ -22,7 +23,7 @@ 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, object -> - if Object.get_cached_by_ap_id(object) do + if Object.get_cached_by_ap_id(object) || Activity.get_by_ap_id(object) do [] else [{field_name, "can't find object"}] diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex new file mode 100644 index 000000000..d0ba418e8 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Undo"]) + |> validate_required([:id, :type, :object, :actor, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + |> validate_undo_rights() + end + + def validate_undo_rights(cng) do + actor = get_field(cng, :actor) + object = get_field(cng, :object) + + with %Activity{data: %{"actor" => object_actor}} <- Activity.get_by_ap_id(object), + true <- object_actor != actor do + cng + |> add_error(:actor, "not the same as object actor") + else + _ -> cng + end + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 93989e28a..8626e127e 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,6 +1,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils @@ -8,6 +9,46 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory + describe "Undos" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) + {:ok, like} = CommonAPI.favorite(user, post_activity.id) + {:ok, valid_like_undo, []} = Builder.undo(user, like) + + %{user: user, like: like, valid_like_undo: valid_like_undo} + end + + test "it validates a basic like undo", %{valid_like_undo: valid_like_undo} do + assert {:ok, _, _} = ObjectValidator.validate(valid_like_undo, []) + end + + test "it does not validate if the actor of the undo is not the actor of the object", %{ + valid_like_undo: valid_like_undo + } do + other_user = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo") + + bad_actor = + valid_like_undo + |> Map.put("actor", other_user.ap_id) + + {:error, cng} = ObjectValidator.validate(bad_actor, []) + + assert {:actor, {"not the same as object actor", []}} in cng.errors + end + + test "it does not validate if the object is missing", %{valid_like_undo: valid_like_undo} do + missing_object = + valid_like_undo + |> Map.put("object", "https://gensokyo.2hu/objects/1") + + {:error, cng} = ObjectValidator.validate(missing_object, []) + + assert {:object, {"can't find object", []}} in cng.errors + assert length(cng.errors) == 1 + end + end + describe "likes" do setup do user = insert(:user) -- cgit v1.2.3 From d861b0790a62767b31b8a85862fc249a4f8ca542 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 16:43:00 +0400 Subject: Add OpenAPI spec for SubscriptionController --- lib/pleroma/web/api_spec.ex | 7 +- .../api_spec/operations/subscription_operation.ex | 188 +++++++++++++++++++++ .../web/api_spec/schemas/push_subscription.ex | 66 ++++++++ .../controllers/subscription_controller.ex | 12 +- lib/pleroma/web/push/subscription.ex | 10 +- lib/pleroma/web/router.ex | 2 +- .../controllers/subscription_controller_test.exs | 28 +-- 7 files changed, 288 insertions(+), 25 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/subscription_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/push_subscription.ex diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index b3c1e3ea2..79fd5f871 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -39,7 +39,12 @@ def spec do password: %OpenApiSpex.OAuthFlow{ authorizationUrl: "/oauth/authorize", tokenUrl: "/oauth/token", - scopes: %{"read" => "read", "write" => "write", "follow" => "follow"} + scopes: %{ + "read" => "read", + "write" => "write", + "follow" => "follow", + "push" => "push" + } } } } diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex new file mode 100644 index 000000000..663b8fa11 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -0,0 +1,188 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.PushSubscription + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Subscribe to push notifications", + description: + "Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.", + operationId: "SubscriptionController.create", + security: [%{"oAuth" => ["push"]}], + requestBody: Helpers.request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Push Subscription", "application/json", PushSubscription), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Get current subscription", + description: "View the PushSubscription currently associated with this access token.", + operationId: "SubscriptionController.show", + security: [%{"oAuth" => ["push"]}], + responses: %{ + 200 => Operation.response("Push Subscription", "application/json", PushSubscription), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Change types of notifications", + description: + "Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.", + operationId: "SubscriptionController.update", + security: [%{"oAuth" => ["push"]}], + requestBody: Helpers.request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Push Subscription", "application/json", PushSubscription), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Push Subscriptions"], + summary: "Remove current subscription", + description: "Removes the current Web Push API subscription.", + operationId: "SubscriptionController.delete", + security: [%{"oAuth" => ["push"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}), + 403 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + title: "SubscriptionCreateRequest", + description: "POST body for creating a push subscription", + type: :object, + properties: %{ + subscription: %Schema{ + type: :object, + properties: %{ + endpoint: %Schema{ + type: :string, + description: "Endpoint URL that is called when a notification event occurs." + }, + keys: %Schema{ + type: :object, + properties: %{ + p256dh: %Schema{ + type: :string, + description: + "User agent public key. Base64 encoded string of public key of ECDH key using `prime256v1` curve." + }, + auth: %Schema{ + type: :string, + description: "Auth secret. Base64 encoded string of 16 bytes of random data." + } + }, + required: [:p256dh, :auth] + } + }, + required: [:endpoint, :keys] + }, + data: %Schema{ + type: :object, + properties: %{ + alerts: %Schema{ + type: :object, + properties: %{ + follow: %Schema{type: :boolean, description: "Receive follow notifications?"}, + favourite: %Schema{ + type: :boolean, + description: "Receive favourite notifications?" + }, + reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"}, + mention: %Schema{type: :boolean, description: "Receive mention notifications?"}, + poll: %Schema{type: :boolean, description: "Receive poll notifications?"} + } + } + } + } + }, + required: [:subscription], + example: %{ + "subscription" => %{ + "endpoint" => "https://example.com/example/1234", + "keys" => %{ + "auth" => "8eDyX_uCN0XRhSbY5hs7Hg==", + "p256dh" => + "BCIWgsnyXDv1VkhqL2P7YRBvdeuDnlwAPT2guNhdIoW3IP7GmHh1SMKPLxRf7x8vJy6ZFK3ol2ohgn_-0yP7QQA=" + } + }, + "data" => %{ + "alerts" => %{ + "follow" => true, + "mention" => true, + "poll" => false + } + } + } + } + end + + defp update_request do + %Schema{ + title: "SubscriptionUpdateRequest", + type: :object, + properties: %{ + data: %Schema{ + type: :object, + properties: %{ + alerts: %Schema{ + type: :object, + properties: %{ + follow: %Schema{type: :boolean, description: "Receive follow notifications?"}, + favourite: %Schema{ + type: :boolean, + description: "Receive favourite notifications?" + }, + reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"}, + mention: %Schema{type: :boolean, description: "Receive mention notifications?"}, + poll: %Schema{type: :boolean, description: "Receive poll notifications?"} + } + } + } + } + }, + example: %{ + "data" => %{ + "alerts" => %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true + } + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/push_subscription.ex b/lib/pleroma/web/api_spec/schemas/push_subscription.ex new file mode 100644 index 000000000..cc91b95b8 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/push_subscription.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.PushSubscription do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PushSubscription", + description: "Response schema for a push subscription", + type: :object, + properties: %{ + id: %Schema{ + anyOf: [%Schema{type: :string}, %Schema{type: :integer}], + description: "The id of the push subscription in the database." + }, + endpoint: %Schema{type: :string, description: "Where push alerts will be sent to."}, + server_key: %Schema{type: :string, description: "The streaming server's VAPID key."}, + alerts: %Schema{ + type: :object, + description: "Which alerts should be delivered to the endpoint.", + properties: %{ + follow: %Schema{ + type: :boolean, + description: "Receive a push notification when someone has followed you?" + }, + favourite: %Schema{ + type: :boolean, + description: + "Receive a push notification when a status you created has been favourited by someone else?" + }, + reblog: %Schema{ + type: :boolean, + description: + "Receive a push notification when a status you created has been boosted by someone else?" + }, + mention: %Schema{ + type: :boolean, + description: + "Receive a push notification when someone else has mentioned you in a status?" + }, + poll: %Schema{ + type: :boolean, + description: + "Receive a push notification when a poll you voted in or created has ended? " + } + } + } + }, + example: %{ + "id" => "328_183", + "endpoint" => "https://yourdomain.example/listener", + "alerts" => %{ + "follow" => true, + "favourite" => true, + "reblog" => true, + "mention" => true, + "poll" => true + }, + "server_key" => + "BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=" + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex index d184ea1d0..34eac97c5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex @@ -11,14 +11,16 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do action_fallback(:errors) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:restrict_push_enabled) plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]}) - plug(:restrict_push_enabled) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SubscriptionOperation # Creates PushSubscription # POST /api/v1/push/subscription # - def create(%{assigns: %{user: user, token: token}} = conn, params) do + def create(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do with {:ok, _} <- Subscription.delete_if_exists(user, token), {:ok, subscription} <- Subscription.create(user, token, params) do render(conn, "show.json", subscription: subscription) @@ -28,7 +30,7 @@ def create(%{assigns: %{user: user, token: token}} = conn, params) do # Gets PushSubscription # GET /api/v1/push/subscription # - def get(%{assigns: %{user: user, token: token}} = conn, _params) do + def show(%{assigns: %{user: user, token: token}} = conn, _params) do with {:ok, subscription} <- Subscription.get(user, token) do render(conn, "show.json", subscription: subscription) end @@ -37,7 +39,7 @@ def get(%{assigns: %{user: user, token: token}} = conn, _params) do # Updates PushSubscription # PUT /api/v1/push/subscription # - def update(%{assigns: %{user: user, token: token}} = conn, params) do + def update(%{assigns: %{user: user, token: token}, body_params: params} = conn, _) do with {:ok, subscription} <- Subscription.update(user, token, params) do render(conn, "show.json", subscription: subscription) end @@ -66,7 +68,7 @@ defp restrict_push_enabled(conn, _) do def errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) - |> json(dgettext("errors", "Not found")) + |> json(%{error: dgettext("errors", "Record not found")}) end def errors(conn, _) do diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index b99b0c5fb..3e401a490 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,9 +25,9 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end - @supported_alert_types ~w[follow favourite mention reblog] + @supported_alert_types ~w[follow favourite mention reblog]a - defp alerts(%{"data" => %{"alerts" => alerts}}) do + defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) %{"alerts" => alerts} end @@ -44,9 +44,9 @@ def create( %User{} = user, %Token{} = token, %{ - "subscription" => %{ - "endpoint" => endpoint, - "keys" => %{"auth" => key_auth, "p256dh" => key_p256dh} + subscription: %{ + endpoint: endpoint, + keys: %{auth: key_auth, p256dh: key_p256dh} } } = params ) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5b00243e9..eda8320ea 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -426,7 +426,7 @@ defmodule Pleroma.Web.Router do post("/statuses/:id/unmute", StatusController, :unmute_conversation) post("/push/subscription", SubscriptionController, :create) - get("/push/subscription", SubscriptionController, :get) + get("/push/subscription", SubscriptionController, :show) put("/push/subscription", SubscriptionController, :update) delete("/push/subscription", SubscriptionController, :delete) diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs index 5682498c0..4aa260663 100644 --- a/test/web/mastodon_api/controllers/subscription_controller_test.exs +++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + alias Pleroma.Web.Push alias Pleroma.Web.Push.Subscription @@ -27,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionControllerTest do build_conn() |> assign(:user, user) |> assign(:token, token) + |> put_req_header("content-type", "application/json") %{conn: conn, user: user, token: token} end @@ -47,8 +49,8 @@ defmacro assert_error_when_disable_push(do: yield) do test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn - |> post("/api/v1/push/subscription", %{}) - |> json_response(403) + |> post("/api/v1/push/subscription", %{subscription: @sub}) + |> json_response_and_validate_schema(403) end end @@ -59,7 +61,7 @@ test "successful creation", %{conn: conn} do "data" => %{"alerts" => %{"mention" => true, "test" => true}}, "subscription" => @sub }) - |> json_response(200) + |> json_response_and_validate_schema(200) [subscription] = Pleroma.Repo.all(Subscription) @@ -77,7 +79,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> get("/api/v1/push/subscription", %{}) - |> json_response(403) + |> json_response_and_validate_schema(403) end end @@ -85,9 +87,9 @@ test "returns error when user hasn't subscription", %{conn: conn} do res = conn |> get("/api/v1/push/subscription", %{}) - |> json_response(404) + |> json_response_and_validate_schema(404) - assert "Not found" == res + assert %{"error" => "Record not found"} == res end test "returns a user subsciption", %{conn: conn, user: user, token: token} do @@ -101,7 +103,7 @@ test "returns a user subsciption", %{conn: conn, user: user, token: token} do res = conn |> get("/api/v1/push/subscription", %{}) - |> json_response(200) + |> json_response_and_validate_schema(200) expect = %{ "alerts" => %{"mention" => true}, @@ -130,7 +132,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> put("/api/v1/push/subscription", %{data: %{"alerts" => %{"mention" => false}}}) - |> json_response(403) + |> json_response_and_validate_schema(403) end end @@ -140,7 +142,7 @@ test "returns updated subsciption", %{conn: conn, subscription: subscription} do |> put("/api/v1/push/subscription", %{ data: %{"alerts" => %{"mention" => false, "follow" => true}} }) - |> json_response(200) + |> json_response_and_validate_schema(200) expect = %{ "alerts" => %{"follow" => true, "mention" => false}, @@ -158,7 +160,7 @@ test "returns error when push disabled ", %{conn: conn} do assert_error_when_disable_push do conn |> delete("/api/v1/push/subscription", %{}) - |> json_response(403) + |> json_response_and_validate_schema(403) end end @@ -166,9 +168,9 @@ test "returns error when user hasn't subscription", %{conn: conn} do res = conn |> delete("/api/v1/push/subscription", %{}) - |> json_response(404) + |> json_response_and_validate_schema(404) - assert "Not found" == res + assert %{"error" => "Record not found"} == res end test "returns empty result and delete user subsciption", %{ @@ -186,7 +188,7 @@ test "returns empty result and delete user subsciption", %{ res = conn |> delete("/api/v1/push/subscription", %{}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{} == res refute Pleroma.Repo.get(Subscription, subscription.id) -- cgit v1.2.3 From 8096565653f262844214d715228c31d4ef761f57 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 14 Apr 2020 22:50:29 +0400 Subject: Add OpenAPI spec for MarkerController --- .../web/api_spec/operations/marker_operation.ex | 52 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/marker.ex | 31 +++++++++++++ .../web/api_spec/schemas/markers_response.ex | 35 +++++++++++++++ .../web/api_spec/schemas/markers_upsert_request.ex | 35 +++++++++++++++ .../mastodon_api/controllers/marker_controller.ex | 12 ++++- .../controllers/marker_controller_test.exs | 12 ++++- 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/marker_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/marker.ex create mode 100644 lib/pleroma/web/api_spec/schemas/markers_response.ex create mode 100644 lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex new file mode 100644 index 000000000..60adc7c7d --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex @@ -0,0 +1,52 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MarkerOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.MarkersResponse + alias Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["markers"], + summary: "Get saved timeline position", + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "MarkerController.index", + parameters: [ + Operation.parameter( + :timeline, + :query, + %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications"]} + }, + "Array of markers to fetch. If not provided, an empty object will be returned." + ) + ], + responses: %{ + 200 => Operation.response("Marker", "application/json", MarkersResponse) + } + } + end + + def upsert_operation do + %Operation{ + tags: ["markers"], + summary: "Save position in timeline", + operationId: "MarkerController.upsert", + requestBody: Helpers.request_body("Parameters", MarkersUpsertRequest, required: true), + security: [%{"oAuth" => ["follow", "write:blocks"]}], + responses: %{ + 200 => Operation.response("Marker", "application/json", MarkersResponse) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/marker.ex b/lib/pleroma/web/api_spec/schemas/marker.ex new file mode 100644 index 000000000..64fca5973 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/marker.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Marker do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Marker", + description: "Schema for a marker", + type: :object, + properties: %{ + last_read_id: %Schema{type: :string}, + version: %Schema{type: :integer}, + updated_at: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + unread_count: %Schema{type: :integer} + } + } + }, + example: %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 5} + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/markers_response.ex b/lib/pleroma/web/api_spec/schemas/markers_response.ex new file mode 100644 index 000000000..cb1121931 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/markers_response.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.MarkersResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + + alias Pleroma.Web.ApiSpec.Schemas.Marker + + OpenApiSpex.schema(%{ + title: "MarkersResponse", + description: "Response schema for markers", + type: :object, + properties: %{ + notifications: %Schema{allOf: [Marker], nullable: true}, + home: %Schema{allOf: [Marker], nullable: true} + }, + items: %Schema{type: :string}, + example: %{ + "notifications" => %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 0} + }, + "home" => %{ + "last_read_id" => "103206604258487607", + "version" => 468, + "updated_at" => "2019-11-26T22:37:25.235Z", + "pleroma" => %{"unread_count" => 10} + } + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex b/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex new file mode 100644 index 000000000..97dcc24b4 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "MarkersUpsertRequest", + description: "Request schema for marker upsert", + type: :object, + properties: %{ + notifications: %Schema{ + type: :object, + properties: %{ + last_read_id: %Schema{type: :string} + } + }, + home: %Schema{ + type: :object, + properties: %{ + last_read_id: %Schema{type: :string} + } + } + }, + example: %{ + "home" => %{ + "last_read_id" => "103194548672408537", + "version" => 462, + "updated_at" => "2019-11-24T19:39:39.337Z" + } + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex index 9f9d4574e..b94171b36 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -15,15 +15,23 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation # GET /api/v1/markers def index(%{assigns: %{user: user}} = conn, params) do - markers = Pleroma.Marker.get_markers(user, params["timeline"]) + markers = Pleroma.Marker.get_markers(user, params[:timeline]) render(conn, "markers.json", %{markers: markers}) end # POST /api/v1/markers - def upsert(%{assigns: %{user: user}} = conn, params) do + def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do + params = + params + |> Map.from_struct() + |> Map.new(fn {key, value} -> {to_string(key), value} end) + with {:ok, result} <- Pleroma.Marker.upsert(user, params), markers <- Map.values(result) do render(conn, "markers.json", %{markers: markers}) diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 919f295bd..1c85ed032 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -4,8 +4,10 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Web.ApiSpec import Pleroma.Factory + import OpenApiSpex.TestAssertions describe "GET /api/v1/markers" do test "gets markers with correct scopes", %{conn: conn} do @@ -22,7 +24,7 @@ test "gets markers with correct scopes", %{conn: conn} do conn |> assign(:user, user) |> assign(:token, token) - |> get("/api/v1/markers", %{timeline: ["notifications"]}) + |> get("/api/v1/markers?timeline[]=notifications") |> json_response(200) assert response == %{ @@ -32,6 +34,8 @@ test "gets markers with correct scopes", %{conn: conn} do "version" => 0 } } + + assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "gets markers with missed scopes", %{conn: conn} do @@ -60,6 +64,7 @@ test "creates a marker with correct scopes", %{conn: conn} do conn |> assign(:user, user) |> assign(:token, token) + |> put_req_header("content-type", "application/json") |> post("/api/v1/markers", %{ home: %{last_read_id: "777"}, notifications: %{"last_read_id" => "69420"} @@ -73,6 +78,8 @@ test "creates a marker with correct scopes", %{conn: conn} do "version" => 0 } } = response + + assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "updates exist marker", %{conn: conn} do @@ -89,6 +96,7 @@ test "updates exist marker", %{conn: conn} do conn |> assign(:user, user) |> assign(:token, token) + |> put_req_header("content-type", "application/json") |> post("/api/v1/markers", %{ home: %{last_read_id: "777"}, notifications: %{"last_read_id" => "69888"} @@ -102,6 +110,8 @@ test "updates exist marker", %{conn: conn} do "version" => 0 } } + + assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "creates a marker with missed scopes", %{conn: conn} do -- cgit v1.2.3 From babcae7130d3bc75f85adeef1845997cd091eb84 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 16:45:34 +0400 Subject: Move single used schemas to Marker operation schema --- .../web/api_spec/operations/marker_operation.ex | 102 +++++++++++++++++++-- lib/pleroma/web/api_spec/schemas/marker.ex | 31 ------- .../web/api_spec/schemas/markers_response.ex | 35 ------- .../web/api_spec/schemas/markers_upsert_request.ex | 35 ------- .../mastodon_api/controllers/marker_controller.ex | 8 +- lib/pleroma/web/mastodon_api/views/marker_view.ex | 13 +-- .../controllers/marker_controller_test.exs | 19 ++-- 7 files changed, 111 insertions(+), 132 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/marker.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/markers_response.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex index 60adc7c7d..06620492a 100644 --- a/lib/pleroma/web/api_spec/operations/marker_operation.ex +++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex @@ -6,8 +6,6 @@ defmodule Pleroma.Web.ApiSpec.MarkerOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers - alias Pleroma.Web.ApiSpec.Schemas.MarkersResponse - alias Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -16,7 +14,7 @@ def open_api_operation(action) do def index_operation do %Operation{ - tags: ["markers"], + tags: ["Markers"], summary: "Get saved timeline position", security: [%{"oAuth" => ["read:statuses"]}], operationId: "MarkerController.index", @@ -32,21 +30,111 @@ def index_operation do ) ], responses: %{ - 200 => Operation.response("Marker", "application/json", MarkersResponse) + 200 => Operation.response("Marker", "application/json", response()), + 403 => Operation.response("Error", "application/json", api_error()) } } end def upsert_operation do %Operation{ - tags: ["markers"], + tags: ["Markers"], summary: "Save position in timeline", operationId: "MarkerController.upsert", - requestBody: Helpers.request_body("Parameters", MarkersUpsertRequest, required: true), + requestBody: Helpers.request_body("Parameters", upsert_request(), required: true), security: [%{"oAuth" => ["follow", "write:blocks"]}], responses: %{ - 200 => Operation.response("Marker", "application/json", MarkersResponse) + 200 => Operation.response("Marker", "application/json", response()), + 403 => Operation.response("Error", "application/json", api_error()) } } end + + defp marker do + %Schema{ + title: "Marker", + description: "Schema for a marker", + type: :object, + properties: %{ + last_read_id: %Schema{type: :string}, + version: %Schema{type: :integer}, + updated_at: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + unread_count: %Schema{type: :integer} + } + } + }, + example: %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 5} + } + } + end + + defp response do + %Schema{ + title: "MarkersResponse", + description: "Response schema for markers", + type: :object, + properties: %{ + notifications: %Schema{allOf: [marker()], nullable: true}, + home: %Schema{allOf: [marker()], nullable: true} + }, + items: %Schema{type: :string}, + example: %{ + "notifications" => %{ + "last_read_id" => "35098814", + "version" => 361, + "updated_at" => "2019-11-26T22:37:25.239Z", + "pleroma" => %{"unread_count" => 0} + }, + "home" => %{ + "last_read_id" => "103206604258487607", + "version" => 468, + "updated_at" => "2019-11-26T22:37:25.235Z", + "pleroma" => %{"unread_count" => 10} + } + } + } + end + + defp upsert_request do + %Schema{ + title: "MarkersUpsertRequest", + description: "Request schema for marker upsert", + type: :object, + properties: %{ + notifications: %Schema{ + type: :object, + properties: %{ + last_read_id: %Schema{type: :string} + } + }, + home: %Schema{ + type: :object, + properties: %{ + last_read_id: %Schema{type: :string} + } + } + }, + example: %{ + "home" => %{ + "last_read_id" => "103194548672408537", + "version" => 462, + "updated_at" => "2019-11-24T19:39:39.337Z" + } + } + } + end + + defp api_error do + %Schema{ + type: :object, + properties: %{error: %Schema{type: :string}} + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/marker.ex b/lib/pleroma/web/api_spec/schemas/marker.ex deleted file mode 100644 index 64fca5973..000000000 --- a/lib/pleroma/web/api_spec/schemas/marker.ex +++ /dev/null @@ -1,31 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.Marker do - require OpenApiSpex - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - title: "Marker", - description: "Schema for a marker", - type: :object, - properties: %{ - last_read_id: %Schema{type: :string}, - version: %Schema{type: :integer}, - updated_at: %Schema{type: :string}, - pleroma: %Schema{ - type: :object, - properties: %{ - unread_count: %Schema{type: :integer} - } - } - }, - example: %{ - "last_read_id" => "35098814", - "version" => 361, - "updated_at" => "2019-11-26T22:37:25.239Z", - "pleroma" => %{"unread_count" => 5} - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/markers_response.ex b/lib/pleroma/web/api_spec/schemas/markers_response.ex deleted file mode 100644 index cb1121931..000000000 --- a/lib/pleroma/web/api_spec/schemas/markers_response.ex +++ /dev/null @@ -1,35 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.MarkersResponse do - require OpenApiSpex - alias OpenApiSpex.Schema - - alias Pleroma.Web.ApiSpec.Schemas.Marker - - OpenApiSpex.schema(%{ - title: "MarkersResponse", - description: "Response schema for markers", - type: :object, - properties: %{ - notifications: %Schema{allOf: [Marker], nullable: true}, - home: %Schema{allOf: [Marker], nullable: true} - }, - items: %Schema{type: :string}, - example: %{ - "notifications" => %{ - "last_read_id" => "35098814", - "version" => 361, - "updated_at" => "2019-11-26T22:37:25.239Z", - "pleroma" => %{"unread_count" => 0} - }, - "home" => %{ - "last_read_id" => "103206604258487607", - "version" => 468, - "updated_at" => "2019-11-26T22:37:25.235Z", - "pleroma" => %{"unread_count" => 10} - } - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex b/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex deleted file mode 100644 index 97dcc24b4..000000000 --- a/lib/pleroma/web/api_spec/schemas/markers_upsert_request.ex +++ /dev/null @@ -1,35 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.MarkersUpsertRequest do - require OpenApiSpex - alias OpenApiSpex.Schema - - OpenApiSpex.schema(%{ - title: "MarkersUpsertRequest", - description: "Request schema for marker upsert", - type: :object, - properties: %{ - notifications: %Schema{ - type: :object, - properties: %{ - last_read_id: %Schema{type: :string} - } - }, - home: %Schema{ - type: :object, - properties: %{ - last_read_id: %Schema{type: :string} - } - } - }, - example: %{ - "home" => %{ - "last_read_id" => "103194548672408537", - "version" => 462, - "updated_at" => "2019-11-24T19:39:39.337Z" - } - } - }) -end diff --git a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex index b94171b36..85310edfa 100644 --- a/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/marker_controller.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do use Pleroma.Web, :controller alias Pleroma.Plugs.OAuthScopesPlug + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["read:statuses"]} @@ -15,7 +17,6 @@ defmodule Pleroma.Web.MastodonAPI.MarkerController do plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - plug(OpenApiSpex.Plug.CastAndValidate) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MarkerOperation @@ -27,10 +28,7 @@ def index(%{assigns: %{user: user}} = conn, params) do # POST /api/v1/markers def upsert(%{assigns: %{user: user}, body_params: params} = conn, _) do - params = - params - |> Map.from_struct() - |> Map.new(fn {key, value} -> {to_string(key), value} end) + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) with {:ok, result} <- Pleroma.Marker.upsert(user, params), markers <- Map.values(result) do diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 985368fe5..9705b7a91 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -6,12 +6,13 @@ defmodule Pleroma.Web.MastodonAPI.MarkerView do use Pleroma.Web, :view def render("markers.json", %{markers: markers}) do - Enum.reduce(markers, %{}, fn m, acc -> - Map.put_new(acc, m.timeline, %{ - last_read_id: m.last_read_id, - version: m.lock_version, - updated_at: NaiveDateTime.to_iso8601(m.updated_at) - }) + Map.new(markers, fn m -> + {m.timeline, + %{ + last_read_id: m.last_read_id, + version: m.lock_version, + updated_at: NaiveDateTime.to_iso8601(m.updated_at) + }} end) end end diff --git a/test/web/mastodon_api/controllers/marker_controller_test.exs b/test/web/mastodon_api/controllers/marker_controller_test.exs index 1c85ed032..bce719bea 100644 --- a/test/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/web/mastodon_api/controllers/marker_controller_test.exs @@ -4,10 +4,8 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Web.ApiSpec import Pleroma.Factory - import OpenApiSpex.TestAssertions describe "GET /api/v1/markers" do test "gets markers with correct scopes", %{conn: conn} do @@ -25,7 +23,7 @@ test "gets markers with correct scopes", %{conn: conn} do |> assign(:user, user) |> assign(:token, token) |> get("/api/v1/markers?timeline[]=notifications") - |> json_response(200) + |> json_response_and_validate_schema(200) assert response == %{ "notifications" => %{ @@ -34,8 +32,6 @@ test "gets markers with correct scopes", %{conn: conn} do "version" => 0 } } - - assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "gets markers with missed scopes", %{conn: conn} do @@ -49,7 +45,7 @@ test "gets markers with missed scopes", %{conn: conn} do |> assign(:user, user) |> assign(:token, token) |> get("/api/v1/markers", %{timeline: ["notifications"]}) - |> json_response(403) + |> json_response_and_validate_schema(403) assert response == %{"error" => "Insufficient permissions: read:statuses."} end @@ -69,7 +65,7 @@ test "creates a marker with correct scopes", %{conn: conn} do home: %{last_read_id: "777"}, notifications: %{"last_read_id" => "69420"} }) - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{ "notifications" => %{ @@ -78,8 +74,6 @@ test "creates a marker with correct scopes", %{conn: conn} do "version" => 0 } } = response - - assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "updates exist marker", %{conn: conn} do @@ -101,7 +95,7 @@ test "updates exist marker", %{conn: conn} do home: %{last_read_id: "777"}, notifications: %{"last_read_id" => "69888"} }) - |> json_response(200) + |> json_response_and_validate_schema(200) assert response == %{ "notifications" => %{ @@ -110,8 +104,6 @@ test "updates exist marker", %{conn: conn} do "version" => 0 } } - - assert_schema(response, "MarkersResponse", ApiSpec.spec()) end test "creates a marker with missed scopes", %{conn: conn} do @@ -122,11 +114,12 @@ test "creates a marker with missed scopes", %{conn: conn} do conn |> assign(:user, user) |> assign(:token, token) + |> put_req_header("content-type", "application/json") |> post("/api/v1/markers", %{ home: %{last_read_id: "777"}, notifications: %{"last_read_id" => "69420"} }) - |> json_response(403) + |> json_response_and_validate_schema(403) assert response == %{"error" => "Insufficient permissions: write:statuses."} end -- cgit v1.2.3 From 5ec6aad5670cf0888942a13e83b9ffd16e97dd18 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 17:05:34 +0400 Subject: Add OpenAPI spec for ListController --- .../web/api_spec/operations/account_operation.ex | 19 +-- .../web/api_spec/operations/list_operation.ex | 189 +++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/list.ex | 23 +++ .../mastodon_api/controllers/list_controller.ex | 26 +-- test/support/conn_case.ex | 2 +- .../controllers/list_controller_test.exs | 60 ++++--- 6 files changed, 266 insertions(+), 53 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/list_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/list.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index fe9548b1b..470fc0215 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do alias Pleroma.Web.ApiSpec.Schemas.ActorType alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.List alias Pleroma.Web.ApiSpec.Schemas.Status alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope @@ -646,28 +647,12 @@ defp mute_request do } end - defp list do - %Schema{ - title: "List", - description: "Response schema for a list", - type: :object, - properties: %{ - id: %Schema{type: :string}, - title: %Schema{type: :string} - }, - example: %{ - "id" => "123", - "title" => "my list" - } - } - end - defp array_of_lists do %Schema{ title: "ArrayOfLists", description: "Response schema for lists", type: :array, - items: list(), + items: List, example: [ %{"id" => "123", "title" => "my list"}, %{"id" => "1337", "title" => "anotehr list"} diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex new file mode 100644 index 000000000..bb903a379 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/list_operation.ex @@ -0,0 +1,189 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ListOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.List + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Lists"], + summary: "Show user's lists", + description: "Fetch all lists that the user owns", + security: [%{"oAuth" => ["read:lists"]}], + operationId: "ListController.index", + responses: %{ + 200 => Operation.response("Array of List", "application/json", array_of_lists()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Lists"], + summary: "Show a single list", + description: "Fetch the list with the given ID. Used for verifying the title of a list.", + operationId: "ListController.create", + requestBody: create_update_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 400 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Lists"], + summary: "Show a single list", + description: "Fetch the list with the given ID. Used for verifying the title of a list.", + operationId: "ListController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Lists"], + summary: "Update a list", + description: "Change the title of a list", + operationId: "ListController.update", + parameters: [id_param()], + requestBody: create_update_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("List", "application/json", List), + 422 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Lists"], + summary: "Delete a list", + operationId: "ListController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def list_accounts_operation do + %Operation{ + tags: ["Lists"], + summary: "View accounts in list", + operationId: "ListController.list_accounts", + parameters: [id_param()], + security: [%{"oAuth" => ["read:lists"]}], + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account + }) + } + } + end + + def add_to_list_operation do + %Operation{ + tags: ["Lists"], + summary: "Add accounts to list", + description: + "Add accounts to the given list. Note that the user must be following these accounts.", + operationId: "ListController.add_to_list", + parameters: [id_param()], + requestBody: add_remove_accounts_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + def remove_from_list_operation do + %Operation{ + tags: ["Lists"], + summary: "Remove accounts from list", + operationId: "ListController.remove_from_list", + parameters: [id_param()], + requestBody: add_remove_accounts_request(), + security: [%{"oAuth" => ["write:lists"]}], + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) + } + } + end + + defp array_of_lists do + %Schema{ + title: "ArrayOfLists", + description: "Response schema for lists", + type: :array, + items: List, + example: [ + %{"id" => "123", "title" => "my list"}, + %{"id" => "1337", "title" => "another list"} + ] + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "List ID", + example: "123", + required: true + ) + end + + defp create_update_request do + request_body( + "Parameters", + %Schema{ + description: "POST body for creating or updating a List", + type: :object, + properties: %{ + title: %Schema{type: :string, description: "List title"} + }, + required: [:title] + }, + required: true + ) + end + + defp add_remove_accounts_request do + request_body( + "Parameters", + %Schema{ + description: "POST body for adding/removing accounts to/from a List", + type: :object, + properties: %{ + account_ids: %Schema{type: :array, description: "Array of account IDs", items: FlakeID} + }, + required: [:account_ids] + }, + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex new file mode 100644 index 000000000..78aa0736f --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/list.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.List do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "List", + description: "Represents a list of some users that the authenticated user follows", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "The internal database ID of the list"}, + title: %Schema{type: :string, description: "The user-defined title of the list"} + }, + example: %{ + "id" => "12249", + "title" => "Friends" + } + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex index bfe856025..acdc76fd2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/list_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/list_controller.ex @@ -9,20 +9,17 @@ defmodule Pleroma.Web.MastodonAPI.ListController do alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView - plug(:list_by_id_and_user when action not in [:index, :create]) - @oauth_read_actions [:index, :show, :list_accounts] + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:list_by_id_and_user when action not in [:index, :create]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in @oauth_read_actions) - - plug( - OAuthScopesPlug, - %{scopes: ["write:lists"]} - when action not in @oauth_read_actions - ) + plug(OAuthScopesPlug, %{scopes: ["write:lists"]} when action not in @oauth_read_actions) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ListOperation + # GET /api/v1/lists def index(%{assigns: %{user: user}} = conn, opts) do lists = Pleroma.List.for_user(user, opts) @@ -30,7 +27,7 @@ def index(%{assigns: %{user: user}} = conn, opts) do end # POST /api/v1/lists - def create(%{assigns: %{user: user}} = conn, %{"title" => title}) do + def create(%{assigns: %{user: user}, body_params: %{title: title}} = conn, _) do with {:ok, %Pleroma.List{} = list} <- Pleroma.List.create(title, user) do render(conn, "show.json", list: list) end @@ -42,7 +39,7 @@ def show(%{assigns: %{list: list}} = conn, _) do end # PUT /api/v1/lists/:id - def update(%{assigns: %{list: list}} = conn, %{"title" => title}) do + def update(%{assigns: %{list: list}, body_params: %{title: title}} = conn, _) do with {:ok, list} <- Pleroma.List.rename(list, title) do render(conn, "show.json", list: list) end @@ -65,7 +62,7 @@ def list_accounts(%{assigns: %{user: user, list: list}} = conn, _) do end # POST /api/v1/lists/:id/accounts - def add_to_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do + def add_to_list(%{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, _) do Enum.each(account_ids, fn account_id -> with %User{} = followed <- User.get_cached_by_id(account_id) do Pleroma.List.follow(list, followed) @@ -76,7 +73,10 @@ def add_to_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids end # DELETE /api/v1/lists/:id/accounts - def remove_from_list(%{assigns: %{list: list}} = conn, %{"account_ids" => account_ids}) do + def remove_from_list( + %{assigns: %{list: list}, body_params: %{account_ids: account_ids}} = conn, + _ + ) do Enum.each(account_ids, fn account_id -> with %User{} = followed <- User.get_cached_by_id(account_id) do Pleroma.List.unfollow(list, followed) @@ -86,7 +86,7 @@ def remove_from_list(%{assigns: %{list: list}} = conn, %{"account_ids" => accoun json(conn, %{}) end - defp list_by_id_and_user(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do + defp list_by_id_and_user(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do case Pleroma.List.get(id, user) do %Pleroma.List{} = list -> assign(conn, :list, list) nil -> conn |> render_error(:not_found, "List not found") |> halt() diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index fa30a0c41..91c03b1a8 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -74,7 +74,7 @@ defp json_response_and_validate_schema( status = Plug.Conn.Status.code(status) unless lookup[op_id].responses[status] do - err = "Response schema not found for #{conn.status} #{conn.method} #{conn.request_path}" + err = "Response schema not found for #{status} #{conn.method} #{conn.request_path}" flunk(err) end diff --git a/test/web/mastodon_api/controllers/list_controller_test.exs b/test/web/mastodon_api/controllers/list_controller_test.exs index c9c4cbb49..57a9ef4a4 100644 --- a/test/web/mastodon_api/controllers/list_controller_test.exs +++ b/test/web/mastodon_api/controllers/list_controller_test.exs @@ -12,37 +12,44 @@ defmodule Pleroma.Web.MastodonAPI.ListControllerTest do test "creating a list" do %{conn: conn} = oauth_access(["write:lists"]) - conn = post(conn, "/api/v1/lists", %{"title" => "cuties"}) - - assert %{"title" => title} = json_response(conn, 200) - assert title == "cuties" + assert %{"title" => "cuties"} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/lists", %{"title" => "cuties"}) + |> json_response_and_validate_schema(:ok) end test "renders error for invalid params" do %{conn: conn} = oauth_access(["write:lists"]) - conn = post(conn, "/api/v1/lists", %{"title" => nil}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/lists", %{"title" => nil}) - assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity) + assert %{"error" => "title - null value where string expected."} = + json_response_and_validate_schema(conn, 400) end test "listing a user's lists" do %{conn: conn} = oauth_access(["read:lists", "write:lists"]) conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/lists", %{"title" => "cuties"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/lists", %{"title" => "cofe"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) conn = get(conn, "/api/v1/lists") assert [ %{"id" => _, "title" => "cofe"}, %{"id" => _, "title" => "cuties"} - ] = json_response(conn, :ok) + ] = json_response_and_validate_schema(conn, :ok) end test "adding users to a list" do @@ -50,9 +57,12 @@ test "adding users to a list" do other_user = insert(:user) {:ok, list} = Pleroma.List.create("name", user) - conn = post(conn, "/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]}) + assert %{} == + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]}) + |> json_response_and_validate_schema(:ok) - assert %{} == json_response(conn, 200) %Pleroma.List{following: following} = Pleroma.List.get(list.id, user) assert following == [other_user.follower_address] end @@ -65,9 +75,12 @@ test "removing users from a list" do {:ok, list} = Pleroma.List.follow(list, other_user) {:ok, list} = Pleroma.List.follow(list, third_user) - conn = delete(conn, "/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]}) + assert %{} == + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]}) + |> json_response_and_validate_schema(:ok) - assert %{} == json_response(conn, 200) %Pleroma.List{following: following} = Pleroma.List.get(list.id, user) assert following == [third_user.follower_address] end @@ -83,7 +96,7 @@ test "listing users in a list" do |> assign(:user, user) |> get("/api/v1/lists/#{list.id}/accounts", %{"account_ids" => [other_user.id]}) - assert [%{"id" => id}] = json_response(conn, 200) + assert [%{"id" => id}] = json_response_and_validate_schema(conn, 200) assert id == to_string(other_user.id) end @@ -96,7 +109,7 @@ test "retrieving a list" do |> assign(:user, user) |> get("/api/v1/lists/#{list.id}") - assert %{"id" => id} = json_response(conn, 200) + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) assert id == to_string(list.id) end @@ -105,17 +118,18 @@ test "renders 404 if list is not found" do conn = get(conn, "/api/v1/lists/666") - assert %{"error" => "List not found"} = json_response(conn, :not_found) + assert %{"error" => "List not found"} = json_response_and_validate_schema(conn, :not_found) end test "renaming a list" do %{user: user, conn: conn} = oauth_access(["write:lists"]) {:ok, list} = Pleroma.List.create("name", user) - conn = put(conn, "/api/v1/lists/#{list.id}", %{"title" => "newname"}) - - assert %{"title" => name} = json_response(conn, 200) - assert name == "newname" + assert %{"title" => "newname"} = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/lists/#{list.id}", %{"title" => "newname"}) + |> json_response_and_validate_schema(:ok) end test "validates title when renaming a list" do @@ -125,9 +139,11 @@ test "validates title when renaming a list" do conn = conn |> assign(:user, user) + |> put_req_header("content-type", "application/json") |> put("/api/v1/lists/#{list.id}", %{"title" => " "}) - assert %{"error" => "can't be blank"} == json_response(conn, :unprocessable_entity) + assert %{"error" => "can't be blank"} == + json_response_and_validate_schema(conn, :unprocessable_entity) end test "deleting a list" do @@ -136,7 +152,7 @@ test "deleting a list" do conn = delete(conn, "/api/v1/lists/#{list.id}") - assert %{} = json_response(conn, 200) + assert %{} = json_response_and_validate_schema(conn, 200) assert is_nil(Repo.get(Pleroma.List, list.id)) end end -- cgit v1.2.3 From f2bf4390f4231d25486b803d426199975996f175 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 1 May 2020 19:53:00 +0400 Subject: Fix descriptions for List API spec --- lib/pleroma/web/api_spec/operations/list_operation.ex | 5 ++--- lib/pleroma/web/api_spec/schemas/list.ex | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/list_operation.ex b/lib/pleroma/web/api_spec/operations/list_operation.ex index bb903a379..c88ed5dd0 100644 --- a/lib/pleroma/web/api_spec/operations/list_operation.ex +++ b/lib/pleroma/web/api_spec/operations/list_operation.ex @@ -33,7 +33,7 @@ def index_operation do def create_operation do %Operation{ tags: ["Lists"], - summary: "Show a single list", + summary: "Create a list", description: "Fetch the list with the given ID. Used for verifying the title of a list.", operationId: "ListController.create", requestBody: create_update_request(), @@ -111,8 +111,7 @@ def add_to_list_operation do %Operation{ tags: ["Lists"], summary: "Add accounts to list", - description: - "Add accounts to the given list. Note that the user must be following these accounts.", + description: "Add accounts to the given list.", operationId: "ListController.add_to_list", parameters: [id_param()], requestBody: add_remove_accounts_request(), diff --git a/lib/pleroma/web/api_spec/schemas/list.ex b/lib/pleroma/web/api_spec/schemas/list.ex index 78aa0736f..b7d1685c9 100644 --- a/lib/pleroma/web/api_spec/schemas/list.ex +++ b/lib/pleroma/web/api_spec/schemas/list.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.List do OpenApiSpex.schema(%{ title: "List", - description: "Represents a list of some users that the authenticated user follows", + description: "Represents a list of users", type: :object, properties: %{ id: %Schema{type: :string, description: "The internal database ID of the list"}, -- cgit v1.2.3 From a3071f023166cb5364ce56e3666d5a77baa16434 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 15:08:41 +0200 Subject: Undoing: Move undoing likes to the pipeline everywhere. --- lib/pleroma/user.ex | 12 +++-- lib/pleroma/web/activity_pub/activity_pub.ex | 23 --------- lib/pleroma/web/activity_pub/side_effects.ex | 19 +++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 11 +--- lib/pleroma/web/common_api/common_api.ex | 9 ++-- .../mastodon_api/controllers/status_controller.ex | 6 +-- test/notification_test.exs | 2 +- test/web/activity_pub/activity_pub_test.exs | 60 ---------------------- test/web/activity_pub/side_effects_test.exs | 29 +++++++++++ .../transmogrifier/undo_handling_test.exs | 9 +++- 10 files changed, 75 insertions(+), 105 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 99358ddaf..0136ba119 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -29,7 +29,9 @@ defmodule Pleroma.User do alias Pleroma.UserRelationship alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils @@ -1553,11 +1555,13 @@ defp delete_activity(%{data: %{"type" => "Create"}} = activity) do end defp delete_activity(%{data: %{"type" => "Like"}} = activity) do - object = Object.normalize(activity) + actor = + activity.actor + |> get_cached_by_ap_id() - activity.actor - |> get_cached_by_ap_id() - |> ActivityPub.unlike(object) + {:ok, undo, _} = Builder.undo(actor, activity) + + Pipeline.common_pipeline(undo, local: true) end defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1c21d78af..daad4d751 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -398,29 +398,6 @@ defp do_unreact_with_emoji(user, reaction_id, options) do end end - @spec unlike(User.t(), Object.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t(), Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()} - def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) do - with {:ok, result} <- - Repo.transaction(fn -> do_unlike(actor, object, activity_id, local) end) do - result - end - end - - defp do_unlike(actor, object, activity_id, local) do - with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object), - unlike_data <- make_unlike_data(actor, like_activity, activity_id), - {:ok, unlike_activity} <- insert(unlike_data, local), - {:ok, _activity} <- Repo.delete(like_activity), - {:ok, object} <- remove_like_from_object(like_activity, object), - :ok <- maybe_federate(unlike_activity) do - {:ok, unlike_activity, like_activity, object} - else - nil -> {:ok, object} - {:error, error} -> Repo.rollback(error) - end - end - @spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} def announce( diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 6a8f1af96..8ed91e257 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,8 +5,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked object, a `Follow` activity will add the user to the follower collection, and so on. """ + alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -23,8 +25,25 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do {:ok, object, meta} end + def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do + with undone_object <- Activity.get_by_ap_id(undone_object), + :ok <- handle_undoing(undone_object) do + {:ok, object, meta} + end + end + # Nothing to do def handle(object, meta) do {:ok, object, meta} end + + def handle_undoing(%{data: %{"type" => "Like"}} = object) do + with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_like_from_object(object, liked_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + + def handle_undoing(object), do: {:error, ["don't know how to handle", object]} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 581e7040b..a60b27bea 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -865,19 +865,12 @@ def handle_incoming( def handle_incoming( %{ "type" => "Undo", - "object" => %{"type" => "Like", "object" => object_id}, - "actor" => _actor, - "id" => id + "object" => %{"type" => "Like"} } = 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, _, _} <- ActivityPub.unlike(actor, object, id, false) do + with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} - else - _e -> :error end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index f9db97d24..a670ea5bc 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -166,9 +166,12 @@ def favorite_helper(user, id) do def unfavorite(id, user) do with {_, %Activity{data: %{"type" => "Create"}} = activity} <- - {:find_activity, Activity.get_by_id(id)} do - object = Object.normalize(activity) - ActivityPub.unlike(user, object) + {:find_activity, Activity.get_by_id(id)}, + %Object{} = note <- Object.normalize(activity, false), + %Activity{} = like <- Utils.get_existing_like(user.ap_id, note), + {:ok, undo, _} <- Builder.undo(user, like), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: false) do + {:ok, activity} else {:find_activity, _} -> {:error, :not_found} _ -> {:error, dgettext("errors", "Could not unfavorite")} diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 9eea2e9eb..2a5eac9d9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -222,9 +222,9 @@ def favourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do end @doc "POST /api/v1/statuses/:id/unfavourite" - def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, _, _, %{data: %{"id" => id}}} <- CommonAPI.unfavorite(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do + with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user), + %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 601a6c0ca..7d5b82993 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -724,7 +724,7 @@ test "liking an activity results in 1 notification, then 0 if the activity is un assert length(Notification.for_user(user)) == 1 - {:ok, _, _, _} = CommonAPI.unfavorite(activity.id, other_user) + {:ok, _} = CommonAPI.unfavorite(activity.id, other_user) assert Enum.empty?(Notification.for_user(user)) end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 84ead93bb..797af66a0 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -995,66 +995,6 @@ test "reverts emoji unreact on error" do end end - describe "unliking" do - test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do - Config.put([:instance, :federating], true) - - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - {:ok, object} = ActivityPub.unlike(user, object) - refute called(Federator.publish()) - - {:ok, _like_activity} = CommonAPI.favorite(user, note_activity.id) - object = Object.get_by_id(object.id) - assert object.data["like_count"] == 1 - - {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object) - assert object.data["like_count"] == 0 - - assert called(Federator.publish(unlike_activity)) - end - - test "reverts unliking on error" do - note_activity = insert(:note_activity) - user = insert(:user) - - {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) - object = Object.normalize(note_activity) - assert object.data["like_count"] == 1 - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.unlike(user, object) - end - - assert Object.get_by_ap_id(object.data["id"]) == object - assert object.data["like_count"] == 1 - assert Activity.get_by_id(like_activity.id) - end - - test "unliking a previously liked object" do - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - # Unliking something that hasn't been liked does nothing - {:ok, object} = ActivityPub.unlike(user, object) - assert object.data["like_count"] == 0 - - {:ok, like_activity} = CommonAPI.favorite(user, note_activity.id) - - object = Object.get_by_id(object.id) - assert object.data["like_count"] == 1 - - {:ok, unlike_activity, _, object} = ActivityPub.unlike(user, object) - assert object.data["like_count"] == 0 - - assert Activity.get_by_id(like_activity.id) == nil - assert note_activity.actor in unlike_activity.recipients - end - end - describe "announcing an object" do test "adds an announce activity to the db" do note_activity = insert(:note_activity) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 0b6b55156..61ef72742 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do use Pleroma.DataCase + alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -15,6 +16,34 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Pleroma.Factory + describe "Undo objects" do + setup do + poster = insert(:user) + user = insert(:user) + {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) + {:ok, like} = CommonAPI.favorite(user, post.id) + + {:ok, undo_data, _meta} = Builder.undo(user, like) + {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true) + + %{like_undo: like_undo, post: post, like: like} + end + + test "a like undo removes the like from the object", %{like_undo: like_undo, post: post} do + {:ok, _like_undo, _} = SideEffects.handle(like_undo) + + object = Object.get_by_ap_id(post.data["object"]) + + assert object.data["like_count"] == 0 + assert object.data["likes"] == [] + end + + test "deletes the original like", %{like_undo: like_undo, like: like} do + {:ok, _like_undo, _} = SideEffects.handle(like_undo) + refute Activity.get_by_id(like.id) + end + end + describe "like objects" do setup do poster = insert(:user) diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs index a9ebfdb18..bf2a6bc5b 100644 --- a/test/web/activity_pub/transmogrifier/undo_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI @@ -67,7 +68,11 @@ test "it works for incoming unlikes with an existing like activity" do assert data["actor"] == "http://mastodon.example.org/users/admin" assert data["type"] == "Undo" assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" - assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" + assert data["object"] == "http://mastodon.example.org/users/admin#likes/2" + + note = Object.get_by_ap_id(like_data["object"]) + assert note.data["like_count"] == 0 + assert note.data["likes"] == [] end test "it works for incoming unlikes with an existing like activity and a compact object" do @@ -94,7 +99,7 @@ test "it works for incoming unlikes with an existing like activity and a compact assert data["actor"] == "http://mastodon.example.org/users/admin" assert data["type"] == "Undo" assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" - assert data["object"]["id"] == "http://mastodon.example.org/users/admin#likes/2" + assert data["object"] == "http://mastodon.example.org/users/admin#likes/2" end test "it works for incoming unannounces with an existing notice" do -- cgit v1.2.3 From e7d8ab8303cb69682a75c30a356572a75deb9837 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 5 May 2020 16:08:44 +0300 Subject: admin_api fetch status by id --- CHANGELOG.md | 1 + docs/API/admin_api.md | 11 +++++++++++ lib/pleroma/web/admin_api/admin_api_controller.ex | 12 +++++++++++- lib/pleroma/web/router.ex | 1 + test/web/admin_api/admin_api_controller_test.exs | 19 +++++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522285efe..114bfac4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. - Mastodon API: Add support for filtering replies in public and home timelines - Admin API: endpoints for create/update/delete OAuth Apps. +- Admin API: endpoint for status view.
    ### Fixed diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 6202c5a1a..23af08961 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -755,6 +755,17 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - 400 Bad Request `"Invalid parameters"` when `status` is missing - On success: `204`, empty response +## `GET /api/pleroma/admin/statuses/:id` + +### Show status by id + +- Params: + - `id`: required, status id +- Response: + - On failure: + - 404 Not Found `"Not Found"` + - On success: JSON, Mastodon Status entity + ## `PUT /api/pleroma/admin/statuses/:id` ### Change the scope of an individual reported status diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 816c11e01..ac661e515 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -93,7 +93,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} - when action in [:list_statuses, :list_user_statuses, :list_instance_statuses] + when action in [:list_statuses, :list_user_statuses, :list_instance_statuses, :status_show] ) plug( @@ -837,6 +837,16 @@ def list_statuses(%{assigns: %{user: _admin}} = conn, params) do |> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) end + def status_show(conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + conn + |> put_view(StatusView) + |> render("show.json", %{activity: activity}) + else + _ -> errors(conn, {:error, :not_found}) + end + end + def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"]) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 5b00243e9..ef2239d59 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -188,6 +188,7 @@ defmodule Pleroma.Web.Router do post("/reports/:id/notes", AdminAPIController, :report_notes_create) delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete) + get("/statuses/:id", AdminAPIController, :status_show) put("/statuses/:id", AdminAPIController, :status_update) delete("/statuses/:id", AdminAPIController, :status_delete) get("/statuses", AdminAPIController, :list_statuses) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 1862a9589..c3f3ad051 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1620,6 +1620,25 @@ test "returns 403 when requested by anonymous" do end end + describe "GET /api/pleroma/admin/statuses/:id" do + test "not found", %{conn: conn} do + assert conn + |> get("/api/pleroma/admin/statuses/not_found") + |> json_response(:not_found) + end + + test "shows activity", %{conn: conn} do + activity = insert(:note_activity) + + response = + conn + |> get("/api/pleroma/admin/statuses/#{activity.id}") + |> json_response(200) + + assert response["id"] == activity.id + end + end + describe "PUT /api/pleroma/admin/statuses/:id" do setup do activity = insert(:note_activity) -- cgit v1.2.3 From 88a14da8172cde6316926b5fbaa2f55b6da6f080 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 17:24:16 +0400 Subject: Add OpenAPI spec for InstanceController --- lib/pleroma/stats.ex | 2 +- .../web/api_spec/operations/instance_operation.ex | 169 +++++++++++++++++++++ .../controllers/instance_controller.ex | 4 + .../controllers/instance_controller_test.exs | 6 +- 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/instance_operation.ex diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 8d2809bbb..6b3a8a41f 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -91,7 +91,7 @@ def calculate_stat_data do peers: peers, stats: %{ domain_count: domain_count, - status_count: status_count, + status_count: status_count || 0, user_count: user_count } } diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex new file mode 100644 index 000000000..36a1a9043 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -0,0 +1,169 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.InstanceOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Instance"], + summary: "Fetch instance", + description: "Information about the server", + operationId: "InstanceController.show", + responses: %{ + 200 => Operation.response("Instance", "application/json", instance()) + } + } + end + + def peers_operation do + %Operation{ + tags: ["Instance"], + summary: "List of connected domains", + operationId: "InstanceController.peers", + responses: %{ + 200 => Operation.response("Array of domains", "application/json", array_of_domains()) + } + } + end + + defp instance do + %Schema{ + type: :object, + properties: %{ + uri: %Schema{type: :string, description: "The domain name of the instance"}, + title: %Schema{type: :string, description: "The title of the website"}, + description: %Schema{ + type: :string, + description: "Admin-defined description of the Mastodon site" + }, + version: %Schema{ + type: :string, + description: "The version of Mastodon installed on the instance" + }, + email: %Schema{ + type: :string, + description: "An email that may be contacted for any inquiries", + format: :email + }, + urls: %Schema{ + type: :object, + description: "URLs of interest for clients apps", + properties: %{ + streaming_api: %Schema{ + type: :string, + description: "Websockets address for push streaming" + } + } + }, + stats: %Schema{ + type: :object, + description: "Statistics about how much information the instance contains", + properties: %{ + user_count: %Schema{ + type: :integer, + description: "Users registered on this instance" + }, + status_count: %Schema{ + type: :integer, + description: "Statuses authored by users on instance" + }, + domain_count: %Schema{ + type: :integer, + description: "Domains federated with this instance" + } + } + }, + thumbnail: %Schema{ + type: :string, + description: "Banner image for the website", + nullable: true + }, + languages: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Primary langauges of the website and its staff" + }, + registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"}, + # Extra (not present in Mastodon): + max_toot_chars: %Schema{ + type: :integer, + description: ": Posts character limit (CW/Subject included in the counter)" + }, + poll_limits: %Schema{ + type: :object, + description: "A map with poll limits for local polls", + properties: %{ + max_options: %Schema{ + type: :integer, + description: "Maximum number of options." + }, + max_option_chars: %Schema{ + type: :integer, + description: "Maximum number of characters per option." + }, + min_expiration: %Schema{ + type: :integer, + description: "Minimum expiration time (in seconds)." + }, + max_expiration: %Schema{ + type: :integer, + description: "Maximum expiration time (in seconds)." + } + } + }, + upload_limit: %Schema{ + type: :integer, + description: "File size limit of uploads (except for avatar, background, banner)" + }, + avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + background_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + banner_upload_limit: %Schema{type: :integer, description: "The title of the website"} + }, + example: %{ + "avatar_upload_limit" => 2_000_000, + "background_upload_limit" => 4_000_000, + "banner_upload_limit" => 4_000_000, + "description" => "A Pleroma instance, an alternative fediverse server", + "email" => "lain@lain.com", + "languages" => ["en"], + "max_toot_chars" => 5000, + "poll_limits" => %{ + "max_expiration" => 31_536_000, + "max_option_chars" => 200, + "max_options" => 20, + "min_expiration" => 0 + }, + "registrations" => false, + "stats" => %{ + "domain_count" => 2996, + "status_count" => 15_802, + "user_count" => 5 + }, + "thumbnail" => "https://lain.com/instance/thumbnail.jpeg", + "title" => "lain.com", + "upload_limit" => 16_000_000, + "uri" => "https://lain.com", + "urls" => %{ + "streaming_api" => "wss://lain.com" + }, + "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)" + } + } + end + + defp array_of_domains do + %Schema{ + type: :array, + items: %Schema{type: :string}, + example: ["pleroma.site", "lain.com", "bikeshed.party"] + } + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 237f85677..d8859731d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -5,12 +5,16 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do use Pleroma.Web, :controller + plug(OpenApiSpex.Plug.CastAndValidate) + plug( :skip_plug, [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] when action in [:show, :peers] ) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation + @doc "GET /api/v1/instance" def show(conn, _params) do render(conn, "show.json") diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 2c7fd9fd0..90840d5ab 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do test "get instance information", %{conn: conn} do conn = get(conn, "/api/v1/instance") - assert result = json_response(conn, 200) + assert result = json_response_and_validate_schema(conn, 200) email = Pleroma.Config.get([:instance, :email]) # Note: not checking for "max_toot_chars" since it's optional @@ -56,7 +56,7 @@ test "get instance stats", %{conn: conn} do conn = get(conn, "/api/v1/instance") - assert result = json_response(conn, 200) + assert result = json_response_and_validate_schema(conn, 200) stats = result["stats"] @@ -74,7 +74,7 @@ test "get peers", %{conn: conn} do conn = get(conn, "/api/v1/instance/peers") - assert result = json_response(conn, 200) + assert result = json_response_and_validate_schema(conn, 200) assert ["peer1.com", "peer2.com"] == Enum.sort(result) end -- cgit v1.2.3 From b5189d2c50929aa67293e2e39ca020bad43f5f8b Mon Sep 17 00:00:00 2001 From: minibikini Date: Thu, 30 Apr 2020 17:45:48 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/instance_operation.ex --- lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 36a1a9043..9407fa74d 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -26,7 +26,7 @@ def show_operation do def peers_operation do %Operation{ tags: ["Instance"], - summary: "List of connected domains", + summary: "List of known hosts", operationId: "InstanceController.peers", responses: %{ 200 => Operation.response("Array of domains", "application/json", array_of_domains()) -- cgit v1.2.3 From 3817f179d777058259324d2e300780da06cce460 Mon Sep 17 00:00:00 2001 From: minibikini Date: Fri, 1 May 2020 12:46:53 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/instance_operation.ex --- lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 9407fa74d..5644cb54d 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -46,7 +46,7 @@ defp instance do }, version: %Schema{ type: :string, - description: "The version of Mastodon installed on the instance" + description: "The version of Pleroma installed on the instance" }, email: %Schema{ type: :string, -- cgit v1.2.3 From 42a4a863f159b863ec4617fc47697e11f92ff956 Mon Sep 17 00:00:00 2001 From: minibikini Date: Fri, 1 May 2020 12:46:56 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/instance_operation.ex --- lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 5644cb54d..880bd3f1b 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -42,7 +42,7 @@ defp instance do title: %Schema{type: :string, description: "The title of the website"}, description: %Schema{ type: :string, - description: "Admin-defined description of the Mastodon site" + description: "Admin-defined description of the Pleroma site" }, version: %Schema{ type: :string, -- cgit v1.2.3 From ec1e4b4f1acb81fc36b396e7f58f67928dc6a0df Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 17:40:00 +0400 Subject: Add OpenAPI spec for FollowRequestController --- .../operations/follow_request_operation.ex | 65 ++++++++++++++++++++++ .../controllers/follow_request_controller.ex | 5 +- .../controllers/follow_request_controller_test.exs | 6 +- 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/follow_request_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex new file mode 100644 index 000000000..ac4aee6da --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Follow Requests"], + summary: "Pending Follows", + security: [%{"oAuth" => ["read:follows", "follow"]}], + operationId: "FollowRequestController.index", + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account, + example: [Account.schema().example] + }) + } + } + end + + def authorize_operation do + %Operation{ + tags: ["Follow Requests"], + summary: "Accept Follow", + operationId: "FollowRequestController.authorize", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + def reject_operation do + %Operation{ + tags: ["Follow Requests"], + summary: "Reject Follow", + operationId: "FollowRequestController.reject", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index 25f2269b9..748b6b475 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do alias Pleroma.Web.CommonAPI plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:assign_follower when action != :index) action_fallback(:errors) @@ -21,6 +22,8 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do %{scopes: ["follow", "write:follows"]} when action != :index ) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation + @doc "GET /api/v1/follow_requests" def index(%{assigns: %{user: followed}} = conn, _params) do follow_requests = User.get_follow_requests(followed) @@ -42,7 +45,7 @@ def reject(%{assigns: %{user: followed, follower: follower}} = conn, _params) do end end - defp assign_follower(%{params: %{"id" => id}} = conn, _) do + defp assign_follower(%{params: %{id: id}} = conn, _) do case User.get_cached_by_id(id) do %User{} = follower -> assign(conn, :follower, follower) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs index d8dbe4800..44e12d15a 100644 --- a/test/web/mastodon_api/controllers/follow_request_controller_test.exs +++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs @@ -27,7 +27,7 @@ test "/api/v1/follow_requests works", %{user: user, conn: conn} do conn = get(conn, "/api/v1/follow_requests") - assert [relationship] = json_response(conn, 200) + assert [relationship] = json_response_and_validate_schema(conn, 200) assert to_string(other_user.id) == relationship["id"] end @@ -44,7 +44,7 @@ test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/authorize") - assert relationship = json_response(conn, 200) + assert relationship = json_response_and_validate_schema(conn, 200) assert to_string(other_user.id) == relationship["id"] user = User.get_cached_by_id(user.id) @@ -62,7 +62,7 @@ test "/api/v1/follow_requests/:id/reject works", %{user: user, conn: conn} do conn = post(conn, "/api/v1/follow_requests/#{other_user.id}/reject") - assert relationship = json_response(conn, 200) + assert relationship = json_response_and_validate_schema(conn, 200) assert to_string(other_user.id) == relationship["id"] user = User.get_cached_by_id(user.id) -- cgit v1.2.3 From 7e7a3e15449792581412be002f287c504e3449a6 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 14 Apr 2020 18:36:32 +0400 Subject: Add OpenAPI spec for FilterController --- lib/pleroma/filter.ex | 9 +-- .../web/api_spec/operations/filter_operation.ex | 89 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/filter.ex | 51 +++++++++++++ .../web/api_spec/schemas/filter_create_request.ex | 30 ++++++++ .../web/api_spec/schemas/filter_update_request.ex | 41 ++++++++++ .../web/api_spec/schemas/filters_response.ex | 40 ++++++++++ .../mastodon_api/controllers/filter_controller.ex | 56 +++++++------- lib/pleroma/web/mastodon_api/views/filter_view.ex | 6 +- test/filter_test.exs | 10 +-- .../controllers/filter_controller_test.exs | 55 +++++++++++-- 10 files changed, 341 insertions(+), 46 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/filter_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/filter.ex create mode 100644 lib/pleroma/web/api_spec/schemas/filter_create_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/filter_update_request.ex create mode 100644 lib/pleroma/web/api_spec/schemas/filters_response.ex diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 7cb49360f..4d61b3650 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -89,11 +89,10 @@ def delete(%Pleroma.Filter{id: filter_key} = filter) when is_nil(filter_key) do |> Repo.delete() end - def update(%Pleroma.Filter{} = filter) do - destination = Map.from_struct(filter) - - Pleroma.Filter.get(filter.filter_id, %{id: filter.user_id}) - |> cast(destination, [:phrase, :context, :hide, :expires_at, :whole_word]) + def update(%Pleroma.Filter{} = filter, params) do + filter + |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word]) + |> validate_required([:phrase, :context]) |> Repo.update() end end diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex new file mode 100644 index 000000000..0d673f566 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -0,0 +1,89 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.FilterOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.Filter + alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse + alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["apps"], + summary: "View all filters", + operationId: "FilterController.index", + security: [%{"oAuth" => ["read:filters"]}], + responses: %{ + 200 => Operation.response("Filters", "application/json", FiltersResponse) + } + } + end + + def create_operation do + %Operation{ + tags: ["apps"], + summary: "Create a filter", + operationId: "FilterController.create", + requestBody: Helpers.request_body("Parameters", FilterCreateRequest, required: true), + security: [%{"oAuth" => ["write:filters"]}], + responses: %{200 => Operation.response("Filter", "application/json", Filter)} + } + end + + def show_operation do + %Operation{ + tags: ["apps"], + summary: "View all filters", + parameters: [id_param()], + operationId: "FilterController.show", + security: [%{"oAuth" => ["read:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", Filter) + } + } + end + + def update_operation do + %Operation{ + tags: ["apps"], + summary: "Update a filter", + parameters: [id_param()], + operationId: "FilterController.update", + requestBody: Helpers.request_body("Parameters", FilterUpdateRequest, required: true), + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => Operation.response("Filter", "application/json", Filter) + } + } + end + + def delete_operation do + %Operation{ + tags: ["apps"], + summary: "Remove a filter", + parameters: [id_param()], + operationId: "FilterController.delete", + security: [%{"oAuth" => ["write:filters"]}], + responses: %{ + 200 => + Operation.response("Filter", "application/json", %Schema{ + type: :object, + description: "Empty object" + }) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/filter.ex b/lib/pleroma/web/api_spec/schemas/filter.ex new file mode 100644 index 000000000..fc5480b71 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/filter.ex @@ -0,0 +1,51 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Filter do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Filter", + type: :object, + properties: %{ + id: %Schema{type: :string}, + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: "The contexts in which the filter should be applied." + }, + expires_at: %Schema{ + type: :string, + format: :"date-time", + description: + "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.", + nullable: true + }, + irreversible: %Schema{ + type: :boolean, + description: + "Should matching entities in home and notifications be dropped by the server?" + }, + whole_word: %Schema{ + type: :boolean, + description: "Should the filter consider word boundaries?" + } + }, + example: %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/filter_create_request.ex b/lib/pleroma/web/api_spec/schemas/filter_create_request.ex new file mode 100644 index 000000000..f2a475b12 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/filter_create_request.ex @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "FilterCreateRequest", + allOf: [ + %OpenApiSpex.Reference{"$ref": "#/components/schemas/FilterUpdateRequest"}, + %Schema{ + type: :object, + properties: %{ + irreversible: %Schema{ + type: :bolean, + description: + "Should the server irreversibly drop matching entities from home and notifications?", + default: false + } + } + } + ], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/filter_update_request.ex b/lib/pleroma/web/api_spec/schemas/filter_update_request.ex new file mode 100644 index 000000000..e703db0ce --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/filter_update_request.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "FilterUpdateRequest", + type: :object, + properties: %{ + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: + "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." + }, + irreversible: %Schema{ + type: :bolean, + description: + "Should the server irreversibly drop matching entities from home and notifications?" + }, + whole_word: %Schema{type: :bolean, description: "Consider word boundaries?", default: true} + # TODO: probably should implement filter expiration + # expires_in: %Schema{ + # type: :string, + # format: :"date-time", + # description: + # "ISO 8601 Datetime for when the filter expires. Otherwise, + # null for a filter that doesn't expire." + # } + }, + required: [:phrase, :context], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/filters_response.ex b/lib/pleroma/web/api_spec/schemas/filters_response.ex new file mode 100644 index 000000000..8c56c5982 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/filters_response.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.FiltersResponse do + require OpenApiSpex + alias Pleroma.Web.ApiSpec.Schemas.Filter + + OpenApiSpex.schema(%{ + title: "FiltersResponse", + description: "Array of Filters", + type: :array, + items: Filter, + example: [ + %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + }, + %{ + "id" => "6191", + "phrase" => ":eurovision2019:", + "context" => [ + "home" + ], + "whole_word" => true, + "expires_at" => "2019-05-21T13:47:31.333Z", + "irreversible" => false + } + ] + }) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index 7fd0562c9..dd13a8a09 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -10,67 +10,69 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do @oauth_read_actions [:show, :index] + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions) plug( OAuthScopesPlug, %{scopes: ["write:filters"]} when action not in @oauth_read_actions ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation @doc "GET /api/v1/filters" def index(%{assigns: %{user: user}} = conn, _) do filters = Filter.get_filters(user) - render(conn, "filters.json", filters: filters) + render(conn, "index.json", filters: filters) end @doc "POST /api/v1/filters" - def create( - %{assigns: %{user: user}} = conn, - %{"phrase" => phrase, "context" => context} = params - ) do + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do query = %Filter{ user_id: user.id, - phrase: phrase, - context: context, - hide: Map.get(params, "irreversible", false), - whole_word: Map.get(params, "boolean", true) + phrase: params.phrase, + context: params.context, + hide: params.irreversible, + whole_word: params.whole_word # expires_at } {:ok, response} = Filter.create(query) - render(conn, "filter.json", filter: response) + render(conn, "show.json", filter: response) end @doc "GET /api/v1/filters/:id" - def show(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + def show(%{assigns: %{user: user}} = conn, %{id: filter_id}) do filter = Filter.get(filter_id, user) - render(conn, "filter.json", filter: filter) + render(conn, "show.json", filter: filter) end @doc "PUT /api/v1/filters/:id" def update( - %{assigns: %{user: user}} = conn, - %{"phrase" => phrase, "context" => context, "id" => filter_id} = params + %{assigns: %{user: user}, body_params: params} = conn, + %{id: filter_id} ) do - query = %Filter{ - user_id: user.id, - filter_id: filter_id, - phrase: phrase, - context: context, - hide: Map.get(params, "irreversible", nil), - whole_word: Map.get(params, "boolean", true) - # expires_at - } - - {:ok, response} = Filter.update(query) - render(conn, "filter.json", filter: response) + params = + params + |> Map.from_struct() + |> Map.delete(:irreversible) + |> Map.put(:hide, params.irreversible) + |> Enum.reject(fn {_key, value} -> is_nil(value) end) + |> Map.new() + + # TODO: add expires_in -> expires_at + + with %Filter{} = filter <- Filter.get(filter_id, user), + {:ok, %Filter{} = filter} <- Filter.update(filter, params) do + render(conn, "show.json", filter: filter) + end end @doc "DELETE /api/v1/filters/:id" - def delete(%{assigns: %{user: user}} = conn, %{"id" => filter_id}) do + def delete(%{assigns: %{user: user}} = conn, %{id: filter_id}) do query = %Filter{ user_id: user.id, filter_id: filter_id diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex index 97fd1e83f..8d5c381ec 100644 --- a/lib/pleroma/web/mastodon_api/views/filter_view.ex +++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex @@ -7,11 +7,11 @@ defmodule Pleroma.Web.MastodonAPI.FilterView do alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.FilterView - def render("filters.json", %{filters: filters} = opts) do - render_many(filters, FilterView, "filter.json", opts) + def render("index.json", %{filters: filters} = opts) do + render_many(filters, FilterView, "show.json", opts) end - def render("filter.json", %{filter: filter}) do + def render("show.json", %{filter: filter}) do expires_at = if filter.expires_at do Utils.to_masto_date(filter.expires_at) diff --git a/test/filter_test.exs b/test/filter_test.exs index b2a8330ee..63a30c736 100644 --- a/test/filter_test.exs +++ b/test/filter_test.exs @@ -141,17 +141,15 @@ test "updating a filter" do context: ["home"] } - query_two = %Pleroma.Filter{ - user_id: user.id, - filter_id: 1, + changes = %{ phrase: "who", context: ["home", "timeline"] } {:ok, filter_one} = Pleroma.Filter.create(query_one) - {:ok, filter_two} = Pleroma.Filter.update(query_two) + {:ok, filter_two} = Pleroma.Filter.update(filter_one, changes) assert filter_one != filter_two - assert filter_two.phrase == query_two.phrase - assert filter_two.context == query_two.context + assert filter_two.phrase == changes.phrase + assert filter_two.context == changes.context end end diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs index 97ab005e0..41a290eb2 100644 --- a/test/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/web/mastodon_api/controllers/filter_controller_test.exs @@ -5,8 +5,15 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do use Pleroma.Web.ConnCase + alias Pleroma.Web.ApiSpec + alias Pleroma.Web.ApiSpec.Schemas.Filter + alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest + alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse + alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest alias Pleroma.Web.MastodonAPI.FilterView + import OpenApiSpex.TestAssertions + test "creating a filter" do %{conn: conn} = oauth_access(["write:filters"]) @@ -15,7 +22,10 @@ test "creating a filter" do context: ["home"] } - conn = post(conn, "/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context}) assert response = json_response(conn, 200) assert response["phrase"] == filter.phrase @@ -23,6 +33,7 @@ test "creating a filter" do assert response["irreversible"] == false assert response["id"] != nil assert response["id"] != "" + assert_schema(response, "Filter", ApiSpec.spec()) end test "fetching a list of filters" do @@ -53,9 +64,11 @@ test "fetching a list of filters" do assert response == render_json( FilterView, - "filters.json", + "index.json", filters: [filter_two, filter_one] ) + + assert_schema(response, "FiltersResponse", ApiSpec.spec()) end test "get a filter" do @@ -72,7 +85,8 @@ test "get a filter" do conn = get(conn, "/api/v1/filters/#{filter.filter_id}") - assert _response = json_response(conn, 200) + assert response = json_response(conn, 200) + assert_schema(response, "Filter", ApiSpec.spec()) end test "update a filter" do @@ -82,7 +96,8 @@ test "update a filter" do user_id: user.id, filter_id: 2, phrase: "knight", - context: ["home"] + context: ["home"], + hide: true } {:ok, _filter} = Pleroma.Filter.create(query) @@ -93,7 +108,9 @@ test "update a filter" do } conn = - put(conn, "/api/v1/filters/#{query.filter_id}", %{ + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/filters/#{query.filter_id}", %{ phrase: new.phrase, context: new.context }) @@ -101,6 +118,8 @@ test "update a filter" do assert response = json_response(conn, 200) assert response["phrase"] == new.phrase assert response["context"] == new.context + assert response["irreversible"] == true + assert_schema(response, "Filter", ApiSpec.spec()) end test "delete a filter" do @@ -120,4 +139,30 @@ test "delete a filter" do assert response = json_response(conn, 200) assert response == %{} end + + describe "OpenAPI" do + test "Filter example matches schema" do + api_spec = ApiSpec.spec() + schema = Filter.schema() + assert_schema(schema.example, "Filter", api_spec) + end + + test "FiltersResponse example matches schema" do + api_spec = ApiSpec.spec() + schema = FiltersResponse.schema() + assert_schema(schema.example, "FiltersResponse", api_spec) + end + + test "FilterCreateRequest example matches schema" do + api_spec = ApiSpec.spec() + schema = FilterCreateRequest.schema() + assert_schema(schema.example, "FilterCreateRequest", api_spec) + end + + test "FilterUpdateRequest example matches schema" do + api_spec = ApiSpec.spec() + schema = FilterUpdateRequest.schema() + assert_schema(schema.example, "FilterUpdateRequest", api_spec) + end + end end -- cgit v1.2.3 From 46aae346f8530d4b9933b8e718e9578a96447f0a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 27 Apr 2020 23:54:11 +0400 Subject: Move single used schemas to Filter operation schema --- .../web/api_spec/operations/filter_operation.ex | 158 +++++++++++++++++++-- lib/pleroma/web/api_spec/schemas/filter.ex | 51 ------- .../web/api_spec/schemas/filter_create_request.ex | 30 ---- .../web/api_spec/schemas/filter_update_request.ex | 41 ------ .../web/api_spec/schemas/filters_response.ex | 40 ------ .../mastodon_api/controllers/filter_controller.ex | 7 +- lib/pleroma/web/mastodon_api/views/filter_view.ex | 4 +- .../controllers/filter_controller_test.exs | 49 +------ 8 files changed, 158 insertions(+), 222 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/schemas/filter.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/filter_create_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/filter_update_request.ex delete mode 100644 lib/pleroma/web/api_spec/schemas/filters_response.ex diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex index 0d673f566..53e57b46b 100644 --- a/lib/pleroma/web/api_spec/operations/filter_operation.ex +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -6,10 +6,6 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers - alias Pleroma.Web.ApiSpec.Schemas.Filter - alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse - alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -23,7 +19,7 @@ def index_operation do operationId: "FilterController.index", security: [%{"oAuth" => ["read:filters"]}], responses: %{ - 200 => Operation.response("Filters", "application/json", FiltersResponse) + 200 => Operation.response("Filters", "application/json", array_of_filters()) } } end @@ -33,9 +29,9 @@ def create_operation do tags: ["apps"], summary: "Create a filter", operationId: "FilterController.create", - requestBody: Helpers.request_body("Parameters", FilterCreateRequest, required: true), + requestBody: Helpers.request_body("Parameters", create_request(), required: true), security: [%{"oAuth" => ["write:filters"]}], - responses: %{200 => Operation.response("Filter", "application/json", Filter)} + responses: %{200 => Operation.response("Filter", "application/json", filter())} } end @@ -47,7 +43,7 @@ def show_operation do operationId: "FilterController.show", security: [%{"oAuth" => ["read:filters"]}], responses: %{ - 200 => Operation.response("Filter", "application/json", Filter) + 200 => Operation.response("Filter", "application/json", filter()) } } end @@ -58,10 +54,10 @@ def update_operation do summary: "Update a filter", parameters: [id_param()], operationId: "FilterController.update", - requestBody: Helpers.request_body("Parameters", FilterUpdateRequest, required: true), + requestBody: Helpers.request_body("Parameters", update_request(), required: true), security: [%{"oAuth" => ["write:filters"]}], responses: %{ - 200 => Operation.response("Filter", "application/json", Filter) + 200 => Operation.response("Filter", "application/json", filter()) } } end @@ -86,4 +82,146 @@ def delete_operation do defp id_param do Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true) end + + defp filter do + %Schema{ + title: "Filter", + type: :object, + properties: %{ + id: %Schema{type: :string}, + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: "The contexts in which the filter should be applied." + }, + expires_at: %Schema{ + type: :string, + format: :"date-time", + description: + "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.", + nullable: true + }, + irreversible: %Schema{ + type: :boolean, + description: + "Should matching entities in home and notifications be dropped by the server?" + }, + whole_word: %Schema{ + type: :boolean, + description: "Should the filter consider word boundaries?" + } + }, + example: %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + } + } + end + + defp array_of_filters do + %Schema{ + title: "ArrayOfFilters", + description: "Array of Filters", + type: :array, + items: filter(), + example: [ + %{ + "id" => "5580", + "phrase" => "@twitter.com", + "context" => [ + "home", + "notifications", + "public", + "thread" + ], + "whole_word" => false, + "expires_at" => nil, + "irreversible" => true + }, + %{ + "id" => "6191", + "phrase" => ":eurovision2019:", + "context" => [ + "home" + ], + "whole_word" => true, + "expires_at" => "2019-05-21T13:47:31.333Z", + "irreversible" => false + } + ] + } + end + + defp create_request do + %Schema{ + title: "FilterCreateRequest", + allOf: [ + update_request(), + %Schema{ + type: :object, + properties: %{ + irreversible: %Schema{ + type: :bolean, + description: + "Should the server irreversibly drop matching entities from home and notifications?", + default: false + } + } + } + ], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + } + end + + defp update_request do + %Schema{ + title: "FilterUpdateRequest", + type: :object, + properties: %{ + phrase: %Schema{type: :string, description: "The text to be filtered"}, + context: %Schema{ + type: :array, + items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, + description: + "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." + }, + irreversible: %Schema{ + type: :bolean, + description: + "Should the server irreversibly drop matching entities from home and notifications?" + }, + whole_word: %Schema{ + type: :bolean, + description: "Consider word boundaries?", + default: true + } + # TODO: probably should implement filter expiration + # expires_in: %Schema{ + # type: :string, + # format: :"date-time", + # description: + # "ISO 8601 Datetime for when the filter expires. Otherwise, + # null for a filter that doesn't expire." + # } + }, + required: [:phrase, :context], + example: %{ + "phrase" => "knights", + "context" => ["home"] + } + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/filter.ex b/lib/pleroma/web/api_spec/schemas/filter.ex deleted file mode 100644 index fc5480b71..000000000 --- a/lib/pleroma/web/api_spec/schemas/filter.ex +++ /dev/null @@ -1,51 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.Filter do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "Filter", - type: :object, - properties: %{ - id: %Schema{type: :string}, - phrase: %Schema{type: :string, description: "The text to be filtered"}, - context: %Schema{ - type: :array, - items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, - description: "The contexts in which the filter should be applied." - }, - expires_at: %Schema{ - type: :string, - format: :"date-time", - description: - "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.", - nullable: true - }, - irreversible: %Schema{ - type: :boolean, - description: - "Should matching entities in home and notifications be dropped by the server?" - }, - whole_word: %Schema{ - type: :boolean, - description: "Should the filter consider word boundaries?" - } - }, - example: %{ - "id" => "5580", - "phrase" => "@twitter.com", - "context" => [ - "home", - "notifications", - "public", - "thread" - ], - "whole_word" => false, - "expires_at" => nil, - "irreversible" => true - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/filter_create_request.ex b/lib/pleroma/web/api_spec/schemas/filter_create_request.ex deleted file mode 100644 index f2a475b12..000000000 --- a/lib/pleroma/web/api_spec/schemas/filter_create_request.ex +++ /dev/null @@ -1,30 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "FilterCreateRequest", - allOf: [ - %OpenApiSpex.Reference{"$ref": "#/components/schemas/FilterUpdateRequest"}, - %Schema{ - type: :object, - properties: %{ - irreversible: %Schema{ - type: :bolean, - description: - "Should the server irreversibly drop matching entities from home and notifications?", - default: false - } - } - } - ], - example: %{ - "phrase" => "knights", - "context" => ["home"] - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/filter_update_request.ex b/lib/pleroma/web/api_spec/schemas/filter_update_request.ex deleted file mode 100644 index e703db0ce..000000000 --- a/lib/pleroma/web/api_spec/schemas/filter_update_request.ex +++ /dev/null @@ -1,41 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest do - alias OpenApiSpex.Schema - require OpenApiSpex - - OpenApiSpex.schema(%{ - title: "FilterUpdateRequest", - type: :object, - properties: %{ - phrase: %Schema{type: :string, description: "The text to be filtered"}, - context: %Schema{ - type: :array, - items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]}, - description: - "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." - }, - irreversible: %Schema{ - type: :bolean, - description: - "Should the server irreversibly drop matching entities from home and notifications?" - }, - whole_word: %Schema{type: :bolean, description: "Consider word boundaries?", default: true} - # TODO: probably should implement filter expiration - # expires_in: %Schema{ - # type: :string, - # format: :"date-time", - # description: - # "ISO 8601 Datetime for when the filter expires. Otherwise, - # null for a filter that doesn't expire." - # } - }, - required: [:phrase, :context], - example: %{ - "phrase" => "knights", - "context" => ["home"] - } - }) -end diff --git a/lib/pleroma/web/api_spec/schemas/filters_response.ex b/lib/pleroma/web/api_spec/schemas/filters_response.ex deleted file mode 100644 index 8c56c5982..000000000 --- a/lib/pleroma/web/api_spec/schemas/filters_response.ex +++ /dev/null @@ -1,40 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Schemas.FiltersResponse do - require OpenApiSpex - alias Pleroma.Web.ApiSpec.Schemas.Filter - - OpenApiSpex.schema(%{ - title: "FiltersResponse", - description: "Array of Filters", - type: :array, - items: Filter, - example: [ - %{ - "id" => "5580", - "phrase" => "@twitter.com", - "context" => [ - "home", - "notifications", - "public", - "thread" - ], - "whole_word" => false, - "expires_at" => nil, - "irreversible" => true - }, - %{ - "id" => "6191", - "phrase" => ":eurovision2019:", - "context" => [ - "home" - ], - "whole_word" => true, - "expires_at" => "2019-05-21T13:47:31.333Z", - "irreversible" => false - } - ] - }) -end diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index dd13a8a09..21dc374cd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -35,7 +35,7 @@ def create(%{assigns: %{user: user}, body_params: params} = conn, _) do context: params.context, hide: params.irreversible, whole_word: params.whole_word - # expires_at + # TODO: support `expires_in` parameter (as in Mastodon API) } {:ok, response} = Filter.create(query) @@ -57,13 +57,12 @@ def update( ) do params = params - |> Map.from_struct() |> Map.delete(:irreversible) - |> Map.put(:hide, params.irreversible) + |> Map.put(:hide, params[:irreversible]) |> Enum.reject(fn {_key, value} -> is_nil(value) end) |> Map.new() - # TODO: add expires_in -> expires_at + # TODO: support `expires_in` parameter (as in Mastodon API) with %Filter{} = filter <- Filter.get(filter_id, user), {:ok, %Filter{} = filter} <- Filter.update(filter, params) do diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex index 8d5c381ec..aeff646f5 100644 --- a/lib/pleroma/web/mastodon_api/views/filter_view.ex +++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex @@ -7,8 +7,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterView do alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.FilterView - def render("index.json", %{filters: filters} = opts) do - render_many(filters, FilterView, "show.json", opts) + def render("index.json", %{filters: filters}) do + render_many(filters, FilterView, "show.json") end def render("show.json", %{filter: filter}) do diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs index 41a290eb2..f29547d13 100644 --- a/test/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/web/mastodon_api/controllers/filter_controller_test.exs @@ -5,15 +5,8 @@ defmodule Pleroma.Web.MastodonAPI.FilterControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Web.ApiSpec - alias Pleroma.Web.ApiSpec.Schemas.Filter - alias Pleroma.Web.ApiSpec.Schemas.FilterCreateRequest - alias Pleroma.Web.ApiSpec.Schemas.FiltersResponse - alias Pleroma.Web.ApiSpec.Schemas.FilterUpdateRequest alias Pleroma.Web.MastodonAPI.FilterView - import OpenApiSpex.TestAssertions - test "creating a filter" do %{conn: conn} = oauth_access(["write:filters"]) @@ -27,13 +20,12 @@ test "creating a filter" do |> put_req_header("content-type", "application/json") |> post("/api/v1/filters", %{"phrase" => filter.phrase, context: filter.context}) - assert response = json_response(conn, 200) + assert response = json_response_and_validate_schema(conn, 200) assert response["phrase"] == filter.phrase assert response["context"] == filter.context assert response["irreversible"] == false assert response["id"] != nil assert response["id"] != "" - assert_schema(response, "Filter", ApiSpec.spec()) end test "fetching a list of filters" do @@ -59,7 +51,7 @@ test "fetching a list of filters" do response = conn |> get("/api/v1/filters") - |> json_response(200) + |> json_response_and_validate_schema(200) assert response == render_json( @@ -67,8 +59,6 @@ test "fetching a list of filters" do "index.json", filters: [filter_two, filter_one] ) - - assert_schema(response, "FiltersResponse", ApiSpec.spec()) end test "get a filter" do @@ -85,8 +75,7 @@ test "get a filter" do conn = get(conn, "/api/v1/filters/#{filter.filter_id}") - assert response = json_response(conn, 200) - assert_schema(response, "Filter", ApiSpec.spec()) + assert response = json_response_and_validate_schema(conn, 200) end test "update a filter" do @@ -115,11 +104,10 @@ test "update a filter" do context: new.context }) - assert response = json_response(conn, 200) + assert response = json_response_and_validate_schema(conn, 200) assert response["phrase"] == new.phrase assert response["context"] == new.context assert response["irreversible"] == true - assert_schema(response, "Filter", ApiSpec.spec()) end test "delete a filter" do @@ -136,33 +124,6 @@ test "delete a filter" do conn = delete(conn, "/api/v1/filters/#{filter.filter_id}") - assert response = json_response(conn, 200) - assert response == %{} - end - - describe "OpenAPI" do - test "Filter example matches schema" do - api_spec = ApiSpec.spec() - schema = Filter.schema() - assert_schema(schema.example, "Filter", api_spec) - end - - test "FiltersResponse example matches schema" do - api_spec = ApiSpec.spec() - schema = FiltersResponse.schema() - assert_schema(schema.example, "FiltersResponse", api_spec) - end - - test "FilterCreateRequest example matches schema" do - api_spec = ApiSpec.spec() - schema = FilterCreateRequest.schema() - assert_schema(schema.example, "FilterCreateRequest", api_spec) - end - - test "FilterUpdateRequest example matches schema" do - api_spec = ApiSpec.spec() - schema = FilterUpdateRequest.schema() - assert_schema(schema.example, "FilterUpdateRequest", api_spec) - end + assert json_response_and_validate_schema(conn, 200) == %{} end end -- cgit v1.2.3 From 32ca9f2c59369c15905f665bee3c759ae963ff91 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 28 Apr 2020 16:25:13 +0400 Subject: Render mastodon-like errors in FilterController --- lib/pleroma/web/mastodon_api/controllers/filter_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index 21dc374cd..abbf0ce02 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do OAuthScopesPlug, %{scopes: ["write:filters"]} when action not in @oauth_read_actions ) - + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FilterOperation @doc "GET /api/v1/filters" -- cgit v1.2.3 From 3a45952a3a324e5fb823e9bdf3ffe19fb3923cb3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 17:44:46 +0400 Subject: Add OpenAPI spec for ConversationController --- lib/pleroma/conversation/participation.ex | 4 +- .../api_spec/operations/conversation_operation.ex | 61 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/conversation.ex | 41 +++++++++++++++ lib/pleroma/web/api_spec/schemas/status.ex | 7 ++- .../controllers/conversation_controller.ex | 5 +- .../controllers/conversation_controller_test.exs | 22 ++++---- 6 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/conversation_operation.ex create mode 100644 lib/pleroma/web/api_spec/schemas/conversation.ex diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index 215265fc9..51bb1bda9 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -128,7 +128,7 @@ def for_user(user, params \\ %{}) do |> Pleroma.Pagination.fetch_paginated(params) end - def restrict_recipients(query, user, %{"recipients" => user_ids}) do + def restrict_recipients(query, user, %{recipients: user_ids}) do user_binary_ids = [user.id | user_ids] |> Enum.uniq() @@ -172,7 +172,7 @@ def for_user_with_last_activity_id(user, params \\ %{}) do | last_activity_id: activity_id } end) - |> Enum.filter(& &1.last_activity_id) + |> Enum.reject(&is_nil(&1.last_activity_id)) end def get(_, _ \\ []) diff --git a/lib/pleroma/web/api_spec/operations/conversation_operation.ex b/lib/pleroma/web/api_spec/operations/conversation_operation.ex new file mode 100644 index 000000000..475468893 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/conversation_operation.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ConversationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Conversations"], + summary: "Show conversation", + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "ConversationController.index", + parameters: [ + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "Only return conversations with the given recipients (a list of user ids)" + ) + | pagination_params() + ], + responses: %{ + 200 => + Operation.response("Array of Conversation", "application/json", %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + }) + } + } + end + + def mark_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Mark as read", + operationId: "ConversationController.mark_as_read", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + ], + security: [%{"oAuth" => ["write:conversations"]}], + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/conversation.ex b/lib/pleroma/web/api_spec/schemas/conversation.ex new file mode 100644 index 000000000..d8ff5ba26 --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/conversation.ex @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Conversation do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.Status + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Conversation", + description: "Represents a conversation with \"direct message\" visibility.", + type: :object, + required: [:id, :accounts, :unread], + properties: %{ + id: %Schema{type: :string}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Participants in the conversation" + }, + unread: %Schema{ + type: :boolean, + description: "Is the conversation currently marked as unread?" + }, + # last_status: Status + last_status: %Schema{ + allOf: [Status], + description: "The last status in the conversation, to be used for optional display" + } + }, + example: %{ + "id" => "418450", + "unread" => true, + "accounts" => [Account.schema().example], + "last_status" => Status.schema().example + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index aef0588d4..42e9dae19 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -86,7 +86,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do properties: %{ content: %Schema{type: :object, additionalProperties: %Schema{type: :string}}, conversation_id: %Schema{type: :integer}, - direct_conversation_id: %Schema{type: :string, nullable: true}, + direct_conversation_id: %Schema{ + type: :integer, + nullable: true, + description: + "The ID of the Mastodon direct message conversation the status is associated with (if any)" + }, emoji_reactions: %Schema{ type: :array, items: %Schema{ diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index c44641526..f35ec3596 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -13,9 +13,12 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index) plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action != :index) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ConversationOperation + @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do participations = Participation.for_user_with_last_activity_id(user, params) @@ -26,7 +29,7 @@ def index(%{assigns: %{user: user}} = conn, params) do end @doc "POST /api/v1/conversations/:id/read" - def mark_as_read(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do + def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do with %Participation{} = participation <- Repo.get_by(Participation, id: participation_id, user_id: user.id), {:ok, participation} <- Participation.mark_as_read(participation) do diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs index 801b0259b..04695572e 100644 --- a/test/web/mastodon_api/controllers/conversation_controller_test.exs +++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs @@ -36,7 +36,7 @@ test "returns a list of conversations", %{user: user_one, conn: conn} do res_conn = get(conn, "/api/v1/conversations") - assert response = json_response(res_conn, 200) + assert response = json_response_and_validate_schema(res_conn, 200) assert [ %{ @@ -91,18 +91,18 @@ test "filters conversations by recipients", %{user: user_one, conn: conn} do "visibility" => "direct" }) - [conversation1, conversation2] = - conn - |> get("/api/v1/conversations", %{"recipients" => [user_two.id]}) - |> json_response(200) + assert [conversation1, conversation2] = + conn + |> get("/api/v1/conversations?recipients[]=#{user_two.id}") + |> json_response_and_validate_schema(200) assert conversation1["last_status"]["id"] == direct5.id assert conversation2["last_status"]["id"] == direct1.id [conversation1] = conn - |> get("/api/v1/conversations", %{"recipients" => [user_two.id, user_three.id]}) - |> json_response(200) + |> get("/api/v1/conversations?recipients[]=#{user_two.id}&recipients[]=#{user_three.id}") + |> json_response_and_validate_schema(200) assert conversation1["last_status"]["id"] == direct3.id end @@ -126,7 +126,7 @@ test "updates the last_status on reply", %{user: user_one, conn: conn} do [%{"last_status" => res_last_status}] = conn |> get("/api/v1/conversations") - |> json_response(200) + |> json_response_and_validate_schema(200) assert res_last_status["id"] == direct_reply.id end @@ -154,12 +154,12 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do [%{"id" => direct_conversation_id, "unread" => true}] = user_two_conn |> get("/api/v1/conversations") - |> json_response(200) + |> json_response_and_validate_schema(200) %{"unread" => false} = user_two_conn |> post("/api/v1/conversations/#{direct_conversation_id}/read") - |> json_response(200) + |> json_response_and_validate_schema(200) assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 @@ -175,7 +175,7 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do [%{"unread" => true}] = conn |> get("/api/v1/conversations") - |> json_response(200) + |> json_response_and_validate_schema(200) assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1 assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 -- cgit v1.2.3 From b34debe61540cf845ccf4ac93066e45a1d9c8f85 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 16:17:09 +0200 Subject: Undoing: Move undoing reactions to the pipeline everywhere. --- lib/pleroma/web/activity_pub/activity_pub.ex | 25 ---------- lib/pleroma/web/activity_pub/side_effects.ex | 8 ++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 27 ++--------- lib/pleroma/web/common_api/common_api.ex | 8 ++-- .../controllers/pleroma_api_controller.ex | 3 +- test/web/activity_pub/activity_pub_test.exs | 56 ---------------------- test/web/activity_pub/side_effects_test.exs | 30 +++++++++++- test/web/common_api/common_api_test.exs | 3 +- .../controllers/pleroma_api_controller_test.exs | 10 +++- 9 files changed, 57 insertions(+), 113 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index daad4d751..c94af3b5f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -373,31 +373,6 @@ defp do_react_with_emoji(user, object, emoji, options) do end end - @spec unreact_with_emoji(User.t(), String.t(), keyword()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def unreact_with_emoji(user, reaction_id, options \\ []) do - with {:ok, result} <- - Repo.transaction(fn -> do_unreact_with_emoji(user, reaction_id, options) end) do - result - end - end - - defp do_unreact_with_emoji(user, reaction_id, options) do - with local <- Keyword.get(options, :local, true), - activity_id <- Keyword.get(options, :activity_id, nil), - user_ap_id <- user.ap_id, - %Activity{actor: ^user_ap_id} = reaction_activity <- Activity.get_by_ap_id(reaction_id), - object <- Object.normalize(reaction_activity), - unreact_data <- make_undo_data(user, reaction_activity, activity_id), - {:ok, activity} <- insert(unreact_data, local), - {:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - {:error, error} -> Repo.rollback(error) - end - end - @spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} def announce( diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 8ed91e257..d58df9394 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -45,5 +45,13 @@ def handle_undoing(%{data: %{"type" => "Like"}} = object) do end end + def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do + with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + def handle_undoing(object), do: {:error, ["don't know how to handle", object]} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index a60b27bea..94849b5f5 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -806,28 +806,6 @@ def handle_incoming( end end - def handle_incoming( - %{ - "type" => "Undo", - "object" => %{"type" => "EmojiReact", "id" => reaction_activity_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, activity, _} <- - ActivityPub.unreact_with_emoji(actor, reaction_activity_id, - activity_id: id, - local: false - ) do - {:ok, activity} - else - _e -> :error - end - end - def handle_incoming( %{ "type" => "Undo", @@ -865,10 +843,11 @@ def handle_incoming( def handle_incoming( %{ "type" => "Undo", - "object" => %{"type" => "Like"} + "object" => %{"type" => type} } = data, _options - ) do + ) + when type in ["Like", "EmojiReact"] do with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index a670ea5bc..067ac875e 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -170,7 +170,7 @@ def unfavorite(id, user) do %Object{} = note <- Object.normalize(activity, false), %Activity{} = like <- Utils.get_existing_like(user.ap_id, note), {:ok, undo, _} <- Builder.undo(user, like), - {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: false) do + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do {:ok, activity} else {:find_activity, _} -> {:error, :not_found} @@ -189,8 +189,10 @@ def react_with_emoji(id, user, emoji) do end def unreact_with_emoji(id, user, emoji) do - with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji) do - ActivityPub.unreact_with_emoji(user, reaction_activity.data["id"]) + with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji), + {:ok, undo, _} <- Builder.undo(user, reaction_activity), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} else _ -> {:error, dgettext("errors", "Could not remove reaction emoji")} diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 1bdb3aa4d..4aa5c1dd8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -98,7 +98,8 @@ def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{ "id" => activity_id, "emoji" => emoji }) do - with {:ok, _activity, _object} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji), + with {:ok, _activity} <- + CommonAPI.unreact_with_emoji(activity_id, user, emoji), activity <- Activity.get_by_id(activity_id) do conn |> put_view(StatusView) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 797af66a0..cb2d41f0b 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -939,62 +939,6 @@ test "reverts emoji reaction on error" do end end - describe "unreacting to an object" do - test_with_mock "sends an activity to federation", Federator, [:passthrough], [] do - Config.put([:instance, :federating], true) - user = insert(:user) - reactor = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"}) - assert object = Object.normalize(activity) - - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥") - - assert called(Federator.publish(reaction_activity)) - - {:ok, unreaction_activity, _object} = - ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"]) - - assert called(Federator.publish(unreaction_activity)) - end - - test "adds an undo activity to the db" do - user = insert(:user) - reactor = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "YASSSS queen slay"}) - assert object = Object.normalize(activity) - - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "🔥") - - {:ok, unreaction_activity, _object} = - ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"]) - - assert unreaction_activity.actor == reactor.ap_id - assert unreaction_activity.data["object"] == reaction_activity.data["id"] - - object = Object.get_by_ap_id(object.data["id"]) - assert object.data["reaction_count"] == 0 - assert object.data["reactions"] == [] - end - - test "reverts emoji unreact on error" do - [user, reactor] = insert_list(2, :user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Status"}) - object = Object.normalize(activity) - - {:ok, reaction_activity, _object} = ActivityPub.react_with_emoji(reactor, object, "😀") - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = - ActivityPub.unreact_with_emoji(reactor, reaction_activity.data["id"]) - end - - object = Object.get_by_ap_id(object.data["id"]) - - assert object.data["reaction_count"] == 1 - assert object.data["reactions"] == [["😀", [reactor.ap_id]]] - end - end - describe "announcing an object" do test "adds an announce activity to the db" do note_activity = insert(:note_activity) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 61ef72742..abcfdfa2f 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -23,10 +23,38 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) {:ok, like} = CommonAPI.favorite(user, post.id) + {:ok, reaction, _} = CommonAPI.react_with_emoji(post.id, user, "👍") + {:ok, undo_data, _meta} = Builder.undo(user, like) {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true) - %{like_undo: like_undo, post: post, like: like} + {:ok, undo_data, _meta} = Builder.undo(user, reaction) + {:ok, reaction_undo, _meta} = ActivityPub.persist(undo_data, local: true) + + %{ + like_undo: like_undo, + post: post, + like: like, + reaction_undo: reaction_undo, + reaction: reaction + } + end + + test "a reaction undo removes the reaction from the object", %{ + reaction_undo: reaction_undo, + post: post + } do + {:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo) + + object = Object.get_by_ap_id(post.data["object"]) + + assert object.data["reaction_count"] == 0 + assert object.data["reactions"] == [] + end + + test "deletes the original reaction", %{reaction_undo: reaction_undo, reaction: reaction} do + {:ok, _reaction_undo, _} = SideEffects.handle(reaction_undo) + refute Activity.get_by_id(reaction.id) end test "a like undo removes the like from the object", %{like_undo: like_undo, post: post} do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index bc0c1a791..0664b7f90 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -295,10 +295,11 @@ test "unreacting to a status with an emoji" do {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) {:ok, reaction, _} = CommonAPI.react_with_emoji(activity.id, user, "👍") - {:ok, unreaction, _} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") + {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") assert unreaction.data["type"] == "Undo" assert unreaction.data["object"] == reaction.data["id"] + assert unreaction.local end test "repeating a status" do diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index 61a1689b9..299dbad41 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -3,12 +3,14 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do + use Oban.Testing, repo: Pleroma.Repo use Pleroma.Web.ConnCase alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -41,7 +43,9 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do other_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) - {:ok, activity, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + {:ok, _reaction, _object} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + + ObanHelpers.perform_all() result = conn @@ -52,7 +56,9 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do assert %{"id" => id} = json_response(result, 200) assert to_string(activity.id) == id - object = Object.normalize(activity) + ObanHelpers.perform_all() + + object = Object.get_by_ap_id(activity.data["object"]) assert object.data["reaction_count"] == 0 end -- cgit v1.2.3 From a3bb2e5474ee068bf375b24df8906e51654c9699 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 16:42:34 +0200 Subject: Undoing: Move undoing announcements to the pipeline everywhere. --- lib/pleroma/user.ex | 10 +---- lib/pleroma/web/activity_pub/activity_pub.ex | 28 ------------- lib/pleroma/web/activity_pub/side_effects.ex | 8 ++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 21 +--------- lib/pleroma/web/common_api/common_api.ex | 9 +++-- .../mastodon_api/controllers/status_controller.ex | 6 +-- test/notification_test.exs | 2 +- test/web/activity_pub/activity_pub_test.exs | 46 ---------------------- test/web/activity_pub/side_effects_test.exs | 26 +++++++++++- .../transmogrifier/undo_handling_test.exs | 5 +-- 10 files changed, 45 insertions(+), 116 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 0136ba119..aa675a521 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1554,7 +1554,7 @@ defp delete_activity(%{data: %{"type" => "Create"}} = activity) do |> ActivityPub.delete() end - defp delete_activity(%{data: %{"type" => "Like"}} = activity) do + defp delete_activity(%{data: %{"type" => type}} = activity) when type in ["Like", "Announce"] do actor = activity.actor |> get_cached_by_ap_id() @@ -1564,14 +1564,6 @@ defp delete_activity(%{data: %{"type" => "Like"}} = activity) do Pipeline.common_pipeline(undo, local: true) end - defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do - object = Object.normalize(activity) - - activity.actor - |> get_cached_by_ap_id() - |> ActivityPub.unannounce(object) - end - defp delete_activity(_activity), do: "Doing nothing" def html_filter_policy(%User{no_rich_text: true}) do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c94af3b5f..be3d72c82 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -402,34 +402,6 @@ defp do_announce(user, object, activity_id, local, public) do end end - @spec unannounce(User.t(), Object.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()} - def unannounce( - %User{} = actor, - %Object{} = object, - activity_id \\ nil, - local \\ true - ) do - with {:ok, result} <- - Repo.transaction(fn -> do_unannounce(actor, object, activity_id, local) end) do - result - end - end - - defp do_unannounce(actor, object, activity_id, local) do - with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object), - unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id), - {:ok, unannounce_activity} <- insert(unannounce_data, local), - :ok <- maybe_federate(unannounce_activity), - {:ok, _activity} <- Repo.delete(announce_activity), - {:ok, object} <- remove_announce_from_object(announce_activity, object) do - {:ok, unannounce_activity, object} - else - nil -> {:ok, object} - {:error, error} -> Repo.rollback(error) - end - end - @spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | {:error, any()} def follow(follower, followed, activity_id \\ nil, local \\ true) do diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index d58df9394..146d30ac1 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -53,5 +53,13 @@ def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do end end + def handle_undoing(%{data: %{"type" => "Announce"}} = object) do + with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]), + {:ok, _} <- Utils.remove_announce_from_object(object, liked_object), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + def handle_undoing(object), do: {:error, ["don't know how to handle", object]} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 94849b5f5..afa171448 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -768,25 +768,6 @@ def handle_incoming( end end - def handle_incoming( - %{ - "type" => "Undo", - "object" => %{"type" => "Announce", "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, _} <- ActivityPub.unannounce(actor, object, id, false) do - {:ok, activity} - else - _e -> :error - end - end - def handle_incoming( %{ "type" => "Undo", @@ -847,7 +828,7 @@ def handle_incoming( } = data, _options ) - when type in ["Like", "EmojiReact"] do + when type in ["Like", "EmojiReact", "Announce"] do with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 067ac875e..fc8246871 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -107,9 +107,12 @@ def repeat(id, user, params \\ %{}) do def unrepeat(id, user) do with {_, %Activity{data: %{"type" => "Create"}} = activity} <- - {:find_activity, Activity.get_by_id(id)} do - object = Object.normalize(activity) - ActivityPub.unannounce(user, object) + {:find_activity, Activity.get_by_id(id)}, + %Object{} = note <- Object.normalize(activity, false), + %Activity{} = announce <- Utils.get_existing_announce(user.ap_id, note), + {:ok, undo, _} <- Builder.undo(user, announce), + {:ok, activity, _} <- Pipeline.common_pipeline(undo, local: true) do + {:ok, activity} else {:find_activity, _} -> {:error, :not_found} _ -> {:error, dgettext("errors", "Could not unrepeat")} diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 2a5eac9d9..12e3ba15e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -206,9 +206,9 @@ def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do end @doc "POST /api/v1/statuses/:id/unreblog" - def unreblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do - with {:ok, _unannounce, %{data: %{"id" => id}}} <- CommonAPI.unrepeat(ap_id_or_id, user), - %Activity{} = activity <- Activity.get_create_by_object_ap_id_with_object(id) do + def unreblog(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do + with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user), + %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 7d5b82993..09714f4c5 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -758,7 +758,7 @@ test "repeating an activity results in 1 notification, then 0 if the activity is assert length(Notification.for_user(user)) == 1 - {:ok, _, _} = CommonAPI.unrepeat(activity.id, other_user) + {:ok, _} = CommonAPI.unrepeat(activity.id, other_user) assert Enum.empty?(Notification.for_user(user)) end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index cb2d41f0b..2c3d354f2 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1008,52 +1008,6 @@ test "does not add an announce activity to the db if the announcer is not the au end end - describe "unannouncing an object" do - test "unannouncing a previously announced object" do - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - # Unannouncing an object that is not announced does nothing - {:ok, object} = ActivityPub.unannounce(user, object) - refute object.data["announcement_count"] - - {:ok, announce_activity, object} = ActivityPub.announce(user, object) - assert object.data["announcement_count"] == 1 - - {:ok, unannounce_activity, object} = ActivityPub.unannounce(user, object) - assert object.data["announcement_count"] == 0 - - assert unannounce_activity.data["to"] == [ - User.ap_followers(user), - object.data["actor"] - ] - - assert unannounce_activity.data["type"] == "Undo" - assert unannounce_activity.data["object"] == announce_activity.data - assert unannounce_activity.data["actor"] == user.ap_id - assert unannounce_activity.data["context"] == announce_activity.data["context"] - - assert Activity.get_by_id(announce_activity.id) == nil - end - - test "reverts unannouncing on error" do - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - {:ok, _announce_activity, object} = ActivityPub.announce(user, object) - assert object.data["announcement_count"] == 1 - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.unannounce(user, object) - end - - object = Object.get_by_ap_id(object.data["id"]) - assert object.data["announcement_count"] == 1 - end - end - describe "uploading files" do test "copies the file to the configured folder" do file = %Plug.Upload{ diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index abcfdfa2f..00241320b 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -22,8 +22,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do user = insert(:user) {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) {:ok, like} = CommonAPI.favorite(user, post.id) - {:ok, reaction, _} = CommonAPI.react_with_emoji(post.id, user, "👍") + {:ok, announce, _} = CommonAPI.repeat(post.id, user) {:ok, undo_data, _meta} = Builder.undo(user, like) {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true) @@ -31,15 +31,37 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, undo_data, _meta} = Builder.undo(user, reaction) {:ok, reaction_undo, _meta} = ActivityPub.persist(undo_data, local: true) + {:ok, undo_data, _meta} = Builder.undo(user, announce) + {:ok, announce_undo, _meta} = ActivityPub.persist(undo_data, local: true) + %{ like_undo: like_undo, post: post, like: like, reaction_undo: reaction_undo, - reaction: reaction + reaction: reaction, + announce_undo: announce_undo, + announce: announce } end + test "an announce undo removes the announce from the object", %{ + announce_undo: announce_undo, + post: post + } do + {:ok, _announce_undo, _} = SideEffects.handle(announce_undo) + + object = Object.get_by_ap_id(post.data["object"]) + + assert object.data["announcement_count"] == 0 + assert object.data["announcements"] == [] + end + + test "deletes the original announce", %{announce_undo: announce_undo, announce: announce} do + {:ok, _announce_undo, _} = SideEffects.handle(announce_undo) + refute Activity.get_by_id(announce.id) + end + test "a reaction undo removes the reaction from the object", %{ reaction_undo: reaction_undo, post: post diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs index bf2a6bc5b..281cf5b0d 100644 --- a/test/web/activity_pub/transmogrifier/undo_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -125,11 +125,8 @@ test "it works for incoming unannounces with an existing notice" do {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) assert data["type"] == "Undo" - assert object_data = data["object"] - assert object_data["type"] == "Announce" - assert object_data["object"] == activity.data["object"] - assert object_data["id"] == + assert data["object"] == "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" end -- cgit v1.2.3 From 92caae592338a3ca307686e7644f2de18bb57ce5 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 18:00:37 +0200 Subject: Undoing: Move undoing blocks to the pipeline everywhere. --- lib/pleroma/web/activity_pub/activity_pub.ex | 21 --------- lib/pleroma/web/activity_pub/side_effects.ex | 12 +++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 51 +++++++--------------- lib/pleroma/web/activity_pub/utils.ex | 49 --------------------- lib/pleroma/web/common_api/common_api.ex | 8 ++++ .../mastodon_api/controllers/account_controller.ex | 3 +- test/web/activity_pub/activity_pub_test.exs | 34 +-------------- test/web/activity_pub/side_effects_test.exs | 25 ++++++++++- .../transmogrifier/undo_handling_test.exs | 4 +- test/web/activity_pub/utils_test.exs | 28 ------------ 10 files changed, 63 insertions(+), 172 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index be3d72c82..78e8c0cbe 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -532,27 +532,6 @@ defp do_block(blocker, blocked, activity_id, local) do end end - @spec unblock(User.t(), User.t(), String.t() | nil, boolean()) :: - {:ok, Activity.t()} | {:error, any()} | nil - def unblock(blocker, blocked, activity_id \\ nil, local \\ true) do - with {:ok, result} <- - Repo.transaction(fn -> do_unblock(blocker, blocked, activity_id, local) end) do - result - end - end - - defp do_unblock(blocker, blocked, activity_id, local) do - with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked), - unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id), - {:ok, activity} <- insert(unblock_data, local), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - nil -> nil - {:error, error} -> Repo.rollback(error) - end - end - @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} def flag( %{ diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 146d30ac1..3fad6e4d8 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -61,5 +62,16 @@ def handle_undoing(%{data: %{"type" => "Announce"}} = object) do end end + def handle_undoing( + %{data: %{"type" => "Block", "actor" => blocker, "object" => blocked}} = object + ) do + with %User{} = blocker <- User.get_cached_by_ap_id(blocker), + %User{} = blocked <- User.get_cached_by_ap_id(blocked), + {:ok, _} <- User.unblock(blocker, blocked), + {:ok, _} <- Repo.delete(object) do + :ok + end + end + def handle_undoing(object), do: {:error, ["don't know how to handle", object]} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index afa171448..65ae643ed 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -787,40 +787,6 @@ def handle_incoming( end end - def handle_incoming( - %{ - "type" => "Undo", - "object" => %{"type" => "Block", "object" => blocked}, - "actor" => blocker, - "id" => id - } = _data, - _options - ) do - with %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked), - {:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker), - {:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do - User.unblock(blocker, blocked) - {:ok, activity} - else - _e -> :error - end - end - - def handle_incoming( - %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data, - _options - ) do - with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked), - {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker), - {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do - User.unfollow(blocker, blocked) - User.block(blocker, blocked) - {:ok, activity} - else - _e -> :error - end - end - def handle_incoming( %{ "type" => "Undo", @@ -828,7 +794,7 @@ def handle_incoming( } = data, _options ) - when type in ["Like", "EmojiReact", "Announce"] do + when type in ["Like", "EmojiReact", "Announce", "Block"] do with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} end @@ -852,6 +818,21 @@ def handle_incoming( end end + def handle_incoming( + %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data, + _options + ) do + with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked), + {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker), + {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do + User.unfollow(blocker, blocked) + User.block(blocker, blocked) + {:ok, activity} + else + _e -> :error + end + end + def handle_incoming( %{ "type" => "Move", diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 2d685ecc0..95fb382f0 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -562,45 +562,6 @@ def make_announce_data( |> maybe_put("id", activity_id) end - @doc """ - Make unannounce activity data for the given actor and object - """ - def make_unannounce_data( - %User{ap_id: ap_id} = user, - %Activity{data: %{"context" => context, "object" => object}} = activity, - activity_id - ) do - object = Object.normalize(object) - - %{ - "type" => "Undo", - "actor" => ap_id, - "object" => activity.data, - "to" => [user.follower_address, object.data["actor"]], - "cc" => [Pleroma.Constants.as_public()], - "context" => context - } - |> maybe_put("id", activity_id) - end - - def make_unlike_data( - %User{ap_id: ap_id} = user, - %Activity{data: %{"context" => context, "object" => object}} = activity, - activity_id - ) do - object = Object.normalize(object) - - %{ - "type" => "Undo", - "actor" => ap_id, - "object" => activity.data, - "to" => [user.follower_address, object.data["actor"]], - "cc" => [Pleroma.Constants.as_public()], - "context" => context - } - |> maybe_put("id", activity_id) - end - def make_undo_data( %User{ap_id: actor, follower_address: follower_address}, %Activity{ @@ -688,16 +649,6 @@ def make_block_data(blocker, blocked, activity_id) do |> maybe_put("id", activity_id) end - def make_unblock_data(blocker, blocked, block_activity, activity_id) do - %{ - "type" => "Undo", - "actor" => blocker.ap_id, - "to" => [blocked.ap_id], - "object" => block_activity.data - } - |> maybe_put("id", activity_id) - end - #### Create-related helpers def make_create_data(params, additional) do diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index fc8246871..2a1eb7f37 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -24,6 +24,14 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger + def unblock(blocker, blocked) do + with %Activity{} = block <- Utils.fetch_latest_block(blocker, blocked), + {:ok, unblock_data, _} <- Builder.undo(blocker, block), + {:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do + {:ok, unblock} + end + end + def follow(follower, followed) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 61b0e2f63..2b208ddab 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -356,8 +356,7 @@ def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do @doc "POST /api/v1/accounts/:id/unblock" def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do - with {:ok, _user_block} <- User.unblock(blocker, blocked), - {:ok, _activity} <- ActivityPub.unblock(blocker, blocked) do + with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do render(conn, "relationship.json", user: blocker, target: blocked) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 2c3d354f2..7824095c7 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1114,7 +1114,7 @@ test "creates an undo activity for a pending follow request" do end end - describe "blocking / unblocking" do + describe "blocking" do test "reverts block activity on error" do [blocker, blocked] = insert_list(2, :user) @@ -1136,38 +1136,6 @@ test "creates a block activity" do assert activity.data["actor"] == blocker.ap_id assert activity.data["object"] == blocked.ap_id end - - test "reverts unblock activity on error" do - [blocker, blocked] = insert_list(2, :user) - {:ok, block_activity} = ActivityPub.block(blocker, blocked) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.unblock(blocker, blocked) - end - - assert block_activity.data["type"] == "Block" - assert block_activity.data["actor"] == blocker.ap_id - - assert Repo.aggregate(Activity, :count, :id) == 1 - assert Repo.aggregate(Object, :count, :id) == 1 - end - - test "creates an undo activity for the last block" do - blocker = insert(:user) - blocked = insert(:user) - - {:ok, block_activity} = ActivityPub.block(blocker, blocked) - {:ok, activity} = ActivityPub.unblock(blocker, blocked) - - assert activity.data["type"] == "Undo" - assert activity.data["actor"] == blocker.ap_id - - embedded_object = activity.data["object"] - assert is_map(embedded_object) - assert embedded_object["type"] == "Block" - assert embedded_object["object"] == blocked.ap_id - assert embedded_object["id"] == block_activity.data["id"] - end end describe "deletion" do diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 00241320b..f41a7f3c1 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.SideEffects @@ -24,6 +25,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, like} = CommonAPI.favorite(user, post.id) {:ok, reaction, _} = CommonAPI.react_with_emoji(post.id, user, "👍") {:ok, announce, _} = CommonAPI.repeat(post.id, user) + {:ok, block} = ActivityPub.block(user, poster) + User.block(user, poster) {:ok, undo_data, _meta} = Builder.undo(user, like) {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true) @@ -34,6 +37,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, undo_data, _meta} = Builder.undo(user, announce) {:ok, announce_undo, _meta} = ActivityPub.persist(undo_data, local: true) + {:ok, undo_data, _meta} = Builder.undo(user, block) + {:ok, block_undo, _meta} = ActivityPub.persist(undo_data, local: true) + %{ like_undo: like_undo, post: post, @@ -41,10 +47,27 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do reaction_undo: reaction_undo, reaction: reaction, announce_undo: announce_undo, - announce: announce + announce: announce, + block_undo: block_undo, + block: block, + poster: poster, + user: user } end + test "deletes the original block", %{block_undo: block_undo, block: block} do + {:ok, _block_undo, _} = SideEffects.handle(block_undo) + refute Activity.get_by_id(block.id) + end + + test "unblocks the blocked user", %{block_undo: block_undo, block: block} do + blocker = User.get_by_ap_id(block.data["actor"]) + blocked = User.get_by_ap_id(block.data["object"]) + + {:ok, _block_undo, _} = SideEffects.handle(block_undo) + refute User.blocks?(blocker, blocked) + end + test "an announce undo removes the announce from the object", %{ announce_undo: announce_undo, post: post diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs index 281cf5b0d..6f5e61ac3 100644 --- a/test/web/activity_pub/transmogrifier/undo_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -176,9 +176,7 @@ test "it works for incoming unblocks with an existing block" do {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) assert data["type"] == "Undo" - assert data["object"]["type"] == "Block" - assert data["object"]["object"] == user.ap_id - assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["object"] == block_data["id"] blocker = User.get_cached_by_ap_id(data["actor"]) diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index b0bfed917..b8d811c73 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -102,34 +102,6 @@ test "works with an object has tags as map" do end end - describe "make_unlike_data/3" do - test "returns data for unlike activity" do - user = insert(:user) - like_activity = insert(:like_activity, data_attrs: %{"context" => "test context"}) - - object = Object.normalize(like_activity.data["object"]) - - assert Utils.make_unlike_data(user, like_activity, nil) == %{ - "type" => "Undo", - "actor" => user.ap_id, - "object" => like_activity.data, - "to" => [user.follower_address, object.data["actor"]], - "cc" => [Pleroma.Constants.as_public()], - "context" => like_activity.data["context"] - } - - assert Utils.make_unlike_data(user, like_activity, "9mJEZK0tky1w2xD2vY") == %{ - "type" => "Undo", - "actor" => user.ap_id, - "object" => like_activity.data, - "to" => [user.follower_address, object.data["actor"]], - "cc" => [Pleroma.Constants.as_public()], - "context" => like_activity.data["context"], - "id" => "9mJEZK0tky1w2xD2vY" - } - end - end - describe "make_like_data" do setup do user = insert(:user) -- cgit v1.2.3 From 0a1394cc1a38ce66b1b30d728856ae891aa3d7b0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 20:14:22 +0400 Subject: Add OpenAPI spec for PollController --- .../web/api_spec/operations/poll_operation.ex | 76 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/poll.ex | 62 +++++++++++++++--- .../mastodon_api/controllers/poll_controller.ex | 8 ++- .../controllers/poll_controller_test.exs | 38 +++++++---- 4 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/poll_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex new file mode 100644 index 000000000..b953323e9 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex @@ -0,0 +1,76 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PollOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Poll + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Polls"], + summary: "View a poll", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + operationId: "PollController.show", + responses: %{ + 200 => Operation.response("Poll", "application/json", Poll), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def vote_operation do + %Operation{ + tags: ["Polls"], + summary: "Block a domain", + parameters: [id_param()], + operationId: "PollController.vote", + requestBody: vote_request(), + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => Operation.response("Poll", "application/json", Poll), + 422 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Poll ID", + example: "123", + required: true + ) + end + + defp vote_request do + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + choices: %Schema{ + type: :array, + items: %Schema{type: :integer}, + description: "Array of own votes containing index for each option (starting from 0)" + } + }, + required: [:choices] + }, + required: true, + example: %{ + "choices" => [0, 1, 2] + } + ) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex index 0474b550b..c62096db0 100644 --- a/lib/pleroma/web/api_spec/schemas/poll.ex +++ b/lib/pleroma/web/api_spec/schemas/poll.ex @@ -11,26 +11,72 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do OpenApiSpex.schema(%{ title: "Poll", - description: "Response schema for account custom fields", + description: "Represents a poll attached to a status", type: :object, properties: %{ id: FlakeID, - expires_at: %Schema{type: :string, format: "date-time"}, - expired: %Schema{type: :boolean}, - multiple: %Schema{type: :boolean}, - votes_count: %Schema{type: :integer}, - voted: %Schema{type: :boolean}, - emojis: %Schema{type: :array, items: Emoji}, + expires_at: %Schema{ + type: :string, + format: :"date-time", + nullable: true, + description: "When the poll ends" + }, + expired: %Schema{type: :boolean, description: "Is the poll currently expired?"}, + multiple: %Schema{ + type: :boolean, + description: "Does the poll allow multiple-choice answers?" + }, + votes_count: %Schema{ + type: :integer, + nullable: true, + description: "How many votes have been received. Number, or null if `multiple` is false." + }, + voted: %Schema{ + type: :boolean, + nullable: true, + description: + "When called with a user token, has the authorized user voted? Boolean, or null if no current user." + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used for rendering poll options." + }, options: %Schema{ type: :array, items: %Schema{ + title: "PollOption", type: :object, properties: %{ title: %Schema{type: :string}, votes_count: %Schema{type: :integer} } - } + }, + description: "Possible answers for the poll." } + }, + example: %{ + id: "34830", + expires_at: "2019-12-05T04:05:08.302Z", + expired: true, + multiple: false, + votes_count: 10, + voters_count: nil, + voted: true, + own_votes: [ + 1 + ], + options: [ + %{ + title: "accept", + votes_count: 6 + }, + %{ + title: "deny", + votes_count: 4 + } + ], + emojis: [] } }) end diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index af9b66eff..db46ffcfc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show @@ -22,8 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation + @doc "GET /api/v1/polls/:id" - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def show(%{assigns: %{user: user}} = conn, %{id: id}) do with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user) do @@ -35,7 +39,7 @@ def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "POST /api/v1/polls/:id/votes" - def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do + def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), true <- Visibility.visible_for_user?(activity, user), diff --git a/test/web/mastodon_api/controllers/poll_controller_test.exs b/test/web/mastodon_api/controllers/poll_controller_test.exs index 88b13a25a..d8f34aa86 100644 --- a/test/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/web/mastodon_api/controllers/poll_controller_test.exs @@ -24,7 +24,7 @@ test "returns poll entity for object id", %{user: user, conn: conn} do conn = get(conn, "/api/v1/polls/#{object.id}") - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) id = to_string(object.id) assert %{"id" => ^id, "expired" => false, "multiple" => false} = response end @@ -43,7 +43,7 @@ test "does not expose polls for private statuses", %{conn: conn} do conn = get(conn, "/api/v1/polls/#{object.id}") - assert json_response(conn, 404) + assert json_response_and_validate_schema(conn, 404) end end @@ -65,9 +65,12 @@ test "votes are added to the poll", %{conn: conn} do object = Object.normalize(activity) - conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) - assert json_response(conn, 200) + assert json_response_and_validate_schema(conn, 200) object = Object.get_by_id(object.id) assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} -> @@ -85,8 +88,9 @@ test "author can't vote", %{user: user, conn: conn} do object = Object.normalize(activity) assert conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]}) - |> json_response(422) == %{"error" => "Poll's author can't vote"} + |> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"} object = Object.get_by_id(object.id) @@ -105,8 +109,9 @@ test "does not allow multiple choices on a single-choice question", %{conn: conn object = Object.normalize(activity) assert conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]}) - |> json_response(422) == %{"error" => "Too many choices"} + |> json_response_and_validate_schema(422) == %{"error" => "Too many choices"} object = Object.get_by_id(object.id) @@ -126,15 +131,21 @@ test "does not allow choice index to be greater than options count", %{conn: con object = Object.normalize(activity) - conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [2]}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]}) - assert json_response(conn, 422) == %{"error" => "Invalid indices"} + assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"} end test "returns 404 error when object is not exist", %{conn: conn} do - conn = post(conn, "/api/v1/polls/1/votes", %{"choices" => [0]}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/polls/1/votes", %{"choices" => [0]}) - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end test "returns 404 when poll is private and not available for user", %{conn: conn} do @@ -149,9 +160,12 @@ test "returns 404 when poll is private and not available for user", %{conn: conn object = Object.normalize(activity) - conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0]}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]}) - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end end end -- cgit v1.2.3 From 9637cded21cef1e6c531dd46d5f5245c4c3ed03c Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 5 May 2020 20:07:47 +0200 Subject: Chat: Fix missing chat id on second 'get' --- lib/pleroma/chat.ex | 3 ++- test/chat_test.exs | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 6008196e4..1a092b992 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -46,7 +46,8 @@ def get_or_create(user_id, recipient) do %__MODULE__{} |> creation_cng(%{user_id: user_id, recipient: recipient}) |> Repo.insert( - on_conflict: :nothing, + # Need to set something, otherwise we get nothing back at all + on_conflict: [set: [recipient: recipient]], returning: true, conflict_target: [:user_id, :recipient] ) diff --git a/test/chat_test.exs b/test/chat_test.exs index 952598c87..943e48111 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -26,13 +26,24 @@ test "it creates a chat for a user and recipient" do assert chat.id end - test "it returns a chat for a user and recipient if it already exists" do + test "it returns and bumps a chat for a user and recipient if it already exists" do user = insert(:user) other_user = insert(:user) {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) + assert chat.id == chat_two.id + assert chat_two.unread == 2 + end + + test "it returns a chat for a user and recipient if it already exists" do + user = insert(:user) + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat_two} = Chat.get_or_create(user.id, other_user.ap_id) + assert chat.id == chat_two.id end -- cgit v1.2.3 From 6ba25d11973e56008e5d674313421197ff418d6d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 23:19:16 +0400 Subject: Add Attachment schema --- lib/pleroma/web/api_spec/schemas/attachment.ex | 68 ++++++++++++++++++++++ .../web/api_spec/schemas/scheduled_status.ex | 53 +++++++++++++++++ lib/pleroma/web/api_spec/schemas/status.ex | 18 +----- 3 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/attachment.ex create mode 100644 lib/pleroma/web/api_spec/schemas/scheduled_status.ex diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex new file mode 100644 index 000000000..c146c416e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/attachment.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Attachment", + description: "Represents a file or media attachment that can be added to a status.", + type: :object, + requried: [:id, :url, :preview_url], + properties: %{ + id: %Schema{type: :string}, + url: %Schema{ + type: :string, + format: :uri, + description: "The location of the original full-size attachment" + }, + remote_url: %Schema{ + type: :string, + format: :uri, + description: + "The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local", + nullable: true + }, + preview_url: %Schema{ + type: :string, + format: :uri, + description: "The location of a scaled-down preview of the attachment" + }, + text_url: %Schema{ + type: :string, + format: :uri, + description: "A shorter URL for the attachment" + }, + description: %Schema{ + type: :string, + nullable: true, + description: + "Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load" + }, + type: %Schema{ + type: :string, + enum: ["image", "video", "audio", "unknown"], + description: "The type of the attachment" + }, + pleroma: %Schema{ + type: :object, + properties: %{ + mime_type: %Schema{type: :string, description: "mime type of the attachment"} + } + } + }, + example: %{ + id: "1638338801", + type: "image", + url: "someurl", + remote_url: "someurl", + preview_url: "someurl", + text_url: "someurl", + description: nil, + pleroma: %{mime_type: "image/png"} + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex new file mode 100644 index 000000000..f0bc4ee3c --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + alias Pleroma.Web.ApiSpec.Schemas.Poll + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "ScheduledStatus", + description: "Represents a status that will be published at a future scheduled date.", + type: :object, + required: [:id, :scheduled_at, :params], + properties: %{ + id: %Schema{type: :string}, + scheduled_at: %Schema{type: :string, format: :"date-time"}, + media_attachments: %Schema{type: :array, format: :"date-time"}, + params: %Schema{ + type: :object, + required: [:text, :visibility], + properties: %{ + text: %Schema{type: :string, nullable: true}, + media_ids: %Schema{type: :array, nullable: true, items: %Schema{type: :string}}, + sensitive: %Schema{type: :boolean, nullable: true}, + spoiler_text: %Schema{type: :string, nullable: true}, + visibility: %Schema{type: VisibilityScope, nullable: true}, + scheduled_at: %Schema{type: :string, format: :"date-time", nullable: true}, + poll: %Schema{type: Poll, nullable: true}, + in_reply_to_id: %Schema{type: :string, nullable: true} + } + } + }, + example: %{ + id: "3221", + scheduled_at: "2019-12-05T12:33:01.000Z", + params: %{ + text: "test content", + media_ids: nil, + sensitive: nil, + spoiler_text: nil, + visibility: nil, + scheduled_at: nil, + poll: nil, + idempotency: nil, + in_reply_to_id: nil + }, + media_attachments: [] + } + }) +end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index aef0588d4..d44636a48 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.Attachment alias Pleroma.Web.ApiSpec.Schemas.Emoji alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.Poll @@ -50,22 +51,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do language: %Schema{type: :string, nullable: true}, media_attachments: %Schema{ type: :array, - items: %Schema{ - type: :object, - properties: %{ - id: %Schema{type: :string}, - url: %Schema{type: :string, format: :uri}, - remote_url: %Schema{type: :string, format: :uri}, - preview_url: %Schema{type: :string, format: :uri}, - text_url: %Schema{type: :string, format: :uri}, - description: %Schema{type: :string}, - type: %Schema{type: :string, enum: ["image", "video", "audio", "unknown"]}, - pleroma: %Schema{ - type: :object, - properties: %{mime_type: %Schema{type: :string}} - } - } - } + items: Attachment }, mentions: %Schema{ type: :array, -- cgit v1.2.3 From 332e016bcdbda5dca90d916bc62a9c67544b5323 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 5 May 2020 23:42:18 +0400 Subject: Add OpenAPI spec for ScheduledActivityController --- .../operations/scheduled_activity_operation.ex | 96 ++++++++++++++++++++++ .../web/api_spec/schemas/scheduled_status.ex | 7 +- .../controllers/scheduled_activity_controller.ex | 12 ++- test/support/helpers.ex | 8 +- .../scheduled_activity_controller_test.exs | 34 +++++--- test/web/mastodon_api/views/status_view_test.exs | 8 +- 6 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex new file mode 100644 index 000000000..fe675a923 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/scheduled_activity_operation.ex @@ -0,0 +1,96 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.ScheduledActivityOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "View scheduled statuses", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: pagination_params(), + operationId: "ScheduledActivity.index", + responses: %{ + 200 => + Operation.response("Array of ScheduledStatus", "application/json", %Schema{ + type: :array, + items: ScheduledStatus + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "View a single scheduled status", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + operationId: "ScheduledActivity.show", + responses: %{ + 200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "Schedule a status", + operationId: "ScheduledActivity.update", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + scheduled_at: %Schema{ + type: :string, + format: :"date-time", + description: + "ISO 8601 Datetime at which the status will be published. Must be at least 5 minutes into the future." + } + } + }), + responses: %{ + 200 => Operation.response("Scheduled Status", "application/json", ScheduledStatus), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Scheduled Statuses"], + summary: "Cancel a scheduled status", + security: [%{"oAuth" => ["write:statuses"]}], + parameters: [id_param()], + operationId: "ScheduledActivity.delete", + responses: %{ + 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}), + 404 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Poll ID", + example: "123", + required: true + ) + end +end diff --git a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex index f0bc4ee3c..0520d0848 100644 --- a/lib/pleroma/web/api_spec/schemas/scheduled_status.ex +++ b/lib/pleroma/web/api_spec/schemas/scheduled_status.ex @@ -4,8 +4,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + alias Pleroma.Web.ApiSpec.Schemas.Attachment alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope require OpenApiSpex @@ -17,7 +18,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do properties: %{ id: %Schema{type: :string}, scheduled_at: %Schema{type: :string, format: :"date-time"}, - media_attachments: %Schema{type: :array, format: :"date-time"}, + media_attachments: %Schema{type: :array, items: Attachment}, params: %Schema{ type: :object, required: [:text, :visibility], @@ -47,7 +48,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ScheduledStatus do idempotency: nil, in_reply_to_id: nil }, - media_attachments: [] + media_attachments: [Attachment.schema().example] } }) end diff --git a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex index 899b78873..1719c67ea 100644 --- a/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex @@ -11,17 +11,21 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do alias Pleroma.ScheduledActivity alias Pleroma.Web.MastodonAPI.MastodonAPI - plug(:assign_scheduled_activity when action != :index) - @oauth_read_actions [:show, :index] + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions) + plug(:assign_scheduled_activity when action != :index) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ScheduledActivityOperation + @doc "GET /api/v1/scheduled_statuses" def index(%{assigns: %{user: user}} = conn, params) do + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do conn |> add_link_headers(scheduled_activities) @@ -35,7 +39,7 @@ def show(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params) end @doc "PUT /api/v1/scheduled_statuses/:id" - def update(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, params) do + def update(%{assigns: %{scheduled_activity: scheduled_activity}, body_params: params} = conn, _) do with {:ok, scheduled_activity} <- ScheduledActivity.update(scheduled_activity, params) do render(conn, "show.json", scheduled_activity: scheduled_activity) end @@ -48,7 +52,7 @@ def delete(%{assigns: %{scheduled_activity: scheduled_activity}} = conn, _params end end - defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{"id" => id}} = conn, _) do + defp assign_scheduled_activity(%{assigns: %{user: user}, params: %{id: id}} = conn, _) do case ScheduledActivity.get(user, id) do %ScheduledActivity{} = activity -> assign(conn, :scheduled_activity, activity) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() diff --git a/test/support/helpers.ex b/test/support/helpers.ex index e68e9bfd2..26281b45e 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -40,12 +40,18 @@ defmacro __using__(_opts) do clear_config: 2 ] - def to_datetime(naive_datetime) do + def to_datetime(%NaiveDateTime{} = naive_datetime) do naive_datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.truncate(:second) end + def to_datetime(datetime) when is_binary(datetime) do + datetime + |> NaiveDateTime.from_iso8601!() + |> to_datetime() + end + def collect_ids(collection) do collection |> Enum.map(& &1.id) diff --git a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs index f86274d57..1ff871c89 100644 --- a/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs +++ b/test/web/mastodon_api/controllers/scheduled_activity_controller_test.exs @@ -24,19 +24,19 @@ test "shows scheduled activities" do # min_id conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&min_id=#{scheduled_activity_id1}") - result = json_response(conn_res, 200) + result = json_response_and_validate_schema(conn_res, 200) assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result # since_id conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&since_id=#{scheduled_activity_id1}") - result = json_response(conn_res, 200) + result = json_response_and_validate_schema(conn_res, 200) assert [%{"id" => ^scheduled_activity_id4}, %{"id" => ^scheduled_activity_id3}] = result # max_id conn_res = get(conn, "/api/v1/scheduled_statuses?limit=2&max_id=#{scheduled_activity_id4}") - result = json_response(conn_res, 200) + result = json_response_and_validate_schema(conn_res, 200) assert [%{"id" => ^scheduled_activity_id3}, %{"id" => ^scheduled_activity_id2}] = result end @@ -46,12 +46,12 @@ test "shows a scheduled activity" do res_conn = get(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}") - assert %{"id" => scheduled_activity_id} = json_response(res_conn, 200) + assert %{"id" => scheduled_activity_id} = json_response_and_validate_schema(res_conn, 200) assert scheduled_activity_id == scheduled_activity.id |> to_string() res_conn = get(conn, "/api/v1/scheduled_statuses/404") - assert %{"error" => "Record not found"} = json_response(res_conn, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404) end test "updates a scheduled activity" do @@ -74,22 +74,32 @@ test "updates a scheduled activity" do assert job.args == %{"activity_id" => scheduled_activity.id} assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(scheduled_at) - new_scheduled_at = Timex.shift(NaiveDateTime.utc_now(), minutes: 120) + new_scheduled_at = + NaiveDateTime.utc_now() + |> Timex.shift(minutes: 120) + |> Timex.format!("%Y-%m-%dT%H:%M:%S.%fZ", :strftime) res_conn = - put(conn, "/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{ + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/scheduled_statuses/#{scheduled_activity.id}", %{ scheduled_at: new_scheduled_at }) - assert %{"scheduled_at" => expected_scheduled_at} = json_response(res_conn, 200) + assert %{"scheduled_at" => expected_scheduled_at} = + json_response_and_validate_schema(res_conn, 200) + assert expected_scheduled_at == Pleroma.Web.CommonAPI.Utils.to_masto_date(new_scheduled_at) job = refresh_record(job) assert DateTime.truncate(job.scheduled_at, :second) == to_datetime(new_scheduled_at) - res_conn = put(conn, "/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at}) + res_conn = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/scheduled_statuses/404", %{scheduled_at: new_scheduled_at}) - assert %{"error" => "Record not found"} = json_response(res_conn, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404) end test "deletes a scheduled activity" do @@ -115,7 +125,7 @@ test "deletes a scheduled activity" do |> assign(:user, user) |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") - assert %{} = json_response(res_conn, 200) + assert %{} = json_response_and_validate_schema(res_conn, 200) refute Repo.get(ScheduledActivity, scheduled_activity.id) refute Repo.get(Oban.Job, job.id) @@ -124,6 +134,6 @@ test "deletes a scheduled activity" do |> assign(:user, user) |> delete("/api/v1/scheduled_statuses/#{scheduled_activity.id}") - assert %{"error" => "Record not found"} = json_response(res_conn, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(res_conn, 404) end end diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 6791c2fb0..451723e60 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -402,11 +402,17 @@ test "attachments" do pleroma: %{mime_type: "image/png"} } + api_spec = Pleroma.Web.ApiSpec.spec() + assert expected == StatusView.render("attachment.json", %{attachment: object}) + OpenApiSpex.TestAssertions.assert_schema(expected, "Attachment", api_spec) # If theres a "id", use that instead of the generated one object = Map.put(object, "id", 2) - assert %{id: "2"} = StatusView.render("attachment.json", %{attachment: object}) + result = StatusView.render("attachment.json", %{attachment: object}) + + assert %{id: "2"} = result + OpenApiSpex.TestAssertions.assert_schema(result, "Attachment", api_spec) end test "put the url advertised in the Activity in to the url attribute" do -- cgit v1.2.3 From 06c69c0a0a03d7797213fc520b6bf24fab65a7e3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 6 May 2020 14:18:19 +0400 Subject: Fix description --- lib/pleroma/web/api_spec/operations/poll_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/poll_operation.ex b/lib/pleroma/web/api_spec/operations/poll_operation.ex index b953323e9..e15c7dc95 100644 --- a/lib/pleroma/web/api_spec/operations/poll_operation.ex +++ b/lib/pleroma/web/api_spec/operations/poll_operation.ex @@ -33,7 +33,7 @@ def show_operation do def vote_operation do %Operation{ tags: ["Polls"], - summary: "Block a domain", + summary: "Vote on a poll", parameters: [id_param()], operationId: "PollController.vote", requestBody: vote_request(), -- cgit v1.2.3 From bd261309cc27ebf5d2f78ea3c1474fe71ae8046d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 6 May 2020 15:08:38 +0300 Subject: added `unread_notifications_count` for `/api/v1/accounts/verify_credentials` --- docs/API/differences_in_mastoapi_responses.md | 1 + lib/pleroma/notification.ex | 8 ++++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 29 +++++++++++++++++++--- .../controllers/account_controller_test.exs | 3 +++ test/web/mastodon_api/views/account_view_test.exs | 18 ++++++++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 8d1da936f..6d37d9008 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -61,6 +61,7 @@ Has these additional fields under the `pleroma` object: - `deactivated`: boolean, true when the user is deactivated - `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. +- `unread_notifications_count`: The count of unread notifications. Only returned to the account owner. ### Source diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 7fd1b2ff6..c135306ca 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -36,6 +36,14 @@ defmodule Pleroma.Notification do timestamps() end + @spec unread_notifications_count(User.t()) :: integer() + def unread_notifications_count(%User{id: user_id}) do + from(q in __MODULE__, + where: q.user_id == ^user_id and q.seen == false + ) + |> Repo.aggregate(:count, :id) + end + def changeset(%Notification{} = notification, attrs) do notification |> cast(attrs, [:seen]) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b4b61e74c..420bd586f 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -36,9 +36,11 @@ def render("index.json", %{users: users} = opts) do end def render("show.json", %{user: user} = opts) do - if User.visible_for?(user, opts[:for]), - do: do_render("show.json", opts), - else: %{} + if User.visible_for?(user, opts[:for]) do + do_render("show.json", opts) + else + %{} + end end def render("mention.json", %{user: user}) do @@ -221,7 +223,7 @@ defp do_render("show.json", %{user: user} = opts) do fields: user.fields, bot: bot, source: %{ - note: (user.bio || "") |> String.replace(~r(
    ), "\n") |> Pleroma.HTML.strip_tags(), + note: prepare_user_bio(user), sensitive: false, fields: user.raw_fields, pleroma: %{ @@ -253,8 +255,17 @@ defp do_render("show.json", %{user: user} = opts) do |> maybe_put_follow_requests_count(user, opts[:for]) |> maybe_put_allow_following_move(user, opts[:for]) |> maybe_put_unread_conversation_count(user, opts[:for]) + |> maybe_put_unread_notification_count(user, opts[:for]) end + defp prepare_user_bio(%User{bio: ""}), do: "" + + defp prepare_user_bio(%User{bio: bio}) when is_binary(bio) do + bio |> String.replace(~r(
    ), "\n") |> Pleroma.HTML.strip_tags() + end + + defp prepare_user_bio(_), do: "" + defp username_from_nickname(string) when is_binary(string) do hd(String.split(string, "@")) end @@ -350,6 +361,16 @@ defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{ defp maybe_put_unread_conversation_count(data, _, _), do: data + defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: user_id} = user) do + Kernel.put_in( + data, + [:pleroma, :unread_notifications_count], + Pleroma.Notification.unread_notifications_count(user) + ) + end + + defp maybe_put_unread_notification_count(data, _, _), do: data + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(_), do: nil end diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index b9da7e924..256a8b304 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -1196,12 +1196,15 @@ test "returns lists to which the account belongs" do describe "verify_credentials" do test "verify_credentials" do %{user: user, conn: conn} = oauth_access(["read:accounts"]) + [notification | _] = insert_list(7, :notification, user: user) + Pleroma.Notification.set_read_up_to(user, notification.id) conn = get(conn, "/api/v1/accounts/verify_credentials") response = json_response_and_validate_schema(conn, 200) assert %{"id" => id, "source" => %{"privacy" => "public"}} = response assert response["pleroma"]["chat_token"] + assert response["pleroma"]["unread_notifications_count"] == 6 assert id == to_string(user.id) end diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 85fa4f6a2..5fb162141 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -466,6 +466,24 @@ test "shows unread_conversation_count only to the account owner" do :unread_conversation_count ] == 1 end + + test "shows unread_count only to the account owner" do + user = insert(:user) + insert_list(7, :notification, user: user) + other_user = insert(:user) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert AccountView.render( + "show.json", + %{user: user, for: other_user} + )[:pleroma][:unread_notifications_count] == nil + + assert AccountView.render( + "show.json", + %{user: user, for: user} + )[:pleroma][:unread_notifications_count] == 7 + end end describe "follow requests counter" do -- cgit v1.2.3 From 3c42caa85c51b4eaa447d6aafcfaa0bfceaa9beb Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 6 May 2020 16:20:47 +0300 Subject: apache chain issue fix --- installation/pleroma-apache.conf | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/installation/pleroma-apache.conf b/installation/pleroma-apache.conf index b5640ac3d..0d627f2d7 100644 --- a/installation/pleroma-apache.conf +++ b/installation/pleroma-apache.conf @@ -32,9 +32,8 @@ CustomLog ${APACHE_LOG_DIR}/access.log combined SSLEngine on - SSLCertificateFile /etc/letsencrypt/live/${servername}/cert.pem + SSLCertificateFile /etc/letsencrypt/live/${servername}/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/${servername}/privkey.pem - SSLCertificateChainFile /etc/letsencrypt/live/${servername}/fullchain.pem # Mozilla modern configuration, tweak to your needs SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 -- cgit v1.2.3 From d7537a37c77dfef469106f12f0dd3649aad197da Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 6 May 2020 08:55:09 -0500 Subject: Add :chat to cheatsheet --- docs/configuration/cheatsheet.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 705c4c15e..2524918d4 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -8,6 +8,10 @@ For from source installations Pleroma configuration works by first importing the To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted. +## :chat + +* `enabled` - Enables the backend chat. Defaults to `true`. + ## :instance * `name`: The instance’s name. * `email`: Email used to reach an Administrator/Moderator of the instance. -- cgit v1.2.3 From 20baa2eaf04425cf0a2eebc84760be6c12ee7f51 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 6 May 2020 16:12:36 +0200 Subject: ChatMessages: Add attachments. --- lib/pleroma/web/activity_pub/builder.ex | 33 ++++++---- lib/pleroma/web/activity_pub/object_validator.ex | 11 +++- .../object_validators/attachment_validator.ex | 72 ++++++++++++++++++++++ .../object_validators/chat_message_validator.ex | 6 +- .../object_validators/url_object_validator.ex | 20 ++++++ .../web/api_spec/operations/chat_operation.ex | 3 +- lib/pleroma/web/api_spec/schemas/chat_message.ex | 6 +- lib/pleroma/web/common_api/common_api.ex | 6 +- .../web/pleroma_api/controllers/chat_controller.ex | 6 +- .../web/pleroma_api/views/chat_message_view.ex | 5 +- test/web/activity_pub/object_validator_test.exs | 50 ++++++++++++++- .../object_validators/types/object_id_test.exs | 4 ++ .../controllers/chat_controller_test.exs | 27 ++++++++ .../pleroma_api/views/chat_message_view_test.exs | 12 +++- 14 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 7f9c071b3..67e65c7b9 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -23,17 +23,28 @@ def create(actor, object, recipients) do }, []} end - def chat_message(actor, recipient, content) do - {:ok, - %{ - "id" => Utils.generate_object_id(), - "actor" => actor.ap_id, - "type" => "ChatMessage", - "to" => [recipient], - "content" => content, - "published" => DateTime.utc_now() |> DateTime.to_iso8601(), - "emoji" => Emoji.Formatter.get_emoji_map(content) - }, []} + def chat_message(actor, recipient, content, opts \\ []) do + basic = %{ + "id" => Utils.generate_object_id(), + "actor" => actor.ap_id, + "type" => "ChatMessage", + "to" => [recipient], + "content" => content, + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "emoji" => Emoji.Formatter.get_emoji_map(content) + } + + case opts[:attachment] do + %Object{data: attachment_data} -> + { + :ok, + Map.put(basic, "attachment", attachment_data), + [] + } + + _ -> + {:ok, basic, []} + end end @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 20c7cceb6..d6c14f7b8 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -63,11 +63,18 @@ def stringify_keys(%{__struct__: _} = object) do |> stringify_keys end - def stringify_keys(object) do + def stringify_keys(object) when is_map(object) do object - |> Map.new(fn {key, val} -> {to_string(key), val} end) + |> Map.new(fn {key, val} -> {to_string(key), stringify_keys(val)} end) end + def stringify_keys(object) when is_list(object) do + object + |> Enum.map(&stringify_keys/1) + end + + def stringify_keys(object), do: object + def fetch_actor(object) do with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do User.get_or_fetch_by_ap_id(actor) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex new file mode 100644 index 000000000..16ed49051 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field(:type, :string) + field(:mediaType, :string) + field(:name, :string) + + embeds_many(:url, UrlObjectValidator) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + data = + data + |> fix_media_type() + |> fix_url() + + struct + |> cast(data, [:type, :mediaType, :name]) + |> cast_embed(:url, required: true) + end + + def fix_media_type(data) do + data + |> Map.put_new("mediaType", data["mimeType"]) + end + + def fix_url(data) do + case data["url"] do + url when is_binary(url) -> + data + |> Map.put( + "url", + [ + %{ + "href" => url, + "type" => "Link", + "mediaType" => data["mediaType"] + } + ] + ) + + _ -> + data + end + end + + def validate_data(cng) do + cng + |> validate_required([:mediaType, :url, :type]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index e87c1ac2e..99ffeba28 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator import Ecto.Changeset import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] @@ -22,6 +23,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do field(:actor, Types.ObjectID) field(:published, Types.DateTime) field(:emoji, :map, default: %{}) + + embeds_one(:attachment, AttachmentValidator) end def cast_and_apply(data) do @@ -51,7 +54,8 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields)) + |> cast(data, List.delete(__schema__(:fields), :attachment)) + |> cast_embed(:attachment) end def validate_data(data_cng) do diff --git a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex new file mode 100644 index 000000000..47e231150 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + @primary_key false + + embedded_schema do + field(:type, :string) + field(:href, Types.Uri) + field(:mediaType, :string) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> validate_required([:type, :href, :mediaType]) + end +end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 0fe0e07b2..8b9dc2e44 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -236,7 +236,8 @@ def chat_message_create do description: "POST body for creating an chat message", type: :object, properties: %{ - content: %Schema{type: :string, description: "The content of your message"} + content: %Schema{type: :string, description: "The content of your message"}, + media_id: %Schema{type: :string, description: "The id of an upload"} }, required: [:content], example: %{ diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 7c93b0c83..89e062ddd 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -17,7 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do chat_id: %Schema{type: :string}, content: %Schema{type: :string}, created_at: %Schema{type: :string, format: :"date-time"}, - emojis: %Schema{type: :array} + emojis: %Schema{type: :array}, + attachment: %Schema{type: :object, nullable: true} }, example: %{ "account_id" => "someflakeid", @@ -32,7 +33,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do "url" => "https://dontbulling.me/emoji/Firefox.gif" } ], - "id" => "14" + "id" => "14", + "attachment" => nil } }) end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index e428cc17d..38b5c6f7c 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -25,14 +25,16 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger - def post_chat_message(%User{} = user, %User{} = recipient, content) do + def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do with :ok <- validate_chat_content_length(content), + maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( user, recipient.ap_id, - content |> Formatter.html_escape("text/plain") + content |> Formatter.html_escape("text/plain"), + attachment: maybe_attachment )}, {_, {:ok, create_activity_data, _meta}} <- {:build_create_activity, Builder.create(user, chat_message_data, [recipient.ap_id])}, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index bedae73bd..450d85332 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -36,14 +36,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation def post_chat_message( - %{body_params: %{content: content}, assigns: %{user: %{id: user_id} = user}} = conn, + %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} = + conn, %{ id: id } ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), - {:ok, activity} <- CommonAPI.post_chat_message(user, recipient, content), + {:ok, activity} <- + CommonAPI.post_chat_message(user, recipient, content, media_id: params[:media_id]), message <- Object.normalize(activity) do conn |> put_view(ChatMessageView) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex index a821479ab..b088a8734 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex @@ -23,7 +23,10 @@ def render( chat_id: chat_id |> to_string(), account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, created_at: Utils.to_masto_date(chat_message["published"]), - emojis: StatusView.build_emojis(chat_message["emoji"]) + emojis: StatusView.build_emojis(chat_message["emoji"]), + attachment: + chat_message["attachment"] && + StatusView.render("attachment.json", attachment: chat_message["attachment"]) } end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 60db7187f..951ed7800 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -2,14 +2,41 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory + describe "attachments" do + test "it turns mastodon attachments into our attachments" do + attachment = %{ + "url" => + "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", + "type" => "Document", + "name" => nil, + "mediaType" => "image/jpeg" + } + + {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + + assert [ + %{ + href: + "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", + type: "Link", + mediaType: "image/jpeg" + } + ] = attachment.url + end + end + describe "chat message create activities" do test "it is invalid if the object already exists" do user = insert(:user) @@ -52,7 +79,28 @@ test "it is invalid if the object data has a different `to` or `actor` field" do test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - assert object == valid_chat_message + assert Map.put(valid_chat_message, "attachment", nil) == object + end + + test "validates for a basic object with an attachment", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", attachment.data) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] end test "does not validate if the message is longer than the remote_limit", %{ 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 834213182..c8911948e 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 @@ -1,3 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID use Pleroma.DataCase diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index cdb2683c8..72a9a91ff 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do alias Pleroma.Chat alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory @@ -49,6 +50,32 @@ test "it posts a message to the chat", %{conn: conn, user: user} do assert result["content"] == "Hallo!!" assert result["chat_id"] == chat.id |> to_string() end + + test "it works with an attachment", %{conn: conn, user: user} do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{ + "content" => "Hallo!!", + "media_id" => to_string(upload.id) + }) + |> json_response_and_validate_schema(200) + + assert result["content"] == "Hallo!!" + assert result["chat_id"] == chat.id |> to_string() + end end describe "GET /api/v1/pleroma/chats/:id/messages" do diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index 5c4c8b0d5..a13a41daa 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -9,12 +9,21 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory test "it displays a chat message" do user = insert(:user) recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") chat = Chat.get(user.id, recipient.ap_id) @@ -30,7 +39,7 @@ test "it displays a chat message" do assert chat_message[:created_at] assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) - {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk") + {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) object = Object.normalize(activity) @@ -40,5 +49,6 @@ test "it displays a chat message" do assert chat_message_two[:content] == "gkgkgk" assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] + assert chat_message_two[:attachment] end end -- cgit v1.2.3 From fc9d0b6eec1b206a27f4ec19f7939b3318a209ef Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 6 May 2020 16:31:21 +0200 Subject: Credo fixes. --- .../web/activity_pub/object_validators/chat_message_validator.ex | 2 +- test/web/activity_pub/object_validator_test.exs | 2 +- test/web/pleroma_api/controllers/chat_controller_test.exs | 2 +- test/web/pleroma_api/views/chat_message_view_test.exs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 99ffeba28..e40c80ab4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 951ed7800..fcc54c8a1 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 72a9a91ff..b4b73da90 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,8 +5,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat - alias Pleroma.Web.CommonAPI alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI import Pleroma.Factory diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs index a13a41daa..d7a2d10a5 100644 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_view_test.exs @@ -7,9 +7,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do alias Pleroma.Chat alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.PleromaAPI.ChatMessageView - alias Pleroma.Web.ActivityPub.ActivityPub import Pleroma.Factory -- cgit v1.2.3 From 4b00eb93fe2cef97a5570b9cc6e6844898d31b9a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 6 May 2020 18:04:16 +0300 Subject: fix for syslog compile with updated rebar3 --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index ee9d93bfb..28287cf97 100644 --- a/mix.lock +++ b/mix.lock @@ -37,7 +37,7 @@ "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, - "ex_syslogger": {:hex, :ex_syslogger, "1.5.0", "bc936ee3fd13d9e592cb4c3a1e8a55fccd33b05e3aa7b185f211f3ed263ff8f0", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.0.5", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "f3b4b184dcdd5f356b7c26c6cd72ab0918ba9dfb4061ccfaf519e562942af87b"}, + "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"}, "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, @@ -102,7 +102,7 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, - "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"}, + "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]}, "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, -- cgit v1.2.3 From 57736c18332b0017e01d90e56547af1f5f830b7a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 6 May 2020 16:30:05 -0500 Subject: Privacy option affects all push notifications, not just Direct Messages --- lib/pleroma/web/push/impl.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index a9f893f7b..7f80bb0c9 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -106,14 +106,13 @@ def build_content(notification, actor, object, mastodon_type \\ nil) def build_content( %{ - activity: %{data: %{"directMessage" => true}}, user: %{notification_settings: %{privacy_option: true}} - }, + } = notification, actor, _object, - _mastodon_type + mastodon_type ) do - %{title: "New Direct Message", body: "@#{actor.nickname}"} + %{title: format_title(notification, mastodon_type), body: "@#{actor.nickname}"} end def build_content(notification, actor, object, mastodon_type) do -- cgit v1.2.3 From a2580adc91ac757e47b88839f5fb723fb15305b1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 6 May 2020 16:42:27 -0500 Subject: Hide the sender when privacy option is enabled --- lib/pleroma/web/push/impl.ex | 4 ++-- test/web/push/impl_test.exs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 7f80bb0c9..691725702 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -108,11 +108,11 @@ def build_content( %{ user: %{notification_settings: %{privacy_option: true}} } = notification, - actor, + _actor, _object, mastodon_type ) do - %{title: format_title(notification, mastodon_type), body: "@#{actor.nickname}"} + %{body: format_title(notification, mastodon_type)} end def build_content(notification, actor, object, mastodon_type) do diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index b2664bf28..3de911810 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -209,8 +209,7 @@ test "returns info content for direct message with enabled privacy option" do object = Object.normalize(activity) assert Impl.build_content(notif, actor, object) == %{ - body: "@Bob", - title: "New Direct Message" + body: "New Direct Message" } end -- cgit v1.2.3 From 3d0c567fbc3506770fdac5f1269c45b244928747 Mon Sep 17 00:00:00 2001 From: Maksim Date: Thu, 7 May 2020 08:14:54 +0000 Subject: Pleroma.Web.TwitterAPI.TwoFactorAuthenticationController -> Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController --- config/config.exs | 13 +- config/description.exs | 56 ++++ config/test.exs | 13 + docs/API/admin_api.md | 9 + docs/API/pleroma_api.md | 44 ++- docs/configuration/cheatsheet.md | 9 +- lib/pleroma/mfa.ex | 156 +++++++++++ lib/pleroma/mfa/backup_codes.ex | 31 +++ lib/pleroma/mfa/changeset.ex | 64 +++++ lib/pleroma/mfa/settings.ex | 24 ++ lib/pleroma/mfa/token.ex | 106 +++++++ lib/pleroma/mfa/totp.ex | 86 ++++++ lib/pleroma/plugs/ensure_authenticated_plug.ex | 14 + lib/pleroma/user.ex | 8 + lib/pleroma/web/admin_api/admin_api_controller.ex | 14 + lib/pleroma/web/auth/pleroma_authenticator.ex | 4 +- lib/pleroma/web/auth/totp_authenticator.ex | 45 +++ lib/pleroma/web/common_api/utils.ex | 1 + lib/pleroma/web/oauth/mfa_controller.ex | 97 +++++++ lib/pleroma/web/oauth/mfa_view.ex | 8 + lib/pleroma/web/oauth/oauth_controller.ex | 48 +++- lib/pleroma/web/oauth/token/clean_worker.ex | 38 +++ lib/pleroma/web/oauth/token/response.ex | 9 + .../two_factor_authentication_controller.ex | 133 +++++++++ lib/pleroma/web/router.ex | 15 + .../web/templates/o_auth/mfa/recovery.html.eex | 24 ++ lib/pleroma/web/templates/o_auth/mfa/totp.html.eex | 24 ++ .../twitter_api/remote_follow/follow_mfa.html.eex | 13 + .../controllers/remote_follow_controller.ex | 47 +++- mix.exs | 1 + mix.lock | 33 +-- ...ulti_factor_authentication_settings_to_user.exs | 9 + .../20190508193213_create_mfa_tokens.exs | 16 ++ .../static/fonts/element-icons.535877f.woff | Bin 28200 -> 0 bytes .../adminfe/static/fonts/element-icons.732389d.ttf | Bin 55956 -> 0 bytes test/mfa/backup_codes_test.exs | 11 + test/mfa/totp_test.exs | 17 ++ test/mfa_test.exs | 53 ++++ test/plugs/ensure_authenticated_plug_test.exs | 25 ++ test/support/builders/user_builder.ex | 1 + test/support/factory.ex | 12 +- test/user_search_test.exs | 1 + test/web/admin_api/admin_api_controller_test.exs | 33 +++ test/web/auth/pleroma_authenticator_test.exs | 43 +++ test/web/auth/totp_authenticator_test.exs | 51 ++++ test/web/oauth/mfa_controller_test.exs | 306 +++++++++++++++++++++ test/web/oauth/oauth_controller_test.exs | 77 ++++++ .../two_factor_authentication_controller_test.exs | 260 +++++++++++++++++ .../twitter_api/remote_follow_controller_test.exs | 116 ++++++++ 49 files changed, 2184 insertions(+), 34 deletions(-) create mode 100644 lib/pleroma/mfa.ex create mode 100644 lib/pleroma/mfa/backup_codes.ex create mode 100644 lib/pleroma/mfa/changeset.ex create mode 100644 lib/pleroma/mfa/settings.ex create mode 100644 lib/pleroma/mfa/token.ex create mode 100644 lib/pleroma/mfa/totp.ex create mode 100644 lib/pleroma/web/auth/totp_authenticator.ex create mode 100644 lib/pleroma/web/oauth/mfa_controller.ex create mode 100644 lib/pleroma/web/oauth/mfa_view.ex create mode 100644 lib/pleroma/web/oauth/token/clean_worker.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex create mode 100644 lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex create mode 100644 lib/pleroma/web/templates/o_auth/mfa/totp.html.eex create mode 100644 lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex create mode 100644 priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs create mode 100644 priv/repo/migrations/20190508193213_create_mfa_tokens.exs delete mode 100644 priv/static/adminfe/static/fonts/element-icons.535877f.woff delete mode 100644 priv/static/adminfe/static/fonts/element-icons.732389d.ttf create mode 100644 test/mfa/backup_codes_test.exs create mode 100644 test/mfa/totp_test.exs create mode 100644 test/mfa_test.exs create mode 100644 test/web/auth/pleroma_authenticator_test.exs create mode 100644 test/web/auth/totp_authenticator_test.exs create mode 100644 test/web/oauth/mfa_controller_test.exs create mode 100644 test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs diff --git a/config/config.exs b/config/config.exs index ca9bbab64..e703c1632 100644 --- a/config/config.exs +++ b/config/config.exs @@ -238,7 +238,18 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false + cleanup_attachments: false, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 5, + length: 16 + ] + ] config :pleroma, :extensions, output_relationships_in_statuses_by_default: true diff --git a/config/description.exs b/config/description.exs index 1b2afebef..39e094082 100644 --- a/config/description.exs +++ b/config/description.exs @@ -919,6 +919,62 @@ key: :external_user_synchronization, type: :boolean, description: "Enabling following/followers counters synchronization for external users" + }, + %{ + key: :multi_factor_authentication, + type: :keyword, + description: "Multi-factor authentication settings", + suggestions: [ + [ + totp: [digits: 6, period: 30], + backup_codes: [number: 5, length: 16] + ] + ], + children: [ + %{ + key: :totp, + type: :keyword, + description: "TOTP settings", + suggestions: [digits: 6, period: 30], + children: [ + %{ + key: :digits, + type: :integer, + suggestions: [6], + description: + "Determines the length of a one-time pass-code, in characters. Defaults to 6 characters." + }, + %{ + key: :period, + type: :integer, + suggestions: [30], + description: + "a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds." + } + ] + }, + %{ + key: :backup_codes, + type: :keyword, + description: "MFA backup codes settings", + suggestions: [number: 5, length: 16], + children: [ + %{ + key: :number, + type: :integer, + suggestions: [5], + description: "number of backup codes to generate." + }, + %{ + key: :length, + type: :integer, + suggestions: [16], + description: + "Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters." + } + ] + } + ] } ] }, diff --git a/config/test.exs b/config/test.exs index cbf775109..e38b9967d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -56,6 +56,19 @@ ignore_hosts: [], ignore_tld: ["local", "localdomain", "lan"] +config :pleroma, :instance, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 2, + length: 6 + ] + ] + config :web_push_encryption, :vapid_details, subject: "mailto:administrator@example.com", public_key: diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 23af08961..c455047cc 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -409,6 +409,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ### Get a password reset token for a given nickname + - Params: none - Response: @@ -427,6 +428,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - `nicknames` - Response: none (code `204`) +## PUT `/api/pleroma/admin/users/disable_mfa` + +### Disable mfa for user's account. + +- Params: + - `nickname` +- Response: User’s nickname + ## `GET /api/pleroma/admin/users/:nickname/credentials` ### Get the user's email, password, display and settings-related fields diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index b927be026..5895613a3 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -70,7 +70,49 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi * Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise * Example response: `{"error": "Invalid password."}` -## `/api/pleroma/admin/`… +## `/api/pleroma/accounts/mfa` +#### Gets current MFA settings +* method: `GET` +* Authentication: required +* OAuth scope: `read:security` +* Response: JSON. Returns `{"enabled": "false", "totp": false }` + +## `/api/pleroma/accounts/mfa/setup/totp` +#### Pre-setup the MFA/TOTP method +* method: `GET` +* Authentication: required +* OAuth scope: `write:security` +* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]" }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}` + +## `/api/pleroma/accounts/mfa/confirm/totp` +#### Confirms & enables MFA/TOTP support for user account. +* method: `POST` +* Authentication: required +* OAuth scope: `write:security` +* Params: + * `password`: user's password + * `code`: token from TOTP App +* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise + + +## `/api/pleroma/accounts/mfa/totp` +#### Disables MFA/TOTP method for user account. +* method: `DELETE` +* Authentication: required +* OAuth scope: `write:security` +* Params: + * `password`: user's password +* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise +* Example response: `{"error": "Invalid password."}` + +## `/api/pleroma/accounts/mfa/backup_codes` +#### Generstes backup codes MFA for user account. +* method: `GET` +* Authentication: required +* OAuth scope: `write:security` +* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}` + +## `/api/pleroma/admin/` See [Admin-API](admin_api.md) ## `/api/v1/pleroma/notifications/read` diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2524918d4..707d7fdbd 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -907,12 +907,18 @@ config :auto_linker, * `runtime_dir`: A path to custom Elixir modules (such as MRF policies). - ## :configurable_from_database Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. +### Multi-factor authentication - :two_factor_authentication +* `totp` - a list containing TOTP configuration + - `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters. + - `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds. +* `backup_codes` - a list containing backup codes configuration + - `number` - number of backup codes to generate. + - `length` - backup code length. Defaults to 16 characters. ## Restrict entities access for unauthenticated users @@ -930,6 +936,7 @@ Restrict access for unauthenticated users to timelines (public and federate), us * `local` * `remote` + ## Pleroma.Web.ApiSpec.CastAndValidate * `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`. diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex new file mode 100644 index 000000000..d353a4dad --- /dev/null +++ b/lib/pleroma/mfa.ex @@ -0,0 +1,156 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA do + @moduledoc """ + The MFA context. + """ + + alias Comeonin.Pbkdf2 + alias Pleroma.User + + alias Pleroma.MFA.BackupCodes + alias Pleroma.MFA.Changeset + alias Pleroma.MFA.Settings + alias Pleroma.MFA.TOTP + + @doc """ + Returns MFA methods the user has enabled. + + ## Examples + + iex> Pleroma.MFA.supported_method(User) + "totp, u2f" + """ + @spec supported_methods(User.t()) :: String.t() + def supported_methods(user) do + settings = fetch_settings(user) + + Settings.mfa_methods() + |> Enum.reduce([], fn m, acc -> + if method_enabled?(m, settings) do + acc ++ [m] + else + acc + end + end) + |> Enum.join(",") + end + + @doc "Checks that user enabled MFA" + def require?(user) do + fetch_settings(user).enabled + end + + @doc """ + Display MFA settings of user + """ + def mfa_settings(user) do + settings = fetch_settings(user) + + Settings.mfa_methods() + |> Enum.map(fn m -> [m, method_enabled?(m, settings)] end) + |> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end) + end + + @doc false + def fetch_settings(%User{} = user) do + user.multi_factor_authentication_settings || %Settings{} + end + + @doc "clears backup codes" + def invalidate_backup_code(%User{} = user, hash_code) do + %{backup_codes: codes} = fetch_settings(user) + + user + |> Changeset.cast_backup_codes(codes -- [hash_code]) + |> User.update_and_set_cache() + end + + @doc "generates backup codes" + @spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()} + def generate_backup_codes(%User{} = user) do + with codes <- BackupCodes.generate(), + hashed_codes <- Enum.map(codes, &Pbkdf2.hashpwsalt/1), + changeset <- Changeset.cast_backup_codes(user, hashed_codes), + {:ok, _} <- User.update_and_set_cache(changeset) do + {:ok, codes} + else + {:error, msg} -> + %{error: msg} + end + end + + @doc """ + Generates secret key and set delivery_type to 'app' for TOTP method. + """ + @spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def setup_totp(user) do + user + |> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"}) + |> User.update_and_set_cache() + end + + @doc """ + Confirms the TOTP method for user. + + `attrs`: + `password` - current user password + `code` - TOTP token + """ + @spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()} + def confirm_totp(%User{} = user, attrs) do + with settings <- user.multi_factor_authentication_settings.totp, + {:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do + user + |> Changeset.confirm_totp() + |> User.update_and_set_cache() + end + end + + @doc """ + Disables the TOTP method for user. + + `attrs`: + `password` - current user password + """ + @spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def disable_totp(%User{} = user) do + user + |> Changeset.disable_totp() + |> Changeset.disable() + |> User.update_and_set_cache() + end + + @doc """ + Force disables all MFA methods for user. + """ + @spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def disable(%User{} = user) do + user + |> Changeset.disable_totp() + |> Changeset.disable(true) + |> User.update_and_set_cache() + end + + @doc """ + Checks if the user has MFA method enabled. + """ + def method_enabled?(method, settings) do + with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do + true + else + _ -> false + end + end + + @doc """ + Checks if the user has enabled at least one MFA method. + """ + def enabled?(settings) do + Settings.mfa_methods() + |> Enum.map(fn m -> method_enabled?(m, settings) end) + |> Enum.any?() + end +end diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex new file mode 100644 index 000000000..2b5ec34f8 --- /dev/null +++ b/lib/pleroma/mfa/backup_codes.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.BackupCodes do + @moduledoc """ + This module contains functions for generating backup codes. + """ + alias Pleroma.Config + + @config_ns [:instance, :multi_factor_authentication, :backup_codes] + + @doc """ + Generates backup codes. + """ + @spec generate(Keyword.t()) :: list(String.t()) + def generate(opts \\ []) do + number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number()) + code_length = Keyword.get(opts, :length, default_backup_codes_code_length()) + + Enum.map(1..number_of_codes, fn _ -> + :crypto.strong_rand_bytes(div(code_length, 2)) + |> Base.encode16(case: :lower) + end) + end + + defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5) + + defp default_backup_codes_code_length, + do: Config.get(@config_ns ++ [:length], 16) +end diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex new file mode 100644 index 000000000..9b020aa8e --- /dev/null +++ b/lib/pleroma/mfa/changeset.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Changeset do + alias Pleroma.MFA + alias Pleroma.MFA.Settings + alias Pleroma.User + + def disable(%Ecto.Changeset{} = changeset, force \\ false) do + settings = + changeset + |> Ecto.Changeset.apply_changes() + |> MFA.fetch_settings() + + if force || not MFA.enabled?(settings) do + put_change(changeset, %Settings{settings | enabled: false}) + else + changeset + end + end + + def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do + user + |> put_change(%Settings{settings | totp: %Settings.TOTP{}}) + end + + def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do + totp_settings = %Settings.TOTP{settings.totp | confirmed: true} + + user + |> put_change(%Settings{settings | totp: totp_settings, enabled: true}) + end + + def setup_totp(%User{} = user, attrs) do + mfa_settings = MFA.fetch_settings(user) + + totp_settings = + %Settings.TOTP{} + |> Ecto.Changeset.cast(attrs, [:secret, :delivery_type]) + + user + |> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)}) + end + + def cast_backup_codes(%User{} = user, codes) do + user + |> put_change(%Settings{ + user.multi_factor_authentication_settings + | backup_codes: codes + }) + end + + defp put_change(%User{} = user, settings) do + user + |> Ecto.Changeset.change() + |> put_change(settings) + end + + defp put_change(%Ecto.Changeset{} = changeset, settings) do + changeset + |> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings) + end +end diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex new file mode 100644 index 000000000..2764b889c --- /dev/null +++ b/lib/pleroma/mfa/settings.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Settings do + use Ecto.Schema + + @primary_key false + + @mfa_methods [:totp] + embedded_schema do + field(:enabled, :boolean, default: false) + field(:backup_codes, {:array, :string}, default: []) + + embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do + field(:secret, :string) + # app | sms + field(:delivery_type, :string, default: "app") + field(:confirmed, :boolean, default: false) + end + end + + def mfa_methods, do: @mfa_methods +end diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex new file mode 100644 index 000000000..25ff7fb29 --- /dev/null +++ b/lib/pleroma/mfa/token.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.Token do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.Token, as: OAuthToken + + @expires 300 + + schema "mfa_tokens" do + field(:token, :string) + field(:valid_until, :naive_datetime_usec) + + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:authorization, Authorization) + + timestamps() + end + + def get_by_token(token) do + from( + t in __MODULE__, + where: t.token == ^token, + preload: [:user, :authorization] + ) + |> Repo.find_resource() + end + + def validate(token) do + with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)}, + {:expired, false} <- {:expired, is_expired?(token)} do + {:ok, token} + else + {:expired, _} -> {:error, :expired_token} + {:fetch_token, _} -> {:error, :not_found} + error -> {:error, error} + end + end + + def create_token(%User{} = user) do + %__MODULE__{} + |> change + |> assign_user(user) + |> put_token + |> put_valid_until + |> Repo.insert() + end + + def create_token(user, authorization) do + %__MODULE__{} + |> change + |> assign_user(user) + |> assign_authorization(authorization) + |> put_token + |> put_valid_until + |> Repo.insert() + end + + defp assign_user(changeset, user) do + changeset + |> put_assoc(:user, user) + |> validate_required([:user]) + end + + defp assign_authorization(changeset, authorization) do + changeset + |> put_assoc(:authorization, authorization) + |> validate_required([:authorization]) + end + + defp put_token(changeset) do + changeset + |> change(%{token: OAuthToken.Utils.generate_token()}) + |> validate_required([:token]) + |> unique_constraint(:token) + end + + defp put_valid_until(changeset) do + expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires) + + changeset + |> change(%{valid_until: expires_in}) + |> validate_required([:valid_until]) + end + + def is_expired?(%__MODULE__{valid_until: valid_until}) do + NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0 + end + + def is_expired?(_), do: false + + def delete_expired_tokens do + from( + q in __MODULE__, + where: fragment("?", q.valid_until) < ^Timex.now() + ) + |> Repo.delete_all() + end +end diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex new file mode 100644 index 000000000..1407afc57 --- /dev/null +++ b/lib/pleroma/mfa/totp.ex @@ -0,0 +1,86 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFA.TOTP do + @moduledoc """ + This module represents functions to create secrets for + TOTP Application as well as validate them with a time based token. + """ + alias Pleroma.Config + + @config_ns [:instance, :multi_factor_authentication, :totp] + + @doc """ + https://github.com/google/google-authenticator/wiki/Key-Uri-Format + """ + def provisioning_uri(secret, label, opts \\ []) do + query = + %{ + secret: secret, + issuer: Keyword.get(opts, :issuer, default_issuer()), + digits: Keyword.get(opts, :digits, default_digits()), + period: Keyword.get(opts, :period, default_period()) + } + |> Enum.filter(fn {_, v} -> not is_nil(v) end) + |> Enum.into(%{}) + |> URI.encode_query() + + %URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query} + |> URI.to_string() + end + + defp default_period, do: Config.get(@config_ns ++ [:period]) + defp default_digits, do: Config.get(@config_ns ++ [:digits]) + + defp default_issuer, + do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name])) + + @doc "Creates a random Base 32 encoded string" + def generate_secret do + Base.encode32(:crypto.strong_rand_bytes(10)) + end + + @doc "Generates a valid token based on a secret" + def generate_token(secret) do + :pot.totp(secret) + end + + @doc """ + Validates a given token based on a secret. + + optional parameters: + `token_length` default `6` + `interval_length` default `30` + `window` default 0 + + Returns {:ok, :pass} if the token is valid and + {:error, :invalid_token} if it is not. + """ + @spec validate_token(String.t(), String.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def validate_token(secret, token) + when is_binary(secret) and is_binary(token) do + opts = [ + token_length: default_digits(), + interval_length: default_period() + ] + + validate_token(secret, token, opts) + end + + def validate_token(_, _), do: {:error, :invalid_secret_and_token} + + @doc "See `validate_token/2`" + @spec validate_token(String.t(), String.t(), Keyword.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def validate_token(secret, token, options) + when is_binary(secret) and is_binary(token) do + case :pot.valid_totp(token, secret, options) do + true -> {:ok, :pass} + false -> {:error, :invalid_token} + end + end + + def validate_token(_, _, _), do: {:error, :invalid_secret_and_token} +end diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 9d5176e2b..3fe550806 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -15,6 +15,20 @@ def init(options) do end @impl true + def perform( + %{ + assigns: %{ + auth_credentials: %{password: _}, + user: %User{multi_factor_authentication_settings: %{enabled: true}} + } + } = conn, + _ + ) do + conn + |> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.") + |> halt() + end + def perform(%{assigns: %{user: %User{}}} = conn, _) do conn end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 323eb2a41..a6f51f0be 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -20,6 +20,7 @@ defmodule Pleroma.User do alias Pleroma.Formatter alias Pleroma.HTML alias Pleroma.Keys + alias Pleroma.MFA alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Registration @@ -190,6 +191,12 @@ defmodule Pleroma.User do # `:subscribers` is deprecated (replaced with `subscriber_users` relation) field(:subscribers, {:array, :string}, default: []) + embeds_one( + :multi_factor_authentication_settings, + MFA.Settings, + on_replace: :delete + ) + timestamps() end @@ -927,6 +934,7 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do end end + @spec get_by_nickname(String.t()) :: User.t() | nil def get_by_nickname(nickname) do Repo.get_by(User, nickname: nickname) || if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 80a4ebaac..9f1fd3aeb 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Activity alias Pleroma.Config alias Pleroma.ConfigDB + alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.ReportNote @@ -61,6 +62,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :right_add, :right_add_multiple, :right_delete, + :disable_mfa, :right_delete_multiple, :update_user_credentials ] @@ -674,6 +676,18 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic json_response(conn, :no_content, "") end + @doc "Disable mfa for user's account." + def disable_mfa(conn, %{"nickname" => nickname}) do + case User.get_by_nickname(nickname) do + %User{} = user -> + MFA.disable(user) + json(conn, nickname) + + _ -> + {:error, :not_found} + end + end + @doc "Show a given user's credentials" def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index cb09664ce..a8f554aa3 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -19,8 +19,8 @@ def get_user(%Plug.Conn{} = conn) do {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do {:ok, user} else - error -> - {:error, error} + {:error, _reason} = error -> error + error -> {:error, error} end end diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex new file mode 100644 index 000000000..98aca9a51 --- /dev/null +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.TOTPAuthenticator do + alias Comeonin.Pbkdf2 + alias Pleroma.MFA + alias Pleroma.MFA.TOTP + alias Pleroma.User + + @doc "Verify code or check backup code." + @spec verify(String.t(), User.t()) :: + {:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token} + def verify( + token, + %User{ + multi_factor_authentication_settings: + %{enabled: true, totp: %{secret: secret, confirmed: true}} = _ + } = _user + ) + when is_binary(token) and byte_size(token) > 0 do + TOTP.validate_token(secret, token) + end + + def verify(_, _), do: {:error, :invalid_token} + + @spec verify_recovery_code(User.t(), String.t()) :: + {:ok, :pass} | {:error, :invalid_token} + def verify_recovery_code( + %User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user, + code + ) + when is_list(codes) and is_binary(code) do + hash_code = Enum.find(codes, fn hash -> Pbkdf2.checkpw(code, hash) end) + + if hash_code do + MFA.invalidate_backup_code(user, hash_code) + {:ok, :pass} + else + {:error, :invalid_token} + end + end + + def verify_recovery_code(_, _), do: {:error, :invalid_token} +end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 6540fa5d1..793f2e7f8 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -402,6 +402,7 @@ defp shortname(name) do end end + @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()} def confirm_current_password(user, password) do with %User{local: true} = db_user <- User.get_cached_by_id(user.id), true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex new file mode 100644 index 000000000..e52cccd85 --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_controller.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAController do + @moduledoc """ + The model represents api to use Multi Factor authentications. + """ + + use Pleroma.Web, :controller + + alias Pleroma.MFA + alias Pleroma.Web.Auth.TOTPAuthenticator + alias Pleroma.Web.OAuth.MFAView, as: View + alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.Token + + plug(:fetch_session when action in [:show, :verify]) + plug(:fetch_flash when action in [:show, :verify]) + + @doc """ + Display form to input mfa code or recovery code. + """ + def show(conn, %{"mfa_token" => mfa_token} = params) do + template = Map.get(params, "challenge_type", "totp") + + conn + |> put_view(View) + |> render("#{template}.html", %{ + mfa_token: mfa_token, + redirect_uri: params["redirect_uri"], + state: params["state"] + }) + end + + @doc """ + Verification code and continue authorization. + """ + def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do + with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), + {:ok, _} <- validates_challenge(user, mfa_params) do + conn + |> OAuthController.after_create_authorization(auth, %{ + "authorization" => %{ + "redirect_uri" => mfa_params["redirect_uri"], + "state" => mfa_params["state"] + } + }) + else + _ -> + conn + |> put_flash(:error, "Two-factor authentication failed.") + |> put_status(:unauthorized) + |> show(mfa_params) + end + end + + @doc """ + Verification second step of MFA (or recovery) and returns access token. + + ## Endpoint + POST /oauth/mfa/challenge + + params: + `client_id` + `client_secret` + `mfa_token` - access token to check second step of mfa + `challenge_type` - 'totp' or 'recovery' + `code` + + """ + def challenge(conn, %{"mfa_token" => mfa_token} = params) do + with {:ok, app} <- Token.Utils.fetch_app(conn), + {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), + {:ok, _} <- validates_challenge(user, params), + {:ok, token} <- Token.exchange_token(app, auth) do + json(conn, Token.Response.build(user, token)) + else + _error -> + conn + |> put_status(400) + |> json(%{error: "Invalid code"}) + end + end + + # Verify TOTP Code + defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do + TOTPAuthenticator.verify(code, user) + end + + # Verify Recovery Code + defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do + TOTPAuthenticator.verify_recovery_code(user, code) + end + + defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type} +end diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex new file mode 100644 index 000000000..e88e7066b --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_view.ex @@ -0,0 +1,8 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAView do + use Pleroma.Web, :view + import Phoenix.HTML.Form +end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 685269877..7c804233c 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper + alias Pleroma.MFA alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration alias Pleroma.Repo @@ -14,6 +15,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.ControllerHelper alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.MFAController alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken @@ -121,7 +123,8 @@ def create_authorization( %{"authorization" => _} = params, opts \\ [] ) do - with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do + with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do after_create_authorization(conn, auth, params) else error -> @@ -179,6 +182,22 @@ defp handle_create_authorization_error( |> authorize(params) end + defp handle_create_authorization_error( + %Plug.Conn{} = conn, + {:mfa_required, user, auth, _}, + params + ) do + {:ok, token} = MFA.Token.create_token(user, auth) + + data = %{ + "mfa_token" => token.token, + "redirect_uri" => params["authorization"]["redirect_uri"], + "state" => params["authorization"]["state"] + } + + MFAController.show(conn, data) + end + defp handle_create_authorization_error( %Plug.Conn{} = conn, {:account_status, :password_reset_pending}, @@ -231,7 +250,8 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} json(conn, Token.Response.build(user, token, response_attrs)) else - _error -> render_invalid_credentials_error(conn) + error -> + handle_token_exchange_error(conn, error) end end @@ -244,6 +264,7 @@ def token_exchange( {:account_status, :active} <- {:account_status, User.account_status(user)}, {:ok, scopes} <- validate_scopes(app, params), {:ok, auth} <- Authorization.create_authorization(app, user, scopes), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)}, {:ok, token} <- Token.exchange_token(app, auth) do json(conn, Token.Response.build(user, token)) else @@ -270,13 +291,20 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} {:ok, token} <- Token.exchange_token(app, auth) do json(conn, Token.Response.build_for_client_credentials(token)) else - _error -> render_invalid_credentials_error(conn) + _error -> + handle_token_exchange_error(conn, :invalid_credentails) end end # Bad request def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params) + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do + conn + |> put_status(:forbidden) + |> json(build_and_response_mfa_token(user, auth)) + end + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do render_error( conn, @@ -434,7 +462,8 @@ def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn), %Registration{} = registration <- Repo.get(Registration, registration_id), - {_, {:ok, auth}} <- {:create_authorization, do_create_authorization(conn, params)}, + {_, {:ok, auth, _user}} <- + {:create_authorization, do_create_authorization(conn, params)}, %User{} = user <- Repo.preload(auth, :user).user, {:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do conn @@ -500,8 +529,9 @@ defp do_create_authorization( %App{} = app <- Repo.get_by(App, client_id: client_id), true <- redirect_uri in String.split(app.redirect_uris), {:ok, scopes} <- validate_scopes(app, auth_attrs), - {:account_status, :active} <- {:account_status, User.account_status(user)} do - Authorization.create_authorization(app, user, scopes) + {:account_status, :active} <- {:account_status, User.account_status(user)}, + {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do + {:ok, auth, user} end end @@ -515,6 +545,12 @@ defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :re defp put_session_registration_id(%Plug.Conn{} = conn, registration_id), do: put_session(conn, :registration_id, registration_id) + defp build_and_response_mfa_token(user, auth) do + with {:ok, token} <- MFA.Token.create_token(user, auth) do + Token.Response.build_for_mfa_token(user, token) + end + end + @spec validate_scopes(App.t(), map()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} defp validate_scopes(%App{} = app, params) do diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex new file mode 100644 index 000000000..2c3bb9ded --- /dev/null +++ b/lib/pleroma/web/oauth/token/clean_worker.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.Token.CleanWorker do + @moduledoc """ + The module represents functions to clean an expired OAuth and MFA tokens. + """ + use GenServer + + @ten_seconds 10_000 + @one_day 86_400_000 + + alias Pleroma.MFA + alias Pleroma.Web.OAuth + alias Pleroma.Workers.BackgroundWorker + + def start_link(_), do: GenServer.start_link(__MODULE__, %{}) + + def init(_) do + Process.send_after(self(), :perform, @ten_seconds) + {:ok, nil} + end + + @doc false + def handle_info(:perform, state) do + BackgroundWorker.enqueue("clean_expired_tokens", %{}) + interval = Pleroma.Config.get([:oauth2, :clean_expired_tokens_interval], @one_day) + + Process.send_after(self(), :perform, interval) + {:noreply, state} + end + + def perform(:clean) do + OAuth.Token.delete_expired_tokens() + MFA.Token.delete_expired_tokens() + end +end diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex index 6f4713dee..0e72c31e9 100644 --- a/lib/pleroma/web/oauth/token/response.ex +++ b/lib/pleroma/web/oauth/token/response.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.OAuth.Token.Response do @moduledoc false + alias Pleroma.MFA alias Pleroma.User alias Pleroma.Web.OAuth.Token.Utils @@ -32,5 +33,13 @@ def build_for_client_credentials(token) do } end + def build_for_mfa_token(user, mfa_token) do + %{ + error: "mfa_required", + mfa_token: mfa_token.token, + supported_challenge_types: MFA.supported_methods(user) + } + end + defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end diff --git a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex new file mode 100644 index 000000000..eb9989cdf --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex @@ -0,0 +1,133 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController do + @moduledoc "The module represents actions to manage MFA" + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.MFA + alias Pleroma.MFA.TOTP + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.CommonAPI.Utils + + plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action in [:settings]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:security"]} when action in [:setup, :confirm, :disable, :backup_codes] + ) + + @doc """ + Gets user multi factor authentication settings + + ## Endpoint + GET /api/pleroma/accounts/mfa + + """ + def settings(%{assigns: %{user: user}} = conn, _params) do + json(conn, %{settings: MFA.mfa_settings(user)}) + end + + @doc """ + Prepare setup mfa method + + ## Endpoint + GET /api/pleroma/accounts/mfa/setup/[:method] + + """ + def setup(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = _params) do + with {:ok, user} <- MFA.setup_totp(user), + %{secret: secret} = _ <- user.multi_factor_authentication_settings.totp do + provisioning_uri = TOTP.provisioning_uri(secret, "#{user.email}") + + json(conn, %{provisioning_uri: provisioning_uri, key: secret}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def setup(conn, _params) do + json_response(conn, :bad_request, %{error: "undefined method"}) + end + + @doc """ + Confirms setup and enable mfa method + + ## Endpoint + POST /api/pleroma/accounts/mfa/confirm/:method + + - params: + `code` - confirmation code + `password` - current password + """ + def confirm( + %{assigns: %{user: user}} = conn, + %{"method" => "totp", "password" => _, "code" => _} = params + ) do + with {:ok, _user} <- Utils.confirm_current_password(user, params["password"]), + {:ok, _user} <- MFA.confirm_totp(user, params) do + json(conn, %{}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def confirm(conn, _) do + json_response(conn, :bad_request, %{error: "undefined mfa method"}) + end + + @doc """ + Disable mfa method and disable mfa if need. + """ + def disable(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = params) do + with {:ok, user} <- Utils.confirm_current_password(user, params["password"]), + {:ok, _user} <- MFA.disable_totp(user) do + json(conn, %{}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def disable(%{assigns: %{user: user}} = conn, %{"method" => "mfa"} = params) do + with {:ok, user} <- Utils.confirm_current_password(user, params["password"]), + {:ok, _user} <- MFA.disable(user) do + json(conn, %{}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end + + def disable(conn, _) do + json_response(conn, :bad_request, %{error: "undefined mfa method"}) + end + + @doc """ + Generates backup codes. + + ## Endpoint + GET /api/pleroma/accounts/mfa/backup_codes + + ## Response + ### Success + `{codes: [codes]}` + + ### Error + `{error: [error_message]}` + + """ + def backup_codes(%{assigns: %{user: user}} = conn, _params) do + with {:ok, codes} <- MFA.generate_backup_codes(user) do + json(conn, %{codes: codes}) + else + {:error, message} -> + json_response(conn, :unprocessable_entity, %{error: message}) + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 281516bb8..7a171f9fb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -132,6 +132,7 @@ defmodule Pleroma.Web.Router do post("/users/follow", AdminAPIController, :user_follow) post("/users/unfollow", AdminAPIController, :user_unfollow) + put("/users/disable_mfa", AdminAPIController, :disable_mfa) delete("/users", AdminAPIController, :user_delete) post("/users", AdminAPIController, :users_create) patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) @@ -258,6 +259,16 @@ defmodule Pleroma.Web.Router do post("/follow_import", UtilController, :follow_import) end + scope "/api/pleroma", Pleroma.Web.PleromaAPI do + pipe_through(:authenticated_api) + + get("/accounts/mfa", TwoFactorAuthenticationController, :settings) + get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes) + get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup) + post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm) + delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable) + end + scope "/oauth", Pleroma.Web.OAuth do scope [] do pipe_through(:oauth) @@ -269,6 +280,10 @@ defmodule Pleroma.Web.Router do post("/revoke", OAuthController, :token_revoke) get("/registration_details", OAuthController, :registration_details) + post("/mfa/challenge", MFAController, :challenge) + post("/mfa/verify", MFAController, :verify, as: :mfa_verify) + get("/mfa", MFAController, :show) + scope [] do pipe_through(:browser) diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex new file mode 100644 index 000000000..750f65386 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -0,0 +1,24 @@ +<%= if get_flash(@conn, :info) do %> + +<% end %> +<%= if get_flash(@conn, :error) do %> + +<% end %> + +

    Two-factor recovery

    + +<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +
    + <%= label f, :code, "Recovery code" %> + <%= text_input f, :code %> + <%= hidden_input f, :mfa_token, value: @mfa_token %> + <%= hidden_input f, :state, value: @state %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :challenge_type, value: "recovery" %> +
    + +<%= submit "Verify" %> +<% end %> +"> + Enter a two-factor code + diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex new file mode 100644 index 000000000..af6e546b0 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -0,0 +1,24 @@ +<%= if get_flash(@conn, :info) do %> + +<% end %> +<%= if get_flash(@conn, :error) do %> + +<% end %> + +

    Two-factor authentication

    + +<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +
    + <%= label f, :code, "Authentication code" %> + <%= text_input f, :code %> + <%= hidden_input f, :mfa_token, value: @mfa_token %> + <%= hidden_input f, :state, value: @state %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :challenge_type, value: "totp" %> +
    + +<%= submit "Verify" %> +<% end %> +"> + Enter a two-factor recovery code + diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex new file mode 100644 index 000000000..adc3a3e3d --- /dev/null +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex @@ -0,0 +1,13 @@ +<%= if @error do %> +

    <%= @error %>

    +<% end %> +

    Two-factor authentication

    +

    <%= @followee.nickname %>

    + +<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %> +<%= text_input f, :code, placeholder: "Authentication code", required: true %> +
    +<%= hidden_input f, :id, value: @followee.id %> +<%= hidden_input f, :token, value: @mfa_token %> +<%= submit "Authorize" %> +<% end %> diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index 89da760da..521dc9322 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -8,10 +8,12 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do require Logger alias Pleroma.Activity + alias Pleroma.MFA alias Pleroma.Object.Fetcher alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.Auth.Authenticator + alias Pleroma.Web.Auth.TOTPAuthenticator alias Pleroma.Web.CommonAPI @status_types ["Article", "Event", "Note", "Video", "Page", "Question"] @@ -68,6 +70,8 @@ defp is_status?(acct) do # POST /ostatus_subscribe # + # adds a remote account in followers if user already is signed in. + # def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, {:ok, _, _, _} <- CommonAPI.follow(user, followee) do @@ -78,9 +82,33 @@ def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => end end + # POST /ostatus_subscribe + # + # step 1. + # checks login\password and displays step 2 form of MFA if need. + # def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do - with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, + with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, {_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee}, + {_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)}, + {:ok, _, _, _} <- CommonAPI.follow(user, followee) do + redirect(conn, to: "/users/#{followee.id}") + else + error -> + handle_follow_error(conn, error) + end + end + + # POST /ostatus_subscribe + # + # step 2 + # checks TOTP code. otherwise displays form with errors + # + def do_follow(conn, %{"mfa" => %{"code" => code, "token" => token, "id" => id}}) do + with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, + {_, _, {:ok, %{user: user}}} <- {:mfa_token, followee, MFA.Token.validate(token)}, + {_, _, _, {:ok, _}} <- + {:verify_mfa_code, followee, token, TOTPAuthenticator.verify(code, user)}, {:ok, _, _, _} <- CommonAPI.follow(user, followee) do redirect(conn, to: "/users/#{followee.id}") else @@ -94,6 +122,23 @@ def do_follow(%{assigns: %{user: nil}} = conn, _) do render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."}) end + defp handle_follow_error(conn, {:mfa_token, followee, _} = _) do + render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee}) + end + + defp handle_follow_error(conn, {:verify_mfa_code, followee, token, _} = _) do + render(conn, "follow_mfa.html", %{ + error: "Wrong authentication code", + followee: followee, + mfa_token: token + }) + end + + defp handle_follow_error(conn, {:mfa_required, followee, user, _} = _) do + {:ok, %{token: token}} = MFA.Token.create_token(user) + render(conn, "follow_mfa.html", %{followee: followee, mfa_token: token, error: false}) + end + defp handle_follow_error(conn, {:auth, _, followee} = _) do render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee}) end diff --git a/mix.exs b/mix.exs index beb05aab9..6d65e18d4 100644 --- a/mix.exs +++ b/mix.exs @@ -176,6 +176,7 @@ defp deps do {:quack, "~> 0.1.1"}, {:joken, "~> 2.0"}, {:benchee, "~> 1.0"}, + {:pot, "~> 0.10.2"}, {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, {:ex_const, "~> 0.2"}, {:plug_static_index_html, "~> 1.0.0"}, diff --git a/mix.lock b/mix.lock index 28287cf97..4792249d7 100644 --- a/mix.lock +++ b/mix.lock @@ -2,8 +2,7 @@ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, - "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, + "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, @@ -19,38 +18,33 @@ "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, - "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, + "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, - "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, + "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"}, - "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, + "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, + "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, - "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, - "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, - "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, @@ -59,37 +53,34 @@ "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, - "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, - "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, - "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"}, - "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, + "phoenix": {:hex, :phoenix, "1.4.12", "b86fa85a2ba336f5de068549de5ccceec356fd413264a9637e7733395d6cc4ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58331ade6d77e1312a3d976f0fa41803b8f004b2b5f489193425bc46aea3ed30"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"}, - "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, - "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, + "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "164baaeb382d19beee0ec484492aa82a9c8685770aee33b24ec727a0971b34d0"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.1", "a196e4f428d7f5d6dba5ded314cc55cd0fbddf1110af620f75c0190e77844b33", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "15a3c34ffaccef8a0b575b8d39ab1b9044586d7dab917292cdc44cf2737df7f2"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, - "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, + "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"}, + "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, diff --git a/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs b/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs new file mode 100644 index 000000000..8b653c61f --- /dev/null +++ b/priv/repo/migrations/20190506054542_add_multi_factor_authentication_settings_to_user.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddMultiFactorAuthenticationSettingsToUser do + use Ecto.Migration + + def change do + alter table(:users) do + add(:multi_factor_authentication_settings, :map, default: %{}) + end + end +end diff --git a/priv/repo/migrations/20190508193213_create_mfa_tokens.exs b/priv/repo/migrations/20190508193213_create_mfa_tokens.exs new file mode 100644 index 000000000..da9f8fabe --- /dev/null +++ b/priv/repo/migrations/20190508193213_create_mfa_tokens.exs @@ -0,0 +1,16 @@ +defmodule Pleroma.Repo.Migrations.CreateMfaTokens do + use Ecto.Migration + + def change do + create table(:mfa_tokens) do + add(:user_id, references(:users, type: :uuid, on_delete: :delete_all)) + add(:authorization_id, references(:oauth_authorizations, on_delete: :delete_all)) + add(:token, :string) + add(:valid_until, :naive_datetime_usec) + + timestamps() + end + + create(unique_index(:mfa_tokens, :token)) + end +end diff --git a/priv/static/adminfe/static/fonts/element-icons.535877f.woff b/priv/static/adminfe/static/fonts/element-icons.535877f.woff deleted file mode 100644 index 02b9a2539..000000000 Binary files a/priv/static/adminfe/static/fonts/element-icons.535877f.woff and /dev/null differ diff --git a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf b/priv/static/adminfe/static/fonts/element-icons.732389d.ttf deleted file mode 100644 index 91b74de36..000000000 Binary files a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf and /dev/null differ diff --git a/test/mfa/backup_codes_test.exs b/test/mfa/backup_codes_test.exs new file mode 100644 index 000000000..7bc01b36b --- /dev/null +++ b/test/mfa/backup_codes_test.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.MFA.BackupCodesTest do + use Pleroma.DataCase + + alias Pleroma.MFA.BackupCodes + + test "generate backup codes" do + codes = BackupCodes.generate(number_of_codes: 2, length: 4) + + assert [<<_::bytes-size(4)>>, <<_::bytes-size(4)>>] = codes + end +end diff --git a/test/mfa/totp_test.exs b/test/mfa/totp_test.exs new file mode 100644 index 000000000..50153d208 --- /dev/null +++ b/test/mfa/totp_test.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.MFA.TOTPTest do + use Pleroma.DataCase + + alias Pleroma.MFA.TOTP + + test "create provisioning_uri to generate qrcode" do + uri = + TOTP.provisioning_uri("test-secrcet", "test@example.com", + issuer: "Plerome-42", + digits: 8, + period: 60 + ) + + assert uri == + "otpauth://totp/test@example.com?digits=8&issuer=Plerome-42&period=60&secret=test-secrcet" + end +end diff --git a/test/mfa_test.exs b/test/mfa_test.exs new file mode 100644 index 000000000..94bc48c26 --- /dev/null +++ b/test/mfa_test.exs @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MFATest do + use Pleroma.DataCase + + import Pleroma.Factory + alias Comeonin.Pbkdf2 + alias Pleroma.MFA + + describe "mfa_settings" do + test "returns settings user's" do + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true} + } + ) + + settings = MFA.mfa_settings(user) + assert match?(^settings, %{enabled: true, totp: true}) + end + end + + describe "generate backup codes" do + test "returns backup codes" do + user = insert(:user) + + {:ok, [code1, code2]} = MFA.generate_backup_codes(user) + updated_user = refresh_record(user) + [hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes + assert Pbkdf2.checkpw(code1, hash1) + assert Pbkdf2.checkpw(code2, hash2) + end + end + + describe "invalidate_backup_code" do + test "invalid used code" do + user = insert(:user) + + {:ok, _} = MFA.generate_backup_codes(user) + user = refresh_record(user) + assert length(user.multi_factor_authentication_settings.backup_codes) == 2 + [hash_code | _] = user.multi_factor_authentication_settings.backup_codes + + {:ok, user} = MFA.invalidate_backup_code(user, hash_code) + + assert length(user.multi_factor_authentication_settings.backup_codes) == 1 + end + end +end diff --git a/test/plugs/ensure_authenticated_plug_test.exs b/test/plugs/ensure_authenticated_plug_test.exs index 4e6142aab..a0667c5e0 100644 --- a/test/plugs/ensure_authenticated_plug_test.exs +++ b/test/plugs/ensure_authenticated_plug_test.exs @@ -24,6 +24,31 @@ test "it continues if a user is assigned", %{conn: conn} do end end + test "it halts if user is assigned and MFA enabled", %{conn: conn} do + conn = + conn + |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: true}}) + |> assign(:auth_credentials, %{password: "xd-42"}) + |> EnsureAuthenticatedPlug.call(%{}) + + assert conn.status == 403 + assert conn.halted == true + + assert conn.resp_body == + "{\"error\":\"Two-factor authentication enabled, you must use a access token.\"}" + end + + test "it continues if user is assigned and MFA disabled", %{conn: conn} do + conn = + conn + |> assign(:user, %User{multi_factor_authentication_settings: %{enabled: false}}) + |> assign(:auth_credentials, %{password: "xd-42"}) + |> EnsureAuthenticatedPlug.call(%{}) + + refute conn.status == 403 + refute conn.halted + end + describe "with :if_func / :unless_func options" do setup do %{ diff --git a/test/support/builders/user_builder.ex b/test/support/builders/user_builder.ex index fcfea666f..0d0490714 100644 --- a/test/support/builders/user_builder.ex +++ b/test/support/builders/user_builder.ex @@ -11,6 +11,7 @@ def build(data \\ %{}) do bio: "A tester.", ap_id: "some id", last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), + multi_factor_authentication_settings: %Pleroma.MFA.Settings{}, notification_settings: %Pleroma.User.NotificationSetting{} } diff --git a/test/support/factory.ex b/test/support/factory.ex index 495764782..c8c45e2a7 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -33,7 +33,8 @@ def user_factory do bio: sequence(:bio, &"Tester Number #{&1}"), last_digest_emailed_at: NaiveDateTime.utc_now(), last_refreshed_at: NaiveDateTime.utc_now(), - notification_settings: %Pleroma.User.NotificationSetting{} + notification_settings: %Pleroma.User.NotificationSetting{}, + multi_factor_authentication_settings: %Pleroma.MFA.Settings{} } %{ @@ -422,4 +423,13 @@ def marker_factory do last_read_id: "1" } end + + def mfa_token_factory do + %Pleroma.MFA.Token{ + token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false), + authorization: build(:oauth_authorization), + valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10), + user: build(:user) + } + end end diff --git a/test/user_search_test.exs b/test/user_search_test.exs index cb847b516..17c63322a 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -172,6 +172,7 @@ test "works with URIs" do |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil) |> Map.put(:last_digest_emailed_at, nil) + |> Map.put(:multi_factor_authentication_settings, nil) |> Map.put(:notification_settings, nil) assert user == expected diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 7ab7cc15c..4697af50e 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -14,6 +14,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.Config alias Pleroma.ConfigDB alias Pleroma.HTML + alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Repo alias Pleroma.ReportNote @@ -1278,6 +1279,38 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "@#{admin.nickname} deactivated users: @#{user.nickname}" end + describe "PUT disable_mfa" do + test "returns 200 and disable 2fa", %{conn: conn} do + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true} + } + ) + + response = + conn + |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname}) + |> json_response(200) + + assert response == user.nickname + mfa_settings = refresh_record(user).multi_factor_authentication_settings + + refute mfa_settings.enabled + refute mfa_settings.totp.confirmed + end + + test "returns 404 if user not found", %{conn: conn} do + response = + conn + |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"}) + |> json_response(404) + + assert response == "Not found" + end + end + describe "POST /api/pleroma/admin/users/invite_token" do test "without options", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/users/invite_token") diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs new file mode 100644 index 000000000..7125c5081 --- /dev/null +++ b/test/web/auth/pleroma_authenticator_test.exs @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Web.Auth.PleromaAuthenticator + import Pleroma.Factory + + setup do + password = "testpassword" + name = "AgentSmith" + user = insert(:user, nickname: name, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + {:ok, [user: user, name: name, password: password]} + end + + test "get_user/authorization", %{user: user, name: name, password: password} do + params = %{"authorization" => %{"name" => name, "password" => password}} + res = PleromaAuthenticator.get_user(%Plug.Conn{params: params}) + + assert {:ok, user} == res + end + + test "get_user/authorization with invalid password", %{name: name} do + params = %{"authorization" => %{"name" => name, "password" => "password"}} + res = PleromaAuthenticator.get_user(%Plug.Conn{params: params}) + + assert {:error, {:checkpw, false}} == res + end + + test "get_user/grant_type_password", %{user: user, name: name, password: password} do + params = %{"grant_type" => "password", "username" => name, "password" => password} + res = PleromaAuthenticator.get_user(%Plug.Conn{params: params}) + + assert {:ok, user} == res + end + + test "error credintails" do + res = PleromaAuthenticator.get_user(%Plug.Conn{params: %{}}) + assert {:error, :invalid_credentials} == res + end +end diff --git a/test/web/auth/totp_authenticator_test.exs b/test/web/auth/totp_authenticator_test.exs new file mode 100644 index 000000000..e08069490 --- /dev/null +++ b/test/web/auth/totp_authenticator_test.exs @@ -0,0 +1,51 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do + use Pleroma.Web.ConnCase + + alias Pleroma.MFA + alias Pleroma.MFA.BackupCodes + alias Pleroma.MFA.TOTP + alias Pleroma.Web.Auth.TOTPAuthenticator + + import Pleroma.Factory + + test "verify token" do + otp_secret = TOTP.generate_secret() + otp_token = TOTP.generate_token(otp_secret) + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + assert TOTPAuthenticator.verify(otp_token, user) == {:ok, :pass} + assert TOTPAuthenticator.verify(nil, user) == {:error, :invalid_token} + assert TOTPAuthenticator.verify("", user) == {:error, :invalid_token} + end + + test "checks backup codes" do + [code | _] = backup_codes = BackupCodes.generate() + + hashed_codes = + backup_codes + |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1)) + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + backup_codes: hashed_codes, + totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true} + } + ) + + assert TOTPAuthenticator.verify_recovery_code(user, code) == {:ok, :pass} + refute TOTPAuthenticator.verify_recovery_code(code, refresh_record(user)) == {:ok, :pass} + end +end diff --git a/test/web/oauth/mfa_controller_test.exs b/test/web/oauth/mfa_controller_test.exs new file mode 100644 index 000000000..ce4a07320 --- /dev/null +++ b/test/web/oauth/mfa_controller_test.exs @@ -0,0 +1,306 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.MFAControllerTest do + use Pleroma.Web.ConnCase + import Pleroma.Factory + + alias Pleroma.MFA + alias Pleroma.MFA.BackupCodes + alias Pleroma.MFA.TOTP + alias Pleroma.Repo + alias Pleroma.Web.OAuth.Authorization + alias Pleroma.Web.OAuth.OAuthController + + setup %{conn: conn} do + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + backup_codes: [Comeonin.Pbkdf2.hashpwsalt("test-code")], + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + app = insert(:oauth_app) + {:ok, conn: conn, user: user, app: app} + end + + describe "show" do + setup %{conn: conn, user: user, app: app} do + mfa_token = + insert(:mfa_token, + user: user, + authorization: build(:oauth_authorization, app: app, scopes: ["write"]) + ) + + {:ok, conn: conn, mfa_token: mfa_token} + end + + test "GET /oauth/mfa renders mfa forms", %{conn: conn, mfa_token: mfa_token} do + conn = + get( + conn, + "/oauth/mfa", + %{ + "mfa_token" => mfa_token.token, + "state" => "a_state", + "redirect_uri" => "http://localhost:8080/callback" + } + ) + + assert response = html_response(conn, 200) + assert response =~ "Two-factor authentication" + assert response =~ mfa_token.token + assert response =~ "http://localhost:8080/callback" + end + + test "GET /oauth/mfa renders mfa recovery forms", %{conn: conn, mfa_token: mfa_token} do + conn = + get( + conn, + "/oauth/mfa", + %{ + "mfa_token" => mfa_token.token, + "state" => "a_state", + "redirect_uri" => "http://localhost:8080/callback", + "challenge_type" => "recovery" + } + ) + + assert response = html_response(conn, 200) + assert response =~ "Two-factor recovery" + assert response =~ mfa_token.token + assert response =~ "http://localhost:8080/callback" + end + end + + describe "verify" do + setup %{conn: conn, user: user, app: app} do + mfa_token = + insert(:mfa_token, + user: user, + authorization: build(:oauth_authorization, app: app, scopes: ["write"]) + ) + + {:ok, conn: conn, user: user, mfa_token: mfa_token, app: app} + end + + test "POST /oauth/mfa/verify, verify totp code", %{ + conn: conn, + user: user, + mfa_token: mfa_token, + app: app + } do + otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret) + + conn = + conn + |> post("/oauth/mfa/verify", %{ + "mfa" => %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "totp", + "code" => otp_token, + "state" => "a_state", + "redirect_uri" => OAuthController.default_redirect_uri(app) + } + }) + + target = redirected_to(conn) + target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string() + query = URI.parse(target).query |> URI.query_decoder() |> Map.new() + assert %{"state" => "a_state", "code" => code} = query + assert target_url == OAuthController.default_redirect_uri(app) + auth = Repo.get_by(Authorization, token: code) + assert auth.scopes == ["write"] + end + + test "POST /oauth/mfa/verify, verify recovery code", %{ + conn: conn, + mfa_token: mfa_token, + app: app + } do + conn = + conn + |> post("/oauth/mfa/verify", %{ + "mfa" => %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "recovery", + "code" => "test-code", + "state" => "a_state", + "redirect_uri" => OAuthController.default_redirect_uri(app) + } + }) + + target = redirected_to(conn) + target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string() + query = URI.parse(target).query |> URI.query_decoder() |> Map.new() + assert %{"state" => "a_state", "code" => code} = query + assert target_url == OAuthController.default_redirect_uri(app) + auth = Repo.get_by(Authorization, token: code) + assert auth.scopes == ["write"] + end + end + + describe "challenge/totp" do + test "returns access token with valid code", %{conn: conn, user: user, app: app} do + otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret) + + mfa_token = + insert(:mfa_token, + user: user, + authorization: build(:oauth_authorization, app: app, scopes: ["write"]) + ) + + response = + conn + |> post("/oauth/mfa/challenge", %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "totp", + "code" => otp_token, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + |> json_response(:ok) + + ap_id = user.ap_id + + assert match?( + %{ + "access_token" => _, + "expires_in" => 600, + "me" => ^ap_id, + "refresh_token" => _, + "scope" => "write", + "token_type" => "Bearer" + }, + response + ) + end + + test "returns errors when mfa token invalid", %{conn: conn, user: user, app: app} do + otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret) + + response = + conn + |> post("/oauth/mfa/challenge", %{ + "mfa_token" => "XXX", + "challenge_type" => "totp", + "code" => otp_token, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + |> json_response(400) + + assert response == %{"error" => "Invalid code"} + end + + test "returns error when otp code is invalid", %{conn: conn, user: user, app: app} do + mfa_token = insert(:mfa_token, user: user) + + response = + conn + |> post("/oauth/mfa/challenge", %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "totp", + "code" => "XXX", + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + |> json_response(400) + + assert response == %{"error" => "Invalid code"} + end + + test "returns error when client credentails is wrong ", %{conn: conn, user: user} do + otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret) + mfa_token = insert(:mfa_token, user: user) + + response = + conn + |> post("/oauth/mfa/challenge", %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "totp", + "code" => otp_token, + "client_id" => "xxx", + "client_secret" => "xxx" + }) + |> json_response(400) + + assert response == %{"error" => "Invalid code"} + end + end + + describe "challenge/recovery" do + setup %{conn: conn} do + app = insert(:oauth_app) + {:ok, conn: conn, app: app} + end + + test "returns access token with valid code", %{conn: conn, app: app} do + otp_secret = TOTP.generate_secret() + + [code | _] = backup_codes = BackupCodes.generate() + + hashed_codes = + backup_codes + |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1)) + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + backup_codes: hashed_codes, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + mfa_token = + insert(:mfa_token, + user: user, + authorization: build(:oauth_authorization, app: app, scopes: ["write"]) + ) + + response = + conn + |> post("/oauth/mfa/challenge", %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "recovery", + "code" => code, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + |> json_response(:ok) + + ap_id = user.ap_id + + assert match?( + %{ + "access_token" => _, + "expires_in" => 600, + "me" => ^ap_id, + "refresh_token" => _, + "scope" => "write", + "token_type" => "Bearer" + }, + response + ) + + error_response = + conn + |> post("/oauth/mfa/challenge", %{ + "mfa_token" => mfa_token.token, + "challenge_type" => "recovery", + "code" => code, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + |> json_response(400) + + assert error_response == %{"error" => "Invalid code"} + end + end +end diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index f2f98d768..7a107584d 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -6,6 +6,8 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + alias Pleroma.MFA + alias Pleroma.MFA.TOTP alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.OAuth.Authorization @@ -604,6 +606,41 @@ test "redirects with oauth authorization, " <> end end + test "redirect to on two-factor auth page" do + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + app = insert(:oauth_app, scopes: ["read", "write", "follow"]) + + conn = + build_conn() + |> post("/oauth/authorize", %{ + "authorization" => %{ + "name" => user.nickname, + "password" => "test", + "client_id" => app.client_id, + "redirect_uri" => app.redirect_uris, + "scope" => "read write", + "state" => "statepassed" + } + }) + + result = html_response(conn, 200) + + mfa_token = Repo.get_by(MFA.Token, user_id: user.id) + assert result =~ app.redirect_uris + assert result =~ "statepassed" + assert result =~ mfa_token.token + assert result =~ "Two-factor authentication" + end + test "returns 401 for wrong credentials", %{conn: conn} do user = insert(:user) app = insert(:oauth_app) @@ -735,6 +772,46 @@ test "issues a token for `password` grant_type with valid credentials, with full assert token.scopes == app.scopes end + test "issues a mfa token for `password` grant_type, when MFA enabled" do + password = "testpassword" + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + app = insert(:oauth_app, scopes: ["read", "write"]) + + response = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + |> json_response(403) + + assert match?( + %{ + "supported_challenge_types" => "totp", + "mfa_token" => _, + "error" => "mfa_required" + }, + response + ) + + token = Repo.get_by(MFA.Token, token: response["mfa_token"]) + assert token.user_id == user.id + assert token.authorization_id + end + test "issues a token for request with HTTP basic auth client credentials" do user = insert(:user) app = insert(:oauth_app, scopes: ["scope1", "scope2", "scope3"]) diff --git a/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs new file mode 100644 index 000000000..d23d08a00 --- /dev/null +++ b/test/web/pleroma_api/controllers/two_factor_authentication_controller_test.exs @@ -0,0 +1,260 @@ +defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + alias Pleroma.MFA.Settings + alias Pleroma.MFA.TOTP + + describe "GET /api/pleroma/accounts/mfa/settings" do + test "returns user mfa settings for new user", %{conn: conn} do + token = insert(:oauth_token, scopes: ["read", "follow"]) + token2 = insert(:oauth_token, scopes: ["write"]) + + assert conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> get("/api/pleroma/accounts/mfa") + |> json_response(:ok) == %{ + "settings" => %{"enabled" => false, "totp" => false} + } + + assert conn + |> put_req_header("authorization", "Bearer #{token2.token}") + |> get("/api/pleroma/accounts/mfa") + |> json_response(403) == %{ + "error" => "Insufficient permissions: read:security." + } + end + + test "returns user mfa settings with enabled totp", %{conn: conn} do + user = + insert(:user, + multi_factor_authentication_settings: %Settings{ + enabled: true, + totp: %Settings.TOTP{secret: "XXX", delivery_type: "app", confirmed: true} + } + ) + + token = insert(:oauth_token, scopes: ["read", "follow"], user: user) + + assert conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> get("/api/pleroma/accounts/mfa") + |> json_response(:ok) == %{ + "settings" => %{"enabled" => true, "totp" => true} + } + end + end + + describe "GET /api/pleroma/accounts/mfa/backup_codes" do + test "returns backup codes", %{conn: conn} do + user = + insert(:user, + multi_factor_authentication_settings: %Settings{ + backup_codes: ["1", "2", "3"], + totp: %Settings.TOTP{secret: "secret"} + } + ) + + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + token2 = insert(:oauth_token, scopes: ["read"]) + + response = + conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> get("/api/pleroma/accounts/mfa/backup_codes") + |> json_response(:ok) + + assert [<<_::bytes-size(6)>>, <<_::bytes-size(6)>>] = response["codes"] + user = refresh_record(user) + mfa_settings = user.multi_factor_authentication_settings + assert mfa_settings.totp.secret == "secret" + refute mfa_settings.backup_codes == ["1", "2", "3"] + refute mfa_settings.backup_codes == [] + + assert conn + |> put_req_header("authorization", "Bearer #{token2.token}") + |> get("/api/pleroma/accounts/mfa/backup_codes") + |> json_response(403) == %{ + "error" => "Insufficient permissions: write:security." + } + end + end + + describe "GET /api/pleroma/accounts/mfa/setup/totp" do + test "return errors when method is invalid", %{conn: conn} do + user = insert(:user) + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + + response = + conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> get("/api/pleroma/accounts/mfa/setup/torf") + |> json_response(400) + + assert response == %{"error" => "undefined method"} + end + + test "returns key and provisioning_uri", %{conn: conn} do + user = + insert(:user, + multi_factor_authentication_settings: %Settings{backup_codes: ["1", "2", "3"]} + ) + + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + token2 = insert(:oauth_token, scopes: ["read"]) + + response = + conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> get("/api/pleroma/accounts/mfa/setup/totp") + |> json_response(:ok) + + user = refresh_record(user) + mfa_settings = user.multi_factor_authentication_settings + secret = mfa_settings.totp.secret + refute mfa_settings.enabled + assert mfa_settings.backup_codes == ["1", "2", "3"] + + assert response == %{ + "key" => secret, + "provisioning_uri" => TOTP.provisioning_uri(secret, "#{user.email}") + } + + assert conn + |> put_req_header("authorization", "Bearer #{token2.token}") + |> get("/api/pleroma/accounts/mfa/setup/totp") + |> json_response(403) == %{ + "error" => "Insufficient permissions: write:security." + } + end + end + + describe "GET /api/pleroma/accounts/mfa/confirm/totp" do + test "returns success result", %{conn: conn} do + secret = TOTP.generate_secret() + code = TOTP.generate_token(secret) + + user = + insert(:user, + multi_factor_authentication_settings: %Settings{ + backup_codes: ["1", "2", "3"], + totp: %Settings.TOTP{secret: secret} + } + ) + + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + token2 = insert(:oauth_token, scopes: ["read"]) + + assert conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code}) + |> json_response(:ok) + + settings = refresh_record(user).multi_factor_authentication_settings + assert settings.enabled + assert settings.totp.secret == secret + assert settings.totp.confirmed + assert settings.backup_codes == ["1", "2", "3"] + + assert conn + |> put_req_header("authorization", "Bearer #{token2.token}") + |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code}) + |> json_response(403) == %{ + "error" => "Insufficient permissions: write:security." + } + end + + test "returns error if password incorrect", %{conn: conn} do + secret = TOTP.generate_secret() + code = TOTP.generate_token(secret) + + user = + insert(:user, + multi_factor_authentication_settings: %Settings{ + backup_codes: ["1", "2", "3"], + totp: %Settings.TOTP{secret: secret} + } + ) + + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + + response = + conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "xxx", code: code}) + |> json_response(422) + + settings = refresh_record(user).multi_factor_authentication_settings + refute settings.enabled + refute settings.totp.confirmed + assert settings.backup_codes == ["1", "2", "3"] + assert response == %{"error" => "Invalid password."} + end + + test "returns error if code incorrect", %{conn: conn} do + secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %Settings{ + backup_codes: ["1", "2", "3"], + totp: %Settings.TOTP{secret: secret} + } + ) + + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + token2 = insert(:oauth_token, scopes: ["read"]) + + response = + conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"}) + |> json_response(422) + + settings = refresh_record(user).multi_factor_authentication_settings + refute settings.enabled + refute settings.totp.confirmed + assert settings.backup_codes == ["1", "2", "3"] + assert response == %{"error" => "invalid_token"} + + assert conn + |> put_req_header("authorization", "Bearer #{token2.token}") + |> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"}) + |> json_response(403) == %{ + "error" => "Insufficient permissions: write:security." + } + end + end + + describe "DELETE /api/pleroma/accounts/mfa/totp" do + test "returns success result", %{conn: conn} do + user = + insert(:user, + multi_factor_authentication_settings: %Settings{ + backup_codes: ["1", "2", "3"], + totp: %Settings.TOTP{secret: "secret"} + } + ) + + token = insert(:oauth_token, scopes: ["write", "follow"], user: user) + token2 = insert(:oauth_token, scopes: ["read"]) + + assert conn + |> put_req_header("authorization", "Bearer #{token.token}") + |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"}) + |> json_response(:ok) + + settings = refresh_record(user).multi_factor_authentication_settings + refute settings.enabled + assert settings.totp.secret == nil + refute settings.totp.confirmed + + assert conn + |> put_req_header("authorization", "Bearer #{token2.token}") + |> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"}) + |> json_response(403) == %{ + "error" => "Insufficient permissions: write:security." + } + end + end +end diff --git a/test/web/twitter_api/remote_follow_controller_test.exs b/test/web/twitter_api/remote_follow_controller_test.exs index 5ff8694a8..f7e54c26a 100644 --- a/test/web/twitter_api/remote_follow_controller_test.exs +++ b/test/web/twitter_api/remote_follow_controller_test.exs @@ -6,11 +6,14 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do use Pleroma.Web.ConnCase alias Pleroma.Config + alias Pleroma.MFA + alias Pleroma.MFA.TOTP alias Pleroma.User alias Pleroma.Web.CommonAPI import ExUnit.CaptureLog import Pleroma.Factory + import Ecto.Query setup do Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -160,6 +163,119 @@ test "returns success result when user already in followers", %{conn: conn} do end end + describe "POST /ostatus_subscribe - follow/2 with enabled Two-Factor Auth " do + test "render the MFA login form", %{conn: conn} do + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + user2 = insert(:user) + + response = + conn + |> post(remote_follow_path(conn, :do_follow), %{ + "authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id} + }) + |> response(200) + + mfa_token = Pleroma.Repo.one(from(q in Pleroma.MFA.Token, where: q.user_id == ^user.id)) + + assert response =~ "Two-factor authentication" + assert response =~ "Authentication code" + assert response =~ mfa_token.token + refute user2.follower_address in User.following(user) + end + + test "returns error when password is incorrect", %{conn: conn} do + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + user2 = insert(:user) + + response = + conn + |> post(remote_follow_path(conn, :do_follow), %{ + "authorization" => %{"name" => user.nickname, "password" => "test1", "id" => user2.id} + }) + |> response(200) + + assert response =~ "Wrong username or password" + refute user2.follower_address in User.following(user) + end + + test "follows", %{conn: conn} do + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + {:ok, %{token: token}} = MFA.Token.create_token(user) + + user2 = insert(:user) + otp_token = TOTP.generate_token(otp_secret) + + conn = + conn + |> post( + remote_follow_path(conn, :do_follow), + %{ + "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id} + } + ) + + assert redirected_to(conn) == "/users/#{user2.id}" + assert user2.follower_address in User.following(user) + end + + test "returns error when auth code is incorrect", %{conn: conn} do + otp_secret = TOTP.generate_secret() + + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} + } + ) + + {:ok, %{token: token}} = MFA.Token.create_token(user) + + user2 = insert(:user) + otp_token = TOTP.generate_token(TOTP.generate_secret()) + + response = + conn + |> post( + remote_follow_path(conn, :do_follow), + %{ + "mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id} + } + ) + |> response(200) + + assert response =~ "Wrong authentication code" + refute user2.follower_address in User.following(user) + end + end + describe "POST /ostatus_subscribe - follow/2 without assigned user " do test "follows", %{conn: conn} do user = insert(:user) -- cgit v1.2.3 From 9491ba3e49450e80cd1c21358c01e4e06e3d881d Mon Sep 17 00:00:00 2001 From: href Date: Thu, 7 May 2020 09:13:32 +0000 Subject: Streamer rework --- lib/pleroma/application.ex | 9 +- lib/pleroma/web/activity_pub/activity_pub.ex | 32 +- lib/pleroma/web/mastodon_api/websocket_handler.ex | 47 +- lib/pleroma/web/streamer/ping.ex | 37 -- lib/pleroma/web/streamer/state.ex | 82 --- lib/pleroma/web/streamer/streamer.ex | 244 ++++++++- lib/pleroma/web/streamer/streamer_socket.ex | 35 -- lib/pleroma/web/streamer/supervisor.ex | 37 -- lib/pleroma/web/streamer/worker.ex | 208 -------- test/integration/mastodon_websocket_test.exs | 7 +- test/notification_test.exs | 18 +- test/support/builders/activity_builder.ex | 10 +- test/support/conn_case.ex | 6 +- test/support/data_case.ex | 6 +- test/web/streamer/ping_test.exs | 36 -- test/web/streamer/state_test.exs | 54 -- test/web/streamer/streamer_test.exs | 594 ++++++++-------------- 17 files changed, 520 insertions(+), 942 deletions(-) delete mode 100644 lib/pleroma/web/streamer/ping.ex delete mode 100644 lib/pleroma/web/streamer/state.ex delete mode 100644 lib/pleroma/web/streamer/streamer_socket.ex delete mode 100644 lib/pleroma/web/streamer/supervisor.ex delete mode 100644 lib/pleroma/web/streamer/worker.ex delete mode 100644 test/web/streamer/ping_test.exs delete mode 100644 test/web/streamer/state_test.exs diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 308d8cffa..a00bc0624 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -173,7 +173,14 @@ defp chat_enabled?, do: Config.get([:chat, :enabled]) defp streamer_child(env) when env in [:test, :benchmark], do: [] defp streamer_child(_) do - [Pleroma.Web.Streamer.supervisor()] + [ + {Registry, + [ + name: Pleroma.Web.Streamer.registry(), + keys: :duplicate, + partitions: System.schedulers_online() + ]} + ] end defp chat_child(_env, true) do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 099df5879..8baaf97ac 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -170,12 +170,6 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) - Notification.create_notifications(activity) - - conversation = create_or_bump_conversation(activity, map["actor"]) - participations = get_participations(conversation) - stream_out(activity) - stream_out_participations(participations) {:ok, activity} else %Activity{} = activity -> @@ -198,6 +192,15 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when end end + def notify_and_stream(activity) do + Notification.create_notifications(activity) + + conversation = create_or_bump_conversation(activity, activity.actor) + participations = get_participations(conversation) + stream_out(activity) + stream_out_participations(participations) + end + defp create_or_bump_conversation(activity, actor) do with {:ok, conversation} <- Conversation.create_or_bump_for(activity), %User{} = user <- User.get_cached_by_ap_id(actor), @@ -274,6 +277,7 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param _ <- increase_poll_votes_if_vote(create_data), {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:ok, _actor} <- increase_note_count_if_public(actor, activity), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -301,6 +305,7 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d additional ), {:ok, activity} <- insert(listen_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -325,6 +330,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} |> Utils.maybe_put("id", activity_id), {:ok, activity} <- insert(data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -344,6 +350,7 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do }, data <- Utils.maybe_put(data, "id", activity_id), {:ok, activity} <- insert(data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} end @@ -365,6 +372,7 @@ defp do_react_with_emoji(user, object, emoji, options) do reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id), {:ok, activity} <- insert(reaction_data, local), {:ok, object} <- add_emoji_reaction_to_object(activity, object), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity, object} else @@ -391,6 +399,7 @@ defp do_unreact_with_emoji(user, reaction_id, options) do unreact_data <- make_undo_data(user, reaction_activity, activity_id), {:ok, activity} <- insert(unreact_data, local), {:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity, object} else @@ -413,6 +422,7 @@ defp do_unlike(actor, object, activity_id, local) do {:ok, unlike_activity} <- insert(unlike_data, local), {:ok, _activity} <- Repo.delete(like_activity), {:ok, object} <- remove_like_from_object(like_activity, object), + _ <- notify_and_stream(unlike_activity), :ok <- maybe_federate(unlike_activity) do {:ok, unlike_activity, like_activity, object} else @@ -442,6 +452,7 @@ defp do_announce(user, object, activity_id, local, public) do announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity, object} else @@ -468,6 +479,7 @@ defp do_unannounce(actor, object, activity_id, local) do with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object), unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id), {:ok, unannounce_activity} <- insert(unannounce_data, local), + _ <- notify_and_stream(unannounce_activity), :ok <- maybe_federate(unannounce_activity), {:ok, _activity} <- Repo.delete(announce_activity), {:ok, object} <- remove_announce_from_object(announce_activity, object) do @@ -490,6 +502,7 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) do defp do_follow(follower, followed, activity_id, local) do with data <- make_follow_data(follower, followed, activity_id), {:ok, activity} <- insert(data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -511,6 +524,7 @@ defp do_unfollow(follower, followed, activity_id, local) do {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"), unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id), {:ok, activity} <- insert(unfollow_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -540,6 +554,7 @@ defp do_block(blocker, blocked, activity_id, local) do with true <- outgoing_blocks, block_data <- make_block_data(blocker, blocked, activity_id), {:ok, activity} <- insert(block_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -560,6 +575,7 @@ defp do_unblock(blocker, blocked, activity_id, local) do with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked), unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id), {:ok, activity} <- insert(unblock_data, local), + _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else @@ -594,6 +610,7 @@ def flag( with flag_data <- make_flag_data(params, additional), {:ok, activity} <- insert(flag_data, local), {:ok, stripped_activity} <- strip_report_status_data(activity), + _ <- notify_and_stream(activity), :ok <- maybe_federate(stripped_activity) do User.all_superusers() |> Enum.filter(fn user -> not is_nil(user.email) end) @@ -617,7 +634,8 @@ def move(%User{} = origin, %User{} = target, local \\ true) do } with true <- origin.ap_id in target.also_known_as, - {:ok, activity} <- insert(params, local) do + {:ok, activity} <- insert(params, local), + _ <- notify_and_stream(activity) do maybe_federate(activity) BackgroundWorker.enqueue("move_following", %{ diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 5652a37c1..6ef3fe2dd 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -12,6 +12,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do @behaviour :cowboy_websocket + # Cowboy timeout period. + @timeout :timer.seconds(30) + # Hibernate every X messages + @hibernate_every 100 + @streams [ "public", "public:local", @@ -25,9 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do ] @anonymous_streams ["public", "public:local", "hashtag"] - # Handled by periodic keepalive in Pleroma.Web.Streamer.Ping. - @timeout :infinity - def init(%{qs: qs} = req, state) do with params <- :cow_qs.parse_qs(qs), sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil), @@ -42,7 +44,7 @@ def init(%{qs: qs} = req, state) do req end - {:cowboy_websocket, req, %{user: user, topic: topic}, %{idle_timeout: @timeout}} + {:cowboy_websocket, req, %{user: user, topic: topic, count: 0}, %{idle_timeout: @timeout}} else {:error, code} -> Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}") @@ -57,7 +59,13 @@ def init(%{qs: qs} = req, state) do end def websocket_init(state) do - send(self(), :subscribe) + Logger.debug( + "#{__MODULE__} accepted websocket connection for user #{ + (state.user || %{id: "anonymous"}).id + }, topic #{state.topic}" + ) + + Streamer.add_socket(state.topic, state.user) {:ok, state} end @@ -66,19 +74,24 @@ def websocket_handle(_frame, state) do {:ok, state} end - def websocket_info(:subscribe, state) do - Logger.debug( - "#{__MODULE__} accepted websocket connection for user #{ - (state.user || %{id: "anonymous"}).id - }, topic #{state.topic}" - ) + def websocket_info({:render_with_user, view, template, item}, state) do + user = %User{} = User.get_cached_by_ap_id(state.user.ap_id) - Streamer.add_socket(state.topic, streamer_socket(state)) - {:ok, state} + unless Streamer.filtered_by_user?(user, item) do + websocket_info({:text, view.render(template, user, item)}, %{state | user: user}) + else + {:ok, state} + end end def websocket_info({:text, message}, state) do - {:reply, {:text, message}, state} + # If the websocket processed X messages, force an hibernate/GC. + # We don't hibernate at every message to balance CPU usage/latency with RAM usage. + if state.count > @hibernate_every do + {:reply, {:text, message}, %{state | count: 0}, :hibernate} + else + {:reply, {:text, message}, %{state | count: state.count + 1}} + end end def terminate(reason, _req, state) do @@ -88,7 +101,7 @@ def terminate(reason, _req, state) do }, topic #{state.topic || "?"}: #{inspect(reason)}" ) - Streamer.remove_socket(state.topic, streamer_socket(state)) + Streamer.remove_socket(state.topic) :ok end @@ -136,8 +149,4 @@ defp expand_topic("list", params) do end defp expand_topic(topic, _), do: topic - - defp streamer_socket(state) do - %{transport_pid: self(), assigns: state} - end end diff --git a/lib/pleroma/web/streamer/ping.ex b/lib/pleroma/web/streamer/ping.ex deleted file mode 100644 index 7a08202a9..000000000 --- a/lib/pleroma/web/streamer/ping.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.Ping do - use GenServer - require Logger - - alias Pleroma.Web.Streamer.State - alias Pleroma.Web.Streamer.StreamerSocket - - @keepalive_interval :timer.seconds(30) - - def start_link(opts) do - ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval) - GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__) - end - - def init(%{ping_interval: ping_interval} = args) do - Process.send_after(self(), :ping, ping_interval) - {:ok, args} - end - - def handle_info(:ping, %{ping_interval: ping_interval} = state) do - State.get_sockets() - |> Map.values() - |> List.flatten() - |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} -> - Logger.debug("Sending keepalive ping") - send(transport_pid, {:text, ""}) - end) - - Process.send_after(self(), :ping, ping_interval) - - {:noreply, state} - end -end diff --git a/lib/pleroma/web/streamer/state.ex b/lib/pleroma/web/streamer/state.ex deleted file mode 100644 index 999550b88..000000000 --- a/lib/pleroma/web/streamer/state.ex +++ /dev/null @@ -1,82 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.State do - use GenServer - require Logger - - alias Pleroma.Web.Streamer.StreamerSocket - - @env Mix.env() - - def start_link(_) do - GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__) - end - - def add_socket(topic, socket) do - GenServer.call(__MODULE__, {:add, topic, socket}) - end - - def remove_socket(topic, socket) do - do_remove_socket(@env, topic, socket) - end - - def get_sockets do - %{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state) - stream_sockets - end - - def init(init_arg) do - {:ok, init_arg} - end - - def handle_call(:get_state, _from, state) do - {:reply, state, state} - end - - def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do - internal_topic = internal_topic(topic, socket) - stream_socket = StreamerSocket.from_socket(socket) - - sockets_for_topic = - sockets - |> Map.get(internal_topic, []) - |> List.insert_at(0, stream_socket) - |> Enum.uniq() - - state = put_in(state, [:sockets, internal_topic], sockets_for_topic) - Logger.debug("Got new conn for #{topic}") - {:reply, state, state} - end - - def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do - internal_topic = internal_topic(topic, socket) - stream_socket = StreamerSocket.from_socket(socket) - - sockets_for_topic = - sockets - |> Map.get(internal_topic, []) - |> List.delete(stream_socket) - - state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic) - {:reply, state, state} - end - - defp do_remove_socket(:test, _, _) do - :ok - end - - defp do_remove_socket(_env, topic, socket) do - GenServer.call(__MODULE__, {:remove, topic, socket}) - end - - defp internal_topic(topic, socket) - when topic in ~w[user user:notification direct] do - "#{topic}:#{socket.assigns[:user].id}" - end - - defp internal_topic(topic, _) do - topic - end -end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 814d5a729..5ad4aa936 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -3,53 +3,241 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Streamer do - alias Pleroma.Web.Streamer.State - alias Pleroma.Web.Streamer.Worker + require Logger + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.Conversation.Participation + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.StreamerView - @timeout 60_000 @mix_env Mix.env() + @registry Pleroma.Web.StreamerRegistry + + def registry, do: @registry - def add_socket(topic, socket) do - State.add_socket(topic, socket) + def add_socket(topic, %User{} = user) do + if should_env_send?(), do: Registry.register(@registry, user_topic(topic, user), true) end - def remove_socket(topic, socket) do - State.remove_socket(topic, socket) + def add_socket(topic, _) do + if should_env_send?(), do: Registry.register(@registry, topic, false) end - def get_sockets do - State.get_sockets() + def remove_socket(topic) do + if should_env_send?(), do: Registry.unregister(@registry, topic) end - def stream(topics, items) do - if should_send?() do - Task.async(fn -> - :poolboy.transaction( - :streamer_worker, - &Worker.stream(&1, topics, items), - @timeout - ) + def stream(topics, item) when is_list(topics) do + if should_env_send?() do + Enum.each(topics, fn t -> + spawn(fn -> do_stream(t, item) end) end) end + + :ok end - def supervisor, do: Pleroma.Web.Streamer.Supervisor + def stream(topic, items) when is_list(items) do + if should_env_send?() do + Enum.each(items, fn i -> + spawn(fn -> do_stream(topic, i) end) + end) - defp should_send? do - handle_should_send(@mix_env) + :ok + end end - defp handle_should_send(:test) do - case Process.whereis(:streamer_worker) do - nil -> - false + def stream(topic, item) do + if should_env_send?() do + spawn(fn -> do_stream(topic, item) end) + end + + :ok + end - pid -> - Process.alive?(pid) + def filtered_by_user?(%User{} = user, %Activity{} = item) do + %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = + User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute]) + + recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids) + recipients = MapSet.new(item.recipients) + domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) + + with parent <- Object.normalize(item) || item, + true <- + Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), + true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, + true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)), + true <- MapSet.disjoint?(recipients, recipient_blocks), + %{host: item_host} <- URI.parse(item.actor), + %{host: parent_host} <- URI.parse(parent.data["actor"]), + false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), + false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), + true <- thread_containment(item, user), + false <- CommonAPI.thread_muted?(user, item) do + false + else + _ -> true end end - defp handle_should_send(:benchmark), do: false + def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do + filtered_by_user?(user, activity) + end + + defp do_stream("direct", item) do + recipient_topics = + User.get_recipients_from_activity(item) + |> Enum.map(fn %{id: id} -> "direct:#{id}" end) + + Enum.each(recipient_topics, fn user_topic -> + Logger.debug("Trying to push direct message to #{user_topic}\n\n") + push_to_socket(user_topic, item) + end) + end + + defp do_stream("participation", participation) do + user_topic = "direct:#{participation.user_id}" + Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") - defp handle_should_send(_), do: true + push_to_socket(user_topic, participation) + end + + defp do_stream("list", item) do + # filter the recipient list if the activity is not public, see #270. + recipient_lists = + case Visibility.is_public?(item) do + true -> + Pleroma.List.get_lists_from_activity(item) + + _ -> + Pleroma.List.get_lists_from_activity(item) + |> Enum.filter(fn list -> + owner = User.get_cached_by_id(list.user_id) + + Visibility.visible_for_user?(item, owner) + end) + end + + recipient_topics = + recipient_lists + |> Enum.map(fn %{id: id} -> "list:#{id}" end) + + Enum.each(recipient_topics, fn list_topic -> + Logger.debug("Trying to push message to #{list_topic}\n\n") + push_to_socket(list_topic, item) + end) + end + + defp do_stream(topic, %Notification{} = item) + when topic in ["user", "user:notification"] do + Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list -> + Enum.each(list, fn {pid, _auth} -> + send(pid, {:render_with_user, StreamerView, "notification.json", item}) + end) + end) + end + + defp do_stream("user", item) do + Logger.debug("Trying to push to users") + + recipient_topics = + User.get_recipients_from_activity(item) + |> Enum.map(fn %{id: id} -> "user:#{id}" end) + + Enum.each(recipient_topics, fn topic -> + push_to_socket(topic, item) + end) + end + + defp do_stream(topic, item) do + Logger.debug("Trying to push to #{topic}") + Logger.debug("Pushing item to #{topic}") + push_to_socket(topic, item) + end + + defp push_to_socket(topic, %Participation{} = participation) do + rendered = StreamerView.render("conversation.json", participation) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _} -> + send(pid, {:text, rendered}) + end) + end) + end + + defp push_to_socket(topic, %Activity{ + data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} + }) do + rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)}) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _} -> + send(pid, {:text, rendered}) + end) + end) + end + + defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop + + defp push_to_socket(topic, item) do + anon_render = StreamerView.render("update.json", item) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, auth?} -> + if auth? do + send(pid, {:render_with_user, StreamerView, "update.json", item}) + else + send(pid, {:text, anon_render}) + end + end) + end) + end + + defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true + + defp thread_containment(activity, user) do + if Config.get([:instance, :skip_thread_containment]) do + true + else + ActivityPub.contain_activity(activity, user) + end + end + + # In test environement, only return true if the registry is started. + # In benchmark environment, returns false. + # In any other environment, always returns true. + cond do + @mix_env == :test -> + def should_env_send? do + case Process.whereis(@registry) do + nil -> + false + + pid -> + Process.alive?(pid) + end + end + + @mix_env == :benchmark -> + def should_env_send?, do: false + + true -> + def should_env_send?, do: true + end + + defp user_topic(topic, user) + when topic in ~w[user user:notification direct] do + "#{topic}:#{user.id}" + end + + defp user_topic(topic, _) do + topic + end end diff --git a/lib/pleroma/web/streamer/streamer_socket.ex b/lib/pleroma/web/streamer/streamer_socket.ex deleted file mode 100644 index 7d5dcd34e..000000000 --- a/lib/pleroma/web/streamer/streamer_socket.ex +++ /dev/null @@ -1,35 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.StreamerSocket do - defstruct transport_pid: nil, user: nil - - alias Pleroma.User - alias Pleroma.Web.Streamer.StreamerSocket - - def from_socket(%{ - transport_pid: transport_pid, - assigns: %{user: nil} - }) do - %StreamerSocket{ - transport_pid: transport_pid - } - end - - def from_socket(%{ - transport_pid: transport_pid, - assigns: %{user: %User{} = user} - }) do - %StreamerSocket{ - transport_pid: transport_pid, - user: user - } - end - - def from_socket(%{transport_pid: transport_pid}) do - %StreamerSocket{ - transport_pid: transport_pid - } - end -end diff --git a/lib/pleroma/web/streamer/supervisor.ex b/lib/pleroma/web/streamer/supervisor.ex deleted file mode 100644 index bd9029bc0..000000000 --- a/lib/pleroma/web/streamer/supervisor.ex +++ /dev/null @@ -1,37 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.Supervisor do - use Supervisor - - def start_link(opts) do - Supervisor.start_link(__MODULE__, opts, name: __MODULE__) - end - - def init(args) do - children = [ - {Pleroma.Web.Streamer.State, args}, - {Pleroma.Web.Streamer.Ping, args}, - :poolboy.child_spec(:streamer_worker, poolboy_config()) - ] - - opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor] - Supervisor.init(children, opts) - end - - defp poolboy_config do - opts = - Pleroma.Config.get(:streamer, - workers: 3, - overflow_workers: 2 - ) - - [ - {:name, {:local, :streamer_worker}}, - {:worker_module, Pleroma.Web.Streamer.Worker}, - {:size, opts[:workers]}, - {:max_overflow, opts[:overflow_workers]} - ] - end -end diff --git a/lib/pleroma/web/streamer/worker.ex b/lib/pleroma/web/streamer/worker.ex deleted file mode 100644 index f6160fa4d..000000000 --- a/lib/pleroma/web/streamer/worker.ex +++ /dev/null @@ -1,208 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Streamer.Worker do - use GenServer - - require Logger - - alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.Conversation.Participation - alias Pleroma.Notification - alias Pleroma.Object - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.Streamer.State - alias Pleroma.Web.Streamer.StreamerSocket - alias Pleroma.Web.StreamerView - - def start_link(_) do - GenServer.start_link(__MODULE__, %{}, []) - end - - def init(init_arg) do - {:ok, init_arg} - end - - def stream(pid, topics, items) do - GenServer.call(pid, {:stream, topics, items}) - end - - def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do - Enum.each(topics, fn t -> - do_stream(%{topic: t, item: item}) - end) - - {:reply, state, state} - end - - def handle_call({:stream, topic, items}, _from, state) when is_list(items) do - Enum.each(items, fn i -> - do_stream(%{topic: topic, item: i}) - end) - - {:reply, state, state} - end - - def handle_call({:stream, topic, item}, _from, state) do - do_stream(%{topic: topic, item: item}) - - {:reply, state, state} - end - - defp do_stream(%{topic: "direct", item: item}) do - recipient_topics = - User.get_recipients_from_activity(item) - |> Enum.map(fn %{id: id} -> "direct:#{id}" end) - - Enum.each(recipient_topics, fn user_topic -> - Logger.debug("Trying to push direct message to #{user_topic}\n\n") - push_to_socket(State.get_sockets(), user_topic, item) - end) - end - - defp do_stream(%{topic: "participation", item: participation}) do - user_topic = "direct:#{participation.user_id}" - Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n") - - push_to_socket(State.get_sockets(), user_topic, participation) - end - - defp do_stream(%{topic: "list", item: item}) do - # filter the recipient list if the activity is not public, see #270. - recipient_lists = - case Visibility.is_public?(item) do - true -> - Pleroma.List.get_lists_from_activity(item) - - _ -> - Pleroma.List.get_lists_from_activity(item) - |> Enum.filter(fn list -> - owner = User.get_cached_by_id(list.user_id) - - Visibility.visible_for_user?(item, owner) - end) - end - - recipient_topics = - recipient_lists - |> Enum.map(fn %{id: id} -> "list:#{id}" end) - - Enum.each(recipient_topics, fn list_topic -> - Logger.debug("Trying to push message to #{list_topic}\n\n") - push_to_socket(State.get_sockets(), list_topic, item) - end) - end - - defp do_stream(%{topic: topic, item: %Notification{} = item}) - when topic in ["user", "user:notification"] do - State.get_sockets() - |> Map.get("#{topic}:#{item.user_id}", []) - |> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} -> - with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id), - true <- should_send?(user, item) do - send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)}) - end - end) - end - - defp do_stream(%{topic: "user", item: item}) do - Logger.debug("Trying to push to users") - - recipient_topics = - User.get_recipients_from_activity(item) - |> Enum.map(fn %{id: id} -> "user:#{id}" end) - - Enum.each(recipient_topics, fn topic -> - push_to_socket(State.get_sockets(), topic, item) - end) - end - - defp do_stream(%{topic: topic, item: item}) do - Logger.debug("Trying to push to #{topic}") - Logger.debug("Pushing item to #{topic}") - push_to_socket(State.get_sockets(), topic, item) - end - - defp should_send?(%User{} = user, %Activity{} = item) do - %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = - User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute]) - - recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids) - recipients = MapSet.new(item.recipients) - domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) - - with parent <- Object.normalize(item) || item, - true <- - Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), - true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, - true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)), - true <- MapSet.disjoint?(recipients, recipient_blocks), - %{host: item_host} <- URI.parse(item.actor), - %{host: parent_host} <- URI.parse(parent.data["actor"]), - false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), - false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), - true <- thread_containment(item, user), - false <- CommonAPI.thread_muted?(user, item) do - true - else - _ -> false - end - end - - defp should_send?(%User{} = user, %Notification{activity: activity}) do - should_send?(user, activity) - end - - def push_to_socket(topics, topic, %Participation{} = participation) do - Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> - send(transport_pid, {:text, StreamerView.render("conversation.json", participation)}) - end) - end - - def push_to_socket(topics, topic, %Activity{ - data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id} - }) do - Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} -> - send( - transport_pid, - {:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()} - ) - end) - end - - def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop - - def push_to_socket(topics, topic, item) do - Enum.each(topics[topic] || [], fn %StreamerSocket{ - transport_pid: transport_pid, - user: socket_user - } -> - # Get the current user so we have up-to-date blocks etc. - if socket_user do - user = User.get_cached_by_ap_id(socket_user.ap_id) - - if should_send?(user, item) do - send(transport_pid, {:text, StreamerView.render("update.json", item, user)}) - end - else - send(transport_pid, {:text, StreamerView.render("update.json", item)}) - end - end) - end - - @spec thread_containment(Activity.t(), User.t()) :: boolean() - defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true - - defp thread_containment(activity, user) do - if Config.get([:instance, :skip_thread_containment]) do - true - else - ActivityPub.contain_activity(activity, user) - end - end -end diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs index bd229c55f..109c7b4cb 100644 --- a/test/integration/mastodon_websocket_test.exs +++ b/test/integration/mastodon_websocket_test.exs @@ -12,17 +12,14 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth + @moduletag needs_streamer: true, capture_log: true + @path Pleroma.Web.Endpoint.url() |> URI.parse() |> Map.put(:scheme, "ws") |> Map.put(:path, "/api/v1/streaming") |> URI.to_string() - setup_all do - start_supervised(Pleroma.Web.Streamer.supervisor()) - :ok - end - def start_socket(qs \\ nil, headers \\ []) do path = case qs do diff --git a/test/notification_test.exs b/test/notification_test.exs index 601a6c0ca..5c85f3368 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -162,14 +162,18 @@ test "does not create a notification for subscribed users if status is a reply" @tag needs_streamer: true test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do user = insert(:user) - task = Task.async(fn -> assert_receive {:text, _}, 4_000 end) - task_user_notification = Task.async(fn -> assert_receive {:text, _}, 4_000 end) - Streamer.add_socket("user", %{transport_pid: task.pid, assigns: %{user: user}}) - Streamer.add_socket( - "user:notification", - %{transport_pid: task_user_notification.pid, assigns: %{user: user}} - ) + task = + Task.async(fn -> + Streamer.add_socket("user", user) + assert_receive {:render_with_user, _, _, _}, 4_000 + end) + + task_user_notification = + Task.async(fn -> + Streamer.add_socket("user:notification", user) + assert_receive {:render_with_user, _, _, _}, 4_000 + end) activity = insert(:note_activity) diff --git a/test/support/builders/activity_builder.ex b/test/support/builders/activity_builder.ex index 6e5a8e059..7c4950bfa 100644 --- a/test/support/builders/activity_builder.ex +++ b/test/support/builders/activity_builder.ex @@ -21,7 +21,15 @@ def build(data \\ %{}, opts \\ %{}) do def insert(data \\ %{}, opts \\ %{}) do activity = build(data, opts) - ActivityPub.insert(activity) + + case ActivityPub.insert(activity) do + ok = {:ok, activity} -> + ActivityPub.notify_and_stream(activity) + ok + + error -> + error + end end def insert_list(times, data \\ %{}, opts \\ %{}) do diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 91c03b1a8..b23918dd1 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -139,7 +139,11 @@ defp ensure_federating_or_authenticated(conn, url, user) do end if tags[:needs_streamer] do - start_supervised(Pleroma.Web.Streamer.supervisor()) + start_supervised(%{ + id: Pleroma.Web.Streamer.registry(), + start: + {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]} + }) end {:ok, conn: Phoenix.ConnTest.build_conn()} diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 1669f2520..ba8848952 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -40,7 +40,11 @@ defmodule Pleroma.DataCase do end if tags[:needs_streamer] do - start_supervised(Pleroma.Web.Streamer.supervisor()) + start_supervised(%{ + id: Pleroma.Web.Streamer.registry(), + start: + {Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]} + }) end :ok diff --git a/test/web/streamer/ping_test.exs b/test/web/streamer/ping_test.exs deleted file mode 100644 index 5df6c1cc3..000000000 --- a/test/web/streamer/ping_test.exs +++ /dev/null @@ -1,36 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PingTest do - use Pleroma.DataCase - - import Pleroma.Factory - alias Pleroma.Web.Streamer - - setup do - start_supervised({Streamer.supervisor(), [ping_interval: 30]}) - - :ok - end - - describe "sockets" do - setup do - user = insert(:user) - {:ok, %{user: user}} - end - - test "it sends pings", %{user: user} do - task = - Task.async(fn -> - assert_receive {:text, received_event}, 40 - assert_receive {:text, received_event}, 40 - assert_receive {:text, received_event}, 40 - end) - - Streamer.add_socket("public", %{transport_pid: task.pid, assigns: %{user: user}}) - - Task.await(task) - end - end -end diff --git a/test/web/streamer/state_test.exs b/test/web/streamer/state_test.exs deleted file mode 100644 index a755e75c0..000000000 --- a/test/web/streamer/state_test.exs +++ /dev/null @@ -1,54 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.StateTest do - use Pleroma.DataCase - - import Pleroma.Factory - alias Pleroma.Web.Streamer - alias Pleroma.Web.Streamer.StreamerSocket - - @moduletag needs_streamer: true - - describe "sockets" do - setup do - user = insert(:user) - user2 = insert(:user) - {:ok, %{user: user, user2: user2}} - end - - test "it can add a socket", %{user: user} do - Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}}) - - assert(%{"public" => [%StreamerSocket{transport_pid: 1}]} = Streamer.get_sockets()) - end - - test "it can add multiple sockets per user", %{user: user} do - Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}}) - Streamer.add_socket("public", %{transport_pid: 2, assigns: %{user: user}}) - - assert( - %{ - "public" => [ - %StreamerSocket{transport_pid: 2}, - %StreamerSocket{transport_pid: 1} - ] - } = Streamer.get_sockets() - ) - end - - test "it will not add a duplicate socket", %{user: user} do - Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}}) - Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}}) - - assert( - %{ - "activity" => [ - %StreamerSocket{transport_pid: 1} - ] - } = Streamer.get_sockets() - ) - end - end -end diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 3c0f240f5..ee530f4e9 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -12,13 +12,9 @@ defmodule Pleroma.Web.StreamerTest do alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Streamer - alias Pleroma.Web.Streamer.StreamerSocket - alias Pleroma.Web.Streamer.Worker @moduletag needs_streamer: true, capture_log: true - @streamer_timeout 150 - @streamer_start_wait 10 setup do: clear_config([:instance, :skip_thread_containment]) describe "user streams" do @@ -29,69 +25,35 @@ defmodule Pleroma.Web.StreamerTest do end test "it streams the user's post in the 'user' stream", %{user: user} do - task = - Task.async(fn -> - assert_receive {:text, _}, @streamer_timeout - end) - - Streamer.add_socket( - "user", - %{transport_pid: task.pid, assigns: %{user: user}} - ) - + Streamer.add_socket("user", user) {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) - - Streamer.stream("user", activity) - Task.await(task) + assert_receive {:render_with_user, _, _, ^activity} + refute Streamer.filtered_by_user?(user, activity) end test "it streams boosts of the user in the 'user' stream", %{user: user} do - task = - Task.async(fn -> - assert_receive {:text, _}, @streamer_timeout - end) - - Streamer.add_socket( - "user", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + Streamer.add_socket("user", user) other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) {:ok, announce, _} = CommonAPI.repeat(activity.id, user) - Streamer.stream("user", announce) - Task.await(task) + assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} + refute Streamer.filtered_by_user?(user, announce) end test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do - task = - Task.async(fn -> - assert_receive {:text, _}, @streamer_timeout - end) - - Streamer.add_socket( - "user", - %{transport_pid: task.pid, assigns: %{user: user}} - ) - + Streamer.add_socket("user", user) Streamer.stream("user", notify) - Task.await(task) + assert_receive {:render_with_user, _, _, ^notify} + refute Streamer.filtered_by_user?(user, notify) end test "it sends notify to in the 'user:notification' stream", %{user: user, notify: notify} do - task = - Task.async(fn -> - assert_receive {:text, _}, @streamer_timeout - end) - - Streamer.add_socket( - "user:notification", - %{transport_pid: task.pid, assigns: %{user: user}} - ) - + Streamer.add_socket("user:notification", user) Streamer.stream("user:notification", notify) - Task.await(task) + assert_receive {:render_with_user, _, _, ^notify} + refute Streamer.filtered_by_user?(user, notify) end test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{ @@ -100,18 +62,12 @@ test "it doesn't send notify to the 'user:notification' stream when a user is bl blocked = insert(:user) {:ok, _user_relationship} = User.block(user, blocked) - task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end) - - Streamer.add_socket( - "user:notification", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + Streamer.add_socket("user:notification", user) {:ok, activity} = CommonAPI.post(user, %{"status" => ":("}) - {:ok, notif} = CommonAPI.favorite(blocked, activity.id) + {:ok, _} = CommonAPI.favorite(blocked, activity.id) - Streamer.stream("user:notification", notif) - Task.await(task) + refute_receive _ end test "it doesn't send notify to the 'user:notification' stream when a thread is muted", %{ @@ -119,45 +75,50 @@ test "it doesn't send notify to the 'user:notification' stream when a thread is } do user2 = insert(:user) - task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end) + {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) + {:ok, _} = CommonAPI.add_mute(user, activity) - Streamer.add_socket( - "user:notification", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + Streamer.add_socket("user:notification", user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) - {:ok, activity} = CommonAPI.add_mute(user, activity) - {:ok, notif} = CommonAPI.favorite(user2, activity.id) + {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) - Streamer.stream("user:notification", notif) - Task.await(task) + refute_receive _ + assert Streamer.filtered_by_user?(user, favorite_activity) end - test "it doesn't send notify to the 'user:notification' stream' when a domain is blocked", %{ + test "it sends favorite to 'user:notification' stream'", %{ user: user } do user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"}) - task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end) + {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) + Streamer.add_socket("user:notification", user) + {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) + + assert_receive {:render_with_user, _, "notification.json", notif} + assert notif.activity.id == favorite_activity.id + refute Streamer.filtered_by_user?(user, notif) + end - Streamer.add_socket( - "user:notification", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + test "it doesn't send the 'user:notification' stream' when a domain is blocked", %{ + user: user + } do + user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"}) {:ok, user} = User.block_domain(user, "hecking-lewd-place.com") {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) - {:ok, notif} = CommonAPI.favorite(user2, activity.id) + Streamer.add_socket("user:notification", user) + {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) - Streamer.stream("user:notification", notif) - Task.await(task) + refute_receive _ + assert Streamer.filtered_by_user?(user, favorite_activity) end test "it sends follow activities to the 'user:notification' stream", %{ user: user } do user_url = user.ap_id + user2 = insert(:user) body = File.read!("test/fixtures/users_mock/localhost.json") @@ -169,47 +130,24 @@ test "it sends follow activities to the 'user:notification' stream", %{ %Tesla.Env{status: 200, body: body} end) - user2 = insert(:user) - task = Task.async(fn -> assert_receive {:text, _}, @streamer_timeout end) - - Process.sleep(@streamer_start_wait) - - Streamer.add_socket( - "user:notification", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + Streamer.add_socket("user:notification", user) + {:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user) - {:ok, _follower, _followed, _activity} = CommonAPI.follow(user2, user) - - # We don't directly pipe the notification to the streamer as it's already - # generated as a side effect of CommonAPI.follow(). - Task.await(task) + assert_receive {:render_with_user, _, "notification.json", notif} + assert notif.activity.id == follow_activity.id + refute Streamer.filtered_by_user?(user, notif) end end - test "it sends to public" do + test "it sends to public authenticated" do user = insert(:user) other_user = insert(:user) - task = - Task.async(fn -> - assert_receive {:text, _}, @streamer_timeout - end) - - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user - } - - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"}) + Streamer.add_socket("public", other_user) - topics = %{ - "public" => [fake_socket] - } - - Worker.push_to_socket(topics, "public", activity) - - Task.await(task) + {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"}) + assert_receive {:render_with_user, _, _, ^activity} + refute Streamer.filtered_by_user?(user, activity) end test "works for deletions" do @@ -217,37 +155,32 @@ test "works for deletions" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"}) - task = - Task.async(fn -> - expected_event = - %{ - "event" => "delete", - "payload" => activity.id - } - |> Jason.encode!() - - assert_receive {:text, received_event}, @streamer_timeout - assert received_event == expected_event - end) + Streamer.add_socket("public", user) - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user - } + {:ok, _} = CommonAPI.delete(activity.id, other_user) + activity_id = activity.id + assert_receive {:text, event} + assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event) + end - {:ok, activity} = CommonAPI.delete(activity.id, other_user) + test "it sends to public unauthenticated" do + user = insert(:user) - topics = %{ - "public" => [fake_socket] - } + Streamer.add_socket("public", nil) - Worker.push_to_socket(topics, "public", activity) + {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"}) + activity_id = activity.id + assert_receive {:text, event} + assert %{"event" => "update", "payload" => payload} = Jason.decode!(event) + assert %{"id" => ^activity_id} = Jason.decode!(payload) - Task.await(task) + {:ok, _} = CommonAPI.delete(activity.id, user) + assert_receive {:text, event} + assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event) end describe "thread_containment" do - test "it doesn't send to user if recipients invalid and thread containment is enabled" do + test "it filters to user if recipients invalid and thread containment is enabled" do Pleroma.Config.put([:instance, :skip_thread_containment], false) author = insert(:user) user = insert(:user) @@ -262,12 +195,10 @@ test "it doesn't send to user if recipients invalid and thread containment is en ) ) - task = Task.async(fn -> refute_receive {:text, _}, 1_000 end) - fake_socket = %StreamerSocket{transport_pid: task.pid, user: user} - topics = %{"public" => [fake_socket]} - Worker.push_to_socket(topics, "public", activity) - - Task.await(task) + Streamer.add_socket("public", user) + Streamer.stream("public", activity) + assert_receive {:render_with_user, _, _, ^activity} + assert Streamer.filtered_by_user?(user, activity) end test "it sends message if recipients invalid and thread containment is disabled" do @@ -285,12 +216,11 @@ test "it sends message if recipients invalid and thread containment is disabled" ) ) - task = Task.async(fn -> assert_receive {:text, _}, 1_000 end) - fake_socket = %StreamerSocket{transport_pid: task.pid, user: user} - topics = %{"public" => [fake_socket]} - Worker.push_to_socket(topics, "public", activity) + Streamer.add_socket("public", user) + Streamer.stream("public", activity) - Task.await(task) + assert_receive {:render_with_user, _, _, ^activity} + refute Streamer.filtered_by_user?(user, activity) end test "it sends message if recipients invalid and thread containment is enabled but user's thread containment is disabled" do @@ -308,255 +238,168 @@ test "it sends message if recipients invalid and thread containment is enabled b ) ) - task = Task.async(fn -> assert_receive {:text, _}, 1_000 end) - fake_socket = %StreamerSocket{transport_pid: task.pid, user: user} - topics = %{"public" => [fake_socket]} - Worker.push_to_socket(topics, "public", activity) + Streamer.add_socket("public", user) + Streamer.stream("public", activity) - Task.await(task) + assert_receive {:render_with_user, _, _, ^activity} + refute Streamer.filtered_by_user?(user, activity) end end describe "blocks" do - test "it doesn't send messages involving blocked users" do + test "it filters messages involving blocked users" do user = insert(:user) blocked_user = insert(:user) {:ok, _user_relationship} = User.block(user, blocked_user) + Streamer.add_socket("public", user) {:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"}) - - task = - Task.async(fn -> - refute_receive {:text, _}, 1_000 - end) - - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user - } - - topics = %{ - "public" => [fake_socket] - } - - Worker.push_to_socket(topics, "public", activity) - - Task.await(task) + assert_receive {:render_with_user, _, _, ^activity} + assert Streamer.filtered_by_user?(user, activity) end - test "it doesn't send messages transitively involving blocked users" do + test "it filters messages transitively involving blocked users" do blocker = insert(:user) blockee = insert(:user) friend = insert(:user) - task = - Task.async(fn -> - refute_receive {:text, _}, 1_000 - end) - - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: blocker - } - - topics = %{ - "public" => [fake_socket] - } + Streamer.add_socket("public", blocker) {:ok, _user_relationship} = User.block(blocker, blockee) {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"}) - Worker.push_to_socket(topics, "public", activity_one) + assert_receive {:render_with_user, _, _, ^activity_one} + assert Streamer.filtered_by_user?(blocker, activity_one) {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"}) - Worker.push_to_socket(topics, "public", activity_two) + assert_receive {:render_with_user, _, _, ^activity_two} + assert Streamer.filtered_by_user?(blocker, activity_two) {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"}) - Worker.push_to_socket(topics, "public", activity_three) - - Task.await(task) + assert_receive {:render_with_user, _, _, ^activity_three} + assert Streamer.filtered_by_user?(blocker, activity_three) end end - test "it doesn't send unwanted DMs to list" do - user_a = insert(:user) - user_b = insert(:user) - user_c = insert(:user) - - {:ok, user_a} = User.follow(user_a, user_b) - - {:ok, list} = List.create("Test", user_a) - {:ok, list} = List.follow(list, user_b) - - {:ok, activity} = - CommonAPI.post(user_b, %{ - "status" => "@#{user_c.nickname} Test", - "visibility" => "direct" - }) - - task = - Task.async(fn -> - refute_receive {:text, _}, 1_000 - end) - - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user_a - } - - topics = %{ - "list:#{list.id}" => [fake_socket] - } - - Worker.handle_call({:stream, "list", activity}, self(), topics) - - Task.await(task) - end - - test "it doesn't send unwanted private posts to list" do - user_a = insert(:user) - user_b = insert(:user) + describe "lists" do + test "it doesn't send unwanted DMs to list" do + user_a = insert(:user) + user_b = insert(:user) + user_c = insert(:user) - {:ok, list} = List.create("Test", user_a) - {:ok, list} = List.follow(list, user_b) + {:ok, user_a} = User.follow(user_a, user_b) - {:ok, activity} = - CommonAPI.post(user_b, %{ - "status" => "Test", - "visibility" => "private" - }) + {:ok, list} = List.create("Test", user_a) + {:ok, list} = List.follow(list, user_b) - task = - Task.async(fn -> - refute_receive {:text, _}, 1_000 - end) + Streamer.add_socket("list:#{list.id}", user_a) - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user_a - } + {:ok, _activity} = + CommonAPI.post(user_b, %{ + "status" => "@#{user_c.nickname} Test", + "visibility" => "direct" + }) - topics = %{ - "list:#{list.id}" => [fake_socket] - } + refute_receive _ + end - Worker.handle_call({:stream, "list", activity}, self(), topics) + test "it doesn't send unwanted private posts to list" do + user_a = insert(:user) + user_b = insert(:user) - Task.await(task) - end + {:ok, list} = List.create("Test", user_a) + {:ok, list} = List.follow(list, user_b) - test "it sends wanted private posts to list" do - user_a = insert(:user) - user_b = insert(:user) + Streamer.add_socket("list:#{list.id}", user_a) - {:ok, user_a} = User.follow(user_a, user_b) + {:ok, _activity} = + CommonAPI.post(user_b, %{ + "status" => "Test", + "visibility" => "private" + }) - {:ok, list} = List.create("Test", user_a) - {:ok, list} = List.follow(list, user_b) + refute_receive _ + end - {:ok, activity} = - CommonAPI.post(user_b, %{ - "status" => "Test", - "visibility" => "private" - }) + test "it sends wanted private posts to list" do + user_a = insert(:user) + user_b = insert(:user) - task = - Task.async(fn -> - assert_receive {:text, _}, 1_000 - end) + {:ok, user_a} = User.follow(user_a, user_b) - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user_a - } + {:ok, list} = List.create("Test", user_a) + {:ok, list} = List.follow(list, user_b) - Streamer.add_socket( - "list:#{list.id}", - fake_socket - ) + Streamer.add_socket("list:#{list.id}", user_a) - Worker.handle_call({:stream, "list", activity}, self(), %{}) + {:ok, activity} = + CommonAPI.post(user_b, %{ + "status" => "Test", + "visibility" => "private" + }) - Task.await(task) + assert_receive {:render_with_user, _, _, ^activity} + refute Streamer.filtered_by_user?(user_a, activity) + end end - test "it doesn't send muted reblogs" do - user1 = insert(:user) - user2 = insert(:user) - user3 = insert(:user) - CommonAPI.hide_reblogs(user1, user2) - - {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"}) - {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2) - - task = - Task.async(fn -> - refute_receive {:text, _}, 1_000 - end) - - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user1 - } - - topics = %{ - "public" => [fake_socket] - } - - Worker.push_to_socket(topics, "public", announce_activity) + describe "muted reblogs" do + test "it filters muted reblogs" do + user1 = insert(:user) + user2 = insert(:user) + user3 = insert(:user) + CommonAPI.follow(user1, user2) + CommonAPI.hide_reblogs(user1, user2) - Task.await(task) - end + {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"}) - test "it does send non-reblog notification for reblog-muted actors" do - user1 = insert(:user) - user2 = insert(:user) - user3 = insert(:user) - CommonAPI.hide_reblogs(user1, user2) + Streamer.add_socket("user", user1) + {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2) + assert_receive {:render_with_user, _, _, ^announce_activity} + assert Streamer.filtered_by_user?(user1, announce_activity) + end - {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"}) - {:ok, favorite_activity} = CommonAPI.favorite(user2, create_activity.id) + test "it filters reblog notification for reblog-muted actors" do + user1 = insert(:user) + user2 = insert(:user) + CommonAPI.follow(user1, user2) + CommonAPI.hide_reblogs(user1, user2) - task = - Task.async(fn -> - assert_receive {:text, _}, 1_000 - end) + {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"}) + Streamer.add_socket("user", user1) + {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2) - fake_socket = %StreamerSocket{ - transport_pid: task.pid, - user: user1 - } + assert_receive {:render_with_user, _, "notification.json", notif} + assert Streamer.filtered_by_user?(user1, notif) + end - topics = %{ - "public" => [fake_socket] - } + test "it send non-reblog notification for reblog-muted actors" do + user1 = insert(:user) + user2 = insert(:user) + CommonAPI.follow(user1, user2) + CommonAPI.hide_reblogs(user1, user2) - Worker.push_to_socket(topics, "public", favorite_activity) + {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"}) + Streamer.add_socket("user", user1) + {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id) - Task.await(task) + assert_receive {:render_with_user, _, "notification.json", notif} + refute Streamer.filtered_by_user?(user1, notif) + end end - test "it doesn't send posts from muted threads" do + test "it filters posts from muted threads" do user = insert(:user) user2 = insert(:user) + Streamer.add_socket("user", user2) {:ok, user2, user, _activity} = CommonAPI.follow(user2, user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) - - {:ok, activity} = CommonAPI.add_mute(user2, activity) - - task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end) - - Streamer.add_socket( - "user", - %{transport_pid: task.pid, assigns: %{user: user2}} - ) - - Streamer.stream("user", activity) - Task.await(task) + {:ok, _} = CommonAPI.add_mute(user2, activity) + assert_receive {:render_with_user, _, _, ^activity} + assert Streamer.filtered_by_user?(user2, activity) end describe "direct streams" do @@ -568,22 +411,7 @@ test "it sends conversation update to the 'direct' stream", %{} do user = insert(:user) another_user = insert(:user) - task = - Task.async(fn -> - assert_receive {:text, received_event}, @streamer_timeout - - assert %{"event" => "conversation", "payload" => received_payload} = - Jason.decode!(received_event) - - assert %{"last_status" => last_status} = Jason.decode!(received_payload) - [participation] = Participation.for_user(user) - assert last_status["pleroma"]["direct_conversation_id"] == participation.id - end) - - Streamer.add_socket( - "direct", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + Streamer.add_socket("direct", user) {:ok, _create_activity} = CommonAPI.post(another_user, %{ @@ -591,42 +419,47 @@ test "it sends conversation update to the 'direct' stream", %{} do "visibility" => "direct" }) - Task.await(task) + assert_receive {:text, received_event} + + assert %{"event" => "conversation", "payload" => received_payload} = + Jason.decode!(received_event) + + assert %{"last_status" => last_status} = Jason.decode!(received_payload) + [participation] = Participation.for_user(user) + assert last_status["pleroma"]["direct_conversation_id"] == participation.id end test "it doesn't send conversation update to the 'direct' stream when the last message in the conversation is deleted" do user = insert(:user) another_user = insert(:user) + Streamer.add_socket("direct", user) + {:ok, create_activity} = CommonAPI.post(another_user, %{ "status" => "hi @#{user.nickname}", "visibility" => "direct" }) - task = - Task.async(fn -> - assert_receive {:text, received_event}, @streamer_timeout - assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event) + create_activity_id = create_activity.id + assert_receive {:render_with_user, _, _, ^create_activity} + assert_receive {:text, received_conversation1} + assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1) - refute_receive {:text, _}, @streamer_timeout - end) + {:ok, _} = CommonAPI.delete(create_activity_id, another_user) - Process.sleep(@streamer_start_wait) + assert_receive {:text, received_event} - Streamer.add_socket( - "direct", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + assert %{"event" => "delete", "payload" => ^create_activity_id} = + Jason.decode!(received_event) - {:ok, _} = CommonAPI.delete(create_activity.id, another_user) - - Task.await(task) + refute_receive _ end test "it sends conversation update to the 'direct' stream when a message is deleted" do user = insert(:user) another_user = insert(:user) + Streamer.add_socket("direct", user) {:ok, create_activity} = CommonAPI.post(another_user, %{ @@ -636,35 +469,30 @@ test "it sends conversation update to the 'direct' stream when a message is dele {:ok, create_activity2} = CommonAPI.post(another_user, %{ - "status" => "hi @#{user.nickname}", + "status" => "hi @#{user.nickname} 2", "in_reply_to_status_id" => create_activity.id, "visibility" => "direct" }) - task = - Task.async(fn -> - assert_receive {:text, received_event}, @streamer_timeout - assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event) - - assert_receive {:text, received_event}, @streamer_timeout + assert_receive {:render_with_user, _, _, ^create_activity} + assert_receive {:render_with_user, _, _, ^create_activity2} + assert_receive {:text, received_conversation1} + assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1) + assert_receive {:text, received_conversation1} + assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1) - assert %{"event" => "conversation", "payload" => received_payload} = - Jason.decode!(received_event) - - assert %{"last_status" => last_status} = Jason.decode!(received_payload) - assert last_status["id"] == to_string(create_activity.id) - end) + {:ok, _} = CommonAPI.delete(create_activity2.id, another_user) - Process.sleep(@streamer_start_wait) + assert_receive {:text, received_event} + assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event) - Streamer.add_socket( - "direct", - %{transport_pid: task.pid, assigns: %{user: user}} - ) + assert_receive {:text, received_event} - {:ok, _} = CommonAPI.delete(create_activity2.id, another_user) + assert %{"event" => "conversation", "payload" => received_payload} = + Jason.decode!(received_event) - Task.await(task) + assert %{"last_status" => last_status} = Jason.decode!(received_payload) + assert last_status["id"] == to_string(create_activity.id) end end end -- cgit v1.2.3 From cdca62e8d4772240c513acc08a627d2f0ee0eed4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 6 May 2020 19:20:26 +0400 Subject: Add schema for Tag --- lib/pleroma/web/api_spec/schemas/status.ex | 12 ++---------- lib/pleroma/web/api_spec/schemas/tag.ex | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 lib/pleroma/web/api_spec/schemas/tag.ex diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 7a804461f..2572c9641 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do alias Pleroma.Web.ApiSpec.Schemas.Emoji alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.Poll + alias Pleroma.Web.ApiSpec.Schemas.Tag alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope require OpenApiSpex @@ -106,16 +107,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do replies_count: %Schema{type: :integer}, sensitive: %Schema{type: :boolean}, spoiler_text: %Schema{type: :string}, - tags: %Schema{ - type: :array, - items: %Schema{ - type: :object, - properties: %{ - name: %Schema{type: :string}, - url: %Schema{type: :string, format: :uri} - } - } - }, + tags: %Schema{type: :array, items: Tag}, uri: %Schema{type: :string, format: :uri}, url: %Schema{type: :string, nullable: true, format: :uri}, visibility: VisibilityScope diff --git a/lib/pleroma/web/api_spec/schemas/tag.ex b/lib/pleroma/web/api_spec/schemas/tag.ex new file mode 100644 index 000000000..e693fb83e --- /dev/null +++ b/lib/pleroma/web/api_spec/schemas/tag.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.Schemas.Tag do + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Tag", + description: "Represents a hashtag used within the content of a status", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "The value of the hashtag after the # sign"}, + url: %Schema{ + type: :string, + format: :uri, + description: "A link to the hashtag on the instance" + } + }, + example: %{ + name: "cofe", + url: "https://lain.com/tag/cofe" + } + }) +end -- cgit v1.2.3 From dc4a448f4863e7d69c55d39273575fb3463c6c3c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 7 May 2020 14:04:48 +0400 Subject: Add OpenAPI spec for SearchController --- .../web/api_spec/operations/account_operation.ex | 5 +- .../web/api_spec/operations/search_operation.ex | 207 +++++++++++++++++++++ .../mastodon_api/controllers/search_controller.ex | 24 ++- .../controllers/search_controller_test.exs | 78 ++++---- 4 files changed, 264 insertions(+), 50 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/search_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 470fc0215..70069d6f9 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -556,11 +556,12 @@ defp update_creadentials_request do } end - defp array_of_accounts do + def array_of_accounts do %Schema{ title: "ArrayOfAccounts", type: :array, - items: Account + items: Account, + example: [Account.schema().example] } end diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex new file mode 100644 index 000000000..ec1ae5dcf --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -0,0 +1,207 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.SearchOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.Tag + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def account_search_operation do + %Operation{ + tags: ["Search"], + summary: "Search for matching accounts by username or display name", + operationId: "SearchController.account_search", + parameters: [ + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 40}, + "Maximum number of results" + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup. Use this when `q` is an exact address." + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only who the user is following." + ) + ], + responses: %{ + 200 => + Operation.response( + "Array of Account", + "application/json", + AccountOperation.array_of_accounts() + ) + } + } + end + + def search_operation do + %Operation{ + tags: ["Search"], + summary: "Search results", + security: [%{"oAuth" => ["read:search"]}], + operationId: "SearchController.search", + deprecated: true, + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer}, + "Offset" + ) + | pagination_params() + ], + responses: %{ + 200 => Operation.response("Results", "application/json", results()) + } + } + end + + def search2_operation do + %Operation{ + tags: ["Search"], + summary: "Search results", + security: [%{"oAuth" => ["read:search"]}], + operationId: "SearchController.search2", + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ) + | pagination_params() + ], + responses: %{ + 200 => Operation.response("Results", "application/json", results2()) + } + } + end + + defp results2 do + %Schema{ + title: "SearchResults", + type: :object, + properties: %{ + accounts: %Schema{ + type: :array, + items: Account, + description: "Accounts which match the given query" + }, + statuses: %Schema{ + type: :array, + items: Status, + description: "Statuses which match the given query" + }, + hashtags: %Schema{ + type: :array, + items: Tag, + description: "Hashtags which match the given query" + } + }, + example: %{ + "accounts" => [Account.schema().example], + "statuses" => [Status.schema().example], + "hashtags" => [Tag.schema().example] + } + } + end + + defp results do + %Schema{ + title: "SearchResults", + type: :object, + properties: %{ + accounts: %Schema{ + type: :array, + items: Account, + description: "Accounts which match the given query" + }, + statuses: %Schema{ + type: :array, + items: Status, + description: "Statuses which match the given query" + }, + hashtags: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: "Hashtags which match the given query" + } + }, + example: %{ + "accounts" => [Account.schema().example], + "statuses" => [Status.schema().example], + "hashtags" => ["cofe"] + } + } + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index cd49da6ad..0e0d54ba4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [fetch_integer_param: 2, skip_relationships?: 1] + import Pleroma.Web.ControllerHelper, only: [skip_relationships?: 1] alias Pleroma.Activity alias Pleroma.Plugs.OAuthScopesPlug @@ -18,6 +18,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do require Logger + plug(Pleroma.Web.ApiSpec.CastAndValidate) + # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) @@ -25,7 +27,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) - def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.SearchOperation + + def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do accounts = User.search(query, search_options(params, user)) conn @@ -36,7 +40,7 @@ def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) d def search2(conn, params), do: do_search(:v2, conn, params) def search(conn, params), do: do_search(:v1, conn, params) - defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = params) do + defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do options = search_options(params, user) timeout = Keyword.get(Repo.config(), :timeout, 15_000) default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []} @@ -44,7 +48,7 @@ defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = para result = default_values |> Enum.map(fn {resource, default_value} -> - if params["type"] in [nil, resource] do + if params[:type] in [nil, resource] do {resource, fn -> resource_search(version, resource, query, options) end} else {resource, fn -> default_value end} @@ -68,11 +72,11 @@ defp do_search(version, %{assigns: %{user: user}} = conn, %{"q" => query} = para defp search_options(params, user) do [ skip_relationships: skip_relationships?(params), - resolve: params["resolve"] == "true", - following: params["following"] == "true", - limit: fetch_integer_param(params, "limit"), - offset: fetch_integer_param(params, "offset"), - type: params["type"], + resolve: params[:resolve], + following: params[:following], + limit: params[:limit], + offset: params[:offset], + type: params[:type], author: get_author(params), for_user: user ] @@ -135,7 +139,7 @@ defp with_fallback(f, fallback \\ []) do end end - defp get_author(%{"account_id" => account_id}) when is_binary(account_id), + defp get_author(%{account_id: account_id}) when is_binary(account_id), do: User.get_cached_by_id(account_id) defp get_author(_params), do: nil diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 11133ff66..02476acb6 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -27,8 +27,8 @@ test "it returns empty result if user or status search return undefined error", capture_log(fn -> results = conn - |> get("/api/v2/search", %{"q" => "2hu"}) - |> json_response(200) + |> get("/api/v2/search?q=2hu") + |> json_response_and_validate_schema(200) assert results["accounts"] == [] assert results["statuses"] == [] @@ -54,8 +54,8 @@ test "search", %{conn: conn} do results = conn - |> get("/api/v2/search", %{"q" => "2hu #private"}) - |> json_response(200) + |> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}") + |> json_response_and_validate_schema(200) [account | _] = results["accounts"] assert account["id"] == to_string(user_three.id) @@ -68,8 +68,8 @@ test "search", %{conn: conn} do assert status["id"] == to_string(activity.id) results = - get(conn, "/api/v2/search", %{"q" => "天子"}) - |> json_response(200) + get(conn, "/api/v2/search?q=天子") + |> json_response_and_validate_schema(200) [status] = results["statuses"] assert status["id"] == to_string(activity.id) @@ -89,8 +89,8 @@ test "excludes a blocked users from search results", %{conn: conn} do conn |> assign(:user, user) |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"])) - |> get("/api/v2/search", %{"q" => "Agent"}) - |> json_response(200) + |> get("/api/v2/search?q=Agent") + |> json_response_and_validate_schema(200) status_ids = Enum.map(results["statuses"], fn g -> g["id"] end) @@ -107,8 +107,8 @@ test "account search", %{conn: conn} do results = conn - |> get("/api/v1/accounts/search", %{"q" => "shp"}) - |> json_response(200) + |> get("/api/v1/accounts/search?q=shp") + |> json_response_and_validate_schema(200) result_ids = for result <- results, do: result["acct"] @@ -117,8 +117,8 @@ test "account search", %{conn: conn} do results = conn - |> get("/api/v1/accounts/search", %{"q" => "2hu"}) - |> json_response(200) + |> get("/api/v1/accounts/search?q=2hu") + |> json_response_and_validate_schema(200) result_ids = for result <- results, do: result["acct"] @@ -130,8 +130,8 @@ test "returns account if query contains a space", %{conn: conn} do results = conn - |> get("/api/v1/accounts/search", %{"q" => "shp@shitposter.club xxx "}) - |> json_response(200) + |> get("/api/v1/accounts/search?q=shp@shitposter.club xxx") + |> json_response_and_validate_schema(200) assert length(results) == 1 end @@ -146,8 +146,8 @@ test "it returns empty result if user or status search return undefined error", capture_log(fn -> results = conn - |> get("/api/v1/search", %{"q" => "2hu"}) - |> json_response(200) + |> get("/api/v1/search?q=2hu") + |> json_response_and_validate_schema(200) assert results["accounts"] == [] assert results["statuses"] == [] @@ -173,8 +173,8 @@ test "search", %{conn: conn} do results = conn - |> get("/api/v1/search", %{"q" => "2hu"}) - |> json_response(200) + |> get("/api/v1/search?q=2hu") + |> json_response_and_validate_schema(200) [account | _] = results["accounts"] assert account["id"] == to_string(user_three.id) @@ -194,8 +194,8 @@ test "search fetches remote statuses and prefers them over other results", %{con results = conn - |> get("/api/v1/search", %{"q" => "https://shitposter.club/notice/2827873"}) - |> json_response(200) + |> get("/api/v1/search?q=https://shitposter.club/notice/2827873") + |> json_response_and_validate_schema(200) [status, %{"id" => ^activity_id}] = results["statuses"] @@ -212,10 +212,12 @@ test "search doesn't show statuses that it shouldn't", %{conn: conn} do }) capture_log(fn -> + q = Object.normalize(activity).data["id"] + results = conn - |> get("/api/v1/search", %{"q" => Object.normalize(activity).data["id"]}) - |> json_response(200) + |> get("/api/v1/search?q=#{q}") + |> json_response_and_validate_schema(200) [] = results["statuses"] end) @@ -228,8 +230,8 @@ test "search fetches remote accounts", %{conn: conn} do conn |> assign(:user, user) |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"])) - |> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "true"}) - |> json_response(200) + |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=true") + |> json_response_and_validate_schema(200) [account] = results["accounts"] assert account["acct"] == "mike@osada.macgirvin.com" @@ -238,8 +240,8 @@ test "search fetches remote accounts", %{conn: conn} do test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do results = conn - |> get("/api/v1/search", %{"q" => "mike@osada.macgirvin.com", "resolve" => "false"}) - |> json_response(200) + |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=false") + |> json_response_and_validate_schema(200) assert [] == results["accounts"] end @@ -254,16 +256,16 @@ test "search with limit and offset", %{conn: conn} do result = conn - |> get("/api/v1/search", %{"q" => "2hu", "limit" => 1}) + |> get("/api/v1/search?q=2hu&limit=1") - assert results = json_response(result, 200) + assert results = json_response_and_validate_schema(result, 200) assert [%{"id" => activity_id1}] = results["statuses"] assert [_] = results["accounts"] results = conn - |> get("/api/v1/search", %{"q" => "2hu", "limit" => 1, "offset" => 1}) - |> json_response(200) + |> get("/api/v1/search?q=2hu&limit=1&offset=1") + |> json_response_and_validate_schema(200) assert [%{"id" => activity_id2}] = results["statuses"] assert [] = results["accounts"] @@ -279,13 +281,13 @@ test "search returns results only for the given type", %{conn: conn} do assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} = conn - |> get("/api/v1/search", %{"q" => "2hu", "type" => "statuses"}) - |> json_response(200) + |> get("/api/v1/search?q=2hu&type=statuses") + |> json_response_and_validate_schema(200) assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} = conn - |> get("/api/v1/search", %{"q" => "2hu", "type" => "accounts"}) - |> json_response(200) + |> get("/api/v1/search?q=2hu&type=accounts") + |> json_response_and_validate_schema(200) end test "search uses account_id to filter statuses by the author", %{conn: conn} do @@ -297,8 +299,8 @@ test "search uses account_id to filter statuses by the author", %{conn: conn} do results = conn - |> get("/api/v1/search", %{"q" => "2hu", "account_id" => user.id}) - |> json_response(200) + |> get("/api/v1/search?q=2hu&account_id=#{user.id}") + |> json_response_and_validate_schema(200) assert [%{"id" => activity_id1}] = results["statuses"] assert activity_id1 == activity1.id @@ -306,8 +308,8 @@ test "search uses account_id to filter statuses by the author", %{conn: conn} do results = conn - |> get("/api/v1/search", %{"q" => "2hu", "account_id" => user_two.id}) - |> json_response(200) + |> get("/api/v1/search?q=2hu&account_id=#{user_two.id}") + |> json_response_and_validate_schema(200) assert [%{"id" => activity_id2}] = results["statuses"] assert activity_id2 == activity2.id -- cgit v1.2.3 From f57fa2a00df2d93eba53c1ff3ab5c7d5fabb8308 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 7 May 2020 12:43:30 +0200 Subject: Notifications: Simplify recipient calculation for some Activities. Fixes the 'getting notfications for other people's posts' bug. --- lib/pleroma/notification.ex | 29 ++++++++++++++++++++++------- test/notification_test.exs | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 98289af08..b14e7c843 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -339,13 +339,7 @@ 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 - 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() + potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity) potential_receivers = User.get_users_from_set(potential_receiver_ap_ids, local_only) @@ -363,6 +357,27 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo def get_notified_from_activity(_, _local_only), do: {[], []} + # For some actitivies, only notifity the author of the object + def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}}) + when type in ~w{Like Announce EmojiReact} do + case Object.get_cached_by_ap_id(object_id) do + %Object{data: %{"actor" => actor}} -> + [actor] + + _ -> + [] + end + end + + def get_potential_receiver_ap_ids(activity) 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() + end + @doc "Filters out AP IDs domain-blocking and not following the activity's actor" def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) diff --git a/test/notification_test.exs b/test/notification_test.exs index 5c85f3368..509ca0b0b 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -12,6 +12,8 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Notification alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.NotificationView @@ -601,6 +603,28 @@ test "it does not send notification to mentioned users in likes" do assert other_user not in enabled_receivers end + test "it only notifies the post's author in likes" do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + + {:ok, activity_one} = + CommonAPI.post(user, %{ + "status" => "hey @#{other_user.nickname}!" + }) + + {:ok, like_data, _} = Builder.like(third_user, activity_one.object) + + {:ok, like, _} = + like_data + |> Map.put("to", [other_user.ap_id | like_data["to"]]) + |> ActivityPub.persist(local: true) + + {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(like) + + assert other_user not in enabled_receivers + end + test "it does not send notification to mentioned users in announces" do user = insert(:user) other_user = insert(:user) -- cgit v1.2.3 From 3f867d8e9bf970e180153b411d5924f15c490046 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 7 May 2020 10:48:09 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/search_operation.ex --- lib/pleroma/web/api_spec/operations/search_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index ec1ae5dcf..6ea00a9a8 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -44,7 +44,7 @@ def account_search_operation do :following, :query, %Schema{allOf: [BooleanLike], default: false}, - "Only who the user is following." + "Only include accounts that the user is following" ) ], responses: %{ -- cgit v1.2.3 From 8ae4d64d475405f8ff98868b80fc71fbe74b45bc Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 7 May 2020 11:01:52 +0000 Subject: Apply suggestion to lib/pleroma/notification.ex --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index b14e7c843..af49fd713 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -357,7 +357,7 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo def get_notified_from_activity(_, _local_only), do: {[], []} - # For some actitivies, only notifity the author of the object + # For some activities, only notify the author of the object def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}}) when type in ~w{Like Announce EmojiReact} do case Object.get_cached_by_ap_id(object_id) do -- cgit v1.2.3 From 9c3c142c32b027addd7b729229820f8b2bf76994 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 7 May 2020 14:35:29 +0300 Subject: Restore mix.lock after 2FA merge It downgraded a bunch of deps, including plug. Which resulted in errors since pleroma was using a feature plug didn't support at the time. --- mix.lock | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/mix.lock b/mix.lock index 4792249d7..c400202b7 100644 --- a/mix.lock +++ b/mix.lock @@ -2,7 +2,8 @@ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, - "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, + "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, @@ -18,33 +19,38 @@ "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, + "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"}, + "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, + "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"}, - "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"}, + "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"}, + "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, + "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, @@ -53,34 +59,38 @@ "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, + "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, + "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"}, - "phoenix": {:hex, :phoenix, "1.4.12", "b86fa85a2ba336f5de068549de5ccceec356fd413264a9637e7733395d6cc4ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58331ade6d77e1312a3d976f0fa41803b8f004b2b5f489193425bc46aea3ed30"}, + "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, "phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"}, - "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "164baaeb382d19beee0ec484492aa82a9c8685770aee33b24ec727a0971b34d0"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.1", "a196e4f428d7f5d6dba5ded314cc55cd0fbddf1110af620f75c0190e77844b33", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "15a3c34ffaccef8a0b575b8d39ab1b9044586d7dab917292cdc44cf2737df7f2"}, - "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, + "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, + "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"}, - "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, + "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, -- cgit v1.2.3 From 788b7e7bbd2732e2af72adad1a660cf363486c6b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 7 May 2020 14:52:37 +0200 Subject: Merge fixes. --- lib/pleroma/user.ex | 13 +-- lib/pleroma/web/activity_pub/object_validator.ex | 6 +- .../object_validators/common_validations.ex | 2 +- test/web/activity_pub/object_validator_test.exs | 1 - test/web/activity_pub/transmogrifier_test.exs | 107 --------------------- 5 files changed, 10 insertions(+), 119 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 921bdd93a..2a6a23fec 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1557,16 +1557,13 @@ def delete_user_activities(%User{ap_id: ap_id} = user) do defp delete_activity(%{data: %{"type" => "Create", "object" => object}}, user) do {:ok, delete_data, _} = Builder.delete(user, object) - Pipeline.common_pipeline(delete_data, local: true) + Pipeline.common_pipeline(delete_data, local: user.local) end - defp delete_activity(%{data: %{"type" => type}} = activity) when type in ["Like", "Announce"] do - actor = - activity.actor - |> get_cached_by_ap_id() - - {:ok, undo, _} = Builder.undo(actor, activity) - Pipeline.common_pipeline(undo, local: true) + defp delete_activity(%{data: %{"type" => type}} = activity, user) + when type in ["Like", "Announce"] do + {:ok, undo, _} = Builder.undo(user, activity) + Pipeline.common_pipeline(undo, local: user.local) end defp delete_activity(_activity, _user), do: "Doing nothing" diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 8e043287d..1f0431b36 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -21,8 +21,10 @@ def validate(object, meta) def validate(%{"type" => "Undo"} = object, meta) do with {:ok, object} <- - object |> UndoValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object |> Map.from_struct()) + object + |> UndoValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) {:ok, object, meta} end end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 2ada9f09e..aeef31945 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -48,7 +48,7 @@ def validate_object_presence(cng, options \\ []) do cng |> validate_change(field_name, fn field_name, object_id -> - object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object) + object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object_id) cond do !object -> diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 4d90a0cf3..174be5ec6 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -107,7 +107,6 @@ test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post {:error, cng} = ObjectValidator.validate(missing_object, []) assert {:object, {"can't find object", []}} in cng.errors - assert length(cng.errors) == 1 end test "it's invalid if the actor of the object and the actor of delete are from different domains", diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index ae5d3bf92..4fd6c8b00 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -685,113 +685,6 @@ test "it works for incoming update activities which lock the account" do assert user.locked == true end - test "it works for incoming deletes" do - activity = insert(:note_activity) - deleting_user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("id", activity.data["object"]) - - data = - data - |> Map.put("object", object) - |> Map.put("actor", deleting_user.ap_id) - - {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} = - Transmogrifier.handle_incoming(data) - - assert id == data["id"] - refute Activity.get_by_id(activity.id) - assert actor == deleting_user.ap_id - end - - test "it fails for incoming deletes with spoofed origin" do - activity = insert(:note_activity) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Poison.decode!() - - object = - data["object"] - |> Map.put("id", activity.data["object"]) - - data = - data - |> Map.put("object", object) - - assert capture_log(fn -> - :error = Transmogrifier.handle_incoming(data) - end) =~ - "[error] Could not decode user at fetch http://mastodon.example.org/users/gargron, {:error, :nxdomain}" - - assert Activity.get_by_id(activity.id) - end - - @tag capture_log: true - test "it works for incoming user deletes" do - %{ap_id: ap_id} = - insert(:user, ap_id: "http://mastodon.example.org/users/admin", local: false) - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Poison.decode!() - - {:ok, _} = Transmogrifier.handle_incoming(data) - ObanHelpers.perform_all() - - refute User.get_cached_by_ap_id(ap_id) - end - - test "it fails for incoming user deletes with spoofed origin" do - %{ap_id: ap_id} = insert(:user) - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Poison.decode!() - |> Map.put("actor", ap_id) - - assert capture_log(fn -> - assert :error == Transmogrifier.handle_incoming(data) - end) =~ "Object containment failed" - - assert User.get_cached_by_ap_id(ap_id) - end - - test "it works for incoming unannounces with an existing notice" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) - - announce_data = - File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: announce_data, local: false}} = - Transmogrifier.handle_incoming(announce_data) - - data = - File.read!("test/fixtures/mastodon-undo-announce.json") - |> Poison.decode!() - |> Map.put("object", announce_data) - |> Map.put("actor", announce_data["actor"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Undo" - assert object_data = data["object"] - assert object_data["type"] == "Announce" - assert object_data["object"] == activity.data["object"] - - assert object_data["id"] == - "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" - end - test "it works for incomming unfollows with an existing follow" do user = insert(:user) -- cgit v1.2.3 From d11eea62b139ce16d7dffbd574947b2550df238f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 7 May 2020 15:09:37 +0200 Subject: Credo fixes --- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 1f0431b36..4782cd8f3 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -13,8 +13,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) -- cgit v1.2.3 From eb1f2fcbc62735a6e1a24c7c5591061d9391e808 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 7 May 2020 16:13:24 +0300 Subject: Streamer: Fix wrong argument order when rendering activities to authenticated user Closes #1747 --- lib/pleroma/web/mastodon_api/websocket_handler.ex | 2 +- lib/pleroma/web/views/streamer_view.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 6ef3fe2dd..e2ffd02d0 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -78,7 +78,7 @@ def websocket_info({:render_with_user, view, template, item}, state) do user = %User{} = User.get_cached_by_ap_id(state.user.ap_id) unless Streamer.filtered_by_user?(user, item) do - websocket_info({:text, view.render(template, user, item)}, %{state | user: user}) + websocket_info({:text, view.render(template, item, user)}, %{state | user: user}) else {:ok, state} end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 443868878..237b29ded 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -25,7 +25,7 @@ def render("update.json", %Activity{} = activity, %User{} = user) do |> Jason.encode!() end - def render("notification.json", %User{} = user, %Notification{} = notify) do + def render("notification.json", %Notification{} = notify, %User{} = user) do %{ event: "notification", payload: -- cgit v1.2.3 From ea01e647df4466975b9382f123f0a2aa35ebfe76 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 7 May 2020 09:13:43 -0500 Subject: Test Direct, Public, and Favorite notifications with privacy option --- test/web/push/impl_test.exs | 60 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 3de911810..b855d72ba 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -193,7 +193,7 @@ test "renders title for create activity with direct visibility" do end describe "build_content/3" do - test "returns info content for direct message with enabled privacy option" do + test "hides details for notifications when privacy option enabled" do user = insert(:user, nickname: "Bob") user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) @@ -211,9 +211,35 @@ test "returns info content for direct message with enabled privacy option" do assert Impl.build_content(notif, actor, object) == %{ body: "New Direct Message" } + + {:ok, activity} = + CommonAPI.post(user, %{ + "visibility" => "public", + "status" => " "public", + "status" => + "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." + }) + + notif = insert(:notification, user: user2, activity: activity) + + actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) + object = Object.normalize(activity) + + assert Impl.build_content(notif, actor, object) == %{ + body: + "@Bob: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini...", + title: "New Mention" + } + + {:ok, activity} = CommonAPI.favorite(user, activity.id) + + notif = insert(:notification, user: user2, activity: activity) + + actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) + object = Object.normalize(activity) + + assert Impl.build_content(notif, actor, object) == %{ + body: "@Bob has favorited your post", + title: "New Favorite" + } end end end -- cgit v1.2.3 From a081135365c2b9d7bc81ee84baffbc3c2be68e8c Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 8 May 2020 12:06:24 +0300 Subject: revert mix.lock --- mix.lock | 54 ++++++++++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/mix.lock b/mix.lock index 11234ae14..c400202b7 100644 --- a/mix.lock +++ b/mix.lock @@ -2,8 +2,8 @@ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, - "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, - "bbcode": {:hex, :bbcode, "0.1.1", "0023e2c7814119b2e620b7add67182e3f6019f92bfec9a22da7e99821aceba70", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5a981b98ac7d366a9b6bf40eac389aaf4d6e623c631e6b6f8a6b571efaafd338"}, + "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, + "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, @@ -19,47 +19,47 @@ "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, - "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "48e513299cd28b12c77266c0ed5b1c844368e5c1823724994ae84834f43d6bbe"}, + "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, - "ecto": {:hex, :ecto, "3.4.2", "6890af71025769bd27ef62b1ed1925cfe23f7f0460bcb3041da4b705215ff23e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3959b8a83e086202a4bd86b4b5e6e71f9f1840813de14a57d502d3fc2ef7132"}, + "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, - "esshd": {:hex, :esshd, "0.1.0", "6f93a2062adb43637edad0ea7357db2702a4b80dd9683482fe00f5134e97f4c1", [:mix], [], "hexpm", "98d0f3c6f4b8a0333170df770c6fe772b3d04564fb514c1a09504cf5ab2f48a5"}, + "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, - "ex_syslogger": {:hex, :ex_syslogger, "1.5.0", "bc936ee3fd13d9e592cb4c3a1e8a55fccd33b05e3aa7b185f211f3ed263ff8f0", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.0.5", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "f3b4b184dcdd5f356b7c26c6cd72ab0918ba9dfb4061ccfaf519e562942af87b"}, + "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"}, - "fast_html": {:hex, :fast_html, "1.0.1", "5bc7df4dc4607ec2c314c16414e4111d79a209956c4f5df96602d194c61197f9", [:make, :mix], [], "hexpm", "18e627dd62051a375ef94b197f41e8027c3e8eef0180ab8f81e0543b3dc6900a"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.1.6", "60a5ae96879956dea409a91a77f5dd2994c24cc10f80eefd8f9892ee4c0c7b25", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b73f50f0cb522dd0331ea8e8c90b408de42c50f37641219d6364f0e3e7efd22c"}, + "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"}, + "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, - "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm", "8453e2289d94c3199396eb517d65d6715ef26bcae0ee83eb5ff7a84445458d76"}, - "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm", "5cacd405e72b2609a7e1f891bddb80c53d0b3b7b0036d1648e7382ca108c41c8"}, - "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm", "f7d97341e536f95b96eef2988d6d4230f7262cf239cda0e2e63123ee0b717222"}, + "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, - "httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "89149056039084024a284cd703b2d1900d584958dba432132cb21ef35aed7487"}, + "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.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, - "joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "eb02df7d5526df13063397e051b926b7006d5986d66f399eefc474f560cdad6a"}, - "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm", "6429c4fee52b2dda7861ee19a4f09c8c1ffa213bee3a1ec187828fde95d447ed"}, + "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, + "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, - "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm", "1feaf05ee886815ad047cad7ede17d6910710986148ae09cf73eee2989717b81"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, @@ -71,41 +71,39 @@ "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.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"}, - "phoenix": {:hex, :phoenix, "1.4.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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", "256ad7a140efadc3f0290470369da5bd3de985ec7c706eba07c2641b228974be"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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", "fe15d9fee5b82f5e64800502011ffe530650d42e1710ae9b14bc4c9be38bf303"}, - "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8b01b3d6d39731ab18aa548d928b5796166d2500755f553725cfe967bafba7d9"}, + "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.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6cd8ddd1bd1fbfa54d3fc61d4719c2057dae67615395d58d40437a919a46f132"}, - "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm", "73c1682f0e414cfb5d9b95c8e8cd6ffcfdae699e3b05e1db744e58b7be857759"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, + "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"}, - "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm", "d39f2ce1f3f29f3bf04f915aa3cf9c7cd4d2cee2f975e05f526e06cae9b7c902"}, + "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, "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"}, - "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm", "6de553ba9ac0668d3728b699d5065543f3e40c854154017461ee8c09038752da"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, "recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, - "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"}, - "syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"}, + "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]}, "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"}, -- cgit v1.2.3 From 6acbe45eb211286e747143f6bd6edaa5c2126657 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 11:30:31 +0200 Subject: Builder: Extract common features of likes and reactions. --- lib/pleroma/web/activity_pub/builder.ex | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f6544d3f5..922a444a9 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} def emoji_react(actor, object, emoji) do - with {:ok, data, meta} <- like(actor, object) do + with {:ok, data, meta} <- object_action(actor, object) do data = data |> Map.put("content", emoji) @@ -64,6 +64,17 @@ def delete(actor, object_id) do @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do + with {:ok, data, meta} <- object_action(actor, object) do + data = + data + |> Map.put("type", "Like") + + {:ok, data, meta} + end + end + + @spec object_action(User.t(), Object.t()) :: {:ok, map(), keyword()} + defp object_action(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. @@ -85,7 +96,6 @@ def like(actor, object) do %{ "id" => Utils.generate_activity_id(), "actor" => actor.ap_id, - "type" => "Like", "object" => object.data["id"], "to" => to, "cc" => cc, -- cgit v1.2.3 From 4d71c4b8051d5cf54f37903091aed7f4d5c1ddd9 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 8 May 2020 12:33:01 +0300 Subject: fixed 'source' object in verify_credentials --- lib/pleroma/web/mastodon_api/views/account_view.ex | 5 ++++- test/web/mastodon_api/views/account_view_test.exs | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 420bd586f..b7cdb52b1 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -261,7 +261,10 @@ defp do_render("show.json", %{user: user} = opts) do defp prepare_user_bio(%User{bio: ""}), do: "" defp prepare_user_bio(%User{bio: bio}) when is_binary(bio) do - bio |> String.replace(~r(
    ), "\n") |> Pleroma.HTML.strip_tags() + bio + |> String.replace(~r(
    ), "\n") + |> Pleroma.HTML.strip_tags() + |> HtmlEntities.decode() end defp prepare_user_bio(_), do: "" diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 5fb162141..375f0103a 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -31,7 +31,7 @@ test "Represent a user account" do nickname: "shp@shitposter.club", name: ":karjalanpiirakka: shp", bio: - "valid html. a
    b
    c
    d
    f", + "valid html. a
    b
    c
    d
    f '&<>\"", inserted_at: ~N[2017-08-15 15:47:06.597036], emoji: %{"karjalanpiirakka" => "/file.png"} }) @@ -46,7 +46,7 @@ test "Represent a user account" do followers_count: 3, following_count: 0, statuses_count: 5, - note: "valid html. a
    b
    c
    d
    f", + 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 +63,7 @@ test "Represent a user account" do fields: [], bot: false, source: %{ - note: "valid html. a\nb\nc\nd\nf", + note: "valid html. a\nb\nc\nd\nf '&<>\"", sensitive: false, pleroma: %{ actor_type: "Person", -- cgit v1.2.3 From f1274c3326207ecba5086ee28f721b43a29eb14c Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 11:41:13 +0200 Subject: Transmogrifier tests: Remove double tests. --- test/web/activity_pub/transmogrifier_test.exs | 81 --------------------------- 1 file changed, 81 deletions(-) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 14c0f57ae..d783f57d2 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -325,87 +325,6 @@ test "it cleans up incoming notices which are not really DMs" do assert object_data["cc"] == to end - test "it works for incoming emoji reaction undos" do - user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, user, "👌") - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", reaction_activity.data["id"]) - |> Map.put("actor", user.ap_id) - - {:ok, activity} = Transmogrifier.handle_incoming(data) - - assert activity.actor == user.ap_id - assert activity.data["id"] == data["id"] - assert activity.data["type"] == "Undo" - end - - test "it returns an error for incoming unlikes wihout a like activity" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - assert Transmogrifier.handle_incoming(data) == :error - end - - test "it works for incoming unlikes with an existing like activity" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) - - like_data = - File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", like_data) - |> Map.put("actor", like_data["actor"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Undo" - assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" - assert data["object"] == "http://mastodon.example.org/users/admin#likes/2" - end - - test "it works for incoming unlikes with an existing like activity and a compact object" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) - - like_data = - File.read!("test/fixtures/mastodon-like.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: like_data, local: false}} = Transmogrifier.handle_incoming(like_data) - - data = - File.read!("test/fixtures/mastodon-undo-like.json") - |> Poison.decode!() - |> Map.put("object", like_data["id"]) - |> Map.put("actor", like_data["actor"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Undo" - assert data["id"] == "http://mastodon.example.org/users/admin#likes/2/undo" - assert data["object"] == "http://mastodon.example.org/users/admin#likes/2" - end - test "it works for incoming emoji reactions" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) -- cgit v1.2.3 From 7e9aaa0d0221311d831161d977c8b0e2a55b3439 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 11:43:07 +0200 Subject: Transmogrifier tests: Remove more double tests. --- test/web/activity_pub/transmogrifier_test.exs | 37 --------------------------- 1 file changed, 37 deletions(-) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index d783f57d2..2914c90ea 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -325,43 +325,6 @@ test "it cleans up incoming notices which are not really DMs" do assert object_data["cc"] == to end - test "it works for incoming emoji reactions" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/emoji-reaction.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "EmojiReact" - assert data["id"] == "http://mastodon.example.org/users/admin#reactions/2" - assert data["object"] == activity.data["object"] - assert data["content"] == "👌" - end - - test "it reject invalid emoji reactions" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) - - data = - File.read!("test/fixtures/emoji-reaction-too-long.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - assert {:error, _} = Transmogrifier.handle_incoming(data) - - data = - File.read!("test/fixtures/emoji-reaction-no-emoji.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - assert {:error, _} = Transmogrifier.handle_incoming(data) - end - test "it works for incoming announces" do data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() -- cgit v1.2.3 From d0bf8cfb8f852a16259af4b808565cdfd58f5e61 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 14:11:58 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 28b519432..c8b675d54 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked object, a `Follow` activity will add the user to the follower collection, and so on. """ - alias Pleroma.Chat alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo -- cgit v1.2.3 From 287f781808c88f43f5689508b5aa21f6639b9d16 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 8 May 2020 16:54:53 +0300 Subject: user deletion --- lib/pleroma/user.ex | 28 ++++++++++++++++++++-------- test/user_test.exs | 27 +++++++++++++++++++++++++++ test/web/activity_pub/side_effects_test.exs | 25 +++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 2a6a23fec..278129ad2 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1431,6 +1431,25 @@ def delete(%User{} = user) do BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) end + defp delete_and_invalidate_cache(%User{} = user) do + invalidate_cache(user) + Repo.delete(user) + end + + defp delete_or_deactivate(%User{local: false} = user), do: delete_and_invalidate_cache(user) + + defp delete_or_deactivate(%User{local: true} = user) do + status = account_status(user) + + if status == :confirmation_pending do + delete_and_invalidate_cache(user) + else + user + |> change(%{deactivated: true, email: nil}) + |> update_and_set_cache() + end + end + def perform(:force_password_reset, user), do: force_password_reset(user) @spec perform(atom(), User.t()) :: {:ok, User.t()} @@ -1452,14 +1471,7 @@ def perform(:delete, %User{} = user) do delete_user_activities(user) - if user.local do - user - |> change(%{deactivated: true, email: nil}) - |> update_and_set_cache() - else - invalidate_cache(user) - Repo.delete(user) - end + delete_or_deactivate(user) end def perform(:deactivate_async, user, status), do: deactivate(user, status) diff --git a/test/user_test.exs b/test/user_test.exs index a3c75aa9b..96116fca6 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1171,6 +1171,33 @@ test "it deactivates a user, all follow relationships and all activities", %{use end end + describe "delete/1 when confirmation is pending" do + setup do + user = insert(:user, confirmation_pending: true) + {:ok, user: user} + end + + test "deletes user from database when activation required", %{user: user} do + clear_config([:instance, :account_activation_required], true) + + {:ok, job} = User.delete(user) + {:ok, _} = ObanHelpers.perform(job) + + refute User.get_cached_by_id(user.id) + refute User.get_by_id(user.id) + end + + test "deactivates user when activation is not required", %{user: user} do + clear_config([:instance, :account_activation_required], false) + + {:ok, job} = User.delete(user) + {:ok, _} = ObanHelpers.perform(job) + + assert %{deactivated: true} = User.get_cached_by_id(user.id) + assert %{deactivated: true} = User.get_by_id(user.id) + end + end + test "get_public_key_for_ap_id fetches a user that's not in the db" do assert {:ok, _key} = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b29a7a7be..5c06dc864 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -99,6 +99,31 @@ test "creates a notification", %{emoji_react: emoji_react, poster: poster} do end end + describe "delete users with confirmation pending" do + setup do + user = insert(:user, confirmation_pending: true) + {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id) + {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true) + {:ok, delete: delete_user, user: user} + end + + test "when activation is not required", %{delete: delete, user: user} do + clear_config([:instance, :account_activation_required], false) + {:ok, _, _} = SideEffects.handle(delete) + ObanHelpers.perform_all() + + assert User.get_cached_by_id(user.id).deactivated + end + + test "when activation is required", %{delete: delete, user: user} do + clear_config([:instance, :account_activation_required], true) + {:ok, _, _} = SideEffects.handle(delete) + ObanHelpers.perform_all() + + refute User.get_cached_by_id(user.id) + end + end + describe "Undo objects" do setup do poster = insert(:user) -- cgit v1.2.3 From 03529f6a0528ed01c7a956bb80628910584a9580 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 8 May 2020 18:26:35 +0200 Subject: Transmogrifier: Don't modify attachments for chats. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 +++ test/web/common_api/common_api_test.exs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 29f668cad..f04dec6be 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1114,6 +1114,9 @@ def add_attributed_to(object) do Map.put(object, "attributedTo", attributed_to) end + # TODO: Revisit this + def prepare_attachments(%{"type" => "ChatMessage"} = object), do: object + def prepare_attachments(object) do attachments = object diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 61affda5d..5501ba18b 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -54,6 +54,8 @@ test "it posts a chat message" do assert Chat.get(author.id, recipient.ap_id) assert Chat.get(recipient.id, author.ap_id) + + assert :ok == Pleroma.Web.Federator.perform(:publish, activity) end test "it reject messages over the local limit" do -- cgit v1.2.3 From 0e1bda55e856cd97b82ed393b719653872c93e34 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 8 May 2020 21:33:56 +0300 Subject: PleromaFE bundle dropping requirement for embedded account relationships in statuses / notifications. https://git.pleroma.social/pleroma/pleroma-fe/-/commit/7a0e554daf843fe9e98053e79ec0114c380ededb --- priv/static/index.html | 2 +- priv/static/static/font/fontello.1588419330867.eot | Bin 22752 -> 0 bytes priv/static/static/font/fontello.1588419330867.svg | 122 -------------------- priv/static/static/font/fontello.1588419330867.ttf | Bin 22584 -> 0 bytes .../static/static/font/fontello.1588419330867.woff | Bin 13836 -> 0 bytes .../static/font/fontello.1588419330867.woff2 | Bin 11712 -> 0 bytes priv/static/static/font/fontello.1588947937982.eot | Bin 0 -> 22976 bytes priv/static/static/font/fontello.1588947937982.svg | 124 +++++++++++++++++++++ priv/static/static/font/fontello.1588947937982.ttf | Bin 0 -> 22808 bytes .../static/static/font/fontello.1588947937982.woff | Bin 0 -> 13988 bytes .../static/font/fontello.1588947937982.woff2 | Bin 0 -> 11816 bytes priv/static/static/fontello.1588419330867.css | Bin 3378 -> 0 bytes priv/static/static/fontello.1588947937982.css | Bin 0 -> 3421 bytes priv/static/static/fontello.json | 6 + priv/static/static/js/2.18e4adec273c4ce867a8.js | Bin 0 -> 2190 bytes .../static/static/js/2.18e4adec273c4ce867a8.js.map | Bin 0 -> 7763 bytes priv/static/static/js/2.1c407059cd79fca99e19.js | Bin 2190 -> 0 bytes .../static/static/js/2.1c407059cd79fca99e19.js.map | Bin 7763 -> 0 bytes priv/static/static/js/app.996428ccaaaa7f28cb8d.js | Bin 0 -> 1079195 bytes .../static/js/app.996428ccaaaa7f28cb8d.js.map | Bin 0 -> 1643581 bytes priv/static/static/js/app.fa89b90e606f4facd209.js | Bin 1075836 -> 0 bytes .../static/js/app.fa89b90e606f4facd209.js.map | Bin 1635217 -> 0 bytes .../static/js/vendors~app.561a1c605d1dfb0e6f74.js | Bin 0 -> 411235 bytes .../js/vendors~app.561a1c605d1dfb0e6f74.js.map | Bin 0 -> 1737881 bytes .../static/js/vendors~app.8aa781e6dd81307f544b.js | Bin 411233 -> 0 bytes .../js/vendors~app.8aa781e6dd81307f544b.js.map | Bin 1737947 -> 0 bytes priv/static/sw-pleroma.js | Bin 31752 -> 31752 bytes 27 files changed, 131 insertions(+), 123 deletions(-) delete mode 100644 priv/static/static/font/fontello.1588419330867.eot delete mode 100644 priv/static/static/font/fontello.1588419330867.svg delete mode 100644 priv/static/static/font/fontello.1588419330867.ttf delete mode 100644 priv/static/static/font/fontello.1588419330867.woff delete mode 100644 priv/static/static/font/fontello.1588419330867.woff2 create mode 100644 priv/static/static/font/fontello.1588947937982.eot create mode 100644 priv/static/static/font/fontello.1588947937982.svg create mode 100644 priv/static/static/font/fontello.1588947937982.ttf create mode 100644 priv/static/static/font/fontello.1588947937982.woff create mode 100644 priv/static/static/font/fontello.1588947937982.woff2 delete mode 100644 priv/static/static/fontello.1588419330867.css create mode 100644 priv/static/static/fontello.1588947937982.css create mode 100644 priv/static/static/js/2.18e4adec273c4ce867a8.js create mode 100644 priv/static/static/js/2.18e4adec273c4ce867a8.js.map delete mode 100644 priv/static/static/js/2.1c407059cd79fca99e19.js delete mode 100644 priv/static/static/js/2.1c407059cd79fca99e19.js.map create mode 100644 priv/static/static/js/app.996428ccaaaa7f28cb8d.js create mode 100644 priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map delete mode 100644 priv/static/static/js/app.fa89b90e606f4facd209.js delete mode 100644 priv/static/static/js/app.fa89b90e606f4facd209.js.map create mode 100644 priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js create mode 100644 priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map delete mode 100644 priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js delete mode 100644 priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map diff --git a/priv/static/index.html b/priv/static/index.html index 4fac5c100..b37cbaa67 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588419330867.eot b/priv/static/static/font/fontello.1588419330867.eot deleted file mode 100644 index 7f8c61e38..000000000 Binary files a/priv/static/static/font/fontello.1588419330867.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1588419330867.svg b/priv/static/static/font/fontello.1588419330867.svg deleted file mode 100644 index 71f81f435..000000000 --- a/priv/static/static/font/fontello.1588419330867.svg +++ /dev/null @@ -1,122 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588419330867.ttf b/priv/static/static/font/fontello.1588419330867.ttf deleted file mode 100644 index 7dc4f108b..000000000 Binary files a/priv/static/static/font/fontello.1588419330867.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1588419330867.woff b/priv/static/static/font/fontello.1588419330867.woff deleted file mode 100644 index 2bf4cbc16..000000000 Binary files a/priv/static/static/font/fontello.1588419330867.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1588419330867.woff2 b/priv/static/static/font/fontello.1588419330867.woff2 deleted file mode 100644 index a31bf3f29..000000000 Binary files a/priv/static/static/font/fontello.1588419330867.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1588947937982.eot b/priv/static/static/font/fontello.1588947937982.eot new file mode 100644 index 000000000..b1297072e Binary files /dev/null and b/priv/static/static/font/fontello.1588947937982.eot differ diff --git a/priv/static/static/font/fontello.1588947937982.svg b/priv/static/static/font/fontello.1588947937982.svg new file mode 100644 index 000000000..e63fb7529 --- /dev/null +++ b/priv/static/static/font/fontello.1588947937982.svg @@ -0,0 +1,124 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588947937982.ttf b/priv/static/static/font/fontello.1588947937982.ttf new file mode 100644 index 000000000..443801c4f Binary files /dev/null and b/priv/static/static/font/fontello.1588947937982.ttf differ diff --git a/priv/static/static/font/fontello.1588947937982.woff b/priv/static/static/font/fontello.1588947937982.woff new file mode 100644 index 000000000..e96fea757 Binary files /dev/null and b/priv/static/static/font/fontello.1588947937982.woff differ diff --git a/priv/static/static/font/fontello.1588947937982.woff2 b/priv/static/static/font/fontello.1588947937982.woff2 new file mode 100644 index 000000000..50318a670 Binary files /dev/null and b/priv/static/static/font/fontello.1588947937982.woff2 differ diff --git a/priv/static/static/fontello.1588419330867.css b/priv/static/static/fontello.1588419330867.css deleted file mode 100644 index 198eff184..000000000 Binary files a/priv/static/static/fontello.1588419330867.css and /dev/null differ diff --git a/priv/static/static/fontello.1588947937982.css b/priv/static/static/fontello.1588947937982.css new file mode 100644 index 000000000..d3d77a8b5 Binary files /dev/null and b/priv/static/static/fontello.1588947937982.css differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json index 5963b68b4..7f0e7cdd5 100755 --- a/priv/static/static/fontello.json +++ b/priv/static/static/fontello.json @@ -346,6 +346,12 @@ "code": 59427, "src": "fontawesome" }, + { + "uid": "4aad6bb50b02c18508aae9cbe14e784e", + "css": "share", + "code": 61920, + "src": "fontawesome" + }, { "uid": "8b80d36d4ef43889db10bc1f0dc9a862", "css": "user", diff --git a/priv/static/static/js/2.18e4adec273c4ce867a8.js b/priv/static/static/js/2.18e4adec273c4ce867a8.js new file mode 100644 index 000000000..d191aa852 Binary files /dev/null and b/priv/static/static/js/2.18e4adec273c4ce867a8.js differ diff --git a/priv/static/static/js/2.18e4adec273c4ce867a8.js.map b/priv/static/static/js/2.18e4adec273c4ce867a8.js.map new file mode 100644 index 000000000..a7f98bfef Binary files /dev/null and b/priv/static/static/js/2.18e4adec273c4ce867a8.js.map differ diff --git a/priv/static/static/js/2.1c407059cd79fca99e19.js b/priv/static/static/js/2.1c407059cd79fca99e19.js deleted file mode 100644 index 14018d92a..000000000 Binary files a/priv/static/static/js/2.1c407059cd79fca99e19.js and /dev/null differ diff --git a/priv/static/static/js/2.1c407059cd79fca99e19.js.map b/priv/static/static/js/2.1c407059cd79fca99e19.js.map deleted file mode 100644 index cfee79ea8..000000000 Binary files a/priv/static/static/js/2.1c407059cd79fca99e19.js.map and /dev/null differ diff --git a/priv/static/static/js/app.996428ccaaaa7f28cb8d.js b/priv/static/static/js/app.996428ccaaaa7f28cb8d.js new file mode 100644 index 000000000..00f3a28e0 Binary files /dev/null and b/priv/static/static/js/app.996428ccaaaa7f28cb8d.js differ diff --git a/priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map b/priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map new file mode 100644 index 000000000..9daca3ff5 Binary files /dev/null and b/priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map differ diff --git a/priv/static/static/js/app.fa89b90e606f4facd209.js b/priv/static/static/js/app.fa89b90e606f4facd209.js deleted file mode 100644 index a2cbcc337..000000000 Binary files a/priv/static/static/js/app.fa89b90e606f4facd209.js and /dev/null differ diff --git a/priv/static/static/js/app.fa89b90e606f4facd209.js.map b/priv/static/static/js/app.fa89b90e606f4facd209.js.map deleted file mode 100644 index 5722844a9..000000000 Binary files a/priv/static/static/js/app.fa89b90e606f4facd209.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js b/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js new file mode 100644 index 000000000..d1f1a1830 Binary files /dev/null and b/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js differ diff --git a/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map b/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map new file mode 100644 index 000000000..0d4a859ea Binary files /dev/null and b/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map differ diff --git a/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js b/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js deleted file mode 100644 index 1d62bb0a4..000000000 Binary files a/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map b/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map deleted file mode 100644 index ce0c86939..000000000 Binary files a/priv/static/static/js/vendors~app.8aa781e6dd81307f544b.js.map and /dev/null differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 88244a549..d2be1782b 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ -- cgit v1.2.3 From 0c2b09a9ba771b3b04a0a08ed940823bd8601a9f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 8 May 2020 22:08:11 +0300 Subject: Add migration for counter_cache table update --- .../20200508092434_update_counter_cache_table.exs | 144 +++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 priv/repo/migrations/20200508092434_update_counter_cache_table.exs diff --git a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs new file mode 100644 index 000000000..81a8d6397 --- /dev/null +++ b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs @@ -0,0 +1,144 @@ +defmodule Pleroma.Repo.Migrations.UpdateCounterCacheTable do + use Ecto.Migration + + @function_name "update_status_visibility_counter_cache" + @trigger_name "status_visibility_counter_cache_trigger" + + def up do + execute("drop trigger if exists #{@trigger_name} on activities") + execute("drop function if exists #{@function_name}()") + drop_if_exists(unique_index(:counter_cache, [:name])) + drop_if_exists(table(:counter_cache)) + + create_if_not_exists table(:counter_cache) do + add(:instance, :string, null: false) + add(:direct, :bigint, null: false, default: 0) + add(:private, :bigint, null: false, default: 0) + add(:unlisted, :bigint, null: false, default: 0) + add(:public, :bigint, null: false, default: 0) + end + + create_if_not_exists(unique_index(:counter_cache, [:instance])) + + """ + CREATE OR REPLACE FUNCTION #{@function_name}() + RETURNS TRIGGER AS + $$ + DECLARE + token_id smallint; + hostname character varying(255); + visibility_new character varying(64); + visibility_old character varying(64); + actor character varying(255); + BEGIN + SELECT "tokid" INTO "token_id" FROM ts_token_type('default') WHERE "alias" = 'host'; + IF TG_OP = 'DELETE' THEN + actor := OLD.actor; + ELSE + actor := NEW.actor; + END IF; + SELECT "token" INTO "hostname" FROM ts_parse('default', actor) WHERE "tokid" = token_id; + IF hostname IS NULL THEN + hostname := split_part(actor, '/', 3); + END IF; + IF TG_OP = 'INSERT' THEN + visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); + IF NEW.data->>'type' = 'Create' THEN + EXECUTE format('INSERT INTO "counter_cache" ("instance", %1$I) VALUES ($1, 1) + ON CONFLICT ("instance") DO + UPDATE SET %1$I = "counter_cache".%1$I + 1', visibility_new) + USING hostname; + END IF; + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); + visibility_old := activity_visibility(OLD.actor, OLD.recipients, OLD.data); + IF (NEW.data->>'type' = 'Create') and (OLD.data->>'type' = 'Create') and visibility_new != visibility_old THEN + EXECUTE format('UPDATE "counter_cache" SET + %1$I = greatest("counter_cache".%1$I - 1, 0), + %2$I = "counter_cache".%2$I + 1 + WHERE "instance" = $1', visibility_old, visibility_new) + USING hostname; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.data->>'type' = 'Create' THEN + visibility_old := activity_visibility(OLD.actor, OLD.recipients, OLD.data); + EXECUTE format('UPDATE "counter_cache" SET + %1$I = greatest("counter_cache".%1$I - 1, 0) + WHERE "instance" = $1', visibility_old) + USING hostname; + END IF; + RETURN OLD; + END IF; + END; + $$ + LANGUAGE 'plpgsql'; + """ + |> execute() + + execute("DROP TRIGGER IF EXISTS #{@trigger_name} ON activities") + + """ + CREATE TRIGGER #{@trigger_name} + BEFORE + INSERT + OR UPDATE of recipients, data + OR DELETE + ON activities + FOR EACH ROW + EXECUTE PROCEDURE #{@function_name}(); + """ + |> execute() + end + + def down do + execute("DROP TRIGGER IF EXISTS #{@trigger_name} ON activities") + execute("DROP FUNCTION IF EXISTS #{@function_name}()") + drop_if_exists(unique_index(:counter_cache, [:instance])) + drop_if_exists(table(:counter_cache)) + + create_if_not_exists table(:counter_cache) do + add(:name, :string, null: false) + add(:count, :bigint, null: false, default: 0) + end + + create_if_not_exists(unique_index(:counter_cache, [:name])) + + """ + CREATE OR REPLACE FUNCTION #{@function_name}() + RETURNS TRIGGER AS + $$ + DECLARE + BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.data->>'type' = 'Create' THEN + EXECUTE 'INSERT INTO counter_cache (name, count) VALUES (''status_visibility_' || activity_visibility(NEW.actor, NEW.recipients, NEW.data) || ''', 1) ON CONFLICT (name) DO UPDATE SET count = counter_cache.count + 1'; + END IF; + RETURN NEW; + ELSIF TG_OP = 'UPDATE' THEN + IF (NEW.data->>'type' = 'Create') and (OLD.data->>'type' = 'Create') and activity_visibility(NEW.actor, NEW.recipients, NEW.data) != activity_visibility(OLD.actor, OLD.recipients, OLD.data) THEN + EXECUTE 'INSERT INTO counter_cache (name, count) VALUES (''status_visibility_' || activity_visibility(NEW.actor, NEW.recipients, NEW.data) || ''', 1) ON CONFLICT (name) DO UPDATE SET count = counter_cache.count + 1'; + EXECUTE 'update counter_cache SET count = counter_cache.count - 1 where count > 0 and name = ''status_visibility_' || activity_visibility(OLD.actor, OLD.recipients, OLD.data) || ''';'; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.data->>'type' = 'Create' THEN + EXECUTE 'update counter_cache SET count = counter_cache.count - 1 where count > 0 and name = ''status_visibility_' || activity_visibility(OLD.actor, OLD.recipients, OLD.data) || ''';'; + END IF; + RETURN OLD; + END IF; + END; + $$ + LANGUAGE 'plpgsql'; + """ + |> execute() + + """ + CREATE TRIGGER #{@trigger_name} BEFORE INSERT OR UPDATE of recipients, data OR DELETE ON activities + FOR EACH ROW + EXECUTE PROCEDURE #{@function_name}(); + """ + |> execute() + end +end -- cgit v1.2.3 From 6a291b1834c9b6a5953963041d4d885e78313d85 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 8 May 2020 14:22:49 -0500 Subject: Restore deleted icons. No idea how this happened. --- .../adminfe/static/fonts/element-icons.535877f.woff | Bin 0 -> 28200 bytes .../adminfe/static/fonts/element-icons.732389d.ttf | Bin 0 -> 55956 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 priv/static/adminfe/static/fonts/element-icons.535877f.woff create mode 100644 priv/static/adminfe/static/fonts/element-icons.732389d.ttf diff --git a/priv/static/adminfe/static/fonts/element-icons.535877f.woff b/priv/static/adminfe/static/fonts/element-icons.535877f.woff new file mode 100644 index 000000000..02b9a2539 Binary files /dev/null and b/priv/static/adminfe/static/fonts/element-icons.535877f.woff differ diff --git a/priv/static/adminfe/static/fonts/element-icons.732389d.ttf b/priv/static/adminfe/static/fonts/element-icons.732389d.ttf new file mode 100644 index 000000000..91b74de36 Binary files /dev/null and b/priv/static/adminfe/static/fonts/element-icons.732389d.ttf differ -- cgit v1.2.3 From c9344b5f2dd225dfbb34b168f2466f8a0b002364 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 8 May 2020 14:36:59 -0500 Subject: Minor grammar nit --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 39e094082..504161a9f 100644 --- a/config/description.exs +++ b/config/description.exs @@ -712,7 +712,7 @@ key: :quarantined_instances, type: {:list, :string}, description: - "List of ActivityPub instances where private (DMs, followers-only) activities will not be send", + "List of ActivityPub instances where private (DMs, followers-only) activities will not be sent", suggestions: [ "quarantined.com", "*.quarantined.com" -- cgit v1.2.3 From f4d50fbd8132d8d48c4e8a57966afab480a26c0b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 8 May 2020 23:54:16 +0300 Subject: CHANGELOG.md: Add entries from 2.0.3 --- CHANGELOG.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec191575f..270c1b869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,24 +40,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Filtering of push notifications on activities from blocked domains - Resolving Peertube accounts with Webfinger -## [unreleased-patch] +## [Unreleased (patch)] + +## [2.0.3] - 2020-05-02 + ### Security - Disallow re-registration of previously deleted users, which allowed viewing direct messages addressed to them - Mastodon API: Fix `POST /api/v1/follow_requests/:id/authorize` allowing to force a follow from a local user even if they didn't request to follow +- CSP: Sandbox uploads ### Fixed -- Logger configuration through AdminFE +- Notifications from blocked domains +- Potential federation issues with Mastodon versions before 3.0.0 - HTTP Basic Authentication permissions issue +- Follow/Block imports not being able to find the user if the nickname started with an `@` +- Instance stats counting internal users +- Inability to run a From Source release without git - ObjectAgePolicy didn't filter out old messages -- Transmogrifier: Keep object sensitive settings for outgoing representation (AP C2S) +- `blob:` urls not being allowed by CSP ### Added - NodeInfo: ObjectAgePolicy settings to the `federation` list. +- Follow request notifications
    API Changes - Admin API: `GET /api/pleroma/admin/need_reboot`.
    +### Upgrade notes + +1. Restart Pleroma +2. Run database migrations (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl migrate` + - From Source: `mix ecto.migrate` + + ## [2.0.2] - 2020-04-08 ### Added - Support for Funkwhale's `Audio` activity -- cgit v1.2.3 From 30eaef9d615428b23dbc569b6e6c745185bb00b8 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 8 May 2020 23:51:59 +0300 Subject: healthcheck: report real amount of memory allocated by beam as opposed to memory currently in use --- CHANGELOG.md | 3 +++ lib/pleroma/healthcheck.ex | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270c1b869..d469793f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased (patch)] +### Fixed +- Healthcheck reporting the number of memory currently used, rather than allocated in total + ## [2.0.3] - 2020-05-02 ### Security diff --git a/lib/pleroma/healthcheck.ex b/lib/pleroma/healthcheck.ex index 8f7f43ec2..92ce83cb7 100644 --- a/lib/pleroma/healthcheck.ex +++ b/lib/pleroma/healthcheck.ex @@ -29,7 +29,7 @@ defmodule Pleroma.Healthcheck do @spec system_info() :: t() def system_info do %Healthcheck{ - memory_used: Float.round(:erlang.memory(:total) / 1024 / 1024, 2) + memory_used: Float.round(:recon_alloc.memory(:allocated) / 1024 / 1024, 2) } |> assign_db_info() |> assign_job_queue_stats() -- cgit v1.2.3 From 39d2f2118aed7906cb352d8a37f22da73f3a3aa3 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 01:20:50 +0300 Subject: update counter_cache logic --- lib/mix/tasks/pleroma/refresh_counter_cache.ex | 42 +++++++++++----- lib/pleroma/counter_cache.ex | 66 ++++++++++++++++++++------ lib/pleroma/stats.ex | 8 +--- 3 files changed, 82 insertions(+), 34 deletions(-) diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex index 15b4dbfa6..280201bef 100644 --- a/lib/mix/tasks/pleroma/refresh_counter_cache.ex +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -17,30 +17,46 @@ defmodule Mix.Tasks.Pleroma.RefreshCounterCache do def run([]) do Mix.Pleroma.start_pleroma() - ["public", "unlisted", "private", "direct"] - |> Enum.each(fn visibility -> - count = status_visibility_count_query(visibility) - name = "status_visibility_#{visibility}" - CounterCache.set(name, count) - Mix.Pleroma.shell_info("Set #{name} to #{count}") + Activity + |> distinct([a], true) + |> select([a], fragment("split_part(?, '/', 3)", a.actor)) + |> Repo.all() + |> Enum.each(fn instance -> + counters = instance_counters(instance) + CounterCache.set(instance, counters) + Mix.Pleroma.shell_info("Setting #{instance} counters: #{inspect(counters)}") end) Mix.Pleroma.shell_info("Done") end - defp status_visibility_count_query(visibility) do + defp instance_counters(instance) do + counters = %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + Activity - |> where( + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([a], like(a.actor, ^"%#{instance}%")) + |> select( + [a], + {fragment( + "activity_visibility(?, ?, ?)", + a.actor, + a.recipients, + a.data + ), count(a.id)} + ) + |> group_by( [a], fragment( - "activity_visibility(?, ?, ?) = ?", + "activity_visibility(?, ?, ?)", a.actor, a.recipients, - a.data, - ^visibility + a.data ) ) - |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) - |> Repo.aggregate(:count, :id, timeout: :timer.minutes(30)) + |> Repo.all(timeout: :timer.minutes(30)) + |> Enum.reduce(counters, fn {visibility, count}, acc -> + Map.put(acc, visibility, count) + end) end end diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index 4d348a413..b469e7b50 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -10,32 +10,70 @@ defmodule Pleroma.CounterCache do import Ecto.Query schema "counter_cache" do - field(:name, :string) - field(:count, :integer) + field(:instance, :string) + field(:public, :integer) + field(:unlisted, :integer) + field(:private, :integer) + field(:direct, :integer) end def changeset(struct, params) do struct - |> cast(params, [:name, :count]) - |> validate_required([:name]) - |> unique_constraint(:name) + |> cast(params, [:instance, :public, :unlisted, :private, :direct]) + |> validate_required([:instance]) + |> unique_constraint(:instance) end - def get_as_map(names) when is_list(names) do + def get_by_instance(instance) do CounterCache - |> where([cc], cc.name in ^names) - |> Repo.all() - |> Enum.group_by(& &1.name, & &1.count) - |> Map.new(fn {k, v} -> {k, hd(v)} end) + |> select([c], %{ + "public" => c.public, + "unlisted" => c.unlisted, + "private" => c.private, + "direct" => c.direct + }) + |> where([c], c.instance == ^instance) + |> Repo.one() + |> case do + nil -> %{"public" => 0, "unlisted" => 0, "private" => 0, "direct" => 0} + val -> val + end end - def set(name, count) do + def get_as_map() do + CounterCache + |> select([c], %{ + "public" => sum(c.public), + "unlisted" => sum(c.unlisted), + "private" => sum(c.private), + "direct" => sum(c.direct) + }) + |> Repo.one() + end + + def set(instance, values) do + params = + Enum.reduce( + ["public", "private", "unlisted", "direct"], + %{"instance" => instance}, + fn param, acc -> + Map.put_new(acc, param, Map.get(values, param, 0)) + end + ) + %CounterCache{} - |> changeset(%{"name" => name, "count" => count}) + |> changeset(params) |> Repo.insert( - on_conflict: [set: [count: count]], + on_conflict: [ + set: [ + public: params["public"], + private: params["private"], + unlisted: params["unlisted"], + direct: params["direct"] + ] + ], returning: true, - conflict_target: :name + conflict_target: :instance ) end end diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 6b3a8a41f..4e355bd5c 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -98,13 +98,7 @@ def calculate_stat_data do end def get_status_visibility_count do - counter_cache = - CounterCache.get_as_map([ - "status_visibility_public", - "status_visibility_private", - "status_visibility_unlisted", - "status_visibility_direct" - ]) + counter_cache = CounterCache.get_as_map() %{ public: counter_cache["status_visibility_public"] || 0, -- cgit v1.2.3 From cbe383ae832f13d5d2a20ee8fb5e85498205fbc3 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:30:37 +0300 Subject: Update stats admin endpoint --- lib/pleroma/counter_cache.ex | 6 ++- lib/pleroma/stats.ex | 15 +++--- lib/pleroma/web/admin_api/admin_api_controller.ex | 7 ++- .../20200508092434_update_counter_cache_table.exs | 8 +++- test/stats_test.exs | 55 ++++++++++++++++++---- test/web/admin_api/admin_api_controller_test.exs | 20 ++++++++ 6 files changed, 87 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index b469e7b50..a940b5e50 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -40,7 +40,7 @@ def get_by_instance(instance) do end end - def get_as_map() do + def get_sum() do CounterCache |> select([c], %{ "public" => sum(c.public), @@ -49,6 +49,10 @@ def get_as_map() do "direct" => sum(c.direct) }) |> Repo.one() + |> Enum.map(fn {visibility, dec_count} -> + {visibility, Decimal.to_integer(dec_count)} + end) + |> Enum.into(%{}) end def set(instance, values) do diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 4e355bd5c..9a03f01db 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -97,14 +97,11 @@ def calculate_stat_data do } end - def get_status_visibility_count do - counter_cache = CounterCache.get_as_map() - - %{ - public: counter_cache["status_visibility_public"] || 0, - unlisted: counter_cache["status_visibility_unlisted"] || 0, - private: counter_cache["status_visibility_private"] || 0, - direct: counter_cache["status_visibility_direct"] || 0 - } + def get_status_visibility_count(instance \\ nil) do + if is_nil(instance) do + CounterCache.get_sum() + else + CounterCache.get_by_instance(instance) + end end end diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 9f1fd3aeb..4db9f4cac 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -1122,11 +1122,10 @@ def oauth_app_delete(conn, params) do end end - def stats(conn, _) do - count = Stats.get_status_visibility_count() + def stats(conn, params) do + counters = Stats.get_status_visibility_count(params["instance"]) - conn - |> json(%{"status_visibility" => count}) + json(conn, %{"status_visibility" => counters}) end defp errors(conn, {:error, :not_found}) do diff --git a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs index 81a8d6397..3d9bfc877 100644 --- a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs +++ b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs @@ -43,7 +43,8 @@ def up do END IF; IF TG_OP = 'INSERT' THEN visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); - IF NEW.data->>'type' = 'Create' THEN + IF NEW.data->>'type' = 'Create' + AND visibility_new IN ('public', 'unlisted', 'private', 'direct') THEN EXECUTE format('INSERT INTO "counter_cache" ("instance", %1$I) VALUES ($1, 1) ON CONFLICT ("instance") DO UPDATE SET %1$I = "counter_cache".%1$I + 1', visibility_new) @@ -53,7 +54,10 @@ def up do ELSIF TG_OP = 'UPDATE' THEN visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); visibility_old := activity_visibility(OLD.actor, OLD.recipients, OLD.data); - IF (NEW.data->>'type' = 'Create') and (OLD.data->>'type' = 'Create') and visibility_new != visibility_old THEN + IF (NEW.data->>'type' = 'Create') + AND (OLD.data->>'type' = 'Create') + AND visibility_new != visibility_old + AND visibility_new IN ('public', 'unlisted', 'private', 'direct') THEN EXECUTE format('UPDATE "counter_cache" SET %1$I = greatest("counter_cache".%1$I - 1, 0), %2$I = "counter_cache".%2$I + 1 diff --git a/test/stats_test.exs b/test/stats_test.exs index c1aeb2c7f..33ed0b7dd 100644 --- a/test/stats_test.exs +++ b/test/stats_test.exs @@ -17,10 +17,11 @@ test "it ignores internal users" do end end - describe "status visibility count" do + describe "status visibility sum count" do test "on new status" do + instance2 = "instance2.tld" user = insert(:user) - other_user = insert(:user) + other_user = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) @@ -45,24 +46,24 @@ test "on new status" do }) end) - assert %{direct: 3, private: 4, public: 1, unlisted: 2} = + assert %{"direct" => 3, "private" => 4, "public" => 1, "unlisted" => 2} = Pleroma.Stats.get_status_visibility_count() end test "on status delete" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - assert %{public: 1} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 1} = Pleroma.Stats.get_status_visibility_count() CommonAPI.delete(activity.id, user) - assert %{public: 0} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 0} = Pleroma.Stats.get_status_visibility_count() end test "on status visibility update" do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - assert %{public: 1, private: 0} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 1, "private" => 0} = Pleroma.Stats.get_status_visibility_count() {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{"visibility" => "private"}) - assert %{public: 0, private: 1} = Pleroma.Stats.get_status_visibility_count() + assert %{"public" => 0, "private" => 1} = Pleroma.Stats.get_status_visibility_count() end test "doesn't count unrelated activities" do @@ -73,8 +74,46 @@ test "doesn't count unrelated activities" do CommonAPI.favorite(other_user, activity.id) CommonAPI.repeat(activity.id, other_user) - assert %{direct: 0, private: 0, public: 1, unlisted: 0} = + assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 0} = Pleroma.Stats.get_status_visibility_count() end end + + describe "status visibility by instance count" do + test "single instance" do + local_instance = Pleroma.Web.Endpoint.url() |> String.split("//") |> Enum.at(1) + instance2 = "instance2.tld" + user1 = insert(:user) + user2 = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) + + CommonAPI.post(user1, %{"visibility" => "public", "status" => "hey"}) + + Enum.each(1..5, fn _ -> + CommonAPI.post(user1, %{ + "visibility" => "unlisted", + "status" => "hey" + }) + end) + + Enum.each(1..10, fn _ -> + CommonAPI.post(user1, %{ + "visibility" => "direct", + "status" => "hey @#{user2.nickname}" + }) + end) + + Enum.each(1..20, fn _ -> + CommonAPI.post(user2, %{ + "visibility" => "private", + "status" => "hey" + }) + end) + + assert %{"direct" => 10, "private" => 0, "public" => 1, "unlisted" => 5} = + Pleroma.Stats.get_status_visibility_count(local_instance) + + assert %{"direct" => 0, "private" => 20, "public" => 0, "unlisted" => 0} = + Pleroma.Stats.get_status_visibility_count(instance2) + end + 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 4697af50e..c3de89ac0 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3612,6 +3612,26 @@ test "status visibility count", %{conn: conn} do assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 2} = response["status_visibility"] end + + test "by instance", %{conn: conn} do + admin = insert(:user, is_admin: true) + user1 = insert(:user) + instance2 = "instance2.tld" + user2 = insert(:user, %{ap_id: "https://#{instance2}/@actor"}) + + CommonAPI.post(user1, %{"visibility" => "public", "status" => "hey"}) + CommonAPI.post(user2, %{"visibility" => "unlisted", "status" => "hey"}) + CommonAPI.post(user2, %{"visibility" => "private", "status" => "hey"}) + + response = + conn + |> assign(:user, admin) + |> get("/api/pleroma/admin/stats", instance: instance2) + |> json_response(200) + + assert %{"direct" => 0, "private" => 1, "public" => 0, "unlisted" => 1} = + response["status_visibility"] + end end describe "POST /api/pleroma/admin/oauth_app" do -- cgit v1.2.3 From 01b06d6dbfdeff7e1733d575fb94eee4dafbb56a Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:43:31 +0300 Subject: Show progress in refresh_counter_cache task --- lib/mix/tasks/pleroma/refresh_counter_cache.ex | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex index 280201bef..b44e2545d 100644 --- a/lib/mix/tasks/pleroma/refresh_counter_cache.ex +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -17,14 +17,21 @@ defmodule Mix.Tasks.Pleroma.RefreshCounterCache do def run([]) do Mix.Pleroma.start_pleroma() - Activity - |> distinct([a], true) - |> select([a], fragment("split_part(?, '/', 3)", a.actor)) - |> Repo.all() - |> Enum.each(fn instance -> + instances = + Activity + |> distinct([a], true) + |> select([a], fragment("split_part(?, '/', 3)", a.actor)) + |> Repo.all() + + instances + |> Enum.with_index(1) + |> Enum.each(fn {instance, i} -> counters = instance_counters(instance) CounterCache.set(instance, counters) - Mix.Pleroma.shell_info("Setting #{instance} counters: #{inspect(counters)}") + + Mix.Pleroma.shell_info( + "[#{i}/#{length(instances)}] Setting #{instance} counters: #{inspect(counters)}" + ) end) Mix.Pleroma.shell_info("Done") -- cgit v1.2.3 From 5c368b004b1a736836d4bc9f68c54714a33056cd Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:49:54 +0300 Subject: Fix refresh_counter_cache test --- test/tasks/refresh_counter_cache_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/tasks/refresh_counter_cache_test.exs b/test/tasks/refresh_counter_cache_test.exs index b63f44c08..378664148 100644 --- a/test/tasks/refresh_counter_cache_test.exs +++ b/test/tasks/refresh_counter_cache_test.exs @@ -37,7 +37,7 @@ test "counts statuses" do assert capture_io(fn -> Mix.Tasks.Pleroma.RefreshCounterCache.run([]) end) =~ "Done\n" - assert %{direct: 3, private: 4, public: 1, unlisted: 2} = + assert %{"direct" => 3, "private" => 4, "public" => 1, "unlisted" => 2} = Pleroma.Stats.get_status_visibility_count() end end -- cgit v1.2.3 From 4f265397179e7286f27fafaf8365a0edc6972448 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 11:59:49 +0300 Subject: Fix credo warning --- lib/pleroma/counter_cache.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index a940b5e50..aa6d38687 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -40,7 +40,7 @@ def get_by_instance(instance) do end end - def get_sum() do + def get_sum do CounterCache |> select([c], %{ "public" => sum(c.public), -- cgit v1.2.3 From 56819f7f0604e5d4eb69edba1d6828256acbc7fe Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 13:13:26 +0300 Subject: Use index on refresh_counter_cache --- lib/mix/tasks/pleroma/refresh_counter_cache.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/refresh_counter_cache.ex b/lib/mix/tasks/pleroma/refresh_counter_cache.ex index b44e2545d..efcbaa3b1 100644 --- a/lib/mix/tasks/pleroma/refresh_counter_cache.ex +++ b/lib/mix/tasks/pleroma/refresh_counter_cache.ex @@ -42,7 +42,7 @@ defp instance_counters(instance) do Activity |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) - |> where([a], like(a.actor, ^"%#{instance}%")) + |> where([a], fragment("split_part(?, '/', 3) = ?", a.actor, ^instance)) |> select( [a], {fragment( -- cgit v1.2.3 From 4c197023903a183790fb2dc67b5637bfabd53bcb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 9 May 2020 14:32:08 +0300 Subject: Add docs --- CHANGELOG.md | 12 ++++++++++++ docs/API/admin_api.md | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d469793f0..f2c9e106e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed
    API Changes + - **Breaking:** Emoji API: changed methods and renamed routes.
    +
    + Admin API Changes + +- Status visibility stats: now can return stats per instance. + +- Mix task to refresh counter cache (`mix pleroma.refresh_counter_cache`) +
    + ### Removed - **Breaking:** removed `with_move` parameter from notifications timeline. @@ -76,6 +85,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 2. Run database migrations (inside Pleroma directory): - OTP: `./bin/pleroma_ctl migrate` - From Source: `mix ecto.migrate` +3. Reset status visibility counters (inside Pleroma directory): + - OTP: `./bin/pleroma_ctl refresh_counter_cache` + - From Source: `mix pleroma.refresh_counter_cache` ## [2.0.2] - 2020-04-08 diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index c455047cc..fa74e7460 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1096,6 +1096,10 @@ Loads json generated from `config/descriptions.exs`. ### Stats +- Query Params: + - *optional* `instance`: **string** instance hostname (without protocol) to get stats for +- Example: `https://mypleroma.org/api/pleroma/admin/stats?instance=lain.com` + - Response: ```json -- cgit v1.2.3 From b6d1bcc55d40b7ac03fdc2f7d4d94ab7c2a1855a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 9 May 2020 14:49:46 +0300 Subject: include eldap in OTP releases Closes #1313 --- mix.exs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 6d65e18d4..97b561790 100644 --- a/mix.exs +++ b/mix.exs @@ -72,7 +72,15 @@ def copy_nginx_config(%{path: target_path} = release) do def application do [ mod: {Pleroma.Application, []}, - extra_applications: [:logger, :runtime_tools, :comeonin, :quack, :fast_sanitize, :ssl], + extra_applications: [ + :logger, + :runtime_tools, + :comeonin, + :quack, + :fast_sanitize, + :ssl, + :eldap + ], included_applications: [:ex_syslogger] ] end -- cgit v1.2.3 From 14a49a04837b0dc5a0d72dd7c5b4dfa482801e7c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 9 May 2020 18:05:44 +0300 Subject: [#2456] Dropped support for embedded `pleroma/account/relationship` in statuses and notifications. --- lib/pleroma/web/admin_api/views/account_view.ex | 9 +++ lib/pleroma/web/admin_api/views/report_view.ex | 10 +-- lib/pleroma/web/admin_api/views/status_view.ex | 15 ++-- lib/pleroma/web/controller_helper.ex | 5 -- .../mastodon_api/controllers/account_controller.ex | 6 +- .../controllers/notification_controller.ex | 5 +- .../mastodon_api/controllers/search_controller.ex | 6 +- .../mastodon_api/controllers/status_controller.ex | 13 ++-- .../controllers/timeline_controller.ex | 17 ++--- .../web/mastodon_api/views/notification_view.ex | 29 +++----- lib/pleroma/web/mastodon_api/views/status_view.ex | 16 +---- .../pleroma_api/controllers/account_controller.ex | 5 +- .../controllers/pleroma_api_controller.ex | 10 ++- .../controllers/timeline_controller_test.exs | 80 +--------------------- 14 files changed, 52 insertions(+), 174 deletions(-) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index a16a3ebf0..8471b0f09 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -6,7 +6,9 @@ defmodule Pleroma.Web.AdminAPI.AccountView do use Pleroma.Web, :view alias Pleroma.User + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.MastodonAPI alias Pleroma.Web.MediaProxy def render("index.json", %{users: users, count: count, page_size: page_size}) do @@ -119,6 +121,13 @@ def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, e } end + def merge_account_views(%User{} = user) do + MastodonAPI.AccountView.render("show.json", %{user: user, skip_relationships: true}) + |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) + end + + def merge_account_views(_), do: %{} + defp parse_error([]), do: "" defp parse_error(errors) do diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index 215e31100..f432b8c2c 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -10,9 +10,10 @@ defmodule Pleroma.Web.AdminAPI.ReportView do alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.MastodonAPI alias Pleroma.Web.MastodonAPI.StatusView + defdelegate merge_account_views(user), to: AdminAPI.AccountView + def render("index.json", %{reports: reports}) do %{ reports: @@ -71,11 +72,4 @@ def render("show_note.json", %{ created_at: Utils.to_masto_date(inserted_at) } end - - defp merge_account_views(%User{} = user) do - MastodonAPI.AccountView.render("show.json", %{user: user, skip_relationships: true}) - |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) - end - - defp merge_account_views(_), do: %{} end diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex index a76fad990..500800be2 100644 --- a/lib/pleroma/web/admin_api/views/status_view.ex +++ b/lib/pleroma/web/admin_api/views/status_view.ex @@ -7,26 +7,19 @@ defmodule Pleroma.Web.AdminAPI.StatusView do require Pleroma.Constants - alias Pleroma.User alias Pleroma.Web.AdminAPI alias Pleroma.Web.MastodonAPI - alias Pleroma.Web.MastodonAPI.StatusView + + defdelegate merge_account_views(user), to: AdminAPI.AccountView def render("index.json", opts) do safe_render_many(opts.activities, __MODULE__, "show.json", opts) end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do - user = StatusView.get_user(activity.data["actor"]) + user = MastodonAPI.StatusView.get_user(activity.data["actor"]) - StatusView.render("show.json", opts) + MastodonAPI.StatusView.render("show.json", opts) |> Map.merge(%{account: merge_account_views(user)}) end - - defp merge_account_views(%User{} = user) do - MastodonAPI.AccountView.render("show.json", %{user: user, skip_relationships: true}) - |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) - end - - defp merge_account_views(_), do: %{} end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index f0b4c087a..61fdec030 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -103,9 +103,4 @@ def try_render(conn, _, _) do def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) - - @doc "Whether to skip `account.pleroma.relationship` rendering for statuses/notifications" - def skip_relationships?(params) do - not truthy_param?(params["with_relationships"]) - end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index b9ed2d7b2..489441da5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -10,8 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, - json_response: 3, - skip_relationships?: 1 + json_response: 3 ] alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug @@ -247,8 +246,7 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do |> render("index.json", activities: activities, for: reading_user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) else _e -> render_error(conn, :not_found, "Can't find user") diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index 596b85617..bcd12c73f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, skip_relationships?: 1] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Notification alias Pleroma.Plugs.OAuthScopesPlug @@ -50,8 +50,7 @@ def index(%{assigns: %{user: user}} = conn, params) do |> add_link_headers(notifications) |> render("index.json", notifications: notifications, - for: user, - skip_relationships: skip_relationships?(params) + for: user ) end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 6663c8707..5d3318ce0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -5,8 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [skip_relationships?: 1] - alias Pleroma.Activity alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter @@ -71,7 +69,6 @@ defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) defp search_options(params, user) do [ - skip_relationships: skip_relationships?(params), resolve: params[:resolve], following: params[:following], limit: params[:limit], @@ -100,8 +97,7 @@ defp resource_search(_, "statuses", query, options) do StatusView.render("index.json", activities: statuses, for: options[:for_user], - as: :activity, - skip_relationships: options[:skip_relationships] + as: :activity ) end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 12e3ba15e..2b2e4a896 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [try_render: 3, add_link_headers: 2, skip_relationships?: 1] + only: [try_render: 3, add_link_headers: 2] require Ecto.Query @@ -102,7 +102,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do `ids` query param is required """ - def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = params) do + def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = _params) do limit = 100 activities = @@ -114,8 +114,7 @@ def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = params) do render(conn, "index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end @@ -370,8 +369,7 @@ def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end @@ -393,8 +391,7 @@ def bookmarks(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 2d67e19da..61cc6ab49 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1, skip_relationships?: 1] + only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] alias Pleroma.Pagination alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug @@ -59,8 +59,7 @@ def home(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end @@ -83,8 +82,7 @@ def direct(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end @@ -118,8 +116,7 @@ def public(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end end @@ -166,8 +163,7 @@ def hashtag(%{assigns: %{user: user}} = conn, params) do |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end @@ -195,8 +191,7 @@ def list(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do render(conn, "index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) else _e -> render_error(conn, :forbidden, "Error.") diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index e518bdedb..0349bcc83 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -15,8 +15,6 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.StatusView def render("index.json", %{notifications: notifications, for: reading_user} = opts) do - opts = Map.merge(%{skip_relationships: true}, opts) - activities = Enum.map(notifications, & &1.activity) parent_activities = @@ -53,9 +51,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op |> Enum.filter(& &1) |> Kernel.++(move_activities_targets) - UserRelationship.view_relationships_option(reading_user, actors, - source_mutes_only: opts[:skip_relationships] - ) + UserRelationship.view_relationships_option(reading_user, actors, source_mutes_only: true) end opts = @@ -73,8 +69,6 @@ def render( for: reading_user } = opts ) do - opts = Map.merge(%{skip_relationships: true}, opts) - actor = User.get_cached_by_ap_id(activity.data["actor"]) parent_activity_fn = fn -> @@ -87,15 +81,15 @@ def render( mastodon_type = Activity.mastodon_notification_type(activity) - render_opts = %{ - relationships: opts[:relationships], - skip_relationships: opts[:skip_relationships] - } + # Note: :relationships contain user mutes (needed for :muted flag in :status) + status_render_opts = %{relationships: opts[:relationships]} + + account_render_opts = %{skip_relationships: true} with %{id: _} = account <- AccountView.render( "show.json", - Map.merge(render_opts, %{user: actor, for: reading_user}) + Map.merge(account_render_opts, %{user: actor, for: reading_user}) ) do response = %{ id: to_string(notification.id), @@ -109,21 +103,20 @@ def render( case mastodon_type do "mention" -> - put_status(response, activity, reading_user, render_opts) + put_status(response, activity, reading_user, status_render_opts) "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, render_opts) + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, render_opts) + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "move" -> - # Note: :skip_relationships option being applied to _account_ rendering (here) - put_target(response, activity, reading_user, render_opts) + put_target(response, activity, reading_user, account_render_opts) "pleroma:emoji_reaction" -> response - |> put_status(parent_activity_fn.(), reading_user, render_opts) + |> put_status(parent_activity_fn.(), reading_user, status_render_opts) |> put_emoji(activity) type when type in ["follow", "follow_request"] -> diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 0bcc84d44..8762f23fd 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -76,8 +76,6 @@ defp reblogged?(activity, user) do end def render("index.json", opts) do - opts = Map.merge(%{skip_relationships: true}, opts) - reading_user = opts[:for] # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list @@ -109,9 +107,7 @@ def render("index.json", opts) do |> Enum.map(&get_user(&1.data["actor"], false)) |> Enum.filter(& &1) - UserRelationship.view_relationships_option(reading_user, actors, - source_mutes_only: opts[:skip_relationships] - ) + UserRelationship.view_relationships_option(reading_user, actors, source_mutes_only: true) end opts = @@ -127,8 +123,6 @@ def render( "show.json", %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts ) do - opts = Map.merge(%{skip_relationships: true}, opts) - user = get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) activity_object = Object.normalize(activity) @@ -167,8 +161,7 @@ def render( AccountView.render("show.json", %{ user: user, for: opts[:for], - relationships: opts[:relationships], - skip_relationships: opts[:skip_relationships] + skip_relationships: true }), in_reply_to_id: nil, in_reply_to_account_id: nil, @@ -202,8 +195,6 @@ def render( end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do - opts = Map.merge(%{skip_relationships: true}, opts) - object = Object.normalize(activity) user = get_user(activity.data["actor"]) @@ -337,8 +328,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} AccountView.render("show.json", %{ user: user, for: opts[:for], - relationships: opts[:relationships], - skip_relationships: opts[:skip_relationships] + skip_relationships: true }), 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/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index be7477867..3c6a951b9 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2, skip_relationships?: 1] + only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] alias Ecto.Changeset alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug @@ -139,8 +139,7 @@ def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do |> render("index.json", activities: activities, for: for_user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 80ecdf67e..b61a6791b 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, skip_relationships?: 1] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Activity alias Pleroma.Conversation.Participation @@ -151,8 +151,7 @@ def conversation_statuses( |> render("index.json", activities: activities, for: user, - as: :activity, - skip_relationships: skip_relationships?(params) + as: :activity ) else _error -> @@ -207,7 +206,7 @@ def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"id" => notif end end - def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"max_id" => max_id} = params) do + def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do with notifications <- Notification.set_read_up_to(user, max_id) do notifications = Enum.take(notifications, 80) @@ -215,8 +214,7 @@ def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"max_id" => m |> put_view(NotificationView) |> render("index.json", notifications: notifications, - for: user, - skip_relationships: skip_relationships?(params) + for: user ) end end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index b8bb83af7..47541979d 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -20,7 +20,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do describe "home" do setup do: oauth_access(["read:statuses"]) - test "does NOT render account/pleroma/relationship by default", %{ + test "does NOT embed account/pleroma/relationship in statuses", %{ user: user, conn: conn } do @@ -39,84 +39,6 @@ test "does NOT render account/pleroma/relationship by default", %{ end) end - test "embeds account relationships with `with_relationships=true`", %{user: user, conn: conn} do - uri = "/api/v1/timelines/home?with_relationships=true" - - following = insert(:user, nickname: "followed") - third_user = insert(:user, nickname: "repeated") - - {: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, uri) - - assert Enum.empty?(json_response(ret_conn, :ok)) - - {:ok, _user} = User.follow(user, following) - - ret_conn = get(conn, uri) - - 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, uri) - - assert [ - %{ - "reblog" => %{ - "content" => "repeated post", - "account" => %{ - "acct" => "repeated", - "pleroma" => %{ - "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 {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) -- cgit v1.2.3 From ac4250a18c27477974a643a730ef89d6c66220f9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 9 May 2020 19:03:07 +0300 Subject: [#2456] Clarified `skip_relationships` option (and its default of `false`) for MastodonAPI.AccountView. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index f0b157962..c1786a322 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -12,8 +12,16 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy + # Default behaviour for account view is to include embedded relationships + # (e.g. when accounts are rendered on their own [e.g. a list of search results], not as + # embedded content in notifications / statuses). + # This option must be explicitly set to false when rendering accounts as embedded content. + defp initialize_skip_relationships(opts) do + Map.merge(%{skip_relationships: false}, opts) + end + def render("index.json", %{users: users} = opts) do - opts = Map.merge(%{skip_relationships: false}, opts) + opts = initialize_skip_relationships(opts) reading_user = opts[:for] @@ -161,7 +169,7 @@ def render("relationships.json", %{user: user, targets: targets} = opts) do end defp do_render("show.json", %{user: user} = opts) do - opts = Map.merge(%{skip_relationships: false}, opts) + opts = initialize_skip_relationships(opts) user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) display_name = user.name || user.nickname -- cgit v1.2.3 From 0ad89762a1cfdbb953a12e0434153a5e577f183a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 9 May 2020 18:51:20 +0300 Subject: insert skeletons migration: fix for non-local locals Apparently some instances have local users with local ap_ids that are marked as local: false. Needs more investigation into how that happened. In the meantime, the skeleton migration was patched to just ignore all known ap ids, not just locals. Doesn't seem to slow down the migration too much on patch.cx Closes #1746 --- CHANGELOG.md | 1 + .../migrations/20200428221338_insert_skeletons_for_deleted_users.exs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d469793f0..4b7fb603d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Healthcheck reporting the number of memory currently used, rather than allocated in total +- `InsertSkeletonsForDeletedUsers` failing on some instances ## [2.0.3] - 2020-05-02 diff --git a/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs b/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs index 11d9a70ba..2adc38186 100644 --- a/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs +++ b/priv/repo/migrations/20200428221338_insert_skeletons_for_deleted_users.exs @@ -30,7 +30,7 @@ def change do Repo, "select distinct unnest(nonexistent_locals.recipients) from activities, lateral (select array_agg(recipient) as recipients from unnest(activities.recipients) as recipient where recipient similar to '#{ instance_uri - }/users/[A-Za-z0-9]*' and not(recipient in (select ap_id from users where local = true))) nonexistent_locals;", + }/users/[A-Za-z0-9]*' and not(recipient in (select ap_id from users))) nonexistent_locals;", [], timeout: :infinity ) -- cgit v1.2.3 From f3f8ed9e19f1ab8863141ba8b31773c54f4771fb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 10 May 2020 09:13:24 +0300 Subject: Set sum types in query --- lib/pleroma/counter_cache.ex | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/counter_cache.ex b/lib/pleroma/counter_cache.ex index aa6d38687..ebd1f603d 100644 --- a/lib/pleroma/counter_cache.ex +++ b/lib/pleroma/counter_cache.ex @@ -43,16 +43,12 @@ def get_by_instance(instance) do def get_sum do CounterCache |> select([c], %{ - "public" => sum(c.public), - "unlisted" => sum(c.unlisted), - "private" => sum(c.private), - "direct" => sum(c.direct) + "public" => type(sum(c.public), :integer), + "unlisted" => type(sum(c.unlisted), :integer), + "private" => type(sum(c.private), :integer), + "direct" => type(sum(c.direct), :integer) }) |> Repo.one() - |> Enum.map(fn {visibility, dec_count} -> - {visibility, Decimal.to_integer(dec_count)} - end) - |> Enum.into(%{}) end def set(instance, values) do -- cgit v1.2.3 From aee88d11be898921d79ad7f1481ab055190f4dfd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 10 May 2020 09:16:48 +0300 Subject: [#2456] Removed support for embedded relationships in account view. --- lib/pleroma/web/admin_api/admin_api_controller.ex | 13 ++-- lib/pleroma/web/admin_api/views/account_view.ex | 2 +- lib/pleroma/web/chat_channel.ex | 7 +- .../mastodon_api/controllers/search_controller.ex | 3 +- lib/pleroma/web/mastodon_api/views/account_view.ex | 41 +----------- .../web/mastodon_api/views/notification_view.ex | 6 +- lib/pleroma/web/mastodon_api/views/status_view.ex | 6 +- .../controllers/pleroma_api_controller.ex | 3 +- test/web/mastodon_api/views/account_view_test.exs | 76 ---------------------- .../mastodon_api/views/notification_view_test.exs | 21 ++---- 10 files changed, 23 insertions(+), 155 deletions(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index d2c5a6b9c..987b3bcba 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -22,6 +22,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView @@ -31,7 +32,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint alias Pleroma.Web.MastodonAPI.AppView - alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.MastodonAPI alias Pleroma.Web.OAuth.App alias Pleroma.Web.Router @@ -280,7 +281,7 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do }) conn - |> put_view(Pleroma.Web.AdminAPI.StatusView) + |> put_view(AdminAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) end @@ -299,7 +300,7 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do }) conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} @@ -829,14 +830,14 @@ def list_statuses(%{assigns: %{user: _admin}} = conn, params) do }) conn - |> put_view(Pleroma.Web.AdminAPI.StatusView) + |> put_view(AdminAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) end def status_show(conn, %{"id" => id}) do with %Activity{} = activity <- Activity.get_by_id(id) do conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("show.json", %{activity: activity}) else _ -> errors(conn, {:error, :not_found}) @@ -856,7 +857,7 @@ def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do }) conn - |> put_view(StatusView) + |> put_view(MastodonAPI.StatusView) |> render("show.json", %{activity: activity}) end end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 8471b0f09..46dadb5ee 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -122,7 +122,7 @@ def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, e end def merge_account_views(%User{} = user) do - MastodonAPI.AccountView.render("show.json", %{user: user, skip_relationships: true}) + MastodonAPI.AccountView.render("show.json", %{user: user}) |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) end diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index 3df8dc0f1..bce27897f 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -22,12 +22,7 @@ def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} if String.length(text) in 1..Pleroma.Config.get([:instance, :chat_limit]) do author = User.get_cached_by_nickname(user_name) - - author = - Pleroma.Web.MastodonAPI.AccountView.render("show.json", - user: author, - skip_relationships: true - ) + author = Pleroma.Web.MastodonAPI.AccountView.render("show.json", user: author) message = ChatChannelState.add_message(%{text: text, author: author}) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 5d3318ce0..c30ae1c7a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -86,8 +86,7 @@ defp resource_search(_, "accounts", query, options) do AccountView.render("index.json", users: accounts, for: options[:for_user], - as: :user, - skip_relationships: true + as: :user ) end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index c1786a322..b3a14d255 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -12,33 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MediaProxy - # Default behaviour for account view is to include embedded relationships - # (e.g. when accounts are rendered on their own [e.g. a list of search results], not as - # embedded content in notifications / statuses). - # This option must be explicitly set to false when rendering accounts as embedded content. - defp initialize_skip_relationships(opts) do - Map.merge(%{skip_relationships: false}, opts) - end - def render("index.json", %{users: users} = opts) do - opts = initialize_skip_relationships(opts) - - reading_user = opts[:for] - - relationships_opt = - cond do - Map.has_key?(opts, :relationships) -> - opts[:relationships] - - is_nil(reading_user) || opts[:skip_relationships] -> - UserRelationship.view_relationships_option(nil, []) - - true -> - UserRelationship.view_relationships_option(reading_user, users) - end - - opts = Map.put(opts, :relationships, relationships_opt) - users |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) @@ -169,8 +143,6 @@ def render("relationships.json", %{user: user, targets: targets} = opts) do end defp do_render("show.json", %{user: user} = opts) do - opts = initialize_skip_relationships(opts) - user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) display_name = user.name || user.nickname @@ -203,17 +175,6 @@ defp do_render("show.json", %{user: user} = opts) do } end) - relationship = - if opts[:skip_relationships] do - %{} - else - render("relationship.json", %{ - user: opts[:for], - target: user, - relationships: opts[:relationships] - }) - end - %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -252,7 +213,7 @@ defp do_render("show.json", %{user: user} = opts) do hide_followers: user.hide_followers, hide_follows: user.hide_follows, hide_favorites: user.hide_favorites, - relationship: relationship, + relationship: %{}, skip_thread_containment: user.skip_thread_containment, background_image: image_url(user.background) |> MediaProxy.url() } diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 0349bcc83..a53218d59 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -84,12 +84,10 @@ def render( # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} - account_render_opts = %{skip_relationships: true} - with %{id: _} = account <- AccountView.render( "show.json", - Map.merge(account_render_opts, %{user: actor, for: reading_user}) + %{user: actor, for: reading_user} ) do response = %{ id: to_string(notification.id), @@ -112,7 +110,7 @@ def render( put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "move" -> - put_target(response, activity, reading_user, account_render_opts) + put_target(response, activity, reading_user, %{}) "pleroma:emoji_reaction" -> response diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 8762f23fd..f7895c514 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -160,8 +160,7 @@ def render( account: AccountView.render("show.json", %{ user: user, - for: opts[:for], - skip_relationships: true + for: opts[:for] }), in_reply_to_id: nil, in_reply_to_account_id: nil, @@ -327,8 +326,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} account: AccountView.render("show.json", %{ user: user, - for: opts[:for], - skip_relationships: true + for: opts[:for] }), 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/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index b61a6791b..e834133b2 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -73,8 +73,7 @@ def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} AccountView.render("index.json", %{ users: users, for: user, - as: :user, - skip_relationships: true + as: :user }), me: !!(user && user.ap_id in user_ap_ids) } diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 5fb162141..3c1aeb6d5 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -295,82 +295,6 @@ test "represent a relationship for the user with a pending follow request" do end end - test "represent an embedded relationship" do - user = - insert(:user, %{ - follower_count: 0, - note_count: 5, - actor_type: "Service", - nickname: "shp@shitposter.club", - inserted_at: ~N[2017-08-15 15:47:06.597036] - }) - - other_user = insert(:user) - {:ok, other_user} = User.follow(other_user, user) - {:ok, _user_relationship} = User.block(other_user, user) - {:ok, _} = User.follow(insert(:user), user) - - expected = %{ - id: to_string(user.id), - username: "shp", - acct: user.nickname, - display_name: user.name, - locked: false, - created_at: "2017-08-15T15:47:06.000Z", - followers_count: 1, - following_count: 0, - statuses_count: 5, - note: user.bio, - url: user.ap_id, - avatar: "http://localhost:4001/images/avi.png", - avatar_static: "http://localhost:4001/images/avi.png", - header: "http://localhost:4001/images/banner.png", - header_static: "http://localhost:4001/images/banner.png", - emojis: [], - fields: [], - bot: true, - source: %{ - note: user.bio, - sensitive: false, - pleroma: %{ - actor_type: "Service", - discoverable: false - }, - fields: [] - }, - pleroma: %{ - background_image: nil, - confirmation_pending: false, - tags: [], - is_admin: false, - is_moderator: false, - hide_favorites: true, - hide_followers: false, - hide_follows: false, - hide_followers_count: false, - hide_follows_count: false, - relationship: %{ - id: to_string(user.id), - following: false, - followed_by: false, - blocking: true, - blocked_by: false, - subscribing: false, - muting: false, - muting_notifications: false, - requested: false, - domain_blocking: false, - showing_reblogs: true, - endorsed: false - }, - skip_thread_containment: false - } - } - - assert expected == - AccountView.render("show.json", %{user: refresh_record(user), for: other_user}) - end - test "returns the settings store if the requesting user is the represented user and it's requested specifically" do user = insert(:user, pleroma_settings_store: %{fe: "test"}) diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 61e6e3ae5..7cdba2fe0 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -45,8 +45,7 @@ test "Mention notification" do account: AccountView.render("show.json", %{ user: user, - for: mentioned_user, - skip_relationships: true + for: mentioned_user }), status: StatusView.render("show.json", %{activity: activity, for: mentioned_user}), created_at: Utils.to_masto_date(notification.inserted_at) @@ -67,8 +66,7 @@ test "Favourite notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "favourite", - account: - AccountView.render("show.json", %{user: another_user, for: user, skip_relationships: true}), + account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: create_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -88,8 +86,7 @@ test "Reblog notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "reblog", - account: - AccountView.render("show.json", %{user: another_user, for: user, skip_relationships: true}), + account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: reblog_activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -107,8 +104,7 @@ test "Follow notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "follow", - account: - AccountView.render("show.json", %{user: follower, for: followed, skip_relationships: true}), + account: AccountView.render("show.json", %{user: follower, for: followed}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -151,10 +147,8 @@ test "Move notification" do id: to_string(notification.id), pleroma: %{is_seen: false}, type: "move", - account: - AccountView.render("show.json", %{user: old_user, for: follower, skip_relationships: true}), - target: - AccountView.render("show.json", %{user: new_user, for: follower, skip_relationships: true}), + account: AccountView.render("show.json", %{user: old_user, for: follower}), + target: AccountView.render("show.json", %{user: new_user, for: follower}), created_at: Utils.to_masto_date(notification.inserted_at) } @@ -179,8 +173,7 @@ test "EmojiReact notification" do pleroma: %{is_seen: false}, type: "pleroma:emoji_reaction", emoji: "☕", - account: - AccountView.render("show.json", %{user: other_user, for: user, skip_relationships: true}), + account: AccountView.render("show.json", %{user: other_user, for: user}), status: StatusView.render("show.json", %{activity: activity, for: user}), created_at: Utils.to_masto_date(notification.inserted_at) } -- cgit v1.2.3 From b960a9430d5fc396e7484b563f74fab39dbd8345 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 10 May 2020 09:35:12 +0300 Subject: [#2456] credo 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 987b3bcba..616ca52bd 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -31,8 +31,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint - alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.MastodonAPI + alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.OAuth.App alias Pleroma.Web.Router -- cgit v1.2.3 From 1054e897622d0a0727f30169d64c83a253a3d11e Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 12:30:24 +0200 Subject: ChatOperation: Add media id to example --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 8b9dc2e44..16d3d5e22 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -241,7 +241,8 @@ def chat_message_create do }, required: [:content], example: %{ - "content" => "Hey wanna buy feet pics?" + "content" => "Hey wanna buy feet pics?", + "media_id" => "134234" } } end -- cgit v1.2.3 From e297d8c649a03510023cff61dc6e0c7131bc29dc Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 12:34:12 +0200 Subject: Documentation: Add attachment docs --- docs/API/chats.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 8d925989c..3ddc13541 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -160,6 +160,7 @@ Posting a chat message for given Chat id works like this: Parameters: - content: The text content of the message +- media_id: The id of an upload that will be attached to the message. Currently, no formatting beyond basic escaping and emoji is implemented, as well as no attachments. This will most probably change. -- cgit v1.2.3 From f335e1404a9cd19451b531e32e3591aa323761ff Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:00:01 +0200 Subject: ChatView: Add the last message to the view. --- lib/pleroma/chat.ex | 34 ++++++++++++++++++++++ .../web/pleroma_api/controllers/chat_controller.ex | 23 ++------------- lib/pleroma/web/pleroma_api/views/chat_view.ex | 7 ++++- test/web/pleroma_api/views/chat_view_test.exs | 17 ++++++++++- 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 1a092b992..6a03ee3c1 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -4,8 +4,11 @@ defmodule Pleroma.Chat do use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -23,6 +26,37 @@ defmodule Pleroma.Chat do timestamps() end + def last_message_for_chat(chat) do + messages_for_chat_query(chat) + |> order_by(desc: :id) + |> Repo.one() + end + + def messages_for_chat_query(chat) do + chat = + chat + |> Repo.preload(:user) + + from(o in Object, + where: fragment("?->>'type' = ?", o.data, "ChatMessage"), + where: + fragment( + """ + (?->>'actor' = ? and ?->'to' = ?) + OR (?->>'actor' = ? and ?->'to' = ?) + """, + o.data, + ^chat.user.ap_id, + o.data, + ^[chat.recipient], + o.data, + ^chat.recipient, + o.data, + ^[chat.user.ap_id] + ) + ) + end + def creation_cng(struct, params) do struct |> cast(params, [:user_id, :recipient, :unread]) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 450d85332..1ef3477c8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -14,9 +14,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView - import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] - import Ecto.Query + import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] # TODO # - Error handling @@ -65,24 +64,8 @@ def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do messages = - from(o in Object, - where: fragment("?->>'type' = ?", o.data, "ChatMessage"), - where: - fragment( - """ - (?->>'actor' = ? and ?->'to' = ?) - OR (?->>'actor' = ? and ?->'to' = ?) - """, - o.data, - ^user.ap_id, - o.data, - ^[chat.recipient], - o.data, - ^chat.recipient, - o.data, - ^[user.ap_id] - ) - ) + chat + |> Chat.messages_for_chat_query() |> Pagination.fetch_paginated(params |> stringify_keys()) conn diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index bc3af5ef5..21f0612ff 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -8,14 +8,19 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do alias Pleroma.Chat alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.PleromaAPI.ChatMessageView def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) + last_message = Chat.last_message_for_chat(chat) + %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: chat.unread + unread: chat.unread, + last_message: + last_message && ChatMessageView.render("show.json", chat: chat, object: last_message) } end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 1ac3483d1..8568d98c6 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,8 +6,11 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.PleromaAPI.ChatMessageView import Pleroma.Factory @@ -22,7 +25,19 @@ test "it represents a chat" do assert represented_chat == %{ id: "#{chat.id}", account: AccountView.render("show.json", user: recipient), - unread: 0 + unread: 0, + last_message: nil } + + {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") + + chat_message = Object.normalize(chat_message_creation, false) + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + represented_chat = ChatView.render("show.json", chat: chat) + + assert represented_chat[:last_message] == + ChatMessageView.render("show.json", chat: chat, object: chat_message) end end -- cgit v1.2.3 From 17be3ff669865102848df034045eb2889eed3976 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:01:20 +0200 Subject: Documentation: Add last_message to chat docs. --- docs/API/chats.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 3ddc13541..1f6175f77 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -62,7 +62,8 @@ Returned data: ... }, "id" : "1", - "unread" : 2 + "unread" : 2, + "last_message" : {...} // The last message in that chat } ``` @@ -105,7 +106,8 @@ Returned data: ... }, "id" : "1", - "unread" : 2 + "unread" : 2, + "last_message" : {...} // The last message in that chat } ] ``` -- cgit v1.2.3 From 172d9b11936bb029093eac430c58c89a81592c08 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:08:01 +0200 Subject: Chat: Add last_message to schema. --- lib/pleroma/web/api_spec/schemas/chat.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index 4d385d6ab..8aaa4a792 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ChatMessage require OpenApiSpex @@ -12,9 +13,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do description: "Response schema for a Chat", type: :object, properties: %{ - id: %Schema{type: :string, nullable: false}, - account: %Schema{type: :object, nullable: false}, - unread: %Schema{type: :integer, nullable: false} + id: %Schema{type: :string}, + account: %Schema{type: :object}, + unread: %Schema{type: :integer}, + last_message: %Schema{type: ChatMessage, nullable: true} }, example: %{ "account" => %{ @@ -64,7 +66,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do "url" => "https://dontbulling.me/users/lain" }, "id" => "1", - "unread" => 2 + "unread" => 2, + "last_message" => ChatMessage.schema().example() } }) end -- cgit v1.2.3 From 8d5597ff68de22ee7b126730467649ada248aaf7 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:26:14 +0200 Subject: ChatController: Add GET /chats/:id --- .../web/api_spec/operations/chat_operation.ex | 31 ++++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/chat.ex | 2 +- lib/pleroma/web/api_spec/schemas/chat_message.ex | 1 + .../web/pleroma_api/controllers/chat_controller.ex | 10 ++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 17 ++++++++++++ 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 16d3d5e22..fe6c2f52f 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -38,6 +38,37 @@ def mark_as_read_operation do } end + def show_operation do + %Operation{ + tags: ["chat"], + summary: "Create a chat", + operationId: "ChatController.show", + parameters: [ + Operation.parameter( + :id, + :path, + :string, + "The id of the chat", + required: true, + example: "1234" + ) + ], + responses: %{ + 200 => + Operation.response( + "The existing chat", + "application/json", + Chat + ) + }, + security: [ + %{ + "oAuth" => ["read"] + } + ] + } + end + def create_operation do %Operation{ tags: ["chat"], diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index 8aaa4a792..c6ec07c88 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do id: %Schema{type: :string}, account: %Schema{type: :object}, unread: %Schema{type: :integer}, - last_message: %Schema{type: ChatMessage, nullable: true} + last_message: ChatMessage }, example: %{ "account" => %{ diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 89e062ddd..6e8f1a10a 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do OpenApiSpex.schema(%{ title: "ChatMessage", description: "Response schema for a ChatMessage", + nullable: true, type: :object, properties: %{ id: %Schema{type: :string}, diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 1ef3477c8..04f136dcd 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["read:statuses"]} when action in [:messages, :index] + %{scopes: ["read:statuses"]} when action in [:messages, :index, :show] ) plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) @@ -100,4 +100,12 @@ def create(%{assigns: %{user: user}} = conn, params) do |> render("show.json", chat: chat) end end + + def show(%{assigns: %{user: user}} = conn, params) do + with %Chat{} = chat <- Repo.get_by(Chat, user_id: user.id, id: params[:id]) do + conn + |> put_view(ChatView) + |> render("show.json", chat: chat) + end + end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 4b264c43e..3b1834d97 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -307,6 +307,7 @@ defmodule Pleroma.Web.Router do post("/chats/by-account-id/:id", ChatController, :create) get("/chats", ChatController, :index) + get("/chats/:id", ChatController, :show) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) post("/chats/:id/read", ChatController, :mark_as_read) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index b4b73da90..dda4f9e5b 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -153,6 +153,23 @@ test "it creates or returns a chat", %{conn: conn} do end end + describe "GET /api/v1/pleroma/chats/:id" do + setup do: oauth_access(["read:statuses"]) + + test "it returns a chat", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> get("/api/v1/pleroma/chats/#{chat.id}") + |> json_response_and_validate_schema(200) + + assert result["id"] == to_string(chat.id) + end + end + describe "GET /api/v1/pleroma/chats" do setup do: oauth_access(["read:statuses"]) -- cgit v1.2.3 From 8cc8d960af87cdc1e2398a8470155b0930f43f5c Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:27:40 +0200 Subject: Documentation: Add GET /chats/:id --- docs/API/chats.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 1f6175f77..ed160abd9 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -43,12 +43,17 @@ you can call: `POST /api/v1/pleroma/chats/by-account-id/{account_id}` -The account id is the normal FlakeId of the usre - +The account id is the normal FlakeId of the user ``` POST /api/v1/pleroma/chats/by-account-id/someflakeid ``` +If you already have the id of a chat, you can also use + +``` +GET /api/v1/pleroma/chats/:id +``` + There will only ever be ONE Chat for you and a given recipient, so this call will return the same Chat if you already have one with that user. -- cgit v1.2.3 From 1b1dfb54eb092921fe9dab2c49928e5b04fa049b Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 10 May 2020 13:28:05 +0200 Subject: Credo fixes. --- test/web/pleroma_api/views/chat_view_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 8568d98c6..e24e29835 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -9,8 +9,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory -- cgit v1.2.3 From da6a38d2053ad978a576378555c06fe9adf56b5e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 11 May 2020 09:35:20 +0300 Subject: copy/paste fix for descriptions --- config/description.exs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/description.exs b/config/description.exs index 0b99c748c..36ec3d40a 100644 --- a/config/description.exs +++ b/config/description.exs @@ -29,7 +29,7 @@ key: :filters, type: {:list, :module}, description: - "List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom MRF module you need to use full name.", + "List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom module you need to use full name.", suggestions: Generator.list_modules_in_dir( "lib/pleroma/upload/filter", @@ -683,7 +683,7 @@ key: :federation_publisher_modules, type: {:list, :module}, description: - "List of modules for federation publishing. Module names are shortened (removed leading `Pleroma.Web.` part), but on adding custom MRF module you need to use full name.", + "List of modules for federation publishing. Module names are shortened (removed leading `Pleroma.Web.` part), but on adding custom module you need to use full name.", suggestions: [ Pleroma.Web.ActivityPub.Publisher ] @@ -697,7 +697,7 @@ key: :rewrite_policy, type: [:module, {:list, :module}], description: - "A list of enabled MRF policies. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom MRF module you need to use full name.", + "A list of enabled MRF policies. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", suggestions: Generator.list_modules_in_dir( "lib/pleroma/web/activity_pub/mrf", @@ -2035,7 +2035,7 @@ key: :parsers, type: {:list, :module}, description: - "List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom MRF module you need to use full name.", + "List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom module you need to use full name.", suggestions: [ Pleroma.Web.RichMedia.Parsers.MetaTagsParser, Pleroma.Web.RichMedia.Parsers.OEmbed, @@ -2048,7 +2048,7 @@ label: "TTL setters", type: {:list, :module}, description: - "List of rich media TTL setters. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parser.` part), but on adding custom MRF module you need to use full name.", + "List of rich media TTL setters. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parser.` part), but on adding custom module you need to use full name.", suggestions: [ Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl ] @@ -2723,7 +2723,7 @@ key: :scrub_policy, type: {:list, :module}, description: - "Module names are shortened (removed leading `Pleroma.HTML.` part), but on adding custom MRF module you need to use full name.", + "Module names are shortened (removed leading `Pleroma.HTML.` part), but on adding custom module you need to use full name.", suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default] } ] -- cgit v1.2.3 From fdb98715b8e6ced7c4037b1292fb10980a994803 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 10:58:14 +0200 Subject: Chat: Fix wrong query. --- lib/pleroma/chat.ex | 1 + test/chat_test.exs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 6a03ee3c1..4c92a58c7 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -29,6 +29,7 @@ defmodule Pleroma.Chat do def last_message_for_chat(chat) do messages_for_chat_query(chat) |> order_by(desc: :id) + |> limit(1) |> Repo.one() end diff --git a/test/chat_test.exs b/test/chat_test.exs index 943e48111..dfcb6422e 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -6,9 +6,26 @@ defmodule Pleroma.ChatTest do use Pleroma.DataCase, async: true alias Pleroma.Chat + alias Pleroma.Web.CommonAPI import Pleroma.Factory + describe "messages" do + test "it returns the last message in a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") + {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + message = Chat.last_message_for_chat(chat) + + assert message.data["content"] == "ho" + end + end + describe "creation and getting" do test "it only works if the recipient is a valid user (for now)" do user = insert(:user) -- cgit v1.2.3 From 3bde0fa3f668d42c03ce83174325920551960de3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 11 May 2020 15:24:59 +0400 Subject: Add OpenAPI spec for TimelineController --- .../web/api_spec/operations/timeline_operation.ex | 199 +++++++++++++++++++++ .../controllers/timeline_controller.ex | 18 +- mix.exs | 2 +- mix.lock | 2 +- .../controllers/timeline_controller_test.exs | 114 ++++++------ 5 files changed, 271 insertions(+), 64 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/timeline_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex new file mode 100644 index 000000000..1b89035d4 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -0,0 +1,199 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.TimelineOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def home_operation do + %Operation{ + tags: ["Timelines"], + summary: "Home timeline", + description: "View statuses from followed users", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + local_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param(), + with_relationships_param() | pagination_params() + ], + operationId: "TimelineController.home", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def direct_operation do + %Operation{ + tags: ["Timelines"], + summary: "Direct timeline", + description: + "View statuses with a “direct” privacy, from your account or in your notifications", + deprecated: true, + parameters: pagination_params(), + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "TimelineController.direct", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def public_operation do + %Operation{ + tags: ["Timelines"], + summary: "Public timeline", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + local_param(), + only_media_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param(), + with_relationships_param() | pagination_params() + ], + operationId: "TimelineController.public", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def hashtag_operation do + %Operation{ + tags: ["Timelines"], + summary: "Hashtag timeline", + description: "View public statuses containing the given hashtag", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :tag, + :path, + %Schema{type: :string}, + "Content of a #hashtag, not including # symbol.", + required: true + ), + Operation.parameter( + :any, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that also includes any of these tags" + ), + Operation.parameter( + :all, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that also includes all of these tags" + ), + Operation.parameter( + :none, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Statuses that do not include these tags" + ), + local_param(), + only_media_param(), + with_muted_param(), + exclude_visibilities_param(), + with_relationships_param() | pagination_params() + ], + operationId: "TimelineController.hashtag", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def list_operation do + %Operation{ + tags: ["Timelines"], + summary: "List timeline", + description: "View statuses in the given list timeline", + security: [%{"oAuth" => ["read:lists"]}], + parameters: [ + Operation.parameter( + :list_id, + :path, + %Schema{type: :string}, + "Local ID of the list in the database", + required: true + ), + with_muted_param(), + exclude_visibilities_param(), + with_relationships_param() | pagination_params() + ], + operationId: "TimelineController.list", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + defp array_of_statuses do + %Schema{ + title: "ArrayOfStatuses", + type: :array, + items: Status, + example: [Status.schema().example] + } + end + + defp with_relationships_param do + Operation.parameter(:with_relationships, :query, BooleanLike, "Include relationships") + end + + defp local_param do + Operation.parameter( + :local, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only local statuses?" + ) + end + + defp with_muted_param do + Operation.parameter(:with_muted, :query, BooleanLike, "Includeactivities by muted users") + end + + defp exclude_visibilities_param do + Operation.parameter( + :exclude_visibilities, + :query, + %Schema{type: :array, items: VisibilityScope}, + "Exclude the statuses with the given visibilities" + ) + end + + defp reply_visibility_param do + Operation.parameter( + :reply_visibility, + :query, + %Schema{type: :string, enum: ["following", "self"]}, + "Filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you." + ) + end + + defp only_media_param do + Operation.parameter( + :only_media, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Show only statuses with media attached?" + ) + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 2d67e19da..bbd576ffd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1, skip_relationships?: 1] + only: [add_link_headers: 2, add_link_headers: 3, skip_relationships?: 1] alias Pleroma.Pagination alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug @@ -15,6 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:public, :hashtag]) # TODO: Replace with a macro when there is a Phoenix release with the following commit in it: @@ -37,10 +38,13 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation + # GET /api/v1/timelines/home def home(%{assigns: %{user: user}} = conn, params) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", ["Create", "Announce"]) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) @@ -68,6 +72,7 @@ def home(%{assigns: %{user: user}} = conn, params) do def direct(%{assigns: %{user: user}} = conn, params) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Create") |> Map.put("blocking_user", user) |> Map.put("user", user) @@ -90,7 +95,9 @@ def direct(%{assigns: %{user: user}} = conn, params) do # GET /api/v1/timelines/public def public(%{assigns: %{user: user}} = conn, params) do - local_only = truthy_param?(params["local"]) + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + + local_only = params["local"] cfg_key = if local_only do @@ -157,8 +164,8 @@ defp hashtag_fetching(params, user, local_only) do # GET /api/v1/timelines/tag/:tag def hashtag(%{assigns: %{user: user}} = conn, params) do - local_only = truthy_param?(params["local"]) - + params = Map.new(params, fn {key, value} -> {to_string(key), value} end) + local_only = params["local"] activities = hashtag_fetching(params, user, local_only) conn @@ -172,10 +179,11 @@ def hashtag(%{assigns: %{user: user}} = conn, params) do end # GET /api/v1/timelines/list/:list_id - def list(%{assigns: %{user: user}} = conn, %{"list_id" => id} = params) do + def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) do with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Create") |> Map.put("blocking_user", user) |> Map.put("user", user) diff --git a/mix.exs b/mix.exs index 97b561790..7f499856f 100644 --- a/mix.exs +++ b/mix.exs @@ -200,7 +200,7 @@ defp deps do {:restarter, path: "./restarter"}, {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", - ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"} + ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock index c400202b7..c5e3af03f 100644 --- a/mix.lock +++ b/mix.lock @@ -74,7 +74,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, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, - "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, + "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "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/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..5e0d92f28 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -34,7 +34,7 @@ test "does NOT render account/pleroma/relationship if this is disabled by defaul conn |> assign(:user, user) |> get("/api/v1/timelines/home") - |> json_response(200) + |> json_response_and_validate_schema(200) assert Enum.all?(response, fn n -> get_in(n, ["account", "pleroma", "relationship"]) == %{} @@ -42,7 +42,7 @@ test "does NOT render account/pleroma/relationship if this is disabled by defaul end test "the home timeline", %{user: user, conn: conn} do - uri = "/api/v1/timelines/home?with_relationships=true" + uri = "/api/v1/timelines/home?with_relationships=1" following = insert(:user, nickname: "followed") third_user = insert(:user, nickname: "repeated") @@ -53,7 +53,7 @@ test "the home timeline", %{user: user, conn: conn} do ret_conn = get(conn, uri) - assert Enum.empty?(json_response(ret_conn, :ok)) + assert Enum.empty?(json_response_and_validate_schema(ret_conn, :ok)) {:ok, _user} = User.follow(user, following) @@ -78,7 +78,7 @@ test "the home timeline", %{user: user, conn: conn} do "pleroma" => %{"relationship" => %{"following" => true}} } } - ] = json_response(ret_conn, :ok) + ] = json_response_and_validate_schema(ret_conn, :ok) {:ok, _user} = User.follow(third_user, user) @@ -104,7 +104,7 @@ test "the home timeline", %{user: user, conn: conn} do "pleroma" => %{"relationship" => %{"following" => true}} } } - ] = json_response(ret_conn, :ok) + ] = json_response_and_validate_schema(ret_conn, :ok) end test "the home timeline when the direct messages are excluded", %{user: user, conn: conn} do @@ -117,9 +117,9 @@ test "the home timeline when the direct messages are excluded", %{user: user, co {:ok, private_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) - conn = get(conn, "/api/v1/timelines/home", %{"exclude_visibilities" => ["direct"]}) + conn = get(conn, "/api/v1/timelines/home?exclude_visibilities[]=direct") - assert status_ids = json_response(conn, :ok) |> Enum.map(& &1["id"]) + assert status_ids = json_response_and_validate_schema(conn, :ok) |> Enum.map(& &1["id"]) assert public_activity.id in status_ids assert unlisted_activity.id in status_ids assert private_activity.id in status_ids @@ -136,17 +136,17 @@ test "the public timeline", %{conn: conn} do _activity = insert(:note_activity, local: false) - conn = get(conn, "/api/v1/timelines/public", %{"local" => "False"}) + conn = get(conn, "/api/v1/timelines/public?local=False") - assert length(json_response(conn, :ok)) == 2 + assert length(json_response_and_validate_schema(conn, :ok)) == 2 - conn = get(build_conn(), "/api/v1/timelines/public", %{"local" => "True"}) + conn = get(build_conn(), "/api/v1/timelines/public?local=True") - assert [%{"content" => "test"}] = json_response(conn, :ok) + assert [%{"content" => "test"}] = json_response_and_validate_schema(conn, :ok) - conn = get(build_conn(), "/api/v1/timelines/public", %{"local" => "1"}) + conn = get(build_conn(), "/api/v1/timelines/public?local=1") - assert [%{"content" => "test"}] = json_response(conn, :ok) + assert [%{"content" => "test"}] = json_response_and_validate_schema(conn, :ok) end test "the public timeline includes only public statuses for an authenticated user" do @@ -158,7 +158,7 @@ test "the public timeline includes only public statuses for an authenticated use {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"}) res_conn = get(conn, "/api/v1/timelines/public") - assert length(json_response(res_conn, 200)) == 1 + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end end @@ -176,15 +176,15 @@ defp local_and_remote_activities do setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true) test "if user is unauthenticated", %{conn: conn} do - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + res_conn = get(conn, "/api/v1/timelines/public?local=true") - assert json_response(res_conn, :unauthorized) == %{ + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ "error" => "authorization required for timeline view" } - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + res_conn = get(conn, "/api/v1/timelines/public?local=false") - assert json_response(res_conn, :unauthorized) == %{ + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ "error" => "authorization required for timeline view" } end @@ -192,11 +192,11 @@ test "if user is unauthenticated", %{conn: conn} do test "if user is authenticated" do %{conn: conn} = oauth_access(["read:statuses"]) - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) - assert length(json_response(res_conn, 200)) == 1 + res_conn = get(conn, "/api/v1/timelines/public?local=true") + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) - assert length(json_response(res_conn, 200)) == 2 + res_conn = get(conn, "/api/v1/timelines/public?local=false") + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end end @@ -206,24 +206,24 @@ test "if user is authenticated" do setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true) test "if user is unauthenticated", %{conn: conn} do - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) + res_conn = get(conn, "/api/v1/timelines/public?local=true") - assert json_response(res_conn, :unauthorized) == %{ + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ "error" => "authorization required for timeline view" } - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) - assert length(json_response(res_conn, 200)) == 2 + res_conn = get(conn, "/api/v1/timelines/public?local=false") + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end test "if user is authenticated", %{conn: _conn} do %{conn: conn} = oauth_access(["read:statuses"]) - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) - assert length(json_response(res_conn, 200)) == 1 + res_conn = get(conn, "/api/v1/timelines/public?local=true") + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) - assert length(json_response(res_conn, 200)) == 2 + res_conn = get(conn, "/api/v1/timelines/public?local=false") + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end end @@ -233,12 +233,12 @@ test "if user is authenticated", %{conn: _conn} do setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true) test "if user is unauthenticated", %{conn: conn} do - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) - assert length(json_response(res_conn, 200)) == 1 + res_conn = get(conn, "/api/v1/timelines/public?local=true") + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) + res_conn = get(conn, "/api/v1/timelines/public?local=false") - assert json_response(res_conn, :unauthorized) == %{ + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ "error" => "authorization required for timeline view" } end @@ -246,11 +246,11 @@ test "if user is unauthenticated", %{conn: conn} do test "if user is authenticated", %{conn: _conn} do %{conn: conn} = oauth_access(["read:statuses"]) - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "true"}) - assert length(json_response(res_conn, 200)) == 1 + res_conn = get(conn, "/api/v1/timelines/public?local=true") + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - res_conn = get(conn, "/api/v1/timelines/public", %{"local" => "false"}) - assert length(json_response(res_conn, 200)) == 2 + res_conn = get(conn, "/api/v1/timelines/public?local=false") + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end end @@ -281,7 +281,7 @@ test "direct timeline", %{conn: conn} do # Only direct should be visible here res_conn = get(conn_user_two, "api/v1/timelines/direct") - [status] = json_response(res_conn, :ok) + assert [status] = json_response_and_validate_schema(res_conn, :ok) assert %{"visibility" => "direct"} = status assert status["url"] != direct.data["id"] @@ -293,14 +293,14 @@ test "direct timeline", %{conn: conn} do |> assign(:token, insert(:oauth_token, user: user_one, scopes: ["read:statuses"])) |> get("api/v1/timelines/direct") - [status] = json_response(res_conn, :ok) + [status] = json_response_and_validate_schema(res_conn, :ok) assert %{"visibility" => "direct"} = status # Both should be visible here res_conn = get(conn_user_two, "api/v1/timelines/home") - [_s1, _s2] = json_response(res_conn, :ok) + [_s1, _s2] = json_response_and_validate_schema(res_conn, :ok) # Test pagination Enum.each(1..20, fn _ -> @@ -313,13 +313,14 @@ test "direct timeline", %{conn: conn} do res_conn = get(conn_user_two, "api/v1/timelines/direct") - statuses = json_response(res_conn, :ok) + statuses = json_response_and_validate_schema(res_conn, :ok) assert length(statuses) == 20 - res_conn = - get(conn_user_two, "api/v1/timelines/direct", %{max_id: List.last(statuses)["id"]}) + max_id = List.last(statuses)["id"] + + res_conn = get(conn_user_two, "api/v1/timelines/direct?max_id=#{max_id}") - [status] = json_response(res_conn, :ok) + assert [status] = json_response_and_validate_schema(res_conn, :ok) assert status["url"] != direct.data["id"] end @@ -344,7 +345,7 @@ test "doesn't include DMs from blocked users" do res_conn = get(conn, "api/v1/timelines/direct") - [status] = json_response(res_conn, :ok) + [status] = json_response_and_validate_schema(res_conn, :ok) assert status["id"] == direct.id end end @@ -361,7 +362,7 @@ test "list timeline", %{user: user, conn: conn} do conn = get(conn, "/api/v1/timelines/list/#{list.id}") - assert [%{"id" => id}] = json_response(conn, :ok) + assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok) assert id == to_string(activity_two.id) end @@ -384,7 +385,7 @@ test "list timeline does not leak non-public statuses for unfollowed users", %{ conn = get(conn, "/api/v1/timelines/list/#{list.id}") - assert [%{"id" => id}] = json_response(conn, :ok) + assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok) assert id == to_string(activity_one.id) end @@ -401,14 +402,14 @@ test "hashtag timeline", %{conn: conn} do nconn = get(conn, "/api/v1/timelines/tag/2hu") - assert [%{"id" => id}] = json_response(nconn, :ok) + assert [%{"id" => id}] = json_response_and_validate_schema(nconn, :ok) assert id == to_string(activity.id) # works for different capitalization too nconn = get(conn, "/api/v1/timelines/tag/2HU") - assert [%{"id" => id}] = json_response(nconn, :ok) + assert [%{"id" => id}] = json_response_and_validate_schema(nconn, :ok) assert id == to_string(activity.id) end @@ -420,22 +421,21 @@ test "multi-hashtag timeline", %{conn: conn} do {:ok, activity_test1} = CommonAPI.post(user, %{"status" => "#test #test1"}) {:ok, activity_none} = CommonAPI.post(user, %{"status" => "#test #none"}) - any_test = get(conn, "/api/v1/timelines/tag/test", %{"any" => ["test1"]}) + any_test = get(conn, "/api/v1/timelines/tag/test?any[]=test1") - [status_none, status_test1, status_test] = json_response(any_test, :ok) + [status_none, status_test1, status_test] = json_response_and_validate_schema(any_test, :ok) assert to_string(activity_test.id) == status_test["id"] assert to_string(activity_test1.id) == status_test1["id"] assert to_string(activity_none.id) == status_none["id"] - restricted_test = - get(conn, "/api/v1/timelines/tag/test", %{"all" => ["test1"], "none" => ["none"]}) + restricted_test = get(conn, "/api/v1/timelines/tag/test?all[]=test1&none[]=none") - assert [status_test1] == json_response(restricted_test, :ok) + assert [status_test1] == json_response_and_validate_schema(restricted_test, :ok) - all_test = get(conn, "/api/v1/timelines/tag/test", %{"all" => ["none"]}) + all_test = get(conn, "/api/v1/timelines/tag/test?all[]=none") - assert [status_none] == json_response(all_test, :ok) + assert [status_none] == json_response_and_validate_schema(all_test, :ok) end end end -- cgit v1.2.3 From 5367a00257c6f862a4a8080e0176f676ce491e4d Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 15:06:23 +0200 Subject: Deletion: Handle the case of pruned objects. --- lib/pleroma/user.ex | 19 +++++++++++--- lib/pleroma/web/activity_pub/builder.ex | 10 ++++++++ .../object_validators/delete_validator.ex | 1 + lib/pleroma/web/activity_pub/transmogrifier.ex | 15 +++++++++++ lib/pleroma/web/common_api/common_api.ex | 29 ++++++++++++++++++---- test/tasks/user_test.exs | 25 +++++++++++++++++++ test/web/activity_pub/side_effects_test.exs | 29 ++++++++++++++++++++++ .../transmogrifier/delete_handling_test.exs | 28 +++++++++++++++++++++ test/web/common_api/common_api_test.exs | 18 ++++++++++++++ 9 files changed, 166 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 2a6a23fec..a86cc3202 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1554,10 +1554,23 @@ def delete_user_activities(%User{ap_id: ap_id} = user) do |> Stream.run() end - defp delete_activity(%{data: %{"type" => "Create", "object" => object}}, user) do - {:ok, delete_data, _} = Builder.delete(user, object) + defp delete_activity(%{data: %{"type" => "Create", "object" => object}} = activity, user) do + with {_, %Object{}} <- {:find_object, Object.get_by_ap_id(object)}, + {:ok, delete_data, _} <- Builder.delete(user, object) do + Pipeline.common_pipeline(delete_data, local: user.local) + else + {:find_object, nil} -> + # We have the create activity, but not the object, it was probably pruned. + # Insert a tombstone and try again + with {:ok, tombstone_data, _} <- Builder.tombstone(user.ap_id, object), + {:ok, _tombstone} <- Object.create(tombstone_data) do + delete_activity(activity, user) + end - Pipeline.common_pipeline(delete_data, local: user.local) + e -> + Logger.error("Could not delete #{object} created by #{activity.data["ap_id"]}") + Logger.error("Error: #{inspect(e)}") + end end defp delete_activity(%{data: %{"type" => type}} = activity, user) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 922a444a9..4a247ad0c 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -62,6 +62,16 @@ def delete(actor, object_id) do }, []} end + @spec tombstone(String.t(), String.t()) :: {:ok, map(), keyword()} + def tombstone(actor, id) do + {:ok, + %{ + "id" => id, + "actor" => actor, + "type" => "Tombstone" + }, []} + end + @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} def like(actor, object) do with {:ok, data, meta} <- object_action(actor, object) do diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index e06de3dff..f42c03510 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -51,6 +51,7 @@ def add_deleted_activity_id(cng) do Page Question Video + Tombstone } def validate_data(cng) do cng diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index be7b57f13..921576617 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -14,7 +14,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -720,6 +722,19 @@ def handle_incoming( ) do with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} + else + {:error, {:validate_object, _}} = e -> + # Check if we have a create activity for this + with {:ok, object_id} <- Types.ObjectID.cast(data["object"]), + %Activity{data: %{"actor" => actor}} <- + Activity.create_by_object_ap_id(object_id) |> Repo.one(), + # We have one, insert a tombstone and retry + {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id), + {:ok, _tombstone} <- Object.create(tombstone_data) do + handle_incoming(data) + else + _ -> e + end end end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c538a634f..fbef05e83 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -83,16 +83,35 @@ def reject_follow_request(follower, followed) do end def delete(activity_id, user) do - with {_, %Activity{data: %{"object" => _}} = activity} <- - {:find_activity, Activity.get_by_id_with_object(activity_id)}, - %Object{} = object <- Object.normalize(activity), + with {_, %Activity{data: %{"object" => _, "type" => "Create"}} = activity} <- + {:find_activity, Activity.get_by_id(activity_id)}, + {_, %Object{} = object, _} <- + {:find_object, Object.normalize(activity, false), activity}, true <- User.superuser?(user) || user.ap_id == object.data["actor"], {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do {:ok, delete} else - {:find_activity, _} -> {:error, :not_found} - _ -> {:error, dgettext("errors", "Could not delete")} + {:find_activity, _} -> + {:error, :not_found} + + {:find_object, nil, %Activity{data: %{"actor" => actor, "object" => object}}} -> + # We have the create activity, but not the object, it was probably pruned. + # Insert a tombstone and try again + with {:ok, tombstone_data, _} <- Builder.tombstone(actor, object), + {:ok, _tombstone} <- Object.create(tombstone_data) do + delete(activity_id, user) + else + _ -> + Logger.error( + "Could not insert tombstone for missing object on deletion. Object is #{object}." + ) + + {:error, dgettext("errors", "Could not delete")} + end + + _ -> + {:error, dgettext("errors", "Could not delete")} end end diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index e0fee7290..b4f68d494 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -3,9 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.UserTest do + alias Pleroma.Activity + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.Token @@ -103,6 +106,28 @@ test "user is deleted" do end end + test "a remote user's create activity is deleted when the object has been pruned" do + user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{"status" => "uguu"}) + object = Object.normalize(post) + Object.prune(object) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + Mix.Tasks.Pleroma.User.run(["rm", user.nickname]) + ObanHelpers.perform_all() + + assert_received {:mix_shell, :info, [message]} + assert message =~ " deleted" + assert %{deactivated: true} = User.get_by_nickname(user.nickname) + + assert called(Pleroma.Web.Federator.publish(:_)) + end + + refute Activity.get_by_id(post.id) + end + test "no user to delete" do Mix.Tasks.Pleroma.User.run(["rm", "nonexistent"]) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b29a7a7be..aa3e40be1 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -64,6 +64,35 @@ test "it handles object deletions", %{ assert object.data["repliesCount"] == 0 end + test "it handles object deletions when the object itself has been pruned", %{ + delete: delete, + post: post, + object: object, + user: user, + op: op + } do + with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough], + stream_out: fn _ -> nil end, + stream_out_participations: fn _, _ -> nil end do + {:ok, delete, _} = SideEffects.handle(delete) + user = User.get_cached_by_ap_id(object.data["actor"]) + + assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(delete)) + assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out_participations(object, user)) + end + + object = Object.get_by_id(object.id) + assert object.data["type"] == "Tombstone" + refute Activity.get_by_id(post.id) + + user = User.get_by_id(user.id) + assert user.note_count == 0 + + object = Object.normalize(op.data["object"], false) + + assert object.data["repliesCount"] == 0 + end + test "it handles user deletions", %{delete_user: delete, user: user} do {:ok, _delete, _} = SideEffects.handle(delete) ObanHelpers.perform_all() diff --git a/test/web/activity_pub/transmogrifier/delete_handling_test.exs b/test/web/activity_pub/transmogrifier/delete_handling_test.exs index f235a8e63..c9a53918c 100644 --- a/test/web/activity_pub/transmogrifier/delete_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/delete_handling_test.exs @@ -44,6 +44,34 @@ test "it works for incoming deletes" do assert object.data["type"] == "Tombstone" end + test "it works for incoming when the object has been pruned" do + activity = insert(:note_activity) + + {:ok, object} = + Object.normalize(activity.data["object"]) + |> Repo.delete() + + Cachex.del(:object_cache, "object:#{object.data["id"]}") + + deleting_user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Poison.decode!() + |> Map.put("actor", deleting_user.ap_id) + |> put_in(["object", "id"], activity.data["object"]) + + {:ok, %Activity{actor: actor, local: false, data: %{"id" => id}}} = + Transmogrifier.handle_incoming(data) + + assert id == data["id"] + + # We delete the Create activity because we base our timelines on it. + # This should be changed after we unify objects and activities + refute Activity.get_by_id(activity.id) + assert actor == deleting_user.ap_id + end + test "it fails for incoming deletes with spoofed origin" do activity = insert(:note_activity) %{ap_id: ap_id} = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo") diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 2fd17a1b8..c524d1c0c 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -24,6 +24,24 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :max_pinned_statuses]) describe "deletion" do + test "it works with pruned objects" do + user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + + Object.normalize(post, false) + |> Object.prune() + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, delete} = CommonAPI.delete(post.id, user) + assert delete.local + assert called(Pleroma.Web.Federator.publish(delete)) + end + + refute Activity.get_by_id(post.id) + end + test "it allows users to delete their posts" do user = insert(:user) -- cgit v1.2.3 From 679afb2de40b532d749485ca6f27656fc5b2f25f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 15:38:19 +0200 Subject: SideEffects test: Add test for favorite deletion. --- test/web/activity_pub/side_effects_test.exs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index aa3e40be1..6c5f8fc61 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -27,12 +27,22 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do {:ok, op} = CommonAPI.post(other_user, %{"status" => "big oof"}) {:ok, post} = CommonAPI.post(user, %{"status" => "hey", "in_reply_to_id" => op}) + {:ok, favorite} = CommonAPI.favorite(user, post.id) object = Object.normalize(post) {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"]) {:ok, delete_user_data, _meta} = Builder.delete(user, user.ap_id) {:ok, delete, _meta} = ActivityPub.persist(delete_data, local: true) {:ok, delete_user, _meta} = ActivityPub.persist(delete_user_data, local: true) - %{user: user, delete: delete, post: post, object: object, delete_user: delete_user, op: op} + + %{ + user: user, + delete: delete, + post: post, + object: object, + delete_user: delete_user, + op: op, + favorite: favorite + } end test "it handles object deletions", %{ @@ -40,7 +50,8 @@ test "it handles object deletions", %{ post: post, object: object, user: user, - op: op + op: op, + favorite: favorite } do with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough], stream_out: fn _ -> nil end, @@ -55,6 +66,7 @@ test "it handles object deletions", %{ object = Object.get_by_id(object.id) assert object.data["type"] == "Tombstone" refute Activity.get_by_id(post.id) + refute Activity.get_by_id(favorite.id) user = User.get_by_id(user.id) assert user.note_count == 0 -- cgit v1.2.3 From e2b15e8ad31f637cbbc53b3bbc2db0972800fc88 Mon Sep 17 00:00:00 2001 From: href Date: Mon, 11 May 2020 16:28:53 +0200 Subject: Fix streamer timeout (closes #1753). Cowboy handles automatically responding to the client's ping, but doesn't automatically send a :ping frame to the client. --- lib/pleroma/web/mastodon_api/websocket_handler.ex | 30 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index e2ffd02d0..393d093e5 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -12,8 +12,10 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do @behaviour :cowboy_websocket + # Client ping period. + @tick :timer.seconds(30) # Cowboy timeout period. - @timeout :timer.seconds(30) + @timeout :timer.seconds(60) # Hibernate every X messages @hibernate_every 100 @@ -44,7 +46,8 @@ def init(%{qs: qs} = req, state) do req end - {:cowboy_websocket, req, %{user: user, topic: topic, count: 0}, %{idle_timeout: @timeout}} + {:cowboy_websocket, req, %{user: user, topic: topic, count: 0, timer: nil}, + %{idle_timeout: @timeout}} else {:error, code} -> Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}") @@ -66,11 +69,18 @@ def websocket_init(state) do ) Streamer.add_socket(state.topic, state.user) - {:ok, state} + {:ok, %{state | timer: timer()}} + end + + # Client's Pong frame. + def websocket_handle(:pong, state) do + if state.timer, do: Process.cancel_timer(state.timer) + {:ok, %{state | timer: timer()}} end # We never receive messages. - def websocket_handle(_frame, state) do + def websocket_handle(frame, state) do + Logger.error("#{__MODULE__} received frame: #{inspect(frame)}") {:ok, state} end @@ -94,6 +104,14 @@ def websocket_info({:text, message}, state) do end end + # Ping tick. We don't re-queue a timer there, it is instead queued when :pong is received. + # As we hibernate there, reset the count to 0. + # If the client misses :pong, Cowboy will automatically timeout the connection after + # `@idle_timeout`. + def websocket_info(:tick, state) do + {:reply, :ping, %{state | timer: nil, count: 0}, :hibernate} + end + def terminate(reason, _req, state) do Logger.debug( "#{__MODULE__} terminating websocket connection for user #{ @@ -149,4 +167,8 @@ defp expand_topic("list", params) do end defp expand_topic(topic, _), do: topic + + defp timer do + Process.send_after(self(), :tick, @tick) + end end -- cgit v1.2.3 From d8dd945a0319692b05370f16f289d8a231217ee6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 21:52:47 +0200 Subject: Markers migration: Fix migration for very large list of markers --- priv/repo/migrations/20200415181818_update_markers.exs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/priv/repo/migrations/20200415181818_update_markers.exs b/priv/repo/migrations/20200415181818_update_markers.exs index 976363565..b7c611333 100644 --- a/priv/repo/migrations/20200415181818_update_markers.exs +++ b/priv/repo/migrations/20200415181818_update_markers.exs @@ -32,9 +32,13 @@ defp update_markers do |> Map.put_new(:updated_at, now) end) - Repo.insert_all("markers", markers_attrs, - on_conflict: {:replace, [:last_read_id]}, - conflict_target: [:user_id, :timeline] - ) + markers_attrs + |> Enum.chunk(1000) + |> Enum.each(fn marker_attrs -> + Repo.insert_all("markers", markers_attrs, + on_conflict: {:replace, [:last_read_id]}, + conflict_target: [:user_id, :timeline] + ) + end) end end -- cgit v1.2.3 From f71376e30ed34b1fa8a7997dd6f1ea0ae76ed5dd Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 22:00:01 +0200 Subject: Migration: Enum.chunk is deprecated. --- priv/repo/migrations/20200415181818_update_markers.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200415181818_update_markers.exs b/priv/repo/migrations/20200415181818_update_markers.exs index b7c611333..d85bd04e0 100644 --- a/priv/repo/migrations/20200415181818_update_markers.exs +++ b/priv/repo/migrations/20200415181818_update_markers.exs @@ -33,7 +33,7 @@ defp update_markers do end) markers_attrs - |> Enum.chunk(1000) + |> Enum.chunk_every(1000) |> Enum.each(fn marker_attrs -> Repo.insert_all("markers", markers_attrs, on_conflict: {:replace, [:last_read_id]}, -- cgit v1.2.3 From f6aa0b4a0792e7c69af6e7008a2ba354ca26adf4 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 11 May 2020 22:03:29 +0200 Subject: Migration: Fix typo --- priv/repo/migrations/20200415181818_update_markers.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/priv/repo/migrations/20200415181818_update_markers.exs b/priv/repo/migrations/20200415181818_update_markers.exs index d85bd04e0..bb9d8e860 100644 --- a/priv/repo/migrations/20200415181818_update_markers.exs +++ b/priv/repo/migrations/20200415181818_update_markers.exs @@ -34,8 +34,8 @@ defp update_markers do markers_attrs |> Enum.chunk_every(1000) - |> Enum.each(fn marker_attrs -> - Repo.insert_all("markers", markers_attrs, + |> Enum.each(fn markers_attrs_chunked -> + Repo.insert_all("markers", markers_attrs_chunked, on_conflict: {:replace, [:last_read_id]}, conflict_target: [:user_id, :timeline] ) -- cgit v1.2.3 From 952c6b29a1cf489e178ee2c10abf23c62e526915 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 12 May 2020 00:40:13 +0300 Subject: Gettext: remove english messages and update the pot file Having an English file doesn't make any sense, since English is the source language for error messages. Also Weblate complains about it --- priv/gettext/en/LC_MESSAGES/errors.po | 465 ---------------------------------- priv/gettext/errors.pot | 294 ++++++++++++++------- 2 files changed, 198 insertions(+), 561 deletions(-) delete mode 100644 priv/gettext/en/LC_MESSAGES/errors.po diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po deleted file mode 100644 index 25a2f73e4..000000000 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ /dev/null @@ -1,465 +0,0 @@ -## `msgid`s in this file come from POT (.pot) files. -## -## Do not add, change, or remove `msgid`s manually here as -## they're tied to the ones in the corresponding POT file -## (with the same domain). -## -## Use `mix gettext.extract --merge` or `mix gettext.merge` -## to merge POT files into PO files. -msgid "" -msgstr "" -"Language: en\n" - -## From Ecto.Changeset.cast/4 -msgid "can't be blank" -msgstr "" - -## From Ecto.Changeset.unique_constraint/3 -msgid "has already been taken" -msgstr "" - -## From Ecto.Changeset.put_change/3 -msgid "is invalid" -msgstr "" - -## From Ecto.Changeset.validate_format/3 -msgid "has invalid format" -msgstr "" - -## From Ecto.Changeset.validate_subset/3 -msgid "has an invalid entry" -msgstr "" - -## From Ecto.Changeset.validate_exclusion/3 -msgid "is reserved" -msgstr "" - -## From Ecto.Changeset.validate_confirmation/3 -msgid "does not match confirmation" -msgstr "" - -## From Ecto.Changeset.no_assoc_constraint/3 -msgid "is still associated with this entry" -msgstr "" - -msgid "are still associated with this entry" -msgstr "" - -## From Ecto.Changeset.validate_length/3 -msgid "should be %{count} character(s)" -msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have %{count} item(s)" -msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at least %{count} character(s)" -msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at least %{count} item(s)" -msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at most %{count} character(s)" -msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at most %{count} item(s)" -msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -## From Ecto.Changeset.validate_number/3 -msgid "must be less than %{number}" -msgstr "" - -msgid "must be greater than %{number}" -msgstr "" - -msgid "must be less than or equal to %{number}" -msgstr "" - -msgid "must be greater than or equal to %{number}" -msgstr "" - -msgid "must be equal to %{number}" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:381 -msgid "Account not found" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:153 -msgid "Already voted" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:263 -msgid "Bad request" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:254 -msgid "Can't delete object" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:569 -msgid "Can't delete this post" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1731 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1737 -msgid "Can't display this activity" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:195 -msgid "Can't find user" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1148 -msgid "Can't get favorites" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:263 -msgid "Can't like object" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/utils.ex:518 -msgid "Cannot post an empty status without attachments" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/utils.ex:461 -msgid "Comment must be up to %{max_size} characters" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/admin_api/config.ex:63 -msgid "Config with params %{params} not found" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:78 -msgid "Could not delete" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:110 -msgid "Could not favorite" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:310 -msgid "Could not pin" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:89 -msgid "Could not repeat" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:120 -msgid "Could not unfavorite" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:327 -msgid "Could not unpin" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:99 -msgid "Could not unrepeat" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:392 -msgid "Could not update state" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1271 -msgid "Error." -msgstr "" - -#, elixir-format -#: lib/pleroma/captcha/kocaptcha.ex:36 -msgid "Invalid CAPTCHA" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1700 -#: lib/pleroma/web/oauth/oauth_controller.ex:465 -msgid "Invalid credentials" -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/ensure_authenticated_plug.ex:20 -msgid "Invalid credentials." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:154 -msgid "Invalid indices" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:411 -msgid "Invalid parameters" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/utils.ex:377 -msgid "Invalid password." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:163 -msgid "Invalid request" -msgstr "" - -#, elixir-format -#: lib/pleroma/captcha/kocaptcha.ex:16 -msgid "Kocaptcha service unavailable" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1696 -msgid "Missing parameters" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/utils.ex:496 -msgid "No such conversation" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:163 -#: lib/pleroma/web/admin_api/admin_api_controller.ex:206 -msgid "No such permission_group" -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:69 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:311 -#: lib/pleroma/web/admin_api/admin_api_controller.ex:399 -#: lib/pleroma/web/mastodon_api/subscription_controller.ex:63 -#: lib/pleroma/web/ostatus/ostatus_controller.ex:248 -msgid "Not found" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:152 -msgid "Poll's author can't vote" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:443 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:444 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:473 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:476 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1180 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1564 -msgid "Record not found" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:417 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1570 -#: lib/pleroma/web/mastodon_api/subscription_controller.ex:69 -#: lib/pleroma/web/ostatus/ostatus_controller.ex:252 -msgid "Something went wrong" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:253 -msgid "The message visibility must be direct" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/utils.ex:521 -msgid "The status is over the character limit" -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:27 -msgid "This resource requires authentication." -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/rate_limiter.ex:89 -msgid "Throttled" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:155 -msgid "Too many choices" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:268 -msgid "Unhandled activity type" -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/user_is_admin_plug.ex:20 -msgid "User is not admin." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:380 -msgid "Valid `account_id` required" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:185 -msgid "You can't revoke your own admin status." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:216 -msgid "Your account is currently disabled" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:158 -#: lib/pleroma/web/oauth/oauth_controller.ex:213 -msgid "Your login is missing a confirmed e-mail address" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:221 -msgid "can't read inbox of %{nickname} as %{as_nickname}" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:297 -msgid "can't update outbox of %{nickname} as %{as_nickname}" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:335 -msgid "conversation is already muted" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:192 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:317 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1196 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1247 -msgid "error" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:789 -msgid "mascots can only be images" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:34 -msgid "not found" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:298 -msgid "Bad OAuth request." -msgstr "" - -#, elixir-format -#: lib/pleroma/captcha/captcha.ex:92 -msgid "CAPTCHA already used" -msgstr "" - -#, elixir-format -#: lib/pleroma/captcha/captcha.ex:89 -msgid "CAPTCHA expired" -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:50 -msgid "Failed" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:314 -msgid "Failed to authenticate: %{message}." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:345 -msgid "Failed to set up user account." -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/oauth_scopes_plug.ex:37 -msgid "Insufficient permissions: %{permissions}." -msgstr "" - -#, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:89 -msgid "Internal Error" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/fallback_controller.ex:22 -#: lib/pleroma/web/oauth/fallback_controller.ex:29 -msgid "Invalid Username/Password" -msgstr "" - -#, elixir-format -#: lib/pleroma/captcha/captcha.ex:107 -msgid "Invalid answer data" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:204 -msgid "Nodeinfo schema version not handled" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:145 -msgid "This action is outside the authorized scopes" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/fallback_controller.ex:14 -msgid "Unknown error, please check the details and try again." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:93 -#: lib/pleroma/web/oauth/oauth_controller.ex:131 -msgid "Unlisted redirect_uri." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:294 -msgid "Unsupported OAuth provider: %{provider}." -msgstr "" - -#, elixir-format -#: lib/pleroma/uploaders/uploader.ex:71 -msgid "Uploader callback timeout" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/uploader_controller.ex:11 -#: lib/pleroma/web/uploader_controller.ex:23 -msgid "bad request" -msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index 2fd9c42e3..0e1cf37eb 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -90,326 +90,312 @@ msgid "must be equal to %{number}" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:381 +#: lib/pleroma/web/common_api/common_api.ex:421 msgid "Account not found" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:153 +#: lib/pleroma/web/common_api/common_api.ex:249 msgid "Already voted" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:263 +#: lib/pleroma/web/oauth/oauth_controller.ex:360 msgid "Bad request" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:254 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 msgid "Can't delete object" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:569 +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 msgid "Can't delete this post" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1731 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1737 +#: lib/pleroma/web/controller_helper.ex:95 +#: lib/pleroma/web/controller_helper.ex:101 msgid "Can't display this activity" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:195 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 msgid "Can't find user" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1148 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 msgid "Can't get favorites" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:263 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 msgid "Can't like object" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:518 +#: lib/pleroma/web/common_api/utils.ex:556 msgid "Cannot post an empty status without attachments" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:461 +#: lib/pleroma/web/common_api/utils.ex:504 msgid "Comment must be up to %{max_size} characters" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/config.ex:63 +#: lib/pleroma/config/config_db.ex:222 msgid "Config with params %{params} not found" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:78 +#: lib/pleroma/web/common_api/common_api.ex:95 msgid "Could not delete" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:110 +#: lib/pleroma/web/common_api/common_api.ex:141 msgid "Could not favorite" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:310 +#: lib/pleroma/web/common_api/common_api.ex:370 msgid "Could not pin" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:89 +#: lib/pleroma/web/common_api/common_api.ex:112 msgid "Could not repeat" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:120 +#: lib/pleroma/web/common_api/common_api.ex:188 msgid "Could not unfavorite" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:327 +#: lib/pleroma/web/common_api/common_api.ex:380 msgid "Could not unpin" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:99 +#: lib/pleroma/web/common_api/common_api.ex:126 msgid "Could not unrepeat" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:392 +#: lib/pleroma/web/common_api/common_api.ex:428 +#: lib/pleroma/web/common_api/common_api.ex:437 msgid "Could not update state" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1271 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 msgid "Error." msgstr "" #, elixir-format -#: lib/pleroma/captcha/kocaptcha.ex:36 +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 msgid "Invalid CAPTCHA" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1700 -#: lib/pleroma/web/oauth/oauth_controller.ex:465 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 +#: lib/pleroma/web/oauth/oauth_controller.ex:569 msgid "Invalid credentials" msgstr "" #, elixir-format -#: lib/pleroma/plugs/ensure_authenticated_plug.ex:20 +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 msgid "Invalid credentials." msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:154 +#: lib/pleroma/web/common_api/common_api.ex:265 msgid "Invalid indices" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:411 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 msgid "Invalid parameters" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:377 +#: lib/pleroma/web/common_api/utils.ex:411 msgid "Invalid password." msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:163 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 msgid "Invalid request" msgstr "" #, elixir-format -#: lib/pleroma/captcha/kocaptcha.ex:16 +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 msgid "Kocaptcha service unavailable" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1696 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 msgid "Missing parameters" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:496 +#: lib/pleroma/web/common_api/utils.ex:540 msgid "No such conversation" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:163 -#: lib/pleroma/web/admin_api/admin_api_controller.ex:206 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:439 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 msgid "No such permission_group" msgstr "" #, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:69 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:311 -#: lib/pleroma/web/admin_api/admin_api_controller.ex:399 -#: lib/pleroma/web/mastodon_api/subscription_controller.ex:63 -#: lib/pleroma/web/ostatus/ostatus_controller.ex:248 +#: lib/pleroma/plugs/uploaded_media.ex:74 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 +#: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 msgid "Not found" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:152 +#: lib/pleroma/web/common_api/common_api.ex:241 msgid "Poll's author can't vote" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:443 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:444 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:473 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:476 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1180 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1564 +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:290 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 msgid "Record not found" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:417 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1570 -#: lib/pleroma/web/mastodon_api/subscription_controller.ex:69 -#: lib/pleroma/web/ostatus/ostatus_controller.ex:252 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 +#: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 msgid "Something went wrong" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:253 +#: lib/pleroma/web/common_api/activity_draft.ex:107 msgid "The message visibility must be direct" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:521 +#: lib/pleroma/web/common_api/utils.ex:566 msgid "The status is over the character limit" msgstr "" #, elixir-format -#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:27 +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 msgid "This resource requires authentication." msgstr "" #, elixir-format -#: lib/pleroma/plugs/rate_limiter.ex:89 +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 msgid "Throttled" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:155 +#: lib/pleroma/web/common_api/common_api.ex:266 msgid "Too many choices" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:268 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 msgid "Unhandled activity type" msgstr "" #, elixir-format -#: lib/pleroma/plugs/user_is_admin_plug.ex:20 -msgid "User is not admin." -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:380 -msgid "Valid `account_id` required" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:185 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:536 msgid "You can't revoke your own admin status." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:216 +#: lib/pleroma/web/oauth/oauth_controller.ex:218 +#: lib/pleroma/web/oauth/oauth_controller.ex:309 msgid "Your account is currently disabled" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:158 -#: lib/pleroma/web/oauth/oauth_controller.ex:213 +#: lib/pleroma/web/oauth/oauth_controller.ex:180 +#: lib/pleroma/web/oauth/oauth_controller.ex:332 msgid "Your login is missing a confirmed e-mail address" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:221 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 msgid "can't read inbox of %{nickname} as %{as_nickname}" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:297 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 msgid "can't update outbox of %{nickname} as %{as_nickname}" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:335 +#: lib/pleroma/web/common_api/common_api.ex:388 msgid "conversation is already muted" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:192 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:317 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1196 -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1247 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 msgid "error" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:789 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 msgid "mascots can only be images" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:34 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 msgid "not found" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:298 +#: lib/pleroma/web/oauth/oauth_controller.ex:395 msgid "Bad OAuth request." msgstr "" #, elixir-format -#: lib/pleroma/captcha/captcha.ex:92 +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 msgid "CAPTCHA already used" msgstr "" #, elixir-format -#: lib/pleroma/captcha/captcha.ex:89 +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 msgid "CAPTCHA expired" msgstr "" #, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:50 +#: lib/pleroma/plugs/uploaded_media.ex:55 msgid "Failed" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:314 +#: lib/pleroma/web/oauth/oauth_controller.ex:411 msgid "Failed to authenticate: %{message}." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:345 +#: lib/pleroma/web/oauth/oauth_controller.ex:442 msgid "Failed to set up user account." msgstr "" #, elixir-format -#: lib/pleroma/plugs/oauth_scopes_plug.ex:37 +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 msgid "Insufficient permissions: %{permissions}." msgstr "" #, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:89 +#: lib/pleroma/plugs/uploaded_media.ex:94 msgid "Internal Error" msgstr "" @@ -420,17 +406,17 @@ msgid "Invalid Username/Password" msgstr "" #, elixir-format -#: lib/pleroma/captcha/captcha.ex:107 +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 msgid "Invalid answer data" msgstr "" #, elixir-format -#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:204 +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 msgid "Nodeinfo schema version not handled" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:145 +#: lib/pleroma/web/oauth/oauth_controller.ex:169 msgid "This action is outside the authorized scopes" msgstr "" @@ -440,23 +426,139 @@ msgid "Unknown error, please check the details and try again." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:93 -#: lib/pleroma/web/oauth/oauth_controller.ex:131 +#: lib/pleroma/web/oauth/oauth_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:155 msgid "Unlisted redirect_uri." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:294 +#: lib/pleroma/web/oauth/oauth_controller.ex:391 msgid "Unsupported OAuth provider: %{provider}." msgstr "" #, elixir-format -#: lib/pleroma/uploaders/uploader.ex:71 +#: lib/pleroma/uploaders/uploader.ex:72 msgid "Uploader callback timeout" msgstr "" #, elixir-format -#: lib/pleroma/web/uploader_controller.ex:11 #: lib/pleroma/web/uploader_controller.ex:23 msgid "bad request" msgstr "" + +#, elixir-format +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +msgid "CAPTCHA Error" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/common_api/common_api.ex:200 +msgid "Could not add reaction emoji" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/common_api/common_api.ex:211 +msgid "Could not remove reaction emoji" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +msgid "List not found" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 +msgid "Missing parameter: %{name}" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/oauth/oauth_controller.ex:207 +#: lib/pleroma/web/oauth/oauth_controller.ex:322 +msgid "Password reset is required" +msgstr "" + +#, elixir-format +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/fallback_redirect_controller.ex:6 +#: lib/pleroma/web/feed/tag_controller.ex:6 lib/pleroma/web/feed/user_controller.ex:6 +#: lib/pleroma/web/mailer/subscription_controller.ex:2 lib/pleroma/web/masto_fe_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 +#: lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 +#: lib/pleroma/web/oauth/fallback_controller.ex:6 lib/pleroma/web/oauth/mfa_controller.ex:10 +#: lib/pleroma/web/oauth/oauth_controller.ex:6 lib/pleroma/web/ostatus/ostatus_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:2 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" + +#, elixir-format +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 +msgid "Unexpected error occurred while adding file to pack." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 +msgid "Unexpected error occurred while creating pack." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 +msgid "Unexpected error occurred while removing file from pack." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 +msgid "Unexpected error occurred while updating file in pack." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 +msgid "Unexpected error occurred while updating pack metadata." +msgstr "" + +#, elixir-format +#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +msgid "User is not an admin or OAuth admin scope is not granted." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/admin_api/admin_api_controller.ex:502 +msgid "You can't revoke your own admin/moderator status." +msgstr "" + +#, elixir-format +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 +msgid "authorization required for timeline view" +msgstr "" -- cgit v1.2.3 From 63477d07adb614413a382a87f06af2bc2495b432 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 12 May 2020 06:44:33 +0300 Subject: unsubscribes of friends when user deactivated --- lib/mix/tasks/pleroma/user.ex | 14 ++------------ lib/pleroma/user.ex | 20 +++++++++++++++----- test/tasks/user_test.exs | 8 ++++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index da140ac86..93ecb4631 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -150,22 +150,12 @@ def run(["unsubscribe", nickname]) do with %User{} = user <- User.get_cached_by_nickname(nickname) do shell_info("Deactivating #{user.nickname}") User.deactivate(user) - - user - |> User.get_friends() - |> Enum.each(fn friend -> - user = User.get_cached_by_id(user.id) - - shell_info("Unsubscribing #{friend.nickname} from #{user.nickname}") - User.unfollow(user, friend) - end) - :timer.sleep(500) user = User.get_cached_by_id(user.id) - if Enum.empty?(User.get_friends(user)) do - shell_info("Successfully unsubscribed all followers from #{user.nickname}") + if Enum.empty?(Enum.filter(User.get_friends(user), & &1.local)) do + shell_info("Successfully unsubscribed all local followers from #{user.nickname}") end else _ -> diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a86cc3202..1c456b27c 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -750,7 +750,19 @@ def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do {:error, "Not subscribed!"} end + @spec unfollow(User.t(), User.t()) :: {:ok, User.t(), Activity.t()} | {:error, String.t()} def unfollow(%User{} = follower, %User{} = followed) do + case do_unfollow(follower, followed) do + {:ok, follower, followed} -> + {:ok, follower, Utils.fetch_latest_follow(follower, followed)} + + error -> + error + end + end + + @spec do_unfollow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, String.t()} + defp do_unfollow(%User{} = follower, %User{} = followed) do case get_follow_state(follower, followed) do state when state in [:follow_pending, :follow_accept] -> FollowingRelationship.unfollow(follower, followed) @@ -761,7 +773,7 @@ def unfollow(%User{} = follower, %User{} = followed) do |> update_following_count() |> set_cache() - {:ok, follower, Utils.fetch_latest_follow(follower, followed)} + {:ok, follower, followed} nil -> {:error, "Not subscribed!"} @@ -1401,15 +1413,13 @@ def deactivate(%User{} = user, status) do user |> get_followers() |> Enum.filter(& &1.local) - |> Enum.each(fn follower -> - follower |> update_following_count() |> set_cache() - end) + |> Enum.each(&set_cache(update_following_count(&1))) # Only update local user counts, remote will be update during the next pull. user |> get_friends() |> Enum.filter(& &1.local) - |> Enum.each(&update_follower_count/1) + |> Enum.each(&do_unfollow(user, &1)) {:ok, user} end diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index b4f68d494..4b3ab5a87 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -172,23 +172,23 @@ test "no user to toggle" do describe "running unsubscribe" do test "user is unsubscribed" do followed = insert(:user) + remote_followed = insert(:user, local: false) user = insert(:user) + User.follow(user, followed, :follow_accept) + User.follow(user, remote_followed, :follow_accept) Mix.Tasks.Pleroma.User.run(["unsubscribe", user.nickname]) assert_received {:mix_shell, :info, [message]} assert message =~ "Deactivating" - assert_received {:mix_shell, :info, [message]} - assert message =~ "Unsubscribing" - # Note that the task has delay :timer.sleep(500) assert_received {:mix_shell, :info, [message]} assert message =~ "Successfully unsubscribed" user = User.get_cached_by_nickname(user.nickname) - assert Enum.empty?(User.get_friends(user)) + assert Enum.empty?(Enum.filter(User.get_friends(user), & &1.local)) assert user.deactivated end -- cgit v1.2.3 From d0ba1844b031030cde5b5cf4a5714bb6ff483866 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 10:52:46 +0200 Subject: ActivityPub: Fix non-federating blocks. --- lib/pleroma/web/activity_pub/activity_pub.ex | 4 +--- lib/pleroma/web/activity_pub/utils.ex | 8 +++++-- test/web/activity_pub/activity_pub_test.exs | 32 ++++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 4955243ab..d752f4f04 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -439,7 +439,6 @@ def block(blocker, blocked, activity_id \\ nil, local \\ true) do end defp do_block(blocker, blocked, activity_id, local) do - outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) if unfollow_blocked do @@ -447,8 +446,7 @@ defp do_block(blocker, blocked, activity_id, local) do if follow_activity, do: unfollow(blocker, blocked, nil, local) end - with true <- outgoing_blocks, - block_data <- make_block_data(blocker, blocked, activity_id), + with block_data <- make_block_data(blocker, blocked, activity_id), {:ok, activity} <- insert(block_data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 09b80fa57..f2375bcc4 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Ecto.Changeset alias Ecto.UUID alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -169,8 +170,11 @@ def create_context(context) do Enqueues an activity for federation if it's local """ @spec maybe_federate(any()) :: :ok - def maybe_federate(%Activity{local: true} = activity) do - if Pleroma.Config.get!([:instance, :federating]) do + def maybe_federate(%Activity{local: true, data: %{"type" => type}} = activity) do + outgoing_blocks = Config.get([:activitypub, :outgoing_blocks]) + + with true <- Config.get!([:instance, :federating]), + true <- type != "Block" || outgoing_blocks do Pleroma.Web.Federator.publish(activity) end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 0739cbfef..59bdd53cd 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1061,14 +1061,38 @@ test "reverts block activity on error" do end test "creates a block activity" do + clear_config([:instance, :federating], true) blocker = insert(:user) blocked = insert(:user) - {:ok, activity} = ActivityPub.block(blocker, blocked) + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + {:ok, activity} = ActivityPub.block(blocker, blocked) - assert activity.data["type"] == "Block" - assert activity.data["actor"] == blocker.ap_id - assert activity.data["object"] == blocked.ap_id + assert activity.data["type"] == "Block" + assert activity.data["actor"] == blocker.ap_id + assert activity.data["object"] == blocked.ap_id + + assert called(Pleroma.Web.Federator.publish(activity)) + end + end + + test "works with outgoing blocks disabled, but doesn't federate" do + clear_config([:instance, :federating], true) + clear_config([:activitypub, :outgoing_blocks], false) + blocker = insert(:user) + blocked = insert(:user) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + {:ok, activity} = ActivityPub.block(blocker, blocked) + + assert activity.data["type"] == "Block" + assert activity.data["actor"] == blocker.ap_id + assert activity.data["object"] == blocked.ap_id + + refute called(Pleroma.Web.Federator.publish(:_)) + end end end -- cgit v1.2.3 From ca31af473c556f8320914f0621d08d59c96d3bef Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 12:29:37 +0200 Subject: Transmogrifier: On incoming follow accept, update follow counts. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 6 +++++- test/web/activity_pub/transmogrifier_test.exs | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 921576617..80701bb63 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -592,6 +592,9 @@ def handle_incoming( {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do + User.update_follower_count(followed) + User.update_following_count(follower) + ActivityPub.accept(%{ to: follow_activity.data["to"], type: "Accept", @@ -601,7 +604,8 @@ def handle_incoming( activity_id: id }) else - _e -> :error + _e -> + :error end end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 2914c90ea..7d39d9067 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -815,6 +815,12 @@ test "it works for incoming accepts which are referenced by IRI only" do follower = User.get_cached_by_id(follower.id) assert User.following?(follower, followed) == true + + follower = User.get_by_id(follower.id) + assert follower.following_count == 1 + + followed = User.get_by_id(followed.id) + assert followed.follower_count == 1 end test "it fails for incoming accepts which cannot be correlated" do -- cgit v1.2.3 From dfb90a1fd64cfb8f81707d939a87a02e8047dc3b Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 12:50:48 +0200 Subject: Transmogrifier: Add tests for certain announces --- test/web/activity_pub/transmogrifier_test.exs | 18 +++++++++++++++ .../controllers/account_controller_test.exs | 27 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 2914c90ea..34e77fa79 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -260,6 +260,24 @@ test "it works for incoming notices with to/cc not being an array (kroeg)" do "

    henlo from my Psion netBook

    message sent from my Psion netBook

    " end + test "it works for incoming honk announces" do + _user = insert(:user, ap_id: "https://honktest/u/test", local: false) + other_user = insert(:user) + {:ok, post} = CommonAPI.post(other_user, %{"status" => "bonkeronk"}) + + announce = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => "https://honktest/u/test", + "id" => "https://honktest/u/test/bonk/1793M7B9MQ48847vdx", + "object" => post.data["object"], + "published" => "2019-06-25T19:33:58Z", + "to" => "https://www.w3.org/ns/activitystreams#Public", + "type" => "Announce" + } + + {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(announce) + end + test "it works for incoming announces with actor being inlined (kroeg)" do data = File.read!("test/fixtures/kroeg-announce-with-inline-actor.json") |> Poison.decode!() diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 256a8b304..0d48ae4ae 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -222,6 +222,33 @@ test "if user is authenticated", %{local: local, remote: remote} do describe "user timelines" do setup do: oauth_access(["read:statuses"]) + test "works with announces that are just addressed to public", %{conn: conn} do + user = insert(:user, ap_id: "https://honktest/u/test", local: false) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(other_user, %{"status" => "bonkeronk"}) + + {:ok, announce, _} = + %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => "https://honktest/u/test", + "id" => "https://honktest/u/test/bonk/1793M7B9MQ48847vdx", + "object" => post.data["object"], + "published" => "2019-06-25T19:33:58Z", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Announce" + } + |> ActivityPub.persist(local: false) + + assert resp = + conn + |> get("/api/v1/accounts/#{user.id}/statuses") + |> json_response_and_validate_schema(200) + + assert [%{"id" => id}] = resp + assert id == announce.id + end + test "respects blocks", %{user: user_one, conn: conn} do user_two = insert(:user) user_three = insert(:user) -- cgit v1.2.3 From b5aa204eb8bf3f737d3d807a9924c0153d1b6d3e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 13:13:03 +0200 Subject: ChatController: Support deletion of chat messages. --- .../object_validators/delete_validator.ex | 3 ++- .../web/api_spec/operations/chat_operation.ex | 25 ++++++++++++++++++++++ .../web/pleroma_api/controllers/chat_controller.ex | 24 ++++++++++++++++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 24 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index f42c03510..e5d08eb5c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -46,12 +46,13 @@ def add_deleted_activity_id(cng) do Answer Article Audio + ChatMessage Event Note Page Question - Video Tombstone + Video } def validate_data(cng) do cng diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index fe6c2f52f..8ba10c603 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -166,6 +166,31 @@ def post_chat_message_operation do } end + def delete_message_operation do + %Operation{ + tags: ["chat"], + summary: "delete_message", + operationId: "ChatController.delete_message", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The deleted ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] + } + end + def chats_response do %Schema{ title: "ChatsResponse", diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 04f136dcd..8eed88752 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do use Pleroma.Web, :controller + alias Pleroma.Activity alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Pagination @@ -22,7 +23,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} when action in [:post_chat_message, :create, :mark_as_read] + %{scopes: ["write:statuses"]} + when action in [:post_chat_message, :create, :mark_as_read, :delete_message] ) plug( @@ -34,6 +36,26 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation + def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ + message_id: id + }) do + with %Object{ + data: %{ + "actor" => ^actor, + "id" => object, + "to" => [recipient], + "type" => "ChatMessage" + } + } = message <- Object.get_by_id(id), + %Chat{} = chat <- Chat.get(user.id, recipient), + %Activity{} = activity <- Activity.get_create_by_object_ap_id(object), + {:ok, _delete} <- CommonAPI.delete(activity.id, user) do + conn + |> put_view(ChatMessageView) + |> render("show.json", for: user, object: message, chat: chat) + end + end + def post_chat_message( %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} = conn, diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 3b1834d97..0e4f45869 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -310,6 +310,7 @@ defmodule Pleroma.Web.Router do get("/chats/:id", ChatController, :show) get("/chats/:id/messages", ChatController, :messages) post("/chats/:id/messages", ChatController, :post_chat_message) + delete("/chats/:id/messages/:message_id", ChatController, :delete_message) post("/chats/:id/read", ChatController, :mark_as_read) end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index dda4f9e5b..86ccbb117 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true + alias Pleroma.Object alias Pleroma.Chat alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -78,6 +79,29 @@ test "it works with an attachment", %{conn: conn, user: user} do end end + describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do + setup do: oauth_access(["write:statuses"]) + + test "it deletes a message for the author of the message", %{conn: conn, user: user} do + recipient = insert(:user) + + {:ok, message} = + CommonAPI.post_chat_message(user, recipient, "Hello darkness my old friend") + + object = Object.normalize(message, false) + + chat = Chat.get(user.id, recipient.ap_id) + + result = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") + |> json_response_and_validate_schema(200) + + assert result["id"] == to_string(object.id) + end + end + describe "GET /api/v1/pleroma/chats/:id/messages" do setup do: oauth_access(["read:statuses"]) -- cgit v1.2.3 From ec72cba43ec4f45faadf1b06a6d014cd4136707e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 13:23:09 +0200 Subject: Chat Controller: Add basic error handling. --- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 5 +++-- test/web/pleroma_api/controllers/chat_controller_test.exs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 8eed88752..4ce3e7419 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -18,8 +18,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do import Ecto.Query import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] - # TODO - # - Error handling + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug( OAuthScopesPlug, @@ -53,6 +52,8 @@ def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ conn |> put_view(ChatMessageView) |> render("show.json", for: user, object: message, chat: chat) + else + _e -> {:error, :could_not_delete} end end diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 86ccbb117..75d4903ed 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -88,6 +88,8 @@ test "it deletes a message for the author of the message", %{conn: conn, user: u {:ok, message} = CommonAPI.post_chat_message(user, recipient, "Hello darkness my old friend") + {:ok, other_message} = CommonAPI.post_chat_message(recipient, user, "nico nico ni") + object = Object.normalize(message, false) chat = Chat.get(user.id, recipient.ap_id) @@ -99,6 +101,16 @@ test "it deletes a message for the author of the message", %{conn: conn, user: u |> json_response_and_validate_schema(200) assert result["id"] == to_string(object.id) + + object = Object.normalize(other_message, false) + + result = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") + |> json_response(400) + + assert result == %{"error" => "could_not_delete"} end end -- cgit v1.2.3 From a61120f497e5d17be1207eacd11525edb7b6db36 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 13:25:25 +0200 Subject: Documention: Add chat message deletion docs --- docs/API/chats.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index ed160abd9..ad36961ae 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -192,6 +192,14 @@ Returned data: } ``` +### Deleting a chat message + +Deleting a chat message for given Chat id works like this: + +`DELETE /api/v1/pleroma/chats/{chat_id}/messages/{message_id}` + +Returned data is the deleted message. + ### Notifications There's a new `pleroma:chat_mention` notification, which has this form: -- cgit v1.2.3 From e44166b510f95bfb2e679b2d64bbf7e0facd0dd2 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 14:44:11 +0200 Subject: Credo fixes. --- test/web/pleroma_api/controllers/chat_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 75d4903ed..861ef10b0 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -4,8 +4,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true - alias Pleroma.Object alias Pleroma.Chat + alias Pleroma.Object alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI -- cgit v1.2.3 From 2f2e5fea34355d1e307a787d1df459813b556406 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 15:02:37 +0200 Subject: Give up for now and make gitlab retry failed jobs. --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1b7c03ebb..aad28a2d8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -48,6 +48,7 @@ benchmark: unit-testing: stage: test + retry: 2 cache: &testing_cache_policy <<: *global_cache_policy policy: pull @@ -80,6 +81,7 @@ unit-testing: unit-testing-rum: stage: test + retry: 2 cache: *testing_cache_policy services: - name: minibikini/postgres-with-rum:12 -- cgit v1.2.3 From 8308611279c989819238eeadb389ef48e9a50265 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 12 May 2020 17:30:39 +0300 Subject: Startup: suggest updating OTP when the version is too low for gun I've seen quite a few people wonder what to do when presented with this error message. --- lib/pleroma/application.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index a00bc0624..9d3d92b38 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -56,7 +56,7 @@ def start(_type, _args) do 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. + You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. Please update your Erlang/OTP to at least 22.2. " end else -- cgit v1.2.3 From c0ea5c60e4e709d3d4415de42a65f878b55dc3bb Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 12 May 2020 16:43:04 +0200 Subject: ChatController: Don't return chats for user you've blocked. --- .../web/pleroma_api/controllers/chat_controller.ex | 5 ++++- .../controllers/chat_controller_test.exs | 23 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 4ce3e7419..496cb8e87 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -102,10 +102,13 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para end end - def index(%{assigns: %{user: %{id: user_id}}} = conn, params) do + def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do + blocked_ap_ids = User.blocked_users_ap_ids(user) + chats = from(c in Chat, where: c.user_id == ^user_id, + where: c.recipient not in ^blocked_ap_ids, order_by: [desc: c.updated_at] ) |> Pagination.fetch_paginated(params |> stringify_keys) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 861ef10b0..037dd2297 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do alias Pleroma.Chat alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI @@ -209,6 +210,28 @@ test "it returns a chat", %{conn: conn, user: user} do describe "GET /api/v1/pleroma/chats" do setup do: oauth_access(["read:statuses"]) + test "it does not return chats with users you blocked", %{conn: conn, user: user} do + recipient = insert(:user) + + {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 1 + + User.block(user, recipient) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 0 + end + test "it paginates", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) -- cgit v1.2.3 From e6d8cacf2de4ba48b881a390b0ba4582981d17a5 Mon Sep 17 00:00:00 2001 From: href Date: Tue, 12 May 2020 18:04:47 +0200 Subject: Expand and authorize streams in Streamer directly --- lib/pleroma/web/mastodon_api/websocket_handler.ex | 69 +++--------- lib/pleroma/web/streamer/streamer.ex | 73 ++++++++++--- test/integration/mastodon_websocket_test.exs | 12 +-- test/notification_test.exs | 4 +- test/web/streamer/streamer_test.exs | 124 +++++++++++++++++----- 5 files changed, 179 insertions(+), 103 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 393d093e5..94e4595d8 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -19,26 +19,12 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do # Hibernate every X messages @hibernate_every 100 - @streams [ - "public", - "public:local", - "public:media", - "public:local:media", - "user", - "user:notification", - "direct", - "list", - "hashtag" - ] - @anonymous_streams ["public", "public:local", "hashtag"] - def init(%{qs: qs} = req, state) do - with params <- :cow_qs.parse_qs(qs), + with params <- Enum.into(:cow_qs.parse_qs(qs), %{}), sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil), - access_token <- List.keyfind(params, "access_token", 0), - {_, stream} <- List.keyfind(params, "stream", 0), - {:ok, user} <- allow_request(stream, [access_token, sec_websocket]), - topic when is_binary(topic) <- expand_topic(stream, params) do + access_token <- Map.get(params, "access_token"), + {:ok, user} <- authenticate_request(access_token, sec_websocket), + {:ok, topic} <- Streamer.get_topic(Map.get(params, "stream"), user, params) do req = if sec_websocket do :cowboy_req.set_resp_header("sec-websocket-protocol", sec_websocket, req) @@ -49,14 +35,14 @@ def init(%{qs: qs} = req, state) do {:cowboy_websocket, req, %{user: user, topic: topic, count: 0, timer: nil}, %{idle_timeout: @timeout}} else - {:error, code} -> - Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}") - {:ok, req} = :cowboy_req.reply(code, req) + {:error, :bad_topic} -> + Logger.debug("#{__MODULE__} bad topic #{inspect(req)}") + {:ok, req} = :cowboy_req.reply(404, req) {:ok, req, state} - error -> - Logger.debug("#{__MODULE__} denied connection: #{inspect(error)} - #{inspect(req)}") - {:ok, req} = :cowboy_req.reply(400, req) + {:error, :unauthorized} -> + Logger.debug("#{__MODULE__} authentication error: #{inspect(req)}") + {:ok, req} = :cowboy_req.reply(401, req) {:ok, req, state} end end @@ -124,50 +110,23 @@ def terminate(reason, _req, state) do end # Public streams without authentication. - defp allow_request(stream, [nil, nil]) when stream in @anonymous_streams do + defp authenticate_request(nil, nil) do {:ok, nil} end # Authenticated streams. - defp allow_request(stream, [access_token, sec_websocket]) when stream in @streams do - token = - with {"access_token", token} <- access_token do - token - else - _ -> sec_websocket - end + defp authenticate_request(access_token, sec_websocket) do + token = access_token || sec_websocket with true <- is_bitstring(token), %Token{user_id: user_id} <- Repo.get_by(Token, token: token), user = %User{} <- User.get_cached_by_id(user_id) do {:ok, user} else - _ -> {:error, 403} - end - end - - # Not authenticated. - defp allow_request(stream, _) when stream in @streams, do: {:error, 403} - - # No matching stream. - defp allow_request(_, _), do: {:error, 404} - - defp expand_topic("hashtag", params) do - case List.keyfind(params, "tag", 0) do - {_, tag} -> "hashtag:#{tag}" - _ -> nil + _ -> {:error, :unauthorized} end end - defp expand_topic("list", params) do - case List.keyfind(params, "list", 0) do - {_, list} -> "list:#{list}" - _ -> nil - end - end - - defp expand_topic(topic, _), do: topic - defp timer do Process.send_after(self(), :tick, @tick) end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 5ad4aa936..49a400df7 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -21,12 +21,68 @@ defmodule Pleroma.Web.Streamer do def registry, do: @registry - def add_socket(topic, %User{} = user) do - if should_env_send?(), do: Registry.register(@registry, user_topic(topic, user), true) + @public_streams ["public", "public:local", "public:media", "public:local:media"] + @user_streams ["user", "user:notification", "direct"] + + @doc "Expands and authorizes a stream, and registers the process for streaming." + @spec get_topic_and_add_socket(stream :: String.t(), User.t() | nil, Map.t() | nil) :: + {:ok, topic :: String.t()} | {:error, :bad_topic} | {:error, :unauthorized} + def get_topic_and_add_socket(stream, user, params \\ %{}) do + case get_topic(stream, user, params) do + {:ok, topic} -> add_socket(topic, user) + error -> error + end + end + + @doc "Expand and authorizes a stream" + @spec get_topic(stream :: String.t(), User.t() | nil, Map.t()) :: + {:ok, topic :: String.t()} | {:error, :bad_topic} + def get_topic(stream, user, params \\ %{}) + + # Allow all public steams. + def get_topic(stream, _, _) when stream in @public_streams do + {:ok, stream} end - def add_socket(topic, _) do - if should_env_send?(), do: Registry.register(@registry, topic, false) + # Allow all hashtags streams. + def get_topic("hashtag", _, %{"tag" => tag}) do + {:ok, "hashtag:" <> tag} + end + + # Expand user streams. + def get_topic(stream, %User{} = user, _) when stream in @user_streams do + {:ok, stream <> ":" <> to_string(user.id)} + end + + def get_topic(stream, _, _) when stream in @user_streams do + {:error, :unauthorized} + end + + # List streams. + def get_topic("list", %User{} = user, %{"list" => id}) do + if Pleroma.List.get(id, user) do + {:ok, "list:" <> to_string(id)} + else + {:error, :bad_topic} + end + end + + def get_topic("list", _, _) do + {:error, :unauthorized} + end + + def get_topic(_, _, _) do + {:error, :bad_topic} + end + + @doc "Registers the process for streaming. Use `get_topic/3` to get the full authorized topic." + def add_socket(topic, user) do + if should_env_send?() do + auth? = if user, do: true + Registry.register(@registry, topic, auth?) + end + + {:ok, topic} end def remove_socket(topic) do @@ -231,13 +287,4 @@ def should_env_send?, do: false true -> def should_env_send?, do: true end - - defp user_topic(topic, user) - when topic in ~w[user user:notification direct] do - "#{topic}:#{user.id}" - end - - defp user_topic(topic, _) do - topic - end end diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs index 109c7b4cb..63f351a80 100644 --- a/test/integration/mastodon_websocket_test.exs +++ b/test/integration/mastodon_websocket_test.exs @@ -32,7 +32,7 @@ def start_socket(qs \\ nil, headers \\ []) do test "refuses invalid requests" do capture_log(fn -> - assert {:error, {400, _}} = start_socket() + assert {:error, {404, _}} = start_socket() assert {:error, {404, _}} = start_socket("?stream=ncjdk") Process.sleep(30) end) @@ -40,8 +40,8 @@ test "refuses invalid requests" do test "requires authentication and a valid token for protected streams" do capture_log(fn -> - assert {:error, {403, _}} = start_socket("?stream=user&access_token=aaaaaaaaaaaa") - assert {:error, {403, _}} = start_socket("?stream=user") + assert {:error, {401, _}} = start_socket("?stream=user&access_token=aaaaaaaaaaaa") + assert {:error, {401, _}} = start_socket("?stream=user") Process.sleep(30) end) end @@ -100,7 +100,7 @@ test "accepts the 'user' stream", %{token: token} = _state do assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}") assert capture_log(fn -> - assert {:error, {403, "Forbidden"}} = start_socket("?stream=user") + assert {:error, {401, _}} = start_socket("?stream=user") Process.sleep(30) end) =~ ":badarg" end @@ -109,7 +109,7 @@ test "accepts the 'user:notification' stream", %{token: token} = _state do assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}") assert capture_log(fn -> - assert {:error, {403, "Forbidden"}} = start_socket("?stream=user:notification") + assert {:error, {401, _}} = start_socket("?stream=user:notification") Process.sleep(30) end) =~ ":badarg" end @@ -118,7 +118,7 @@ test "accepts valid token on Sec-WebSocket-Protocol header", %{token: token} do assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}]) assert capture_log(fn -> - assert {:error, {403, "Forbidden"}} = + assert {:error, {401, "Forbidden"}} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}]) Process.sleep(30) diff --git a/test/notification_test.exs b/test/notification_test.exs index 24e5f0c73..4dfbc1019 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -170,13 +170,13 @@ test "it creates a notification for user and send to the 'user' and the 'user:no task = Task.async(fn -> - Streamer.add_socket("user", user) + Streamer.get_topic_and_add_socket("user", user) assert_receive {:render_with_user, _, _, _}, 4_000 end) task_user_notification = Task.async(fn -> - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) assert_receive {:render_with_user, _, _, _}, 4_000 end) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index ee530f4e9..db07c5df5 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -17,6 +17,76 @@ defmodule Pleroma.Web.StreamerTest do setup do: clear_config([:instance, :skip_thread_containment]) + describe "get_topic without an user" do + test "allows public" do + assert {:ok, "public"} = Streamer.get_topic("public", nil) + assert {:ok, "public:local"} = Streamer.get_topic("public:local", nil) + assert {:ok, "public:media"} = Streamer.get_topic("public:media", nil) + assert {:ok, "public:local:media"} = Streamer.get_topic("public:local:media", nil) + end + + test "allows hashtag streams" do + assert {:ok, "hashtag:cofe"} = Streamer.get_topic("hashtag", nil, %{"tag" => "cofe"}) + end + + test "disallows user streams" do + assert {:error, _} = Streamer.get_topic("user", nil) + assert {:error, _} = Streamer.get_topic("user:notification", nil) + assert {:error, _} = Streamer.get_topic("direct", nil) + end + + test "disallows list streams" do + assert {:error, _} = Streamer.get_topic("list", nil, %{"list" => 42}) + end + end + + describe "get_topic with an user" do + setup do + user = insert(:user) + {:ok, %{user: user}} + end + + test "allows public streams", %{user: user} do + assert {:ok, "public"} = Streamer.get_topic("public", user) + assert {:ok, "public:local"} = Streamer.get_topic("public:local", user) + assert {:ok, "public:media"} = Streamer.get_topic("public:media", user) + assert {:ok, "public:local:media"} = Streamer.get_topic("public:local:media", user) + end + + test "allows user streams", %{user: user} do + expected_user_topic = "user:#{user.id}" + expected_notif_topic = "user:notification:#{user.id}" + expected_direct_topic = "direct:#{user.id}" + assert {:ok, ^expected_user_topic} = Streamer.get_topic("user", user) + assert {:ok, ^expected_notif_topic} = Streamer.get_topic("user:notification", user) + assert {:ok, ^expected_direct_topic} = Streamer.get_topic("direct", user) + end + + test "allows hashtag streams", %{user: user} do + assert {:ok, "hashtag:cofe"} = Streamer.get_topic("hashtag", user, %{"tag" => "cofe"}) + end + + test "disallows registering to an user stream", %{user: user} do + another_user = insert(:user) + assert {:error, _} = Streamer.get_topic("user:#{another_user.id}", user) + assert {:error, _} = Streamer.get_topic("user:notification:#{another_user.id}", user) + assert {:error, _} = Streamer.get_topic("direct:#{another_user.id}", user) + end + + test "allows list stream that are owned by the user", %{user: user} do + {:ok, list} = List.create("Test", user) + assert {:error, _} = Streamer.get_topic("list:#{list.id}", user) + assert {:ok, _} = Streamer.get_topic("list", user, %{"list" => list.id}) + end + + test "disallows list stream that are not owned by the user", %{user: user} do + another_user = insert(:user) + {:ok, list} = List.create("Test", another_user) + assert {:error, _} = Streamer.get_topic("list:#{list.id}", user) + assert {:error, _} = Streamer.get_topic("list", user, %{"list" => list.id}) + end + end + describe "user streams" do setup do user = insert(:user) @@ -25,14 +95,14 @@ defmodule Pleroma.Web.StreamerTest do end test "it streams the user's post in the 'user' stream", %{user: user} do - Streamer.add_socket("user", user) + Streamer.get_topic_and_add_socket("user", user) {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) assert_receive {:render_with_user, _, _, ^activity} refute Streamer.filtered_by_user?(user, activity) end test "it streams boosts of the user in the 'user' stream", %{user: user} do - Streamer.add_socket("user", user) + Streamer.get_topic_and_add_socket("user", user) other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) @@ -43,14 +113,14 @@ test "it streams boosts of the user in the 'user' stream", %{user: user} do end test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do - Streamer.add_socket("user", user) + Streamer.get_topic_and_add_socket("user", user) Streamer.stream("user", notify) assert_receive {:render_with_user, _, _, ^notify} refute Streamer.filtered_by_user?(user, notify) end test "it sends notify to in the 'user:notification' stream", %{user: user, notify: notify} do - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) Streamer.stream("user:notification", notify) assert_receive {:render_with_user, _, _, ^notify} refute Streamer.filtered_by_user?(user, notify) @@ -62,7 +132,7 @@ test "it doesn't send notify to the 'user:notification' stream when a user is bl blocked = insert(:user) {:ok, _user_relationship} = User.block(user, blocked) - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) {:ok, activity} = CommonAPI.post(user, %{"status" => ":("}) {:ok, _} = CommonAPI.favorite(blocked, activity.id) @@ -78,7 +148,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, _} = CommonAPI.add_mute(user, activity) - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) @@ -92,7 +162,7 @@ test "it sends favorite to 'user:notification' stream'", %{ user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"}) {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) assert_receive {:render_with_user, _, "notification.json", notif} @@ -107,7 +177,7 @@ test "it doesn't send the 'user:notification' stream' when a domain is blocked", {:ok, user} = User.block_domain(user, "hecking-lewd-place.com") {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) refute_receive _ @@ -130,7 +200,7 @@ test "it sends follow activities to the 'user:notification' stream", %{ %Tesla.Env{status: 200, body: body} end) - Streamer.add_socket("user:notification", user) + Streamer.get_topic_and_add_socket("user:notification", user) {:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user) assert_receive {:render_with_user, _, "notification.json", notif} @@ -143,7 +213,7 @@ test "it sends to public authenticated" do user = insert(:user) other_user = insert(:user) - Streamer.add_socket("public", other_user) + Streamer.get_topic_and_add_socket("public", other_user) {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"}) assert_receive {:render_with_user, _, _, ^activity} @@ -155,7 +225,7 @@ test "works for deletions" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"}) - Streamer.add_socket("public", user) + Streamer.get_topic_and_add_socket("public", user) {:ok, _} = CommonAPI.delete(activity.id, other_user) activity_id = activity.id @@ -166,7 +236,7 @@ test "works for deletions" do test "it sends to public unauthenticated" do user = insert(:user) - Streamer.add_socket("public", nil) + Streamer.get_topic_and_add_socket("public", nil) {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"}) activity_id = activity.id @@ -195,7 +265,7 @@ test "it filters to user if recipients invalid and thread containment is enabled ) ) - Streamer.add_socket("public", user) + Streamer.get_topic_and_add_socket("public", user) Streamer.stream("public", activity) assert_receive {:render_with_user, _, _, ^activity} assert Streamer.filtered_by_user?(user, activity) @@ -216,7 +286,7 @@ test "it sends message if recipients invalid and thread containment is disabled" ) ) - Streamer.add_socket("public", user) + Streamer.get_topic_and_add_socket("public", user) Streamer.stream("public", activity) assert_receive {:render_with_user, _, _, ^activity} @@ -238,7 +308,7 @@ test "it sends message if recipients invalid and thread containment is enabled b ) ) - Streamer.add_socket("public", user) + Streamer.get_topic_and_add_socket("public", user) Streamer.stream("public", activity) assert_receive {:render_with_user, _, _, ^activity} @@ -252,7 +322,7 @@ test "it filters messages involving blocked users" do blocked_user = insert(:user) {:ok, _user_relationship} = User.block(user, blocked_user) - Streamer.add_socket("public", user) + Streamer.get_topic_and_add_socket("public", user) {:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"}) assert_receive {:render_with_user, _, _, ^activity} assert Streamer.filtered_by_user?(user, activity) @@ -263,7 +333,7 @@ test "it filters messages transitively involving blocked users" do blockee = insert(:user) friend = insert(:user) - Streamer.add_socket("public", blocker) + Streamer.get_topic_and_add_socket("public", blocker) {:ok, _user_relationship} = User.block(blocker, blockee) @@ -295,7 +365,7 @@ test "it doesn't send unwanted DMs to list" do {:ok, list} = List.create("Test", user_a) {:ok, list} = List.follow(list, user_b) - Streamer.add_socket("list:#{list.id}", user_a) + Streamer.get_topic_and_add_socket("list", user_a, %{"list" => list.id}) {:ok, _activity} = CommonAPI.post(user_b, %{ @@ -313,7 +383,7 @@ test "it doesn't send unwanted private posts to list" do {:ok, list} = List.create("Test", user_a) {:ok, list} = List.follow(list, user_b) - Streamer.add_socket("list:#{list.id}", user_a) + Streamer.get_topic_and_add_socket("list", user_a, %{"list" => list.id}) {:ok, _activity} = CommonAPI.post(user_b, %{ @@ -333,7 +403,7 @@ test "it sends wanted private posts to list" do {:ok, list} = List.create("Test", user_a) {:ok, list} = List.follow(list, user_b) - Streamer.add_socket("list:#{list.id}", user_a) + Streamer.get_topic_and_add_socket("list", user_a, %{"list" => list.id}) {:ok, activity} = CommonAPI.post(user_b, %{ @@ -356,7 +426,7 @@ test "it filters muted reblogs" do {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"}) - Streamer.add_socket("user", user1) + Streamer.get_topic_and_add_socket("user", user1) {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2) assert_receive {:render_with_user, _, _, ^announce_activity} assert Streamer.filtered_by_user?(user1, announce_activity) @@ -369,7 +439,7 @@ test "it filters reblog notification for reblog-muted actors" do CommonAPI.hide_reblogs(user1, user2) {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"}) - Streamer.add_socket("user", user1) + Streamer.get_topic_and_add_socket("user", user1) {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2) assert_receive {:render_with_user, _, "notification.json", notif} @@ -383,7 +453,7 @@ test "it send non-reblog notification for reblog-muted actors" do CommonAPI.hide_reblogs(user1, user2) {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"}) - Streamer.add_socket("user", user1) + Streamer.get_topic_and_add_socket("user", user1) {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id) assert_receive {:render_with_user, _, "notification.json", notif} @@ -394,7 +464,7 @@ test "it send non-reblog notification for reblog-muted actors" do test "it filters posts from muted threads" do user = insert(:user) user2 = insert(:user) - Streamer.add_socket("user", user2) + Streamer.get_topic_and_add_socket("user", user2) {:ok, user2, user, _activity} = CommonAPI.follow(user2, user) {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) {:ok, _} = CommonAPI.add_mute(user2, activity) @@ -411,7 +481,7 @@ test "it sends conversation update to the 'direct' stream", %{} do user = insert(:user) another_user = insert(:user) - Streamer.add_socket("direct", user) + Streamer.get_topic_and_add_socket("direct", user) {:ok, _create_activity} = CommonAPI.post(another_user, %{ @@ -433,7 +503,7 @@ test "it doesn't send conversation update to the 'direct' stream when the last m user = insert(:user) another_user = insert(:user) - Streamer.add_socket("direct", user) + Streamer.get_topic_and_add_socket("direct", user) {:ok, create_activity} = CommonAPI.post(another_user, %{ @@ -459,7 +529,7 @@ test "it doesn't send conversation update to the 'direct' stream when the last m test "it sends conversation update to the 'direct' stream when a message is deleted" do user = insert(:user) another_user = insert(:user) - Streamer.add_socket("direct", user) + Streamer.get_topic_and_add_socket("direct", user) {:ok, create_activity} = CommonAPI.post(another_user, %{ -- cgit v1.2.3 From 620247a015f6cd894a119bb5173a3da7e5913064 Mon Sep 17 00:00:00 2001 From: Stephanie Wilde-Hobbs Date: Tue, 12 May 2020 17:12:27 +0100 Subject: Add database configuration whitelist --- docs/configuration/cheatsheet.md | 11 +++++++++++ lib/pleroma/web/admin_api/admin_api_controller.ex | 13 +++++++++++- test/web/admin_api/admin_api_controller_test.exs | 24 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 707d7fdbd..7b7a332c7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -911,6 +911,17 @@ config :auto_linker, Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. +## :database_config_whitelist + +List of valid configuration sections which are allowed to be configured from the database. + +Example: +```elixir +config :pleroma, :database_config_whitelist, [ + {:pleroma, :instance}, + {:pleroma, Pleroma.Web.Metadata} +] +``` ### Multi-factor authentication - :two_factor_authentication * `totp` - a list containing TOTP configuration diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 9f1fd3aeb..9c5fbfc5d 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -949,7 +949,8 @@ def config_show(conn, _params) do def config_update(conn, %{"configs" => configs}) do with :ok <- configurable_from_database(conn) do {_errors, results} = - Enum.map(configs, fn + Enum.filter(configs, &whitelisted_config?/1) + |> Enum.map(fn %{"group" => group, "key" => key, "delete" => true} = params -> ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) @@ -1011,6 +1012,16 @@ defp configurable_from_database(conn) do end end + defp whitelisted_config?(%{"group" => group, "key" => key}) do + if whitelisted_configs = Config.get(:database_config_whitelist) do + Enum.any?(whitelisted_configs, fn {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + end) + else + true + end + end + def reload_emoji(conn, _params) do Pleroma.Emoji.reload() diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 4697af50e..31e73d6a5 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2943,6 +2943,30 @@ test "proxy tuple ip", %{conn: conn} do ] } end + + test "doesn't set keys not in the whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :key1}, + {:pleroma, :key2}, + {:pleroma, Pleroma.Captcha.NotReal} + ]) + + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{group: ":pleroma", key: ":key2", value: "value2"}, + %{group: ":pleroma", key: ":key3", value: "value3"}, + %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, + %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"} + ] + }) + + assert Application.get_env(:pleroma, :key1) == "value1" + assert Application.get_env(:pleroma, :key2) == "value2" + assert Application.get_env(:pleroma, :key3) == nil + assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil + assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" + end end describe "GET /api/pleroma/admin/restart" do -- cgit v1.2.3 From 63a1a82f38d3d8a63dd7d52e1412446274c94722 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 12 May 2020 19:14:35 +0300 Subject: [#2456] Added support for `embed_relationships` param, nailed down endpoints which should support it. Fixed :source_mutes relationships subset fetching. --- lib/pleroma/user_relationship.ex | 43 +++++++++++++++++++--- lib/pleroma/web/controller_helper.ex | 5 +++ .../mastodon_api/controllers/account_controller.ex | 17 ++++++++- .../mastodon_api/controllers/search_controller.ex | 14 ++++++- lib/pleroma/web/mastodon_api/views/account_view.ex | 29 ++++++++++++++- .../web/mastodon_api/views/notification_view.ex | 2 +- lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- 7 files changed, 99 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index 235ad427c..6dfdd2860 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -87,6 +87,22 @@ def dictionary( source_to_target_rel_types \\ nil, target_to_source_rel_types \\ nil ) + + def dictionary( + _source_users, + _target_users, + [] = _source_to_target_rel_types, + [] = _target_to_source_rel_types + ) do + [] + end + + def dictionary( + source_users, + target_users, + source_to_target_rel_types, + target_to_source_rel_types + ) when is_list(source_users) and is_list(target_users) do source_user_ids = User.binary_id(source_users) target_user_ids = User.binary_id(target_users) @@ -138,11 +154,16 @@ def view_relationships_option(nil = _reading_user, _actors, _opts) do def view_relationships_option(%User{} = reading_user, actors, opts) do {source_to_target_rel_types, target_to_source_rel_types} = - if opts[:source_mutes_only] do - # This option is used for rendering statuses (FE needs `muted` flag for each one anyways) - {[:mute], []} - else - {[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]} + case opts[:subset] do + :source_mutes -> + # Used for statuses rendering (FE needs `muted` flag for each status when statuses load) + {[:mute], []} + + nil -> + {[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]} + + unknown -> + raise "Unsupported :subset option value: #{inspect(unknown)}" end user_relationships = @@ -153,7 +174,17 @@ def view_relationships_option(%User{} = reading_user, actors, opts) do target_to_source_rel_types ) - following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors) + following_relationships = + case opts[:subset] do + :source_mutes -> + [] + + nil -> + FollowingRelationship.all_between_user_sets([reading_user], actors) + + unknown -> + raise "Unsupported :subset option value: #{inspect(unknown)}" + end %{user_relationships: user_relationships, following_relationships: following_relationships} end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 61fdec030..ae9b265b1 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -103,4 +103,9 @@ def try_render(conn, _, _) do def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) + + def embed_relationships?(params) do + # To do: change to `truthy_param?(params["embed_relationships"])` once PleromaFE supports it + not explicitly_falsy_param?(params["embed_relationships"]) + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 489441da5..ef41f9e96 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, + embed_relationships?: 1, json_response: 3 ] @@ -269,7 +270,13 @@ def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do conn |> add_link_headers(followers) - |> render("index.json", for: for_user, users: followers, as: :user) + # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 + |> render("index.json", + for: for_user, + users: followers, + as: :user, + embed_relationships: embed_relationships?(params) + ) end @doc "GET /api/v1/accounts/:id/following" @@ -288,7 +295,13 @@ def following(%{assigns: %{user: for_user, account: user}} = conn, params) do conn |> add_link_headers(followers) - |> render("index.json", for: for_user, users: followers, as: :user) + # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 + |> render("index.json", + for: for_user, + users: followers, + as: :user, + embed_relationships: embed_relationships?(params) + ) end @doc "GET /api/v1/accounts/:id/lists" diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index c30ae1c7a..632c4590f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web + alias Pleroma.Web.ControllerHelper alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView @@ -32,7 +33,13 @@ def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do conn |> put_view(AccountView) - |> render("index.json", users: accounts, for: user, as: :user) + # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 + |> render("index.json", + users: accounts, + for: user, + as: :user, + embed_relationships: ControllerHelper.embed_relationships?(params) + ) end def search2(conn, params), do: do_search(:v2, conn, params) @@ -75,6 +82,7 @@ defp search_options(params, user) do offset: params[:offset], type: params[:type], author: get_author(params), + embed_relationships: ControllerHelper.embed_relationships?(params), for_user: user ] |> Enum.filter(&elem(&1, 1)) @@ -86,7 +94,9 @@ defp resource_search(_, "accounts", query, options) do AccountView.render("index.json", users: accounts, for: options[:for_user], - as: :user + as: :user, + # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 + embed_relationships: options[:embed_relationships] ) end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b3a14d255..6304d77ca 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -13,6 +13,22 @@ 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(reading_user) || !opts[:embed_relationships] -> + UserRelationship.view_relationships_option(nil, []) + + true -> + UserRelationship.view_relationships_option(reading_user, users) + end + + opts = Map.put(opts, :relationships, relationships_opt) + users |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) @@ -175,6 +191,17 @@ defp do_render("show.json", %{user: user} = opts) do } end) + relationship = + if opts[:embed_relationships] do + render("relationship.json", %{ + user: opts[:for], + target: user, + relationships: opts[:relationships] + }) + else + %{} + end + %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -213,7 +240,7 @@ defp do_render("show.json", %{user: user} = opts) do hide_followers: user.hide_followers, hide_follows: user.hide_follows, hide_favorites: user.hide_favorites, - relationship: %{}, + relationship: relationship, skip_thread_containment: user.skip_thread_containment, background_image: image_url(user.background) |> MediaProxy.url() } diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index a53218d59..c46ddcf55 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -51,7 +51,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op |> Enum.filter(& &1) |> Kernel.++(move_activities_targets) - UserRelationship.view_relationships_option(reading_user, actors, source_mutes_only: true) + UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes) 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 f7895c514..05a26017a 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -107,7 +107,7 @@ def render("index.json", opts) do |> Enum.map(&get_user(&1.data["actor"], false)) |> Enum.filter(& &1) - UserRelationship.view_relationships_option(reading_user, actors, source_mutes_only: true) + UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes) end opts = -- cgit v1.2.3 From 68cca29dcf87d68be82c0287f3803847abd54353 Mon Sep 17 00:00:00 2001 From: href Date: Tue, 12 May 2020 18:51:10 +0200 Subject: Fix typo in test --- test/integration/mastodon_websocket_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs index 63f351a80..f61150cd2 100644 --- a/test/integration/mastodon_websocket_test.exs +++ b/test/integration/mastodon_websocket_test.exs @@ -118,7 +118,7 @@ test "accepts valid token on Sec-WebSocket-Protocol header", %{token: token} do assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}]) assert capture_log(fn -> - assert {:error, {401, "Forbidden"}} = + assert {:error, {401, _}} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}]) Process.sleep(30) -- cgit v1.2.3 From bfb48e3db6009c31e52cfe5ac4828a6143d7e549 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 12 May 2020 20:55:01 +0300 Subject: [#2456] OpenAPI: added `embed_relationships` param definition. --- lib/pleroma/web/api_spec/helpers.ex | 9 ++ .../web/api_spec/operations/account_operation.ex | 6 +- .../web/api_spec/operations/search_operation.ex | 117 +++++++++++---------- 3 files changed, 73 insertions(+), 59 deletions(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index 183df43ee..ee077a3f9 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -47,6 +47,15 @@ def pagination_params do ] end + def embed_relationships_param do + Operation.parameter( + :embed_relationships, + :query, + :boolean, + "Embed relationships into accounts (Pleroma extension)" + ) + end + def empty_object_response do Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}}) end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 70069d6f9..c2a56b786 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -156,7 +156,8 @@ def followers_operation do description: "Accounts which follow the given account, if network is not hidden by the account owner.", parameters: - [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), + [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ + pagination_params() ++ [embed_relationships_param()], responses: %{ 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } @@ -172,7 +173,8 @@ def following_operation do description: "Accounts which the given account is following, if network is not hidden by the account owner.", parameters: - [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), + [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ + pagination_params() ++ [embed_relationships_param()], responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} } end diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index 6ea00a9a8..0dd908d7f 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -24,29 +24,30 @@ def account_search_operation do tags: ["Search"], summary: "Search for matching accounts by username or display name", operationId: "SearchController.account_search", - parameters: [ - Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", - required: true - ), - Operation.parameter( - :limit, - :query, - %Schema{type: :integer, default: 40}, - "Maximum number of results" - ), - Operation.parameter( - :resolve, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Attempt WebFinger lookup. Use this when `q` is an exact address." - ), - Operation.parameter( - :following, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Only include accounts that the user is following" - ) - ], + parameters: + [ + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 40}, + "Maximum number of results" + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup. Use this when `q` is an exact address." + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ) + ] ++ [embed_relationships_param()], responses: %{ 200 => Operation.response( @@ -65,40 +66,42 @@ def search_operation do security: [%{"oAuth" => ["read:search"]}], operationId: "SearchController.search", deprecated: true, - parameters: [ - Operation.parameter( - :account_id, - :query, - FlakeID, - "If provided, statuses returned will be authored only by this account" - ), - Operation.parameter( - :type, - :query, - %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, - "Search type" - ), - Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true), - Operation.parameter( - :resolve, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Attempt WebFinger lookup" - ), - Operation.parameter( - :following, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Only include accounts that the user is following" - ), - Operation.parameter( - :offset, - :query, - %Schema{type: :integer}, - "Offset" - ) - | pagination_params() - ], + parameters: + [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", + required: true + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer}, + "Offset" + ) + ] ++ pagination_params() ++ [embed_relationships_param()], responses: %{ 200 => Operation.response("Results", "application/json", results()) } -- cgit v1.2.3 From da2fe8920de0ab7455de9f72e74a3ef764db6ad3 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 12 May 2020 23:03:21 +0300 Subject: fix eldap being required for non-OTP releases --- mix.exs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 97b561790..3656059f2 100644 --- a/mix.exs +++ b/mix.exs @@ -36,7 +36,7 @@ def project do releases: [ pleroma: [ include_executables_for: [:unix], - applications: [ex_syslogger: :load, syslog: :load], + applications: [ex_syslogger: :load, syslog: :load, eldap: :transient], steps: [:assemble, &put_otp_version/1, ©_files/1, ©_nginx_config/1] ] ] @@ -78,8 +78,7 @@ def application do :comeonin, :quack, :fast_sanitize, - :ssl, - :eldap + :ssl ], included_applications: [:ex_syslogger] ] -- cgit v1.2.3 From a2fcfc78c9dcf33081db47292d96ffa7c7709abb Mon Sep 17 00:00:00 2001 From: Stephanie Wilde-Hobbs Date: Tue, 12 May 2020 21:07:33 +0100 Subject: Filter config descriptions by config whitelist --- lib/pleroma/docs/json.ex | 1 - lib/pleroma/web/admin_api/admin_api_controller.ex | 19 +++++++-- test/web/admin_api/admin_api_controller_test.exs | 51 ++++++++++++++++++----- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index 74f8b2615..d1cf1f487 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -18,7 +18,6 @@ def compile do with config <- Pleroma.Config.Loader.read("config/description.exs") do config[:pleroma][:config_description] |> Pleroma.Docs.Generator.convert_to_strings() - |> Jason.encode!() end end end diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 9c5fbfc5d..fa064a8c7 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -37,7 +37,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do require Logger - @descriptions_json Pleroma.Docs.JSON.compile() + @descriptions Pleroma.Docs.JSON.compile() @users_page_size 50 plug( @@ -892,9 +892,14 @@ def list_log(conn, params) do end def config_descriptions(conn, _params) do + descriptions_json = + @descriptions + |> Enum.filter(&whitelisted_config?/1) + |> Jason.encode!() + conn |> Plug.Conn.put_resp_content_type("application/json") - |> Plug.Conn.send_resp(200, @descriptions_json) + |> Plug.Conn.send_resp(200, descriptions_json) end def config_show(conn, %{"only_db" => true}) do @@ -1012,7 +1017,7 @@ defp configurable_from_database(conn) do end end - defp whitelisted_config?(%{"group" => group, "key" => key}) do + defp whitelisted_config?(group, key) do if whitelisted_configs = Config.get(:database_config_whitelist) do Enum.any?(whitelisted_configs, fn {whitelisted_group, whitelisted_key} -> group == inspect(whitelisted_group) && key == inspect(whitelisted_key) @@ -1022,6 +1027,14 @@ defp whitelisted_config?(%{"group" => group, "key" => key}) do end end + defp whitelisted_config?(%{"group" => group, "key" => key}) do + whitelisted_config?(group, key) + end + + defp whitelisted_config?(%{:group => group} = config) do + whitelisted_config?(group, config[:key]) + end + def reload_emoji(conn, _params) do Pleroma.Emoji.reload() diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 31e73d6a5..7d42a400c 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -3604,19 +3604,50 @@ test "it deletes the note", %{conn: conn, report_id: report_id} do end end - test "GET /api/pleroma/admin/config/descriptions", %{conn: conn} do - admin = insert(:user, is_admin: true) + describe "GET /api/pleroma/admin/config/descriptions" do + test "structure", %{conn: conn} do + admin = insert(:user, is_admin: true) - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + assert [child | _others] = json_response(conn, 200) - assert [child | _others] = json_response(conn, 200) + assert child["children"] + assert child["key"] + assert String.starts_with?(child["group"], ":") + assert child["description"] + end - assert child["children"] - assert child["key"] - assert String.starts_with?(child["group"], ":") - assert child["description"] + test "filters by database configuration whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :instance}, + {:pleroma, :activitypub}, + {:pleroma, Pleroma.Upload} + ]) + + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + children = json_response(conn, 200) + + assert length(children) == 3 + + assert Enum.all?(children, fn c -> c["group"] == ":pleroma" end) + + instance = Enum.find(children, fn c -> c["key"] == ":instance" end) + assert instance["children"] + + activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) + assert activitypub["children"] + + web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) + assert web_endpoint["children"] + end end describe "/api/pleroma/admin/stats" do -- cgit v1.2.3 From 7803a85d2ced092fbd8e0f1bde0944bd27f8d649 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 12 May 2020 23:59:26 +0400 Subject: Add OpenAPI spec for StatusController --- lib/pleroma/bbs/handler.ex | 4 +- lib/pleroma/scheduled_activity.ex | 2 +- lib/pleroma/user/welcome_message.ex | 4 +- lib/pleroma/web/admin_api/admin_api_controller.ex | 9 +- .../web/api_spec/operations/status_operation.ex | 502 ++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/status.ex | 198 ++++++-- .../web/api_spec/schemas/visibility_scope.ex | 2 +- lib/pleroma/web/common_api/activity_draft.ex | 22 +- lib/pleroma/web/common_api/common_api.ex | 29 +- lib/pleroma/web/common_api/utils.ex | 18 +- .../mastodon_api/controllers/status_controller.ex | 75 +-- lib/pleroma/web/mastodon_api/views/account_view.ex | 6 +- lib/pleroma/workers/scheduled_activity_worker.ex | 2 + test/activity_test.exs | 8 +- test/bbs/handler_test.exs | 6 +- test/bookmark_test.exs | 6 +- test/conversation/participation_test.exs | 72 +-- test/conversation_test.exs | 22 +- test/html_test.exs | 14 +- test/integration/mastodon_websocket_test.exs | 2 +- test/notification_test.exs | 126 ++--- test/stats_test.exs | 22 +- test/tasks/count_statuses_test.exs | 6 +- test/tasks/database_test.exs | 6 +- test/tasks/digest_test.exs | 2 +- test/tasks/refresh_counter_cache_test.exs | 14 +- test/tasks/user_test.exs | 2 +- test/user_test.exs | 16 +- .../activity_pub/activity_pub_controller_test.exs | 2 +- test/web/activity_pub/activity_pub_test.exs | 248 +++++----- test/web/activity_pub/object_validator_test.exs | 8 +- test/web/activity_pub/side_effects_test.exs | 10 +- .../transmogrifier/emoji_react_handling_test.exs | 4 +- .../transmogrifier/like_handling_test.exs | 6 +- .../transmogrifier/undo_handling_test.exs | 10 +- test/web/activity_pub/transmogrifier_test.exs | 71 ++- test/web/activity_pub/utils_test.exs | 26 +- test/web/activity_pub/views/object_view_test.exs | 2 +- test/web/activity_pub/views/user_view_test.exs | 2 +- test/web/activity_pub/visibilty_test.exs | 12 +- test/web/admin_api/admin_api_controller_test.exs | 46 +- test/web/admin_api/views/report_view_test.exs | 2 +- test/web/common_api/common_api_test.exs | 112 +++-- test/web/common_api/common_api_utils_test.exs | 14 +- test/web/federator_test.exs | 4 +- test/web/feed/tag_controller_test.exs | 12 +- .../controllers/account_controller_test.exs | 29 +- .../controllers/conversation_controller_test.exs | 58 +-- .../controllers/instance_controller_test.exs | 2 +- .../controllers/notification_controller_test.exs | 77 ++-- .../controllers/poll_controller_test.exs | 38 +- .../controllers/report_controller_test.exs | 2 +- .../controllers/search_controller_test.exs | 38 +- .../controllers/status_controller_test.exs | 513 +++++++++++++-------- .../controllers/timeline_controller_test.exs | 64 ++- test/web/mastodon_api/mastodon_api_test.exs | 4 +- test/web/mastodon_api/views/account_view_test.exs | 13 +- .../mastodon_api/views/conversation_view_test.exs | 2 +- .../mastodon_api/views/notification_view_test.exs | 8 +- test/web/mastodon_api/views/poll_view_test.exs | 36 +- .../views/scheduled_activity_view_test.exs | 4 +- test/web/mastodon_api/views/status_view_test.exs | 53 ++- test/web/metadata/twitter_card_test.exs | 6 +- .../controllers/account_controller_test.exs | 8 +- .../controllers/pleroma_api_controller_test.exs | 42 +- test/web/push/impl_test.exs | 28 +- test/web/rich_media/helpers_test.exs | 28 +- test/web/static_fe/static_fe_controller_test.exs | 27 +- test/web/streamer/streamer_test.exs | 64 +-- test/workers/cron/digest_emails_worker_test.exs | 2 +- test/workers/cron/new_users_digest_worker_test.exs | 4 +- 71 files changed, 1856 insertions(+), 1082 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/status_operation.ex diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex index c7bc8ef6c..12d64c2fe 100644 --- a/lib/pleroma/bbs/handler.ex +++ b/lib/pleroma/bbs/handler.ex @@ -66,7 +66,7 @@ def handle_command(%{user: user} = state, "r " <> text) do with %Activity{} <- Activity.get_by_id(activity_id), {:ok, _activity} <- - CommonAPI.post(user, %{"status" => rest, "in_reply_to_status_id" => activity_id}) do + CommonAPI.post(user, %{status: rest, in_reply_to_status_id: activity_id}) do IO.puts("Replied!") else _e -> IO.puts("Could not reply...") @@ -78,7 +78,7 @@ def handle_command(%{user: user} = state, "r " <> text) do def handle_command(%{user: user} = state, "p " <> text) do text = String.trim(text) - with {:ok, _activity} <- CommonAPI.post(user, %{"status" => text}) do + with {:ok, _activity} <- CommonAPI.post(user, %{status: text}) do IO.puts("Posted!") else _e -> IO.puts("Could not post...") diff --git a/lib/pleroma/scheduled_activity.ex b/lib/pleroma/scheduled_activity.ex index 8ff06a462..0937cb7db 100644 --- a/lib/pleroma/scheduled_activity.ex +++ b/lib/pleroma/scheduled_activity.ex @@ -40,7 +40,7 @@ defp with_media_attachments( %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset ) when is_list(media_ids) do - media_attachments = Utils.attachments_from_ids(%{"media_ids" => media_ids}) + media_attachments = Utils.attachments_from_ids(%{media_ids: media_ids}) params = params diff --git a/lib/pleroma/user/welcome_message.ex b/lib/pleroma/user/welcome_message.ex index f0ac8ebae..f8f520285 100644 --- a/lib/pleroma/user/welcome_message.ex +++ b/lib/pleroma/user/welcome_message.ex @@ -10,8 +10,8 @@ def post_welcome_message_to_user(user) do with %User{} = sender_user <- welcome_user(), message when is_binary(message) <- welcome_message() do CommonAPI.post(sender_user, %{ - "visibility" => "direct", - "status" => "@#{user.nickname}\n#{message}" + visibility: "direct", + status: "@#{user.nickname}\n#{message}" }) else _ -> {:ok, nil} diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 9f1fd3aeb..9821173d0 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -844,15 +844,20 @@ def status_show(conn, %{"id" => id}) do end def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do + params = + params + |> Map.take(["sensitive", "visibility"]) + |> Map.new(fn {key, value} -> {String.to_existing_atom(key), value} end) + with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do - {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"]) + {:ok, sensitive} = Ecto.Type.cast(:boolean, params[:sensitive]) ModerationLog.insert_log(%{ action: "status_update", actor: admin, subject: activity, sensitive: sensitive, - visibility: params["visibility"] + visibility: params[:visibility] }) conn diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex new file mode 100644 index 000000000..2c28b23aa --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -0,0 +1,502 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.StatusOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.AccountOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Statuses"], + summary: "Get multiple statuses by IDs", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :ids, + :query, + %Schema{type: :array, items: FlakeID}, + "Array of account IDs" + ) + ], + operationId: "StatusController.index", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + } + } + end + + def create_operation do + %Operation{ + tags: ["Statuses"], + summary: "Publish new status", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Post a new status", + operationId: "StatusController.create", + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => + Operation.response( + "Status. When `scheduled_at` is present, ScheduledStatus is returned instead", + "application/json", + %Schema{oneOf: [Status, ScheduledStatus]} + ), + 422 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Statuses"], + summary: "View specific status", + description: "View information about a status", + operationId: "StatusController.show", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Statuses"], + summary: "Delete status", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Delete one of your own statuses", + operationId: "StatusController.delete", + parameters: [id_param()], + responses: %{ + 200 => empty_object_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reblog_operation do + %Operation{ + tags: ["Statuses"], + summary: "Boost", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Reshare a status", + operationId: "StatusController.reblog", + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + visibility: %Schema{allOf: [VisibilityScope], default: "public"} + } + }), + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unreblog_operation do + %Operation{ + tags: ["Statuses"], + summary: "Undo boost", + security: [%{"oAuth" => ["write:statuses"]}], + description: "Undo a reshare of a status", + operationId: "StatusController.unreblog", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def favourite_operation do + %Operation{ + tags: ["Statuses"], + summary: "Favourite", + security: [%{"oAuth" => ["write:favourites"]}], + description: "Add a status to your favourites list", + operationId: "StatusController.favourite", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unfavourite_operation do + %Operation{ + tags: ["Statuses"], + summary: "Undo favourite", + security: [%{"oAuth" => ["write:favourites"]}], + description: "Remove a status from your favourites list", + operationId: "StatusController.unfavourite", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def pin_operation do + %Operation{ + tags: ["Statuses"], + summary: "Pin to profile", + security: [%{"oAuth" => ["write:accounts"]}], + description: "Feature one of your own public statuses at the top of your profile", + operationId: "StatusController.pin", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unpin_operation do + %Operation{ + tags: ["Statuses"], + summary: "Unpin to profile", + security: [%{"oAuth" => ["write:accounts"]}], + description: "Unfeature a status from the top of your profile", + operationId: "StatusController.unpin", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def bookmark_operation do + %Operation{ + tags: ["Statuses"], + summary: "Bookmark", + security: [%{"oAuth" => ["write:bookmarks"]}], + description: "Privately bookmark a status", + operationId: "StatusController.bookmark", + parameters: [id_param()], + responses: %{ + 200 => status_response() + } + } + end + + def unbookmark_operation do + %Operation{ + tags: ["Statuses"], + summary: "Undo bookmark", + security: [%{"oAuth" => ["write:bookmarks"]}], + description: "Remove a status from your private bookmarks", + operationId: "StatusController.unbookmark", + parameters: [id_param()], + responses: %{ + 200 => status_response() + } + } + end + + def mute_conversation_operation do + %Operation{ + tags: ["Statuses"], + summary: "Mute conversation", + security: [%{"oAuth" => ["write:mutes"]}], + description: + "Do not receive notifications for the thread that this status is part of. Must be a thread in which you are a participant.", + operationId: "StatusController.mute_conversation", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def unmute_conversation_operation do + %Operation{ + tags: ["Statuses"], + summary: "Unmute conversation", + security: [%{"oAuth" => ["write:mutes"]}], + description: + "Start receiving notifications again for the thread that this status is part of", + operationId: "StatusController.unmute_conversation", + parameters: [id_param()], + responses: %{ + 200 => status_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def card_operation do + %Operation{ + tags: ["Statuses"], + deprecated: true, + summary: "Preview card", + description: "Deprecated in favor of card property inlined on Status entity", + operationId: "StatusController.card", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response("Card", "application/json", %Schema{ + type: :object, + nullable: true, + properties: %{ + type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]}, + provider_name: %Schema{type: :string, nullable: true}, + provider_url: %Schema{type: :string, format: :uri}, + url: %Schema{type: :string, format: :uri}, + image: %Schema{type: :string, nullable: true, format: :uri}, + title: %Schema{type: :string}, + description: %Schema{type: :string} + } + }) + } + } + end + + def favourited_by_operation do + %Operation{ + tags: ["Statuses"], + summary: "Favourited by", + description: "View who favourited a given status", + operationId: "StatusController.favourited_by", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def reblogged_by_operation do + %Operation{ + tags: ["Statuses"], + summary: "Boosted by", + description: "View who boosted a given status", + operationId: "StatusController.reblogged_by", + security: [%{"oAuth" => ["read:accounts"]}], + parameters: [id_param()], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + AccountOperation.array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def context_operation do + %Operation{ + tags: ["Statuses"], + summary: "Parent and child statuses", + description: "View statuses above and below this status in the thread", + operationId: "StatusController.context", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [id_param()], + responses: %{ + 200 => Operation.response("Context", "application/json", context()) + } + } + end + + def favourites_operation do + %Operation{ + tags: ["Statuses"], + summary: "Favourited statuses", + description: "Statuses the user has favourited", + operationId: "StatusController.favourites", + parameters: pagination_params(), + security: [%{"oAuth" => ["read:favourites"]}], + responses: %{ + 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) + } + } + end + + def bookmarks_operation do + %Operation{ + tags: ["Statuses"], + summary: "Bookmarked statuses", + description: "Statuses the user has bookmarked", + operationId: "StatusController.bookmarks", + parameters: [ + Operation.parameter(:with_relationships, :query, BooleanLike, "Include relationships") + | pagination_params() + ], + security: [%{"oAuth" => ["read:bookmarks"]}], + responses: %{ + 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) + } + } + end + + defp array_of_statuses do + %Schema{type: :array, items: Status, example: [Status.schema().example]} + end + + defp create_request do + %Schema{ + title: "StatusCreateRequest", + type: :object, + properties: %{ + status: %Schema{ + type: :string, + description: + "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided." + }, + media_ids: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "Array of Attachment ids to be attached as media. If provided, `status` becomes optional, and `poll` cannot be used." + }, + poll: %Schema{ + type: :object, + required: [:options], + properties: %{ + options: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "Array of possible answers. If provided, `media_ids` cannot be used, and `poll[expires_in]` must be provided." + }, + expires_in: %Schema{ + type: :integer, + description: + "Duration the poll should be open, in seconds. If provided, `media_ids` cannot be used, and `poll[options]` must be provided." + }, + multiple: %Schema{type: :boolean, description: "Allow multiple choices?"}, + hide_totals: %Schema{ + type: :boolean, + description: "Hide vote counts until the poll ends?" + } + } + }, + in_reply_to_id: %Schema{ + allOf: [FlakeID], + description: "ID of the status being replied to, if status is a reply" + }, + sensitive: %Schema{ + type: :boolean, + description: "Mark status and attached media as sensitive?" + }, + spoiler_text: %Schema{ + type: :string, + description: + "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field." + }, + scheduled_at: %Schema{ + type: :string, + format: :"date-time", + nullable: true, + description: + "ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future." + }, + language: %Schema{type: :string, description: "ISO 639 language code for this status."}, + # Pleroma-specific properties: + preview: %Schema{ + type: :boolean, + description: + "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" + }, + content_type: %Schema{ + type: :string, + description: + "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint." + }, + to: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply" + }, + visibility: %Schema{ + anyOf: [ + VisibilityScope, + %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"} + ], + description: + "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`" + }, + expires_in: %Schema{ + type: :integer, + description: + "The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour." + }, + in_reply_to_conversation_id: %Schema{ + type: :string, + description: + "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." + } + }, + example: %{ + "status" => "What time is it?", + "sensitive" => "false", + "poll" => %{ + "options" => ["Cofe", "Adventure"], + "expires_in" => 420 + } + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Status ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp status_response do + Operation.response("Status", "application/json", Status) + end + + defp context do + %Schema{ + title: "StatusContext", + description: + "Represents the tree around a given status. Used for reconstructing threads of statuses.", + type: :object, + required: [:ancestors, :descendants], + properties: %{ + ancestors: array_of_statuses(), + descendants: array_of_statuses() + }, + example: %{ + "ancestors" => [Status.schema().example], + "descendants" => [Status.schema().example] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 2572c9641..8b87cb25b 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -19,60 +19,127 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do description: "Response schema for a status", type: :object, properties: %{ - account: Account, + account: %Schema{allOf: [Account], description: "The account that authored this status"}, application: %Schema{ + description: "The application used to post this status", type: :object, properties: %{ name: %Schema{type: :string}, website: %Schema{type: :string, nullable: true, format: :uri} } }, - bookmarked: %Schema{type: :boolean}, + bookmarked: %Schema{type: :boolean, description: "Have you bookmarked this status?"}, card: %Schema{ type: :object, nullable: true, + description: "Preview card for links included within status content", + required: [:url, :title, :description, :type], properties: %{ - type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]}, - provider_name: %Schema{type: :string, nullable: true}, - provider_url: %Schema{type: :string, format: :uri}, - url: %Schema{type: :string, format: :uri}, - image: %Schema{type: :string, nullable: true, format: :uri}, - title: %Schema{type: :string}, - description: %Schema{type: :string} + type: %Schema{ + type: :string, + enum: ["link", "photo", "video", "rich"], + description: "The type of the preview card" + }, + provider_name: %Schema{ + type: :string, + nullable: true, + description: "The provider of the original resource" + }, + provider_url: %Schema{ + type: :string, + format: :uri, + description: "A link to the provider of the original resource" + }, + url: %Schema{type: :string, format: :uri, description: "Location of linked resource"}, + image: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "Preview thumbnail" + }, + title: %Schema{type: :string, description: "Title of linked resource"}, + description: %Schema{type: :string, description: "Description of preview"} } }, - content: %Schema{type: :string, format: :html}, - created_at: %Schema{type: :string, format: "date-time"}, - emojis: %Schema{type: :array, items: Emoji}, - favourited: %Schema{type: :boolean}, - favourites_count: %Schema{type: :integer}, + content: %Schema{type: :string, format: :html, description: "HTML-encoded status content"}, + created_at: %Schema{ + type: :string, + format: "date-time", + description: "The date when this status was created" + }, + emojis: %Schema{ + type: :array, + items: Emoji, + description: "Custom emoji to be used when rendering status content" + }, + favourited: %Schema{type: :boolean, description: "Have you favourited this status?"}, + favourites_count: %Schema{ + type: :integer, + description: "How many favourites this status has received" + }, id: FlakeID, - in_reply_to_account_id: %Schema{type: :string, nullable: true}, - in_reply_to_id: %Schema{type: :string, nullable: true}, - language: %Schema{type: :string, nullable: true}, + in_reply_to_account_id: %Schema{ + allOf: [FlakeID], + nullable: true, + description: "ID of the account being replied to" + }, + in_reply_to_id: %Schema{ + allOf: [FlakeID], + nullable: true, + description: "ID of the status being replied" + }, + language: %Schema{ + type: :string, + nullable: true, + description: "Primary language of this status" + }, media_attachments: %Schema{ type: :array, - items: Attachment + items: Attachment, + description: "Media that is attached to this status" }, mentions: %Schema{ type: :array, + description: "Mentions of users within the status content", items: %Schema{ type: :object, properties: %{ - id: %Schema{type: :string}, - acct: %Schema{type: :string}, - username: %Schema{type: :string}, - url: %Schema{type: :string, format: :uri} + id: %Schema{allOf: [FlakeID], description: "The account id of the mentioned user"}, + acct: %Schema{ + type: :string, + description: + "The webfinger acct: URI of the mentioned user. Equivalent to `username` for local users, or `username@domain` for remote users." + }, + username: %Schema{type: :string, description: "The username of the mentioned user"}, + url: %Schema{ + type: :string, + format: :uri, + description: "The location of the mentioned user's profile" + } } } }, - muted: %Schema{type: :boolean}, - pinned: %Schema{type: :boolean}, + muted: %Schema{ + type: :boolean, + description: "Have you muted notifications for this status's conversation?" + }, + pinned: %Schema{ + type: :boolean, + description: "Have you pinned this status? Only appears if the status is pinnable." + }, pleroma: %Schema{ type: :object, properties: %{ - content: %Schema{type: :object, additionalProperties: %Schema{type: :string}}, - conversation_id: %Schema{type: :integer}, + content: %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: + "A map consisting of alternate representations of the `content` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain`" + }, + conversation_id: %Schema{ + type: :integer, + description: "The ID of the AP context the status is associated with (if any)" + }, direct_conversation_id: %Schema{ type: :integer, nullable: true, @@ -81,6 +148,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do }, emoji_reactions: %Schema{ type: :array, + description: + "A list with emoji / reaction maps. Contains no information about the reacting users, for that use the /statuses/:id/reactions endpoint.", items: %Schema{ type: :object, properties: %{ @@ -90,27 +159,74 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do } } }, - expires_at: %Schema{type: :string, format: "date-time", nullable: true}, - in_reply_to_account_acct: %Schema{type: :string, nullable: true}, - local: %Schema{type: :boolean}, - spoiler_text: %Schema{type: :object, additionalProperties: %Schema{type: :string}}, - thread_muted: %Schema{type: :boolean} + expires_at: %Schema{ + type: :string, + format: "date-time", + nullable: true, + description: + "A datetime (ISO 8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire" + }, + in_reply_to_account_acct: %Schema{ + type: :string, + nullable: true, + description: "The `acct` property of User entity for replied user (if any)" + }, + local: %Schema{ + type: :boolean, + description: "`true` if the post was made on the local instance" + }, + spoiler_text: %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: + "A map consisting of alternate representations of the `spoiler_text` property with the key being it's mimetype. Currently the only alternate representation supported is `text/plain`." + }, + thread_muted: %Schema{ + type: :boolean, + description: "`true` if the thread the post belongs to is muted" + } } }, - poll: %Schema{type: Poll, nullable: true}, + poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"}, reblog: %Schema{ allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], - nullable: true + nullable: true, + description: "The status being reblogged" + }, + reblogged: %Schema{type: :boolean, description: "Have you boosted this status?"}, + reblogs_count: %Schema{ + type: :integer, + description: "How many boosts this status has received" + }, + replies_count: %Schema{ + type: :integer, + description: "How many replies this status has received" + }, + sensitive: %Schema{ + type: :boolean, + description: "Is this status marked as sensitive content?" + }, + spoiler_text: %Schema{ + type: :string, + description: + "Subject or summary line, below which status content is collapsed until expanded" }, - reblogged: %Schema{type: :boolean}, - reblogs_count: %Schema{type: :integer}, - replies_count: %Schema{type: :integer}, - sensitive: %Schema{type: :boolean}, - spoiler_text: %Schema{type: :string}, tags: %Schema{type: :array, items: Tag}, - uri: %Schema{type: :string, format: :uri}, - url: %Schema{type: :string, nullable: true, format: :uri}, - visibility: VisibilityScope + uri: %Schema{ + type: :string, + format: :uri, + description: "URI of the status used for federation" + }, + url: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "A link to the status's HTML representation" + }, + visibility: %Schema{ + allOf: [VisibilityScope], + description: "Visibility of this status" + } }, example: %{ "account" => %{ diff --git a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex index 8c81a4d73..831734e27 100644 --- a/lib/pleroma/web/api_spec/schemas/visibility_scope.ex +++ b/lib/pleroma/web/api_spec/schemas/visibility_scope.ex @@ -9,6 +9,6 @@ defmodule Pleroma.Web.ApiSpec.Schemas.VisibilityScope do title: "VisibilityScope", description: "Status visibility", type: :string, - enum: ["public", "unlisted", "private", "direct"] + enum: ["public", "unlisted", "private", "direct", "list"] }) end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 244cf2be5..3f1a50b96 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -58,16 +58,16 @@ def create(user, params) do end defp put_params(draft, params) do - params = Map.put_new(params, "in_reply_to_status_id", params["in_reply_to_id"]) + params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id]) %__MODULE__{draft | params: params} end - defp status(%{params: %{"status" => status}} = draft) do + defp status(%{params: %{status: status}} = draft) do %__MODULE__{draft | status: String.trim(status)} end defp summary(%{params: params} = draft) do - %__MODULE__{draft | summary: Map.get(params, "spoiler_text", "")} + %__MODULE__{draft | summary: Map.get(params, :spoiler_text, "")} end defp full_payload(%{status: status, summary: summary} = draft) do @@ -84,20 +84,20 @@ defp attachments(%{params: params} = draft) do %__MODULE__{draft | attachments: attachments} end - defp in_reply_to(%{params: %{"in_reply_to_status_id" => ""}} = draft), do: draft + defp in_reply_to(%{params: %{in_reply_to_status_id: ""}} = draft), do: draft - defp in_reply_to(%{params: %{"in_reply_to_status_id" => id}} = draft) when is_binary(id) do + defp in_reply_to(%{params: %{in_reply_to_status_id: id}} = draft) when is_binary(id) do %__MODULE__{draft | in_reply_to: Activity.get_by_id(id)} end - defp in_reply_to(%{params: %{"in_reply_to_status_id" => %Activity{} = in_reply_to}} = draft) do + defp in_reply_to(%{params: %{in_reply_to_status_id: %Activity{} = in_reply_to}} = draft) do %__MODULE__{draft | in_reply_to: in_reply_to} end defp in_reply_to(draft), do: draft defp in_reply_to_conversation(draft) do - in_reply_to_conversation = Participation.get(draft.params["in_reply_to_conversation_id"]) + in_reply_to_conversation = Participation.get(draft.params[:in_reply_to_conversation_id]) %__MODULE__{draft | in_reply_to_conversation: in_reply_to_conversation} end @@ -112,7 +112,7 @@ defp visibility(%{params: params} = draft) do end defp expires_at(draft) do - case CommonAPI.check_expiry_date(draft.params["expires_in"]) do + case CommonAPI.check_expiry_date(draft.params[:expires_in]) do {:ok, expires_at} -> %__MODULE__{draft | expires_at: expires_at} {:error, message} -> add_error(draft, message) end @@ -144,7 +144,7 @@ defp to_and_cc(draft) do addressed_users = draft.mentions |> Enum.map(fn {_, mentioned_user} -> mentioned_user.ap_id end) - |> Utils.get_addressed_users(draft.params["to"]) + |> Utils.get_addressed_users(draft.params[:to]) {to, cc} = Utils.get_to_and_cc( @@ -164,7 +164,7 @@ defp context(draft) do end defp sensitive(draft) do - sensitive = draft.params["sensitive"] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) + sensitive = draft.params[:sensitive] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) %__MODULE__{draft | sensitive: sensitive} end @@ -191,7 +191,7 @@ defp object(draft) do end defp preview?(draft) do - preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params["preview"]) + preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params[:preview]) %__MODULE__{draft | preview?: preview?} end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index fbef05e83..601caeb46 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -116,19 +116,18 @@ def delete(activity_id, user) do end def repeat(id, user, params \\ %{}) do - with {_, %Activity{data: %{"type" => "Create"}} = activity} <- - {:find_activity, Activity.get_by_id(id)}, - object <- Object.normalize(activity), - announce_activity <- Utils.get_existing_announce(user.ap_id, object), - public <- public_announce?(object, params) do + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id) do + object = Object.normalize(activity) + announce_activity = Utils.get_existing_announce(user.ap_id, object) + public = public_announce?(object, params) + if announce_activity do {:ok, announce_activity, object} else ActivityPub.announce(user, object, nil, true, public) end else - {:find_activity, _} -> {:error, :not_found} - _ -> {:error, dgettext("errors", "Could not repeat")} + _ -> {:error, :not_found} end end @@ -286,7 +285,7 @@ defp normalize_and_validate_choices(choices, object) do end end - def public_announce?(_, %{"visibility" => visibility}) + def public_announce?(_, %{visibility: visibility}) when visibility in ~w{public unlisted private direct}, do: visibility in ~w(public unlisted) @@ -296,11 +295,11 @@ def public_announce?(object, _) do def get_visibility(_, _, %Participation{}), do: {"direct", "direct"} - def get_visibility(%{"visibility" => visibility}, in_reply_to, _) + def get_visibility(%{visibility: visibility}, in_reply_to, _) when visibility in ~w{public unlisted private direct}, do: {visibility, get_replied_to_visibility(in_reply_to)} - def get_visibility(%{"visibility" => "list:" <> list_id}, in_reply_to, _) do + def get_visibility(%{visibility: "list:" <> list_id}, in_reply_to, _) do visibility = {:list, String.to_integer(list_id)} {visibility, get_replied_to_visibility(in_reply_to)} end @@ -358,7 +357,7 @@ def listen(user, %{"title" => _} = data) do end end - def post(user, %{"status" => _} = data) do + def post(user, %{status: _} = data) do with {:ok, draft} <- Pleroma.Web.CommonAPI.ActivityDraft.create(user, data) do draft.changes |> ActivityPub.create(draft.preview?) @@ -467,11 +466,11 @@ def update_activity_scope(activity_id, opts \\ %{}) do end end - defp toggle_sensitive(activity, %{"sensitive" => sensitive}) when sensitive in ~w(true false) do - toggle_sensitive(activity, %{"sensitive" => String.to_existing_atom(sensitive)}) + defp toggle_sensitive(activity, %{sensitive: sensitive}) when sensitive in ~w(true false) do + toggle_sensitive(activity, %{sensitive: String.to_existing_atom(sensitive)}) end - defp toggle_sensitive(%Activity{object: object} = activity, %{"sensitive" => sensitive}) + defp toggle_sensitive(%Activity{object: object} = activity, %{sensitive: sensitive}) when is_boolean(sensitive) do new_data = Map.put(object.data, "sensitive", sensitive) @@ -485,7 +484,7 @@ defp toggle_sensitive(%Activity{object: object} = activity, %{"sensitive" => sen defp toggle_sensitive(activity, _), do: {:ok, activity} - defp set_visibility(activity, %{"visibility" => visibility}) do + defp set_visibility(activity, %{visibility: visibility}) do Utils.update_activity_visibility(activity, visibility) end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 793f2e7f8..e8deee223 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -22,11 +22,11 @@ defmodule Pleroma.Web.CommonAPI.Utils do require Logger require Pleroma.Constants - def attachments_from_ids(%{"media_ids" => ids, "descriptions" => desc} = _) do + def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do attachments_from_ids_descs(ids, desc) end - def attachments_from_ids(%{"media_ids" => ids} = _) do + def attachments_from_ids(%{media_ids: ids}) do attachments_from_ids_no_descs(ids) end @@ -37,11 +37,11 @@ def attachments_from_ids_no_descs([]), do: [] def attachments_from_ids_no_descs(ids) do Enum.map(ids, fn media_id -> case Repo.get(Object, media_id) do - %Object{data: data} = _ -> data + %Object{data: data} -> data _ -> nil end end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) end def attachments_from_ids_descs([], _), do: [] @@ -51,14 +51,14 @@ def attachments_from_ids_descs(ids, descs_str) do Enum.map(ids, fn media_id -> case Repo.get(Object, media_id) do - %Object{data: data} = _ -> + %Object{data: data} -> Map.put(data, "name", descs[media_id]) _ -> nil end end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) end @spec get_to_and_cc( @@ -140,7 +140,7 @@ def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data) |> make_poll_data() end - def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data) + def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data) when is_list(options) do limits = Pleroma.Config.get([:instance, :poll_limits]) @@ -163,7 +163,7 @@ def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_i |> DateTime.add(expires_in) |> DateTime.to_iso8601() - key = if truthy_param?(data["poll"]["multiple"]), do: "anyOf", else: "oneOf" + key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf" poll = %{"type" => "Question", key => option_notes, "closed" => end_time} {:ok, {poll, emoji}} @@ -213,7 +213,7 @@ def make_content_html( |> Map.get("attachment_links", Config.get([:instance, :attachment_links])) |> truthy_param?() - content_type = get_content_type(data["content_type"]) + content_type = get_content_type(data[:content_type]) options = if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 12e3ba15e..25e499a77 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -24,6 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.ScheduledActivityView + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :show]) @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} @@ -97,12 +98,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.StatusOperation + @doc """ GET `/api/v1/statuses?ids[]=1&ids[]=2` `ids` query param is required """ - def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = params) do + def index(%{assigns: %{user: user}} = conn, %{ids: ids} = params) do limit = 100 activities = @@ -125,21 +128,29 @@ def index(%{assigns: %{user: user}} = conn, %{"ids" => ids} = params) do Creates a scheduled status when `scheduled_at` param is present and it's far enough """ def create( - %{assigns: %{user: user}} = conn, - %{"status" => _, "scheduled_at" => scheduled_at} = params + %{ + assigns: %{user: user}, + body_params: %{status: _, scheduled_at: scheduled_at} = params + } = conn, + _ ) when not is_nil(scheduled_at) do - params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + params = Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id]) + + attrs = %{ + params: Map.new(params, fn {key, value} -> {to_string(key), value} end), + scheduled_at: scheduled_at + } with {:far_enough, true} <- {:far_enough, ScheduledActivity.far_enough?(scheduled_at)}, - attrs <- %{"params" => params, "scheduled_at" => scheduled_at}, {:ok, scheduled_activity} <- ScheduledActivity.create(user, attrs) do conn |> put_view(ScheduledActivityView) |> render("show.json", scheduled_activity: scheduled_activity) else {:far_enough, _} -> - create(conn, Map.drop(params, ["scheduled_at"])) + params = Map.drop(params, [:scheduled_at]) + create(%Plug.Conn{conn | body_params: params}, %{}) error -> error @@ -151,8 +162,8 @@ def create( Creates a regular status """ - def create(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do - params = Map.put(params, "in_reply_to_status_id", params["in_reply_to_id"]) + def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, _) do + params = Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id]) with {:ok, activity} <- CommonAPI.post(user, params) do try_render(conn, "show.json", @@ -169,12 +180,13 @@ def create(%{assigns: %{user: user}} = conn, %{"status" => _} = params) do end end - def create(%{assigns: %{user: _user}} = conn, %{"media_ids" => _} = params) do - create(conn, Map.put(params, "status", "")) + def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = conn, _) do + params = Map.put(params, :status, "") + create(%Plug.Conn{conn | body_params: params}, %{}) end @doc "GET /api/v1/statuses/:id" - def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def show(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), true <- Visibility.visible_for_user?(activity, user) do try_render(conn, "show.json", @@ -188,7 +200,7 @@ def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "DELETE /api/v1/statuses/:id" - def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def delete(%{assigns: %{user: user}} = conn, %{id: id}) do with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do json(conn, %{}) else @@ -198,7 +210,7 @@ def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "POST /api/v1/statuses/:id/reblog" - def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do + def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or_id}) do with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user, params), %Activity{} = announce <- Activity.normalize(announce.data) do try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) @@ -206,7 +218,7 @@ def reblog(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id} = params) do end @doc "POST /api/v1/statuses/:id/unreblog" - def unreblog(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do + def unreblog(%{assigns: %{user: user}} = conn, %{id: activity_id}) do with {:ok, _unannounce} <- CommonAPI.unrepeat(activity_id, user), %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", %{activity: activity, for: user, as: :activity}) @@ -214,7 +226,7 @@ def unreblog(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do end @doc "POST /api/v1/statuses/:id/favourite" - def favourite(%{assigns: %{user: user}} = conn, %{"id" => activity_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) @@ -222,7 +234,7 @@ def favourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do end @doc "POST /api/v1/statuses/:id/unfavourite" - def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do + def unfavourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do with {:ok, _unfav} <- CommonAPI.unfavorite(activity_id, user), %Activity{} = activity <- Activity.get_by_id(activity_id) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -230,21 +242,21 @@ def unfavourite(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do end @doc "POST /api/v1/statuses/:id/pin" - def pin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end end @doc "POST /api/v1/statuses/:id/unpin" - def unpin(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do + def unpin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do with {:ok, activity} <- CommonAPI.unpin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) end end @doc "POST /api/v1/statuses/:id/bookmark" - def bookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def bookmark(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), @@ -254,7 +266,7 @@ def bookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "POST /api/v1/statuses/:id/unbookmark" - def unbookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), %User{} = user <- User.get_cached_by_nickname(user.nickname), true <- Visibility.visible_for_user?(activity, user), @@ -264,7 +276,7 @@ def unbookmark(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "POST /api/v1/statuses/:id/mute" - def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def mute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id), {:ok, activity} <- CommonAPI.add_mute(user, activity) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -272,7 +284,7 @@ def mute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "POST /api/v1/statuses/:id/unmute" - def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def unmute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id), {:ok, activity} <- CommonAPI.remove_mute(user, activity) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) @@ -281,7 +293,7 @@ def unmute_conversation(%{assigns: %{user: user}} = conn, %{"id" => id}) do @doc "GET /api/v1/statuses/:id/card" @deprecated "https://github.com/tootsuite/mastodon/pull/11213" - def card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do + def card(%{assigns: %{user: user}} = conn, %{id: status_id}) do with %Activity{} = activity <- Activity.get_by_id(status_id), true <- Visibility.visible_for_user?(activity, user) do data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) @@ -292,7 +304,7 @@ def card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do end @doc "GET /api/v1/statuses/:id/favourited_by" - def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def favourited_by(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do @@ -312,7 +324,7 @@ def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "GET /api/v1/statuses/:id/reblogged_by" - def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def reblogged_by(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{data: %{"announcements" => announces, "id" => ap_id}} <- @@ -344,7 +356,7 @@ def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do end @doc "GET /api/v1/statuses/:id/context" - def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def context(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do activities = ActivityPub.fetch_activities_for_context(activity.data["context"], %{ @@ -359,11 +371,12 @@ def context(%{assigns: %{user: user}} = conn, %{"id" => id}) do @doc "GET /api/v1/favourites" def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do - activities = - ActivityPub.fetch_favourites( - user, - Map.take(params, Pleroma.Pagination.page_keys()) - ) + params = + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.take(Pleroma.Pagination.page_keys()) + + activities = ActivityPub.fetch_favourites(user, params) conn |> add_link_headers(activities) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b7cdb52b1..835dfe9f4 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -337,7 +337,11 @@ defp maybe_put_role(data, %User{id: user_id} = user, %User{id: user_id}) do defp maybe_put_role(data, _, _), do: data defp maybe_put_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do - Kernel.put_in(data, [:pleroma, :notification_settings], user.notification_settings) + Kernel.put_in( + data, + [:pleroma, :notification_settings], + Map.from_struct(user.notification_settings) + ) end defp maybe_put_notification_settings(data, _, _), do: data diff --git a/lib/pleroma/workers/scheduled_activity_worker.ex b/lib/pleroma/workers/scheduled_activity_worker.ex index 8905f4ad0..97d1efbfb 100644 --- a/lib/pleroma/workers/scheduled_activity_worker.ex +++ b/lib/pleroma/workers/scheduled_activity_worker.ex @@ -30,6 +30,8 @@ def perform(%{"activity_id" => activity_id}, _job) do end defp post_activity(%ScheduledActivity{user_id: user_id, params: params} = scheduled_activity) do + params = Map.new(params, fn {key, value} -> {String.to_existing_atom(key), value} end) + with {:delete, {:ok, _}} <- {:delete, ScheduledActivity.delete(scheduled_activity)}, {:user, %User{} = user} <- {:user, User.get_cached_by_id(user_id)}, {:post, {:ok, _}} <- {:post, CommonAPI.post(user, params)} do diff --git a/test/activity_test.exs b/test/activity_test.exs index 0c19f481b..507027e5a 100644 --- a/test/activity_test.exs +++ b/test/activity_test.exs @@ -125,8 +125,8 @@ test "when association is not loaded" do "to" => ["https://www.w3.org/ns/activitystreams#Public"] } - {:ok, local_activity} = Pleroma.Web.CommonAPI.post(user, %{"status" => "find me!"}) - {:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{"status" => "更新情報"}) + {:ok, local_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "find me!"}) + {:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "更新情報"}) {:ok, job} = Pleroma.Web.Federator.incoming_ap_doc(params) {:ok, remote_activity} = ObanHelpers.perform(job) @@ -225,8 +225,8 @@ test "get_by_id/1" do test "all_by_actor_and_id/2" do user = insert(:user) - {:ok, %{id: id1}} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"}) - {:ok, %{id: id2}} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofefe"}) + {:ok, %{id: id1}} = Pleroma.Web.CommonAPI.post(user, %{status: "cofe"}) + {:ok, %{id: id2}} = Pleroma.Web.CommonAPI.post(user, %{status: "cofefe"}) assert [] == Activity.all_by_actor_and_id(user, []) diff --git a/test/bbs/handler_test.exs b/test/bbs/handler_test.exs index 74982547b..eb716486e 100644 --- a/test/bbs/handler_test.exs +++ b/test/bbs/handler_test.exs @@ -21,8 +21,8 @@ test "getting the home timeline" do {:ok, user} = User.follow(user, followed) - {:ok, _first} = CommonAPI.post(user, %{"status" => "hey"}) - {:ok, _second} = CommonAPI.post(followed, %{"status" => "hello"}) + {:ok, _first} = CommonAPI.post(user, %{status: "hey"}) + {:ok, _second} = CommonAPI.post(followed, %{status: "hello"}) output = capture_io(fn -> @@ -62,7 +62,7 @@ test "replying" do user = insert(:user) another_user = insert(:user) - {:ok, activity} = CommonAPI.post(another_user, %{"status" => "this is a test post"}) + {:ok, activity} = CommonAPI.post(another_user, %{status: "this is a test post"}) activity_object = Object.normalize(activity) output = diff --git a/test/bookmark_test.exs b/test/bookmark_test.exs index 021f79322..2726fe7cd 100644 --- a/test/bookmark_test.exs +++ b/test/bookmark_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.BookmarkTest do describe "create/2" do test "with valid params" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Some cool information"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"}) {:ok, bookmark} = Bookmark.create(user.id, activity.id) assert bookmark.user_id == user.id assert bookmark.activity_id == activity.id @@ -32,7 +32,7 @@ test "with invalid params" do test "with valid params" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Some cool information"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"}) {:ok, _bookmark} = Bookmark.create(user.id, activity.id) {:ok, _deleted_bookmark} = Bookmark.destroy(user.id, activity.id) @@ -45,7 +45,7 @@ test "gets a bookmark" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "Scientists Discover The Secret Behind Tenshi Eating A Corndog Being So Cute – Science Daily" }) diff --git a/test/conversation/participation_test.exs b/test/conversation/participation_test.exs index 3536842e8..59a1b6492 100644 --- a/test/conversation/participation_test.exs +++ b/test/conversation/participation_test.exs @@ -16,7 +16,7 @@ test "getting a participation will also preload things" do other_user = insert(:user) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hey @#{other_user.nickname}.", visibility: "direct"}) [participation] = Participation.for_user(user) @@ -30,7 +30,7 @@ test "for a new conversation or a reply, it doesn't mark the author's participat other_user = insert(:user) {:ok, _} = - CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hey @#{other_user.nickname}.", visibility: "direct"}) user = User.get_cached_by_id(user.id) other_user = User.get_cached_by_id(other_user.id) @@ -43,9 +43,9 @@ test "for a new conversation or a reply, it doesn't mark the author's participat {:ok, _} = CommonAPI.post(other_user, %{ - "status" => "Hey @#{user.nickname}.", - "visibility" => "direct", - "in_reply_to_conversation_id" => participation.id + status: "Hey @#{user.nickname}.", + visibility: "direct", + in_reply_to_conversation_id: participation.id }) user = User.get_cached_by_id(user.id) @@ -64,7 +64,7 @@ test "for a new conversation, it sets the recipents of the participation" do third_user = insert(:user) {:ok, activity} = - CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hey @#{other_user.nickname}.", visibility: "direct"}) user = User.get_cached_by_id(user.id) other_user = User.get_cached_by_id(other_user.id) @@ -79,9 +79,9 @@ test "for a new conversation, it sets the recipents of the participation" do {:ok, _activity} = CommonAPI.post(user, %{ - "in_reply_to_status_id" => activity.id, - "status" => "Hey @#{third_user.nickname}.", - "visibility" => "direct" + in_reply_to_status_id: activity.id, + status: "Hey @#{third_user.nickname}.", + visibility: "direct" }) [participation] = Participation.for_user(user) @@ -154,14 +154,14 @@ test "it marks all the user's participations as read" do test "gets all the participations for a user, ordered by updated at descending" do user = insert(:user) - {:ok, activity_one} = CommonAPI.post(user, %{"status" => "x", "visibility" => "direct"}) - {:ok, activity_two} = CommonAPI.post(user, %{"status" => "x", "visibility" => "direct"}) + {:ok, activity_one} = CommonAPI.post(user, %{status: "x", visibility: "direct"}) + {:ok, activity_two} = CommonAPI.post(user, %{status: "x", visibility: "direct"}) {:ok, activity_three} = CommonAPI.post(user, %{ - "status" => "x", - "visibility" => "direct", - "in_reply_to_status_id" => activity_one.id + status: "x", + visibility: "direct", + in_reply_to_status_id: activity_one.id }) # Offset participations because the accuracy of updated_at is down to a second @@ -201,7 +201,7 @@ test "gets all the participations for a user, ordered by updated at descending" test "Doesn't die when the conversation gets empty" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) [participation] = Participation.for_user_with_last_activity_id(user) assert participation.last_activity_id == activity.id @@ -215,7 +215,7 @@ test "it sets recipients, always keeping the owner of the participation even whe user = insert(:user) other_user = insert(:user) - {:ok, _activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, _activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) [participation] = Participation.for_user_with_last_activity_id(user) participation = Repo.preload(participation, :recipients) @@ -239,26 +239,26 @@ test "when the user blocks a recipient, the existing conversations with them are {:ok, _direct1} = CommonAPI.post(third_user, %{ - "status" => "Hi @#{blocker.nickname}", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}", + visibility: "direct" }) {:ok, _direct2} = CommonAPI.post(third_user, %{ - "status" => "Hi @#{blocker.nickname}, @#{blocked.nickname}", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}, @#{blocked.nickname}", + visibility: "direct" }) {:ok, _direct3} = CommonAPI.post(blocked, %{ - "status" => "Hi @#{blocker.nickname}", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}", + visibility: "direct" }) {:ok, _direct4} = CommonAPI.post(blocked, %{ - "status" => "Hi @#{blocker.nickname}, @#{third_user.nickname}", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}, @#{third_user.nickname}", + visibility: "direct" }) assert [%{read: false}, %{read: false}, %{read: false}, %{read: false}] = @@ -293,8 +293,8 @@ test "the new conversation with the blocked user is not marked as unread " do # When the blocked user is the author {:ok, _direct1} = CommonAPI.post(blocked, %{ - "status" => "Hi @#{blocker.nickname}", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}", + visibility: "direct" }) assert [%{read: true}] = Participation.for_user(blocker) @@ -303,8 +303,8 @@ test "the new conversation with the blocked user is not marked as unread " do # When the blocked user is a recipient {:ok, _direct2} = CommonAPI.post(third_user, %{ - "status" => "Hi @#{blocker.nickname}, @#{blocked.nickname}", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}, @#{blocked.nickname}", + visibility: "direct" }) assert [%{read: true}, %{read: true}] = Participation.for_user(blocker) @@ -321,8 +321,8 @@ test "the conversation with the blocked user is not marked as unread on a reply" {:ok, _direct1} = CommonAPI.post(blocker, %{ - "status" => "Hi @#{third_user.nickname}, @#{blocked.nickname}", - "visibility" => "direct" + status: "Hi @#{third_user.nickname}, @#{blocked.nickname}", + visibility: "direct" }) {:ok, _user_relationship} = User.block(blocker, blocked) @@ -334,9 +334,9 @@ test "the conversation with the blocked user is not marked as unread on a reply" # When it's a reply from the blocked user {:ok, _direct2} = CommonAPI.post(blocked, %{ - "status" => "reply", - "visibility" => "direct", - "in_reply_to_conversation_id" => blocked_participation.id + status: "reply", + visibility: "direct", + in_reply_to_conversation_id: blocked_participation.id }) assert [%{read: true}] = Participation.for_user(blocker) @@ -347,9 +347,9 @@ test "the conversation with the blocked user is not marked as unread on a reply" # When it's a reply from the third user {:ok, _direct3} = CommonAPI.post(third_user, %{ - "status" => "reply", - "visibility" => "direct", - "in_reply_to_conversation_id" => third_user_participation.id + status: "reply", + visibility: "direct", + in_reply_to_conversation_id: third_user_participation.id }) assert [%{read: true}] = Participation.for_user(blocker) diff --git a/test/conversation_test.exs b/test/conversation_test.exs index 056a0e920..359aa6840 100644 --- a/test/conversation_test.exs +++ b/test/conversation_test.exs @@ -18,7 +18,7 @@ test "it goes through old direct conversations" do other_user = insert(:user) {:ok, _activity} = - CommonAPI.post(user, %{"visibility" => "direct", "status" => "hey @#{other_user.nickname}"}) + CommonAPI.post(user, %{visibility: "direct", status: "hey @#{other_user.nickname}"}) Pleroma.Tests.ObanHelpers.perform_all() @@ -46,7 +46,7 @@ test "it creates a conversation for given ap_id" do test "public posts don't create conversations" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Hey"}) object = Pleroma.Object.normalize(activity) context = object.data["context"] @@ -62,7 +62,7 @@ test "it creates or updates a conversation and participations for a given DM" do tridi = insert(:user) {:ok, activity} = - CommonAPI.post(har, %{"status" => "Hey @#{jafnhar.nickname}", "visibility" => "direct"}) + CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct"}) object = Pleroma.Object.normalize(activity) context = object.data["context"] @@ -81,9 +81,9 @@ test "it creates or updates a conversation and participations for a given DM" do {:ok, activity} = CommonAPI.post(jafnhar, %{ - "status" => "Hey @#{har.nickname}", - "visibility" => "direct", - "in_reply_to_status_id" => activity.id + status: "Hey @#{har.nickname}", + visibility: "direct", + in_reply_to_status_id: activity.id }) object = Pleroma.Object.normalize(activity) @@ -105,9 +105,9 @@ test "it creates or updates a conversation and participations for a given DM" do {:ok, activity} = CommonAPI.post(tridi, %{ - "status" => "Hey @#{har.nickname}", - "visibility" => "direct", - "in_reply_to_status_id" => activity.id + status: "Hey @#{har.nickname}", + visibility: "direct", + in_reply_to_status_id: activity.id }) object = Pleroma.Object.normalize(activity) @@ -149,14 +149,14 @@ test "create_or_bump_for returns the conversation with participations" do jafnhar = insert(:user, local: false) {:ok, activity} = - CommonAPI.post(har, %{"status" => "Hey @#{jafnhar.nickname}", "visibility" => "direct"}) + CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "direct"}) {:ok, conversation} = Conversation.create_or_bump_for(activity) assert length(conversation.participations) == 2 {:ok, activity} = - CommonAPI.post(har, %{"status" => "Hey @#{jafnhar.nickname}", "visibility" => "public"}) + CommonAPI.post(har, %{status: "Hey @#{jafnhar.nickname}", visibility: "public"}) assert {:error, _} = Conversation.create_or_bump_for(activity) end diff --git a/test/html_test.exs b/test/html_test.exs index a006fd492..0a4b4ebbc 100644 --- a/test/html_test.exs +++ b/test/html_test.exs @@ -171,7 +171,7 @@ test "extracts the url" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "I think I just found the best github repo https://github.com/komeiji-satori/Dress" }) @@ -186,7 +186,7 @@ test "skips mentions" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "@#{other_user.nickname} install misskey! https://github.com/syuilo/misskey/blob/develop/docs/setup.en.md" }) @@ -203,8 +203,7 @@ test "skips hashtags" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => - "#cofe https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140" + status: "#cofe https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140" }) object = Object.normalize(activity) @@ -218,9 +217,9 @@ test "skips microformats hashtags" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "#cofe https://www.pixiv.net/member_illust.php?mode=medium&illust_id=72255140", - "content_type" => "text/html" + content_type: "text/html" }) object = Object.normalize(activity) @@ -232,8 +231,7 @@ test "skips microformats hashtags" do test "does not crash when there is an HTML entity in a link" do user = insert(:user) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "\"http://cofe.com/?boomer=ok&foo=bar\""}) + {:ok, activity} = CommonAPI.post(user, %{status: "\"http://cofe.com/?boomer=ok&foo=bar\""}) object = Object.normalize(activity) diff --git a/test/integration/mastodon_websocket_test.exs b/test/integration/mastodon_websocket_test.exs index f61150cd2..ea17e9feb 100644 --- a/test/integration/mastodon_websocket_test.exs +++ b/test/integration/mastodon_websocket_test.exs @@ -55,7 +55,7 @@ test "allows public streams without authentication" do test "receives well formatted events" do user = insert(:user) {:ok, _} = start_socket("?stream=public") - {:ok, activity} = CommonAPI.post(user, %{"status" => "nice echo chamber"}) + {:ok, activity} = CommonAPI.post(user, %{status: "nice echo chamber"}) assert_receive {:text, raw_json}, 1_000 assert {:ok, json} = Jason.decode(raw_json) diff --git a/test/notification_test.exs b/test/notification_test.exs index 4dfbc1019..111ff09f4 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -25,7 +25,7 @@ test "creates a notification for an emoji reaction" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "yeah"}) + {:ok, activity} = CommonAPI.post(user, %{status: "yeah"}) {:ok, activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") {:ok, [notification]} = Notification.create_notifications(activity) @@ -40,7 +40,7 @@ test "notifies someone when they are directly addressed" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname} and @#{third_user.nickname}" + status: "hey @#{other_user.nickname} and @#{third_user.nickname}" }) {:ok, [notification, other_notification]} = Notification.create_notifications(activity) @@ -60,7 +60,7 @@ test "it creates a notification for subscribed users" do User.subscribe(subscriber, user) - {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) + {:ok, status} = CommonAPI.post(user, %{status: "Akariiiin"}) {:ok, [notification]} = Notification.create_notifications(status) assert notification.user_id == subscriber.id @@ -73,12 +73,12 @@ test "does not create a notification for subscribed users if status is a reply" User.subscribe(subscriber, other_user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) {:ok, _reply_activity} = CommonAPI.post(other_user, %{ - "status" => "test reply", - "in_reply_to_status_id" => activity.id + status: "test reply", + in_reply_to_status_id: activity.id }) user_notifications = Notification.for_user(user) @@ -98,7 +98,7 @@ test "does not create a notification for subscribed users if status is a reply" blocker = insert(:user) {:ok, _user_relationship} = User.block(blocker, user) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "hey @#{blocker.nickname}!"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "hey @#{blocker.nickname}!"}) blocker_id = blocker.id assert [%Notification{user_id: ^blocker_id}] = Repo.all(Notification) @@ -113,7 +113,7 @@ test "does not create a notification for subscribed users if status is a reply" muter = insert(:user) {:ok, _user_relationships} = User.mute(muter, user) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "hey @#{muter.nickname}!"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "hey @#{muter.nickname}!"}) muter_id = muter.id assert [%Notification{user_id: ^muter_id}] = Repo.all(Notification) @@ -127,14 +127,14 @@ test "does not create a notification for subscribed users if status is a reply" user = insert(:user) thread_muter = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{thread_muter.nickname}!"}) + {: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 + status: "hey-hey-hey @#{thread_muter.nickname}!", + in_reply_to_status_id: activity.id }) [pre_mute_notification, post_mute_notification] = @@ -202,7 +202,7 @@ test "it creates a notification for the user if the user mutes the activity auth muted = insert(:user) {:ok, _} = User.mute(muter, muted) muter = Repo.get(User, muter.id) - {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"}) + {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"}) assert Notification.create_notification(activity, muter) end @@ -213,7 +213,7 @@ test "notification created if user is muted without notifications" do {:ok, _user_relationships} = User.mute(muter, muted, false) - {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"}) + {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"}) assert Notification.create_notification(activity, muter) end @@ -221,13 +221,13 @@ test "notification created if user is muted without notifications" do test "it creates a notification for an activity from a muted thread" do muter = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(muter, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(muter, %{status: "hey"}) CommonAPI.add_mute(muter, activity) {:ok, activity} = CommonAPI.post(other_user, %{ - "status" => "Hi @#{muter.nickname}", - "in_reply_to_status_id" => activity.id + status: "Hi @#{muter.nickname}", + in_reply_to_status_id: activity.id }) assert Notification.create_notification(activity, muter) @@ -240,7 +240,7 @@ test "it disables notifications from followers" do insert(:user, notification_settings: %Pleroma.User.NotificationSetting{followers: false}) User.follow(follower, followed) - {:ok, activity} = CommonAPI.post(follower, %{"status" => "hey @#{followed.nickname}"}) + {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) refute Notification.create_notification(activity, followed) end @@ -252,7 +252,7 @@ test "it disables notifications from non-followers" do notification_settings: %Pleroma.User.NotificationSetting{non_followers: false} ) - {:ok, activity} = CommonAPI.post(follower, %{"status" => "hey @#{followed.nickname}"}) + {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) refute Notification.create_notification(activity, followed) end @@ -263,7 +263,7 @@ test "it disables notifications from people the user follows" do followed = insert(:user) User.follow(follower, followed) follower = Repo.get(User, follower.id) - {:ok, activity} = CommonAPI.post(followed, %{"status" => "hey @#{follower.nickname}"}) + {:ok, activity} = CommonAPI.post(followed, %{status: "hey @#{follower.nickname}"}) refute Notification.create_notification(activity, follower) end @@ -272,7 +272,7 @@ test "it disables notifications from people the user does not follow" do insert(:user, notification_settings: %Pleroma.User.NotificationSetting{non_follows: false}) followed = insert(:user) - {:ok, activity} = CommonAPI.post(followed, %{"status" => "hey @#{follower.nickname}"}) + {:ok, activity} = CommonAPI.post(followed, %{status: "hey @#{follower.nickname}"}) refute Notification.create_notification(activity, follower) end @@ -289,7 +289,7 @@ test "it doesn't create duplicate notifications for follow+subscribed users" do {:ok, _, _, _} = CommonAPI.follow(subscriber, user) User.subscribe(subscriber, user) - {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) + {:ok, status} = CommonAPI.post(user, %{status: "Akariiiin"}) {:ok, [_notif]} = Notification.create_notifications(status) end @@ -299,7 +299,7 @@ test "it doesn't create subscription notifications if the recipient cannot see t User.subscribe(subscriber, user) - {:ok, status} = CommonAPI.post(user, %{"status" => "inwisible", "visibility" => "direct"}) + {:ok, status} = CommonAPI.post(user, %{status: "inwisible", visibility: "direct"}) assert {:ok, []} == Notification.create_notifications(status) end @@ -370,7 +370,7 @@ test "it gets a notification that belongs to the user" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) {:ok, notification} = Notification.get(other_user, notification.id) @@ -382,7 +382,7 @@ test "it returns error if the notification doesn't belong to the user" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) {:error, _notification} = Notification.get(user, notification.id) @@ -394,7 +394,7 @@ test "it dismisses a notification that belongs to the user" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) {:ok, notification} = Notification.dismiss(other_user, notification.id) @@ -406,7 +406,7 @@ test "it returns error if the notification doesn't belong to the user" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) {:error, _notification} = Notification.dismiss(user, notification.id) @@ -421,14 +421,14 @@ test "it clears all notifications belonging to the user" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname} and @#{third_user.nickname} !" + status: "hey @#{other_user.nickname} and @#{third_user.nickname} !" }) {:ok, _notifs} = Notification.create_notifications(activity) {:ok, activity} = CommonAPI.post(user, %{ - "status" => "hey again @#{other_user.nickname} and @#{third_user.nickname} !" + status: "hey again @#{other_user.nickname} and @#{third_user.nickname} !" }) {:ok, _notifs} = Notification.create_notifications(activity) @@ -446,12 +446,12 @@ test "it sets all notifications as read up to a specified notification ID" do {:ok, _activity} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname}!" + status: "hey @#{other_user.nickname}!" }) {:ok, _activity} = CommonAPI.post(user, %{ - "status" => "hey again @#{other_user.nickname}!" + status: "hey again @#{other_user.nickname}!" }) [n2, n1] = notifs = Notification.for_user(other_user) @@ -461,7 +461,7 @@ test "it sets all notifications as read up to a specified notification ID" do {:ok, _activity} = CommonAPI.post(user, %{ - "status" => "hey yet again @#{other_user.nickname}!" + status: "hey yet again @#{other_user.nickname}!" }) Notification.set_read_up_to(other_user, n2.id) @@ -500,7 +500,7 @@ test "Returns recent notifications" do Enum.each(0..10, fn i -> {:ok, _activity} = CommonAPI.post(user1, %{ - "status" => "hey ##{i} @#{user2.nickname}!" + status: "hey ##{i} @#{user2.nickname}!" }) end) @@ -536,7 +536,7 @@ test "it sends notifications to addressed users in new messages" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname}!" + status: "hey @#{other_user.nickname}!" }) {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity) @@ -605,7 +605,7 @@ test "it does not send notification to mentioned users in likes" do {:ok, activity_one} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname}!" + status: "hey @#{other_user.nickname}!" }) {:ok, activity_two} = CommonAPI.favorite(third_user, activity_one.id) @@ -623,7 +623,7 @@ test "it only notifies the post's author in likes" do {:ok, activity_one} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname}!" + status: "hey @#{other_user.nickname}!" }) {:ok, like_data, _} = Builder.like(third_user, activity_one.object) @@ -645,7 +645,7 @@ test "it does not send notification to mentioned users in announces" do {:ok, activity_one} = CommonAPI.post(user, %{ - "status" => "hey @#{other_user.nickname}!" + status: "hey @#{other_user.nickname}!" }) {:ok, activity_two, _} = CommonAPI.repeat(activity_one.id, third_user) @@ -661,7 +661,7 @@ test "it returns blocking recipient in disabled recipients list" do other_user = insert(:user) {:ok, _user_relationship} = User.block(other_user, user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"}) {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) @@ -674,7 +674,7 @@ test "it returns notification-muting recipient in disabled recipients list" do other_user = insert(:user) {:ok, _user_relationships} = User.mute(other_user, user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"}) {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) @@ -686,14 +686,14 @@ test "it returns thread-muting recipient in disabled recipients list" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + {: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 + status: "hey-hey-hey @#{other_user.nickname}!", + in_reply_to_status_id: activity.id }) {enabled_receivers, disabled_receivers} = @@ -710,7 +710,7 @@ test "it returns non-following domain-blocking recipient in disabled recipients {:ok, other_user} = User.block_domain(other_user, blocked_domain) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"}) {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) @@ -726,7 +726,7 @@ test "it returns following domain-blocking recipient in enabled recipients list" {:ok, other_user} = User.block_domain(other_user, blocked_domain) {:ok, other_user} = User.follow(other_user, user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{other_user.nickname}!"}) {enabled_receivers, disabled_receivers} = Notification.get_notified_from_activity(activity) @@ -740,7 +740,7 @@ test "liking an activity results in 1 notification, then 0 if the activity is de user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) assert Enum.empty?(Notification.for_user(user)) @@ -757,7 +757,7 @@ test "liking an activity results in 1 notification, then 0 if the activity is un user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) assert Enum.empty?(Notification.for_user(user)) @@ -774,7 +774,7 @@ test "repeating an activity results in 1 notification, then 0 if the activity is user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) assert Enum.empty?(Notification.for_user(user)) @@ -791,7 +791,7 @@ test "repeating an activity results in 1 notification, then 0 if the activity is user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) assert Enum.empty?(Notification.for_user(user)) @@ -808,7 +808,7 @@ test "liking an activity which is already deleted does not generate a notificati user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) assert Enum.empty?(Notification.for_user(user)) @@ -825,7 +825,7 @@ test "repeating an activity which is already deleted does not generate a notific user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) assert Enum.empty?(Notification.for_user(user)) @@ -842,13 +842,13 @@ test "replying to a deleted post without tagging does not generate a notificatio user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) {:ok, _deletion_activity} = CommonAPI.delete(activity.id, user) {:ok, _reply_activity} = CommonAPI.post(other_user, %{ - "status" => "test reply", - "in_reply_to_status_id" => activity.id + status: "test reply", + in_reply_to_status_id: activity.id }) assert Enum.empty?(Notification.for_user(user)) @@ -859,7 +859,7 @@ test "notifications are deleted if a local user is deleted" do other_user = insert(:user) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "hi @#{other_user.nickname}", visibility: "direct"}) refute Enum.empty?(Notification.for_user(other_user)) @@ -970,7 +970,7 @@ test "it returns notifications for muted user without notifications" do muted = insert(:user) {:ok, _user_relationships} = User.mute(user, muted, false) - {:ok, _activity} = CommonAPI.post(muted, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) assert length(Notification.for_user(user)) == 1 end @@ -980,7 +980,7 @@ test "it doesn't return notifications for muted user with notifications" do muted = insert(:user) {:ok, _user_relationships} = User.mute(user, muted) - {:ok, _activity} = CommonAPI.post(muted, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) assert Notification.for_user(user) == [] end @@ -990,7 +990,7 @@ test "it doesn't return notifications for blocked user" do blocked = insert(:user) {:ok, _user_relationship} = User.block(user, blocked) - {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) assert Notification.for_user(user) == [] end @@ -1000,7 +1000,7 @@ test "it doesn't return notifications for domain-blocked non-followed user" do blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") - {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) assert Notification.for_user(user) == [] end @@ -1012,7 +1012,7 @@ test "it returns notifications for domain-blocked but followed user" do {:ok, user} = User.block_domain(user, "some-domain.com") {:ok, _} = User.follow(user, blocked) - {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) assert length(Notification.for_user(user)) == 1 end @@ -1021,7 +1021,7 @@ test "it doesn't return notifications for muted thread" do user = insert(:user) another_user = insert(:user) - {:ok, activity} = CommonAPI.post(another_user, %{"status" => "hey @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(another_user, %{status: "hey @#{user.nickname}"}) {:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"]) assert Notification.for_user(user) == [] @@ -1032,7 +1032,7 @@ test "it returns notifications from a muted user when with_muted is set" do muted = insert(:user) {:ok, _user_relationships} = User.mute(user, muted) - {:ok, _activity} = CommonAPI.post(muted, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) assert length(Notification.for_user(user, %{with_muted: true})) == 1 end @@ -1042,7 +1042,7 @@ test "it doesn't return notifications from a blocked user when with_muted is set blocked = insert(:user) {:ok, _user_relationship} = User.block(user, blocked) - {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) assert Enum.empty?(Notification.for_user(user, %{with_muted: true})) end @@ -1053,7 +1053,7 @@ test "when with_muted is set, " <> blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") - {:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(blocked, %{status: "hey @#{user.nickname}"}) assert Enum.empty?(Notification.for_user(user, %{with_muted: true})) end @@ -1062,7 +1062,7 @@ test "it returns notifications from muted threads when with_muted is set" do user = insert(:user) another_user = insert(:user) - {:ok, activity} = CommonAPI.post(another_user, %{"status" => "hey @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(another_user, %{status: "hey @#{user.nickname}"}) {:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"]) assert length(Notification.for_user(user, %{with_muted: true})) == 1 diff --git a/test/stats_test.exs b/test/stats_test.exs index c1aeb2c7f..4b76e2e78 100644 --- a/test/stats_test.exs +++ b/test/stats_test.exs @@ -22,26 +22,26 @@ test "on new status" do user = insert(:user) other_user = insert(:user) - CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + CommonAPI.post(user, %{visibility: "public", status: "hey"}) Enum.each(0..1, fn _ -> CommonAPI.post(user, %{ - "visibility" => "unlisted", - "status" => "hey" + visibility: "unlisted", + status: "hey" }) end) Enum.each(0..2, fn _ -> CommonAPI.post(user, %{ - "visibility" => "direct", - "status" => "hey @#{other_user.nickname}" + visibility: "direct", + status: "hey @#{other_user.nickname}" }) end) Enum.each(0..3, fn _ -> CommonAPI.post(user, %{ - "visibility" => "private", - "status" => "hey" + visibility: "private", + status: "hey" }) end) @@ -51,7 +51,7 @@ test "on new status" do test "on status delete" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{visibility: "public", status: "hey"}) assert %{public: 1} = Pleroma.Stats.get_status_visibility_count() CommonAPI.delete(activity.id, user) assert %{public: 0} = Pleroma.Stats.get_status_visibility_count() @@ -59,16 +59,16 @@ test "on status delete" do test "on status visibility update" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{visibility: "public", status: "hey"}) assert %{public: 1, private: 0} = Pleroma.Stats.get_status_visibility_count() - {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{"visibility" => "private"}) + {:ok, _} = CommonAPI.update_activity_scope(activity.id, %{visibility: "private"}) assert %{public: 0, private: 1} = Pleroma.Stats.get_status_visibility_count() end test "doesn't count unrelated activities" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{visibility: "public", status: "hey"}) _ = CommonAPI.follow(user, other_user) CommonAPI.favorite(other_user, activity.id) CommonAPI.repeat(activity.id, other_user) diff --git a/test/tasks/count_statuses_test.exs b/test/tasks/count_statuses_test.exs index 73c2ea690..c5cd16960 100644 --- a/test/tasks/count_statuses_test.exs +++ b/test/tasks/count_statuses_test.exs @@ -13,11 +13,11 @@ defmodule Mix.Tasks.Pleroma.CountStatusesTest do test "counts statuses" do user = insert(:user) - {:ok, _} = CommonAPI.post(user, %{"status" => "test"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "test2"}) + {:ok, _} = CommonAPI.post(user, %{status: "test"}) + {:ok, _} = CommonAPI.post(user, %{status: "test2"}) user2 = insert(:user) - {:ok, _} = CommonAPI.post(user2, %{"status" => "test3"}) + {:ok, _} = CommonAPI.post(user2, %{status: "test3"}) user = refresh_record(user) user2 = refresh_record(user2) diff --git a/test/tasks/database_test.exs b/test/tasks/database_test.exs index 7b05993d3..883828d77 100644 --- a/test/tasks/database_test.exs +++ b/test/tasks/database_test.exs @@ -26,7 +26,7 @@ defmodule Mix.Tasks.Pleroma.DatabaseTest do describe "running remove_embedded_objects" do test "it replaces objects with references" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) new_data = Map.put(activity.data, "object", activity.object.data) {:ok, activity} = @@ -99,8 +99,8 @@ test "following and followers count are updated" do test "it turns OrderedCollection likes into empty arrays" do [user, user2] = insert_pair(:user) - {:ok, %{id: id, object: object}} = CommonAPI.post(user, %{"status" => "test"}) - {:ok, %{object: object2}} = CommonAPI.post(user, %{"status" => "test test"}) + {:ok, %{id: id, object: object}} = CommonAPI.post(user, %{status: "test"}) + {:ok, %{object: object2}} = CommonAPI.post(user, %{status: "test test"}) CommonAPI.favorite(user2, id) diff --git a/test/tasks/digest_test.exs b/test/tasks/digest_test.exs index 96d762685..eefbc8936 100644 --- a/test/tasks/digest_test.exs +++ b/test/tasks/digest_test.exs @@ -25,7 +25,7 @@ test "Sends digest to the given user" do Enum.each(0..10, fn i -> {:ok, _activity} = CommonAPI.post(user1, %{ - "status" => "hey ##{i} @#{user2.nickname}!" + status: "hey ##{i} @#{user2.nickname}!" }) end) diff --git a/test/tasks/refresh_counter_cache_test.exs b/test/tasks/refresh_counter_cache_test.exs index b63f44c08..851971a77 100644 --- a/test/tasks/refresh_counter_cache_test.exs +++ b/test/tasks/refresh_counter_cache_test.exs @@ -12,26 +12,26 @@ test "counts statuses" do user = insert(:user) other_user = insert(:user) - CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) + CommonAPI.post(user, %{visibility: "public", status: "hey"}) Enum.each(0..1, fn _ -> CommonAPI.post(user, %{ - "visibility" => "unlisted", - "status" => "hey" + visibility: "unlisted", + status: "hey" }) end) Enum.each(0..2, fn _ -> CommonAPI.post(user, %{ - "visibility" => "direct", - "status" => "hey @#{other_user.nickname}" + visibility: "direct", + status: "hey @#{other_user.nickname}" }) end) Enum.each(0..3, fn _ -> CommonAPI.post(user, %{ - "visibility" => "private", - "status" => "hey" + visibility: "private", + status: "hey" }) end) diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index b4f68d494..4aa873f0b 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -109,7 +109,7 @@ test "user is deleted" do test "a remote user's create activity is deleted when the object has been pruned" do user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "uguu"}) + {:ok, post} = CommonAPI.post(user, %{status: "uguu"}) object = Object.normalize(post) Object.prune(object) diff --git a/test/user_test.exs b/test/user_test.exs index a3c75aa9b..6b9df60a4 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -990,7 +990,7 @@ test "works for announces" do actor = insert(:user) user = insert(:user, local: true) - {:ok, activity} = CommonAPI.post(actor, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(actor, %{status: "hello"}) {:ok, announce, _} = CommonAPI.repeat(activity.id, user) recipients = User.get_recipients_from_activity(announce) @@ -1007,7 +1007,7 @@ test "get recipients" do {:ok, activity} = CommonAPI.post(actor, %{ - "status" => "hey @#{addressed.nickname} @#{addressed_remote.nickname}" + status: "hey @#{addressed.nickname} @#{addressed_remote.nickname}" }) assert Enum.map([actor, addressed], & &1.ap_id) -- @@ -1029,7 +1029,7 @@ test "has following" do {:ok, activity} = CommonAPI.post(actor, %{ - "status" => "hey @#{addressed.nickname}" + status: "hey @#{addressed.nickname}" }) assert Enum.map([actor, addressed], & &1.ap_id) -- @@ -1090,7 +1090,7 @@ test "hide a user's statuses from timelines and notifications" do {:ok, user2} = User.follow(user2, user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{user2.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{user2.nickname}"}) activity = Repo.preload(activity, :bookmark) @@ -1126,7 +1126,7 @@ test "hide a user's statuses from timelines and notifications" do setup do: clear_config([:instance, :federating]) test ".delete_user_activities deletes all create activities", %{user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu"}) + {:ok, activity} = CommonAPI.post(user, %{status: "2hu"}) User.delete_user_activities(user) @@ -1411,7 +1411,7 @@ test "Only includes users who has no recent activity" do {:ok, _} = CommonAPI.post(user, %{ - "status" => "hey @#{to.nickname}" + status: "hey @#{to.nickname}" }) end) @@ -1443,12 +1443,12 @@ test "Only includes users with no read notifications" do Enum.each(recipients, fn to -> {:ok, _} = CommonAPI.post(sender, %{ - "status" => "hey @#{to.nickname}" + status: "hey @#{to.nickname}" }) {:ok, _} = CommonAPI.post(sender, %{ - "status" => "hey again @#{to.nickname}" + status: "hey again @#{to.nickname}" }) end) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 776ddc8d4..c432c90e3 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -341,7 +341,7 @@ test "it caches a response", %{conn: conn} do test "cached purged after activity deletion", %{conn: conn} do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "cofe"}) uuid = String.split(activity.data["id"], "/") |> List.last() diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 59bdd53cd..56fde97e7 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -32,7 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do describe "streaming out participations" do test "it streams them out" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) {:ok, conversation} = Pleroma.Conversation.create_or_bump_for(activity) @@ -56,8 +56,8 @@ test "streams them out on activity creation" do stream: fn _, _ -> nil end do {:ok, activity} = CommonAPI.post(user_one, %{ - "status" => "@#{user_two.nickname}", - "visibility" => "direct" + status: "@#{user_two.nickname}", + visibility: "direct" }) conversation = @@ -74,15 +74,13 @@ test "streams them out on activity creation" do test "it restricts by the appropriate visibility" do user = insert(:user) - {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + {:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"}) - {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) - {:ok, unlisted_activity} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) + {:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"}) - {:ok, private_activity} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) activities = ActivityPub.fetch_activities([], %{:visibility => "direct", "actor_id" => user.ap_id}) @@ -118,15 +116,13 @@ test "it restricts by the appropriate visibility" do test "it excludes by the appropriate visibility" do user = insert(:user) - {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + {:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"}) - {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) - {:ok, unlisted_activity} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) + {:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"}) - {:ok, private_activity} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) activities = ActivityPub.fetch_activities([], %{ @@ -193,9 +189,9 @@ test "it returns a user that is invisible" do test "it fetches the appropriate tag-restricted posts" do user = insert(:user) - {:ok, status_one} = CommonAPI.post(user, %{"status" => ". #test"}) - {:ok, status_two} = CommonAPI.post(user, %{"status" => ". #essais"}) - {:ok, status_three} = CommonAPI.post(user, %{"status" => ". #test #reject"}) + {:ok, status_one} = CommonAPI.post(user, %{status: ". #test"}) + {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) + {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) fetch_one = ActivityPub.fetch_activities([], %{"type" => "Create", "tag" => "test"}) @@ -432,26 +428,26 @@ test "increases user note count only for public activities" do {:ok, _} = CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "1", - "visibility" => "public" + status: "1", + visibility: "public" }) {:ok, _} = CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "2", - "visibility" => "unlisted" + status: "2", + visibility: "unlisted" }) {:ok, _} = CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "2", - "visibility" => "private" + status: "2", + visibility: "private" }) {:ok, _} = CommonAPI.post(User.get_cached_by_id(user.id), %{ - "status" => "3", - "visibility" => "direct" + status: "3", + visibility: "direct" }) user = User.get_cached_by_id(user.id) @@ -462,27 +458,27 @@ test "increases replies count" do user = insert(:user) user2 = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "1", "visibility" => "public"}) + {:ok, activity} = CommonAPI.post(user, %{status: "1", visibility: "public"}) ap_id = activity.data["id"] - reply_data = %{"status" => "1", "in_reply_to_status_id" => activity.id} + reply_data = %{status: "1", in_reply_to_status_id: activity.id} # public - {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "public")) + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "public")) assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 1 # unlisted - {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "unlisted")) + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "unlisted")) assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 2 # private - {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "private")) + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "private")) assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 2 # direct - {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, "visibility", "direct")) + {:ok, _} = CommonAPI.post(user2, Map.put(reply_data, :visibility, "direct")) assert %{data: data, object: object} = Activity.get_by_ap_id_with_object(ap_id) assert object.data["repliesCount"] == 2 end @@ -569,13 +565,13 @@ test "doesn't return transitive interactions concerning blocked users" do {:ok, _user_relationship} = User.block(blocker, blockee) - {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey!"}) + {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"}) - {:ok, activity_two} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"}) + {:ok, activity_two} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"}) - {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"}) + {:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"}) - {:ok, activity_four} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"}) + {:ok, activity_four} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"}) activities = ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) @@ -592,9 +588,9 @@ test "doesn't return announce activities concerning blocked users" do {:ok, _user_relationship} = User.block(blocker, blockee) - {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey!"}) + {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"}) - {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"}) + {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"}) {:ok, activity_three, _} = CommonAPI.repeat(activity_two.id, friend) @@ -774,10 +770,9 @@ test "excludes reblogs on request" do test "doesn't retrieve unlisted activities" do user = insert(:user) - {:ok, _unlisted_activity} = - CommonAPI.post(user, %{"status" => "yeah", "visibility" => "unlisted"}) + {:ok, _unlisted_activity} = CommonAPI.post(user, %{status: "yeah", visibility: "unlisted"}) - {:ok, listed_activity} = CommonAPI.post(user, %{"status" => "yeah"}) + {:ok, listed_activity} = CommonAPI.post(user, %{status: "yeah"}) [activity] = ActivityPub.fetch_public_activities() @@ -912,7 +907,7 @@ test "reverts annouce from object on error" do describe "announcing a private object" do test "adds an announce activity to the db if the audience is not widened" do user = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) object = Object.normalize(note_activity) {:ok, announce_activity, object} = ActivityPub.announce(user, object, nil, true, false) @@ -926,7 +921,7 @@ test "adds an announce activity to the db if the audience is not widened" do test "does not add an announce activity to the db if the audience is widened" do user = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) object = Object.normalize(note_activity) assert {:error, _} = ActivityPub.announce(user, object, nil, true, true) @@ -935,7 +930,7 @@ test "does not add an announce activity to the db if the audience is widened" do test "does not add an announce activity to the db if the announcer is not the author" do user = insert(:user) announcer = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) object = Object.normalize(note_activity) assert {:error, _} = ActivityPub.announce(announcer, object, nil, true, false) @@ -1111,23 +1106,22 @@ test "it filters broken threads" do {:ok, user3} = User.follow(user3, user2) assert User.following?(user3, user2) - {:ok, public_activity} = CommonAPI.post(user3, %{"status" => "hi 1"}) + {:ok, public_activity} = CommonAPI.post(user3, %{status: "hi 1"}) - {:ok, private_activity_1} = - CommonAPI.post(user3, %{"status" => "hi 2", "visibility" => "private"}) + {:ok, private_activity_1} = CommonAPI.post(user3, %{status: "hi 2", visibility: "private"}) {:ok, private_activity_2} = CommonAPI.post(user2, %{ - "status" => "hi 3", - "visibility" => "private", - "in_reply_to_status_id" => private_activity_1.id + status: "hi 3", + visibility: "private", + in_reply_to_status_id: private_activity_1.id }) {:ok, private_activity_3} = CommonAPI.post(user3, %{ - "status" => "hi 4", - "visibility" => "private", - "in_reply_to_status_id" => private_activity_2.id + status: "hi 4", + visibility: "private", + in_reply_to_status_id: private_activity_2.id }) activities = @@ -1177,9 +1171,9 @@ test "returned pinned statuses" do Config.put([:instance, :max_pinned_statuses], 3) user = insert(:user) - {:ok, activity_one} = CommonAPI.post(user, %{"status" => "HI!!!"}) - {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) - {:ok, activity_three} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity_one} = CommonAPI.post(user, %{status: "HI!!!"}) + {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) + {:ok, activity_three} = CommonAPI.post(user, %{status: "HI!!!"}) CommonAPI.pin(activity_one.id, user) user = refresh_record(user) @@ -1200,7 +1194,7 @@ test "returned pinned statuses" do reporter = insert(:user) target_account = insert(:user) content = "foobar" - {:ok, activity} = CommonAPI.post(target_account, %{"status" => content}) + {:ok, activity} = CommonAPI.post(target_account, %{status: content}) context = Utils.generate_context_id() reporter_ap_id = reporter.ap_id @@ -1296,8 +1290,7 @@ test "fetch_activities/2 returns activities addressed to a list " do {:ok, list} = Pleroma.List.create("foo", user) {:ok, list} = Pleroma.List.follow(list, member) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) activity = Repo.preload(activity, :bookmark) activity = %Activity{activity | thread_muted?: !!activity.thread_muted?} @@ -1315,8 +1308,8 @@ test "fetches private posts for followed users" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "thought I looked cute might delete later :3", - "visibility" => "private" + status: "thought I looked cute might delete later :3", + visibility: "private" }) [result] = ActivityPub.fetch_activities_bounded([user.follower_address], []) @@ -1325,12 +1318,12 @@ test "fetches private posts for followed users" do test "fetches only public posts for other users" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe", "visibility" => "public"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe", visibility: "public"}) {:ok, _private_activity} = CommonAPI.post(user, %{ - "status" => "why is tenshi eating a corndog so cute?", - "visibility" => "private" + status: "why is tenshi eating a corndog so cute?", + visibility: "private" }) [result] = ActivityPub.fetch_activities_bounded([], [user.follower_address]) @@ -1458,11 +1451,11 @@ test "returns a favourite activities sorted by adds to favorite" do other_user = insert(:user) user1 = insert(:user) user2 = insert(:user) - {:ok, a1} = CommonAPI.post(user1, %{"status" => "bla"}) - {:ok, _a2} = CommonAPI.post(user2, %{"status" => "traps are happy"}) - {:ok, a3} = CommonAPI.post(user2, %{"status" => "Trees Are "}) - {:ok, a4} = CommonAPI.post(user2, %{"status" => "Agent Smith "}) - {:ok, a5} = CommonAPI.post(user1, %{"status" => "Red or Blue "}) + {:ok, a1} = CommonAPI.post(user1, %{status: "bla"}) + {:ok, _a2} = CommonAPI.post(user2, %{status: "traps are happy"}) + {:ok, a3} = CommonAPI.post(user2, %{status: "Trees Are "}) + {:ok, a4} = CommonAPI.post(user2, %{status: "Agent Smith "}) + {:ok, a5} = CommonAPI.post(user1, %{status: "Red or Blue "}) {:ok, _} = CommonAPI.favorite(user, a4.id) {:ok, _} = CommonAPI.favorite(other_user, a3.id) @@ -1542,10 +1535,9 @@ test "old user must be in the new user's `also_known_as` list" do test "doesn't retrieve replies activities with exclude_replies" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "yeah"}) + {:ok, activity} = CommonAPI.post(user, %{status: "yeah"}) - {:ok, _reply} = - CommonAPI.post(user, %{"status" => "yeah", "in_reply_to_status_id" => activity.id}) + {:ok, _reply} = CommonAPI.post(user, %{status: "yeah", in_reply_to_status_id: activity.id}) [result] = ActivityPub.fetch_public_activities(%{"exclude_replies" => "true"}) @@ -1858,84 +1850,84 @@ defp public_messages(_) do {:ok, u2} = User.follow(u2, u3) {:ok, u3} = User.follow(u3, u2) - {:ok, a1} = CommonAPI.post(u1, %{"status" => "Status"}) + {:ok, a1} = CommonAPI.post(u1, %{status: "Status"}) {:ok, r1_1} = CommonAPI.post(u2, %{ - "status" => "@#{u1.nickname} reply from u2 to u1", - "in_reply_to_status_id" => a1.id + status: "@#{u1.nickname} reply from u2 to u1", + in_reply_to_status_id: a1.id }) {:ok, r1_2} = CommonAPI.post(u3, %{ - "status" => "@#{u1.nickname} reply from u3 to u1", - "in_reply_to_status_id" => a1.id + status: "@#{u1.nickname} reply from u3 to u1", + in_reply_to_status_id: a1.id }) {:ok, r1_3} = CommonAPI.post(u4, %{ - "status" => "@#{u1.nickname} reply from u4 to u1", - "in_reply_to_status_id" => a1.id + status: "@#{u1.nickname} reply from u4 to u1", + in_reply_to_status_id: a1.id }) - {:ok, a2} = CommonAPI.post(u2, %{"status" => "Status"}) + {:ok, a2} = CommonAPI.post(u2, %{status: "Status"}) {:ok, r2_1} = CommonAPI.post(u1, %{ - "status" => "@#{u2.nickname} reply from u1 to u2", - "in_reply_to_status_id" => a2.id + status: "@#{u2.nickname} reply from u1 to u2", + in_reply_to_status_id: a2.id }) {:ok, r2_2} = CommonAPI.post(u3, %{ - "status" => "@#{u2.nickname} reply from u3 to u2", - "in_reply_to_status_id" => a2.id + status: "@#{u2.nickname} reply from u3 to u2", + in_reply_to_status_id: a2.id }) {:ok, r2_3} = CommonAPI.post(u4, %{ - "status" => "@#{u2.nickname} reply from u4 to u2", - "in_reply_to_status_id" => a2.id + status: "@#{u2.nickname} reply from u4 to u2", + in_reply_to_status_id: a2.id }) - {:ok, a3} = CommonAPI.post(u3, %{"status" => "Status"}) + {:ok, a3} = CommonAPI.post(u3, %{status: "Status"}) {:ok, r3_1} = CommonAPI.post(u1, %{ - "status" => "@#{u3.nickname} reply from u1 to u3", - "in_reply_to_status_id" => a3.id + status: "@#{u3.nickname} reply from u1 to u3", + in_reply_to_status_id: a3.id }) {:ok, r3_2} = CommonAPI.post(u2, %{ - "status" => "@#{u3.nickname} reply from u2 to u3", - "in_reply_to_status_id" => a3.id + status: "@#{u3.nickname} reply from u2 to u3", + in_reply_to_status_id: a3.id }) {:ok, r3_3} = CommonAPI.post(u4, %{ - "status" => "@#{u3.nickname} reply from u4 to u3", - "in_reply_to_status_id" => a3.id + status: "@#{u3.nickname} reply from u4 to u3", + in_reply_to_status_id: a3.id }) - {:ok, a4} = CommonAPI.post(u4, %{"status" => "Status"}) + {:ok, a4} = CommonAPI.post(u4, %{status: "Status"}) {:ok, r4_1} = CommonAPI.post(u1, %{ - "status" => "@#{u4.nickname} reply from u1 to u4", - "in_reply_to_status_id" => a4.id + status: "@#{u4.nickname} reply from u1 to u4", + in_reply_to_status_id: a4.id }) {:ok, r4_2} = CommonAPI.post(u2, %{ - "status" => "@#{u4.nickname} reply from u2 to u4", - "in_reply_to_status_id" => a4.id + status: "@#{u4.nickname} reply from u2 to u4", + in_reply_to_status_id: a4.id }) {:ok, r4_3} = CommonAPI.post(u3, %{ - "status" => "@#{u4.nickname} reply from u3 to u4", - "in_reply_to_status_id" => a4.id + status: "@#{u4.nickname} reply from u3 to u4", + in_reply_to_status_id: a4.id }) {:ok, @@ -1959,68 +1951,68 @@ defp private_messages(_) do {:ok, u2} = User.follow(u2, u3) {:ok, u3} = User.follow(u3, u2) - {:ok, a1} = CommonAPI.post(u1, %{"status" => "Status", "visibility" => "private"}) + {:ok, a1} = CommonAPI.post(u1, %{status: "Status", visibility: "private"}) {:ok, r1_1} = CommonAPI.post(u2, %{ - "status" => "@#{u1.nickname} reply from u2 to u1", - "in_reply_to_status_id" => a1.id, - "visibility" => "private" + status: "@#{u1.nickname} reply from u2 to u1", + in_reply_to_status_id: a1.id, + visibility: "private" }) {:ok, r1_2} = CommonAPI.post(u3, %{ - "status" => "@#{u1.nickname} reply from u3 to u1", - "in_reply_to_status_id" => a1.id, - "visibility" => "private" + status: "@#{u1.nickname} reply from u3 to u1", + in_reply_to_status_id: a1.id, + visibility: "private" }) {:ok, r1_3} = CommonAPI.post(u4, %{ - "status" => "@#{u1.nickname} reply from u4 to u1", - "in_reply_to_status_id" => a1.id, - "visibility" => "private" + status: "@#{u1.nickname} reply from u4 to u1", + in_reply_to_status_id: a1.id, + visibility: "private" }) - {:ok, a2} = CommonAPI.post(u2, %{"status" => "Status", "visibility" => "private"}) + {:ok, a2} = CommonAPI.post(u2, %{status: "Status", visibility: "private"}) {:ok, r2_1} = CommonAPI.post(u1, %{ - "status" => "@#{u2.nickname} reply from u1 to u2", - "in_reply_to_status_id" => a2.id, - "visibility" => "private" + status: "@#{u2.nickname} reply from u1 to u2", + in_reply_to_status_id: a2.id, + visibility: "private" }) {:ok, r2_2} = CommonAPI.post(u3, %{ - "status" => "@#{u2.nickname} reply from u3 to u2", - "in_reply_to_status_id" => a2.id, - "visibility" => "private" + status: "@#{u2.nickname} reply from u3 to u2", + in_reply_to_status_id: a2.id, + visibility: "private" }) - {:ok, a3} = CommonAPI.post(u3, %{"status" => "Status", "visibility" => "private"}) + {:ok, a3} = CommonAPI.post(u3, %{status: "Status", visibility: "private"}) {:ok, r3_1} = CommonAPI.post(u1, %{ - "status" => "@#{u3.nickname} reply from u1 to u3", - "in_reply_to_status_id" => a3.id, - "visibility" => "private" + status: "@#{u3.nickname} reply from u1 to u3", + in_reply_to_status_id: a3.id, + visibility: "private" }) {:ok, r3_2} = CommonAPI.post(u2, %{ - "status" => "@#{u3.nickname} reply from u2 to u3", - "in_reply_to_status_id" => a3.id, - "visibility" => "private" + status: "@#{u3.nickname} reply from u2 to u3", + in_reply_to_status_id: a3.id, + visibility: "private" }) - {:ok, a4} = CommonAPI.post(u4, %{"status" => "Status", "visibility" => "private"}) + {:ok, a4} = CommonAPI.post(u4, %{status: "Status", visibility: "private"}) {:ok, r4_1} = CommonAPI.post(u1, %{ - "status" => "@#{u4.nickname} reply from u1 to u4", - "in_reply_to_status_id" => a4.id, - "visibility" => "private" + status: "@#{u4.nickname} reply from u1 to u4", + in_reply_to_status_id: a4.id, + visibility: "private" }) {:ok, diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index f382adf3e..96eff1c30 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do describe "EmojiReacts" do setup do user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) object = Pleroma.Object.get_by_ap_id(post_activity.data["object"]) @@ -53,7 +53,7 @@ test "it is not valid with a non-emoji content field", %{valid_emoji_react: vali describe "Undos" do setup do user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) {:ok, like} = CommonAPI.favorite(user, post_activity.id) {:ok, valid_like_undo, []} = Builder.undo(user, like) @@ -93,7 +93,7 @@ test "it does not validate if the object is missing", %{valid_like_undo: valid_l describe "deletes" do setup do user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{"status" => "cancel me daddy"}) + {:ok, post_activity} = CommonAPI.post(user, %{status: "cancel me daddy"}) {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"]) {:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id) @@ -185,7 +185,7 @@ test "it's valid if the actor of the object is a local superuser", describe "likes" do setup do user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{"status" => "uguu"}) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) valid_like = %{ "to" => [user.ap_id], diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 6c5f8fc61..797f00d08 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -25,8 +25,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do user = insert(:user) other_user = insert(:user) - {:ok, op} = CommonAPI.post(other_user, %{"status" => "big oof"}) - {:ok, post} = CommonAPI.post(user, %{"status" => "hey", "in_reply_to_id" => op}) + {:ok, op} = CommonAPI.post(other_user, %{status: "big oof"}) + {:ok, post} = CommonAPI.post(user, %{status: "hey", in_reply_to_id: op}) {:ok, favorite} = CommonAPI.favorite(user, post.id) object = Object.normalize(post) {:ok, delete_data, _meta} = Builder.delete(user, object.data["id"]) @@ -118,7 +118,7 @@ test "it handles user deletions", %{delete_user: delete, user: user} do poster = insert(:user) user = insert(:user) - {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) + {:ok, post} = CommonAPI.post(poster, %{status: "hey"}) {:ok, emoji_react_data, []} = Builder.emoji_react(user, post.object, "👌") {:ok, emoji_react, _meta} = ActivityPub.persist(emoji_react_data, local: true) @@ -144,7 +144,7 @@ test "creates a notification", %{emoji_react: emoji_react, poster: poster} do setup do poster = insert(:user) user = insert(:user) - {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) + {:ok, post} = CommonAPI.post(poster, %{status: "hey"}) {:ok, like} = CommonAPI.favorite(user, post.id) {:ok, reaction} = CommonAPI.react_with_emoji(post.id, user, "👍") {:ok, announce, _} = CommonAPI.repeat(post.id, user) @@ -244,7 +244,7 @@ test "deletes the original like", %{like_undo: like_undo, like: like} do setup do poster = insert(:user) user = insert(:user) - {:ok, post} = CommonAPI.post(poster, %{"status" => "hey"}) + {:ok, post} = CommonAPI.post(poster, %{status: "hey"}) {:ok, like_data, _meta} = Builder.like(user, post.object) {:ok, like, _meta} = ActivityPub.persist(like_data, local: true) diff --git a/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs index 6988e3e0a..0fb056b50 100644 --- a/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/emoji_react_handling_test.exs @@ -15,7 +15,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do test "it works for incoming emoji reactions" do user = insert(:user) other_user = insert(:user, local: false) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) data = File.read!("test/fixtures/emoji-reaction.json") @@ -40,7 +40,7 @@ test "it works for incoming emoji reactions" do test "it reject invalid emoji reactions" do user = insert(:user) other_user = insert(:user, local: false) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) data = File.read!("test/fixtures/emoji-reaction-too-long.json") diff --git a/test/web/activity_pub/transmogrifier/like_handling_test.exs b/test/web/activity_pub/transmogrifier/like_handling_test.exs index 54a5c1dbc..53fe1d550 100644 --- a/test/web/activity_pub/transmogrifier/like_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/like_handling_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.LikeHandlingTest do test "it works for incoming likes" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) data = File.read!("test/fixtures/mastodon-like.json") @@ -36,7 +36,7 @@ test "it works for incoming likes" do test "it works for incoming misskey likes, turning them into EmojiReacts" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) data = File.read!("test/fixtures/misskey-like.json") @@ -57,7 +57,7 @@ test "it works for incoming misskey likes, turning them into EmojiReacts" do test "it works for incoming misskey likes that contain unicode emojis, turning them into EmojiReacts" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) data = File.read!("test/fixtures/misskey-like.json") diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs index eaf58adf7..01dd6c370 100644 --- a/test/web/activity_pub/transmogrifier/undo_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UndoHandlingTest do test "it works for incoming emoji reaction undos" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hello"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) {:ok, reaction_activity} = CommonAPI.react_with_emoji(activity.id, user, "👌") data = @@ -34,7 +34,7 @@ test "it works for incoming emoji reaction undos" do test "it returns an error for incoming unlikes wihout a like activity" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + {:ok, activity} = CommonAPI.post(user, %{status: "leave a like pls"}) data = File.read!("test/fixtures/mastodon-undo-like.json") @@ -46,7 +46,7 @@ test "it returns an error for incoming unlikes wihout a like activity" do test "it works for incoming unlikes with an existing like activity" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + {:ok, activity} = CommonAPI.post(user, %{status: "leave a like pls"}) like_data = File.read!("test/fixtures/mastodon-like.json") @@ -77,7 +77,7 @@ test "it works for incoming unlikes with an existing like activity" do test "it works for incoming unlikes with an existing like activity and a compact object" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "leave a like pls"}) + {:ok, activity} = CommonAPI.post(user, %{status: "leave a like pls"}) like_data = File.read!("test/fixtures/mastodon-like.json") @@ -104,7 +104,7 @@ test "it works for incoming unlikes with an existing like activity and a compact test "it works for incoming unannounces with an existing notice" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) announce_data = File.read!("test/fixtures/mastodon-announce.json") diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 3f908f867..0a54e3bb9 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -212,8 +212,8 @@ test "it rewrites Note votes to Answers and increments vote counters on question {:ok, activity} = CommonAPI.post(user, %{ - "status" => "suya...", - "poll" => %{"options" => ["suya", "suya.", "suya.."], "expires_in" => 10} + status: "suya...", + poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} }) object = Object.normalize(activity) @@ -263,7 +263,7 @@ test "it works for incoming notices with to/cc not being an array (kroeg)" do test "it works for incoming honk announces" do _user = insert(:user, ap_id: "https://honktest/u/test", local: false) other_user = insert(:user) - {:ok, post} = CommonAPI.post(other_user, %{"status" => "bonkeronk"}) + {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"}) announce = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@ -362,7 +362,7 @@ test "it works for incoming announces" do test "it works for incoming announces with an existing activity" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) data = File.read!("test/fixtures/mastodon-announce.json") @@ -412,7 +412,7 @@ test "it rejects incoming announces with an inlined activity from another origin test "it does not clobber the addressing on announce activities" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) data = File.read!("test/fixtures/mastodon-announce.json") @@ -498,7 +498,7 @@ test "it strips internal likes" do test "it strips internal reactions" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "📢") %{object: object} = Activity.get_by_id_with_object(activity.id) @@ -996,7 +996,7 @@ test "it accepts Flag activities" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test post"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test post"}) object = Object.normalize(activity) note_obj = %{ @@ -1140,13 +1140,13 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth setup do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "post1"}) + {:ok, activity} = CommonAPI.post(user, %{status: "post1"}) {:ok, reply1} = - CommonAPI.post(user, %{"status" => "reply1", "in_reply_to_status_id" => activity.id}) + CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id}) {:ok, reply2} = - CommonAPI.post(user, %{"status" => "reply2", "in_reply_to_status_id" => activity.id}) + CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: activity.id}) replies_uris = Enum.map([reply1, reply2], fn a -> a.object.data["id"] end) @@ -1186,7 +1186,7 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth test "it inlines private announced objects" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey", "visibility" => "private"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey", visibility: "private"}) {:ok, announce_activity, _} = CommonAPI.repeat(activity.id, user) @@ -1201,7 +1201,7 @@ test "it turns mentions into tags" do other_user = insert(:user) {:ok, activity} = - CommonAPI.post(user, %{"status" => "hey, @#{other_user.nickname}, how are ya? #2hu"}) + CommonAPI.post(user, %{status: "hey, @#{other_user.nickname}, how are ya? #2hu"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) object = modified["object"] @@ -1225,7 +1225,7 @@ test "it turns mentions into tags" do test "it adds the sensitive property" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#nsfw hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#nsfw hey"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) assert modified["object"]["sensitive"] @@ -1234,7 +1234,7 @@ test "it adds the sensitive property" do test "it adds the json-ld context and the conversation property" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) assert modified["@context"] == @@ -1246,7 +1246,7 @@ test "it adds the json-ld context and the conversation property" do test "it sets the 'attributedTo' property to the actor of the object if it doesn't have one" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) assert modified["object"]["actor"] == modified["object"]["attributedTo"] @@ -1255,7 +1255,7 @@ test "it sets the 'attributedTo' property to the actor of the object if it doesn test "it strips internal hashtag data" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#2hu"}) expected_tag = %{ "href" => Pleroma.Web.Endpoint.url() <> "/tags/2hu", @@ -1271,7 +1271,7 @@ test "it strips internal hashtag data" do test "it strips internal fields" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu :firefox:"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#2hu :firefox:"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) @@ -1303,14 +1303,13 @@ test "the directMessage flag is present" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "2hu :moominmamma:"}) + {:ok, activity} = CommonAPI.post(user, %{status: "2hu :moominmamma:"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) assert modified["directMessage"] == false - {:ok, activity} = - CommonAPI.post(user, %{"status" => "@#{other_user.nickname} :moominmamma:"}) + {:ok, activity} = CommonAPI.post(user, %{status: "@#{other_user.nickname} :moominmamma:"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) @@ -1318,8 +1317,8 @@ test "the directMessage flag is present" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "@#{other_user.nickname} :moominmamma:", - "visibility" => "direct" + status: "@#{other_user.nickname} :moominmamma:", + visibility: "direct" }) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) @@ -1331,8 +1330,7 @@ test "it strips BCC field" do user = insert(:user) {:ok, list} = Pleroma.List.create("foo", user) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) @@ -1367,8 +1365,8 @@ test "it upgrades a user to activitypub" do user_two = insert(:user) Pleroma.FollowingRelationship.follow(user_two, user, :follow_accept) - {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) - {:ok, unrelated_activity} = CommonAPI.post(user_two, %{"status" => "test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) + {:ok, unrelated_activity} = CommonAPI.post(user_two, %{status: "test"}) assert "http://localhost:4001/users/rye@niu.moe/followers" in activity.recipients user = User.get_cached_by_id(user.id) @@ -1534,8 +1532,8 @@ test "Rewrites Answers to Notes" do {:ok, poll_activity} = CommonAPI.post(user, %{ - "status" => "suya...", - "poll" => %{"options" => ["suya", "suya.", "suya.."], "expires_in" => 10} + status: "suya...", + poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} }) poll_object = Object.normalize(poll_activity) @@ -1878,28 +1876,27 @@ test "returns unmodified object if activity doesn't have self-replies" do test "sets `replies` collection with a limited number of self-replies" do [user, another_user] = insert_list(2, :user) - {:ok, %{id: id1} = activity} = CommonAPI.post(user, %{"status" => "1"}) + {:ok, %{id: id1} = activity} = CommonAPI.post(user, %{status: "1"}) {:ok, %{id: id2} = self_reply1} = - CommonAPI.post(user, %{"status" => "self-reply 1", "in_reply_to_status_id" => id1}) + CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: id1}) {:ok, self_reply2} = - CommonAPI.post(user, %{"status" => "self-reply 2", "in_reply_to_status_id" => id1}) + CommonAPI.post(user, %{status: "self-reply 2", in_reply_to_status_id: id1}) # Assuming to _not_ be present in `replies` due to :note_replies_output_limit is set to 2 - {:ok, _} = - CommonAPI.post(user, %{"status" => "self-reply 3", "in_reply_to_status_id" => id1}) + {:ok, _} = CommonAPI.post(user, %{status: "self-reply 3", in_reply_to_status_id: id1}) {:ok, _} = CommonAPI.post(user, %{ - "status" => "self-reply to self-reply", - "in_reply_to_status_id" => id2 + status: "self-reply to self-reply", + in_reply_to_status_id: id2 }) {:ok, _} = CommonAPI.post(another_user, %{ - "status" => "another user's reply", - "in_reply_to_status_id" => id1 + status: "another user's reply", + in_reply_to_status_id: id1 }) object = Object.normalize(activity) diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index b8d811c73..9e0a0f1c4 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -120,7 +120,7 @@ test "addresses actor's follower address if the activity is public", %{ {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "hey @#{other_user.nickname}, @#{third_user.nickname} how about beering together this weekend?" }) @@ -139,8 +139,8 @@ test "does not adress actor's follower address if the activity is not public", % {:ok, activity} = CommonAPI.post(user, %{ - "status" => "@#{other_user.nickname} @#{third_user.nickname} bought a new swimsuit!", - "visibility" => "private" + status: "@#{other_user.nickname} @#{third_user.nickname} bought a new swimsuit!", + visibility: "private" }) %{"to" => to, "cc" => cc} = Utils.make_like_data(other_user, activity, nil) @@ -168,11 +168,11 @@ test "fetches existing votes" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "How do I pronounce LaTeX?", - "poll" => %{ - "options" => ["laytekh", "lahtekh", "latex"], - "expires_in" => 20, - "multiple" => true + status: "How do I pronounce LaTeX?", + poll: %{ + options: ["laytekh", "lahtekh", "latex"], + expires_in: 20, + multiple: true } }) @@ -187,10 +187,10 @@ test "fetches only Create activities" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Are we living in a society?", - "poll" => %{ - "options" => ["yes", "no"], - "expires_in" => 20 + status: "Are we living in a society?", + poll: %{ + options: ["yes", "no"], + expires_in: 20 } }) @@ -469,7 +469,7 @@ test "returns empty map when params is invalid" do test "returns map with Flag object" do reporter = insert(:user) target_account = insert(:user) - {:ok, activity} = CommonAPI.post(target_account, %{"status" => "foobar"}) + {:ok, activity} = CommonAPI.post(target_account, %{status: "foobar"}) context = Utils.generate_context_id() content = "foobar" diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs index 6c006206b..43f0617f0 100644 --- a/test/web/activity_pub/views/object_view_test.exs +++ b/test/web/activity_pub/views/object_view_test.exs @@ -44,7 +44,7 @@ test "renders `replies` collection for a note activity" do activity = insert(:note_activity, user: user) {:ok, self_reply1} = - CommonAPI.post(user, %{"status" => "self-reply 1", "in_reply_to_status_id" => activity.id}) + CommonAPI.post(user, %{status: "self-reply 1", in_reply_to_status_id: activity.id}) replies_uris = [self_reply1.object.data["id"]] result = ObjectView.render("object.json", %{object: refresh_record(activity)}) diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index 8d00893a5..20b0f223c 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -164,7 +164,7 @@ test "activity collection page aginates correctly" do posts = for i <- 0..25 do - {:ok, activity} = CommonAPI.post(user, %{"status" => "post #{i}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) activity end diff --git a/test/web/activity_pub/visibilty_test.exs b/test/web/activity_pub/visibilty_test.exs index 5b91630d4..8e9354c65 100644 --- a/test/web/activity_pub/visibilty_test.exs +++ b/test/web/activity_pub/visibilty_test.exs @@ -21,21 +21,21 @@ defmodule Pleroma.Web.ActivityPub.VisibilityTest do Pleroma.List.follow(list, unrelated) {:ok, public} = - CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "public"}) + CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "public"}) {:ok, private} = - CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "private"}) + CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "private"}) {:ok, direct} = - CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "direct"}) {:ok, unlisted} = - CommonAPI.post(user, %{"status" => "@#{mentioned.nickname}", "visibility" => "unlisted"}) + CommonAPI.post(user, %{status: "@#{mentioned.nickname}", visibility: "unlisted"}) {:ok, list} = CommonAPI.post(user, %{ - "status" => "@#{mentioned.nickname}", - "visibility" => "list:#{list.id}" + status: "@#{mentioned.nickname}", + visibility: "list:#{list.id}" }) %{ diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 4697af50e..ecf5465be 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -1747,7 +1747,7 @@ test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do test "change visibility flag", %{conn: conn, id: id, admin: admin} do response = conn - |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "public"}) + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "public"}) |> json_response(:ok) assert response["visibility"] == "public" @@ -1759,21 +1759,21 @@ test "change visibility flag", %{conn: conn, id: id, admin: admin} do response = conn - |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "private"}) + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "private"}) |> json_response(:ok) assert response["visibility"] == "private" response = conn - |> put("/api/pleroma/admin/statuses/#{id}", %{"visibility" => "unlisted"}) + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "unlisted"}) |> json_response(:ok) assert response["visibility"] == "unlisted" end test "returns 400 when visibility is unknown", %{conn: conn, id: id} do - conn = put(conn, "/api/pleroma/admin/statuses/#{id}", %{"visibility" => "test"}) + conn = put(conn, "/api/pleroma/admin/statuses/#{id}", %{visibility: "test"}) assert json_response(conn, :bad_request) == "Unsupported visibility" end @@ -2977,13 +2977,12 @@ test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do user = insert(:user) User.block(admin, blocked) - {:ok, _} = - CommonAPI.post(user, %{"status" => "@#{admin.nickname}", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"}) - {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) - {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) - {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) - {:ok, _} = CommonAPI.post(blocked, %{"status" => ".", "visibility" => "public"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + {:ok, _} = CommonAPI.post(blocked, %{status: ".", visibility: "public"}) response = conn @@ -3011,11 +3010,10 @@ test "returns only local statuses with local_only on", %{conn: conn} do test "returns private and direct statuses with godmode on", %{conn: conn, admin: admin} do user = insert(:user) - {:ok, _} = - CommonAPI.post(user, %{"status" => "@#{admin.nickname}", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"}) - {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) - {:ok, _} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) conn = get(conn, "/api/pleroma/admin/statuses?godmode=true") assert json_response(conn, 200) |> length() == 3 end @@ -3049,11 +3047,9 @@ test "renders user's statuses with a limit", %{conn: conn, user: user} do end test "doesn't return private statuses by default", %{conn: conn, user: user} do - {:ok, _private_status} = - CommonAPI.post(user, %{"status" => "private", "visibility" => "private"}) + {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"}) - {:ok, _public_status} = - CommonAPI.post(user, %{"status" => "public", "visibility" => "public"}) + {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"}) conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses") @@ -3061,11 +3057,9 @@ test "doesn't return private statuses by default", %{conn: conn, user: user} do end test "returns private statuses with godmode on", %{conn: conn, user: user} do - {:ok, _private_status} = - CommonAPI.post(user, %{"status" => "private", "visibility" => "private"}) + {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"}) - {:ok, _public_status} = - CommonAPI.post(user, %{"status" => "public", "visibility" => "public"}) + {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"}) conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?godmode=true") @@ -3074,7 +3068,7 @@ test "returns private statuses with godmode on", %{conn: conn, user: user} do test "excludes reblogs by default", %{conn: conn, user: user} do other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "."}) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, other_user) conn_res = get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses") @@ -3599,9 +3593,9 @@ test "GET /api/pleroma/admin/config/descriptions", %{conn: conn} do test "status visibility count", %{conn: conn} do admin = insert(:user, is_admin: true) user = insert(:user) - CommonAPI.post(user, %{"visibility" => "public", "status" => "hey"}) - CommonAPI.post(user, %{"visibility" => "unlisted", "status" => "hey"}) - CommonAPI.post(user, %{"visibility" => "unlisted", "status" => "hey"}) + CommonAPI.post(user, %{visibility: "public", status: "hey"}) + CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) + CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) response = conn diff --git a/test/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index 8cfa1dcfa..f00b0afb2 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -45,7 +45,7 @@ test "renders a report" do test "includes reported statuses" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "toot"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "toot"}) {:ok, report_activity} = CommonAPI.report(user, %{account_id: other_user.id, status_ids: [activity.id]}) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index c524d1c0c..26e41c313 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -27,7 +27,7 @@ defmodule Pleroma.Web.CommonAPITest do test "it works with pruned objects" do user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) Object.normalize(post, false) |> Object.prune() @@ -45,7 +45,7 @@ test "it works with pruned objects" do test "it allows users to delete their posts" do user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) with_mock Pleroma.Web.Federator, publish: fn _ -> nil end do @@ -61,7 +61,7 @@ test "it does not allow a user to delete their posts" do user = insert(:user) other_user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) assert {:error, "Could not delete"} = CommonAPI.delete(post.id, other_user) assert Activity.get_by_id(post.id) @@ -71,7 +71,7 @@ test "it allows moderators to delete other user's posts" do user = insert(:user) moderator = insert(:user, is_moderator: true) - {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) assert {:ok, delete} = CommonAPI.delete(post.id, moderator) assert delete.local @@ -83,7 +83,7 @@ test "it allows admins to delete other user's posts" do user = insert(:user) moderator = insert(:user, is_admin: true) - {:ok, post} = CommonAPI.post(user, %{"status" => "namu amida butsu"}) + {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) assert {:ok, delete} = CommonAPI.delete(post.id, moderator) assert delete.local @@ -124,7 +124,7 @@ test "favoriting race condition" do users_serial = insert_list(10, :user) users = insert_list(10, :user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "."}) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) users_serial |> Enum.map(fn user -> @@ -151,7 +151,7 @@ test "repeating race condition" do users_serial = insert_list(10, :user) users = insert_list(10, :user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "."}) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) users_serial |> Enum.map(fn user -> @@ -175,12 +175,12 @@ test "repeating race condition" do test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) [participation] = Participation.for_user(user) {:ok, convo_reply} = - CommonAPI.post(user, %{"status" => ".", "in_reply_to_conversation_id" => participation.id}) + CommonAPI.post(user, %{status: ".", in_reply_to_conversation_id: participation.id}) assert Visibility.is_direct?(convo_reply) @@ -194,8 +194,8 @@ test "when replying to a conversation / participation, it only mentions the reci {:ok, activity} = CommonAPI.post(har, %{ - "status" => "@#{jafnhar.nickname} hey", - "visibility" => "direct" + status: "@#{jafnhar.nickname} hey", + visibility: "direct" }) assert har.ap_id in activity.recipients @@ -205,10 +205,10 @@ test "when replying to a conversation / participation, it only mentions the reci {:ok, activity} = CommonAPI.post(har, %{ - "status" => "I don't really like @#{tridi.nickname}", - "visibility" => "direct", - "in_reply_to_status_id" => activity.id, - "in_reply_to_conversation_id" => participation.id + status: "I don't really like @#{tridi.nickname}", + visibility: "direct", + in_reply_to_status_id: activity.id, + in_reply_to_conversation_id: participation.id }) assert har.ap_id in activity.recipients @@ -225,8 +225,8 @@ test "with the safe_dm_mention option set, it does not mention people beyond the {:ok, activity} = CommonAPI.post(har, %{ - "status" => "@#{jafnhar.nickname} hey, i never want to see @#{tridi.nickname} again", - "visibility" => "direct" + status: "@#{jafnhar.nickname} hey, i never want to see @#{tridi.nickname} again", + visibility: "direct" }) refute tridi.ap_id in activity.recipients @@ -235,7 +235,7 @@ test "with the safe_dm_mention option set, it does not mention people beyond the test "it de-duplicates tags" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#2hu #2HU"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#2hu #2HU"}) object = Object.normalize(activity) @@ -244,7 +244,7 @@ test "it de-duplicates tags" do test "it adds emoji in the object" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => ":firefox:"}) + {:ok, activity} = CommonAPI.post(user, %{status: ":firefox:"}) assert Object.normalize(activity).data["emoji"]["firefox"] end @@ -258,9 +258,9 @@ test "it supports explicit addressing" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "Hey, I think @#{user_three.nickname} is ugly. @#{user_four.nickname} is alright though.", - "to" => [user_two.nickname, user_four.nickname, "nonexistent"] + to: [user_two.nickname, user_four.nickname, "nonexistent"] }) assert user.ap_id in activity.recipients @@ -276,8 +276,8 @@ test "it filters out obviously bad tags when accepting a post as HTML" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => post, - "content_type" => "text/html" + status: post, + content_type: "text/html" }) object = Object.normalize(activity) @@ -292,8 +292,8 @@ test "it filters out obviously bad tags when accepting a post as Markdown" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => post, - "content_type" => "text/markdown" + status: post, + content_type: "text/markdown" }) object = Object.normalize(activity) @@ -304,21 +304,21 @@ test "it filters out obviously bad tags when accepting a post as Markdown" do test "it does not allow replies to direct messages that are not direct messages themselves" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "suya..", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: "suya..", visibility: "direct"}) assert {:ok, _} = CommonAPI.post(user, %{ - "status" => "suya..", - "visibility" => "direct", - "in_reply_to_status_id" => activity.id + status: "suya..", + visibility: "direct", + in_reply_to_status_id: activity.id }) Enum.each(["public", "private", "unlisted"], fn visibility -> assert {:error, "The message visibility must be direct"} = CommonAPI.post(user, %{ - "status" => "suya..", - "visibility" => visibility, - "in_reply_to_status_id" => activity.id + status: "suya..", + visibility: visibility, + in_reply_to_status_id: activity.id }) end) end @@ -327,8 +327,7 @@ test "it allows to address a list" do user = insert(:user) {:ok, list} = Pleroma.List.create("foo", user) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) assert activity.data["bcc"] == [list.ap_id] assert activity.recipients == [list.ap_id, user.ap_id] @@ -339,7 +338,7 @@ test "it returns error when status is empty and no attachments" do user = insert(:user) assert {:error, "Cannot post an empty status without attachments"} = - CommonAPI.post(user, %{"status" => ""}) + CommonAPI.post(user, %{status: ""}) end test "it validates character limits are correctly enforced" do @@ -348,9 +347,9 @@ test "it validates character limits are correctly enforced" do user = insert(:user) assert {:error, "The status is over the character limit"} = - CommonAPI.post(user, %{"status" => "foobar"}) + CommonAPI.post(user, %{status: "foobar"}) - assert {:ok, activity} = CommonAPI.post(user, %{"status" => "12345"}) + assert {:ok, activity} = CommonAPI.post(user, %{status: "12345"}) end test "it can handle activities that expire" do @@ -361,8 +360,7 @@ test "it can handle activities that expire" do |> NaiveDateTime.truncate(:second) |> NaiveDateTime.add(1_000_000, :second) - assert {:ok, activity} = - CommonAPI.post(user, %{"status" => "chai", "expires_in" => 1_000_000}) + assert {:ok, activity} = CommonAPI.post(user, %{status: "chai", expires_in: 1_000_000}) assert expiration = Pleroma.ActivityExpiration.get_by_activity_id(activity.id) assert expiration.scheduled_at == expires_at @@ -374,14 +372,14 @@ test "reacting to a status with an emoji" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍") assert reaction.data["actor"] == user.ap_id assert reaction.data["content"] == "👍" - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:error, _} = CommonAPI.react_with_emoji(activity.id, user, ".") end @@ -390,7 +388,7 @@ test "unreacting to a status with an emoji" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍") {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") @@ -404,7 +402,7 @@ test "repeating a status" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, user) end @@ -412,7 +410,7 @@ test "repeating a status" do test "can't repeat a repeat" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, %Activity{} = announce, _} = CommonAPI.repeat(activity.id, other_user) @@ -423,10 +421,10 @@ test "repeating a status privately" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, %Activity{} = announce_activity, _} = - CommonAPI.repeat(activity.id, user, %{"visibility" => "private"}) + CommonAPI.repeat(activity.id, user, %{visibility: "private"}) assert Visibility.is_private?(announce_activity) end @@ -435,7 +433,7 @@ test "favoriting a status" do user = insert(:user) other_user = insert(:user) - {:ok, post_activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, post_activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, %Activity{data: data}} = CommonAPI.favorite(user, post_activity.id) assert data["type"] == "Like" @@ -447,7 +445,7 @@ test "retweeting a status twice returns the status" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, %Activity{} = announce, object} = CommonAPI.repeat(activity.id, user) {:ok, ^announce, ^object} = CommonAPI.repeat(activity.id, user) end @@ -456,7 +454,7 @@ test "favoriting a status twice returns ok, but without the like activity" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "cofe"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) {:ok, %Activity{}} = CommonAPI.favorite(user, activity.id) assert {:ok, :already_liked} = CommonAPI.favorite(user, activity.id) end @@ -467,7 +465,7 @@ test "favoriting a status twice returns ok, but without the like activity" do Pleroma.Config.put([:instance, :max_pinned_statuses], 1) user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"}) [user: user, activity: activity] end @@ -484,8 +482,8 @@ test "pin status", %{user: user, activity: activity} do test "pin poll", %{user: user} do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "How is fediverse today?", - "poll" => %{"options" => ["Absolutely outstanding", "Not good"], "expires_in" => 20} + status: "How is fediverse today?", + poll: %{options: ["Absolutely outstanding", "Not good"], expires_in: 20} }) assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) @@ -497,7 +495,7 @@ test "pin poll", %{user: user} do end test "unlisted statuses can be pinned", %{user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!", "visibility" => "unlisted"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!", visibility: "unlisted"}) assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) end @@ -508,7 +506,7 @@ test "only self-authored can be pinned", %{activity: activity} do end test "max pinned statuses", %{user: user, activity: activity_one} do - {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) assert {:ok, ^activity_one} = CommonAPI.pin(activity_one.id, user) @@ -576,7 +574,7 @@ test "creates a report" do reporter = insert(:user) target_user = insert(:user) - {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"}) + {:ok, activity} = CommonAPI.post(target_user, %{status: "foobar"}) reporter_ap_id = reporter.ap_id target_ap_id = target_user.ap_id @@ -813,8 +811,8 @@ test "does not allow to vote twice" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} + status: "Am I cute?", + poll: %{options: ["Yes", "No"], expires_in: 20} }) object = Object.normalize(activity) diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index 18a3b3b87..5708db6a4 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -228,7 +228,7 @@ test "for public posts, a reply" do user = insert(:user) mentioned_user = insert(:user) third_user = insert(:user) - {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) + {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) mentions = [mentioned_user.ap_id] {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "public", nil) @@ -261,7 +261,7 @@ test "for unlisted posts, a reply" do user = insert(:user) mentioned_user = insert(:user) third_user = insert(:user) - {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) + {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) mentions = [mentioned_user.ap_id] {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "unlisted", nil) @@ -292,7 +292,7 @@ test "for private posts, a reply" do user = insert(:user) mentioned_user = insert(:user) third_user = insert(:user) - {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) + {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) mentions = [mentioned_user.ap_id] {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "private", nil) @@ -322,7 +322,7 @@ test "for direct posts, a reply" do user = insert(:user) mentioned_user = insert(:user) third_user = insert(:user) - {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) + {:ok, activity} = CommonAPI.post(third_user, %{status: "uguu"}) mentions = [mentioned_user.ap_id] {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "direct", nil) @@ -463,8 +463,8 @@ test "returns attachments with descs" do desc = Jason.encode!(%{object.id => "test-desc"}) assert Utils.attachments_from_ids(%{ - "media_ids" => ["#{object.id}"], - "descriptions" => desc + media_ids: ["#{object.id}"], + descriptions: desc }) == [ Map.merge(object.data, %{"name" => "test-desc"}) ] @@ -472,7 +472,7 @@ test "returns attachments with descs" do test "returns attachments without descs" do object = insert(:note) - assert Utils.attachments_from_ids(%{"media_ids" => ["#{object.id}"]}) == [object.data] + assert Utils.attachments_from_ids(%{media_ids: ["#{object.id}"]}) == [object.data] end test "returns [] when not pass media_ids" do diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index 261518ef0..de90aa6e0 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -29,7 +29,7 @@ defmodule Pleroma.Web.FederatorTest do describe "Publish an activity" do setup do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI"}) relay_mock = { Pleroma.Web.ActivityPub.Relay, @@ -96,7 +96,7 @@ test "it federates only to reachable instances via AP" do Instances.set_consistently_unreachable(URI.parse(inbox2).host) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "HI @nick1@domain.com, @nick2@domain2.com!"}) + CommonAPI.post(user, %{status: "HI @nick1@domain.com, @nick2@domain2.com!"}) expected_dt = NaiveDateTime.to_iso8601(dt) diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index d95aac108..a54161bd4 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -21,7 +21,7 @@ test "gets a feed (ATOM)", %{conn: conn} do ) user = insert(:user) - {:ok, activity1} = CommonAPI.post(user, %{"status" => "yeah #PleromaArt"}) + {:ok, activity1} = CommonAPI.post(user, %{status: "yeah #PleromaArt"}) object = Object.normalize(activity1) @@ -43,9 +43,9 @@ test "gets a feed (ATOM)", %{conn: conn} do |> Ecto.Changeset.change(data: object_data) |> Pleroma.Repo.update() - {:ok, activity2} = CommonAPI.post(user, %{"status" => "42 This is :moominmamma #PleromaArt"}) + {:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"}) - {:ok, _activity3} = CommonAPI.post(user, %{"status" => "This is :moominmamma"}) + {:ok, _activity3} = CommonAPI.post(user, %{status: "This is :moominmamma"}) response = conn @@ -88,7 +88,7 @@ test "gets a feed (RSS)", %{conn: conn} do ) user = insert(:user) - {:ok, activity1} = CommonAPI.post(user, %{"status" => "yeah #PleromaArt"}) + {:ok, activity1} = CommonAPI.post(user, %{status: "yeah #PleromaArt"}) object = Object.normalize(activity1) @@ -110,9 +110,9 @@ test "gets a feed (RSS)", %{conn: conn} do |> Ecto.Changeset.change(data: object_data) |> Pleroma.Repo.update() - {:ok, activity2} = CommonAPI.post(user, %{"status" => "42 This is :moominmamma #PleromaArt"}) + {:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"}) - {:ok, _activity3} = CommonAPI.post(user, %{"status" => "This is :moominmamma"}) + {:ok, _activity3} = CommonAPI.post(user, %{status: "This is :moominmamma"}) response = conn diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 0d48ae4ae..280bd6aca 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -226,7 +226,7 @@ test "works with announces that are just addressed to public", %{conn: conn} do user = insert(:user, ap_id: "https://honktest/u/test", local: false) other_user = insert(:user) - {:ok, post} = CommonAPI.post(other_user, %{"status" => "bonkeronk"}) + {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"}) {:ok, announce, _} = %{ @@ -255,7 +255,7 @@ test "respects blocks", %{user: user_one, conn: conn} do User.block(user_one, user_two) - {:ok, activity} = CommonAPI.post(user_two, %{"status" => "User one sux0rz"}) + {:ok, activity} = CommonAPI.post(user_two, %{status: "User one sux0rz"}) {:ok, repeat, _} = CommonAPI.repeat(activity.id, user_three) assert resp = @@ -298,16 +298,16 @@ test "gets users statuses", %{conn: conn} do {:ok, _user_three} = User.follow(user_three, user_one) - {:ok, activity} = CommonAPI.post(user_one, %{"status" => "HI!!!"}) + {:ok, activity} = CommonAPI.post(user_one, %{status: "HI!!!"}) {:ok, direct_activity} = CommonAPI.post(user_one, %{ - "status" => "Hi, @#{user_two.nickname}.", - "visibility" => "direct" + status: "Hi, @#{user_two.nickname}.", + visibility: "direct" }) {:ok, private_activity} = - CommonAPI.post(user_one, %{"status" => "private", "visibility" => "private"}) + CommonAPI.post(user_one, %{status: "private", visibility: "private"}) # TODO!!! resp = @@ -362,8 +362,7 @@ test "gets an users media", %{conn: conn} do {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: user.ap_id) - {:ok, %{id: image_post_id}} = - CommonAPI.post(user, %{"status" => "cofe", "media_ids" => [media_id]}) + {:ok, %{id: image_post_id}} = CommonAPI.post(user, %{status: "cofe", media_ids: [media_id]}) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?only_media=true") @@ -375,7 +374,7 @@ test "gets an users media", %{conn: conn} do end test "gets a user's statuses without reblogs", %{user: user, conn: conn} do - {:ok, %{id: post_id}} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "HI!!!"}) {:ok, _, _} = CommonAPI.repeat(post_id, user) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=true") @@ -386,8 +385,8 @@ test "gets a user's statuses without reblogs", %{user: user, conn: conn} do end test "filters user's statuses by a hashtag", %{user: user, conn: conn} do - {:ok, %{id: post_id}} = CommonAPI.post(user, %{"status" => "#hashtag"}) - {:ok, _post} = CommonAPI.post(user, %{"status" => "hashtag"}) + {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "#hashtag"}) + {:ok, _post} = CommonAPI.post(user, %{status: "hashtag"}) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?tagged=hashtag") assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200) @@ -398,9 +397,9 @@ test "the user views their own timelines and excludes direct messages", %{ conn: conn } do {:ok, %{id: public_activity_id}} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) + CommonAPI.post(user, %{status: ".", visibility: "public"}) - {:ok, _direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, _direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_visibilities[]=direct") assert [%{"id" => ^public_activity_id}] = json_response_and_validate_schema(conn, 200) @@ -678,7 +677,7 @@ test "following without reblogs" do assert %{"showing_reblogs" => false} = json_response_and_validate_schema(ret_conn, 200) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) {:ok, %{id: reblog_id}, _} = CommonAPI.repeat(activity.id, followed) assert [] == @@ -777,7 +776,7 @@ test "without notifications", %{conn: conn} do describe "pinned statuses" do setup do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"}) %{conn: conn} = oauth_access(["read:statuses"], user: user) [conn: conn, user: user, activity: activity] diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs index 04695572e..693ba51e5 100644 --- a/test/web/mastodon_api/controllers/conversation_controller_test.exs +++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs @@ -22,16 +22,16 @@ test "returns a list of conversations", %{user: user_one, conn: conn} do {:ok, direct} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!", + visibility: "direct" }) assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 {:ok, _follower_only} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "private" + status: "Hi @#{user_two.nickname}!", + visibility: "private" }) res_conn = get(conn, "/api/v1/conversations") @@ -63,32 +63,32 @@ test "filters conversations by recipients", %{user: user_one, conn: conn} do {:ok, direct1} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}!", + visibility: "direct" }) {:ok, _direct2} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_three.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_three.nickname}!", + visibility: "direct" }) {:ok, direct3} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!", + visibility: "direct" }) {:ok, _direct4} = CommonAPI.post(user_two, %{ - "status" => "Hi @#{user_three.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_three.nickname}!", + visibility: "direct" }) {:ok, direct5} = CommonAPI.post(user_two, %{ - "status" => "Hi @#{user_one.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_one.nickname}!", + visibility: "direct" }) assert [conversation1, conversation2] = @@ -112,15 +112,15 @@ test "updates the last_status on reply", %{user: user_one, conn: conn} do {:ok, direct} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}", + visibility: "direct" }) {:ok, direct_reply} = CommonAPI.post(user_two, %{ - "status" => "reply", - "visibility" => "direct", - "in_reply_to_status_id" => direct.id + status: "reply", + visibility: "direct", + in_reply_to_status_id: direct.id }) [%{"last_status" => res_last_status}] = @@ -136,8 +136,8 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do {:ok, direct} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}", + visibility: "direct" }) assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 @@ -167,9 +167,9 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do # The conversation is marked as unread on reply {:ok, _} = CommonAPI.post(user_two, %{ - "status" => "reply", - "visibility" => "direct", - "in_reply_to_status_id" => direct.id + status: "reply", + visibility: "direct", + in_reply_to_status_id: direct.id }) [%{"unread" => true}] = @@ -183,9 +183,9 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do # A reply doesn't increment the user's unread_conversation_count if the conversation is unread {:ok, _} = CommonAPI.post(user_two, %{ - "status" => "reply", - "visibility" => "direct", - "in_reply_to_status_id" => direct.id + status: "reply", + visibility: "direct", + in_reply_to_status_id: direct.id }) assert User.get_cached_by_id(user_one.id).unread_conversation_count == 1 @@ -197,8 +197,8 @@ test "(vanilla) Mastodon frontend behaviour", %{user: user_one, conn: conn} do {:ok, direct} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}!", + visibility: "direct" }) res_conn = get(conn, "/api/v1/statuses/#{direct.id}/context") diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 90840d5ab..2c61dc5ba 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -50,7 +50,7 @@ test "get instance stats", %{conn: conn} do insert(:user, %{local: false, nickname: "u@peer1.com"}) insert(:user, %{local: false, nickname: "u@peer2.com"}) - {:ok, _} = Pleroma.Web.CommonAPI.post(user, %{"status" => "cofe"}) + {:ok, _} = Pleroma.Web.CommonAPI.post(user, %{status: "cofe"}) Pleroma.Stats.force_update() diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index db380f76a..d9356a844 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -18,7 +18,7 @@ test "does NOT render account/pleroma/relationship if this is disabled by defaul %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) {:ok, [_notification]} = Notification.create_notifications(activity) response = @@ -36,7 +36,7 @@ test "list of notifications" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) {:ok, [_notification]} = Notification.create_notifications(activity) @@ -60,7 +60,7 @@ test "getting a single notification" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) @@ -79,7 +79,7 @@ test "dismissing a single notification (deprecated endpoint)" do %{user: user, conn: conn} = oauth_access(["write:notifications"]) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) @@ -96,7 +96,7 @@ 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, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) @@ -112,7 +112,7 @@ test "clearing all notifications" do %{user: user, conn: conn} = oauth_access(["write:notifications", "read:notifications"]) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) {:ok, [_notification]} = Notification.create_notifications(activity) @@ -130,10 +130,10 @@ test "paginates notifications using min_id, since_id, max_id, and limit" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) - {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity3} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity4} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) + {:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) + {:ok, activity3} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) + {:ok, activity4} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) notification1_id = get_notification_id_by_activity(activity1) notification2_id = get_notification_id_by_activity(activity2) @@ -173,16 +173,16 @@ test "filters notifications for mentions" do other_user = insert(:user) {:ok, public_activity} = - CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "public"}) + CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "public"}) {:ok, direct_activity} = - CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "direct"}) + CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"}) {:ok, unlisted_activity} = - CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "unlisted"}) + CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "unlisted"}) {:ok, private_activity} = - CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "private"}) + CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "private"}) query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "private"]}) conn_res = get(conn, "/api/v1/notifications?" <> query) @@ -213,17 +213,15 @@ test "filters notifications for Like activities" do user = insert(:user) %{user: other_user, conn: conn} = oauth_access(["read:notifications"]) - {:ok, public_activity} = - CommonAPI.post(other_user, %{"status" => ".", "visibility" => "public"}) + {:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"}) {:ok, direct_activity} = - CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "direct"}) + CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"}) {:ok, unlisted_activity} = - CommonAPI.post(other_user, %{"status" => ".", "visibility" => "unlisted"}) + CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"}) - {:ok, private_activity} = - CommonAPI.post(other_user, %{"status" => ".", "visibility" => "private"}) + {:ok, private_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "private"}) {:ok, _} = CommonAPI.favorite(user, public_activity.id) {:ok, _} = CommonAPI.favorite(user, direct_activity.id) @@ -279,11 +277,10 @@ test "filters notifications for Announce activities" do user = insert(:user) %{user: other_user, conn: conn} = oauth_access(["read:notifications"]) - {:ok, public_activity} = - CommonAPI.post(other_user, %{"status" => ".", "visibility" => "public"}) + {:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"}) {:ok, unlisted_activity} = - CommonAPI.post(other_user, %{"status" => ".", "visibility" => "unlisted"}) + CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"}) {:ok, _, _} = CommonAPI.repeat(public_activity.id, user) {:ok, _, _} = CommonAPI.repeat(unlisted_activity.id, user) @@ -303,8 +300,8 @@ test "filters notifications using exclude_types" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) - {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) - {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"}) + {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) {: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) @@ -341,8 +338,8 @@ test "filters notifications using include_types" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) - {:ok, mention_activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) - {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"}) + {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) {: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) @@ -388,10 +385,10 @@ test "destroy multiple" do %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"]) other_user = insert(:user) - {:ok, activity1} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity2} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) - {:ok, activity3} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"}) - {:ok, activity4} = CommonAPI.post(user, %{"status" => "hi @#{other_user.nickname}"}) + {:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) + {:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) + {:ok, activity3} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"}) + {:ok, activity4} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"}) notification1_id = get_notification_id_by_activity(activity1) notification2_id = get_notification_id_by_activity(activity2) @@ -435,7 +432,7 @@ test "doesn't see notifications after muting user with notifications" do user2 = insert(:user) {:ok, _, _, _} = CommonAPI.follow(user, user2) - {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + {:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"}) ret_conn = get(conn, "/api/v1/notifications") @@ -453,7 +450,7 @@ test "see notifications after muting user without notifications" do user2 = insert(:user) {:ok, _, _, _} = CommonAPI.follow(user, user2) - {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + {:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"}) ret_conn = get(conn, "/api/v1/notifications") @@ -471,7 +468,7 @@ test "see notifications after muting user with notifications and with_muted para user2 = insert(:user) {:ok, _, _, _} = CommonAPI.follow(user, user2) - {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + {:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"}) ret_conn = get(conn, "/api/v1/notifications") @@ -518,14 +515,14 @@ test "preserves parameters in link headers" do {:ok, activity1} = CommonAPI.post(other_user, %{ - "status" => "hi @#{user.nickname}", - "visibility" => "public" + status: "hi @#{user.nickname}", + visibility: "public" }) {:ok, activity2} = CommonAPI.post(other_user, %{ - "status" => "hi @#{user.nickname}", - "visibility" => "public" + status: "hi @#{user.nickname}", + visibility: "public" }) notification1 = Repo.get_by(Notification, activity_id: activity1.id) @@ -550,8 +547,8 @@ test "account_id" do %{id: account_id} = other_user1 = insert(:user) other_user2 = insert(:user) - {:ok, _activity} = CommonAPI.post(other_user1, %{"status" => "hi @#{user.nickname}"}) - {:ok, _activity} = CommonAPI.post(other_user2, %{"status" => "bye @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(other_user1, %{status: "hi @#{user.nickname}"}) + {:ok, _activity} = CommonAPI.post(other_user2, %{status: "bye @#{user.nickname}"}) assert [%{"account" => %{"id" => ^account_id}}] = conn diff --git a/test/web/mastodon_api/controllers/poll_controller_test.exs b/test/web/mastodon_api/controllers/poll_controller_test.exs index d8f34aa86..f41de6448 100644 --- a/test/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/web/mastodon_api/controllers/poll_controller_test.exs @@ -16,8 +16,8 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do test "returns poll entity for object id", %{user: user, conn: conn} do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Pleroma does", - "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20} + status: "Pleroma does", + poll: %{options: ["what Mastodon't", "n't what Mastodoes"], expires_in: 20} }) object = Object.normalize(activity) @@ -34,9 +34,9 @@ test "does not expose polls for private statuses", %{conn: conn} do {:ok, activity} = CommonAPI.post(other_user, %{ - "status" => "Pleroma does", - "poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20}, - "visibility" => "private" + status: "Pleroma does", + poll: %{options: ["what Mastodon't", "n't what Mastodoes"], expires_in: 20}, + visibility: "private" }) object = Object.normalize(activity) @@ -55,11 +55,11 @@ test "votes are added to the poll", %{conn: conn} do {:ok, activity} = CommonAPI.post(other_user, %{ - "status" => "A very delicious sandwich", - "poll" => %{ - "options" => ["Lettuce", "Grilled Bacon", "Tomato"], - "expires_in" => 20, - "multiple" => true + status: "A very delicious sandwich", + poll: %{ + options: ["Lettuce", "Grilled Bacon", "Tomato"], + expires_in: 20, + multiple: true } }) @@ -81,8 +81,8 @@ test "votes are added to the poll", %{conn: conn} do test "author can't vote", %{user: user, conn: conn} do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} + status: "Am I cute?", + poll: %{options: ["Yes", "No"], expires_in: 20} }) object = Object.normalize(activity) @@ -102,8 +102,8 @@ test "does not allow multiple choices on a single-choice question", %{conn: conn {:ok, activity} = CommonAPI.post(other_user, %{ - "status" => "The glass is", - "poll" => %{"options" => ["half empty", "half full"], "expires_in" => 20} + status: "The glass is", + poll: %{options: ["half empty", "half full"], expires_in: 20} }) object = Object.normalize(activity) @@ -125,8 +125,8 @@ test "does not allow choice index to be greater than options count", %{conn: con {:ok, activity} = CommonAPI.post(other_user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} + status: "Am I cute?", + poll: %{options: ["Yes", "No"], expires_in: 20} }) object = Object.normalize(activity) @@ -153,9 +153,9 @@ test "returns 404 when poll is private and not available for user", %{conn: conn {:ok, activity} = CommonAPI.post(other_user, %{ - "status" => "Am I cute?", - "poll" => %{"options" => ["Yes", "No"], "expires_in" => 20}, - "visibility" => "private" + status: "Am I cute?", + poll: %{options: ["Yes", "No"], expires_in: 20}, + visibility: "private" }) object = Object.normalize(activity) diff --git a/test/web/mastodon_api/controllers/report_controller_test.exs b/test/web/mastodon_api/controllers/report_controller_test.exs index 21b037237..6636cff96 100644 --- a/test/web/mastodon_api/controllers/report_controller_test.exs +++ b/test/web/mastodon_api/controllers/report_controller_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.ReportControllerTest do setup do target_user = insert(:user) - {:ok, activity} = CommonAPI.post(target_user, %{"status" => "foobar"}) + {:ok, activity} = CommonAPI.post(target_user, %{status: "foobar"}) [target_user: target_user, activity: activity] end diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 02476acb6..6ad9a59fe 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -42,15 +42,15 @@ test "search", %{conn: conn} do user_two = insert(:user, %{nickname: "shp@shitposter.club"}) user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"}) - {:ok, activity} = CommonAPI.post(user, %{"status" => "This is about 2hu private 天子"}) + {:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"}) {:ok, _activity} = CommonAPI.post(user, %{ - "status" => "This is about 2hu, but private", - "visibility" => "private" + status: "This is about 2hu, but private", + visibility: "private" }) - {:ok, _} = CommonAPI.post(user_two, %{"status" => "This isn't"}) + {:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"}) results = conn @@ -80,9 +80,9 @@ test "excludes a blocked users from search results", %{conn: conn} do user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"}) user_neo = insert(:user, %{nickname: "Agent Neo", name: "Agent"}) - {:ok, act1} = CommonAPI.post(user, %{"status" => "This is about 2hu private 天子"}) - {:ok, act2} = CommonAPI.post(user_smith, %{"status" => "Agent Smith"}) - {:ok, act3} = CommonAPI.post(user_neo, %{"status" => "Agent Smith"}) + {:ok, act1} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"}) + {:ok, act2} = CommonAPI.post(user_smith, %{status: "Agent Smith"}) + {:ok, act3} = CommonAPI.post(user_neo, %{status: "Agent Smith"}) Pleroma.User.block(user, user_smith) results = @@ -161,15 +161,15 @@ test "search", %{conn: conn} do user_two = insert(:user, %{nickname: "shp@shitposter.club"}) user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"}) - {:ok, activity} = CommonAPI.post(user, %{"status" => "This is about 2hu"}) + {:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu"}) {:ok, _activity} = CommonAPI.post(user, %{ - "status" => "This is about 2hu, but private", - "visibility" => "private" + status: "This is about 2hu, but private", + visibility: "private" }) - {:ok, _} = CommonAPI.post(user_two, %{"status" => "This isn't"}) + {:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"}) results = conn @@ -189,7 +189,7 @@ test "search fetches remote statuses and prefers them over other results", %{con capture_log(fn -> {:ok, %{id: activity_id}} = CommonAPI.post(insert(:user), %{ - "status" => "check out https://shitposter.club/notice/2827873" + status: "check out https://shitposter.club/notice/2827873" }) results = @@ -207,8 +207,8 @@ test "search fetches remote statuses and prefers them over other results", %{con test "search doesn't show statuses that it shouldn't", %{conn: conn} do {:ok, activity} = CommonAPI.post(insert(:user), %{ - "status" => "This is about 2hu, but private", - "visibility" => "private" + status: "This is about 2hu, but private", + visibility: "private" }) capture_log(fn -> @@ -251,8 +251,8 @@ test "search with limit and offset", %{conn: conn} do _user_two = insert(:user, %{nickname: "shp@shitposter.club"}) _user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"}) - {:ok, _activity1} = CommonAPI.post(user, %{"status" => "This is about 2hu"}) - {:ok, _activity2} = CommonAPI.post(user, %{"status" => "This is also about 2hu"}) + {:ok, _activity1} = CommonAPI.post(user, %{status: "This is about 2hu"}) + {:ok, _activity2} = CommonAPI.post(user, %{status: "This is also about 2hu"}) result = conn @@ -277,7 +277,7 @@ test "search returns results only for the given type", %{conn: conn} do user = insert(:user) _user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"}) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "This is about 2hu"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "This is about 2hu"}) assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} = conn @@ -294,8 +294,8 @@ test "search uses account_id to filter statuses by the author", %{conn: conn} do user = insert(:user, %{nickname: "shp@shitposter.club"}) user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"}) - {:ok, activity1} = CommonAPI.post(user, %{"status" => "This is about 2hu"}) - {:ok, activity2} = CommonAPI.post(user_two, %{"status" => "This is also about 2hu"}) + {:ok, activity1} = CommonAPI.post(user, %{status: "This is about 2hu"}) + {:ok, activity2} = CommonAPI.post(user_two, %{status: "This is also about 2hu"}) results = conn diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 85068edd0..a4403132c 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -32,13 +32,14 @@ test "posting a status does not increment reblog_count when relaying", %{conn: c response = conn + |> put_req_header("content-type", "application/json") |> post("api/v1/statuses", %{ "content_type" => "text/plain", "source" => "Pleroma FE", "status" => "Hello world", "visibility" => "public" }) - |> json_response(200) + |> json_response_and_validate_schema(200) assert response["reblogs_count"] == 0 ObanHelpers.perform_all() @@ -46,7 +47,7 @@ test "posting a status does not increment reblog_count when relaying", %{conn: c response = conn |> get("api/v1/statuses/#{response["id"]}", %{}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert response["reblogs_count"] == 0 end @@ -56,6 +57,7 @@ test "posting a status", %{conn: conn} do conn_one = conn + |> put_req_header("content-type", "application/json") |> put_req_header("idempotency-key", idempotency_key) |> post("/api/v1/statuses", %{ "status" => "cofe", @@ -68,12 +70,13 @@ test "posting a status", %{conn: conn} do assert ttl > :timer.seconds(6 * 60 * 60 - 1) assert %{"content" => "cofe", "id" => id, "spoiler_text" => "2hu", "sensitive" => false} = - json_response(conn_one, 200) + json_response_and_validate_schema(conn_one, 200) assert Activity.get_by_id(id) conn_two = conn + |> put_req_header("content-type", "application/json") |> put_req_header("idempotency-key", idempotency_key) |> post("/api/v1/statuses", %{ "status" => "cofe", @@ -86,13 +89,14 @@ test "posting a status", %{conn: conn} do conn_three = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses", %{ "status" => "cofe", "spoiler_text" => "2hu", "sensitive" => "false" }) - assert %{"id" => third_id} = json_response(conn_three, 200) + assert %{"id" => third_id} = json_response_and_validate_schema(conn_three, 200) refute id == third_id # An activity that will expire: @@ -101,12 +105,15 @@ test "posting a status", %{conn: conn} do conn_four = conn + |> put_req_header("content-type", "application/json") |> post("api/v1/statuses", %{ "status" => "oolong", "expires_in" => expires_in }) - assert fourth_response = %{"id" => fourth_id} = json_response(conn_four, 200) + assert fourth_response = + %{"id" => fourth_id} = json_response_and_validate_schema(conn_four, 200) + assert activity = Activity.get_by_id(fourth_id) assert expiration = ActivityExpiration.get_by_activity_id(fourth_id) @@ -130,22 +137,24 @@ test "it fails to create a status if `expires_in` is less or equal than an hour" assert %{"error" => "Expiry date is too soon"} = conn + |> put_req_header("content-type", "application/json") |> post("api/v1/statuses", %{ "status" => "oolong", "expires_in" => expires_in }) - |> json_response(422) + |> json_response_and_validate_schema(422) # 30 minutes expires_in = 30 * 60 assert %{"error" => "Expiry date is too soon"} = conn + |> put_req_header("content-type", "application/json") |> post("api/v1/statuses", %{ "status" => "oolong", "expires_in" => expires_in }) - |> json_response(422) + |> json_response_and_validate_schema(422) end test "posting an undefined status with an attachment", %{user: user, conn: conn} do @@ -158,21 +167,24 @@ test "posting an undefined status with an attachment", %{user: user, conn: conn} {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "media_ids" => [to_string(upload.id)] }) - assert json_response(conn, 200) + assert json_response_and_validate_schema(conn, 200) end test "replying to a status", %{user: user, conn: conn} do - {:ok, replied_to} = CommonAPI.post(user, %{"status" => "cofe"}) + {:ok, replied_to} = CommonAPI.post(user, %{status: "cofe"}) conn = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id}) - assert %{"content" => "xD", "id" => id} = json_response(conn, 200) + assert %{"content" => "xD", "id" => id} = json_response_and_validate_schema(conn, 200) activity = Activity.get_by_id(id) @@ -184,43 +196,56 @@ test "replying to a direct message with visibility other than direct", %{ user: user, conn: conn } do - {:ok, replied_to} = CommonAPI.post(user, %{"status" => "suya..", "visibility" => "direct"}) + {:ok, replied_to} = CommonAPI.post(user, %{status: "suya..", visibility: "direct"}) Enum.each(["public", "private", "unlisted"], fn visibility -> conn = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses", %{ "status" => "@#{user.nickname} hey", "in_reply_to_id" => replied_to.id, "visibility" => visibility }) - assert json_response(conn, 422) == %{"error" => "The message visibility must be direct"} + assert json_response_and_validate_schema(conn, 422) == %{ + "error" => "The message visibility must be direct" + } end) end test "posting a status with an invalid in_reply_to_id", %{conn: conn} do - conn = post(conn, "/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => ""}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => ""}) - assert %{"content" => "xD", "id" => id} = json_response(conn, 200) + assert %{"content" => "xD", "id" => id} = json_response_and_validate_schema(conn, 200) assert Activity.get_by_id(id) end test "posting a sensitive status", %{conn: conn} do - conn = post(conn, "/api/v1/statuses", %{"status" => "cofe", "sensitive" => true}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{"status" => "cofe", "sensitive" => true}) + + assert %{"content" => "cofe", "id" => id, "sensitive" => true} = + json_response_and_validate_schema(conn, 200) - assert %{"content" => "cofe", "id" => id, "sensitive" => true} = json_response(conn, 200) assert Activity.get_by_id(id) end test "posting a fake status", %{conn: conn} do real_conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it" }) - real_status = json_response(real_conn, 200) + real_status = json_response_and_validate_schema(real_conn, 200) assert real_status assert Object.get_by_ap_id(real_status["uri"]) @@ -234,13 +259,15 @@ test "posting a fake status", %{conn: conn} do |> Kernel.put_in(["pleroma", "conversation_id"], nil) fake_conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "\"Tenshi Eating a Corndog\" is a much discussed concept on /jp/. The significance of it is disputed, so I will focus on one core concept: the symbolism behind it", "preview" => true }) - fake_status = json_response(fake_conn, 200) + fake_status = json_response_and_validate_schema(fake_conn, 200) assert fake_status refute Object.get_by_ap_id(fake_status["uri"]) @@ -261,11 +288,15 @@ test "posting a status with OGP link preview", %{conn: conn} do Config.put([:rich_media, :enabled], true) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "https://example.com/ogp" }) - assert %{"id" => id, "card" => %{"title" => "The Rock"}} = json_response(conn, 200) + assert %{"id" => id, "card" => %{"title" => "The Rock"}} = + json_response_and_validate_schema(conn, 200) + assert Activity.get_by_id(id) end @@ -273,9 +304,12 @@ test "posting a direct status", %{conn: conn} do user2 = insert(:user) content = "direct cofe @#{user2.nickname}" - conn = post(conn, "api/v1/statuses", %{"status" => content, "visibility" => "direct"}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("api/v1/statuses", %{"status" => content, "visibility" => "direct"}) - assert %{"id" => id} = response = json_response(conn, 200) + assert %{"id" => id} = response = json_response_and_validate_schema(conn, 200) assert response["visibility"] == "direct" assert response["pleroma"]["direct_conversation_id"] assert activity = Activity.get_by_id(id) @@ -289,32 +323,45 @@ test "posting a direct status", %{conn: conn} do setup do: oauth_access(["write:statuses"]) test "creates a scheduled activity", %{conn: conn} do - scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + scheduled_at = + NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + |> NaiveDateTime.to_iso8601() + |> Kernel.<>("Z") conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "scheduled", "scheduled_at" => scheduled_at }) - assert %{"scheduled_at" => expected_scheduled_at} = json_response(conn, 200) + assert %{"scheduled_at" => expected_scheduled_at} = + json_response_and_validate_schema(conn, 200) + assert expected_scheduled_at == CommonAPI.Utils.to_masto_date(scheduled_at) assert [] == Repo.all(Activity) end test "ignores nil values", %{conn: conn} do conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "not scheduled", "scheduled_at" => nil }) - assert result = json_response(conn, 200) + assert result = json_response_and_validate_schema(conn, 200) assert Activity.get_by_id(result["id"]) end test "creates a scheduled activity with a media attachment", %{user: user, conn: conn} do - scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(120), :millisecond) + scheduled_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(:timer.minutes(120), :millisecond) + |> NaiveDateTime.to_iso8601() + |> Kernel.<>("Z") file = %Plug.Upload{ content_type: "image/jpg", @@ -325,13 +372,17 @@ test "creates a scheduled activity with a media attachment", %{user: user, conn: {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "media_ids" => [to_string(upload.id)], "status" => "scheduled", "scheduled_at" => scheduled_at }) - assert %{"media_attachments" => [media_attachment]} = json_response(conn, 200) + assert %{"media_attachments" => [media_attachment]} = + json_response_and_validate_schema(conn, 200) + assert %{"type" => "image"} = media_attachment end @@ -339,14 +390,18 @@ test "skips the scheduling and creates the activity if scheduled_at is earlier t %{conn: conn} do scheduled_at = NaiveDateTime.add(NaiveDateTime.utc_now(), :timer.minutes(5) - 1, :millisecond) + |> NaiveDateTime.to_iso8601() + |> Kernel.<>("Z") conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "not scheduled", "scheduled_at" => scheduled_at }) - assert %{"content" => "not scheduled"} = json_response(conn, 200) + assert %{"content" => "not scheduled"} = json_response_and_validate_schema(conn, 200) assert [] == Repo.all(ScheduledActivity) end @@ -355,14 +410,19 @@ test "returns error when daily user limit is exceeded", %{user: user, conn: conn NaiveDateTime.utc_now() |> NaiveDateTime.add(:timer.minutes(6), :millisecond) |> NaiveDateTime.to_iso8601() + # TODO + |> Kernel.<>("Z") attrs = %{params: %{}, scheduled_at: today} {:ok, _} = ScheduledActivity.create(user, attrs) {:ok, _} = ScheduledActivity.create(user, attrs) - conn = post(conn, "/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => today}) - assert %{"error" => "daily limit exceeded"} == json_response(conn, 422) + assert %{"error" => "daily limit exceeded"} == json_response_and_validate_schema(conn, 422) end test "returns error when total user limit is exceeded", %{user: user, conn: conn} do @@ -370,11 +430,13 @@ test "returns error when total user limit is exceeded", %{user: user, conn: conn NaiveDateTime.utc_now() |> NaiveDateTime.add(:timer.minutes(6), :millisecond) |> NaiveDateTime.to_iso8601() + |> Kernel.<>("Z") tomorrow = NaiveDateTime.utc_now() |> NaiveDateTime.add(:timer.hours(36), :millisecond) |> NaiveDateTime.to_iso8601() + |> Kernel.<>("Z") attrs = %{params: %{}, scheduled_at: today} {:ok, _} = ScheduledActivity.create(user, attrs) @@ -382,9 +444,11 @@ test "returns error when total user limit is exceeded", %{user: user, conn: conn {:ok, _} = ScheduledActivity.create(user, %{params: %{}, scheduled_at: tomorrow}) conn = - post(conn, "/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow}) + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{"status" => "scheduled", "scheduled_at" => tomorrow}) - assert %{"error" => "total limit exceeded"} == json_response(conn, 422) + assert %{"error" => "total limit exceeded"} == json_response_and_validate_schema(conn, 422) end end @@ -395,12 +459,17 @@ test "posting a poll", %{conn: conn} do time = NaiveDateTime.utc_now() conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "Who is the #bestgrill?", - "poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420} + "poll" => %{ + "options" => ["Rei", "Asuka", "Misato"], + "expires_in" => 420 + } }) - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) assert Enum.all?(response["poll"]["options"], fn %{"title" => title} -> title in ["Rei", "Asuka", "Misato"] @@ -419,12 +488,14 @@ test "option limit is enforced", %{conn: conn} do limit = Config.get([:instance, :poll_limits, :max_options]) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "desu~", "poll" => %{"options" => Enum.map(0..limit, fn _ -> "desu" end), "expires_in" => 1} }) - %{"error" => error} = json_response(conn, 422) + %{"error" => error} = json_response_and_validate_schema(conn, 422) assert error == "Poll can't contain more than #{limit} options" end @@ -432,7 +503,9 @@ test "option character limit is enforced", %{conn: conn} do limit = Config.get([:instance, :poll_limits, :max_option_chars]) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "...", "poll" => %{ "options" => [Enum.reduce(0..limit, "", fn _, acc -> acc <> "." end)], @@ -440,7 +513,7 @@ test "option character limit is enforced", %{conn: conn} do } }) - %{"error" => error} = json_response(conn, 422) + %{"error" => error} = json_response_and_validate_schema(conn, 422) assert error == "Poll options cannot be longer than #{limit} characters each" end @@ -448,7 +521,9 @@ test "minimal date limit is enforced", %{conn: conn} do limit = Config.get([:instance, :poll_limits, :min_expiration]) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "imagine arbitrary limits", "poll" => %{ "options" => ["this post was made by pleroma gang"], @@ -456,7 +531,7 @@ test "minimal date limit is enforced", %{conn: conn} do } }) - %{"error" => error} = json_response(conn, 422) + %{"error" => error} = json_response_and_validate_schema(conn, 422) assert error == "Expiration date is too soon" end @@ -464,7 +539,9 @@ test "maximum date limit is enforced", %{conn: conn} do limit = Config.get([:instance, :poll_limits, :max_expiration]) conn = - post(conn, "/api/v1/statuses", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{ "status" => "imagine arbitrary limits", "poll" => %{ "options" => ["this post was made by pleroma gang"], @@ -472,7 +549,7 @@ test "maximum date limit is enforced", %{conn: conn} do } }) - %{"error" => error} = json_response(conn, 422) + %{"error" => error} = json_response_and_validate_schema(conn, 422) assert error == "Expiration date is too far in the future" end end @@ -483,7 +560,7 @@ test "get a status" do conn = get(conn, "/api/v1/statuses/#{activity.id}") - assert %{"id" => id} = json_response(conn, 200) + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) assert id == to_string(activity.id) end @@ -503,13 +580,13 @@ defp local_and_remote_activities do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses/#{local.id}") - assert json_response(res_conn, :not_found) == %{ + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } res_conn = get(conn, "/api/v1/statuses/#{remote.id}") - assert json_response(res_conn, :not_found) == %{ + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } end @@ -517,10 +594,10 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -532,21 +609,21 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses/#{local.id}") - assert json_response(res_conn, :not_found) == %{ + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } res_conn = get(conn, "/api/v1/statuses/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -557,11 +634,11 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/statuses/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") - assert json_response(res_conn, :not_found) == %{ + assert json_response_and_validate_schema(res_conn, :not_found) == %{ "error" => "Record not found" } end @@ -569,10 +646,10 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) res_conn = get(conn, "/api/v1/statuses/#{local.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) res_conn = get(conn, "/api/v1/statuses/#{remote.id}") - assert %{"id" => _} = json_response(res_conn, 200) + assert %{"id" => _} = json_response_and_validate_schema(res_conn, 200) end end @@ -582,7 +659,7 @@ test "getting a status that doesn't exist returns 404" do conn = get(conn, "/api/v1/statuses/#{String.downcase(activity.id)}") - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end test "get a direct status" do @@ -590,7 +667,7 @@ test "get a direct status" do other_user = insert(:user) {:ok, activity} = - CommonAPI.post(user, %{"status" => "@#{other_user.nickname}", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "@#{other_user.nickname}", visibility: "direct"}) conn = conn @@ -599,7 +676,7 @@ test "get a direct status" do [participation] = Participation.for_user(user) - res = json_response(conn, 200) + res = json_response_and_validate_schema(conn, 200) assert res["pleroma"]["direct_conversation_id"] == participation.id end @@ -611,7 +688,8 @@ test "get statuses by IDs" do query_string = "ids[]=#{id1}&ids[]=#{id2}" conn = get(conn, "/api/v1/statuses/?#{query_string}") - assert [%{"id" => ^id1}, %{"id" => ^id2}] = Enum.sort_by(json_response(conn, :ok), & &1["id"]) + assert [%{"id" => ^id1}, %{"id" => ^id2}] = + Enum.sort_by(json_response_and_validate_schema(conn, :ok), & &1["id"]) end describe "getting statuses by ids with restricted unauthenticated for local and remote" do @@ -622,17 +700,17 @@ test "get statuses by IDs" do setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}") - assert json_response(res_conn, 200) == [] + assert json_response_and_validate_schema(res_conn, 200) == [] end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) - res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}") - assert length(json_response(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end end @@ -642,18 +720,18 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: clear_config([:restrict_unauthenticated, :activities, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}") remote_id = remote.id - assert [%{"id" => ^remote_id}] = json_response(res_conn, 200) + assert [%{"id" => ^remote_id}] = json_response_and_validate_schema(res_conn, 200) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) - res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}") - assert length(json_response(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end end @@ -663,18 +741,18 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: clear_config([:restrict_unauthenticated, :activities, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}") local_id = local.id - assert [%{"id" => ^local_id}] = json_response(res_conn, 200) + assert [%{"id" => ^local_id}] = json_response_and_validate_schema(res_conn, 200) end test "if user is authenticated", %{local: local, remote: remote} do %{conn: conn} = oauth_access(["read"]) - res_conn = get(conn, "/api/v1/statuses", %{ids: [local.id, remote.id]}) + res_conn = get(conn, "/api/v1/statuses?ids[]=#{local.id}&ids[]=#{remote.id}") - assert length(json_response(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end end @@ -688,7 +766,7 @@ test "when you created it" do |> assign(:user, author) |> delete("/api/v1/statuses/#{activity.id}") - assert %{} = json_response(conn, 200) + assert %{} = json_response_and_validate_schema(conn, 200) refute Activity.get_by_id(activity.id) end @@ -702,7 +780,7 @@ test "when it doesn't exist" do |> assign(:user, author) |> delete("/api/v1/statuses/#{String.downcase(activity.id)}") - assert %{"error" => "Record not found"} == json_response(conn, 404) + assert %{"error" => "Record not found"} == json_response_and_validate_schema(conn, 404) end test "when you didn't create it" do @@ -711,7 +789,7 @@ test "when you didn't create it" do conn = delete(conn, "/api/v1/statuses/#{activity.id}") - assert %{"error" => _} = json_response(conn, 403) + assert %{"error" => _} = json_response_and_validate_schema(conn, 403) assert Activity.get_by_id(activity.id) == activity end @@ -728,7 +806,7 @@ test "when you're an admin or moderator", %{conn: conn} do |> assign(:token, insert(:oauth_token, user: admin, scopes: ["write:statuses"])) |> delete("/api/v1/statuses/#{activity1.id}") - assert %{} = json_response(res_conn, 200) + assert %{} = json_response_and_validate_schema(res_conn, 200) res_conn = conn @@ -736,7 +814,7 @@ test "when you're an admin or moderator", %{conn: conn} do |> assign(:token, insert(:oauth_token, user: moderator, scopes: ["write:statuses"])) |> delete("/api/v1/statuses/#{activity2.id}") - assert %{} = json_response(res_conn, 200) + assert %{} = json_response_and_validate_schema(res_conn, 200) refute Activity.get_by_id(activity1.id) refute Activity.get_by_id(activity2.id) @@ -749,12 +827,15 @@ test "when you're an admin or moderator", %{conn: conn} do test "reblogs and returns the reblogged status", %{conn: conn} do activity = insert(:note_activity) - conn = post(conn, "/api/v1/statuses/#{activity.id}/reblog") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/reblog") assert %{ "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}, "reblogged" => true - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert to_string(activity.id) == id end @@ -762,21 +843,30 @@ test "reblogs and returns the reblogged status", %{conn: conn} do test "returns 404 if the reblogged status doesn't exist", %{conn: conn} do activity = insert(:note_activity) - conn = post(conn, "/api/v1/statuses/#{String.downcase(activity.id)}/reblog") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{String.downcase(activity.id)}/reblog") - assert %{"error" => "Record not found"} = json_response(conn, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn, 404) end test "reblogs privately and returns the reblogged status", %{conn: conn} do activity = insert(:note_activity) - conn = post(conn, "/api/v1/statuses/#{activity.id}/reblog", %{"visibility" => "private"}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/v1/statuses/#{activity.id}/reblog", + %{"visibility" => "private"} + ) assert %{ "reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}, "reblogged" => true, "visibility" => "private" - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert to_string(activity.id) == id end @@ -802,7 +892,7 @@ test "reblogged status for another user" do "reblogged" => false, "favourited" => false, "bookmarked" => false - } = json_response(conn_res, 200) + } = json_response_and_validate_schema(conn_res, 200) conn_res = build_conn() @@ -815,7 +905,7 @@ test "reblogged status for another user" do "reblogged" => true, "favourited" => true, "bookmarked" => true - } = json_response(conn_res, 200) + } = json_response_and_validate_schema(conn_res, 200) assert to_string(activity.id) == id end @@ -829,17 +919,24 @@ test "unreblogs and returns the unreblogged status", %{user: user, conn: conn} d {:ok, _, _} = CommonAPI.repeat(activity.id, user) - conn = post(conn, "/api/v1/statuses/#{activity.id}/unreblog") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unreblog") - assert %{"id" => id, "reblogged" => false, "reblogs_count" => 0} = json_response(conn, 200) + assert %{"id" => id, "reblogged" => false, "reblogs_count" => 0} = + json_response_and_validate_schema(conn, 200) assert to_string(activity.id) == id end test "returns 404 error when activity does not exist", %{conn: conn} do - conn = post(conn, "/api/v1/statuses/foo/unreblog") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/foo/unreblog") - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end end @@ -849,10 +946,13 @@ test "returns 404 error when activity does not exist", %{conn: conn} do test "favs a status and returns it", %{conn: conn} do activity = insert(:note_activity) - conn = post(conn, "/api/v1/statuses/#{activity.id}/favourite") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/favourite") assert %{"id" => id, "favourites_count" => 1, "favourited" => true} = - json_response(conn, 200) + json_response_and_validate_schema(conn, 200) assert to_string(activity.id) == id end @@ -860,18 +960,23 @@ test "favs a status and returns it", %{conn: conn} do test "favoriting twice will just return 200", %{conn: conn} do activity = insert(:note_activity) - post(conn, "/api/v1/statuses/#{activity.id}/favourite") + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/favourite") - assert post(conn, "/api/v1/statuses/#{activity.id}/favourite") - |> json_response(200) + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/favourite") + |> json_response_and_validate_schema(200) end test "returns 404 error for a wrong id", %{conn: conn} do conn = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/1/favourite") - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end end @@ -883,18 +988,24 @@ test "unfavorites a status and returns it", %{user: user, conn: conn} do {:ok, _} = CommonAPI.favorite(user, activity.id) - conn = post(conn, "/api/v1/statuses/#{activity.id}/unfavourite") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/unfavourite") assert %{"id" => id, "favourites_count" => 0, "favourited" => false} = - json_response(conn, 200) + json_response_and_validate_schema(conn, 200) assert to_string(activity.id) == id end test "returns 404 error for a wrong id", %{conn: conn} do - conn = post(conn, "/api/v1/statuses/1/unfavourite") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/1/unfavourite") - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end end @@ -902,7 +1013,7 @@ test "returns 404 error for a wrong id", %{conn: conn} do setup do: oauth_access(["write:accounts"]) setup %{user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI!!!"}) %{activity: activity} end @@ -914,21 +1025,25 @@ test "pin status", %{conn: conn, user: user, activity: activity} do assert %{"id" => ^id_str, "pinned" => true} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/#{activity.id}/pin") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [%{"id" => ^id_str, "pinned" => true}] = conn |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do - {:ok, dm} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"}) + {:ok, dm} = CommonAPI.post(user, %{status: "test", visibility: "direct"}) - conn = post(conn, "/api/v1/statuses/#{dm.id}/pin") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{dm.id}/pin") - assert json_response(conn, 400) == %{"error" => "Could not pin"} + assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not pin"} end test "unpin status", %{conn: conn, user: user, activity: activity} do @@ -941,29 +1056,33 @@ test "unpin status", %{conn: conn, user: user, activity: activity} do conn |> assign(:user, user) |> post("/api/v1/statuses/#{activity.id}/unpin") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [] = conn |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "/unpin: returns 400 error when activity is not exist", %{conn: conn} do - conn = post(conn, "/api/v1/statuses/1/unpin") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/1/unpin") - assert json_response(conn, 400) == %{"error" => "Could not unpin"} + assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not unpin"} end test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do - {:ok, activity_two} = CommonAPI.post(user, %{"status" => "HI!!!"}) + {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) id_str_one = to_string(activity_one.id) assert %{"id" => ^id_str_one, "pinned" => true} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/#{id_str_one}/pin") - |> json_response(200) + |> json_response_and_validate_schema(200) user = refresh_record(user) @@ -971,7 +1090,7 @@ test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do conn |> assign(:user, user) |> post("/api/v1/statuses/#{activity_two.id}/pin") - |> json_response(400) + |> json_response_and_validate_schema(400) end end @@ -985,7 +1104,7 @@ test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do test "returns rich-media card", %{conn: conn, user: user} do Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) - {:ok, activity} = CommonAPI.post(user, %{"status" => "https://example.com/ogp"}) + {:ok, activity} = CommonAPI.post(user, %{status: "https://example.com/ogp"}) card_data = %{ "image" => "http://ia.media-imdb.com/images/rock.jpg", @@ -1011,18 +1130,18 @@ test "returns rich-media card", %{conn: conn, user: user} do response = conn |> get("/api/v1/statuses/#{activity.id}/card") - |> json_response(200) + |> json_response_and_validate_schema(200) assert response == card_data # works with private posts {:ok, activity} = - CommonAPI.post(user, %{"status" => "https://example.com/ogp", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "https://example.com/ogp", visibility: "direct"}) response_two = conn |> get("/api/v1/statuses/#{activity.id}/card") - |> json_response(200) + |> json_response_and_validate_schema(200) assert response_two == card_data end @@ -1030,13 +1149,12 @@ test "returns rich-media card", %{conn: conn, user: user} do test "replaces missing description with an empty string", %{conn: conn, user: user} do Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "https://example.com/ogp-missing-data"}) + {:ok, activity} = CommonAPI.post(user, %{status: "https://example.com/ogp-missing-data"}) response = conn |> get("/api/v1/statuses/#{activity.id}/card") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response == %{ "type" => "link", @@ -1063,36 +1181,42 @@ test "bookmarks" do %{conn: conn} = oauth_access(["write:bookmarks", "read:bookmarks"]) author = insert(:user) - {:ok, activity1} = - CommonAPI.post(author, %{ - "status" => "heweoo?" - }) + {:ok, activity1} = CommonAPI.post(author, %{status: "heweoo?"}) + {:ok, activity2} = CommonAPI.post(author, %{status: "heweoo!"}) - {:ok, activity2} = - CommonAPI.post(author, %{ - "status" => "heweoo!" - }) - - response1 = post(conn, "/api/v1/statuses/#{activity1.id}/bookmark") + response1 = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity1.id}/bookmark") - assert json_response(response1, 200)["bookmarked"] == true + assert json_response_and_validate_schema(response1, 200)["bookmarked"] == true - response2 = post(conn, "/api/v1/statuses/#{activity2.id}/bookmark") + response2 = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity2.id}/bookmark") - assert json_response(response2, 200)["bookmarked"] == true + assert json_response_and_validate_schema(response2, 200)["bookmarked"] == true bookmarks = get(conn, bookmarks_uri) - assert [json_response(response2, 200), json_response(response1, 200)] == - json_response(bookmarks, 200) + assert [ + json_response_and_validate_schema(response2, 200), + json_response_and_validate_schema(response1, 200) + ] == + json_response_and_validate_schema(bookmarks, 200) - response1 = post(conn, "/api/v1/statuses/#{activity1.id}/unbookmark") + response1 = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity1.id}/unbookmark") - assert json_response(response1, 200)["bookmarked"] == false + assert json_response_and_validate_schema(response1, 200)["bookmarked"] == false bookmarks = get(conn, bookmarks_uri) - assert [json_response(response2, 200)] == json_response(bookmarks, 200) + assert [json_response_and_validate_schema(response2, 200)] == + json_response_and_validate_schema(bookmarks, 200) end describe "conversation muting" do @@ -1100,7 +1224,7 @@ test "bookmarks" do setup do post_user = insert(:user) - {:ok, activity} = CommonAPI.post(post_user, %{"status" => "HIE"}) + {:ok, activity} = CommonAPI.post(post_user, %{status: "HIE"}) %{activity: activity} end @@ -1109,16 +1233,22 @@ test "mute conversation", %{conn: conn, activity: activity} do assert %{"id" => ^id_str, "muted" => true} = conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/#{activity.id}/mute") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "cannot mute already muted conversation", %{conn: conn, user: user, activity: activity} do {:ok, _} = CommonAPI.add_mute(user, activity) - conn = post(conn, "/api/v1/statuses/#{activity.id}/mute") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/mute") - assert json_response(conn, 400) == %{"error" => "conversation is already muted"} + assert json_response_and_validate_schema(conn, 400) == %{ + "error" => "conversation is already muted" + } end test "unmute conversation", %{conn: conn, user: user, activity: activity} do @@ -1130,7 +1260,7 @@ test "unmute conversation", %{conn: conn, user: user, activity: activity} do conn # |> assign(:user, user) |> post("/api/v1/statuses/#{activity.id}/unmute") - |> json_response(200) + |> json_response_and_validate_schema(200) end end @@ -1139,16 +1269,17 @@ test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{c user2 = insert(:user) user3 = insert(:user) - {:ok, replied_to} = CommonAPI.post(user1, %{"status" => "cofe"}) + {:ok, replied_to} = CommonAPI.post(user1, %{status: "cofe"}) # Reply to status from another user conn1 = conn |> assign(:user, user2) |> assign(:token, insert(:oauth_token, user: user2, scopes: ["write:statuses"])) + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses", %{"status" => "xD", "in_reply_to_id" => replied_to.id}) - assert %{"content" => "xD", "id" => id} = json_response(conn1, 200) + assert %{"content" => "xD", "id" => id} = json_response_and_validate_schema(conn1, 200) activity = Activity.get_by_id_with_object(id) @@ -1160,10 +1291,11 @@ test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{c conn |> assign(:user, user3) |> assign(:token, insert(:oauth_token, user: user3, scopes: ["write:statuses"])) + |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/#{activity.id}/reblog") assert %{"reblog" => %{"id" => id, "reblogged" => true, "reblogs_count" => 1}} = - json_response(conn2, 200) + json_response_and_validate_schema(conn2, 200) assert to_string(activity.id) == id @@ -1186,7 +1318,7 @@ test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{c setup do: oauth_access(["read:accounts"]) setup %{user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) %{activity: activity} end @@ -1198,7 +1330,7 @@ test "returns users who have favorited the status", %{conn: conn, activity: acti response = conn |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [%{"id" => id}] = response @@ -1212,7 +1344,7 @@ test "returns empty array when status has not been favorited yet", %{ response = conn |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -1229,7 +1361,7 @@ test "does not return users who have favorited the status but are blocked", %{ response = conn |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -1241,7 +1373,7 @@ test "does not fail on an unauthenticated request", %{activity: activity} do response = build_conn() |> get("/api/v1/statuses/#{activity.id}/favourited_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [%{"id" => id}] = response assert id == other_user.id @@ -1252,8 +1384,8 @@ test "requires authentication for private posts", %{user: user} do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "@#{other_user.nickname} wanna get some #cofe together?", - "visibility" => "direct" + status: "@#{other_user.nickname} wanna get some #cofe together?", + visibility: "direct" }) {:ok, _} = CommonAPI.favorite(other_user, activity.id) @@ -1262,7 +1394,7 @@ test "requires authentication for private posts", %{user: user} do build_conn() |> get(favourited_by_url) - |> json_response(404) + |> json_response_and_validate_schema(404) conn = build_conn() @@ -1272,12 +1404,12 @@ test "requires authentication for private posts", %{user: user} do conn |> assign(:token, nil) |> get(favourited_by_url) - |> json_response(404) + |> json_response_and_validate_schema(404) response = conn |> get(favourited_by_url) - |> json_response(200) + |> json_response_and_validate_schema(200) [%{"id" => id}] = response assert id == other_user.id @@ -1288,7 +1420,7 @@ test "requires authentication for private posts", %{user: user} do setup do: oauth_access(["read:accounts"]) setup %{user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) %{activity: activity} end @@ -1300,7 +1432,7 @@ test "returns users who have reblogged the status", %{conn: conn, activity: acti response = conn |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [%{"id" => id}] = response @@ -1314,7 +1446,7 @@ test "returns empty array when status has not been reblogged yet", %{ response = conn |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -1331,7 +1463,7 @@ test "does not return users who have reblogged the status but are blocked", %{ response = conn |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -1342,12 +1474,12 @@ test "does not return users who have reblogged the status privately", %{ } do other_user = insert(:user) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user, %{"visibility" => "private"}) + {:ok, _, _} = CommonAPI.repeat(activity.id, other_user, %{visibility: "private"}) response = conn |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -1359,7 +1491,7 @@ test "does not fail on an unauthenticated request", %{activity: activity} do response = build_conn() |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [%{"id" => id}] = response assert id == other_user.id @@ -1370,20 +1502,20 @@ test "requires authentication for private posts", %{user: user} do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "@#{other_user.nickname} wanna get some #cofe together?", - "visibility" => "direct" + status: "@#{other_user.nickname} wanna get some #cofe together?", + visibility: "direct" }) build_conn() |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(404) + |> json_response_and_validate_schema(404) response = build_conn() |> assign(:user, other_user) |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:accounts"])) |> get("/api/v1/statuses/#{activity.id}/reblogged_by") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [] == response end @@ -1392,16 +1524,16 @@ test "requires authentication for private posts", %{user: user} do test "context" do user = insert(:user) - {:ok, %{id: id1}} = CommonAPI.post(user, %{"status" => "1"}) - {:ok, %{id: id2}} = CommonAPI.post(user, %{"status" => "2", "in_reply_to_status_id" => id1}) - {:ok, %{id: id3}} = CommonAPI.post(user, %{"status" => "3", "in_reply_to_status_id" => id2}) - {:ok, %{id: id4}} = CommonAPI.post(user, %{"status" => "4", "in_reply_to_status_id" => id3}) - {:ok, %{id: id5}} = CommonAPI.post(user, %{"status" => "5", "in_reply_to_status_id" => id4}) + {:ok, %{id: id1}} = CommonAPI.post(user, %{status: "1"}) + {:ok, %{id: id2}} = CommonAPI.post(user, %{status: "2", in_reply_to_status_id: id1}) + {:ok, %{id: id3}} = CommonAPI.post(user, %{status: "3", in_reply_to_status_id: id2}) + {:ok, %{id: id4}} = CommonAPI.post(user, %{status: "4", in_reply_to_status_id: id3}) + {:ok, %{id: id5}} = CommonAPI.post(user, %{status: "5", in_reply_to_status_id: id4}) response = build_conn() |> get("/api/v1/statuses/#{id3}/context") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert %{ "ancestors" => [%{"id" => ^id1}, %{"id" => ^id2}], @@ -1413,14 +1545,14 @@ test "returns the favorites of a user" do %{user: user, conn: conn} = oauth_access(["read:favourites"]) other_user = insert(:user) - {:ok, _} = CommonAPI.post(other_user, %{"status" => "bla"}) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "traps are happy"}) + {:ok, _} = CommonAPI.post(other_user, %{status: "bla"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "traps are happy"}) {:ok, _} = CommonAPI.favorite(user, activity.id) first_conn = get(conn, "/api/v1/favourites") - assert [status] = json_response(first_conn, 200) + assert [status] = json_response_and_validate_schema(first_conn, 200) assert status["id"] == to_string(activity.id) assert [{"link", _link_header}] = @@ -1429,8 +1561,7 @@ test "returns the favorites of a user" do # Honours query params {:ok, second_activity} = CommonAPI.post(other_user, %{ - "status" => - "Trees Are Never Sad Look At Them Every Once In Awhile They're Quite Beautiful." + status: "Trees Are Never Sad Look At Them Every Once In Awhile They're Quite Beautiful." }) {:ok, _} = CommonAPI.favorite(user, second_activity.id) @@ -1439,17 +1570,17 @@ test "returns the favorites of a user" do second_conn = get(conn, "/api/v1/favourites?since_id=#{last_like}") - assert [second_status] = json_response(second_conn, 200) + assert [second_status] = json_response_and_validate_schema(second_conn, 200) assert second_status["id"] == to_string(second_activity.id) third_conn = get(conn, "/api/v1/favourites?limit=0") - assert [] = json_response(third_conn, 200) + assert [] = json_response_and_validate_schema(third_conn, 200) end test "expires_at is nil for another user" do %{conn: conn, user: user} = oauth_access(["read:statuses"]) - {:ok, activity} = CommonAPI.post(user, %{"status" => "foobar", "expires_in" => 1_000_000}) + {:ok, activity} = CommonAPI.post(user, %{status: "foobar", expires_in: 1_000_000}) expires_at = activity.id @@ -1458,11 +1589,15 @@ test "expires_at is nil for another user" do |> NaiveDateTime.to_iso8601() assert %{"pleroma" => %{"expires_at" => ^expires_at}} = - conn |> get("/api/v1/statuses/#{activity.id}") |> json_response(:ok) + conn + |> get("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(:ok) %{conn: conn} = oauth_access(["read:statuses"]) assert %{"pleroma" => %{"expires_at" => nil}} = - conn |> get("/api/v1/statuses/#{activity.id}") |> json_response(:ok) + conn + |> get("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(:ok) end end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 06efdc901..6d8f81b75 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -28,7 +28,7 @@ test "does NOT render account/pleroma/relationship if this is disabled by defaul other_user = insert(:user) - {:ok, _} = CommonAPI.post(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, _} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"}) response = conn @@ -47,8 +47,8 @@ test "the home timeline", %{user: user, conn: conn} do following = insert(:user, nickname: "followed") third_user = insert(:user, nickname: "repeated") - {:ok, _activity} = CommonAPI.post(following, %{"status" => "post"}) - {:ok, activity} = CommonAPI.post(third_user, %{"status" => "repeated post"}) + {: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, uri) @@ -108,14 +108,12 @@ test "the home timeline", %{user: user, conn: conn} do end test "the home timeline when the direct messages are excluded", %{user: user, conn: conn} do - {:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) - {:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) + {:ok, public_activity} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + {:ok, direct_activity} = CommonAPI.post(user, %{status: ".", visibility: "direct"}) - {:ok, unlisted_activity} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) + {:ok, unlisted_activity} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"}) - {:ok, private_activity} = - CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) + {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) conn = get(conn, "/api/v1/timelines/home", %{"exclude_visibilities" => ["direct"]}) @@ -132,7 +130,7 @@ test "the home timeline when the direct messages are excluded", %{user: user, co test "the public timeline", %{conn: conn} do following = insert(:user) - {:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) + {:ok, _activity} = CommonAPI.post(following, %{status: "test"}) _activity = insert(:note_activity, local: false) @@ -152,10 +150,10 @@ test "the public timeline", %{conn: conn} do test "the public timeline includes only public statuses for an authenticated user" do %{user: user, conn: conn} = oauth_access(["read:statuses"]) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "test"}) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "private"}) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "unlisted"}) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "test", "visibility" => "direct"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "test"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "private"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "unlisted"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "test", visibility: "direct"}) res_conn = get(conn, "/api/v1/timelines/public") assert length(json_response(res_conn, 200)) == 1 @@ -263,14 +261,14 @@ test "direct timeline", %{conn: conn} do {:ok, direct} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}!", + visibility: "direct" }) {:ok, _follower_only} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "private" + status: "Hi @#{user_two.nickname}!", + visibility: "private" }) conn_user_two = @@ -306,8 +304,8 @@ test "direct timeline", %{conn: conn} do Enum.each(1..20, fn _ -> {:ok, _} = CommonAPI.post(user_one, %{ - "status" => "Hi @#{user_two.nickname}!", - "visibility" => "direct" + status: "Hi @#{user_two.nickname}!", + visibility: "direct" }) end) @@ -332,14 +330,14 @@ test "doesn't include DMs from blocked users" do {:ok, _blocked_direct} = CommonAPI.post(blocked, %{ - "status" => "Hi @#{blocker.nickname}!", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}!", + visibility: "direct" }) {:ok, direct} = CommonAPI.post(other_user, %{ - "status" => "Hi @#{blocker.nickname}!", - "visibility" => "direct" + status: "Hi @#{blocker.nickname}!", + visibility: "direct" }) res_conn = get(conn, "api/v1/timelines/direct") @@ -354,8 +352,8 @@ test "doesn't include DMs from blocked users" do test "list timeline", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, _activity_one} = CommonAPI.post(user, %{"status" => "Marisa is cute."}) - {:ok, activity_two} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."}) + {:ok, _activity_one} = CommonAPI.post(user, %{status: "Marisa is cute."}) + {:ok, activity_two} = CommonAPI.post(other_user, %{status: "Marisa is cute."}) {:ok, list} = Pleroma.List.create("name", user) {:ok, list} = Pleroma.List.follow(list, other_user) @@ -371,12 +369,12 @@ test "list timeline does not leak non-public statuses for unfollowed users", %{ conn: conn } do other_user = insert(:user) - {:ok, activity_one} = CommonAPI.post(other_user, %{"status" => "Marisa is cute."}) + {:ok, activity_one} = CommonAPI.post(other_user, %{status: "Marisa is cute."}) {:ok, _activity_two} = CommonAPI.post(other_user, %{ - "status" => "Marisa is cute.", - "visibility" => "private" + status: "Marisa is cute.", + visibility: "private" }) {:ok, list} = Pleroma.List.create("name", user) @@ -397,7 +395,7 @@ test "list timeline does not leak non-public statuses for unfollowed users", %{ test "hashtag timeline", %{conn: conn} do following = insert(:user) - {:ok, activity} = CommonAPI.post(following, %{"status" => "test #2hu"}) + {:ok, activity} = CommonAPI.post(following, %{status: "test #2hu"}) nconn = get(conn, "/api/v1/timelines/tag/2hu") @@ -416,9 +414,9 @@ test "hashtag timeline", %{conn: conn} do test "multi-hashtag timeline", %{conn: conn} do user = insert(:user) - {:ok, activity_test} = CommonAPI.post(user, %{"status" => "#test"}) - {:ok, activity_test1} = CommonAPI.post(user, %{"status" => "#test #test1"}) - {:ok, activity_none} = CommonAPI.post(user, %{"status" => "#test #none"}) + {:ok, activity_test} = CommonAPI.post(user, %{status: "#test"}) + {:ok, activity_test1} = CommonAPI.post(user, %{status: "#test #test1"}) + {:ok, activity_none} = CommonAPI.post(user, %{status: "#test #none"}) any_test = get(conn, "/api/v1/timelines/tag/test", %{"any" => ["test1"]}) diff --git a/test/web/mastodon_api/mastodon_api_test.exs b/test/web/mastodon_api/mastodon_api_test.exs index cb971806a..a7f9c5205 100644 --- a/test/web/mastodon_api/mastodon_api_test.exs +++ b/test/web/mastodon_api/mastodon_api_test.exs @@ -75,9 +75,9 @@ test "returns notifications for user" do User.subscribe(subscriber, user) - {:ok, status} = CommonAPI.post(user, %{"status" => "Akariiiin"}) + {:ok, status} = CommonAPI.post(user, %{status: "Akariiiin"}) - {:ok, status1} = CommonAPI.post(user, %{"status" => "Magi"}) + {:ok, status1} = CommonAPI.post(user, %{status: "Magi"}) {:ok, [notification]} = Notification.create_notifications(status) {:ok, [notification1]} = Notification.create_notifications(status1) res = MastodonAPI.get_notifications(subscriber) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 375f0103a..69ddbb5d4 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -93,7 +93,14 @@ test "Represent a user account" do test "Represent the user account for the account owner" do user = insert(:user) - notification_settings = %Pleroma.User.NotificationSetting{} + notification_settings = %{ + followers: true, + follows: true, + non_followers: true, + non_follows: true, + privacy_option: false + } + privacy = user.default_scope assert %{ @@ -452,8 +459,8 @@ test "shows unread_conversation_count only to the account owner" do {:ok, _activity} = CommonAPI.post(other_user, %{ - "status" => "Hey @#{user.nickname}.", - "visibility" => "direct" + status: "Hey @#{user.nickname}.", + visibility: "direct" }) user = User.get_cached_by_ap_id(user.ap_id) diff --git a/test/web/mastodon_api/views/conversation_view_test.exs b/test/web/mastodon_api/views/conversation_view_test.exs index dbf3c51e2..6f84366f8 100644 --- a/test/web/mastodon_api/views/conversation_view_test.exs +++ b/test/web/mastodon_api/views/conversation_view_test.exs @@ -16,7 +16,7 @@ test "represents a Mastodon Conversation entity" do other_user = insert(:user) {:ok, activity} = - CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "hey @#{other_user.nickname}", visibility: "direct"}) [participation] = Participation.for_user_with_last_activity_id(user) diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 0806269a2..04a774d17 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -34,7 +34,7 @@ defp test_notifications_rendering(notifications, user, expected_result) do test "Mention notification" do user = insert(:user) mentioned_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey @#{mentioned_user.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey @#{mentioned_user.nickname}"}) {:ok, [notification]} = Notification.create_notifications(activity) user = User.get_cached_by_id(user.id) @@ -53,7 +53,7 @@ test "Mention notification" do test "Favourite notification" do user = insert(:user) another_user = insert(:user) - {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) {: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) @@ -73,7 +73,7 @@ test "Favourite notification" do test "Reblog notification" do user = insert(:user) another_user = insert(:user) - {:ok, create_activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) {:ok, reblog_activity, _object} = CommonAPI.repeat(create_activity.id, another_user) {:ok, [notification]} = Notification.create_notifications(reblog_activity) reblog_activity = Activity.get_by_id(create_activity.id) @@ -155,7 +155,7 @@ test "EmojiReact notification" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) {:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") activity = Repo.get(Activity, activity.id) diff --git a/test/web/mastodon_api/views/poll_view_test.exs b/test/web/mastodon_api/views/poll_view_test.exs index 63b204387..76672f36c 100644 --- a/test/web/mastodon_api/views/poll_view_test.exs +++ b/test/web/mastodon_api/views/poll_view_test.exs @@ -22,10 +22,10 @@ test "renders a poll" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Is Tenshi eating a corndog cute?", - "poll" => %{ - "options" => ["absolutely!", "sure", "yes", "why are you even asking?"], - "expires_in" => 20 + status: "Is Tenshi eating a corndog cute?", + poll: %{ + options: ["absolutely!", "sure", "yes", "why are you even asking?"], + expires_in: 20 } }) @@ -62,11 +62,11 @@ test "detects if it is multiple choice" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Which Mastodon developer is your favourite?", - "poll" => %{ - "options" => ["Gargron", "Eugen"], - "expires_in" => 20, - "multiple" => true + status: "Which Mastodon developer is your favourite?", + poll: %{ + options: ["Gargron", "Eugen"], + expires_in: 20, + multiple: true } }) @@ -91,10 +91,10 @@ test "detects emoji" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "What's with the smug face?", - "poll" => %{ - "options" => [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"], - "expires_in" => 20 + status: "What's with the smug face?", + poll: %{ + options: [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"], + expires_in: 20 } }) @@ -109,11 +109,11 @@ test "detects vote status" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Which input devices do you use?", - "poll" => %{ - "options" => ["mouse", "trackball", "trackpoint"], - "multiple" => true, - "expires_in" => 20 + status: "Which input devices do you use?", + poll: %{ + options: ["mouse", "trackball", "trackpoint"], + multiple: true, + expires_in: 20 } }) diff --git a/test/web/mastodon_api/views/scheduled_activity_view_test.exs b/test/web/mastodon_api/views/scheduled_activity_view_test.exs index 0c0987593..fbfd873ef 100644 --- a/test/web/mastodon_api/views/scheduled_activity_view_test.exs +++ b/test/web/mastodon_api/views/scheduled_activity_view_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityViewTest do test "A scheduled activity with a media attachment" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hi"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hi"}) scheduled_at = NaiveDateTime.utc_now() @@ -47,7 +47,7 @@ test "A scheduled activity with a media attachment" do expected = %{ id: to_string(scheduled_activity.id), media_attachments: - %{"media_ids" => [upload.id]} + %{media_ids: [upload.id]} |> Utils.attachments_from_ids() |> Enum.map(&StatusView.render("attachment.json", %{attachment: &1})), params: %{ diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index b5e7dc317..ffad65b01 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -20,6 +20,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do import Pleroma.Factory import Tesla.Mock + import OpenApiSpex.TestAssertions setup do mock(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -30,7 +31,7 @@ test "has an emoji reaction list" do user = insert(:user) other_user = insert(:user) third_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "dae cofe??"}) + {:ok, activity} = CommonAPI.post(user, %{status: "dae cofe??"}) {:ok, _} = CommonAPI.react_with_emoji(activity.id, user, "☕") {:ok, _} = CommonAPI.react_with_emoji(activity.id, third_user, "🍵") @@ -38,6 +39,8 @@ test "has an emoji reaction list" do activity = Repo.get(Activity, activity.id) status = StatusView.render("show.json", activity: activity) + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + assert status[:pleroma][:emoji_reactions] == [ %{name: "☕", count: 2, me: false}, %{name: "🍵", count: 1, me: false} @@ -45,6 +48,8 @@ test "has an emoji reaction list" do status = StatusView.render("show.json", activity: activity, for: user) + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) + assert status[:pleroma][:emoji_reactions] == [ %{name: "☕", count: 2, me: true}, %{name: "🍵", count: 1, me: false} @@ -54,7 +59,7 @@ test "has an emoji reaction list" do test "loads and returns the direct conversation id when given the `with_direct_conversation_id` option" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"}) [participation] = Participation.for_user(user) status = @@ -68,12 +73,13 @@ test "loads and returns the direct conversation id when given the `with_direct_c status = StatusView.render("show.json", activity: activity, for: user) assert status[:pleroma][:direct_conversation_id] == nil + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end test "returns the direct conversation id when given the `direct_conversation_id` option" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"}) [participation] = Participation.for_user(user) status = @@ -84,12 +90,13 @@ test "returns the direct conversation id when given the `direct_conversation_id` ) assert status[:pleroma][:direct_conversation_id] == participation.id + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end test "returns a temporary ap_id based user for activities missing db users" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"}) Repo.delete(user) Cachex.clear(:user_cache) @@ -119,7 +126,7 @@ test "returns a temporary ap_id based user for activities missing db users" do test "tries to get a user by nickname if fetching by ap_id doesn't work" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Hey @shp!", visibility: "direct"}) {:ok, user} = user @@ -131,6 +138,7 @@ test "tries to get a user by nickname if fetching by ap_id doesn't work" do result = StatusView.render("show.json", activity: activity) assert result[:account][:id] == to_string(user.id) + assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec()) end test "a note with null content" do @@ -149,6 +157,7 @@ test "a note with null content" do status = StatusView.render("show.json", %{activity: note}) assert status.content == "" + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end test "a note activity" do @@ -222,6 +231,7 @@ test "a note activity" do } assert status == expected + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end test "tells if the message is muted for some reason" do @@ -230,13 +240,14 @@ 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"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "test"}) relationships_opt = UserRelationship.view_relationships_option(user, [other_user]) opts = %{activity: activity} status = StatusView.render("show.json", opts) assert status.muted == false + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) status = StatusView.render("show.json", Map.put(opts, :relationships, relationships_opt)) assert status.muted == false @@ -247,6 +258,7 @@ test "tells if the message is muted for some reason" do status = StatusView.render("show.json", Map.put(for_opts, :relationships, relationships_opt)) assert status.muted == true + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end test "tells if the message is thread muted" do @@ -255,7 +267,7 @@ test "tells if the message is thread muted" do {:ok, _user_relationships} = User.mute(user, other_user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "test"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "test"}) status = StatusView.render("show.json", %{activity: activity, for: user}) assert status.pleroma.thread_muted == false @@ -270,7 +282,7 @@ test "tells if the message is thread muted" do test "tells if the status is bookmarked" do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Cute girls doing cute things"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Cute girls doing cute things"}) status = StatusView.render("show.json", %{activity: activity}) assert status.bookmarked == false @@ -292,8 +304,7 @@ test "a reply" do note = insert(:note_activity) user = insert(:user) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "he", "in_reply_to_status_id" => note.id}) + {:ok, activity} = CommonAPI.post(user, %{status: "he", in_reply_to_status_id: note.id}) status = StatusView.render("show.json", %{activity: activity}) @@ -308,12 +319,14 @@ test "contains mentions" do user = insert(:user) mentioned = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hi @#{mentioned.nickname}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hi @#{mentioned.nickname}"}) status = StatusView.render("show.json", %{activity: activity}) assert status.mentions == Enum.map([mentioned], fn u -> AccountView.render("mention.json", %{user: u}) end) + + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end test "create mentions from the 'to' field" do @@ -405,14 +418,14 @@ test "attachments" do api_spec = Pleroma.Web.ApiSpec.spec() assert expected == StatusView.render("attachment.json", %{attachment: object}) - OpenApiSpex.TestAssertions.assert_schema(expected, "Attachment", api_spec) + assert_schema(expected, "Attachment", api_spec) # If theres a "id", use that instead of the generated one object = Map.put(object, "id", 2) result = StatusView.render("attachment.json", %{attachment: object}) assert %{id: "2"} = result - OpenApiSpex.TestAssertions.assert_schema(result, "Attachment", api_spec) + assert_schema(result, "Attachment", api_spec) end test "put the url advertised in the Activity in to the url attribute" do @@ -436,6 +449,7 @@ test "a reblog" do assert represented[:id] == to_string(reblog.id) assert represented[:reblog][:id] == to_string(activity.id) assert represented[:emojis] == [] + assert_schema(represented, "Status", Pleroma.Web.ApiSpec.spec()) end test "a peertube video" do @@ -452,6 +466,7 @@ test "a peertube video" do assert represented[:id] == to_string(activity.id) assert length(represented[:media_attachments]) == 1 + assert_schema(represented, "Status", Pleroma.Web.ApiSpec.spec()) end test "funkwhale audio" do @@ -567,13 +582,15 @@ test "embeds a relationship in the account" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "drink more water" + status: "drink more water" }) result = StatusView.render("show.json", %{activity: activity, for: other_user}) assert result[:account][:pleroma][:relationship] == AccountView.render("relationship.json", %{user: other_user, target: user}) + + assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec()) end test "embeds a relationship in the account in reposts" do @@ -582,7 +599,7 @@ test "embeds a relationship in the account in reposts" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "˙˙ɐʎns" + status: "˙˙ɐʎns" }) {:ok, activity, _object} = CommonAPI.repeat(activity.id, other_user) @@ -594,6 +611,8 @@ test "embeds a relationship in the account in reposts" do assert result[:reblog][:account][:pleroma][:relationship] == AccountView.render("relationship.json", %{user: user, target: user}) + + assert_schema(result, "Status", Pleroma.Web.ApiSpec.spec()) end test "visibility/list" do @@ -601,8 +620,7 @@ test "visibility/list" do {:ok, list} = Pleroma.List.create("foo", user) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "foobar", "visibility" => "list:#{list.id}"}) + {:ok, activity} = CommonAPI.post(user, %{status: "foobar", visibility: "list:#{list.id}"}) status = StatusView.render("show.json", activity: activity) @@ -616,5 +634,6 @@ test "successfully renders a Listen activity (pleroma extension)" do assert status.length == listen_activity.data["object"]["length"] assert status.title == listen_activity.data["object"]["title"] + assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) end end diff --git a/test/web/metadata/twitter_card_test.exs b/test/web/metadata/twitter_card_test.exs index 9e9c6853a..10931b5ba 100644 --- a/test/web/metadata/twitter_card_test.exs +++ b/test/web/metadata/twitter_card_test.exs @@ -30,7 +30,7 @@ test "it renders twitter card for user info" do test "it uses summary twittercard if post has no attachment" do user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI"}) note = insert(:note, %{ @@ -56,7 +56,7 @@ test "it uses summary twittercard if post has no attachment" do test "it renders avatar not attachment if post is nsfw and unfurl_nsfw is disabled" do Pleroma.Config.put([Pleroma.Web.Metadata, :unfurl_nsfw], false) user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI"}) note = insert(:note, %{ @@ -100,7 +100,7 @@ test "it renders avatar not attachment if post is nsfw and unfurl_nsfw is disabl test "it renders supported types of attachments and skips unknown types" do user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") - {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"}) + {:ok, activity} = CommonAPI.post(user, %{status: "HI"}) note = insert(:note, %{ diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index 6b671a667..34fc4aa23 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -171,8 +171,8 @@ test "returns favorited DM only when user is logged in and he is one of recipien } do {:ok, direct} = CommonAPI.post(current_user, %{ - "status" => "Hi @#{user.nickname}!", - "visibility" => "direct" + status: "Hi @#{user.nickname}!", + visibility: "direct" }) CommonAPI.favorite(user, direct.id) @@ -204,8 +204,8 @@ test "does not return others' favorited DM when user is not one of recipients", {:ok, direct} = CommonAPI.post(user_two, %{ - "status" => "Hi @#{user.nickname}!", - "visibility" => "direct" + status: "Hi @#{user.nickname}!", + visibility: "direct" }) CommonAPI.favorite(user, direct.id) diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index 43f1b154d..cfd1dbd24 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -20,7 +20,7 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) result = conn @@ -42,7 +42,7 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") ObanHelpers.perform_all() @@ -68,7 +68,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do other_user = insert(:user) doomed_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) result = conn @@ -106,7 +106,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "#cofe"}) + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) result = conn @@ -133,7 +133,7 @@ test "/api/v1/pleroma/conversations/:id" do %{user: other_user, conn: conn} = oauth_access(["read:statuses"]) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}!", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"}) [participation] = Participation.for_user(other_user) @@ -151,18 +151,18 @@ test "/api/v1/pleroma/conversations/:id/statuses" do third_user = insert(:user) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "Hi @#{third_user.nickname}!", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hi @#{third_user.nickname}!", visibility: "direct"}) {:ok, activity} = - CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}!", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"}) [participation] = Participation.for_user(other_user) {:ok, activity_two} = CommonAPI.post(other_user, %{ - "status" => "Hi!", - "in_reply_to_status_id" => activity.id, - "in_reply_to_conversation_id" => participation.id + status: "Hi!", + in_reply_to_status_id: activity.id, + in_reply_to_conversation_id: participation.id }) result = @@ -178,9 +178,9 @@ test "/api/v1/pleroma/conversations/:id/statuses" do {:ok, %{id: id_three}} = CommonAPI.post(other_user, %{ - "status" => "Bye!", - "in_reply_to_status_id" => activity.id, - "in_reply_to_conversation_id" => participation.id + status: "Bye!", + in_reply_to_status_id: activity.id, + in_reply_to_conversation_id: participation.id }) assert [%{"id" => ^id_two}, %{"id" => ^id_three}] = @@ -198,7 +198,7 @@ test "PATCH /api/v1/pleroma/conversations/:id" do %{user: user, conn: conn} = oauth_access(["write:conversations"]) other_user = insert(:user) - {:ok, _activity} = CommonAPI.post(user, %{"status" => "Hi", "visibility" => "direct"}) + {:ok, _activity} = CommonAPI.post(user, %{status: "Hi", visibility: "direct"}) [participation] = Participation.for_user(user) @@ -229,10 +229,10 @@ test "POST /api/v1/pleroma/conversations/read" do %{user: other_user, conn: conn} = oauth_access(["write:conversations"]) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"}) {:ok, _activity} = - CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}", "visibility" => "direct"}) + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"}) [participation2, participation1] = Participation.for_user(other_user) assert Participation.get(participation2.id).read == false @@ -255,8 +255,8 @@ test "POST /api/v1/pleroma/conversations/read" do test "it marks a single notification as read", %{user: user1, conn: conn} do user2 = insert(:user) - {:ok, activity1} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"}) - {:ok, activity2} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"}) + {:ok, activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) {:ok, [notification1]} = Notification.create_notifications(activity1) {:ok, [notification2]} = Notification.create_notifications(activity2) @@ -272,9 +272,9 @@ test "it marks a single notification as read", %{user: user1, conn: conn} do test "it marks multiple notifications as read", %{user: user1, conn: conn} do user2 = insert(:user) - {:ok, _activity1} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"}) - {:ok, _activity2} = CommonAPI.post(user2, %{"status" => "hi @#{user1.nickname}"}) - {:ok, _activity3} = CommonAPI.post(user2, %{"status" => "HIE @#{user1.nickname}"}) + {:ok, _activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, _activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, _activity3} = CommonAPI.post(user2, %{status: "HIE @#{user1.nickname}"}) [notification3, notification2, notification1] = Notification.for_user(user1, %{limit: 3}) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index b855d72ba..2fc3e73b5 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -55,7 +55,7 @@ test "performs sending notifications" do data: %{alerts: %{"follow" => true, "mention" => false}} ) - {:ok, activity} = CommonAPI.post(user, %{"status" => " + status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) @@ -147,7 +147,7 @@ test "renders title and body for announce activity" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) @@ -166,7 +166,7 @@ test "renders title and body for like activity" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => + status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) @@ -184,8 +184,8 @@ test "renders title for create activity with direct visibility" do {:ok, activity} = CommonAPI.post(user, %{ - "visibility" => "direct", - "status" => "This is just between you and me, pal" + visibility: "direct", + status: "This is just between you and me, pal" }) assert Impl.format_title(%{activity: activity}) == @@ -199,8 +199,8 @@ test "hides details for notifications when privacy option enabled" do {:ok, activity} = CommonAPI.post(user, %{ - "visibility" => "direct", - "status" => " "public", - "status" => " "direct", - "status" => + visibility: "direct", + status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) @@ -263,8 +263,8 @@ test "returns regular content for notifications with privacy option disabled" do {:ok, activity} = CommonAPI.post(user, %{ - "visibility" => "public", - "status" => + visibility: "public", + status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) diff --git a/test/web/rich_media/helpers_test.exs b/test/web/rich_media/helpers_test.exs index aa0c5c830..8264a9c41 100644 --- a/test/web/rich_media/helpers_test.exs +++ b/test/web/rich_media/helpers_test.exs @@ -26,8 +26,8 @@ test "refuses to crawl incomplete URLs" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "[test](example.com/ogp)", - "content_type" => "text/markdown" + status: "[test](example.com/ogp)", + content_type: "text/markdown" }) Config.put([:rich_media, :enabled], true) @@ -40,8 +40,8 @@ test "refuses to crawl malformed URLs" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "[test](example.com[]/ogp)", - "content_type" => "text/markdown" + status: "[test](example.com[]/ogp)", + content_type: "text/markdown" }) Config.put([:rich_media, :enabled], true) @@ -54,8 +54,8 @@ test "crawls valid, complete URLs" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "[test](https://example.com/ogp)", - "content_type" => "text/markdown" + status: "[test](https://example.com/ogp)", + content_type: "text/markdown" }) Config.put([:rich_media, :enabled], true) @@ -69,8 +69,8 @@ test "refuses to crawl URLs from posts marked sensitive" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "http://example.com/ogp", - "sensitive" => true + status: "http://example.com/ogp", + sensitive: true }) %Object{} = object = Object.normalize(activity) @@ -87,7 +87,7 @@ test "refuses to crawl URLs from posts tagged NSFW" do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "http://example.com/ogp #nsfw" + status: "http://example.com/ogp #nsfw" }) %Object{} = object = Object.normalize(activity) @@ -103,12 +103,12 @@ test "refuses to crawl URLs of private network from posts" do user = insert(:user) {:ok, activity} = - CommonAPI.post(user, %{"status" => "http://127.0.0.1:4000/notice/9kCP7VNyPJXFOXDrgO"}) + CommonAPI.post(user, %{status: "http://127.0.0.1:4000/notice/9kCP7VNyPJXFOXDrgO"}) - {:ok, activity2} = CommonAPI.post(user, %{"status" => "https://10.111.10.1/notice/9kCP7V"}) - {:ok, activity3} = CommonAPI.post(user, %{"status" => "https://172.16.32.40/notice/9kCP7V"}) - {:ok, activity4} = CommonAPI.post(user, %{"status" => "https://192.168.10.40/notice/9kCP7V"}) - {:ok, activity5} = CommonAPI.post(user, %{"status" => "https://pleroma.local/notice/9kCP7V"}) + {:ok, activity2} = CommonAPI.post(user, %{status: "https://10.111.10.1/notice/9kCP7V"}) + {:ok, activity3} = CommonAPI.post(user, %{status: "https://172.16.32.40/notice/9kCP7V"}) + {:ok, activity4} = CommonAPI.post(user, %{status: "https://192.168.10.40/notice/9kCP7V"}) + {:ok, activity5} = CommonAPI.post(user, %{status: "https://pleroma.local/notice/9kCP7V"}) Config.put([:rich_media, :enabled], true) diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index 430683ea0..a49ab002f 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -32,8 +32,8 @@ test "404 when user not found", %{conn: conn} do end test "profile does not include private messages", %{conn: conn, user: user} do - CommonAPI.post(user, %{"status" => "public"}) - CommonAPI.post(user, %{"status" => "private", "visibility" => "private"}) + CommonAPI.post(user, %{status: "public"}) + CommonAPI.post(user, %{status: "private", visibility: "private"}) conn = get(conn, "/users/#{user.nickname}") @@ -44,7 +44,7 @@ test "profile does not include private messages", %{conn: conn, user: user} do end test "pagination", %{conn: conn, user: user} do - Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end) + Enum.map(1..30, fn i -> CommonAPI.post(user, %{status: "test#{i}"}) end) conn = get(conn, "/users/#{user.nickname}") @@ -57,7 +57,7 @@ test "pagination", %{conn: conn, user: user} do end test "pagination, page 2", %{conn: conn, user: user} do - activities = Enum.map(1..30, fn i -> CommonAPI.post(user, %{"status" => "test#{i}"}) end) + activities = Enum.map(1..30, fn i -> CommonAPI.post(user, %{status: "test#{i}"}) end) {:ok, a11} = Enum.at(activities, 11) conn = get(conn, "/users/#{user.nickname}?max_id=#{a11.id}") @@ -77,7 +77,7 @@ test "it requires authentication if instance is NOT federating", %{conn: conn, u describe "notice html" do test "single notice page", %{conn: conn, user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "testing a thing!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"}) conn = get(conn, "/notice/#{activity.id}") @@ -89,7 +89,7 @@ test "single notice page", %{conn: conn, user: user} do test "filters HTML tags", %{conn: conn} do user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => ""}) + {:ok, activity} = CommonAPI.post(user, %{status: ""}) conn = conn @@ -101,11 +101,11 @@ test "filters HTML tags", %{conn: conn} do end test "shows the whole thread", %{conn: conn, user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "space: the final frontier"}) + {:ok, activity} = CommonAPI.post(user, %{status: "space: the final frontier"}) CommonAPI.post(user, %{ - "status" => "these are the voyages or something", - "in_reply_to_status_id" => activity.id + status: "these are the voyages or something", + in_reply_to_status_id: activity.id }) conn = get(conn, "/notice/#{activity.id}") @@ -117,7 +117,7 @@ test "shows the whole thread", %{conn: conn, user: user} do test "redirect by AP object ID", %{conn: conn, user: user} do {:ok, %Activity{data: %{"object" => object_url}}} = - CommonAPI.post(user, %{"status" => "beam me up"}) + CommonAPI.post(user, %{status: "beam me up"}) conn = get(conn, URI.parse(object_url).path) @@ -126,7 +126,7 @@ test "redirect by AP object ID", %{conn: conn, user: user} do test "redirect by activity ID", %{conn: conn, user: user} do {:ok, %Activity{data: %{"id" => id}}} = - CommonAPI.post(user, %{"status" => "I'm a doctor, not a devops!"}) + CommonAPI.post(user, %{status: "I'm a doctor, not a devops!"}) conn = get(conn, URI.parse(id).path) @@ -140,8 +140,7 @@ test "404 when notice not found", %{conn: conn} do end test "404 for private status", %{conn: conn, user: user} do - {:ok, activity} = - CommonAPI.post(user, %{"status" => "don't show me!", "visibility" => "private"}) + {:ok, activity} = CommonAPI.post(user, %{status: "don't show me!", visibility: "private"}) conn = get(conn, "/notice/#{activity.id}") @@ -171,7 +170,7 @@ test "302 for remote cached status", %{conn: conn, user: user} do end test "it requires authentication if instance is NOT federating", %{conn: conn, user: user} do - {:ok, activity} = CommonAPI.post(user, %{"status" => "testing a thing!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"}) ensure_federating_or_authenticated(conn, "/notice/#{activity.id}", user) end diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index db07c5df5..95b7d1420 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -96,7 +96,7 @@ test "disallows list stream that are not owned by the user", %{user: user} do test "it streams the user's post in the 'user' stream", %{user: user} do Streamer.get_topic_and_add_socket("user", user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) assert_receive {:render_with_user, _, _, ^activity} refute Streamer.filtered_by_user?(user, activity) end @@ -105,7 +105,7 @@ test "it streams boosts of the user in the 'user' stream", %{user: user} do Streamer.get_topic_and_add_socket("user", user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) {:ok, announce, _} = CommonAPI.repeat(activity.id, user) assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} @@ -134,7 +134,7 @@ test "it doesn't send notify to the 'user:notification' stream when a user is bl Streamer.get_topic_and_add_socket("user:notification", user) - {:ok, activity} = CommonAPI.post(user, %{"status" => ":("}) + {:ok, activity} = CommonAPI.post(user, %{status: ":("}) {:ok, _} = CommonAPI.favorite(blocked, activity.id) refute_receive _ @@ -145,7 +145,7 @@ test "it doesn't send notify to the 'user:notification' stream when a thread is } do user2 = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) + {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"}) {:ok, _} = CommonAPI.add_mute(user, activity) Streamer.get_topic_and_add_socket("user:notification", user) @@ -161,7 +161,7 @@ test "it sends favorite to 'user:notification' stream'", %{ } do user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"}) - {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) + {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"}) Streamer.get_topic_and_add_socket("user:notification", user) {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) @@ -176,7 +176,7 @@ test "it doesn't send the 'user:notification' stream' when a domain is blocked", user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"}) {:ok, user} = User.block_domain(user, "hecking-lewd-place.com") - {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) + {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"}) Streamer.get_topic_and_add_socket("user:notification", user) {:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id) @@ -215,7 +215,7 @@ test "it sends to public authenticated" do Streamer.get_topic_and_add_socket("public", other_user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Test"}) assert_receive {:render_with_user, _, _, ^activity} refute Streamer.filtered_by_user?(user, activity) end @@ -223,7 +223,7 @@ test "it sends to public authenticated" do test "works for deletions" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "Test"}) Streamer.get_topic_and_add_socket("public", user) @@ -238,7 +238,7 @@ test "it sends to public unauthenticated" do Streamer.get_topic_and_add_socket("public", nil) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Test"}) activity_id = activity.id assert_receive {:text, event} assert %{"event" => "update", "payload" => payload} = Jason.decode!(event) @@ -323,7 +323,7 @@ test "it filters messages involving blocked users" do {:ok, _user_relationship} = User.block(user, blocked_user) Streamer.get_topic_and_add_socket("public", user) - {:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"}) + {:ok, activity} = CommonAPI.post(blocked_user, %{status: "Test"}) assert_receive {:render_with_user, _, _, ^activity} assert Streamer.filtered_by_user?(user, activity) end @@ -337,17 +337,17 @@ test "it filters messages transitively involving blocked users" do {:ok, _user_relationship} = User.block(blocker, blockee) - {:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"}) + {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"}) assert_receive {:render_with_user, _, _, ^activity_one} assert Streamer.filtered_by_user?(blocker, activity_one) - {:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"}) + {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"}) assert_receive {:render_with_user, _, _, ^activity_two} assert Streamer.filtered_by_user?(blocker, activity_two) - {:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"}) + {:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"}) assert_receive {:render_with_user, _, _, ^activity_three} assert Streamer.filtered_by_user?(blocker, activity_three) @@ -369,8 +369,8 @@ test "it doesn't send unwanted DMs to list" do {:ok, _activity} = CommonAPI.post(user_b, %{ - "status" => "@#{user_c.nickname} Test", - "visibility" => "direct" + status: "@#{user_c.nickname} Test", + visibility: "direct" }) refute_receive _ @@ -387,8 +387,8 @@ test "it doesn't send unwanted private posts to list" do {:ok, _activity} = CommonAPI.post(user_b, %{ - "status" => "Test", - "visibility" => "private" + status: "Test", + visibility: "private" }) refute_receive _ @@ -407,8 +407,8 @@ test "it sends wanted private posts to list" do {:ok, activity} = CommonAPI.post(user_b, %{ - "status" => "Test", - "visibility" => "private" + status: "Test", + visibility: "private" }) assert_receive {:render_with_user, _, _, ^activity} @@ -424,7 +424,7 @@ test "it filters muted reblogs" do CommonAPI.follow(user1, user2) CommonAPI.hide_reblogs(user1, user2) - {:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"}) + {:ok, create_activity} = CommonAPI.post(user3, %{status: "I'm kawen"}) Streamer.get_topic_and_add_socket("user", user1) {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2) @@ -438,7 +438,7 @@ test "it filters reblog notification for reblog-muted actors" do CommonAPI.follow(user1, user2) CommonAPI.hide_reblogs(user1, user2) - {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"}) + {:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"}) Streamer.get_topic_and_add_socket("user", user1) {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2) @@ -452,7 +452,7 @@ test "it send non-reblog notification for reblog-muted actors" do CommonAPI.follow(user1, user2) CommonAPI.hide_reblogs(user1, user2) - {:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"}) + {:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"}) Streamer.get_topic_and_add_socket("user", user1) {:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id) @@ -466,7 +466,7 @@ test "it filters posts from muted threads" do user2 = insert(:user) Streamer.get_topic_and_add_socket("user", user2) {:ok, user2, user, _activity} = CommonAPI.follow(user2, user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"}) + {:ok, activity} = CommonAPI.post(user, %{status: "super hot take"}) {:ok, _} = CommonAPI.add_mute(user2, activity) assert_receive {:render_with_user, _, _, ^activity} assert Streamer.filtered_by_user?(user2, activity) @@ -485,8 +485,8 @@ test "it sends conversation update to the 'direct' stream", %{} do {:ok, _create_activity} = CommonAPI.post(another_user, %{ - "status" => "hey @#{user.nickname}", - "visibility" => "direct" + status: "hey @#{user.nickname}", + visibility: "direct" }) assert_receive {:text, received_event} @@ -507,8 +507,8 @@ test "it doesn't send conversation update to the 'direct' stream when the last m {:ok, create_activity} = CommonAPI.post(another_user, %{ - "status" => "hi @#{user.nickname}", - "visibility" => "direct" + status: "hi @#{user.nickname}", + visibility: "direct" }) create_activity_id = create_activity.id @@ -533,15 +533,15 @@ test "it sends conversation update to the 'direct' stream when a message is dele {:ok, create_activity} = CommonAPI.post(another_user, %{ - "status" => "hi @#{user.nickname}", - "visibility" => "direct" + status: "hi @#{user.nickname}", + visibility: "direct" }) {:ok, create_activity2} = CommonAPI.post(another_user, %{ - "status" => "hi @#{user.nickname} 2", - "in_reply_to_status_id" => create_activity.id, - "visibility" => "direct" + status: "hi @#{user.nickname} 2", + in_reply_to_status_id: create_activity.id, + visibility: "direct" }) assert_receive {:render_with_user, _, _, ^create_activity} diff --git a/test/workers/cron/digest_emails_worker_test.exs b/test/workers/cron/digest_emails_worker_test.exs index 0a63bf4e0..f9bc50db5 100644 --- a/test/workers/cron/digest_emails_worker_test.exs +++ b/test/workers/cron/digest_emails_worker_test.exs @@ -29,7 +29,7 @@ defmodule Pleroma.Workers.Cron.DigestEmailsWorkerTest do user2 = insert(:user, last_digest_emailed_at: date) {:ok, _} = User.switch_email_notifications(user2, "digest", true) - CommonAPI.post(user, %{"status" => "hey @#{user2.nickname}!"}) + CommonAPI.post(user, %{status: "hey @#{user2.nickname}!"}) {:ok, user2: user2} end diff --git a/test/workers/cron/new_users_digest_worker_test.exs b/test/workers/cron/new_users_digest_worker_test.exs index e6d050ecc..54cf0ca46 100644 --- a/test/workers/cron/new_users_digest_worker_test.exs +++ b/test/workers/cron/new_users_digest_worker_test.exs @@ -15,7 +15,7 @@ test "it sends new users digest emails" do admin = insert(:user, %{is_admin: true}) user = insert(:user, %{inserted_at: yesterday}) user2 = insert(:user, %{inserted_at: yesterday}) - CommonAPI.post(user, %{"status" => "cofe"}) + CommonAPI.post(user, %{status: "cofe"}) NewUsersDigestWorker.perform(nil, nil) ObanHelpers.perform_all() @@ -36,7 +36,7 @@ test "it doesn't fail when admin has no email" do insert(:user, %{inserted_at: yesterday}) user = insert(:user, %{inserted_at: yesterday}) - CommonAPI.post(user, %{"status" => "cofe"}) + CommonAPI.post(user, %{status: "cofe"}) NewUsersDigestWorker.perform(nil, nil) ObanHelpers.perform_all() -- cgit v1.2.3 From 648cc0d72d7dbc0c906b07b3b66577c2bb6a12c6 Mon Sep 17 00:00:00 2001 From: minibikini Date: Tue, 12 May 2020 21:09:26 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 2c28b23aa..2b2ad04bf 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -30,7 +30,7 @@ def index_operation do :ids, :query, %Schema{type: :array, items: FlakeID}, - "Array of account IDs" + "Array of status IDs" ) ], operationId: "StatusController.index", -- cgit v1.2.3 From 0bb164a3e121d1453497e9203936d2284235feed Mon Sep 17 00:00:00 2001 From: minibikini Date: Tue, 12 May 2020 21:12:22 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 2b2ad04bf..b25d10cce 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -96,7 +96,7 @@ def reblog_operation do tags: ["Statuses"], summary: "Boost", security: [%{"oAuth" => ["write:statuses"]}], - description: "Reshare a status", + description: "Share a status", operationId: "StatusController.reblog", parameters: [id_param()], requestBody: -- cgit v1.2.3 From 40646a7e0ed7e007e9a5f4278c44b0709a1c1754 Mon Sep 17 00:00:00 2001 From: minibikini Date: Tue, 12 May 2020 21:14:52 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index b25d10cce..6b0576e3c 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -222,7 +222,7 @@ def mute_conversation_operation do summary: "Mute conversation", security: [%{"oAuth" => ["write:mutes"]}], description: - "Do not receive notifications for the thread that this status is part of. Must be a thread in which you are a participant.", + "Do not receive notifications for the thread that this status is part of.", operationId: "StatusController.mute_conversation", parameters: [id_param()], responses: %{ -- cgit v1.2.3 From 822e9c09aaddcfc0e3dcbd2f2312e1e94a0e275d Mon Sep 17 00:00:00 2001 From: minibikini Date: Tue, 12 May 2020 21:23:21 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 6b0576e3c..0aacf0f3e 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -379,7 +379,7 @@ defp create_request do type: :array, items: %Schema{type: :string}, description: - "Array of Attachment ids to be attached as media. If provided, `status` becomes optional, and `poll` cannot be used." + "Array of Attachment ids to be attached as media." }, poll: %Schema{ type: :object, -- cgit v1.2.3 From 5a2333925c00eb6ba9ce1c33f19389a59f19ed2c Mon Sep 17 00:00:00 2001 From: minibikini Date: Tue, 12 May 2020 21:23:36 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 0aacf0f3e..3b8cd40aa 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -389,7 +389,7 @@ defp create_request do type: :array, items: %Schema{type: :string}, description: - "Array of possible answers. If provided, `media_ids` cannot be used, and `poll[expires_in]` must be provided." + "Array of possible answers. Must be provided with `poll[expires_in]`." }, expires_in: %Schema{ type: :integer, -- cgit v1.2.3 From cb1a3e196cc752fef4e73b0bfb4b2d02b71e0535 Mon Sep 17 00:00:00 2001 From: minibikini Date: Tue, 12 May 2020 21:23:43 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 3b8cd40aa..f150b6edc 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -394,7 +394,7 @@ defp create_request do expires_in: %Schema{ type: :integer, description: - "Duration the poll should be open, in seconds. If provided, `media_ids` cannot be used, and `poll[options]` must be provided." + "Duration the poll should be open, in seconds. Must be provided with `poll[options]`" }, multiple: %Schema{type: :boolean, description: "Allow multiple choices?"}, hide_totals: %Schema{ -- cgit v1.2.3 From 79ad12064dfd31f135763bae1523a94c493b6aed Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 13 May 2020 01:59:17 +0400 Subject: Fix format --- lib/pleroma/web/api_spec/operations/status_operation.ex | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index f150b6edc..a6bb87560 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -221,8 +221,7 @@ def mute_conversation_operation do tags: ["Statuses"], summary: "Mute conversation", security: [%{"oAuth" => ["write:mutes"]}], - description: - "Do not receive notifications for the thread that this status is part of.", + description: "Do not receive notifications for the thread that this status is part of.", operationId: "StatusController.mute_conversation", parameters: [id_param()], responses: %{ @@ -378,8 +377,7 @@ defp create_request do media_ids: %Schema{ type: :array, items: %Schema{type: :string}, - description: - "Array of Attachment ids to be attached as media." + description: "Array of Attachment ids to be attached as media." }, poll: %Schema{ type: :object, @@ -388,8 +386,7 @@ defp create_request do options: %Schema{ type: :array, items: %Schema{type: :string}, - description: - "Array of possible answers. Must be provided with `poll[expires_in]`." + description: "Array of possible answers. Must be provided with `poll[expires_in]`." }, expires_in: %Schema{ type: :integer, -- cgit v1.2.3 From b46811a07444187e7765f439e933f214c0a0aeb3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 May 2020 16:42:24 -0500 Subject: Upgrade Comeonin to v5 https://github.com/riverrun/comeonin/blob/master/UPGRADE_v5.md --- benchmarks/load_testing/users.ex | 2 +- lib/pleroma/bbs/authenticator.ex | 3 +-- lib/pleroma/mfa.ex | 3 +-- lib/pleroma/plugs/authentication_plug.ex | 7 +++---- lib/pleroma/user.ex | 3 +-- lib/pleroma/web/auth/totp_authenticator.ex | 3 +-- lib/pleroma/web/mongooseim/mongoose_im_controller.ex | 3 +-- mix.exs | 3 +-- mix.lock | 4 ++-- test/mfa_test.exs | 5 ++--- test/plugs/authentication_plug_test.exs | 2 +- test/support/builders/user_builder.ex | 2 +- test/support/factory.ex | 2 +- test/web/auth/basic_auth_test.exs | 2 +- test/web/auth/pleroma_authenticator_test.exs | 2 +- test/web/auth/totp_authenticator_test.exs | 2 +- test/web/mongooseim/mongoose_im_controller_test.exs | 4 ++-- test/web/oauth/ldap_authorization_test.exs | 6 +++--- test/web/oauth/mfa_controller_test.exs | 4 ++-- test/web/oauth/oauth_controller_test.exs | 16 ++++++++-------- test/web/twitter_api/password_controller_test.exs | 2 +- test/web/twitter_api/util_controller_test.exs | 2 +- 22 files changed, 37 insertions(+), 45 deletions(-) diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index 1a8c6e22f..e4d0b22ff 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -55,7 +55,7 @@ defp generate_user(i) do name: "Test テスト User #{i}", email: "user#{i}@example.com", nickname: "nick#{i}", - password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), + password_hash: Pbkdf2.hash_pwd_salt("test"), bio: "Tester Number #{i}", local: !remote } diff --git a/lib/pleroma/bbs/authenticator.ex b/lib/pleroma/bbs/authenticator.ex index e5b37f33e..d4494b003 100644 --- a/lib/pleroma/bbs/authenticator.ex +++ b/lib/pleroma/bbs/authenticator.ex @@ -4,7 +4,6 @@ defmodule Pleroma.BBS.Authenticator do use Sshd.PasswordAuthenticator - alias Comeonin.Pbkdf2 alias Pleroma.User def authenticate(username, password) do @@ -12,7 +11,7 @@ def authenticate(username, password) do password = to_string(password) with %User{} = user <- User.get_by_nickname(username) do - Pbkdf2.checkpw(password, user.password_hash) + Pbkdf2.verify_pass(password, user.password_hash) else _e -> false end diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex index d353a4dad..2b77f5426 100644 --- a/lib/pleroma/mfa.ex +++ b/lib/pleroma/mfa.ex @@ -7,7 +7,6 @@ defmodule Pleroma.MFA do The MFA context. """ - alias Comeonin.Pbkdf2 alias Pleroma.User alias Pleroma.MFA.BackupCodes @@ -72,7 +71,7 @@ def invalidate_backup_code(%User{} = user, hash_code) do @spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()} def generate_backup_codes(%User{} = user) do with codes <- BackupCodes.generate(), - hashed_codes <- Enum.map(codes, &Pbkdf2.hashpwsalt/1), + hashed_codes <- Enum.map(codes, &Pbkdf2.hash_pwd_salt/1), changeset <- Changeset.cast_backup_codes(user, hashed_codes), {:ok, _} <- User.update_and_set_cache(changeset) do {:ok, codes} diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index 0061c69dc..ae4a235bd 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Plugs.AuthenticationPlug do - alias Comeonin.Pbkdf2 alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User @@ -18,7 +17,7 @@ def checkpw(password, "$6" <> _ = password_hash) do end def checkpw(password, "$pbkdf2" <> _ = password_hash) do - Pbkdf2.checkpw(password, password_hash) + Pbkdf2.verify_pass(password, password_hash) end def checkpw(_password, _password_hash) do @@ -37,7 +36,7 @@ def call( } = conn, _ ) do - if Pbkdf2.checkpw(password, password_hash) do + if Pbkdf2.verify_pass(password, password_hash) do conn |> assign(:user, auth_user) |> OAuthScopesPlug.skip_plug() @@ -47,7 +46,7 @@ def call( end def call(%{assigns: %{auth_credentials: %{password: _}}} = conn, _) do - Pbkdf2.dummy_checkpw() + Pbkdf2.no_user_verify() conn end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a86cc3202..cba391072 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -9,7 +9,6 @@ defmodule Pleroma.User do import Ecto.Query import Ecto, only: [assoc: 2] - alias Comeonin.Pbkdf2 alias Ecto.Multi alias Pleroma.Activity alias Pleroma.Config @@ -1926,7 +1925,7 @@ def get_ap_ids_by_nicknames(nicknames) do defp put_password_hash( %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset ) do - change(changeset, password_hash: Pbkdf2.hashpwsalt(password)) + change(changeset, password_hash: Pbkdf2.hash_pwd_salt(password)) end defp put_password_hash(changeset), do: changeset diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex index 98aca9a51..04e489c83 100644 --- a/lib/pleroma/web/auth/totp_authenticator.ex +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.TOTPAuthenticator do - alias Comeonin.Pbkdf2 alias Pleroma.MFA alias Pleroma.MFA.TOTP alias Pleroma.User @@ -31,7 +30,7 @@ def verify_recovery_code( code ) when is_list(codes) and is_binary(code) do - hash_code = Enum.find(codes, fn hash -> Pbkdf2.checkpw(code, hash) end) + hash_code = Enum.find(codes, fn hash -> Pbkdf2.verify_pass(code, hash) end) if hash_code do MFA.invalidate_backup_code(user, hash_code) diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 1ed6ee521..0814b3bc3 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do use Pleroma.Web, :controller - alias Comeonin.Pbkdf2 alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User @@ -28,7 +27,7 @@ def user_exists(conn, %{"user" => username}) do def check_password(conn, %{"user" => username, "pass" => password}) do with %User{password_hash: password_hash, deactivated: false} <- Repo.get_by(User, nickname: username, local: true), - true <- Pbkdf2.checkpw(password, password_hash) do + true <- Pbkdf2.verify_pass(password, password_hash) do conn |> json(true) else diff --git a/mix.exs b/mix.exs index 3656059f2..6aa459d7f 100644 --- a/mix.exs +++ b/mix.exs @@ -126,8 +126,7 @@ defp deps do {:postgrex, ">= 0.13.5"}, {:oban, "~> 1.2"}, {:gettext, "~> 0.15"}, - {:comeonin, "~> 4.1.1"}, - {:pbkdf2_elixir, "~> 0.12.3"}, + {:pbkdf2_elixir, "~> 1.0"}, {:trailing_format_plug, "~> 0.0.7"}, {:fast_sanitize, "~> 0.1"}, {:html_entities, "~> 0.5", override: true}, diff --git a/mix.lock b/mix.lock index c400202b7..022c73012 100644 --- a/mix.lock +++ b/mix.lock @@ -13,7 +13,7 @@ "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"}, + "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, @@ -76,7 +76,7 @@ "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "b862ebd78de0df95875cf46feb6e9607130dc2a8", [ref: "b862ebd78de0df95875cf46feb6e9607130dc2a8"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, - "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm", "595d09db74cb093b1903381c9de423276a931a2480a46a1a5dc7f932a2a6375b"}, + "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, "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"}, diff --git a/test/mfa_test.exs b/test/mfa_test.exs index 94bc48c26..8875cefd9 100644 --- a/test/mfa_test.exs +++ b/test/mfa_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.MFATest do use Pleroma.DataCase import Pleroma.Factory - alias Comeonin.Pbkdf2 alias Pleroma.MFA describe "mfa_settings" do @@ -31,8 +30,8 @@ test "returns backup codes" do {:ok, [code1, code2]} = MFA.generate_backup_codes(user) updated_user = refresh_record(user) [hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes - assert Pbkdf2.checkpw(code1, hash1) - assert Pbkdf2.checkpw(code2, hash2) + assert Pbkdf2.verify_pass(code1, hash1) + assert Pbkdf2.verify_pass(code2, hash2) end end diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs index 646bda9d3..31e20d726 100644 --- a/test/plugs/authentication_plug_test.exs +++ b/test/plugs/authentication_plug_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Plugs.AuthenticationPlugTest do user = %User{ id: 1, name: "dude", - password_hash: Comeonin.Pbkdf2.hashpwsalt("guy") + password_hash: Pbkdf2.hash_pwd_salt("guy") } conn = diff --git a/test/support/builders/user_builder.ex b/test/support/builders/user_builder.ex index 0d0490714..0c687c029 100644 --- a/test/support/builders/user_builder.ex +++ b/test/support/builders/user_builder.ex @@ -7,7 +7,7 @@ def build(data \\ %{}) do email: "test@example.org", name: "Test Name", nickname: "testname", - password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), + password_hash: Pbkdf2.hash_pwd_salt("test"), bio: "A tester.", ap_id: "some id", last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), diff --git a/test/support/factory.ex b/test/support/factory.ex index c8c45e2a7..d4284831c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -29,7 +29,7 @@ def user_factory do name: sequence(:name, &"Test テスト User #{&1}"), email: sequence(:email, &"user#{&1}@example.com"), nickname: sequence(:nickname, &"nick#{&1}"), - password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), + password_hash: Pbkdf2.hash_pwd_salt("test"), bio: sequence(:bio, &"Tester Number #{&1}"), last_digest_emailed_at: NaiveDateTime.utc_now(), last_refreshed_at: NaiveDateTime.utc_now(), diff --git a/test/web/auth/basic_auth_test.exs b/test/web/auth/basic_auth_test.exs index 64f8a6863..bf6e3d2fc 100644 --- a/test/web/auth/basic_auth_test.exs +++ b/test/web/auth/basic_auth_test.exs @@ -11,7 +11,7 @@ test "with HTTP Basic Auth used, grants access to OAuth scope-restricted endpoin conn: conn } do user = insert(:user) - assert Comeonin.Pbkdf2.checkpw("test", user.password_hash) + assert Pbkdf2.verify_pass("test", user.password_hash) basic_auth_contents = (URI.encode_www_form(user.nickname) <> ":" <> URI.encode_www_form("test")) diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs index 7125c5081..5a421e5ed 100644 --- a/test/web/auth/pleroma_authenticator_test.exs +++ b/test/web/auth/pleroma_authenticator_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do setup do password = "testpassword" name = "AgentSmith" - user = insert(:user, nickname: name, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + user = insert(:user, nickname: name, password_hash: Pbkdf2.hash_pwd_salt(password)) {:ok, [user: user, name: name, password: password]} end diff --git a/test/web/auth/totp_authenticator_test.exs b/test/web/auth/totp_authenticator_test.exs index e08069490..e502e0ae8 100644 --- a/test/web/auth/totp_authenticator_test.exs +++ b/test/web/auth/totp_authenticator_test.exs @@ -34,7 +34,7 @@ test "checks backup codes" do hashed_codes = backup_codes - |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1)) + |> Enum.map(&Pbkdf2.hash_pwd_salt(&1)) user = insert(:user, diff --git a/test/web/mongooseim/mongoose_im_controller_test.exs b/test/web/mongooseim/mongoose_im_controller_test.exs index 1ac2f2c27..5176cde84 100644 --- a/test/web/mongooseim/mongoose_im_controller_test.exs +++ b/test/web/mongooseim/mongoose_im_controller_test.exs @@ -41,13 +41,13 @@ test "/user_exists", %{conn: conn} do end test "/check_password", %{conn: conn} do - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("cool")) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt("cool")) _deactivated_user = insert(:user, nickname: "konata", deactivated: true, - password_hash: Comeonin.Pbkdf2.hashpwsalt("cool") + password_hash: Pbkdf2.hash_pwd_salt("cool") ) res = diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs index a8fe8a841..011642c08 100644 --- a/test/web/oauth/ldap_authorization_test.exs +++ b/test/web/oauth/ldap_authorization_test.exs @@ -19,7 +19,7 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do @tag @skip test "authorizes the existing user using LDAP credentials" do password = "testpassword" - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password)) app = insert(:oauth_app, scopes: ["read", "write"]) host = Pleroma.Config.get([:ldap, :host]) |> to_charlist @@ -104,7 +104,7 @@ test "creates a new user after successful LDAP authorization" do @tag @skip test "falls back to the default authorization when LDAP is unavailable" do password = "testpassword" - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password)) app = insert(:oauth_app, scopes: ["read", "write"]) host = Pleroma.Config.get([:ldap, :host]) |> to_charlist @@ -148,7 +148,7 @@ test "falls back to the default authorization when LDAP is unavailable" do @tag @skip test "disallow authorization for wrong LDAP credentials" do password = "testpassword" - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password)) app = insert(:oauth_app, scopes: ["read", "write"]) host = Pleroma.Config.get([:ldap, :host]) |> to_charlist diff --git a/test/web/oauth/mfa_controller_test.exs b/test/web/oauth/mfa_controller_test.exs index ce4a07320..3c341facd 100644 --- a/test/web/oauth/mfa_controller_test.exs +++ b/test/web/oauth/mfa_controller_test.exs @@ -20,7 +20,7 @@ defmodule Pleroma.Web.OAuth.MFAControllerTest do insert(:user, multi_factor_authentication_settings: %MFA.Settings{ enabled: true, - backup_codes: [Comeonin.Pbkdf2.hashpwsalt("test-code")], + backup_codes: [Pbkdf2.hash_pwd_salt("test-code")], totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} } ) @@ -247,7 +247,7 @@ test "returns access token with valid code", %{conn: conn, app: app} do hashed_codes = backup_codes - |> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1)) + |> Enum.map(&Pbkdf2.hash_pwd_salt(&1)) user = insert(:user, diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index 7a107584d..d389e4ce0 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -311,7 +311,7 @@ test "with valid params, POST /oauth/register?op=connect redirects to `redirect_ app: app, conn: conn } do - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("testpassword")) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt("testpassword")) registration = insert(:registration, user: nil) redirect_uri = OAuthController.default_redirect_uri(app) @@ -342,7 +342,7 @@ test "with unlisted `redirect_uri`, POST /oauth/register?op=connect results in H app: app, conn: conn } do - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt("testpassword")) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt("testpassword")) registration = insert(:registration, user: nil) unlisted_redirect_uri = "http://cross-site-request.com" @@ -750,7 +750,7 @@ test "issues a token for an all-body request" do test "issues a token for `password` grant_type with valid credentials, with full permissions by default" do password = "testpassword" - user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password)) app = insert(:oauth_app, scopes: ["read", "write"]) @@ -778,7 +778,7 @@ test "issues a mfa token for `password` grant_type, when MFA enabled" do user = insert(:user, - password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + password_hash: Pbkdf2.hash_pwd_salt(password), multi_factor_authentication_settings: %MFA.Settings{ enabled: true, totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true} @@ -887,7 +887,7 @@ test "rejects token exchange for valid credentials belonging to unconfirmed user password = "testpassword" {:ok, user} = - insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(password)) + insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password)) |> User.confirmation_changeset(need_confirmation: true) |> User.update_and_set_cache() @@ -915,7 +915,7 @@ test "rejects token exchange for valid credentials belonging to deactivated user user = insert(:user, - password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + password_hash: Pbkdf2.hash_pwd_salt(password), deactivated: true ) @@ -943,7 +943,7 @@ test "rejects token exchange for user with password_reset_pending set to true" d user = insert(:user, - password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + password_hash: Pbkdf2.hash_pwd_salt(password), password_reset_pending: true ) @@ -972,7 +972,7 @@ test "rejects token exchange for user with confirmation_pending set to true" do user = insert(:user, - password_hash: Comeonin.Pbkdf2.hashpwsalt(password), + password_hash: Pbkdf2.hash_pwd_salt(password), confirmation_pending: true ) diff --git a/test/web/twitter_api/password_controller_test.exs b/test/web/twitter_api/password_controller_test.exs index 0a24860d3..231a46c67 100644 --- a/test/web/twitter_api/password_controller_test.exs +++ b/test/web/twitter_api/password_controller_test.exs @@ -54,7 +54,7 @@ test "it returns HTTP 200", %{conn: conn} do assert response =~ "

    Password changed!

    " user = refresh_record(user) - assert Comeonin.Pbkdf2.checkpw("test", user.password_hash) + assert Pbkdf2.verify_pass("test", user.password_hash) assert Enum.empty?(Token.get_user_tokens(user)) end diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index b701239a0..ad919d341 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -688,7 +688,7 @@ test "with proper permissions, valid password and matching new password and conf assert json_response(conn, 200) == %{"status" => "success"} fetched_user = User.get_cached_by_id(user.id) - assert Comeonin.Pbkdf2.checkpw("newpass", fetched_user.password_hash) == true + assert Pbkdf2.verify_pass("newpass", fetched_user.password_hash) == true end end -- cgit v1.2.3 From 4ba913d64157909c63cbeab38b6036a5e4beee53 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 8 May 2020 18:51:16 +0300 Subject: {:error, :enoent} s3 fix s3 tests were executed before temp file was uploaded --- test/uploaders/s3_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/uploaders/s3_test.exs b/test/uploaders/s3_test.exs index 6950ccb25..d949c90a5 100644 --- a/test/uploaders/s3_test.exs +++ b/test/uploaders/s3_test.exs @@ -58,7 +58,7 @@ test "it returns path with bucket namespace when namespace is set" do name: "image-tet.jpg", content_type: "image/jpg", path: "test_folder/image-tet.jpg", - tempfile: Path.absname("test/fixtures/image_tmp.jpg") + tempfile: Path.absname("test/instance_static/add/shortcode.png") } [file_upload: file_upload] -- cgit v1.2.3 From 712055612f2696b2677f830be154e18f2396223d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 11 May 2020 15:07:05 +0300 Subject: don't run tests which change env in async --- test/http/request_builder_test.exs | 14 +++---- test/web/admin_api/admin_api_controller_test.exs | 51 +++++++++++------------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/test/http/request_builder_test.exs b/test/http/request_builder_test.exs index f11528c3f..fab909905 100644 --- a/test/http/request_builder_test.exs +++ b/test/http/request_builder_test.exs @@ -3,23 +3,19 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.RequestBuilderTest do - use ExUnit.Case, async: true + use ExUnit.Case use Pleroma.Tests.Helpers - alias Pleroma.Config alias Pleroma.HTTP.Request alias Pleroma.HTTP.RequestBuilder describe "headers/2" do - setup do: clear_config([:http, :send_user_agent]) - setup do: clear_config([:http, :user_agent]) - test "don't send pleroma user agent" do assert RequestBuilder.headers(%Request{}, []) == %Request{headers: []} end test "send pleroma user agent" do - Config.put([:http, :send_user_agent], true) - Config.put([:http, :user_agent], :default) + clear_config([:http, :send_user_agent], true) + clear_config([:http, :user_agent], :default) assert RequestBuilder.headers(%Request{}, []) == %Request{ headers: [{"user-agent", Pleroma.Application.user_agent()}] @@ -27,8 +23,8 @@ test "send pleroma user agent" do end test "send custom user agent" do - Config.put([:http, :send_user_agent], true) - Config.put([:http, :user_agent], "totally-not-pleroma") + clear_config([:http, :send_user_agent], true) + clear_config([:http, :user_agent], "totally-not-pleroma") assert RequestBuilder.headers(%Request{}, []) == %Request{ headers: [{"user-agent", "totally-not-pleroma"}] diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 4697af50e..94c59de9c 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2862,26 +2862,25 @@ test "proxy tuple localhost", %{conn: conn} do group: ":pleroma", key: ":http", value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]}, - %{"tuple" => [":send_user_agent", false]} + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} ] } ] }) - assert json_response(conn, 200) == %{ + assert %{ "configs" => [ %{ "group" => ":pleroma", "key" => ":http", - "value" => [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]}, - %{"tuple" => [":send_user_agent", false]} - ], - "db" => [":proxy_url", ":send_user_agent"] + "value" => value, + "db" => db } ] - } + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value + assert ":proxy_url" in db end test "proxy tuple domain", %{conn: conn} do @@ -2892,26 +2891,25 @@ test "proxy tuple domain", %{conn: conn} do group: ":pleroma", key: ":http", value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]}, - %{"tuple" => [":send_user_agent", false]} + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} ] } ] }) - assert json_response(conn, 200) == %{ + assert %{ "configs" => [ %{ "group" => ":pleroma", "key" => ":http", - "value" => [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]}, - %{"tuple" => [":send_user_agent", false]} - ], - "db" => [":proxy_url", ":send_user_agent"] + "value" => value, + "db" => db } ] - } + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value + assert ":proxy_url" in db end test "proxy tuple ip", %{conn: conn} do @@ -2922,26 +2920,25 @@ test "proxy tuple ip", %{conn: conn} do group: ":pleroma", key: ":http", value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]}, - %{"tuple" => [":send_user_agent", false]} + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} ] } ] }) - assert json_response(conn, 200) == %{ + assert %{ "configs" => [ %{ "group" => ":pleroma", "key" => ":http", - "value" => [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]}, - %{"tuple" => [":send_user_agent", false]} - ], - "db" => [":proxy_url", ":send_user_agent"] + "value" => value, + "db" => db } ] - } + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value + assert ":proxy_url" in db end end -- cgit v1.2.3 From e0944dee993eec88f678e98de9382af82ca5a77a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 11 May 2020 15:22:52 +0300 Subject: make test fail everytime --- test/web/feed/tag_controller_test.exs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index d95aac108..d726300a9 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -82,6 +82,10 @@ test "gets a feed (ATOM)", %{conn: conn} do end test "gets a feed (RSS)", %{conn: conn} do + %{microsecond: {micro, _}} = DateTime.utc_now() + micro = (micro / 1000) |> floor() + Process.sleep(950 - micro) + Pleroma.Config.put( [:feed, :post_title], %{max_length: 25, omission: "..."} -- cgit v1.2.3 From ec27f346eebc99ff8bbc7d1e34c9559cf80b8691 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 12 May 2020 12:12:10 +0300 Subject: correct order for publised in assert --- test/web/feed/tag_controller_test.exs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/web/feed/tag_controller_test.exs b/test/web/feed/tag_controller_test.exs index d726300a9..c3d83ef81 100644 --- a/test/web/feed/tag_controller_test.exs +++ b/test/web/feed/tag_controller_test.exs @@ -82,10 +82,6 @@ test "gets a feed (ATOM)", %{conn: conn} do end test "gets a feed (RSS)", %{conn: conn} do - %{microsecond: {micro, _}} = DateTime.utc_now() - micro = (micro / 1000) |> floor() - Process.sleep(950 - micro) - Pleroma.Config.put( [:feed, :post_title], %{max_length: 25, omission: "..."} @@ -142,8 +138,8 @@ test "gets a feed (RSS)", %{conn: conn} do ] assert xpath(xml, ~x"//channel/item/pubDate/text()"sl) == [ - FeedView.pub_date(activity1.data["published"]), - FeedView.pub_date(activity2.data["published"]) + FeedView.pub_date(activity2.data["published"]), + FeedView.pub_date(activity1.data["published"]) ] assert xpath(xml, ~x"//channel/item/enclosure/@url"sl) == [ -- cgit v1.2.3 From b962b24e6f984dbec0089c80b22fac8f4f9c1fa4 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 13 May 2020 08:00:17 +0300 Subject: don't run in async if tests depend on env config --- test/http/connection_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/http/connection_test.exs b/test/http/connection_test.exs index 5cc78ad5b..7c94a50b2 100644 --- a/test/http/connection_test.exs +++ b/test/http/connection_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.ConnectionTest do - use ExUnit.Case, async: true + use ExUnit.Case use Pleroma.Tests.Helpers import ExUnit.CaptureLog -- cgit v1.2.3 From 12635bc15626dd7d2d4a02b9c8d763687a0d34ce Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 13 May 2020 09:20:25 +0300 Subject: don't use global mocks in setup callbacks --- test/activity_test.exs | 7 +++++-- test/web/mastodon_api/controllers/search_controller_test.exs | 2 +- test/web/push/impl_test.exs | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/test/activity_test.exs b/test/activity_test.exs index 0c19f481b..7c3f66da9 100644 --- a/test/activity_test.exs +++ b/test/activity_test.exs @@ -11,6 +11,11 @@ defmodule Pleroma.ActivityTest do alias Pleroma.ThreadMute import Pleroma.Factory + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + test "returns an activity by it's AP id" do activity = insert(:note_activity) found_activity = Activity.get_by_ap_id(activity.data["id"]) @@ -107,8 +112,6 @@ test "when association is not loaded" do describe "search" do setup do - Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) - user = insert(:user) params = %{ diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 02476acb6..8b9459735 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do import Tesla.Mock import Mock - setup do + setup_all do mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok end diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index b855d72ba..57b35061a 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -13,8 +13,8 @@ defmodule Pleroma.Web.Push.ImplTest do import Pleroma.Factory - setup_all do - Tesla.Mock.mock_global(fn + setup do + Tesla.Mock.mock(fn %{method: :post, url: "https://example.com/example/1234"} -> %Tesla.Env{status: 200} -- cgit v1.2.3 From 2c356a4bacc534702e9d1c1451fb351152c4bf7a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 13 May 2020 09:29:41 +0300 Subject: don't use async with global mocks --- test/web/rel_me_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/rel_me_test.exs b/test/web/rel_me_test.exs index e05a8863d..65255916d 100644 --- a/test/web/rel_me_test.exs +++ b/test/web/rel_me_test.exs @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RelMeTest do - use ExUnit.Case, async: true + use ExUnit.Case setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) -- cgit v1.2.3 From fbe3d3aa5fe07a89c52a16b7bf0626e323366597 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 13 May 2020 10:17:47 +0300 Subject: ignore order --- test/config/config_db_test.exs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index 6b0e7b4b6..a8e947365 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -43,11 +43,9 @@ test "get_all_as_keyword/0" do {ConfigDB.from_string(saved.key), ConfigDB.from_binary(saved.value)} ] - assert config[:quack] == [ - level: :info, - meta: [:none], - webhook_url: "https://hooks.slack.com/services/KEY/some_val" - ] + assert config[:quack][:level] == :info + assert config[:quack][:meta] == [:none] + assert config[:quack][:webhook_url] == "https://hooks.slack.com/services/KEY/some_val" end describe "update_or_create/1" do -- cgit v1.2.3 From 33b798425f8d194db9a5acdff372f14a69e43c9d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 13 May 2020 12:50:52 +0300 Subject: [#2456] Post-merge fix. --- lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index e2922d830..958567510 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do use Pleroma.Web, :controller import Pleroma.Web.ControllerHelper, - only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] + only: [add_link_headers: 2, add_link_headers: 3] alias Pleroma.Pagination alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug -- cgit v1.2.3 From bcadbf964a56c071b2a3b5a5a95c419a467e5e1e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 13 May 2020 14:15:24 +0400 Subject: Add OpenAPI spec for SuggestionController --- .../controllers/suggestion_controller.ex | 21 ++++++++++++++++++--- .../controllers/suggestion_controller_test.exs | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex index c93a43969..f91df9ab7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex @@ -5,11 +5,26 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionController do use Pleroma.Web, :controller - alias Pleroma.Plugs.OAuthScopesPlug - require Logger - plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index) + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["read"]} when action == :index) + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %OpenApiSpex.Operation{ + tags: ["Suggestions"], + summary: "Follow suggestions (Not implemented)", + operationId: "SuggestionController.index", + responses: %{ + 200 => Pleroma.Web.ApiSpec.Helpers.empty_array_response() + } + } + end @doc "GET /api/v1/suggestions" def index(conn, params), diff --git a/test/web/mastodon_api/controllers/suggestion_controller_test.exs b/test/web/mastodon_api/controllers/suggestion_controller_test.exs index f120bd0cd..7f08e187c 100644 --- a/test/web/mastodon_api/controllers/suggestion_controller_test.exs +++ b/test/web/mastodon_api/controllers/suggestion_controller_test.exs @@ -11,7 +11,7 @@ test "returns empty result", %{conn: conn} do res = conn |> get("/api/v1/suggestions") - |> json_response(200) + |> json_response_and_validate_schema(200) assert res == [] end -- cgit v1.2.3 From ae3b0b4c0d67753696f46c5a01ecdecb10104a4e Mon Sep 17 00:00:00 2001 From: Michael Weiss Date: Wed, 13 May 2020 13:08:10 +0200 Subject: Fix digest mix task on OTP releases This is based on #2191, credit belongs to @rinpatch. --- lib/mix/tasks/pleroma/digest.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 7d09e70c5..3595f912d 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -1,5 +1,6 @@ defmodule Mix.Tasks.Pleroma.Digest do use Mix.Task + import Mix.Pleroma @shortdoc "Manages digest emails" @moduledoc File.read!("docs/administration/CLI_tasks/digest.md") @@ -22,12 +23,10 @@ def run(["test", nickname | opts]) do with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(patched_user) do {:ok, _} = Pleroma.Emails.Mailer.deliver(email) - Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") + shell_info("Digest email have been sent to #{nickname} (#{user.email})") else _ -> - Mix.shell().info( - "Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}" - ) + shell_info("Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}") end end end -- cgit v1.2.3 From 06cad239e50cada3aec4fc3b4c494a70d328672c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 13 May 2020 14:05:22 +0200 Subject: InstanceView: Add pleroma chat messages to nodeinfo --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 3 ++- test/web/node_info_test.exs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index a329ffc28..17cfc4fcf 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -68,7 +68,8 @@ def features do if Config.get([:instance, :safe_dm_mentions]) do "safe_dm_mentions" end, - "pleroma_emoji_reactions" + "pleroma_emoji_reactions", + "pleroma_chat_messages" ] |> Enum.filter(& &1) end diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 9bcc07b37..00925caad 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 "shareable_emoji_packs", "multifetch", "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter" + "pleroma:api/v1/notifications:include_types_filter", + "pleroma_chat_messages" ] assert MapSet.subset?( -- cgit v1.2.3 From 59b6d5f2aa57f78ecfe7066671bb12d223214c18 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 13 May 2020 15:08:07 +0300 Subject: [#2456] Changed `embed_relationships` param to `with_relationships`. --- lib/pleroma/web/api_spec/helpers.ex | 10 +- .../web/api_spec/operations/account_operation.ex | 14 +-- .../web/api_spec/operations/search_operation.ex | 118 ++++++++++----------- .../web/api_spec/operations/timeline_operation.ex | 4 - lib/pleroma/web/controller_helper.ex | 6 +- .../mastodon_api/controllers/account_controller.ex | 6 +- .../mastodon_api/controllers/search_controller.ex | 4 +- 7 files changed, 77 insertions(+), 85 deletions(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index ee077a3f9..859e45b57 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ApiSpec.Helpers do alias OpenApiSpex.Operation alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike def request_body(description, schema_ref, opts \\ []) do media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] @@ -47,13 +48,8 @@ def pagination_params do ] end - def embed_relationships_param do - Operation.parameter( - :embed_relationships, - :query, - :boolean, - "Embed relationships into accounts (Pleroma extension)" - ) + def with_relationships_param do + Operation.parameter(:with_relationships, :query, BooleanLike, "Include relationships") end def empty_object_response do diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index c2a56b786..7056f739b 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -155,9 +155,10 @@ def followers_operation do security: [%{"oAuth" => ["read:accounts"]}], description: "Accounts which follow the given account, if network is not hidden by the account owner.", - parameters: - [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ - pagination_params() ++ [embed_relationships_param()], + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + with_relationships_param() | pagination_params() + ], responses: %{ 200 => Operation.response("Accounts", "application/json", array_of_accounts()) } @@ -172,9 +173,10 @@ def following_operation do security: [%{"oAuth" => ["read:accounts"]}], description: "Accounts which the given account is following, if network is not hidden by the account owner.", - parameters: - [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ - pagination_params() ++ [embed_relationships_param()], + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + with_relationships_param() | pagination_params() + ], responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} } end diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index 0dd908d7f..475848ff5 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -24,30 +24,30 @@ def account_search_operation do tags: ["Search"], summary: "Search for matching accounts by username or display name", operationId: "SearchController.account_search", - parameters: - [ - Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", - required: true - ), - Operation.parameter( - :limit, - :query, - %Schema{type: :integer, default: 40}, - "Maximum number of results" - ), - Operation.parameter( - :resolve, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Attempt WebFinger lookup. Use this when `q` is an exact address." - ), - Operation.parameter( - :following, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Only include accounts that the user is following" - ) - ] ++ [embed_relationships_param()], + parameters: [ + Operation.parameter(:q, :query, %Schema{type: :string}, "What to search for", + required: true + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 40}, + "Maximum number of results" + ), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup. Use this when `q` is an exact address." + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + with_relationships_param() + ], responses: %{ 200 => Operation.response( @@ -66,42 +66,40 @@ def search_operation do security: [%{"oAuth" => ["read:search"]}], operationId: "SearchController.search", deprecated: true, - parameters: - [ - Operation.parameter( - :account_id, - :query, - FlakeID, - "If provided, statuses returned will be authored only by this account" - ), - Operation.parameter( - :type, - :query, - %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, - "Search type" - ), - Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", - required: true - ), - Operation.parameter( - :resolve, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Attempt WebFinger lookup" - ), - Operation.parameter( - :following, - :query, - %Schema{allOf: [BooleanLike], default: false}, - "Only include accounts that the user is following" - ), - Operation.parameter( - :offset, - :query, - %Schema{type: :integer}, - "Offset" - ) - ] ++ pagination_params() ++ [embed_relationships_param()], + parameters: [ + Operation.parameter( + :account_id, + :query, + FlakeID, + "If provided, statuses returned will be authored only by this account" + ), + Operation.parameter( + :type, + :query, + %Schema{type: :string, enum: ["accounts", "hashtags", "statuses"]}, + "Search type" + ), + Operation.parameter(:q, :query, %Schema{type: :string}, "The search query", required: true), + Operation.parameter( + :resolve, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Attempt WebFinger lookup" + ), + Operation.parameter( + :following, + :query, + %Schema{allOf: [BooleanLike], default: false}, + "Only include accounts that the user is following" + ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer}, + "Offset" + ), + with_relationships_param() | pagination_params() + ], responses: %{ 200 => Operation.response("Results", "application/json", results()) } diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 1b89035d4..6cbc7f747 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -153,10 +153,6 @@ defp array_of_statuses do } end - defp with_relationships_param do - Operation.parameter(:with_relationships, :query, BooleanLike, "Include relationships") - end - defp local_param do Operation.parameter( :local, diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index ae9b265b1..ff94c6be0 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -104,8 +104,8 @@ def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) - def embed_relationships?(params) do - # To do: change to `truthy_param?(params["embed_relationships"])` once PleromaFE supports it - not explicitly_falsy_param?(params["embed_relationships"]) + def with_relationships?(params) do + # To do: change to `truthy_param?(params["with_relationships"])` once PleromaFE supports it + not explicitly_falsy_param?(params["with_relationships"]) end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index ef41f9e96..2dd0252cc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, - embed_relationships?: 1, + with_relationships?: 1, json_response: 3 ] @@ -275,7 +275,7 @@ def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do for: for_user, users: followers, as: :user, - embed_relationships: embed_relationships?(params) + embed_relationships: with_relationships?(params) ) end @@ -300,7 +300,7 @@ def following(%{assigns: %{user: for_user, account: user}} = conn, params) do for: for_user, users: followers, as: :user, - embed_relationships: embed_relationships?(params) + embed_relationships: with_relationships?(params) ) end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 632c4590f..1c2860cc7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -38,7 +38,7 @@ def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do users: accounts, for: user, as: :user, - embed_relationships: ControllerHelper.embed_relationships?(params) + embed_relationships: ControllerHelper.with_relationships?(params) ) end @@ -82,7 +82,7 @@ defp search_options(params, user) do offset: params[:offset], type: params[:type], author: get_author(params), - embed_relationships: ControllerHelper.embed_relationships?(params), + embed_relationships: ControllerHelper.with_relationships?(params), for_user: user ] |> Enum.filter(&elem(&1, 1)) -- cgit v1.2.3 From 0f0acc740d30c47d093f27875d4decf0693b2845 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 13 May 2020 15:31:28 +0200 Subject: Chat: Allow posting without content if an attachment is present. --- docs/API/chats.md | 5 ++-- .../object_validators/chat_message_validator.ex | 14 +++++++++- .../web/api_spec/operations/chat_operation.ex | 12 +++++--- lib/pleroma/web/api_spec/schemas/chat_message.ex | 2 +- lib/pleroma/web/common_api/common_api.ex | 17 +++++++++--- .../web/pleroma_api/controllers/chat_controller.ex | 7 +++-- test/web/activity_pub/object_validator_test.exs | 32 ++++++++++++++++++++++ test/web/common_api/common_api_test.exs | 23 ++++++++++++++++ .../controllers/chat_controller_test.exs | 18 ++++++++++-- 9 files changed, 111 insertions(+), 19 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index ad36961ae..1ea18ff5f 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -166,11 +166,10 @@ Posting a chat message for given Chat id works like this: `POST /api/v1/pleroma/chats/{id}/messages` Parameters: -- content: The text content of the message +- content: The text content of the message. Optional if media is attached. - media_id: The id of an upload that will be attached to the message. -Currently, no formatting beyond basic escaping and emoji is implemented, as well as no -attachments. This will most probably change. +Currently, no formatting beyond basic escaping and emoji is implemented. Returned data: diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index e40c80ab4..9c20c188a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -61,12 +61,24 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) - |> validate_required([:id, :actor, :to, :type, :content, :published]) + |> validate_required([:id, :actor, :to, :type, :published]) + |> validate_content_or_attachment() |> validate_length(:to, is: 1) |> validate_length(:content, max: Pleroma.Config.get([:instance, :remote_limit])) |> validate_local_concern() end + def validate_content_or_attachment(cng) do + attachment = get_field(cng, :attachment) + + if attachment do + cng + else + cng + |> validate_required([:content]) + end + end + @doc """ Validates the following - If both users are in our system diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 8ba10c603..a1c5db5dc 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.Chat alias Pleroma.Web.ApiSpec.Schemas.ChatMessage @@ -149,14 +150,15 @@ def post_chat_message_operation do parameters: [ Operation.parameter(:id, :path, :string, "The ID of the Chat") ], - requestBody: request_body("Parameters", chat_message_create(), required: true), + requestBody: request_body("Parameters", chat_message_create()), responses: %{ 200 => Operation.response( "The newly created ChatMessage", "application/json", ChatMessage - ) + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) }, security: [ %{ @@ -292,10 +294,12 @@ def chat_message_create do description: "POST body for creating an chat message", type: :object, properties: %{ - content: %Schema{type: :string, description: "The content of your message"}, + content: %Schema{ + type: :string, + description: "The content of your message. Optional if media_id is present" + }, media_id: %Schema{type: :string, description: "The id of an upload"} }, - required: [:content], example: %{ "content" => "Hey wanna buy feet pics?", "media_id" => "134234" diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 6e8f1a10a..3ee85aa76 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do id: %Schema{type: :string}, account_id: %Schema{type: :string, description: "The Mastodon API id of the actor"}, chat_id: %Schema{type: :string}, - content: %Schema{type: :string}, + content: %Schema{type: :string, nullable: true}, created_at: %Schema{type: :string, format: :"date-time"}, emojis: %Schema{type: :array}, attachment: %Schema{type: :object, nullable: true} diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 664175a4f..7008cea44 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -26,14 +26,14 @@ defmodule Pleroma.Web.CommonAPI do require Logger def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do - with :ok <- validate_chat_content_length(content), - maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), + with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), + :ok <- validate_chat_content_length(content, !!maybe_attachment), {_, {:ok, chat_message_data, _meta}} <- {:build_object, Builder.chat_message( user, recipient.ap_id, - content |> Formatter.html_escape("text/plain"), + content |> format_chat_content, attachment: maybe_attachment )}, {_, {:ok, create_activity_data, _meta}} <- @@ -47,7 +47,16 @@ def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) end end - defp validate_chat_content_length(content) do + defp format_chat_content(nil), do: nil + + defp format_chat_content(content) do + content |> Formatter.html_escape("text/plain") + end + + defp validate_chat_content_length(_, true), do: :ok + defp validate_chat_content_length(nil, false), do: {:error, :no_content} + + defp validate_chat_content_length(content, _) do if String.length(content) <= Pleroma.Config.get([:instance, :chat_limit]) do :ok else diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 496cb8e87..210c8ec4a 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -58,8 +58,7 @@ def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ end def post_chat_message( - %{body_params: %{content: content} = params, assigns: %{user: %{id: user_id} = user}} = - conn, + %{body_params: params, assigns: %{user: %{id: user_id} = user}} = conn, %{ id: id } @@ -67,7 +66,9 @@ def post_chat_message( with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), %User{} = recipient <- User.get_cached_by_ap_id(chat.recipient), {:ok, activity} <- - CommonAPI.post_chat_message(user, recipient, content, media_id: params[:media_id]), + CommonAPI.post_chat_message(user, recipient, params[:content], + media_id: params[:media_id] + ), message <- Object.normalize(activity) do conn |> put_view(ChatMessageView) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index d9f5a8fac..da33d3dbc 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -103,6 +103,38 @@ test "validates for a basic object with an attachment", %{ assert object["attachment"] end + test "validates for a basic object with an attachment but without content", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", attachment.data) + |> Map.delete("content") + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + + test "does not validate if the message has no content", %{ + valid_chat_message: valid_chat_message + } do + contentless = + valid_chat_message + |> Map.delete("content") + + refute match?({:ok, _object, _meta}, ObjectValidator.validate(contentless, [])) + end + test "does not validate if the message is longer than the remote_limit", %{ valid_chat_message: valid_chat_message } do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index fd2c486a1..46ffd2888 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -27,6 +27,29 @@ defmodule Pleroma.Web.CommonAPITest do describe "posting chat messages" do setup do: clear_config([:instance, :chat_limit]) + test "it posts a chat message without content but with an attachment" do + author = insert(:user) + recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: author.ap_id) + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + nil, + media_id: upload.id + ) + + assert activity + end + test "it posts a chat message" do author = insert(:user) recipient = insert(:user) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 037dd2297..d79aa3148 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -53,6 +53,20 @@ test "it posts a message to the chat", %{conn: conn, user: user} do assert result["chat_id"] == chat.id |> to_string() end + test "it fails if there is no content", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/chats/#{chat.id}/messages") + |> json_response_and_validate_schema(400) + + assert result + end + test "it works with an attachment", %{conn: conn, user: user} do file = %Plug.Upload{ content_type: "image/jpg", @@ -70,13 +84,11 @@ test "it works with an attachment", %{conn: conn, user: user} do conn |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/chats/#{chat.id}/messages", %{ - "content" => "Hallo!!", "media_id" => to_string(upload.id) }) |> json_response_and_validate_schema(200) - assert result["content"] == "Hallo!!" - assert result["chat_id"] == chat.id |> to_string() + assert result["attachment"] end end -- cgit v1.2.3 From 5f5681282e0661aad68318569d7b57d32bc55b57 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 11 May 2020 23:08:20 +0000 Subject: Translated using Weblate (French) Currently translated at 100.0% (90 of 90 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/fr/ --- priv/gettext/fr/LC_MESSAGES/errors.po | 164 ++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 78 deletions(-) diff --git a/priv/gettext/fr/LC_MESSAGES/errors.po b/priv/gettext/fr/LC_MESSAGES/errors.po index 678b32289..406f98de9 100644 --- a/priv/gettext/fr/LC_MESSAGES/errors.po +++ b/priv/gettext/fr/LC_MESSAGES/errors.po @@ -8,8 +8,16 @@ ## to merge POT files into PO files. msgid "" msgstr "" +"PO-Revision-Date: 2020-05-12 15:52+0000\n" +"Last-Translator: Haelwenn (lanodan) Monnier " +"\n" +"Language-Team: French \n" "Language: fr\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.0.4\n" +"Content-Transfer-Encoding: 8bit\n" msgid "can't be blank" msgstr "ne peut être vide" @@ -35,10 +43,10 @@ msgid "does not match confirmation" msgstr "ne correspondent pas" msgid "is still associated with this entry" -msgstr "" +msgstr "est toujours associé à cette entrée" msgid "are still associated with this entry" -msgstr "" +msgstr "sont toujours associés à cette entrée" msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" @@ -85,375 +93,375 @@ msgstr "doit être supérieur ou égal à %{number}" msgid "must be equal to %{number}" msgstr "doit égal à %{number}" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:381 +#, elixir-format msgid "Account not found" msgstr "Compte non trouvé" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:153 +#, elixir-format msgid "Already voted" msgstr "A déjà voté" -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:263 +#, elixir-format msgid "Bad request" msgstr "Requête Invalide" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:254 +#, elixir-format msgid "Can't delete object" msgstr "Ne peut supprimer cet objet" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:569 +#, elixir-format msgid "Can't delete this post" msgstr "Ne peut supprimer ce message" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1731 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1737 +#, elixir-format msgid "Can't display this activity" msgstr "Ne peut afficher cette activitée" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:195 +#, elixir-format msgid "Can't find user" msgstr "Compte non trouvé" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1148 +#, elixir-format msgid "Can't get favorites" msgstr "Favoris non trouvables" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:263 +#, elixir-format msgid "Can't like object" msgstr "Ne peut aimer cet objet" -#, elixir-format #: lib/pleroma/web/common_api/utils.ex:518 +#, elixir-format msgid "Cannot post an empty status without attachments" msgstr "Ne peut envoyer un status vide sans attachements" -#, elixir-format #: lib/pleroma/web/common_api/utils.ex:461 +#, elixir-format msgid "Comment must be up to %{max_size} characters" msgstr "Le commentaire ne doit faire plus de %{max_size} charactères" -#, elixir-format #: lib/pleroma/web/admin_api/config.ex:63 +#, elixir-format msgid "Config with params %{params} not found" msgstr "Configuration avec les paramètres %{params} non trouvée" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:78 +#, elixir-format msgid "Could not delete" msgstr "Échec de la suppression" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:110 +#, elixir-format msgid "Could not favorite" msgstr "Échec de mise en favoris" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:310 +#, elixir-format msgid "Could not pin" msgstr "Échec de l'épinglage" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:89 +#, elixir-format msgid "Could not repeat" msgstr "Échec de création la répétition" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:120 +#, elixir-format msgid "Could not unfavorite" msgstr "Échec de suppression des favoris" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:327 +#, elixir-format msgid "Could not unpin" msgstr "Échec du dépinglage" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:99 +#, elixir-format msgid "Could not unrepeat" msgstr "Échec de suppression de la répétition" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:392 +#, elixir-format msgid "Could not update state" msgstr "Échec de la mise à jour du status" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1271 +#, elixir-format msgid "Error." msgstr "Erreur." -#, elixir-format #: lib/pleroma/captcha/kocaptcha.ex:36 +#, elixir-format msgid "Invalid CAPTCHA" msgstr "CAPTCHA invalide" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1700 #: lib/pleroma/web/oauth/oauth_controller.ex:465 +#, elixir-format msgid "Invalid credentials" msgstr "Paramètres d'authentification invalides" -#, elixir-format #: lib/pleroma/plugs/ensure_authenticated_plug.ex:20 +#, elixir-format msgid "Invalid credentials." msgstr "Paramètres d'authentification invalides." -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:154 +#, elixir-format msgid "Invalid indices" msgstr "Indices invalides" -#, elixir-format #: lib/pleroma/web/admin_api/admin_api_controller.ex:411 +#, elixir-format msgid "Invalid parameters" msgstr "Paramètres invalides" -#, elixir-format #: lib/pleroma/web/common_api/utils.ex:377 +#, elixir-format msgid "Invalid password." msgstr "Mot de passe invalide." -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:163 +#, elixir-format msgid "Invalid request" msgstr "Requête invalide" -#, elixir-format #: lib/pleroma/captcha/kocaptcha.ex:16 +#, elixir-format msgid "Kocaptcha service unavailable" msgstr "Service Kocaptcha non disponible" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1696 +#, elixir-format msgid "Missing parameters" msgstr "Paramètres manquants" -#, elixir-format #: lib/pleroma/web/common_api/utils.ex:496 +#, elixir-format msgid "No such conversation" msgstr "Conversation inconnue" -#, elixir-format #: lib/pleroma/web/admin_api/admin_api_controller.ex:163 #: lib/pleroma/web/admin_api/admin_api_controller.ex:206 +#, elixir-format msgid "No such permission_group" msgstr "Groupe de permission inconnu" -#, elixir-format #: lib/pleroma/plugs/uploaded_media.ex:69 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:311 #: lib/pleroma/web/admin_api/admin_api_controller.ex:399 #: lib/pleroma/web/mastodon_api/subscription_controller.ex:63 #: lib/pleroma/web/ostatus/ostatus_controller.ex:248 +#, elixir-format msgid "Not found" msgstr "Non Trouvé" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:152 +#, elixir-format msgid "Poll's author can't vote" msgstr "L'auteur·rice d'un sondage ne peut voter" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:443 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:444 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:473 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:476 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1180 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1564 +#, elixir-format msgid "Record not found" msgstr "Enregistrement non trouvé" -#, elixir-format #: lib/pleroma/web/admin_api/admin_api_controller.ex:417 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1570 #: lib/pleroma/web/mastodon_api/subscription_controller.ex:69 #: lib/pleroma/web/ostatus/ostatus_controller.ex:252 +#, elixir-format msgid "Something went wrong" msgstr "Erreur inconnue" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:253 +#, elixir-format msgid "The message visibility must be direct" msgstr "La visibilitée du message doit être « direct »" -#, elixir-format #: lib/pleroma/web/common_api/utils.ex:521 +#, elixir-format msgid "The status is over the character limit" msgstr "Le status est au-delà de la limite de charactères" -#, elixir-format #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:27 +#, elixir-format msgid "This resource requires authentication." msgstr "Cette resource nécessite une authentification." -#, elixir-format #: lib/pleroma/plugs/rate_limiter.ex:89 +#, elixir-format msgid "Throttled" msgstr "Limité" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:155 +#, elixir-format msgid "Too many choices" msgstr "Trop de choix" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:268 +#, elixir-format msgid "Unhandled activity type" msgstr "Type d'activitée non-gérée" -#, elixir-format #: lib/pleroma/plugs/user_is_admin_plug.ex:20 +#, elixir-format msgid "User is not admin." msgstr "Le compte n'est pas admin." -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:380 +#, elixir-format msgid "Valid `account_id` required" msgstr "Un `account_id` valide est requis" -#, elixir-format #: lib/pleroma/web/admin_api/admin_api_controller.ex:185 +#, elixir-format msgid "You can't revoke your own admin status." msgstr "Vous ne pouvez révoquer votre propre status d'admin." -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:216 +#, elixir-format msgid "Your account is currently disabled" msgstr "Votre compte est actuellement désactivé" -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:158 #: lib/pleroma/web/oauth/oauth_controller.ex:213 +#, elixir-format msgid "Your login is missing a confirmed e-mail address" msgstr "Une confirmation de l'addresse de couriel est requise pour l'authentification" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:221 +#, elixir-format msgid "can't read inbox of %{nickname} as %{as_nickname}" msgstr "Ne peut lire la boite de réception de %{nickname} en tant que %{as_nickname}" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:297 +#, elixir-format msgid "can't update outbox of %{nickname} as %{as_nickname}" msgstr "Ne peut poster dans la boite d'émission de %{nickname} en tant que %{as_nickname}" -#, elixir-format #: lib/pleroma/web/common_api/common_api.ex:335 +#, elixir-format msgid "conversation is already muted" msgstr "la conversation est déjà baillonée" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:192 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:317 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1196 #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:1247 +#, elixir-format msgid "error" msgstr "erreur" -#, elixir-format #: lib/pleroma/web/mastodon_api/mastodon_api_controller.ex:789 +#, elixir-format msgid "mascots can only be images" msgstr "les mascottes ne peuvent être que des images" -#, elixir-format #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:34 +#, elixir-format msgid "not found" msgstr "non trouvé" -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:298 +#, elixir-format msgid "Bad OAuth request." msgstr "Requête OAuth invalide." -#, elixir-format #: lib/pleroma/captcha/captcha.ex:92 +#, elixir-format msgid "CAPTCHA already used" msgstr "CAPTCHA déjà utilisé" -#, elixir-format #: lib/pleroma/captcha/captcha.ex:89 +#, elixir-format msgid "CAPTCHA expired" msgstr "CAPTCHA expiré" -#, elixir-format #: lib/pleroma/plugs/uploaded_media.ex:50 +#, elixir-format msgid "Failed" msgstr "Échec" -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:314 +#, elixir-format msgid "Failed to authenticate: %{message}." -msgstr "Échec de l'authentification: %{message}" +msgstr "Échec de l'authentification : %{message}." -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:345 +#, elixir-format msgid "Failed to set up user account." msgstr "Échec de création de votre compte." -#, elixir-format #: lib/pleroma/plugs/oauth_scopes_plug.ex:37 +#, elixir-format msgid "Insufficient permissions: %{permissions}." -msgstr "Permissions insuffisantes: %{permissions}." +msgstr "Permissions insuffisantes : %{permissions}." -#, elixir-format #: lib/pleroma/plugs/uploaded_media.ex:89 +#, elixir-format msgid "Internal Error" msgstr "Erreur interne" -#, elixir-format #: lib/pleroma/web/oauth/fallback_controller.ex:22 #: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format msgid "Invalid Username/Password" msgstr "Nom d'utilisateur/mot de passe invalide" -#, elixir-format #: lib/pleroma/captcha/captcha.ex:107 +#, elixir-format msgid "Invalid answer data" msgstr "Réponse invalide" -#, elixir-format #: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:204 +#, elixir-format msgid "Nodeinfo schema version not handled" msgstr "Version du schéma nodeinfo non géré" -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:145 +#, elixir-format msgid "This action is outside the authorized scopes" -msgstr "Cette action est en dehors des authorisations" # "scopes" ? +msgstr "Cette action est en dehors des authorisations" # "scopes" -#, elixir-format #: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format msgid "Unknown error, please check the details and try again." msgstr "Erreur inconnue, veuillez vérifier les détails et réessayer." -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:93 #: lib/pleroma/web/oauth/oauth_controller.ex:131 +#, elixir-format msgid "Unlisted redirect_uri." msgstr "redirect_uri non listé." -#, elixir-format #: lib/pleroma/web/oauth/oauth_controller.ex:294 +#, elixir-format msgid "Unsupported OAuth provider: %{provider}." msgstr "Fournisseur OAuth non supporté : %{provider}." -#, elixir-format #: lib/pleroma/uploaders/uploader.ex:71 +#, elixir-format msgid "Uploader callback timeout" -msgstr "" -## msgstr "Attente écoulée" +msgstr "Temps d'attente du téléverseur écoulé" -#, elixir-format +## msgstr "Attente écoulée" #: lib/pleroma/web/uploader_controller.ex:11 #: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format msgid "bad request" msgstr "requête invalide" -- cgit v1.2.3 From 6802dc28ba10aa8120680c2c9610649316907f55 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 13 May 2020 19:06:25 +0400 Subject: Add OpenAPI spec for PleromaAPI.AccountController --- lib/pleroma/upload.ex | 2 +- lib/pleroma/web/api_spec/helpers.ex | 4 + .../operations/pleroma_account_operation.ex | 185 +++++++++++++++++++++ .../web/api_spec/operations/status_operation.ex | 2 +- .../pleroma_api/controllers/account_controller.ex | 26 ++- test/web/activity_pub/activity_pub_test.exs | 2 +- .../controllers/account_controller_test.exs | 107 ++++++++---- 7 files changed, 284 insertions(+), 44 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 762d813d9..1be1a3a5b 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -134,7 +134,7 @@ defp prepare_upload(%Plug.Upload{} = file, opts) do end end - defp prepare_upload(%{"img" => "data:image/" <> image_data}, opts) do + defp prepare_upload(%{img: "data:image/" <> image_data}, opts) do parsed = Regex.named_captures(~r/(?jpeg|png|gif);base64,(?.*)/, image_data) data = Base.decode64!(parsed["data"], ignore: :whitespace) hash = String.downcase(Base.encode16(:crypto.hash(:sha256, data))) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index 183df43ee..f0b558aa5 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -54,4 +54,8 @@ def empty_object_response do def empty_array_response do Operation.response("Empty array", "application/json", %Schema{type: :array, example: []}) end + + def no_content_response do + Operation.response("No Content", "application/json", %Schema{type: :string, example: ""}) + end end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex new file mode 100644 index 000000000..af231b4a8 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -0,0 +1,185 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def confirmation_resend_operation do + %Operation{ + tags: ["Accounts"], + summary: "Resend confirmation email. Expects `email` or `nic`", + operationId: "PleromaAPI.AccountController.confirmation_resend", + parameters: [ + Operation.parameter(:email, :query, :string, "Email of that needs to be verified", + example: "cofe@cofe.io" + ), + Operation.parameter( + :nickname, + :query, + :string, + "Nickname of user that needs to be verified", + example: "cofefe" + ) + ], + responses: %{ + 204 => no_content_response() + } + } + end + + def update_avatar_operation do + %Operation{ + tags: ["Accounts"], + summary: "Set/clear user avatar image", + operationId: "PleromaAPI.AccountController.update_avatar", + requestBody: + request_body("Parameters", update_avatar_or_background_request(), required: true), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => update_response(), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def update_banner_operation do + %Operation{ + tags: ["Accounts"], + summary: "Set/clear user banner image", + operationId: "PleromaAPI.AccountController.update_banner", + requestBody: request_body("Parameters", update_banner_request(), required: true), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => update_response() + } + } + end + + def update_background_operation do + %Operation{ + tags: ["Accounts"], + summary: "Set/clear user background image", + operationId: "PleromaAPI.AccountController.update_background", + security: [%{"oAuth" => ["write:accounts"]}], + requestBody: + request_body("Parameters", update_avatar_or_background_request(), required: true), + responses: %{ + 200 => update_response() + } + } + end + + def favourites_operation do + %Operation{ + tags: ["Accounts"], + summary: "Returns favorites timeline of any user", + operationId: "PleromaAPI.AccountController.favourites", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:favourites"]}], + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def subscribe_operation do + %Operation{ + tags: ["Accounts"], + summary: "Subscribe to receive notifications for all statuses posted by a user", + operationId: "PleromaAPI.AccountController.subscribe", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unsubscribe_operation do + %Operation{ + tags: ["Accounts"], + summary: "Unsubscribe to stop receiving notifications from user statuses¶", + operationId: "PleromaAPI.AccountController.unsubscribe", + parameters: [id_param()], + security: [%{"oAuth" => ["follow", "write:follows"]}], + responses: %{ + 200 => Operation.response("Relationship", "application/json", AccountRelationship), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Account ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp update_avatar_or_background_request do + %Schema{ + title: "PleromaAccountUpdateAvatarOrBackgroundRequest", + type: :object, + properties: %{ + img: %Schema{ + type: :string, + format: :binary, + description: "Image encoded using `multipart/form-data` or an empty string to clear" + } + } + } + end + + defp update_banner_request do + %Schema{ + title: "PleromaAccountUpdateBannerRequest", + type: :object, + properties: %{ + banner: %Schema{ + type: :string, + format: :binary, + description: "Image encoded using `multipart/form-data` or an empty string to clear" + } + } + } + end + + defp update_response do + Operation.response("PleromaAccountUpdateResponse", "application/json", %Schema{ + type: :object, + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + nullable: true, + description: "Image URL" + } + }, + example: %{ + "url" => + "https://cofe.party/media/9d0add56-bcb6-4c0f-8225-cbbd0b6dd773/13eadb6972c9ccd3f4ffa3b8196f0e0d38b4d2f27594457c52e52946c054cd9a.gif" + } + }) + end +end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index a6bb87560..561db3bce 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -360,7 +360,7 @@ def bookmarks_operation do } end - defp array_of_statuses do + def array_of_statuses do %Schema{type: :array, items: Status, example: [Status.schema().example]} end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index be7477867..07078d415 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -18,6 +18,13 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do require Pleroma.Constants + plug( + OpenApiSpex.Plug.PutApiSpec, + [module: Pleroma.Web.ApiSpec] when action == :confirmation_resend + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( :skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirmation_resend @@ -49,9 +56,11 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaAccountOperation + @doc "POST /api/v1/pleroma/accounts/confirmation_resend" def confirmation_resend(conn, params) do - nickname_or_email = params["email"] || params["nickname"] + nickname_or_email = params[:email] || params[:nickname] with %User{} = user <- User.get_by_nickname_or_email(nickname_or_email), {:ok, _} <- User.try_send_confirmation_email(user) do @@ -60,7 +69,7 @@ def confirmation_resend(conn, params) do end @doc "PATCH /api/v1/pleroma/accounts/update_avatar" - def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + def update_avatar(%{assigns: %{user: user}, body_params: %{img: ""}} = conn, _) do {:ok, _user} = user |> Changeset.change(%{avatar: nil}) @@ -69,7 +78,7 @@ def update_avatar(%{assigns: %{user: user}} = conn, %{"img" => ""}) do json(conn, %{url: nil}) end - def update_avatar(%{assigns: %{user: user}} = conn, params) do + def update_avatar(%{assigns: %{user: user}, body_params: params} = conn, _params) do {:ok, %{data: data}} = ActivityPub.upload(params, type: :avatar) {:ok, _user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() %{"url" => [%{"href" => href} | _]} = data @@ -78,14 +87,14 @@ def update_avatar(%{assigns: %{user: user}} = conn, params) do end @doc "PATCH /api/v1/pleroma/accounts/update_banner" - def update_banner(%{assigns: %{user: user}} = conn, %{"banner" => ""}) do + def update_banner(%{assigns: %{user: user}, body_params: %{banner: ""}} = conn, _) do with {:ok, _user} <- User.update_banner(user, %{}) do json(conn, %{url: nil}) end end - def update_banner(%{assigns: %{user: user}} = conn, params) do - with {:ok, object} <- ActivityPub.upload(%{"img" => params["banner"]}, type: :banner), + def update_banner(%{assigns: %{user: user}, body_params: params} = conn, _) do + with {:ok, object} <- ActivityPub.upload(%{img: params[:banner]}, type: :banner), {:ok, _user} <- User.update_banner(user, object.data) do %{"url" => [%{"href" => href} | _]} = object.data @@ -94,13 +103,13 @@ def update_banner(%{assigns: %{user: user}} = conn, params) do end @doc "PATCH /api/v1/pleroma/accounts/update_background" - def update_background(%{assigns: %{user: user}} = conn, %{"img" => ""}) do + def update_background(%{assigns: %{user: user}, body_params: %{img: ""}} = conn, _) do with {:ok, _user} <- User.update_background(user, %{}) do json(conn, %{url: nil}) end end - def update_background(%{assigns: %{user: user}} = conn, params) do + def update_background(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, object} <- ActivityPub.upload(params, type: :background), {:ok, _user} <- User.update_background(user, object.data) do %{"url" => [%{"href" => href} | _]} = object.data @@ -117,6 +126,7 @@ def favourites(%{assigns: %{account: %{hide_favorites: true}}} = conn, _params) def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Create") |> Map.put("favorited_by", user.ap_id) |> Map.put("blocking_user", for_user) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 56fde97e7..77bd07edf 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -951,7 +951,7 @@ test "copies the file to the configured folder" do test "works with base64 encoded images" do file = %{ - "img" => data_uri() + img: data_uri() } {:ok, %Object{}} = ActivityPub.upload(file) diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index 34fc4aa23..103997c31 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -31,8 +31,28 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do test "resend account confirmation email", %{conn: conn, user: user} do conn + |> put_req_header("content-type", "application/json") |> post("/api/v1/pleroma/accounts/confirmation_resend?email=#{user.email}") - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) + + ObanHelpers.perform_all() + + email = Pleroma.Emails.UserEmail.account_confirmation_email(user) + notify_email = Config.get([:instance, :notify_email]) + instance_name = Config.get([:instance, :name]) + + assert_email_sent( + from: {instance_name, notify_email}, + to: {user.name, user.email}, + html_body: email.html_body + ) + end + + test "resend account confirmation email (with nickname)", %{conn: conn, user: user} do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/accounts/confirmation_resend?nickname=#{user.nickname}") + |> json_response_and_validate_schema(:no_content) ObanHelpers.perform_all() @@ -54,7 +74,10 @@ test "resend account confirmation email", %{conn: conn, user: user} do test "user avatar can be set", %{user: user, conn: conn} do avatar_image = File.read!("test/fixtures/avatar_data_uri") - conn = patch(conn, "/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image}) user = refresh_record(user) @@ -70,17 +93,20 @@ test "user avatar can be set", %{user: user, conn: conn} do ] } = user.avatar - assert %{"url" => _} = json_response(conn, 200) + assert %{"url" => _} = json_response_and_validate_schema(conn, 200) end test "user avatar can be reset", %{user: user, conn: conn} do - conn = patch(conn, "/api/v1/pleroma/accounts/update_avatar", %{img: ""}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: ""}) user = User.get_cached_by_id(user.id) assert user.avatar == nil - assert %{"url" => nil} = json_response(conn, 200) + assert %{"url" => nil} = json_response_and_validate_schema(conn, 200) end end @@ -88,21 +114,27 @@ test "user avatar can be reset", %{user: user, conn: conn} do setup do: oauth_access(["write:accounts"]) test "can set profile banner", %{user: user, conn: conn} do - conn = patch(conn, "/api/v1/pleroma/accounts/update_banner", %{"banner" => @image}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => @image}) user = refresh_record(user) assert user.banner["type"] == "Image" - assert %{"url" => _} = json_response(conn, 200) + assert %{"url" => _} = json_response_and_validate_schema(conn, 200) end test "can reset profile banner", %{user: user, conn: conn} do - conn = patch(conn, "/api/v1/pleroma/accounts/update_banner", %{"banner" => ""}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => ""}) user = refresh_record(user) assert user.banner == %{} - assert %{"url" => nil} = json_response(conn, 200) + assert %{"url" => nil} = json_response_and_validate_schema(conn, 200) end end @@ -110,19 +142,26 @@ test "can reset profile banner", %{user: user, conn: conn} do setup do: oauth_access(["write:accounts"]) test "background image can be set", %{user: user, conn: conn} do - conn = patch(conn, "/api/v1/pleroma/accounts/update_background", %{"img" => @image}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => @image}) user = refresh_record(user) assert user.background["type"] == "Image" - assert %{"url" => _} = json_response(conn, 200) + # assert %{"url" => _} = json_response(conn, 200) + assert %{"url" => _} = json_response_and_validate_schema(conn, 200) end test "background image can be reset", %{user: user, conn: conn} do - conn = patch(conn, "/api/v1/pleroma/accounts/update_background", %{"img" => ""}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => ""}) user = refresh_record(user) assert user.background == %{} - assert %{"url" => nil} = json_response(conn, 200) + assert %{"url" => nil} = json_response_and_validate_schema(conn, 200) end end @@ -143,7 +182,7 @@ test "returns list of statuses favorited by specified user", %{ response = conn |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [like] = response @@ -160,7 +199,7 @@ test "returns favorites for specified user_id when requester is not logged in", response = build_conn() |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(response) == 1 end @@ -183,7 +222,7 @@ test "returns favorited DM only when user is logged in and he is one of recipien |> assign(:user, u) |> assign(:token, insert(:oauth_token, user: u, scopes: ["read:favourites"])) |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert length(response) == 1 end @@ -191,7 +230,7 @@ test "returns favorited DM only when user is logged in and he is one of recipien response = build_conn() |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(response) == 0 end @@ -213,7 +252,7 @@ test "does not return others' favorited DM when user is not one of recipients", response = conn |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -233,11 +272,12 @@ test "paginates favorites using since_id and max_id", %{ response = conn - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{ - since_id: third_activity.id, - max_id: seventh_activity.id - }) - |> json_response(:ok) + |> get( + "/api/v1/pleroma/accounts/#{user.id}/favourites?since_id=#{third_activity.id}&max_id=#{ + seventh_activity.id + }" + ) + |> json_response_and_validate_schema(:ok) assert length(response) == 3 refute third_activity in response @@ -256,8 +296,8 @@ test "limits favorites using limit parameter", %{ response = conn - |> get("/api/v1/pleroma/accounts/#{user.id}/favourites", %{limit: "3"}) - |> json_response(:ok) + |> get("/api/v1/pleroma/accounts/#{user.id}/favourites?limit=3") + |> json_response_and_validate_schema(:ok) assert length(response) == 3 end @@ -269,7 +309,7 @@ test "returns empty response when user does not have any favorited statuses", %{ response = conn |> get("/api/v1/pleroma/accounts/#{user.id}/favourites") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response) end @@ -277,7 +317,7 @@ test "returns empty response when user does not have any favorited statuses", %{ test "returns 404 error when specified user is not exist", %{conn: conn} do conn = get(conn, "/api/v1/pleroma/accounts/test/favourites") - assert json_response(conn, 404) == %{"error" => "Record not found"} + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} end test "returns 403 error when user has hidden own favorites", %{conn: conn} do @@ -287,7 +327,7 @@ test "returns 403 error when user has hidden own favorites", %{conn: conn} do conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/favourites") - assert json_response(conn, 403) == %{"error" => "Can't get favorites"} + assert json_response_and_validate_schema(conn, 403) == %{"error" => "Can't get favorites"} end test "hides favorites for new users by default", %{conn: conn} do @@ -298,7 +338,7 @@ test "hides favorites for new users by default", %{conn: conn} do assert user.hide_favorites conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/favourites") - assert json_response(conn, 403) == %{"error" => "Can't get favorites"} + assert json_response_and_validate_schema(conn, 403) == %{"error" => "Can't get favorites"} end end @@ -312,11 +352,12 @@ test "subscribing / unsubscribing to a user" do |> assign(:user, user) |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe") - assert %{"id" => _id, "subscribing" => true} = json_response(ret_conn, 200) + assert %{"id" => _id, "subscribing" => true} = + json_response_and_validate_schema(ret_conn, 200) conn = post(conn, "/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe") - assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200) + assert %{"id" => _id, "subscribing" => false} = json_response_and_validate_schema(conn, 200) end end @@ -326,7 +367,7 @@ test "returns 404 when subscription_target not found" do conn = post(conn, "/api/v1/pleroma/accounts/target_id/subscribe") - assert %{"error" => "Record not found"} = json_response(conn, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn, 404) end end @@ -336,7 +377,7 @@ test "returns 404 when subscription_target not found" do conn = post(conn, "/api/v1/pleroma/accounts/target_id/unsubscribe") - assert %{"error" => "Record not found"} = json_response(conn, 404) + assert %{"error" => "Record not found"} = json_response_and_validate_schema(conn, 404) end end end -- cgit v1.2.3 From 9cbf17d59fe34a760f8a4f94bc60f78b38ccba06 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 May 2020 16:57:01 -0500 Subject: Handle bcrypt passwords for Mastodon migration --- lib/pleroma/plugs/authentication_plug.ex | 5 +++++ mix.exs | 1 + mix.lock | 2 ++ test/plugs/authentication_plug_test.exs | 7 +++++++ 4 files changed, 15 insertions(+) diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index ae4a235bd..1994b807e 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -16,6 +16,11 @@ def checkpw(password, "$6" <> _ = password_hash) do :crypt.crypt(password, password_hash) == password_hash end + def checkpw(password, "$2" <> _ = password_hash) do + # Handle bcrypt passwords for Mastodon migration + Bcrypt.verify_pass(password, password_hash) + end + def checkpw(password, "$pbkdf2" <> _ = password_hash) do Pbkdf2.verify_pass(password, password_hash) end diff --git a/mix.exs b/mix.exs index 0186d291f..b8e663a03 100644 --- a/mix.exs +++ b/mix.exs @@ -127,6 +127,7 @@ defp deps do {:oban, "~> 1.2"}, {:gettext, "~> 0.15"}, {:pbkdf2_elixir, "~> 1.0"}, + {:bcrypt_elixir, "~> 2.0"}, {:trailing_format_plug, "~> 0.0.7"}, {:fast_sanitize, "~> 0.1"}, {:html_entities, "~> 0.5", override: true}, diff --git a/mix.lock b/mix.lock index 62fe35146..955b2bb37 100644 --- a/mix.lock +++ b/mix.lock @@ -5,6 +5,7 @@ "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.2.0", "3df902b81ce7fa8867a2ae30d20a1da6877a2c056bfb116fd0bc8a5f0190cea4", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "762be3fcb779f08207531bc6612cca480a338e4b4357abb49f5ce00240a77d1e"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, @@ -29,6 +30,7 @@ "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, + "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "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"}, diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs index 31e20d726..c8ede71c0 100644 --- a/test/plugs/authentication_plug_test.exs +++ b/test/plugs/authentication_plug_test.exs @@ -79,6 +79,13 @@ test "check sha512-crypt hash" do assert AuthenticationPlug.checkpw("password", hash) end + test "check bcrypt hash" do + hash = "$2a$10$uyhC/R/zoE1ndwwCtMusK.TLVzkQ/Ugsbqp3uXI.CTTz0gBw.24jS" + + assert AuthenticationPlug.checkpw("password", hash) + refute AuthenticationPlug.checkpw("password1", hash) + end + test "it returns false when hash invalid" do hash = "psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1" -- cgit v1.2.3 From 8062d590ddf3798616fe66e99574f925cc3b8c5e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 13 May 2020 18:56:45 +0300 Subject: [#2456] OpenAPI-related tweaks. Removed support for `with_relationships` param in `GET /api/v1/accounts/search`. --- lib/pleroma/web/api_spec/helpers.ex | 7 ++++++- lib/pleroma/web/api_spec/operations/search_operation.ex | 8 ++++---- lib/pleroma/web/api_spec/operations/status_operation.ex | 6 +----- lib/pleroma/web/api_spec/operations/timeline_operation.ex | 12 ++++-------- lib/pleroma/web/controller_helper.ex | 14 +++++++++++--- .../web/mastodon_api/controllers/account_controller.ex | 6 +++--- .../web/mastodon_api/controllers/search_controller.ex | 7 ++----- 7 files changed, 31 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index 859e45b57..16e7ed124 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -49,7 +49,12 @@ def pagination_params do end def with_relationships_param do - Operation.parameter(:with_relationships, :query, BooleanLike, "Include relationships") + Operation.parameter( + :with_relationships, + :query, + BooleanLike, + "Embed relationships into accounts." + ) end def empty_object_response do diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index 475848ff5..169c36d87 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -19,6 +19,7 @@ def open_api_operation(action) do apply(__MODULE__, operation, []) end + # Note: `with_relationships` param is not supported (PleromaFE uses this op for autocomplete) def account_search_operation do %Operation{ tags: ["Search"], @@ -45,8 +46,7 @@ def account_search_operation do :query, %Schema{allOf: [BooleanLike], default: false}, "Only include accounts that the user is following" - ), - with_relationships_param() + ) ], responses: %{ 200 => @@ -139,8 +139,8 @@ def search2_operation do :query, %Schema{allOf: [BooleanLike], default: false}, "Only include accounts that the user is following" - ) - | pagination_params() + ), + with_relationships_param() | pagination_params() ], responses: %{ 200 => Operation.response("Results", "application/json", results2()) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index a6bb87560..f74ea664c 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.AccountOperation alias Pleroma.Web.ApiSpec.Schemas.ApiError - alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus alias Pleroma.Web.ApiSpec.Schemas.Status @@ -349,10 +348,7 @@ def bookmarks_operation do summary: "Bookmarked statuses", description: "Statuses the user has bookmarked", operationId: "StatusController.bookmarks", - parameters: [ - Operation.parameter(:with_relationships, :query, BooleanLike, "Include relationships") - | pagination_params() - ], + parameters: pagination_params(), security: [%{"oAuth" => ["read:bookmarks"]}], responses: %{ 200 => Operation.response("Array of Statuses", "application/json", array_of_statuses()) diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 6cbc7f747..cb9d75841 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -27,8 +27,7 @@ def home_operation do local_param(), with_muted_param(), exclude_visibilities_param(), - reply_visibility_param(), - with_relationships_param() | pagination_params() + reply_visibility_param() | pagination_params() ], operationId: "TimelineController.home", responses: %{ @@ -63,8 +62,7 @@ def public_operation do only_media_param(), with_muted_param(), exclude_visibilities_param(), - reply_visibility_param(), - with_relationships_param() | pagination_params() + reply_visibility_param() | pagination_params() ], operationId: "TimelineController.public", responses: %{ @@ -109,8 +107,7 @@ def hashtag_operation do local_param(), only_media_param(), with_muted_param(), - exclude_visibilities_param(), - with_relationships_param() | pagination_params() + exclude_visibilities_param() | pagination_params() ], operationId: "TimelineController.hashtag", responses: %{ @@ -134,8 +131,7 @@ def list_operation do required: true ), with_muted_param(), - exclude_visibilities_param(), - with_relationships_param() | pagination_params() + exclude_visibilities_param() | pagination_params() ], operationId: "TimelineController.list", responses: %{ diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index ff94c6be0..5a1316a5f 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -104,8 +104,16 @@ def put_if_exist(map, _key, nil), do: map def put_if_exist(map, key, value), do: Map.put(map, key, value) - def with_relationships?(params) do - # To do: change to `truthy_param?(params["with_relationships"])` once PleromaFE supports it - not explicitly_falsy_param?(params["with_relationships"]) + @doc """ + Returns true if request specifies to include embedded relationships in account objects. + May only be used in selected account-related endpoints; has no effect for status- or + notification-related endpoints. + """ + # Intended for PleromaFE: https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838 + def embed_relationships?(params) do + # To do once OpenAPI transition mess is over: just `truthy_param?(params[:with_relationships])` + params + |> Map.get(:with_relationships, params["with_relationships"]) + |> truthy_param?() end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 2dd0252cc..ef41f9e96 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, - with_relationships?: 1, + embed_relationships?: 1, json_response: 3 ] @@ -275,7 +275,7 @@ def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do for: for_user, users: followers, as: :user, - embed_relationships: with_relationships?(params) + embed_relationships: embed_relationships?(params) ) end @@ -300,7 +300,7 @@ def following(%{assigns: %{user: for_user, account: user}} = conn, params) do for: for_user, users: followers, as: :user, - embed_relationships: with_relationships?(params) + embed_relationships: embed_relationships?(params) ) end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 1c2860cc7..77e2224e4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -33,12 +33,10 @@ def account_search(%{assigns: %{user: user}} = conn, %{q: query} = params) do conn |> put_view(AccountView) - # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 |> render("index.json", users: accounts, for: user, - as: :user, - embed_relationships: ControllerHelper.with_relationships?(params) + as: :user ) end @@ -82,7 +80,7 @@ defp search_options(params, user) do offset: params[:offset], type: params[:type], author: get_author(params), - embed_relationships: ControllerHelper.with_relationships?(params), + embed_relationships: ControllerHelper.embed_relationships?(params), for_user: user ] |> Enum.filter(&elem(&1, 1)) @@ -95,7 +93,6 @@ defp resource_search(_, "accounts", query, options) do users: accounts, for: options[:for_user], as: :user, - # https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223 embed_relationships: options[:embed_relationships] ) end -- cgit v1.2.3 From 74912dd9eeb8ffe615562530e9a202cbff92893e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 13 May 2020 19:20:21 +0300 Subject: PleromaFE bundle supporting `with_relationships` param. https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1107/diffs?commit_id=9c7cb3a95431bbea44391f79da465f77565a4b49 --- priv/static/index.html | 2 +- priv/static/static/font/fontello.1588947937982.eot | Bin 22976 -> 0 bytes priv/static/static/font/fontello.1588947937982.svg | 124 --------------------- priv/static/static/font/fontello.1588947937982.ttf | Bin 22808 -> 0 bytes .../static/static/font/fontello.1588947937982.woff | Bin 13988 -> 0 bytes .../static/font/fontello.1588947937982.woff2 | Bin 11816 -> 0 bytes priv/static/static/font/fontello.1589385935077.eot | Bin 0 -> 22976 bytes priv/static/static/font/fontello.1589385935077.svg | 124 +++++++++++++++++++++ priv/static/static/font/fontello.1589385935077.ttf | Bin 0 -> 22808 bytes .../static/static/font/fontello.1589385935077.woff | Bin 0 -> 13988 bytes .../static/font/fontello.1589385935077.woff2 | Bin 0 -> 11796 bytes priv/static/static/fontello.1588947937982.css | Bin 3421 -> 0 bytes priv/static/static/fontello.1589385935077.css | Bin 0 -> 3421 bytes priv/static/static/js/app.838ffa9aecf210c7d744.js | Bin 0 -> 1079319 bytes .../static/js/app.838ffa9aecf210c7d744.js.map | Bin 0 -> 1643789 bytes priv/static/static/js/app.996428ccaaaa7f28cb8d.js | Bin 1079195 -> 0 bytes .../static/js/app.996428ccaaaa7f28cb8d.js.map | Bin 1643581 -> 0 bytes priv/static/sw-pleroma.js | Bin 31752 -> 31752 bytes 18 files changed, 125 insertions(+), 125 deletions(-) delete mode 100644 priv/static/static/font/fontello.1588947937982.eot delete mode 100644 priv/static/static/font/fontello.1588947937982.svg delete mode 100644 priv/static/static/font/fontello.1588947937982.ttf delete mode 100644 priv/static/static/font/fontello.1588947937982.woff delete mode 100644 priv/static/static/font/fontello.1588947937982.woff2 create mode 100644 priv/static/static/font/fontello.1589385935077.eot create mode 100644 priv/static/static/font/fontello.1589385935077.svg create mode 100644 priv/static/static/font/fontello.1589385935077.ttf create mode 100644 priv/static/static/font/fontello.1589385935077.woff create mode 100644 priv/static/static/font/fontello.1589385935077.woff2 delete mode 100644 priv/static/static/fontello.1588947937982.css create mode 100644 priv/static/static/fontello.1589385935077.css create mode 100644 priv/static/static/js/app.838ffa9aecf210c7d744.js create mode 100644 priv/static/static/js/app.838ffa9aecf210c7d744.js.map delete mode 100644 priv/static/static/js/app.996428ccaaaa7f28cb8d.js delete mode 100644 priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map diff --git a/priv/static/index.html b/priv/static/index.html index b37cbaa67..ddd4ec4eb 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588947937982.eot b/priv/static/static/font/fontello.1588947937982.eot deleted file mode 100644 index b1297072e..000000000 Binary files a/priv/static/static/font/fontello.1588947937982.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1588947937982.svg b/priv/static/static/font/fontello.1588947937982.svg deleted file mode 100644 index e63fb7529..000000000 --- a/priv/static/static/font/fontello.1588947937982.svg +++ /dev/null @@ -1,124 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1588947937982.ttf b/priv/static/static/font/fontello.1588947937982.ttf deleted file mode 100644 index 443801c4f..000000000 Binary files a/priv/static/static/font/fontello.1588947937982.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1588947937982.woff b/priv/static/static/font/fontello.1588947937982.woff deleted file mode 100644 index e96fea757..000000000 Binary files a/priv/static/static/font/fontello.1588947937982.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1588947937982.woff2 b/priv/static/static/font/fontello.1588947937982.woff2 deleted file mode 100644 index 50318a670..000000000 Binary files a/priv/static/static/font/fontello.1588947937982.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1589385935077.eot b/priv/static/static/font/fontello.1589385935077.eot new file mode 100644 index 000000000..e5f37013a Binary files /dev/null and b/priv/static/static/font/fontello.1589385935077.eot differ diff --git a/priv/static/static/font/fontello.1589385935077.svg b/priv/static/static/font/fontello.1589385935077.svg new file mode 100644 index 000000000..e63fb7529 --- /dev/null +++ b/priv/static/static/font/fontello.1589385935077.svg @@ -0,0 +1,124 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1589385935077.ttf b/priv/static/static/font/fontello.1589385935077.ttf new file mode 100644 index 000000000..0fde96cea Binary files /dev/null and b/priv/static/static/font/fontello.1589385935077.ttf differ diff --git a/priv/static/static/font/fontello.1589385935077.woff b/priv/static/static/font/fontello.1589385935077.woff new file mode 100644 index 000000000..f48488a77 Binary files /dev/null and b/priv/static/static/font/fontello.1589385935077.woff differ diff --git a/priv/static/static/font/fontello.1589385935077.woff2 b/priv/static/static/font/fontello.1589385935077.woff2 new file mode 100644 index 000000000..012eb9305 Binary files /dev/null and b/priv/static/static/font/fontello.1589385935077.woff2 differ diff --git a/priv/static/static/fontello.1588947937982.css b/priv/static/static/fontello.1588947937982.css deleted file mode 100644 index d3d77a8b5..000000000 Binary files a/priv/static/static/fontello.1588947937982.css and /dev/null differ diff --git a/priv/static/static/fontello.1589385935077.css b/priv/static/static/fontello.1589385935077.css new file mode 100644 index 000000000..746492163 Binary files /dev/null and b/priv/static/static/fontello.1589385935077.css differ diff --git a/priv/static/static/js/app.838ffa9aecf210c7d744.js b/priv/static/static/js/app.838ffa9aecf210c7d744.js new file mode 100644 index 000000000..7e224748e Binary files /dev/null and b/priv/static/static/js/app.838ffa9aecf210c7d744.js differ diff --git a/priv/static/static/js/app.838ffa9aecf210c7d744.js.map b/priv/static/static/js/app.838ffa9aecf210c7d744.js.map new file mode 100644 index 000000000..4c2835cb4 Binary files /dev/null and b/priv/static/static/js/app.838ffa9aecf210c7d744.js.map differ diff --git a/priv/static/static/js/app.996428ccaaaa7f28cb8d.js b/priv/static/static/js/app.996428ccaaaa7f28cb8d.js deleted file mode 100644 index 00f3a28e0..000000000 Binary files a/priv/static/static/js/app.996428ccaaaa7f28cb8d.js and /dev/null differ diff --git a/priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map b/priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map deleted file mode 100644 index 9daca3ff5..000000000 Binary files a/priv/static/static/js/app.996428ccaaaa7f28cb8d.js.map and /dev/null differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index d2be1782b..4d73c414e 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ -- cgit v1.2.3 From 3525400eb2c8c9fd7ac0cac7c3e3f2cd0e340274 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 15:57:39 -0500 Subject: Sync FE static/config.json --- priv/static/static/config.json | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/priv/static/static/config.json b/priv/static/static/config.json index c82678699..727dde73b 100644 --- a/priv/static/static/config.json +++ b/priv/static/static/config.json @@ -1,23 +1,28 @@ { - "theme": "pleroma-dark", + "alwaysShowSubjectInput": true, "background": "/static/aurora_borealis.jpg", - "logo": "/static/logo.png", - "logoMask": true, - "logoMargin": ".1em", - "redirectRootNoLogin": "/main/all", - "redirectRootLogin": "/main/friends", - "showInstanceSpecificPanel": false, "collapseMessageWithSubject": false, - "scopeCopy": true, - "subjectLineBehavior": "email", - "postContentType": "text/plain", - "alwaysShowSubjectInput": true, + "disableChat": false, + "greentext": false, + "hideFilteredStatuses": false, + "hideMutedPosts": false, "hidePostStats": false, + "hideSitename": false, "hideUserStats": false, "loginMethod": "password", - "webPushNotifications": false, + "logo": "/static/logo.png", + "logoMargin": ".1em", + "logoMask": true, + "minimalScopesMode": false, "noAttachmentLinks": false, "nsfwCensorImage": "", + "postContentType": "text/plain", + "redirectRootLogin": "/main/friends", + "redirectRootNoLogin": "/main/all", + "scopeCopy": true, "showFeaturesPanel": true, - "minimalScopesMode": false + "showInstanceSpecificPanel": false, + "subjectLineBehavior": "email", + "theme": "pleroma-dark", + "webPushNotifications": false } -- cgit v1.2.3 From 1b9358116246a6c4c5fcfce0b15b11f2c92d1e07 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:03:42 -0500 Subject: Synchronize suggestions with all available static/config.json settings --- config/description.exs | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/config/description.exs b/config/description.exs index 36ec3d40a..65353efc3 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1105,24 +1105,32 @@ description: "Settings for Pleroma FE", suggestions: [ %{ - theme: "pleroma-dark", - logo: "/static/logo.png", - background: "/images/city.jpg", - redirectRootNoLogin: "/main/all", - redirectRootLogin: "/main/friends", - showInstanceSpecificPanel: true, - scopeOptionsEnabled: false, - formattingOptionsEnabled: false, + alwaysShowSubjectInput: true, + background: "/static/aurora_borealis.jpg", collapseMessageWithSubject: false, + disableChat: false, + greentext: false, + hideFilteredStatuses: false, + hideMutedPosts: false, hidePostStats: false, + hideSitename: false, hideUserStats: false, + loginMethod: "password", + logo: "/static/logo.png", + logoMargin: ".1em", + logoMask: true, + minimalScopesMode: false, + noAttachmentLinks: false, + nsfwCensorImage: "", + postContentType: "text/plain", + redirectRootLogin: "/main/friends", + redirectRootNoLogin: "/main/all", scopeCopy: true, + showFeaturesPanel: true, + showInstanceSpecificPanel: false, subjectLineBehavior: "email", - alwaysShowSubjectInput: true, - logoMask: false, - logoMargin: ".1em", - stickers: false, - enableEmojiPicker: false + theme: "pleroma-dark", + webPushNotifications: false } ], children: [ -- cgit v1.2.3 From 249e009c5ebe3d92e5d1a304f1b8a53dbe9f2d15 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 May 2020 16:14:24 -0500 Subject: Add `pleroma_internal` as an internal field --- lib/pleroma/constants.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 3a9eec5ea..06174f624 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -17,7 +17,8 @@ defmodule Pleroma.Constants do "announcement_count", "emoji", "context_id", - "deleted_activity_id" + "deleted_activity_id", + "pleroma_internal" ] ) -- cgit v1.2.3 From 1e48aee50e91e0e81887d4a2a482aa03691f1a6b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:18:26 -0500 Subject: Alpha sort FE config descriptions --- config/description.exs | 130 ++++++++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/config/description.exs b/config/description.exs index 65353efc3..2119ed177 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1135,16 +1135,10 @@ ], children: [ %{ - key: :theme, - type: :string, - description: "Which theme to use, they are defined in styles.json", - suggestions: ["pleroma-dark"] - }, - %{ - key: :logo, - type: :string, - description: "URL of the logo, defaults to Pleroma's logo", - suggestions: ["/static/logo.png"] + key: :alwaysShowSubjectInput, + label: "Always show subject input", + type: :boolean, + description: "When disabled, auto-hide the subject field if it's empty" }, %{ key: :background, @@ -1154,32 +1148,17 @@ suggestions: ["/images/city.jpg"] }, %{ - key: :redirectRootNoLogin, - label: "Redirect root no login", - type: :string, - description: - "Relative URL which indicates where to redirect when a user isn't logged in", - suggestions: ["/main/all"] - }, - %{ - key: :redirectRootLogin, - label: "Redirect root login", - type: :string, - description: - "Relative URL which indicates where to redirect when a user is logged in", - suggestions: ["/main/friends"] - }, - %{ - key: :showInstanceSpecificPanel, - label: "Show instance specific panel", + key: :collapseMessageWithSubject, + label: "Collapse message with subject", type: :boolean, - description: "Whenether to show the instance's specific panel" + description: + "When a message has a subject (aka Content Warning), collapse it by default" }, %{ - key: :scopeOptionsEnabled, - label: "Scope options enabled", + key: :enableEmojiPicker, + label: "Emoji picker", type: :boolean, - description: "Enable setting a notice visibility and subject/CW when posting" + description: "Enables emoji picker." }, %{ key: :formattingOptionsEnabled, @@ -1188,13 +1167,6 @@ description: "Enable setting a formatting different than plain-text (ie. HTML, Markdown) when posting, relates to `:instance`, `allowed_post_formats`" }, - %{ - key: :collapseMessageWithSubject, - label: "Collapse message with subject", - type: :boolean, - description: - "When a message has a subject (aka Content Warning), collapse it by default" - }, %{ key: :hidePostStats, label: "Hide post stats", @@ -1209,26 +1181,19 @@ "Hide profile statistics (posts, posts per day, followers, followings, ...)" }, %{ - key: :scopeCopy, - label: "Scope copy", - type: :boolean, - description: "Copy the scope (private/unlisted/public) in replies to posts by default" - }, - %{ - key: :subjectLineBehavior, - label: "Subject line behavior", + key: :logo, type: :string, - description: "Allows changing the default behaviour of subject lines in replies. - `email`: copy and preprend re:, as in email, - `masto`: copy verbatim, as in Mastodon, - `noop`: don't copy the subject.", - suggestions: ["email", "masto", "noop"] + description: "URL of the logo, defaults to Pleroma's logo", + suggestions: ["/static/logo.png"] }, %{ - key: :alwaysShowSubjectInput, - label: "Always show subject input", - type: :boolean, - description: "When disabled, auto-hide the subject field if it's empty" + key: :logoMargin, + label: "Logo margin", + type: :string, + description: + "Allows you to adjust vertical margins between logo boundary and navbar borders. " <> + "The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.", + suggestions: [".1em"] }, %{ key: :logoMask, @@ -1239,13 +1204,38 @@ "If you want a colorful logo you must disable logoMask." }, %{ - key: :logoMargin, - label: "Logo margin", + key: :redirectRootNoLogin, + label: "Redirect root no login", type: :string, description: - "Allows you to adjust vertical margins between logo boundary and navbar borders. " <> - "The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.", - suggestions: [".1em"] + "Relative URL which indicates where to redirect when a user isn't logged in", + suggestions: ["/main/all"] + }, + %{ + key: :redirectRootLogin, + label: "Redirect root login", + type: :string, + description: + "Relative URL which indicates where to redirect when a user is logged in", + suggestions: ["/main/friends"] + }, + %{ + key: :scopeCopy, + label: "Scope copy", + type: :boolean, + description: "Copy the scope (private/unlisted/public) in replies to posts by default" + }, + %{ + key: :scopeOptionsEnabled, + label: "Scope options enabled", + type: :boolean, + description: "Enable setting a notice visibility and subject/CW when posting" + }, + %{ + key: :showInstanceSpecificPanel, + label: "Show instance specific panel", + type: :boolean, + description: "Whenether to show the instance's specific panel" }, %{ key: :stickers, @@ -1253,10 +1243,20 @@ description: "Enables stickers." }, %{ - key: :enableEmojiPicker, - label: "Emoji picker", - type: :boolean, - description: "Enables emoji picker." + key: :subjectLineBehavior, + label: "Subject line behavior", + type: :string, + description: "Allows changing the default behaviour of subject lines in replies. + `email`: copy and preprend re:, as in email, + `masto`: copy verbatim, as in Mastodon, + `noop`: don't copy the subject.", + suggestions: ["email", "masto", "noop"] + }, + %{ + key: :theme, + type: :string, + description: "Which theme to use, they are defined in styles.json", + suggestions: ["pleroma-dark"] } ] }, -- cgit v1.2.3 From e2c80e62f1234165ba296beeac20d8e8bd6fc295 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:19:17 -0500 Subject: Stickers setting does not exist --- config/description.exs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/config/description.exs b/config/description.exs index 2119ed177..8d22c6f48 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1237,11 +1237,6 @@ type: :boolean, description: "Whenether to show the instance's specific panel" }, - %{ - key: :stickers, - type: :boolean, - description: "Enables stickers." - }, %{ key: :subjectLineBehavior, label: "Subject line behavior", -- cgit v1.2.3 From 2e28b501323e3656e04f7f7672f753272679a5ef Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:23:42 -0500 Subject: scopeOptionsEnabled has been replaced with minimalScopesMode --- config/description.exs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/config/description.exs b/config/description.exs index 8d22c6f48..c2e309cd4 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1203,6 +1203,13 @@ "By default it assumes logo used will be monochrome with alpha channel to be compatible with both light and dark themes. " <> "If you want a colorful logo you must disable logoMask." }, + %{ + key: :minimalScopesMode, + label: "Minimal scopes mode", + type: :boolean, + description: "Limit scope selection to Direct, User default, and Scope of post replying to. " <> + "Also prevents replying to a DM with a public post from PleromaFE." + }, %{ key: :redirectRootNoLogin, label: "Redirect root no login", @@ -1225,12 +1232,6 @@ type: :boolean, description: "Copy the scope (private/unlisted/public) in replies to posts by default" }, - %{ - key: :scopeOptionsEnabled, - label: "Scope options enabled", - type: :boolean, - description: "Enable setting a notice visibility and subject/CW when posting" - }, %{ key: :showInstanceSpecificPanel, label: "Show instance specific panel", -- cgit v1.2.3 From 4aad764c1d2ccd90b697e38ce2044ae4ccdb7dea Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:24:37 -0500 Subject: enableEmojiPicker is not a setting --- config/description.exs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/config/description.exs b/config/description.exs index c2e309cd4..8f050ae8a 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1154,12 +1154,6 @@ description: "When a message has a subject (aka Content Warning), collapse it by default" }, - %{ - key: :enableEmojiPicker, - label: "Emoji picker", - type: :boolean, - description: "Enables emoji picker." - }, %{ key: :formattingOptionsEnabled, label: "Formatting options enabled", -- cgit v1.2.3 From 2420d7f4394f1a447221c74270b593fcf3956f41 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:26:49 -0500 Subject: Spelling/grammar --- config/description.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 8f050ae8a..5e097aec2 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1230,7 +1230,7 @@ key: :showInstanceSpecificPanel, label: "Show instance specific panel", type: :boolean, - description: "Whenether to show the instance's specific panel" + description: "Whether to show the instance's custom panel" }, %{ key: :subjectLineBehavior, @@ -1245,7 +1245,7 @@ %{ key: :theme, type: :string, - description: "Which theme to use, they are defined in styles.json", + description: "Which theme to use. Available themes are defined in styles.json", suggestions: ["pleroma-dark"] } ] -- cgit v1.2.3 From 4bdde143f99d0a5cef6a15475b5e4591994ca546 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:30:24 -0500 Subject: Add disableChat option --- config/description.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/description.exs b/config/description.exs index 5e097aec2..2870a6591 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1154,6 +1154,12 @@ description: "When a message has a subject (aka Content Warning), collapse it by default" }, + %{ + key: :disableChat, + label: "PleromaFE Chat", + type: :boolean, + description: "Disables PleromaFE Chat component" + }, %{ key: :formattingOptionsEnabled, label: "Formatting options enabled", -- cgit v1.2.3 From 38fb5eaf6adf97ca9158bf5d0df1225b9c1778de Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:31:15 -0500 Subject: formattingOptionsEnabled no longer exists --- config/description.exs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/config/description.exs b/config/description.exs index 2870a6591..b4c598cb4 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1160,13 +1160,6 @@ type: :boolean, description: "Disables PleromaFE Chat component" }, - %{ - key: :formattingOptionsEnabled, - label: "Formatting options enabled", - type: :boolean, - description: - "Enable setting a formatting different than plain-text (ie. HTML, Markdown) when posting, relates to `:instance`, `allowed_post_formats`" - }, %{ key: :hidePostStats, label: "Hide post stats", -- cgit v1.2.3 From 7f00698c3bc94058185ea76b54dbf7b3d5a0f483 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:32:38 -0500 Subject: Add greentext option --- config/description.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/description.exs b/config/description.exs index b4c598cb4..82e888188 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1160,6 +1160,12 @@ type: :boolean, description: "Disables PleromaFE Chat component" }, + %{ + key: :greentext, + label: "Greentext", + type: :boolean, + description: "Enables green text on lines prefixed with the > character." + }, %{ key: :hidePostStats, label: "Hide post stats", -- cgit v1.2.3 From c86cdb76a787ca0ee65702004e829473861148f6 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:35:12 -0500 Subject: Add hideFilteredStatuses and hideMutedPosts settings --- config/description.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/config/description.exs b/config/description.exs index 82e888188..9cd43ae37 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1166,6 +1166,18 @@ type: :boolean, description: "Enables green text on lines prefixed with the > character." }, + %{ + key: :hideFilteredStatuses, + label: "Hide Filtered Statuses", + type: :boolean, + description: "Hides filtered statuses from timelines." + }, + %{ + key: :hideMutedPosts, + label: "Hide Muted Posts", + type: :boolean, + description: "Hides muted statuses from timelines." + }, %{ key: :hidePostStats, label: "Hide post stats", -- cgit v1.2.3 From 923ab78807d16595e4dfc4f2a4a18f249ab88cd0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:36:33 -0500 Subject: Add missing hideSitename setting --- config/description.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/description.exs b/config/description.exs index 9cd43ae37..f353378ac 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1184,6 +1184,12 @@ type: :boolean, description: "Hide notices statistics (repeats, favorites, ...)" }, + %{ + key: :hideSitename, + label: "Hide Sitename", + type: :boolean, + description: "Hides instance name from PleromaFE banner." + }, %{ key: :hideUserStats, label: "Hide user stats", -- cgit v1.2.3 From 52a95a0265eae8c20f284690cdc97c5a6699b1d8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:40:04 -0500 Subject: Add missing nsfwCensorImage option --- config/description.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config/description.exs b/config/description.exs index f353378ac..00f32859c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1227,6 +1227,13 @@ description: "Limit scope selection to Direct, User default, and Scope of post replying to. " <> "Also prevents replying to a DM with a public post from PleromaFE." }, + %{ + key: :nsfwCensorImage, + label: "NSFW Censor Image", + type: :string, + description: "URL of the image to use for hiding NSFW media attachments in the timeline.", + suggestions: ["/static/img/nsfw.png"] + }, %{ key: :redirectRootNoLogin, label: "Redirect root no login", -- cgit v1.2.3 From 5131149056de4501f717084275f4be667f7a463a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:45:16 -0500 Subject: add postContentType setting --- config/description.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/config/description.exs b/config/description.exs index 00f32859c..80c4ac96d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1234,6 +1234,13 @@ description: "URL of the image to use for hiding NSFW media attachments in the timeline.", suggestions: ["/static/img/nsfw.png"] }, + %{ + key: :postContentType, + label: "Post Content Type", + type: {:dropdown, :atom}, + description: "Default post formatting option.", + suggestions: [text/plain, text/html, text/markdown, text/bbcode] + }, %{ key: :redirectRootNoLogin, label: "Redirect root no login", -- cgit v1.2.3 From 0c82a967ec6c67a32328dba1a4d47e6092137fc7 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:49:56 -0500 Subject: Add missing showFeaturesPanel setting --- config/description.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/description.exs b/config/description.exs index 80c4ac96d..ca02d2261 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1263,6 +1263,12 @@ type: :boolean, description: "Copy the scope (private/unlisted/public) in replies to posts by default" }, + %{ + key: :showFeaturesPanel, + label: "Show instance features panel", + type: :boolean, + description: "Enables panel displaying functionality of the instance." + }, %{ key: :showInstanceSpecificPanel, label: "Show instance specific panel", -- cgit v1.2.3 From 2560a4aa560ce9179baa25f22a94ba9755d09bd5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:51:23 -0500 Subject: Formatting --- config/description.exs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/config/description.exs b/config/description.exs index ca02d2261..8ba1cf369 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1224,14 +1224,16 @@ key: :minimalScopesMode, label: "Minimal scopes mode", type: :boolean, - description: "Limit scope selection to Direct, User default, and Scope of post replying to. " <> + description: + "Limit scope selection to Direct, User default, and Scope of post replying to. " <> "Also prevents replying to a DM with a public post from PleromaFE." }, %{ key: :nsfwCensorImage, label: "NSFW Censor Image", type: :string, - description: "URL of the image to use for hiding NSFW media attachments in the timeline.", + description: + "URL of the image to use for hiding NSFW media attachments in the timeline.", suggestions: ["/static/img/nsfw.png"] }, %{ @@ -1239,7 +1241,7 @@ label: "Post Content Type", type: {:dropdown, :atom}, description: "Default post formatting option.", - suggestions: [text/plain, text/html, text/markdown, text/bbcode] + suggestions: ["text/plain", "text/html", "text/markdown", "text/bbcode"] }, %{ key: :redirectRootNoLogin, -- cgit v1.2.3 From 6f53d8815e5e0af9563fbe280bfbd873a06ba06d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 16:57:33 -0500 Subject: Clarify where the Features panel is --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 8ba1cf369..7d7f4e93b 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1269,7 +1269,7 @@ key: :showFeaturesPanel, label: "Show instance features panel", type: :boolean, - description: "Enables panel displaying functionality of the instance." + description: "Enables panel displaying functionality of the instance on the About page." }, %{ key: :showInstanceSpecificPanel, -- cgit v1.2.3 From 54b482418694b8c41984235ea85078fe00572cdc Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 13 May 2020 17:07:14 -0500 Subject: Lint --- config/description.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 7d7f4e93b..a800d7823 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1269,7 +1269,8 @@ key: :showFeaturesPanel, label: "Show instance features panel", type: :boolean, - description: "Enables panel displaying functionality of the instance on the About page." + description: + "Enables panel displaying functionality of the instance on the About page." }, %{ key: :showInstanceSpecificPanel, -- cgit v1.2.3 From e688d4ee69dfbda0f8fd3a5544720a566b3946c5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 14 Apr 2020 18:59:04 +0200 Subject: MRF.StealEmojiPolicy: New Policy Inspired by https://git.pleroma.social/moonman/emoji-stealer-mrf/-/blob/master/steal_emoji_policy.ex --- CHANGELOG.md | 1 + docs/configuration/cheatsheet.md | 5 ++ .../web/activity_pub/mrf/steal_emoji_policy.ex | 97 ++++++++++++++++++++++ test/support/http_request_mock.ex | 4 + .../activity_pub/mrf/steal_emoji_policy_test.exs | 64 ++++++++++++++ 5 files changed, 171 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex create mode 100644 test/web/activity_pub/mrf/steal_emoji_policy_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7fb603d..acd0fe171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mix task to create trusted OAuth App. - Notifications: Added `follow_request` notification type. - Added `:reject_deletes` group to SimplePolicy +- MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances
    API Changes - Mastodon API: Extended `/api/v1/instance`. diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 707d7fdbd..1b2d72087 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -149,6 +149,11 @@ config :pleroma, :mrf_user_allowlist, * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines * `:reject` rejects the message entirely +#### mrf_steal_emoji +* `hosts`: List of hosts to steal emojis from +* `rejected_shortcodes`: Regex-list of shortcodes to reject +* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk + ### :activitypub * `unfollow_blocked`: Whether blocks result in people getting unfollowed * `outgoing_blocks`: Whether to federate blocks to other instances diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex new file mode 100644 index 000000000..2858af9eb --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -0,0 +1,97 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do + require Logger + + alias Pleroma.Config + + @moduledoc "Detect new emojis by their shortcode and steals them" + @behaviour Pleroma.Web.ActivityPub.MRF + + defp remote_host?(host), do: host != Config.get([Pleroma.Web.Endpoint, :url, :host]) + + defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) + + defp steal_emoji({shortcode, url}) do + url = Pleroma.Web.MediaProxy.url(url) + {:ok, response} = Pleroma.HTTP.get(url) + size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000) + + if byte_size(response.body) <= size_limit do + emoji_dir_path = + Config.get( + [:mrf_steal_emoji, :path], + Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") + ) + + extension = + url + |> URI.parse() + |> Map.get(:path) + |> Path.basename() + |> Path.extname() + + file_path = Path.join([emoji_dir_path, shortcode <> (extension || ".png")]) + + try do + :ok = File.write(file_path, response.body) + + shortcode + rescue + e -> + Logger.warn("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}") + nil + end + else + Logger.debug( + "MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{ + size_limit + } B)" + ) + + nil + end + rescue + e -> + Logger.warn("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}") + nil + end + + @impl true + def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = message) do + host = URI.parse(actor).host + + if remote_host?(host) and accept_host?(host) do + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + + new_emojis = + foreign_emojis + |> Enum.filter(fn {shortcode, _url} -> shortcode not in installed_emoji end) + |> Enum.filter(fn {shortcode, _url} -> + reject_emoji? = + Config.get([:mrf_steal_emoji, :rejected_shortcodes], []) + |> Enum.find(false, fn regex -> String.match?(shortcode, regex) end) + + !reject_emoji? + end) + |> Enum.map(&steal_emoji(&1)) + |> Enum.filter(& &1) + + if !Enum.empty?(new_emojis) do + Logger.info("Stole new emojis: #{inspect(new_emojis)}") + Pleroma.Emoji.reload() + end + end + + {:ok, message} + end + + def filter(message), do: {:ok, message} + + @impl true + def describe do + {:ok, %{}} + end +end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 3a95e92da..3d5128835 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1291,6 +1291,10 @@ def get("https://skippers-bin.com/notes/7x9tmrp97i", _, _, _) do }} end + def get("https://example.org/emoji/firedfox.png", _, _, _) do + {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/image.jpg")}} + end + def get("https://skippers-bin.com/users/7v1w1r8ce6", _, _, _) do {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/sjw.json")}} end diff --git a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs new file mode 100644 index 000000000..8882c8c13 --- /dev/null +++ b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicyTest do + use Pleroma.DataCase + + alias Pleroma.Config + alias Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + setup do + clear_config(:mrf_steal_emoji) + + emoji_path = Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") + File.rm_rf!(emoji_path) + File.mkdir!(emoji_path) + + Pleroma.Emoji.reload() + end + + test "does nothing by default" do + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + refute "firedfox" in installed_emoji + + message = %{ + "type" => "Create", + "object" => %{ + "emoji" => [{"firedfox", "https://example.org/emoji/firedfox.png"}], + "actor" => "https://example.org/users/admin" + } + } + + assert {:ok, message} == StealEmojiPolicy.filter(message) + + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + refute "firedfox" in installed_emoji + end + + test "Steals emoji on unknown shortcode from allowed remote host" do + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + refute "firedfox" in installed_emoji + + message = %{ + "type" => "Create", + "object" => %{ + "emoji" => [{"firedfox", "https://example.org/emoji/firedfox.png"}], + "actor" => "https://example.org/users/admin" + } + } + + Config.put([:mrf_steal_emoji, :hosts], ["example.org"]) + Config.put([:mrf_steal_emoji, :size_limit], 284_468) + + assert {:ok, message} == StealEmojiPolicy.filter(message) + + installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) + assert "firedfox" in installed_emoji + end +end -- cgit v1.2.3 From cb363f018380cceb9531e0ddd12a979b8accc0b2 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 29 Apr 2020 17:38:14 +0200 Subject: MastodonAPI: /api/v2/media endpoints --- .../mastodon_api/controllers/media_controller.ex | 31 +++++++++++ lib/pleroma/web/router.ex | 3 ++ .../controllers/media_controller_test.exs | 62 ++++++++++++++++++++-- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index e36751220..1997ac1af 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -29,6 +29,26 @@ def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do end end + def create(_conn, _data), do: {:error, :bad_request} + + @doc "POST /api/v2/media" + def create2(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + with {:ok, object} <- + ActivityPub.upload( + file, + actor: User.ap_id(user), + description: Map.get(data, "description") + ) do + attachment_data = Map.put(object.data, "id", object.id) + + conn + |> put_status(202) + |> render("attachment.json", %{attachment: attachment_data}) + end + end + + def create2(_conn, _data), do: {:error, :bad_request} + @doc "PUT /api/v1/media/:id" def update(%{assigns: %{user: user}} = conn, %{"id" => id, "description" => description}) when is_binary(description) do @@ -42,4 +62,15 @@ def update(%{assigns: %{user: user}} = conn, %{"id" => id, "description" => desc end def update(_conn, _data), do: {:error, :bad_request} + + @doc "GET /api/v1/media/:id" + def show(conn, %{"id" => id}) do + with %Object{data: data, id: object_id} <- Object.get_by_id(id) do + attachment_data = Map.put(data, "id", object_id) + + render(conn, "attachment.json", %{attachment: attachment_data}) + end + end + + def get_media(_conn, _data), do: {:error, :bad_request} end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 7a171f9fb..d77a61361 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -403,6 +403,7 @@ defmodule Pleroma.Web.Router do post("/markers", MarkerController, :upsert) post("/media", MediaController, :create) + get("/media/:id", MediaController, :show) put("/media/:id", MediaController, :update) get("/notifications", NotificationController, :index) @@ -497,6 +498,8 @@ defmodule Pleroma.Web.Router do scope "/api/v2", Pleroma.Web.MastodonAPI do pipe_through(:api) get("/search", SearchController, :search2) + + post("/media", MediaController, :create2) end scope "/api", Pleroma.Web do diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs index 6ac4cf63b..d872ff484 100644 --- a/test/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do setup do: oauth_access(["write:media"]) - describe "media upload" do + describe "Upload media" do setup do image = %Plug.Upload{ content_type: "image/jpg", @@ -25,7 +25,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do setup do: clear_config([:media_proxy]) setup do: clear_config([Pleroma.Upload]) - test "returns uploaded image", %{conn: conn, image: image} do + test "/api/v1/media", %{conn: conn, image: image} do desc = "Description of the image" media = @@ -40,9 +40,31 @@ test "returns uploaded image", %{conn: conn, image: image} do object = Object.get_by_id(media["id"]) assert object.data["actor"] == User.ap_id(conn.assigns[:user]) end + + test "/api/v2/media", %{conn: conn, image: image} do + desc = "Description of the image" + + response = + conn + |> post("/api/v2/media", %{"file" => image, "description" => desc}) + |> json_response(202) + + assert media_id = response["id"] + + media = + conn + |> get("/api/v1/media/#{media_id}") + |> json_response(200) + + assert media["type"] == "image" + assert media["description"] == desc + assert media["id"] + object = Object.get_by_id(media["id"]) + assert object.data["actor"] == User.ap_id(conn.assigns[:user]) + end end - describe "PUT /api/v1/media/:id" do + describe "Update media description" do setup %{user: actor} do file = %Plug.Upload{ content_type: "image/jpg", @@ -60,7 +82,7 @@ test "returns uploaded image", %{conn: conn, image: image} do [object: object] end - test "updates name of media", %{conn: conn, object: object} do + test "/api/v1/media/:id good request", %{conn: conn, object: object} do media = conn |> put("/api/v1/media/#{object.id}", %{"description" => "test-media"}) @@ -70,7 +92,7 @@ test "updates name of media", %{conn: conn, object: object} do assert refresh_record(object).data["name"] == "test-media" end - test "returns error when request is bad", %{conn: conn, object: object} do + test "/api/v1/media/:id bad request", %{conn: conn, object: object} do media = conn |> put("/api/v1/media/#{object.id}", %{}) @@ -79,4 +101,34 @@ test "returns error when request is bad", %{conn: conn, object: object} do assert media == %{"error" => "bad_request"} end end + + describe "Get media by id" do + setup %{user: actor} do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, %Object{} = object} = + ActivityPub.upload( + file, + actor: User.ap_id(actor), + description: "test-media" + ) + + [object: object] + end + + test "/api/v1/media/:id", %{conn: conn, object: object} do + media = + conn + |> get("/api/v1/media/#{object.id}") + |> json_response(:ok) + + assert media["description"] == "test-media" + assert media["type"] == "image" + assert media["id"] + end + end end -- cgit v1.2.3 From 1c2629328de05b53412b52cf16de6bc0059acee9 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 14 May 2020 09:07:09 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex --- lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index af231b4a8..9280d5d81 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -20,7 +20,7 @@ def open_api_operation(action) do def confirmation_resend_operation do %Operation{ tags: ["Accounts"], - summary: "Resend confirmation email. Expects `email` or `nic`", + summary: "Resend confirmation email. Expects `email` or `nickname`", operationId: "PleromaAPI.AccountController.confirmation_resend", parameters: [ Operation.parameter(:email, :query, :string, "Email of that needs to be verified", -- cgit v1.2.3 From 359d7b0a6d41df8b42c1c495a5b97420c4a943a7 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 14 May 2020 09:09:11 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex --- lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index 9280d5d81..435991037 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -119,7 +119,7 @@ def subscribe_operation do def unsubscribe_operation do %Operation{ tags: ["Accounts"], - summary: "Unsubscribe to stop receiving notifications from user statuses¶", + summary: "Unsubscribe to stop receiving notifications from user statuses", operationId: "PleromaAPI.AccountController.unsubscribe", parameters: [id_param()], security: [%{"oAuth" => ["follow", "write:follows"]}], -- cgit v1.2.3 From 41db52729eee0158c90d69a8dfc0d87d2a866de0 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 14 May 2020 09:14:59 +0000 Subject: Apply suggestion to docs/configuration/storing_remote_media.md --- docs/configuration/storing_remote_media.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/storing_remote_media.md b/docs/configuration/storing_remote_media.md index 619300e7e..7e91fe7d9 100644 --- a/docs/configuration/storing_remote_media.md +++ b/docs/configuration/storing_remote_media.md @@ -1,7 +1,7 @@ # Storing Remote Media Pleroma does not store remote/federated media by default. The best way to achieve this is to change Nginx to keep its reverse proxy cache -forever and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy +for a year and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy as soon as the post is received by your instance. ## Nginx -- cgit v1.2.3 From 099e314a1bb823a83d9c1af0cca2363487a07899 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 14 May 2020 10:50:12 +0200 Subject: Add OpenAPISpex for MediaController --- .../web/api_spec/operations/media_operation.ex | 131 +++++++++++++++++++++ lib/pleroma/web/api_spec/schemas/attachment.ex | 2 +- .../mastodon_api/controllers/media_controller.ex | 3 + 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/web/api_spec/operations/media_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex new file mode 100644 index 000000000..0fe686efa --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -0,0 +1,131 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.MediaOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["media"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.create", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", create_request()), + responses: %{ + 200 => + Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp create_request() do + %Schema{ + title: "MediaCreateRequest", + description: "POST body for creating an attachment", + type: :object, + properties: %{ + file: %Schema{ + type: :binary, + description: "The file to be attached, using multipart form data.", + required: true + }, + description: %Schema{ + type: :string, + description: "A plain-text description of the media, for accessibility purposes." + }, + focus: %Schema{ + type: :string, + description: "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0." + } + } + } + end + + def update_operation do + %Operation{ + tags: ["media"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.update", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", update_request()), + responses: %{ + 200 => + Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + defp update_request() do + %Schema{ + title: "MediaCreateRequest", + description: "POST body for creating an attachment", + type: :object, + properties: %{ + id: %Schema{ + type: :string, + description: "The id of the Attachment entity to be updated", + required: true + }, + file: %Schema{ + type: :binary, + description: "The file to be attached, using multipart form data." + }, + description: %Schema{ + type: :string, + description: "A plain-text description of the media, for accessibility purposes." + }, + focus: %Schema{ + type: :string, + description: "Two floating points (x,y), comma-delimited, ranging from -1.0 to 1.0." + } + } + } + end + + def show_operation do + %Operation{ + tags: ["media"], + summary: "Show Uploaded media attachment", + operationId: "MediaController.show", + security: [%{"oAuth" => ["read:media"]}], + responses: %{ + 200 => + Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 401 => Operation.response("Media", "application/json", ApiError), + 422 => Operation.response("Media", "application/json", ApiError) + } + } + end + + def create2_operation do + %Operation{ + tags: ["media"], + summary: "Upload media as attachment", + description: "Creates an attachment to be used with a new status.", + operationId: "MediaController.create2", + security: [%{"oAuth" => ["write:media"]}], + requestBody: Helpers.request_body("Parameters", create_request()), + responses: %{ + 202 => + Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 422 => Operation.response("Media", "application/json", ApiError), + 500 => Operation.response("Media", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/schemas/attachment.ex b/lib/pleroma/web/api_spec/schemas/attachment.ex index c146c416e..c6edf6d36 100644 --- a/lib/pleroma/web/api_spec/schemas/attachment.ex +++ b/lib/pleroma/web/api_spec/schemas/attachment.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Attachment do type: :object, requried: [:id, :url, :preview_url], properties: %{ - id: %Schema{type: :string}, + id: %Schema{type: :string, description: "The ID of the attachment in the database."}, url: %Schema{ type: :string, format: :uri, diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 1997ac1af..52e0b22d8 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -11,10 +11,13 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do alias Pleroma.Web.ActivityPub.ActivityPub action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) plug(OAuthScopesPlug, %{scopes: ["write:media"]}) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation + @doc "POST /api/v1/media" def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do with {:ok, object} <- -- cgit v1.2.3 From 5c6f57531505f1e5e39836c0e0e6d2563fdaedf8 Mon Sep 17 00:00:00 2001 From: Steph Date: Thu, 14 May 2020 09:50:53 +0000 Subject: Style fixes --- lib/pleroma/web/admin_api/admin_api_controller.ex | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index fa064a8c7..3053f57a1 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -892,14 +892,9 @@ def list_log(conn, params) do end def config_descriptions(conn, _params) do - descriptions_json = - @descriptions - |> Enum.filter(&whitelisted_config?/1) - |> Jason.encode!() + descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) - conn - |> Plug.Conn.put_resp_content_type("application/json") - |> Plug.Conn.send_resp(200, descriptions_json) + json(conn, descriptions) end def config_show(conn, %{"only_db" => true}) do @@ -954,7 +949,8 @@ def config_show(conn, _params) do def config_update(conn, %{"configs" => configs}) do with :ok <- configurable_from_database(conn) do {_errors, results} = - Enum.filter(configs, &whitelisted_config?/1) + configs + |> Enum.filter(&whitelisted_config?/1) |> Enum.map(fn %{"group" => group, "key" => key, "delete" => true} = params -> ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) -- cgit v1.2.3 From 3342846ac2bbd48e985cfeff26ba4593f4815879 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 14 May 2020 13:20:28 +0200 Subject: ChatView: Add update_at field. --- lib/pleroma/web/pleroma_api/views/chat_view.ex | 4 +++- test/web/pleroma_api/views/chat_view_test.exs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 21f0612ff..08d5110c3 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do alias Pleroma.Chat alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.PleromaAPI.ChatMessageView @@ -20,7 +21,8 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread, last_message: - last_message && ChatMessageView.render("show.json", chat: chat, object: last_message) + last_message && ChatMessageView.render("show.json", chat: chat, object: last_message), + updated_at: Utils.to_masto_date(chat.updated_at) } end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index e24e29835..6062a0cfe 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do alias Pleroma.Chat alias Pleroma.Object alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView @@ -26,7 +27,8 @@ test "it represents a chat" do id: "#{chat.id}", account: AccountView.render("show.json", user: recipient), unread: 0, - last_message: nil + last_message: nil, + updated_at: Utils.to_masto_date(chat.updated_at) } {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") -- cgit v1.2.3 From 20cbfb5cb5515044de03cc48e8464ec45ad0ca50 Mon Sep 17 00:00:00 2001 From: Stephanie Wilde-Hobbs Date: Thu, 14 May 2020 12:34:46 +0100 Subject: Allow whitelisting whole groups --- docs/configuration/cheatsheet.md | 3 ++- lib/pleroma/web/admin_api/admin_api_controller.ex | 8 ++++++-- test/web/admin_api/admin_api_controller_test.exs | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 7b7a332c7..f0ecebc99 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -919,7 +919,8 @@ Example: ```elixir config :pleroma, :database_config_whitelist, [ {:pleroma, :instance}, - {:pleroma, Pleroma.Web.Metadata} + {:pleroma, Pleroma.Web.Metadata}, + {:auto_linker} ] ``` diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 3053f57a1..c996a2a5a 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -1015,8 +1015,12 @@ defp configurable_from_database(conn) do defp whitelisted_config?(group, key) do if whitelisted_configs = Config.get(:database_config_whitelist) do - Enum.any?(whitelisted_configs, fn {whitelisted_group, whitelisted_key} -> - group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + Enum.any?(whitelisted_configs, fn + {whitelisted_group} -> + group == inspect(whitelisted_group) + + {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) end) else true diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 7d42a400c..e573220ba 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2948,7 +2948,8 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do clear_config(:database_config_whitelist, [ {:pleroma, :key1}, {:pleroma, :key2}, - {:pleroma, Pleroma.Captcha.NotReal} + {:pleroma, Pleroma.Captcha.NotReal}, + {:not_real} ]) post(conn, "/api/pleroma/admin/config", %{ @@ -2957,7 +2958,8 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do %{group: ":pleroma", key: ":key2", value: "value2"}, %{group: ":pleroma", key: ":key3", value: "value3"}, %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, - %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"} + %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, + %{group: ":not_real", key: ":anything", value: "value6"} ] }) @@ -2966,6 +2968,7 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do assert Application.get_env(:pleroma, :key3) == nil assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" + assert Application.get_env(:not_real, :anything) == "value6" end end @@ -3624,7 +3627,8 @@ test "filters by database configuration whitelist", %{conn: conn} do clear_config(:database_config_whitelist, [ {:pleroma, :instance}, {:pleroma, :activitypub}, - {:pleroma, Pleroma.Upload} + {:pleroma, Pleroma.Upload}, + {:esshd} ]) admin = insert(:user, is_admin: true) @@ -3635,9 +3639,9 @@ test "filters by database configuration whitelist", %{conn: conn} do children = json_response(conn, 200) - assert length(children) == 3 + assert length(children) == 4 - assert Enum.all?(children, fn c -> c["group"] == ":pleroma" end) + assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 instance = Enum.find(children, fn c -> c["key"] == ":instance" end) assert instance["children"] @@ -3647,6 +3651,9 @@ test "filters by database configuration whitelist", %{conn: conn} do web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) assert web_endpoint["children"] + + esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) + assert esshd["children"] end end -- cgit v1.2.3 From 3eff54267837a2c6fd65f8600643cb9f0cd5b972 Mon Sep 17 00:00:00 2001 From: Stephanie Wilde-Hobbs Date: Thu, 14 May 2020 12:36:49 +0100 Subject: Add Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7fb603d..feda41320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. +- Configuration: Add `:database_config_whitelist` setting to whitelist settings which can be configured from AdminFE. - 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. - Mix task to create trusted OAuth App. - Notifications: Added `follow_request` notification type. -- cgit v1.2.3 From 0f885b4b86ad7ba738ef0dd0de7f7d0496b7e43d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 14 May 2020 16:18:30 +0400 Subject: Fix OpenAPI spec --- .../web/api_spec/operations/media_operation.ex | 43 +++++++++++----------- .../mastodon_api/controllers/media_controller.ex | 14 ++++--- .../controllers/media_controller_test.exs | 16 +++++--- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex index 0fe686efa..d9c3c42db 100644 --- a/lib/pleroma/web/api_spec/operations/media_operation.ex +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.Attachment def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -22,24 +23,24 @@ def create_operation do security: [%{"oAuth" => ["write:media"]}], requestBody: Helpers.request_body("Parameters", create_request()), responses: %{ - 200 => - Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 200 => Operation.response("Media", "application/json", Attachment), 401 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError) } } end - defp create_request() do + defp create_request do %Schema{ title: "MediaCreateRequest", description: "POST body for creating an attachment", type: :object, + required: [:file], properties: %{ file: %Schema{ - type: :binary, - description: "The file to be attached, using multipart form data.", - required: true + type: :string, + format: :binary, + description: "The file to be attached, using multipart form data." }, description: %Schema{ type: :string, @@ -60,29 +61,26 @@ def update_operation do description: "Creates an attachment to be used with a new status.", operationId: "MediaController.update", security: [%{"oAuth" => ["write:media"]}], + parameters: [id_param()], requestBody: Helpers.request_body("Parameters", update_request()), responses: %{ - 200 => - Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 200 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), 401 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError) } } end - defp update_request() do + defp update_request do %Schema{ - title: "MediaCreateRequest", - description: "POST body for creating an attachment", + title: "MediaUpdateRequest", + description: "POST body for updating an attachment", type: :object, properties: %{ - id: %Schema{ - type: :string, - description: "The id of the Attachment entity to be updated", - required: true - }, file: %Schema{ - type: :binary, + type: :string, + format: :binary, description: "The file to be attached, using multipart form data." }, description: %Schema{ @@ -102,10 +100,10 @@ def show_operation do tags: ["media"], summary: "Show Uploaded media attachment", operationId: "MediaController.show", + parameters: [id_param()], security: [%{"oAuth" => ["read:media"]}], responses: %{ - 200 => - Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 200 => Operation.response("Media", "application/json", Attachment), 401 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError) } @@ -121,11 +119,14 @@ def create2_operation do security: [%{"oAuth" => ["write:media"]}], requestBody: Helpers.request_body("Parameters", create_request()), responses: %{ - 202 => - Operation.response("Media", "application/json", Pleroma.Web.ApiSpec.Schemas.Attachment), + 202 => Operation.response("Media", "application/json", Attachment), 422 => Operation.response("Media", "application/json", ApiError), 500 => Operation.response("Media", "application/json", ApiError) } } end + + defp id_param do + Operation.parameter(:id, :path, :string, "The ID of the Attachment entity") + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 52e0b22d8..3b2ea751c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -19,12 +19,12 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation @doc "POST /api/v1/media" - def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + def create(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do with {:ok, object} <- ActivityPub.upload( file, actor: User.ap_id(user), - description: Map.get(data, "description") + description: Map.get(data, :description) ) do attachment_data = Map.put(object.data, "id", object.id) @@ -35,12 +35,12 @@ def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do def create(_conn, _data), do: {:error, :bad_request} @doc "POST /api/v2/media" - def create2(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do + def create2(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, _) do with {:ok, object} <- ActivityPub.upload( file, actor: User.ap_id(user), - description: Map.get(data, "description") + description: Map.get(data, :description) ) do attachment_data = Map.put(object.data, "id", object.id) @@ -53,7 +53,9 @@ def create2(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do def create2(_conn, _data), do: {:error, :bad_request} @doc "PUT /api/v1/media/:id" - def update(%{assigns: %{user: user}} = conn, %{"id" => id, "description" => description}) + def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{ + id: id + }) when is_binary(description) do with %Object{} = object <- Object.get_by_id(id), true <- Object.authorize_mutation(object, user), @@ -67,7 +69,7 @@ def update(%{assigns: %{user: user}} = conn, %{"id" => id, "description" => desc def update(_conn, _data), do: {:error, :bad_request} @doc "GET /api/v1/media/:id" - def show(conn, %{"id" => id}) do + def show(conn, %{id: id}) do with %Object{data: data, id: object_id} <- Object.get_by_id(id) do attachment_data = Map.put(data, "id", object_id) diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs index d872ff484..715747818 100644 --- a/test/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -30,8 +30,9 @@ test "/api/v1/media", %{conn: conn, image: image} do media = conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/v1/media", %{"file" => image, "description" => desc}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert media["type"] == "image" assert media["description"] == desc @@ -46,15 +47,16 @@ test "/api/v2/media", %{conn: conn, image: image} do response = conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/v2/media", %{"file" => image, "description" => desc}) - |> json_response(202) + |> json_response_and_validate_schema(202) assert media_id = response["id"] media = conn |> get("/api/v1/media/#{media_id}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert media["type"] == "image" assert media["description"] == desc @@ -85,8 +87,9 @@ test "/api/v2/media", %{conn: conn, image: image} do test "/api/v1/media/:id good request", %{conn: conn, object: object} do media = conn + |> put_req_header("content-type", "multipart/form-data") |> put("/api/v1/media/#{object.id}", %{"description" => "test-media"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert media["description"] == "test-media" assert refresh_record(object).data["name"] == "test-media" @@ -95,8 +98,9 @@ test "/api/v1/media/:id good request", %{conn: conn, object: object} do test "/api/v1/media/:id bad request", %{conn: conn, object: object} do media = conn + |> put_req_header("content-type", "multipart/form-data") |> put("/api/v1/media/#{object.id}", %{}) - |> json_response(400) + |> json_response_and_validate_schema(400) assert media == %{"error" => "bad_request"} end @@ -124,7 +128,7 @@ test "/api/v1/media/:id", %{conn: conn, object: object} do media = conn |> get("/api/v1/media/#{object.id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert media["description"] == "test-media" assert media["type"] == "image" -- cgit v1.2.3 From bb03dfdb03714027640087ad1bd6475a8bb1c2c3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 14 May 2020 16:29:32 +0400 Subject: Do not require `description` in `update` action --- lib/pleroma/web/mastodon_api/controllers/media_controller.ex | 7 ++----- test/web/mastodon_api/controllers/media_controller_test.exs | 10 ---------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 3b2ea751c..a21233393 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -53,10 +53,7 @@ def create2(%{assigns: %{user: user}, body_params: %{file: file} = data} = conn, def create2(_conn, _data), do: {:error, :bad_request} @doc "PUT /api/v1/media/:id" - def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{ - id: id - }) - when is_binary(description) do + def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{id: id}) do with %Object{} = object <- Object.get_by_id(id), true <- Object.authorize_mutation(object, user), {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do @@ -66,7 +63,7 @@ def update(%{assigns: %{user: user}, body_params: %{description: description}} = end end - def update(_conn, _data), do: {:error, :bad_request} + def update(conn, data), do: show(conn, data) @doc "GET /api/v1/media/:id" def show(conn, %{id: id}) do diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs index 715747818..7ba1727f2 100644 --- a/test/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -94,16 +94,6 @@ test "/api/v1/media/:id good request", %{conn: conn, object: object} do assert media["description"] == "test-media" assert refresh_record(object).data["name"] == "test-media" end - - test "/api/v1/media/:id bad request", %{conn: conn, object: object} do - media = - conn - |> put_req_header("content-type", "multipart/form-data") - |> put("/api/v1/media/#{object.id}", %{}) - |> json_response_and_validate_schema(400) - - assert media == %{"error" => "bad_request"} - end end describe "Get media by id" do -- cgit v1.2.3 From 5b0f27d23d8f60d2e12c0556c56fdb52809398eb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 14 May 2020 08:42:27 -0500 Subject: Pbkdf2.verify_pass --> AuthenticationPlug.checkpw --- lib/pleroma/bbs/authenticator.ex | 3 ++- lib/pleroma/plugs/authentication_plug.ex | 2 +- lib/pleroma/web/auth/totp_authenticator.ex | 3 ++- lib/pleroma/web/mongooseim/mongoose_im_controller.ex | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/bbs/authenticator.ex b/lib/pleroma/bbs/authenticator.ex index d4494b003..815de7002 100644 --- a/lib/pleroma/bbs/authenticator.ex +++ b/lib/pleroma/bbs/authenticator.ex @@ -4,6 +4,7 @@ defmodule Pleroma.BBS.Authenticator do use Sshd.PasswordAuthenticator + alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.User def authenticate(username, password) do @@ -11,7 +12,7 @@ def authenticate(username, password) do password = to_string(password) with %User{} = user <- User.get_by_nickname(username) do - Pbkdf2.verify_pass(password, user.password_hash) + AuthenticationPlug.checkpw(password, user.password_hash) else _e -> false end diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index 1994b807e..2cdf6c951 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -41,7 +41,7 @@ def call( } = conn, _ ) do - if Pbkdf2.verify_pass(password, password_hash) do + if checkpw(password, password_hash) do conn |> assign(:user, auth_user) |> OAuthScopesPlug.skip_plug() diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex index 04e489c83..ce8a76219 100644 --- a/lib/pleroma/web/auth/totp_authenticator.ex +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.Auth.TOTPAuthenticator do alias Pleroma.MFA alias Pleroma.MFA.TOTP + alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.User @doc "Verify code or check backup code." @@ -30,7 +31,7 @@ def verify_recovery_code( code ) when is_list(codes) and is_binary(code) do - hash_code = Enum.find(codes, fn hash -> Pbkdf2.verify_pass(code, hash) end) + hash_code = Enum.find(codes, fn hash -> AuthenticationPlug.checkpw(code, hash) end) if hash_code do MFA.invalidate_backup_code(user, hash_code) diff --git a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex index 0814b3bc3..6cbbe8fd8 100644 --- a/lib/pleroma/web/mongooseim/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongooseim/mongoose_im_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do use Pleroma.Web, :controller + alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.User @@ -27,7 +28,7 @@ def user_exists(conn, %{"user" => username}) do def check_password(conn, %{"user" => username, "pass" => password}) do with %User{password_hash: password_hash, deactivated: false} <- Repo.get_by(User, nickname: username, local: true), - true <- Pbkdf2.verify_pass(password, password_hash) do + true <- AuthenticationPlug.checkpw(password, password_hash) do conn |> json(true) else -- cgit v1.2.3 From 80308c5c262662084dc89de05e976e7166cbb304 Mon Sep 17 00:00:00 2001 From: Stephanie Wilde-Hobbs Date: Thu, 14 May 2020 15:56:14 +0100 Subject: Add config migration disclaimer to config whitelist documentation --- docs/configuration/cheatsheet.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f0ecebc99..1078c4e87 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -913,7 +913,10 @@ Boolean, enables/disables in-database configuration. Read [Transfering the confi ## :database_config_whitelist -List of valid configuration sections which are allowed to be configured from the database. +List of valid configuration sections which are allowed to be configured from the +database. Settings stored in the database before the whitelist is configured are +still applied, so it is suggested to only use the whitelist on instances that +have not migrated the config to the database. Example: ```elixir -- cgit v1.2.3 From c2e57f2981304ab2159461fb79d6500ec8c7a547 Mon Sep 17 00:00:00 2001 From: Michał Sidor Date: Wed, 13 May 2020 16:37:17 +0000 Subject: Added translation using Weblate (Polish) --- priv/gettext/pl/LC_MESSAGES/errors.po | 584 ++++++++++++++++++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 priv/gettext/pl/LC_MESSAGES/errors.po diff --git a/priv/gettext/pl/LC_MESSAGES/errors.po b/priv/gettext/pl/LC_MESSAGES/errors.po new file mode 100644 index 000000000..a0afeff44 --- /dev/null +++ b/priv/gettext/pl/LC_MESSAGES/errors.po @@ -0,0 +1,584 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-13 16:37+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: pl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 2.5.1\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:421 +#, elixir-format +msgid "Account not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:249 +#, elixir-format +msgid "Already voted" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:360 +#, elixir-format +msgid "Bad request" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 +#, elixir-format +msgid "Can't delete object" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 +#, elixir-format +msgid "Can't delete this post" +msgstr "" + +#: lib/pleroma/web/controller_helper.ex:95 +#: lib/pleroma/web/controller_helper.ex:101 +#, elixir-format +msgid "Can't display this activity" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 +#, elixir-format +msgid "Can't find user" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 +#, elixir-format +msgid "Can't get favorites" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 +#, elixir-format +msgid "Can't like object" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:556 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:504 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "" + +#: lib/pleroma/config/config_db.ex:222 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:95 +#, elixir-format +msgid "Could not delete" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:141 +#, elixir-format +msgid "Could not favorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:370 +#, elixir-format +msgid "Could not pin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:112 +#, elixir-format +msgid "Could not repeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:188 +#, elixir-format +msgid "Could not unfavorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:380 +#, elixir-format +msgid "Could not unpin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:126 +#, elixir-format +msgid "Could not unrepeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:428 +#: lib/pleroma/web/common_api/common_api.ex:437 +#, elixir-format +msgid "Could not update state" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 +#, elixir-format +msgid "Error." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 +#: lib/pleroma/web/oauth/oauth_controller.ex:569 +#, elixir-format +msgid "Invalid credentials" +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:265 +#, elixir-format +msgid "Invalid indices" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 +#, elixir-format +msgid "Invalid parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:411 +#, elixir-format +msgid "Invalid password." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 +#, elixir-format +msgid "Invalid request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 +#, elixir-format +msgid "Missing parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:540 +#, elixir-format +msgid "No such conversation" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:439 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 +#, elixir-format +msgid "No such permission_group" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:74 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 +#: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:241 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:290 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 +#: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:566 +#, elixir-format +msgid "The status is over the character limit" +msgstr "" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "" + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:266 +#, elixir-format +msgid "Too many choices" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 +#, elixir-format +msgid "Unhandled activity type" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:536 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:218 +#: lib/pleroma/web/oauth/oauth_controller.ex:309 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:180 +#: lib/pleroma/web/oauth/oauth_controller.ex:332 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:388 +#, elixir-format +msgid "conversation is already muted" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 +#, elixir-format +msgid "error" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 +#, elixir-format +msgid "mascots can only be images" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 +#, elixir-format +msgid "not found" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:395 +#, elixir-format +msgid "Bad OAuth request." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:55 +#, elixir-format +msgid "Failed" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:411 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:442 +#, elixir-format +msgid "Failed to set up user account." +msgstr "" + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:94 +#, elixir-format +msgid "Internal Error" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:169 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:155 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:391 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "" + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:200 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:211 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:207 +#: lib/pleroma/web/oauth/oauth_controller.ex:322 +#, elixir-format +msgid "Password reset is required" +msgstr "" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/fallback_redirect_controller.ex:6 +#: lib/pleroma/web/feed/tag_controller.ex:6 lib/pleroma/web/feed/user_controller.ex:6 +#: lib/pleroma/web/mailer/subscription_controller.ex:2 lib/pleroma/web/masto_fe_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 +#: lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 +#: lib/pleroma/web/oauth/fallback_controller.ex:6 lib/pleroma/web/oauth/mfa_controller.ex:10 +#: lib/pleroma/web/oauth/oauth_controller.ex:6 lib/pleroma/web/ostatus/ostatus_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:2 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#, elixir-format +msgid "User is not an admin or OAuth admin scope is not granted." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:502 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "" -- cgit v1.2.3 From 08f5e49395ada741feb086a874d2212dd83a721c Mon Sep 17 00:00:00 2001 From: Michał Sidor Date: Wed, 13 May 2020 16:49:25 +0000 Subject: Translated using Weblate (Polish) Currently translated at 55.6% (59 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/pl/ --- priv/gettext/pl/LC_MESSAGES/errors.po | 129 +++++++++++++++++----------------- 1 file changed, 66 insertions(+), 63 deletions(-) diff --git a/priv/gettext/pl/LC_MESSAGES/errors.po b/priv/gettext/pl/LC_MESSAGES/errors.po index a0afeff44..fae8ef82a 100644 --- a/priv/gettext/pl/LC_MESSAGES/errors.po +++ b/priv/gettext/pl/LC_MESSAGES/errors.po @@ -3,14 +3,17 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-13 16:37+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2020-05-13 18:57+0000\n" +"Last-Translator: Michał Sidor \n" +"Language-Team: Polish \n" "Language: pl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.5.1\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " +"|| n%100>=20) ? 1 : 2;\n" +"X-Generator: Weblate 4.0.4\n" ## This file is a PO Template file. ## @@ -112,39 +115,39 @@ msgstr "" #: lib/pleroma/web/common_api/common_api.ex:421 #, elixir-format msgid "Account not found" -msgstr "" +msgstr "Nie znaleziono konta" #: lib/pleroma/web/common_api/common_api.ex:249 #, elixir-format msgid "Already voted" -msgstr "" +msgstr "Już zagłosowano" #: lib/pleroma/web/oauth/oauth_controller.ex:360 #, elixir-format msgid "Bad request" -msgstr "" +msgstr "Nieprawidłowe żądanie" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 #, elixir-format msgid "Can't delete object" -msgstr "" +msgstr "Nie można usunąć obiektu" #: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 #, elixir-format msgid "Can't delete this post" -msgstr "" +msgstr "Nie udało się usunąć tego statusu" #: lib/pleroma/web/controller_helper.ex:95 #: lib/pleroma/web/controller_helper.ex:101 #, elixir-format msgid "Can't display this activity" -msgstr "" +msgstr "Nie można wyświetlić tej aktywności" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 #, elixir-format msgid "Can't find user" -msgstr "" +msgstr "Nie znaleziono użytkownika" #: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 #, elixir-format @@ -154,17 +157,17 @@ msgstr "" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 #, elixir-format msgid "Can't like object" -msgstr "" +msgstr "Nie udało się polubić obiektu" #: lib/pleroma/web/common_api/utils.ex:556 #, elixir-format msgid "Cannot post an empty status without attachments" -msgstr "" +msgstr "Nie można opublikować pustego statusu bez załączników" #: lib/pleroma/web/common_api/utils.ex:504 #, elixir-format msgid "Comment must be up to %{max_size} characters" -msgstr "" +msgstr "Komentarz może mieć co najwyżej %{max_size} znaków" #: lib/pleroma/config/config_db.ex:222 #, elixir-format @@ -174,37 +177,37 @@ msgstr "" #: lib/pleroma/web/common_api/common_api.ex:95 #, elixir-format msgid "Could not delete" -msgstr "" +msgstr "Nie udało się usunąć" #: lib/pleroma/web/common_api/common_api.ex:141 #, elixir-format msgid "Could not favorite" -msgstr "" +msgstr "Nie udało się dodać do ulubionych" #: lib/pleroma/web/common_api/common_api.ex:370 #, elixir-format msgid "Could not pin" -msgstr "" +msgstr "Nie udało się przypiąć" #: lib/pleroma/web/common_api/common_api.ex:112 #, elixir-format msgid "Could not repeat" -msgstr "" +msgstr "Nie udało się powtórzyć" #: lib/pleroma/web/common_api/common_api.ex:188 #, elixir-format msgid "Could not unfavorite" -msgstr "" +msgstr "Nie udało się usunąć z ulubionych" #: lib/pleroma/web/common_api/common_api.ex:380 #, elixir-format msgid "Could not unpin" -msgstr "" +msgstr "Nie udało się odpiąć" #: lib/pleroma/web/common_api/common_api.ex:126 #, elixir-format msgid "Could not unrepeat" -msgstr "" +msgstr "Nie udało się cofnąć powtórzenia" #: lib/pleroma/web/common_api/common_api.ex:428 #: lib/pleroma/web/common_api/common_api.ex:437 @@ -246,45 +249,45 @@ msgstr "" #: lib/pleroma/web/common_api/utils.ex:411 #, elixir-format msgid "Invalid password." -msgstr "" +msgstr "Nieprawidłowe hasło." #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 #, elixir-format msgid "Invalid request" -msgstr "" +msgstr "Nieprawidłowe żądanie" #: lib/pleroma/web/twitter_api/twitter_api.ex:109 #, elixir-format msgid "Kocaptcha service unavailable" -msgstr "" +msgstr "Usługa Kocaptcha niedostępna" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 #, elixir-format msgid "Missing parameters" -msgstr "" +msgstr "Brakujące parametry" #: lib/pleroma/web/common_api/utils.ex:540 #, elixir-format msgid "No such conversation" -msgstr "" +msgstr "Nie ma takiej rozmowy" #: lib/pleroma/web/admin_api/admin_api_controller.ex:439 #: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 #, elixir-format msgid "No such permission_group" -msgstr "" +msgstr "Nie ma takiej grupy uprawnień" #: lib/pleroma/plugs/uploaded_media.ex:74 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 #: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 #, elixir-format msgid "Not found" -msgstr "" +msgstr "Nie znaleziono" #: lib/pleroma/web/common_api/common_api.ex:241 #, elixir-format msgid "Poll's author can't vote" -msgstr "" +msgstr "Autor ankiety nie może głosować" #: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 #: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 @@ -292,7 +295,7 @@ msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 #, elixir-format msgid "Record not found" -msgstr "" +msgstr "Nie znaleziono rekordu" #: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 #: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 @@ -309,7 +312,7 @@ msgstr "" #: lib/pleroma/web/common_api/utils.ex:566 #, elixir-format msgid "The status is over the character limit" -msgstr "" +msgstr "Ten status przekracza limit znaków" #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 #, elixir-format @@ -329,18 +332,18 @@ msgstr "" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 #, elixir-format msgid "Unhandled activity type" -msgstr "" +msgstr "Nieobsługiwany typ aktywności" #: lib/pleroma/web/admin_api/admin_api_controller.ex:536 #, elixir-format msgid "You can't revoke your own admin status." -msgstr "" +msgstr "Nie możesz odebrać samemu sobie statusu administratora." #: lib/pleroma/web/oauth/oauth_controller.ex:218 #: lib/pleroma/web/oauth/oauth_controller.ex:309 #, elixir-format msgid "Your account is currently disabled" -msgstr "" +msgstr "Twoje konto jest obecnie nieaktywne" #: lib/pleroma/web/oauth/oauth_controller.ex:180 #: lib/pleroma/web/oauth/oauth_controller.ex:332 @@ -361,43 +364,43 @@ msgstr "" #: lib/pleroma/web/common_api/common_api.ex:388 #, elixir-format msgid "conversation is already muted" -msgstr "" +msgstr "rozmowa jest już wyciszona" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 #, elixir-format msgid "error" -msgstr "" +msgstr "błąd" #: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 #, elixir-format msgid "mascots can only be images" -msgstr "" +msgstr "maskotki muszą być obrazkami" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 #, elixir-format msgid "not found" -msgstr "" +msgstr "nie znaleziono" #: lib/pleroma/web/oauth/oauth_controller.ex:395 #, elixir-format msgid "Bad OAuth request." -msgstr "" +msgstr "Niepoprawne żądanie OAuth." #: lib/pleroma/web/twitter_api/twitter_api.ex:115 #, elixir-format msgid "CAPTCHA already used" -msgstr "" +msgstr "Zużyta CAPTCHA" #: lib/pleroma/web/twitter_api/twitter_api.ex:112 #, elixir-format msgid "CAPTCHA expired" -msgstr "" +msgstr "CAPTCHA wygasła" #: lib/pleroma/plugs/uploaded_media.ex:55 #, elixir-format msgid "Failed" -msgstr "" +msgstr "Nie udało się" #: lib/pleroma/web/oauth/oauth_controller.ex:411 #, elixir-format @@ -412,18 +415,18 @@ msgstr "" #: lib/pleroma/plugs/oauth_scopes_plug.ex:38 #, elixir-format msgid "Insufficient permissions: %{permissions}." -msgstr "" +msgstr "Niewystarczające uprawnienia: %{permissions}." #: lib/pleroma/plugs/uploaded_media.ex:94 #, elixir-format msgid "Internal Error" -msgstr "" +msgstr "Błąd wewnętrzny" #: lib/pleroma/web/oauth/fallback_controller.ex:22 #: lib/pleroma/web/oauth/fallback_controller.ex:29 #, elixir-format msgid "Invalid Username/Password" -msgstr "" +msgstr "Nieprawidłowa nazwa użytkownika lub hasło" #: lib/pleroma/web/twitter_api/twitter_api.ex:118 #, elixir-format @@ -433,7 +436,7 @@ msgstr "" #: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 #, elixir-format msgid "Nodeinfo schema version not handled" -msgstr "" +msgstr "Nieobsługiwana wersja schematu Nodeinfo" #: lib/pleroma/web/oauth/oauth_controller.ex:169 #, elixir-format @@ -443,7 +446,7 @@ msgstr "" #: lib/pleroma/web/oauth/fallback_controller.ex:14 #, elixir-format msgid "Unknown error, please check the details and try again." -msgstr "" +msgstr "Nieznany błąd, sprawdź szczegóły i spróbuj ponownie." #: lib/pleroma/web/oauth/oauth_controller.ex:116 #: lib/pleroma/web/oauth/oauth_controller.ex:155 @@ -454,7 +457,7 @@ msgstr "" #: lib/pleroma/web/oauth/oauth_controller.ex:391 #, elixir-format msgid "Unsupported OAuth provider: %{provider}." -msgstr "" +msgstr "Nieobsługiwany dostawca OAuth: %{provider}." #: lib/pleroma/uploaders/uploader.ex:72 #, elixir-format @@ -464,12 +467,12 @@ msgstr "" #: lib/pleroma/web/uploader_controller.ex:23 #, elixir-format msgid "bad request" -msgstr "" +msgstr "nieprawidłowe żądanie" #: lib/pleroma/web/twitter_api/twitter_api.ex:103 #, elixir-format msgid "CAPTCHA Error" -msgstr "" +msgstr "Błąd CAPTCHA" #: lib/pleroma/web/common_api/common_api.ex:200 #, elixir-format @@ -484,23 +487,23 @@ msgstr "" #: lib/pleroma/web/twitter_api/twitter_api.ex:129 #, elixir-format msgid "Invalid CAPTCHA (Missing parameter: %{name})" -msgstr "" +msgstr "Nieprawidłowa CAPTCHA (Brakujący parametr: %{name})" #: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 #, elixir-format msgid "List not found" -msgstr "" +msgstr "Nie znaleziono listy" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 #, elixir-format msgid "Missing parameter: %{name}" -msgstr "" +msgstr "Brakujący parametr: %{name}" #: lib/pleroma/web/oauth/oauth_controller.ex:207 #: lib/pleroma/web/oauth/oauth_controller.ex:322 #, elixir-format msgid "Password reset is required" -msgstr "" +msgstr "Wymagany reset hasła" #: lib/pleroma/tests/auth_test_controller.ex:9 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 @@ -536,32 +539,32 @@ msgstr "" #: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 #, elixir-format msgid "Two-factor authentication enabled, you must use a access token." -msgstr "" +msgstr "Uwierzytelnienie dwuskładnikowe jest włączone, musisz użyć tokenu." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 #, elixir-format msgid "Unexpected error occurred while adding file to pack." -msgstr "" +msgstr "Nieoczekiwany błąd podczas dodawania pliku do paczki." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 #, elixir-format msgid "Unexpected error occurred while creating pack." -msgstr "" +msgstr "Nieoczekiwany błąd podczas tworzenia paczki." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 #, elixir-format msgid "Unexpected error occurred while removing file from pack." -msgstr "" +msgstr "Nieoczekiwany błąd podczas usuwania pliku z paczki." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 #, elixir-format msgid "Unexpected error occurred while updating file in pack." -msgstr "" +msgstr "Nieoczekiwany błąd podczas zmieniania pliku w paczce." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 #, elixir-format msgid "Unexpected error occurred while updating pack metadata." -msgstr "" +msgstr "Nieoczekiwany błąd podczas zmieniania metadanych paczki." #: lib/pleroma/plugs/user_is_admin_plug.ex:40 #, elixir-format @@ -571,14 +574,14 @@ msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 #, elixir-format msgid "Web push subscription is disabled on this Pleroma instance" -msgstr "" +msgstr "Powiadomienia web push są wyłączone na tej instancji Pleromy" #: lib/pleroma/web/admin_api/admin_api_controller.ex:502 #, elixir-format msgid "You can't revoke your own admin/moderator status." -msgstr "" +msgstr "Nie możesz odebrać samemu sobie statusu administratora/moderatora." #: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 #, elixir-format msgid "authorization required for timeline view" -msgstr "" +msgstr "logowanie wymagane do przeglądania osi czasu" -- cgit v1.2.3 From 0d074751b57f24d8489f559a535e6f98ab9ed4ce Mon Sep 17 00:00:00 2001 From: Michał Sidor Date: Thu, 14 May 2020 11:19:12 +0000 Subject: Translated using Weblate (Polish) Currently translated at 61.3% (65 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/pl/ --- priv/gettext/pl/LC_MESSAGES/errors.po | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/priv/gettext/pl/LC_MESSAGES/errors.po b/priv/gettext/pl/LC_MESSAGES/errors.po index fae8ef82a..af9e214c6 100644 --- a/priv/gettext/pl/LC_MESSAGES/errors.po +++ b/priv/gettext/pl/LC_MESSAGES/errors.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-13 16:37+0000\n" -"PO-Revision-Date: 2020-05-13 18:57+0000\n" +"PO-Revision-Date: 2020-05-14 14:37+0000\n" "Last-Translator: Michał Sidor \n" "Language-Team: Polish \n" @@ -26,23 +26,23 @@ msgstr "" ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "nie może być pusty" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "jest już zajęty" ## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "jest nieprawidłowy" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" -msgstr "" +msgstr "ma niepoprawny format" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "ma niepoprawny wpis" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" @@ -302,7 +302,7 @@ msgstr "Nie znaleziono rekordu" #: lib/pleroma/web/ostatus/ostatus_controller.ex:149 #, elixir-format msgid "Something went wrong" -msgstr "" +msgstr "Coś się zepsuło" #: lib/pleroma/web/common_api/activity_draft.ex:107 #, elixir-format -- cgit v1.2.3 From e090191d03b21020a75c1ef91a200c3e4807c2d1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 15 May 2020 14:55:41 +0400 Subject: [OpenAPI] Mark all not required request fields as nullable --- .../web/api_spec/operations/account_operation.ex | 54 ++++++++++++++++++---- .../web/api_spec/operations/app_operation.ex | 6 ++- .../web/api_spec/operations/filter_operation.ex | 6 ++- .../web/api_spec/operations/marker_operation.ex | 6 ++- .../operations/pleroma_account_operation.ex | 2 + .../web/api_spec/operations/report_operation.ex | 3 ++ .../web/api_spec/operations/status_operation.ex | 26 ++++++++++- .../api_spec/operations/subscription_operation.ex | 54 ++++++++++++++++++---- 8 files changed, 134 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 70069d6f9..988bab882 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -367,15 +367,18 @@ defp create_request do title: "AccountCreateRequest", description: "POST body for creating an account", type: :object, + required: [:username, :password, :agreement], properties: %{ reason: %Schema{ type: :string, + nullable: true, description: "Text that will be reviewed by moderators if registrations require manual approval" }, username: %Schema{type: :string, description: "The desired username for the account"}, email: %Schema{ type: :string, + nullable: true, description: "The email address to be used for login. Required when `account_activation_required` is enabled.", format: :email @@ -392,23 +395,33 @@ defp create_request do }, locale: %Schema{ type: :string, + nullable: true, description: "The language of the confirmation email that will be sent" }, # Pleroma-specific properties: - fullname: %Schema{type: :string, description: "Full name"}, - bio: %Schema{type: :string, description: "Bio", default: ""}, + fullname: %Schema{type: :string, nullable: true, description: "Full name"}, + bio: %Schema{type: :string, description: "Bio", nullable: true, default: ""}, captcha_solution: %Schema{ type: :string, + nullable: true, description: "Provider-specific captcha solution" }, - captcha_token: %Schema{type: :string, description: "Provider-specific captcha token"}, - captcha_answer_data: %Schema{type: :string, description: "Provider-specific captcha data"}, + captcha_token: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha token" + }, + captcha_answer_data: %Schema{ + type: :string, + nullable: true, + description: "Provider-specific captcha data" + }, token: %Schema{ type: :string, + nullable: true, description: "Invite token required when the registrations aren't public" } }, - required: [:username, :password, :agreement], example: %{ "username" => "cofe", "email" => "cofe@example.com", @@ -447,28 +460,34 @@ defp update_creadentials_request do properties: %{ bot: %Schema{ type: :boolean, + nullable: true, description: "Whether the account has a bot flag." }, display_name: %Schema{ type: :string, + nullable: true, description: "The display name to use for the profile." }, note: %Schema{type: :string, description: "The account bio."}, avatar: %Schema{ type: :string, + nullable: true, description: "Avatar image encoded using multipart/form-data", format: :binary }, header: %Schema{ type: :string, + nullable: true, description: "Header image encoded using multipart/form-data", format: :binary }, locked: %Schema{ type: :boolean, + nullable: true, description: "Whether manual approval of follow requests is required." }, fields_attributes: %Schema{ + nullable: true, oneOf: [ %Schema{type: :array, items: attribute_field()}, %Schema{type: :object, additionalProperties: %Schema{type: attribute_field()}} @@ -488,47 +507,65 @@ defp update_creadentials_request do # Pleroma-specific fields no_rich_text: %Schema{ type: :boolean, + nullable: true, description: "html tags are stripped from all statuses requested from the API" }, - hide_followers: %Schema{type: :boolean, description: "user's followers will be hidden"}, - hide_follows: %Schema{type: :boolean, description: "user's follows will be hidden"}, + hide_followers: %Schema{ + type: :boolean, + nullable: true, + description: "user's followers will be hidden" + }, + hide_follows: %Schema{ + type: :boolean, + nullable: true, + description: "user's follows will be hidden" + }, hide_followers_count: %Schema{ type: :boolean, + nullable: true, description: "user's follower count will be hidden" }, hide_follows_count: %Schema{ type: :boolean, + nullable: true, description: "user's follow count will be hidden" }, hide_favorites: %Schema{ type: :boolean, + nullable: true, description: "user's favorites timeline will be hidden" }, show_role: %Schema{ type: :boolean, + nullable: true, description: "user's role (e.g admin, moderator) will be exposed to anyone in the API" }, default_scope: VisibilityScope, pleroma_settings_store: %Schema{ type: :object, + nullable: true, description: "Opaque user settings to be saved on the backend." }, skip_thread_containment: %Schema{ type: :boolean, + nullable: true, description: "Skip filtering out broken threads" }, allow_following_move: %Schema{ type: :boolean, + nullable: true, description: "Allows automatically follow moved following accounts" }, pleroma_background_image: %Schema{ type: :string, + nullable: true, description: "Sets the background image of the user.", format: :binary }, discoverable: %Schema{ type: :boolean, + nullable: true, description: "Discovery of this account in search results and other services is allowed." }, @@ -624,7 +661,7 @@ defp follow_by_uri_request do description: "POST body for muting an account", type: :object, properties: %{ - uri: %Schema{type: :string, format: :uri} + uri: %Schema{type: :string, nullable: true, format: :uri} }, required: [:uri] } @@ -638,6 +675,7 @@ defp mute_request do properties: %{ notifications: %Schema{ type: :boolean, + nullable: true, description: "Mute notifications in addition to statuses? Defaults to true.", default: true } diff --git a/lib/pleroma/web/api_spec/operations/app_operation.ex b/lib/pleroma/web/api_spec/operations/app_operation.ex index f6ccd073f..ae01cbbec 100644 --- a/lib/pleroma/web/api_spec/operations/app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/app_operation.ex @@ -105,7 +105,11 @@ defp create_request do description: "Space separated list of scopes", default: "read" }, - website: %Schema{type: :string, description: "A URL to the homepage of your app"} + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of your app" + } }, required: [:client_name, :redirect_uris], example: %{ diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex index 53e57b46b..7310c1c4d 100644 --- a/lib/pleroma/web/api_spec/operations/filter_operation.ex +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -199,12 +199,14 @@ defp update_request do "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." }, irreversible: %Schema{ - type: :bolean, + type: :boolean, + nullable: true, description: "Should the server irreversibly drop matching entities from home and notifications?" }, whole_word: %Schema{ - type: :bolean, + type: :boolean, + nullable: true, description: "Consider word boundaries?", default: true } diff --git a/lib/pleroma/web/api_spec/operations/marker_operation.ex b/lib/pleroma/web/api_spec/operations/marker_operation.ex index 06620492a..714ef1f99 100644 --- a/lib/pleroma/web/api_spec/operations/marker_operation.ex +++ b/lib/pleroma/web/api_spec/operations/marker_operation.ex @@ -110,14 +110,16 @@ defp upsert_request do properties: %{ notifications: %Schema{ type: :object, + nullable: true, properties: %{ - last_read_id: %Schema{type: :string} + last_read_id: %Schema{nullable: true, type: :string} } }, home: %Schema{ type: :object, + nullable: true, properties: %{ - last_read_id: %Schema{type: :string} + last_read_id: %Schema{nullable: true, type: :string} } } }, diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index 435991037..90922c064 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -143,6 +143,7 @@ defp update_avatar_or_background_request do type: :object, properties: %{ img: %Schema{ + nullable: true, type: :string, format: :binary, description: "Image encoded using `multipart/form-data` or an empty string to clear" @@ -158,6 +159,7 @@ defp update_banner_request do properties: %{ banner: %Schema{ type: :string, + nullable: true, format: :binary, description: "Image encoded using `multipart/form-data` or an empty string to clear" } diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex index da4d50703..882177c96 100644 --- a/lib/pleroma/web/api_spec/operations/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -37,15 +37,18 @@ defp create_request do account_id: %Schema{type: :string, description: "ID of the account to report"}, status_ids: %Schema{ type: :array, + nullable: true, items: %Schema{type: :string}, description: "Array of Statuses to attach to the report, for context" }, comment: %Schema{ type: :string, + nullable: true, description: "Reason for the report" }, forward: %Schema{ type: :boolean, + nullable: true, default: false, description: "If the account is remote, should the report be forwarded to the remote admin?" diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 561db3bce..fc2909d8c 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -371,15 +371,18 @@ defp create_request do properties: %{ status: %Schema{ type: :string, + nullable: true, description: "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided." }, media_ids: %Schema{ + nullable: true, type: :array, items: %Schema{type: :string}, description: "Array of Attachment ids to be attached as media." }, poll: %Schema{ + nullable: true, type: :object, required: [:options], properties: %{ @@ -390,26 +393,35 @@ defp create_request do }, expires_in: %Schema{ type: :integer, + nullable: true, description: "Duration the poll should be open, in seconds. Must be provided with `poll[options]`" }, - multiple: %Schema{type: :boolean, description: "Allow multiple choices?"}, + multiple: %Schema{ + type: :boolean, + nullable: true, + description: "Allow multiple choices?" + }, hide_totals: %Schema{ type: :boolean, + nullable: true, description: "Hide vote counts until the poll ends?" } } }, in_reply_to_id: %Schema{ + nullable: true, allOf: [FlakeID], description: "ID of the status being replied to, if status is a reply" }, sensitive: %Schema{ type: :boolean, + nullable: true, description: "Mark status and attached media as sensitive?" }, spoiler_text: %Schema{ type: :string, + nullable: true, description: "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field." }, @@ -420,25 +432,33 @@ defp create_request do description: "ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future." }, - language: %Schema{type: :string, description: "ISO 639 language code for this status."}, + language: %Schema{ + type: :string, + nullable: true, + description: "ISO 639 language code for this status." + }, # Pleroma-specific properties: preview: %Schema{ type: :boolean, + nullable: true, description: "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" }, content_type: %Schema{ type: :string, + nullable: true, description: "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint." }, to: %Schema{ type: :array, + nullable: true, items: %Schema{type: :string}, description: "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply" }, visibility: %Schema{ + nullable: true, anyOf: [ VisibilityScope, %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"} @@ -447,11 +467,13 @@ defp create_request do "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`" }, expires_in: %Schema{ + nullable: true, type: :integer, description: "The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour." }, in_reply_to_conversation_id: %Schema{ + nullable: true, type: :string, description: "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`." diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index 663b8fa11..cf6dcb068 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -109,19 +109,38 @@ defp create_request do required: [:endpoint, :keys] }, data: %Schema{ + nullable: true, type: :object, properties: %{ alerts: %Schema{ + nullable: true, type: :object, properties: %{ - follow: %Schema{type: :boolean, description: "Receive follow notifications?"}, + follow: %Schema{ + type: :boolean, + nullable: true, + description: "Receive follow notifications?" + }, favourite: %Schema{ type: :boolean, + nullable: true, description: "Receive favourite notifications?" }, - reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"}, - mention: %Schema{type: :boolean, description: "Receive mention notifications?"}, - poll: %Schema{type: :boolean, description: "Receive poll notifications?"} + reblog: %Schema{ + type: :boolean, + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + type: :boolean, + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + type: :boolean, + nullable: true, + description: "Receive poll notifications?" + } } } } @@ -154,19 +173,38 @@ defp update_request do type: :object, properties: %{ data: %Schema{ + nullable: true, type: :object, properties: %{ alerts: %Schema{ + nullable: true, type: :object, properties: %{ - follow: %Schema{type: :boolean, description: "Receive follow notifications?"}, + follow: %Schema{ + type: :boolean, + nullable: true, + description: "Receive follow notifications?" + }, favourite: %Schema{ type: :boolean, + nullable: true, description: "Receive favourite notifications?" }, - reblog: %Schema{type: :boolean, description: "Receive reblog notifications?"}, - mention: %Schema{type: :boolean, description: "Receive mention notifications?"}, - poll: %Schema{type: :boolean, description: "Receive poll notifications?"} + reblog: %Schema{ + type: :boolean, + nullable: true, + description: "Receive reblog notifications?" + }, + mention: %Schema{ + type: :boolean, + nullable: true, + description: "Receive mention notifications?" + }, + poll: %Schema{ + type: :boolean, + nullable: true, + description: "Receive poll notifications?" + } } } } -- cgit v1.2.3 From 1d18721a3c60aa0acc7d1ba858a92277e544a54a Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 15 May 2020 13:18:41 +0200 Subject: Chats: Add updated_at to Schema and docs. --- docs/API/chats.md | 9 ++++++--- lib/pleroma/web/api_spec/schemas/chat.ex | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 1ea18ff5f..2e415e4da 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -68,7 +68,8 @@ Returned data: }, "id" : "1", "unread" : 2, - "last_message" : {...} // The last message in that chat + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -88,7 +89,8 @@ Returned data: ... }, "id" : "1", - "unread" : 0 + "unread" : 0, + "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -112,7 +114,8 @@ Returned data: }, "id" : "1", "unread" : 2, - "last_message" : {...} // The last message in that chat + "last_message" : {...}, // The last message in that chat + "updated_at": "2020-04-21T15:11:46.000Z" } ] ``` diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index c6ec07c88..b4986b734 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -16,7 +16,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do id: %Schema{type: :string}, account: %Schema{type: :object}, unread: %Schema{type: :integer}, - last_message: ChatMessage + last_message: ChatMessage, + updated_at: %Schema{type: :string, format: :"date-time"} }, example: %{ "account" => %{ @@ -67,7 +68,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do }, "id" => "1", "unread" => 2, - "last_message" => ChatMessage.schema().example() + "last_message" => ChatMessage.schema().example(), + "updated_at" => "2020-04-21T15:06:45.000Z" } }) end -- cgit v1.2.3 From 3c29f4f957dbfa4bf7b914ecc1680bfa71bb7621 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 15 May 2020 16:16:02 +0300 Subject: returning partial chain --- lib/pleroma/config/config_db.ex | 11 +++++++++++ test/config/config_db_test.exs | 8 ++++++++ test/web/admin_api/admin_api_controller_test.exs | 3 +++ 3 files changed, 22 insertions(+) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 4097ee5b7..2b43d4c36 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -278,6 +278,8 @@ 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 @@ -321,6 +323,15 @@ 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/test/config/config_db_test.exs b/test/config/config_db_test.exs index a8e947365..336de7359 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -476,6 +476,14 @@ 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/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 9b7120712..370d876d0 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -2509,6 +2509,7 @@ 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"]}, @@ -2532,6 +2533,7 @@ 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"]}, @@ -2544,6 +2546,7 @@ test "common config example", %{conn: conn} do ":seconds_valid", ":path", ":key1", + ":partial_chain", ":regex1", ":regex2", ":regex3", -- cgit v1.2.3 From cb40602a167f4637dc6df6633ec2dfe33f774177 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 15 May 2020 21:34:46 +0300 Subject: added media proxy invalidation --- config/config.exs | 7 ++++ lib/pleroma/object.ex | 34 ++++++++++----- lib/pleroma/web/media_proxy/invalidation.ex | 19 +++++++++ lib/pleroma/web/media_proxy/invalidations/nginx.ex | 12 ++++++ .../web/media_proxy/invalidations/script.ex | 10 +++++ lib/pleroma/workers/attachments_cleanup_worker.ex | 49 +++++++++++++--------- 6 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 lib/pleroma/web/media_proxy/invalidation.ex create mode 100644 lib/pleroma/web/media_proxy/invalidations/nginx.ex create mode 100644 lib/pleroma/web/media_proxy/invalidations/script.ex diff --git a/config/config.exs b/config/config.exs index e703c1632..5394c7c7a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -378,6 +378,13 @@ config :pleroma, :media_proxy, enabled: false, + invalidation: [ + enabled: false, + provider: Pleroma.Web.MediaProxy.Invalidation.Script, + options: %{ + script_path: "" + } + ], proxy_opts: [ redirect_on_failure: false, max_body_length: 25 * 1_048_576, diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index e678fd415..66b233498 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -9,11 +9,13 @@ defmodule Pleroma.Object do import Ecto.Changeset alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Object alias Pleroma.Object.Fetcher alias Pleroma.ObjectTombstone alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Workers.AttachmentsCleanupWorker require Logger @@ -183,27 +185,37 @@ def swap_object_with_tombstone(object) do def delete(%Object{data: %{"id" => id}} = object) do with {:ok, _obj} = swap_object_with_tombstone(object), deleted_activity = Activity.delete_all_by_object_ap_id(id), - {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), - {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do - with true <- Pleroma.Config.get([:instance, :cleanup_attachments]) do - {:ok, _} = - Pleroma.Workers.AttachmentsCleanupWorker.enqueue("cleanup_attachments", %{ - "object" => object - }) - end + {:ok, _} <- invalid_object_cache(object) do + cleanup_attachments( + Config.get([:instance, :cleanup_attachments]), + %{"object" => object} + ) {:ok, object, deleted_activity} end end - def prune(%Object{data: %{"id" => id}} = object) do + @spec cleanup_attachments(boolean(), %{required(:object) => map()}) :: + {:ok, Oban.Job.t() | nil} + def cleanup_attachments(true, %{"object" => _} = params) do + AttachmentsCleanupWorker.enqueue("cleanup_attachments", params) + end + + def cleanup_attachments(_, _), do: {:ok, nil} + + def prune(%Object{data: %{"id" => _id}} = object) do with {:ok, object} <- Repo.delete(object), - {:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), - {:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do + {:ok, _} <- invalid_object_cache(object) do {:ok, object} end end + def invalid_object_cache(%Object{data: %{"id" => id}}) do + with {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do + Cachex.del(:web_resp_cache, URI.parse(id).path) + end + end + def set_cache(%Object{data: %{"id" => ap_id}} = object) do Cachex.put(:object_cache, "object:#{ap_id}", object) {:ok, object} diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex new file mode 100644 index 000000000..dd9a53a27 --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -0,0 +1,19 @@ +defmodule Pleroma.Web.MediaProxy.Invalidation do + @callback purge(list(String.t()), map()) :: {:ok, String.t()} | {:error, String.t()} + + alias Pleroma.Config + + def purge(urls) do + [:media_proxy, :invalidation, :enabled] + |> Config.get() + |> do_purge(urls) + end + + defp do_purge(true, urls) do + config = Config.get([:media_proxy, :invalidation]) + config[:provider].purge(urls, config[:options]) + :ok + end + + defp do_purge(_, _), do: :ok +end diff --git a/lib/pleroma/web/media_proxy/invalidations/nginx.ex b/lib/pleroma/web/media_proxy/invalidations/nginx.ex new file mode 100644 index 000000000..5bfdd505c --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidations/nginx.ex @@ -0,0 +1,12 @@ +defmodule Pleroma.Web.MediaProxy.Invalidation.Nginx do + @behaviour Pleroma.Web.MediaProxy.Invalidation + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, _opts) do + Enum.each(urls, fn url -> + Pleroma.HTTP.request(:purge, url, "", [], []) + end) + + {:ok, "success"} + end +end diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex new file mode 100644 index 000000000..f458845a0 --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -0,0 +1,10 @@ +defmodule Pleroma.Web.MediaProxy.Invalidation.Script do + @behaviour Pleroma.Web.MediaProxy.Invalidation + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, %{script_path: script_path} = options) do + script_args = List.wrap(Map.get(options, :script_args, [])) + System.cmd(Path.expand(script_path), [urls] ++ script_args) + {:ok, "success"} + end +end diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex index 3c5820a86..49352db2a 100644 --- a/lib/pleroma/workers/attachments_cleanup_worker.ex +++ b/lib/pleroma/workers/attachments_cleanup_worker.ex @@ -27,8 +27,20 @@ def perform( uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) + prefix = + case Pleroma.Config.get([Pleroma.Upload, :base_url]) do + nil -> "media" + _ -> "" + end + + base_url = + String.trim_trailing( + Pleroma.Config.get([Pleroma.Upload, :base_url], Pleroma.Web.base_url()), + "/" + ) + # find all objects for copies of the attachments, name and actor doesn't matter here - delete_ids = + object_ids_and_hrefs = from(o in Object, where: fragment( @@ -67,29 +79,28 @@ def perform( |> Enum.map(fn {href, %{id: id, count: count}} -> # only delete files that have single instance with 1 <- count do - prefix = - case Pleroma.Config.get([Pleroma.Upload, :base_url]) do - nil -> "media" - _ -> "" - end - - base_url = - String.trim_trailing( - Pleroma.Config.get([Pleroma.Upload, :base_url], Pleroma.Web.base_url()), - "/" - ) - - file_path = String.trim_leading(href, "#{base_url}/#{prefix}") + href + |> String.trim_leading("#{base_url}/#{prefix}") + |> uploader.delete_file() - uploader.delete_file(file_path) + {id, href} + else + _ -> {id, nil} end - - id end) - from(o in Object, where: o.id in ^delete_ids) + object_ids = Enum.map(object_ids_and_hrefs, fn {id, _} -> id end) + + from(o in Object, where: o.id in ^object_ids) |> Repo.delete_all() + + object_ids_and_hrefs + |> Enum.filter(fn {_, href} -> not is_nil(href) end) + |> Enum.map(&elem(&1, 1)) + |> Pleroma.Web.MediaProxy.Invalidation.purge() + + {:ok, :success} end - def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: :ok + def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: {:ok, :skip} end -- cgit v1.2.3 From 2dcb26a6e52b18c62aaa1ef464d94685732496ab Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 16 May 2020 12:28:24 +0200 Subject: CommonAPI: Unblock a user even if we don't have an activity. --- lib/pleroma/web/common_api/common_api.ex | 13 ++++++++++++- test/web/common_api/common_api_test.exs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 601caeb46..7c94f16b6 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -25,10 +25,21 @@ defmodule Pleroma.Web.CommonAPI do require Logger def unblock(blocker, blocked) do - with %Activity{} = block <- Utils.fetch_latest_block(blocker, blocked), + with {_, %Activity{} = block} <- {:fetch_block, Utils.fetch_latest_block(blocker, blocked)}, {:ok, unblock_data, _} <- Builder.undo(blocker, block), {:ok, unblock, _} <- Pipeline.common_pipeline(unblock_data, local: true) do {:ok, unblock} + else + {:fetch_block, nil} -> + if User.blocks?(blocker, blocked) do + User.unblock(blocker, blocked) + {:ok, :no_activity} + else + {:error, :not_blocking} + end + + e -> + e end end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 26e41c313..fd8299013 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -23,6 +23,18 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + describe "unblocking" do + test "it works even without an existing block activity" do + blocked = insert(:user) + blocker = insert(:user) + User.block(blocker, blocked) + + assert User.blocks?(blocker, blocked) + assert {:ok, :no_activity} == CommonAPI.unblock(blocker, blocked) + refute User.blocks?(blocker, blocked) + end + end + describe "deletion" do test "it works with pruned objects" do user = insert(:user) -- cgit v1.2.3 From 4a925b964ac500a7be4b2e8c8bc10ce050c2b2e6 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Sat, 16 May 2020 20:39:42 +0300 Subject: Remove description of the settings that should't be altered --- config/description.exs | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/config/description.exs b/config/description.exs index 36ec3d40a..ff2b3c029 100644 --- a/config/description.exs +++ b/config/description.exs @@ -679,15 +679,6 @@ 7 ] }, - %{ - key: :federation_publisher_modules, - type: {:list, :module}, - description: - "List of modules for federation publishing. Module names are shortened (removed leading `Pleroma.Web.` part), but on adding custom module you need to use full name.", - suggestions: [ - Pleroma.Web.ActivityPub.Publisher - ] - }, %{ key: :allow_relay, type: :boolean, @@ -1858,12 +1849,6 @@ (see https://github.com/sorentwo/oban/issues/52). """, children: [ - %{ - key: :repo, - type: :module, - description: "Application's Ecto repo", - suggestions: [Pleroma.Repo] - }, %{ key: :verbose, type: {:dropdown, :atom}, @@ -2638,18 +2623,6 @@ } ] }, - %{ - group: :http_signatures, - type: :group, - description: "HTTP Signatures settings", - children: [ - %{ - key: :adapter, - type: :module, - suggestions: [Pleroma.Signature] - } - ] - }, %{ group: :pleroma, key: :http, -- cgit v1.2.3 From 3f8d68bdf3224cd6023b3d7f8e64221222872820 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Sat, 16 May 2020 15:16:33 +0300 Subject: added example cache purge script --- config/config.exs | 2 +- installation/nginx-cache-purge.example | 39 ++++++++++++++++++++++ lib/pleroma/web/media_proxy/invalidations/http.ex | 16 +++++++++ lib/pleroma/web/media_proxy/invalidations/nginx.ex | 12 ------- .../web/media_proxy/invalidations/script.ex | 11 ++++-- 5 files changed, 64 insertions(+), 16 deletions(-) create mode 100755 installation/nginx-cache-purge.example create mode 100644 lib/pleroma/web/media_proxy/invalidations/http.ex delete mode 100644 lib/pleroma/web/media_proxy/invalidations/nginx.ex diff --git a/config/config.exs b/config/config.exs index 5394c7c7a..882d25069 100644 --- a/config/config.exs +++ b/config/config.exs @@ -382,7 +382,7 @@ enabled: false, provider: Pleroma.Web.MediaProxy.Invalidation.Script, options: %{ - script_path: "" + script_path: "./installation/nginx-cache-purge.example" } ], proxy_opts: [ diff --git a/installation/nginx-cache-purge.example b/installation/nginx-cache-purge.example new file mode 100755 index 000000000..12dfa733c --- /dev/null +++ b/installation/nginx-cache-purge.example @@ -0,0 +1,39 @@ +#!/bin/bash + +# A simple Bash script to delete an media from the Nginx cache. + +SCRIPTNAME=${0##*/} + +# NGINX cache directory +CACHE_DIRECTORY="/tmp/pleroma-media-cache" + +function get_cache_files() { + local max_parallel=${3-16} + find $2 -maxdepth 1 -type d | xargs -P $max_parallel -n 1 grep -ERl "^KEY:.*$1" | sort -u +} + +function purge_item() { + local cache_files + cache_files=$(get_cache_files "$1" "$2") + + if [ -n "$cache_files" ]; then + for i in $cache_files; do + [ -f $i ] || continue + echo "Deleting $i from $2." + rm $i + done + else + echo "$1 is not cached." + fi +} + +function purge() { + for url in "$@" + do + echo "$SCRIPTNAME delete $url from cache ($CACHE_DIRECTORY)" + purge_item $url $CACHE_DIRECTORY + done + +} + +purge $1 diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex new file mode 100644 index 000000000..40c624efc --- /dev/null +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -0,0 +1,16 @@ +defmodule Pleroma.Web.MediaProxy.Invalidation.Http do + @behaviour Pleroma.Web.MediaProxy.Invalidation + + @impl Pleroma.Web.MediaProxy.Invalidation + def purge(urls, opts) do + method = Map.get(opts, :http_method, :purge) + headers = Map.get(opts, :http_headers, []) + options = Map.get(opts, :http_options, []) + + Enum.each(urls, fn url -> + Pleroma.HTTP.request(method, url, "", headers, options) + end) + + {:ok, "success"} + end +end diff --git a/lib/pleroma/web/media_proxy/invalidations/nginx.ex b/lib/pleroma/web/media_proxy/invalidations/nginx.ex deleted file mode 100644 index 5bfdd505c..000000000 --- a/lib/pleroma/web/media_proxy/invalidations/nginx.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Pleroma.Web.MediaProxy.Invalidation.Nginx do - @behaviour Pleroma.Web.MediaProxy.Invalidation - - @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, _opts) do - Enum.each(urls, fn url -> - Pleroma.HTTP.request(:purge, url, "", [], []) - end) - - {:ok, "success"} - end -end diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index f458845a0..94c79511a 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -2,9 +2,14 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do @behaviour Pleroma.Web.MediaProxy.Invalidation @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, %{script_path: script_path} = options) do - script_args = List.wrap(Map.get(options, :script_args, [])) - System.cmd(Path.expand(script_path), [urls] ++ script_args) + def purge(urls, %{script_path: script_path} = _options) do + args = + urls + |> List.wrap() + |> Enum.uniq() + |> Enum.join(" ") + + System.cmd(Path.expand(script_path), [args]) {:ok, "success"} end end -- cgit v1.2.3 From af9dfdce6b502d3a33db7a496879dda56719f56e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 17 May 2020 08:46:43 +0300 Subject: MediaController OAuth scope assignments fix. Typo fix (`def get_media` instead of `def show`). --- .../web/mastodon_api/controllers/media_controller.ex | 6 ++++-- .../web/mastodon_api/controllers/media_controller_test.exs | 14 +++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index a21233393..afa8b2ea2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -14,7 +14,8 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) - plug(OAuthScopesPlug, %{scopes: ["write:media"]}) + plug(OAuthScopesPlug, %{scopes: ["read:media"]} when action == :show) + plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action != :show) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MediaOperation @@ -65,6 +66,7 @@ def update(%{assigns: %{user: user}, body_params: %{description: description}} = def update(conn, data), do: show(conn, data) + # TODO: clarify: is the access to non-owned objects granted intentionally? @doc "GET /api/v1/media/:id" def show(conn, %{id: id}) do with %Object{data: data, id: object_id} <- Object.get_by_id(id) do @@ -74,5 +76,5 @@ def show(conn, %{id: id}) do end end - def get_media(_conn, _data), do: {:error, :bad_request} + def show(_conn, _data), do: {:error, :bad_request} end diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs index 7ba1727f2..98ec239b1 100644 --- a/test/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -9,9 +9,9 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - setup do: oauth_access(["write:media"]) - describe "Upload media" do + setup do: oauth_access(["write:media"]) + setup do image = %Plug.Upload{ content_type: "image/jpg", @@ -42,7 +42,7 @@ test "/api/v1/media", %{conn: conn, image: image} do assert object.data["actor"] == User.ap_id(conn.assigns[:user]) end - test "/api/v2/media", %{conn: conn, image: image} do + test "/api/v2/media", %{conn: conn, user: user, image: image} do desc = "Description of the image" response = @@ -53,6 +53,8 @@ test "/api/v2/media", %{conn: conn, image: image} do assert media_id = response["id"] + %{conn: conn} = oauth_access(["read:media"], user: user) + media = conn |> get("/api/v1/media/#{media_id}") @@ -62,11 +64,15 @@ test "/api/v2/media", %{conn: conn, image: image} do assert media["description"] == desc assert media["id"] object = Object.get_by_id(media["id"]) + + # TODO: clarify: if this EP allows access to non-owned objects, the following may be false: assert object.data["actor"] == User.ap_id(conn.assigns[:user]) end end describe "Update media description" do + setup do: oauth_access(["write:media"]) + setup %{user: actor} do file = %Plug.Upload{ content_type: "image/jpg", @@ -97,6 +103,8 @@ test "/api/v1/media/:id good request", %{conn: conn, object: object} do end describe "Get media by id" do + setup do: oauth_access(["read:media"]) + setup %{user: actor} do file = %Plug.Upload{ content_type: "image/jpg", -- cgit v1.2.3 From 5725e03f55f6d60b0f97f470c486d70485356a60 Mon Sep 17 00:00:00 2001 From: Fristi Date: Fri, 15 May 2020 09:37:44 +0000 Subject: Added translation using Weblate (Dutch) --- priv/gettext/nl/LC_MESSAGES/errors.po | 578 ++++++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 priv/gettext/nl/LC_MESSAGES/errors.po diff --git a/priv/gettext/nl/LC_MESSAGES/errors.po b/priv/gettext/nl/LC_MESSAGES/errors.po new file mode 100644 index 000000000..7e12ff96c --- /dev/null +++ b/priv/gettext/nl/LC_MESSAGES/errors.po @@ -0,0 +1,578 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-05-15 09:37+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 2.5.1\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:421 +#, elixir-format +msgid "Account not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:249 +#, elixir-format +msgid "Already voted" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:360 +#, elixir-format +msgid "Bad request" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 +#, elixir-format +msgid "Can't delete object" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 +#, elixir-format +msgid "Can't delete this post" +msgstr "" + +#: lib/pleroma/web/controller_helper.ex:95 +#: lib/pleroma/web/controller_helper.ex:101 +#, elixir-format +msgid "Can't display this activity" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 +#, elixir-format +msgid "Can't find user" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 +#, elixir-format +msgid "Can't get favorites" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 +#, elixir-format +msgid "Can't like object" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:556 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:504 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "" + +#: lib/pleroma/config/config_db.ex:222 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:95 +#, elixir-format +msgid "Could not delete" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:141 +#, elixir-format +msgid "Could not favorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:370 +#, elixir-format +msgid "Could not pin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:112 +#, elixir-format +msgid "Could not repeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:188 +#, elixir-format +msgid "Could not unfavorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:380 +#, elixir-format +msgid "Could not unpin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:126 +#, elixir-format +msgid "Could not unrepeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:428 +#: lib/pleroma/web/common_api/common_api.ex:437 +#, elixir-format +msgid "Could not update state" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 +#, elixir-format +msgid "Error." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 +#: lib/pleroma/web/oauth/oauth_controller.ex:569 +#, elixir-format +msgid "Invalid credentials" +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:265 +#, elixir-format +msgid "Invalid indices" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 +#, elixir-format +msgid "Invalid parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:411 +#, elixir-format +msgid "Invalid password." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 +#, elixir-format +msgid "Invalid request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 +#, elixir-format +msgid "Missing parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:540 +#, elixir-format +msgid "No such conversation" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:439 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 +#, elixir-format +msgid "No such permission_group" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:74 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 +#: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:241 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:290 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 +#: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:566 +#, elixir-format +msgid "The status is over the character limit" +msgstr "" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "" + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:266 +#, elixir-format +msgid "Too many choices" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 +#, elixir-format +msgid "Unhandled activity type" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:536 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:218 +#: lib/pleroma/web/oauth/oauth_controller.ex:309 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:180 +#: lib/pleroma/web/oauth/oauth_controller.ex:332 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:388 +#, elixir-format +msgid "conversation is already muted" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 +#, elixir-format +msgid "error" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 +#, elixir-format +msgid "mascots can only be images" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 +#, elixir-format +msgid "not found" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:395 +#, elixir-format +msgid "Bad OAuth request." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:55 +#, elixir-format +msgid "Failed" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:411 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:442 +#, elixir-format +msgid "Failed to set up user account." +msgstr "" + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:94 +#, elixir-format +msgid "Internal Error" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:169 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:155 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:391 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "" + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:200 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:211 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:207 +#: lib/pleroma/web/oauth/oauth_controller.ex:322 +#, elixir-format +msgid "Password reset is required" +msgstr "" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/fallback_redirect_controller.ex:6 +#: lib/pleroma/web/feed/tag_controller.ex:6 lib/pleroma/web/feed/user_controller.ex:6 +#: lib/pleroma/web/mailer/subscription_controller.ex:2 lib/pleroma/web/masto_fe_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 +#: lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 +#: lib/pleroma/web/oauth/fallback_controller.ex:6 lib/pleroma/web/oauth/mfa_controller.ex:10 +#: lib/pleroma/web/oauth/oauth_controller.ex:6 lib/pleroma/web/ostatus/ostatus_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:2 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#, elixir-format +msgid "User is not an admin or OAuth admin scope is not granted." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:502 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "" -- cgit v1.2.3 From 4b6190baecd74724332cdd5af3bfd7e18e8bb1ec Mon Sep 17 00:00:00 2001 From: Jędrzej Tomaszewski Date: Sat, 16 May 2020 14:06:36 +0000 Subject: Translated using Weblate (Polish) Currently translated at 65.0% (69 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/pl/ --- priv/gettext/pl/LC_MESSAGES/errors.po | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/priv/gettext/pl/LC_MESSAGES/errors.po b/priv/gettext/pl/LC_MESSAGES/errors.po index af9e214c6..7bc39c52a 100644 --- a/priv/gettext/pl/LC_MESSAGES/errors.po +++ b/priv/gettext/pl/LC_MESSAGES/errors.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-13 16:37+0000\n" -"PO-Revision-Date: 2020-05-14 14:37+0000\n" -"Last-Translator: Michał Sidor \n" +"PO-Revision-Date: 2020-05-16 17:13+0000\n" +"Last-Translator: Jędrzej Tomaszewski \n" "Language-Team: Polish \n" "Language: pl\n" @@ -46,7 +46,7 @@ msgstr "ma niepoprawny wpis" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "jest zarezerwowany" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" @@ -54,17 +54,17 @@ msgstr "" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -msgstr "" +msgstr "jest wciąż powiązane z tym wpisem" msgid "are still associated with this entry" -msgstr "" +msgstr "są wciąż powiązane z tym wpisem" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "powinno mieć %{count} znak" +msgstr[1] "powinno mieć %{count} znaki" +msgstr[2] "powinno mieć %{count} znaków" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -- cgit v1.2.3 From baef35bcc8685757b0039f76d2614bbb08e410f7 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 17 May 2020 10:31:01 +0200 Subject: Authentication Plug: Update bcrypt password on login. --- lib/pleroma/plugs/authentication_plug.ex | 13 +++++++++++++ test/plugs/authentication_plug_test.exs | 18 ++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index 2cdf6c951..7d7da6125 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -30,6 +30,17 @@ def checkpw(_password, _password_hash) do false end + def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do + user + |> User.password_update_changeset(%{ + "password" => password, + "password_confirmation" => password + }) + |> Pleroma.Repo.update() + end + + def maybe_update_password(user, _), do: {:ok, user} + def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call( @@ -42,6 +53,8 @@ def call( _ ) do if checkpw(password, password_hash) do + {:ok, auth_user} = maybe_update_password(auth_user, password) + conn |> assign(:user, auth_user) |> OAuthScopesPlug.skip_plug() diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs index c8ede71c0..2c793b29a 100644 --- a/test/plugs/authentication_plug_test.exs +++ b/test/plugs/authentication_plug_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Plugs.AuthenticationPlugTest do alias Pleroma.User import ExUnit.CaptureLog + import Pleroma.Factory setup %{conn: conn} do user = %User{ @@ -50,16 +51,21 @@ test "with a correct password in the credentials, " <> assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end - test "with a wrong password in the credentials, it does nothing", %{conn: conn} do - conn = - conn - |> assign(:auth_credentials, %{password: "wrong"}) + test "with a bcrypt hash, it updates to a pkbdf2 hash", %{conn: conn} do + user = insert(:user, password_hash: Bcrypt.hash_pwd_salt("123")) + assert "$2" <> _ = user.password_hash - ret_conn = + conn = conn + |> assign(:auth_user, user) + |> assign(:auth_credentials, %{password: "123"}) |> AuthenticationPlug.call(%{}) - assert conn == ret_conn + assert conn.assigns.user.id == conn.assigns.auth_user.id + assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) + + user = User.get_by_id(user.id) + assert "$pbkdf2" <> _ = user.password_hash end describe "checkpw/2" do -- cgit v1.2.3 From bfdd90f6d7c9bb85e572033070d6fa7efda8aeac Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 17 May 2020 11:40:25 +0200 Subject: AuthenticationPlug: Also update crypt passwords. --- lib/pleroma/plugs/authentication_plug.ex | 12 ++++++++++-- test/plugs/authentication_plug_test.exs | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/plugs/authentication_plug.ex b/lib/pleroma/plugs/authentication_plug.ex index 7d7da6125..057ea42f1 100644 --- a/lib/pleroma/plugs/authentication_plug.ex +++ b/lib/pleroma/plugs/authentication_plug.ex @@ -31,6 +31,16 @@ def checkpw(_password, _password_hash) do end def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(user, _), do: {:ok, user} + + defp do_update_password(user, password) do user |> User.password_update_changeset(%{ "password" => password, @@ -39,8 +49,6 @@ def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do |> Pleroma.Repo.update() end - def maybe_update_password(user, _), do: {:ok, user} - def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call( diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs index 2c793b29a..3c70c1747 100644 --- a/test/plugs/authentication_plug_test.exs +++ b/test/plugs/authentication_plug_test.exs @@ -68,6 +68,26 @@ test "with a bcrypt hash, it updates to a pkbdf2 hash", %{conn: conn} do assert "$pbkdf2" <> _ = user.password_hash end + test "with a crypt hash, it updates to a pkbdf2 hash", %{conn: conn} do + user = + insert(:user, + password_hash: + "$6$9psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1" + ) + + conn = + conn + |> assign(:auth_user, user) + |> assign(:auth_credentials, %{password: "password"}) + |> AuthenticationPlug.call(%{}) + + assert conn.assigns.user.id == conn.assigns.auth_user.id + assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) + + user = User.get_by_id(user.id) + assert "$pbkdf2" <> _ = user.password_hash + end + describe "checkpw/2" do test "check pbkdf2 hash" do hash = -- cgit v1.2.3 From 8bfd9710ae70204b29e184f08d78b95a2f81ad6c Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 17 May 2020 11:53:17 +0200 Subject: Pleroma Authenticator: Also update passwords here. --- lib/pleroma/web/auth/pleroma_authenticator.ex | 3 ++- test/web/auth/pleroma_authenticator_test.exs | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index a8f554aa3..200ca03dc 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -16,7 +16,8 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do def get_user(%Plug.Conn{} = conn) do with {:ok, {name, password}} <- fetch_credentials(conn), {_, %User{} = user} <- {:user, fetch_user(name)}, - {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do + {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)}, + {:ok, user} <- AuthenticationPlug.maybe_update_password(user, password) do {:ok, user} else {:error, _reason} = error -> error diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs index 5a421e5ed..731bd5932 100644 --- a/test/web/auth/pleroma_authenticator_test.exs +++ b/test/web/auth/pleroma_authenticator_test.exs @@ -15,11 +15,16 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do {:ok, [user: user, name: name, password: password]} end - test "get_user/authorization", %{user: user, name: name, password: password} do + test "get_user/authorization", %{name: name, password: password} do + name = name <> "1" + user = insert(:user, nickname: name, password_hash: Bcrypt.hash_pwd_salt(password)) + params = %{"authorization" => %{"name" => name, "password" => password}} res = PleromaAuthenticator.get_user(%Plug.Conn{params: params}) - assert {:ok, user} == res + assert {:ok, returned_user} = res + assert returned_user.id == user.id + assert "$pbkdf2" <> _ = returned_user.password_hash end test "get_user/authorization with invalid password", %{name: name} do -- cgit v1.2.3 From baf051a59e8bfcb2e55b5e28e46e80d6961b9bb4 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 17 May 2020 12:22:26 +0200 Subject: SideEffects: Don't update unread count for actor in chatmessages. --- lib/pleroma/web/activity_pub/side_effects.ex | 6 +++++- test/web/activity_pub/side_effects_test.exs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index c8b675d54..8e64b4615 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -117,7 +117,11 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do [[actor, recipient], [recipient, actor]] |> Enum.each(fn [user, other_user] -> if user.local do - Chat.bump_or_create(user.id, other_user.ap_id) + if user.ap_id == actor.ap_id do + Chat.get_or_create(user.id, other_user.ap_id) + else + Chat.bump_or_create(user.id, other_user.ap_id) + end end end) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 148fa4442..37d7491ca 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -284,6 +284,27 @@ test "notifies the recipient" do assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) end + test "it creates a Chat for the local users and bumps the unread count, except for the author" do + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + chat = Chat.get(author.id, recipient.ap_id) + assert chat.unread == 0 + + chat = Chat.get(recipient.id, author.ap_id) + assert chat.unread == 1 + end + test "it creates a Chat for the local users and bumps the unread count" do author = insert(:user, local: false) recipient = insert(:user, local: true) -- cgit v1.2.3 From c33a4315fb09e67d0ed5f644877054a3fb7b1fe1 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 18 May 2020 06:48:19 +0300 Subject: updated docs --- config/config.exs | 5 +---- docs/configuration/cheatsheet.md | 20 ++++++++++++++++++++ lib/pleroma/web/media_proxy/invalidation.ex | 5 +++-- lib/pleroma/web/media_proxy/invalidations/http.ex | 6 +++--- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/config/config.exs b/config/config.exs index 882d25069..25cf2a9b9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -380,10 +380,7 @@ enabled: false, invalidation: [ enabled: false, - provider: Pleroma.Web.MediaProxy.Invalidation.Script, - options: %{ - script_path: "./installation/nginx-cache-purge.example" - } + provider: Pleroma.Web.MediaProxy.Invalidation.Script ], proxy_opts: [ redirect_on_failure: false, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 1078c4e87..aaea3f46c 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -249,6 +249,26 @@ This section describe PWA manifest instance-specific values. Currently this opti * `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts. * `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`. * `whitelist`: List of domains to bypass the mediaproxy +* `invalidation`: options for remove media from cache after delete object: + * `enabled`: Enables purge cache + * `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use. + +### Purge cache strategy + +#### Pleroma.Web.MediaProxy.Invalidation.Script + +This strategy allow perform external bash script to purge cache. +Urls of attachments pass to script as arguments. + +* `script_path`: path to external script. + +#### Pleroma.Web.MediaProxy.Invalidation.Http + +This strategy allow perform custom http request to purge cache. + +* `method`: http method. default is `purge` +* `headers`: http headers. default is empty +* `options`: request options. default is empty ## Link previews diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index dd9a53a27..371aa8ae0 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -10,8 +10,9 @@ def purge(urls) do end defp do_purge(true, urls) do - config = Config.get([:media_proxy, :invalidation]) - config[:provider].purge(urls, config[:options]) + provider = Config.get([:media_proxy, :invalidation, :provider]) + options = Config.get(provider) + provider.purge(urls, options) :ok end diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 40c624efc..66fafa7ba 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -3,9 +3,9 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do @impl Pleroma.Web.MediaProxy.Invalidation def purge(urls, opts) do - method = Map.get(opts, :http_method, :purge) - headers = Map.get(opts, :http_headers, []) - options = Map.get(opts, :http_options, []) + method = Map.get(opts, :method, :purge) + headers = Map.get(opts, :headers, []) + options = Map.get(opts, :options, []) Enum.each(urls, fn url -> Pleroma.HTTP.request(method, url, "", headers, options) -- cgit v1.2.3 From 9b765652649f8b6110bd70aa90b148a90057ff6a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 18 May 2020 09:51:53 +0300 Subject: MediaController: enforced owner-only access in :show action. Improved error response on denied access (now 403). Adjusted tests. --- lib/pleroma/object.ex | 15 ++++++++++----- .../mastodon_api/controllers/fallback_controller.ex | 4 ++++ .../web/mastodon_api/controllers/media_controller.ex | 8 ++++---- .../controllers/media_controller_test.exs | 20 +++++++++++++++----- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index e678fd415..ab16bf2db 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -138,12 +138,17 @@ def normalize(ap_id, true, options) when is_binary(ap_id) do def normalize(_, _, _), do: nil - # Owned objects can only be mutated by their owner - def authorize_mutation(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}), - do: actor == ap_id + # Owned objects can only be accessed by their owner + def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do + if actor == ap_id do + :ok + else + {:error, :forbidden} + end + end - # Legacy objects can be mutated by anybody - def authorize_mutation(%Object{}, %User{}), do: true + # Legacy objects can be accessed by anybody + def authorize_access(%Object{}, %User{}), do: :ok @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil def get_cached_by_ap_id(ap_id) do diff --git a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex index 0a257f604..8af557b61 100644 --- a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex @@ -20,6 +20,10 @@ def call(conn, {:error, :not_found}) do render_error(conn, :not_found, "Record not found") end + def call(conn, {:error, :forbidden}) do + render_error(conn, :forbidden, "Access denied") + end + def call(conn, {:error, error_message}) do conn |> put_status(:bad_request) diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index afa8b2ea2..513de279f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -56,7 +56,7 @@ def create2(_conn, _data), do: {:error, :bad_request} @doc "PUT /api/v1/media/:id" def update(%{assigns: %{user: user}, body_params: %{description: description}} = conn, %{id: id}) do with %Object{} = object <- Object.get_by_id(id), - true <- Object.authorize_mutation(object, user), + :ok <- Object.authorize_access(object, user), {:ok, %Object{data: data}} <- Object.update_data(object, %{"name" => description}) do attachment_data = Map.put(data, "id", object.id) @@ -66,10 +66,10 @@ def update(%{assigns: %{user: user}, body_params: %{description: description}} = def update(conn, data), do: show(conn, data) - # TODO: clarify: is the access to non-owned objects granted intentionally? @doc "GET /api/v1/media/:id" - def show(conn, %{id: id}) do - with %Object{data: data, id: object_id} <- Object.get_by_id(id) do + def show(%{assigns: %{user: user}} = conn, %{id: id}) do + with %Object{data: data, id: object_id} = object <- Object.get_by_id(id), + :ok <- Object.authorize_access(object, user) do attachment_data = Map.put(data, "id", object_id) render(conn, "attachment.json", %{attachment: attachment_data}) diff --git a/test/web/mastodon_api/controllers/media_controller_test.exs b/test/web/mastodon_api/controllers/media_controller_test.exs index 98ec239b1..906fd940f 100644 --- a/test/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/web/mastodon_api/controllers/media_controller_test.exs @@ -63,10 +63,9 @@ test "/api/v2/media", %{conn: conn, user: user, image: image} do assert media["type"] == "image" assert media["description"] == desc assert media["id"] - object = Object.get_by_id(media["id"]) - # TODO: clarify: if this EP allows access to non-owned objects, the following may be false: - assert object.data["actor"] == User.ap_id(conn.assigns[:user]) + object = Object.get_by_id(media["id"]) + assert object.data["actor"] == user.ap_id end end @@ -102,7 +101,7 @@ test "/api/v1/media/:id good request", %{conn: conn, object: object} do end end - describe "Get media by id" do + describe "Get media by id (/api/v1/media/:id)" do setup do: oauth_access(["read:media"]) setup %{user: actor} do @@ -122,7 +121,7 @@ test "/api/v1/media/:id good request", %{conn: conn, object: object} do [object: object] end - test "/api/v1/media/:id", %{conn: conn, object: object} do + test "it returns media object when requested by owner", %{conn: conn, object: object} do media = conn |> get("/api/v1/media/#{object.id}") @@ -132,5 +131,16 @@ test "/api/v1/media/:id", %{conn: conn, object: object} do assert media["type"] == "image" assert media["id"] end + + test "it returns 403 if media object requested by non-owner", %{object: object, user: user} do + %{conn: conn, user: other_user} = oauth_access(["read:media"]) + + assert object.data["actor"] == user.ap_id + refute user.id == other_user.id + + conn + |> get("/api/v1/media/#{object.id}") + |> json_response(403) + end end end -- cgit v1.2.3 From e7bc2f980cce170731960e024614c497b821fe90 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 7 May 2020 13:44:38 +0300 Subject: account visibility --- lib/pleroma/user.ex | 50 +++++++++++------- .../web/api_spec/operations/account_operation.ex | 8 ++- .../mastodon_api/controllers/account_controller.ex | 21 ++++++-- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- test/user_test.exs | 10 ++-- .../controllers/account_controller_test.exs | 59 ++++++++++++++++------ 6 files changed, 104 insertions(+), 46 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index cba391072..7a2558c29 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -262,37 +262,51 @@ def account_status(%User{deactivated: true}), do: :deactivated def account_status(%User{password_reset_pending: true}), do: :password_reset_pending def account_status(%User{confirmation_pending: true}) do - case Config.get([:instance, :account_activation_required]) do - true -> :confirmation_pending - _ -> :active + if Config.get([:instance, :account_activation_required]) do + :confirmation_pending + else + :active end end def account_status(%User{}), do: :active - @spec visible_for?(User.t(), User.t() | nil) :: boolean() - def visible_for?(user, for_user \\ nil) + @spec visible_for(User.t(), User.t() | nil) :: + boolean() + | :invisible + | :restricted_unauthenticated + | :deactivated + | :confirmation_pending + def visible_for(user, for_user \\ nil) - def visible_for?(%User{invisible: true}, _), do: false + def visible_for(%User{invisible: true}, _), do: :invisible - def visible_for?(%User{id: user_id}, %User{id: user_id}), do: true + def visible_for(%User{id: user_id}, %User{id: user_id}), do: true - def visible_for?(%User{local: local} = user, nil) do - cfg_key = - if local, - do: :local, - else: :remote + def visible_for(%User{} = user, nil) do + if restrict_unauthenticated?(user) do + :restrict_unauthenticated + else + visible_account_status(user) + end + end - if Config.get([:restrict_unauthenticated, :profiles, cfg_key]), - do: false, - else: account_status(user) == :active + def visible_for(%User{} = user, for_user) do + superuser?(for_user) || visible_account_status(user) end - def visible_for?(%User{} = user, for_user) do - account_status(user) == :active || superuser?(for_user) + def visible_for(_, _), do: false + + defp restrict_unauthenticated?(%User{local: local}) do + config_key = if local, do: :local, else: :remote + + Config.get([:restrict_unauthenticated, :profiles, config_key], false) end - def visible_for?(_, _), do: false + defp visible_account_status(user) do + status = account_status(user) + status in [:active, :password_reset_pending] || status + end @spec superuser?(User.t()) :: boolean() def superuser?(%User{local: true, is_admin: true}), do: true diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 934f6038e..43168acf7 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -102,7 +102,9 @@ def show_operation do parameters: [%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}], responses: %{ 200 => Operation.response("Account", "application/json", Account), - 404 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError), + 410 => Operation.response("Error", "application/json", ApiError) } } end @@ -142,7 +144,9 @@ def statuses_operation do ] ++ pagination_params(), responses: %{ 200 => Operation.response("Statuses", "application/json", array_of_statuses()), - 404 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError), + 410 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index ef41f9e96..ffa82731f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -221,17 +221,17 @@ def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) @doc "GET /api/v1/accounts/:id" def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), - true <- User.visible_for?(user, for_user) do + true <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, for: for_user) else - _e -> render_error(conn, :not_found, "Can't find user") + error -> user_visibility_error(conn, error) end end @doc "GET /api/v1/accounts/:id/statuses" def statuses(%{assigns: %{user: reading_user}} = conn, params) do with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user), - true <- User.visible_for?(user, reading_user) do + true <- User.visible_for(user, reading_user) do params = params |> Map.delete(:tagged) @@ -250,7 +250,20 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do as: :activity ) else - _e -> render_error(conn, :not_found, "Can't find user") + error -> user_visibility_error(conn, error) + end + end + + defp user_visibility_error(conn, error) do + case error do + :deactivated -> + render_error(conn, :gone, "") + + :restrict_unauthenticated -> + render_error(conn, :unauthorized, "This API requires an authenticated user") + + _ -> + render_error(conn, :not_found, "Can't find user") 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 45fffaad2..8e723d013 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -35,7 +35,7 @@ def render("index.json", %{users: users} = opts) do end def render("show.json", %{user: user} = opts) do - if User.visible_for?(user, opts[:for]) do + if User.visible_for(user, opts[:for]) == true do do_render("show.json", opts) else %{} diff --git a/test/user_test.exs b/test/user_test.exs index 6b9df60a4..3bfcfd10c 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1289,11 +1289,11 @@ test "returns false for a non-invisible user" do end end - describe "visible_for?/2" do + describe "visible_for/2" do test "returns true when the account is itself" do user = insert(:user, local: true) - assert User.visible_for?(user, user) + assert User.visible_for(user, user) end test "returns false when the account is unauthenticated and auth is required" do @@ -1302,14 +1302,14 @@ test "returns false when the account is unauthenticated and auth is required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - refute User.visible_for?(user, other_user) + refute User.visible_for(user, other_user) == true end test "returns true when the account is unauthenticated and auth is not required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - assert User.visible_for?(user, other_user) + assert User.visible_for(user, other_user) end test "returns true when the account is unauthenticated and being viewed by a privileged account (auth required)" do @@ -1318,7 +1318,7 @@ test "returns true when the account is unauthenticated and being viewed by a pri user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true, is_admin: true) - assert User.visible_for?(user, other_user) + assert User.visible_for(user, other_user) 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 280bd6aca..7dfea2f9e 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -127,6 +127,15 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do |> get("/api/v1/accounts/internal.fetch") |> json_response_and_validate_schema(404) end + + test "returns 401 for deactivated user", %{conn: conn} do + user = insert(:user, deactivated: true) + + assert %{} = + conn + |> get("/api/v1/accounts/#{user.id}") + |> json_response_and_validate_schema(:gone) + end end defp local_and_remote_users do @@ -143,15 +152,15 @@ defp local_and_remote_users do setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{local.id}") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{remote.id}") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) end test "if user is authenticated", %{local: local, remote: remote} do @@ -173,8 +182,8 @@ test "if user is authenticated", %{local: local, remote: remote} do test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do res_conn = get(conn, "/api/v1/accounts/#{local.id}") - assert json_response_and_validate_schema(res_conn, :not_found) == %{ - "error" => "Can't find user" + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ + "error" => "This API requires an authenticated user" } res_conn = get(conn, "/api/v1/accounts/#{remote.id}") @@ -203,8 +212,8 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d res_conn = get(conn, "/api/v1/accounts/#{remote.id}") - assert json_response_and_validate_schema(res_conn, :not_found) == %{ - "error" => "Can't find user" + assert json_response_and_validate_schema(res_conn, :unauthorized) == %{ + "error" => "This API requires an authenticated user" } end @@ -249,6 +258,24 @@ test "works with announces that are just addressed to public", %{conn: conn} do assert id == announce.id end + test "deactivated user", %{conn: conn} do + user = insert(:user, deactivated: true) + + assert %{} == + conn + |> get("/api/v1/accounts/#{user.id}/statuses") + |> json_response_and_validate_schema(:gone) + end + + test "returns 404 when user is invisible", %{conn: conn} do + user = insert(:user, %{invisible: true}) + + assert %{"error" => "Can't find user"} = + conn + |> get("/api/v1/accounts/#{user.id}") + |> json_response_and_validate_schema(404) + end + test "respects blocks", %{user: user_one, conn: conn} do user_two = insert(:user) user_three = insert(:user) @@ -422,15 +449,15 @@ defp local_and_remote_activities(%{local: local, remote: remote}) do setup do: clear_config([:restrict_unauthenticated, :profiles, :remote], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{local.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{remote.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) end test "if user is authenticated", %{local: local, remote: remote} do @@ -451,10 +478,10 @@ test "if user is authenticated", %{local: local, remote: remote} do setup do: clear_config([:restrict_unauthenticated, :profiles, :local], true) test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} do - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{local.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) res_conn = get(conn, "/api/v1/accounts/#{remote.id}/statuses") assert length(json_response_and_validate_schema(res_conn, 200)) == 1 @@ -481,10 +508,10 @@ test "if user is unauthenticated", %{conn: conn, local: local, remote: remote} d res_conn = get(conn, "/api/v1/accounts/#{local.id}/statuses") assert length(json_response_and_validate_schema(res_conn, 200)) == 1 - assert %{"error" => "Can't find user"} == + assert %{"error" => "This API requires an authenticated user"} == conn |> get("/api/v1/accounts/#{remote.id}/statuses") - |> json_response_and_validate_schema(:not_found) + |> json_response_and_validate_schema(:unauthorized) end test "if user is authenticated", %{local: local, remote: remote} do -- cgit v1.2.3 From b1aa402229b6422a5ab1aa7102c7a104e218d0e3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 13 May 2020 11:11:10 +0300 Subject: removing 410 status --- lib/pleroma/web/api_spec/operations/account_operation.ex | 6 ++---- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 3 --- test/web/mastodon_api/controllers/account_controller_test.exs | 8 ++++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 43168acf7..74b395dfe 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -103,8 +103,7 @@ def show_operation do responses: %{ 200 => Operation.response("Account", "application/json", Account), 401 => Operation.response("Error", "application/json", ApiError), - 404 => Operation.response("Error", "application/json", ApiError), - 410 => Operation.response("Error", "application/json", ApiError) + 404 => Operation.response("Error", "application/json", ApiError) } } end @@ -145,8 +144,7 @@ def statuses_operation do responses: %{ 200 => Operation.response("Statuses", "application/json", array_of_statuses()), 401 => Operation.response("Error", "application/json", ApiError), - 404 => Operation.response("Error", "application/json", ApiError), - 410 => Operation.response("Error", "application/json", ApiError) + 404 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index ffa82731f..1edc0d96a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -256,9 +256,6 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do defp user_visibility_error(conn, error) do case error do - :deactivated -> - render_error(conn, :gone, "") - :restrict_unauthenticated -> render_error(conn, :unauthorized, "This API requires an authenticated user") diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 7dfea2f9e..8700ab2f5 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -131,10 +131,10 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do test "returns 401 for deactivated user", %{conn: conn} do user = insert(:user, deactivated: true) - assert %{} = + assert %{"error" => "Can't find user"} = conn |> get("/api/v1/accounts/#{user.id}") - |> json_response_and_validate_schema(:gone) + |> json_response_and_validate_schema(:not_found) end end @@ -261,10 +261,10 @@ test "works with announces that are just addressed to public", %{conn: conn} do test "deactivated user", %{conn: conn} do user = insert(:user, deactivated: true) - assert %{} == + assert %{"error" => "Can't find user"} == conn |> get("/api/v1/accounts/#{user.id}/statuses") - |> json_response_and_validate_schema(:gone) + |> json_response_and_validate_schema(:not_found) end test "returns 404 when user is invisible", %{conn: conn} do -- cgit v1.2.3 From 1671864d886bf63d11bbf3d7303719e8744bfc32 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 15 May 2020 20:29:09 +0300 Subject: return :visible instead of boolean --- lib/pleroma/user.ex | 19 ++++++++++++++----- .../mastodon_api/controllers/account_controller.ex | 4 ++-- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- test/user_test.exs | 8 ++++---- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7a2558c29..5052f7b97 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -272,7 +272,7 @@ def account_status(%User{confirmation_pending: true}) do def account_status(%User{}), do: :active @spec visible_for(User.t(), User.t() | nil) :: - boolean() + :visible | :invisible | :restricted_unauthenticated | :deactivated @@ -281,7 +281,7 @@ def visible_for(user, for_user \\ nil) def visible_for(%User{invisible: true}, _), do: :invisible - def visible_for(%User{id: user_id}, %User{id: user_id}), do: true + def visible_for(%User{id: user_id}, %User{id: user_id}), do: :visible def visible_for(%User{} = user, nil) do if restrict_unauthenticated?(user) do @@ -292,10 +292,14 @@ def visible_for(%User{} = user, nil) do end def visible_for(%User{} = user, for_user) do - superuser?(for_user) || visible_account_status(user) + if superuser?(for_user) do + :visible + else + visible_account_status(user) + end end - def visible_for(_, _), do: false + def visible_for(_, _), do: :invisible defp restrict_unauthenticated?(%User{local: local}) do config_key = if local, do: :local, else: :remote @@ -305,7 +309,12 @@ defp restrict_unauthenticated?(%User{local: local}) do defp visible_account_status(user) do status = account_status(user) - status in [:active, :password_reset_pending] || status + + if status in [:active, :password_reset_pending] do + :visible + else + status + end end @spec superuser?(User.t()) :: boolean() diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 1edc0d96a..8727faab7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -221,7 +221,7 @@ def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, []) @doc "GET /api/v1/accounts/:id" def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user), - true <- User.visible_for(user, for_user) do + :visible <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, for: for_user) else error -> user_visibility_error(conn, error) @@ -231,7 +231,7 @@ def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id}) do @doc "GET /api/v1/accounts/:id/statuses" def statuses(%{assigns: %{user: reading_user}} = conn, params) do with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user), - true <- User.visible_for(user, reading_user) do + :visible <- User.visible_for(user, reading_user) do params = params |> Map.delete(:tagged) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 8e723d013..4a1508b22 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -35,7 +35,7 @@ def render("index.json", %{users: users} = opts) do end def render("show.json", %{user: user} = opts) do - if User.visible_for(user, opts[:for]) == true do + if User.visible_for(user, opts[:for]) == :visible do do_render("show.json", opts) else %{} diff --git a/test/user_test.exs b/test/user_test.exs index 3bfcfd10c..6865bd9be 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1293,7 +1293,7 @@ test "returns false for a non-invisible user" do test "returns true when the account is itself" do user = insert(:user, local: true) - assert User.visible_for(user, user) + assert User.visible_for(user, user) == :visible end test "returns false when the account is unauthenticated and auth is required" do @@ -1302,14 +1302,14 @@ test "returns false when the account is unauthenticated and auth is required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - refute User.visible_for(user, other_user) == true + refute User.visible_for(user, other_user) == :visible end test "returns true when the account is unauthenticated and auth is not required" do user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true) - assert User.visible_for(user, other_user) + assert User.visible_for(user, other_user) == :visible end test "returns true when the account is unauthenticated and being viewed by a privileged account (auth required)" do @@ -1318,7 +1318,7 @@ test "returns true when the account is unauthenticated and being viewed by a pri user = insert(:user, local: true, confirmation_pending: true) other_user = insert(:user, local: true, is_admin: true) - assert User.visible_for(user, other_user) + assert User.visible_for(user, other_user) == :visible end end -- cgit v1.2.3 From 0321a3e07814c3f225f19e0372b69a7813cef15e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 18 May 2020 10:34:34 +0300 Subject: test naming fix --- test/web/mastodon_api/controllers/account_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 8700ab2f5..3008970af 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -128,7 +128,7 @@ test "returns 404 for internal.fetch actor", %{conn: conn} do |> json_response_and_validate_schema(404) end - test "returns 401 for deactivated user", %{conn: conn} do + test "returns 404 for deactivated user", %{conn: conn} do user = insert(:user, deactivated: true) assert %{"error" => "Can't find user"} = -- cgit v1.2.3 From 1be6b3056e97654612f377eaf3c8d80de6d8d77f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Mon, 18 May 2020 12:38:16 +0300 Subject: Use indexed split_part/3 to get a hostname rather than ts_ functions --- priv/repo/migrations/20200508092434_update_counter_cache_table.exs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs index 3d9bfc877..738344868 100644 --- a/priv/repo/migrations/20200508092434_update_counter_cache_table.exs +++ b/priv/repo/migrations/20200508092434_update_counter_cache_table.exs @@ -25,22 +25,17 @@ def up do RETURNS TRIGGER AS $$ DECLARE - token_id smallint; hostname character varying(255); visibility_new character varying(64); visibility_old character varying(64); actor character varying(255); BEGIN - SELECT "tokid" INTO "token_id" FROM ts_token_type('default') WHERE "alias" = 'host'; IF TG_OP = 'DELETE' THEN actor := OLD.actor; ELSE actor := NEW.actor; END IF; - SELECT "token" INTO "hostname" FROM ts_parse('default', actor) WHERE "tokid" = token_id; - IF hostname IS NULL THEN - hostname := split_part(actor, '/', 3); - END IF; + hostname := split_part(actor, '/', 3); IF TG_OP = 'INSERT' THEN visibility_new := activity_visibility(NEW.actor, NEW.recipients, NEW.data); IF NEW.data->>'type' = 'Create' -- cgit v1.2.3 From 188b32145e0a97411878a11bd4f8ad8bc9cc1d9a Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 13:28:50 +0200 Subject: InstanceView: Expose background image link. This will make it easier for more clients to support this feature. --- config/config.exs | 1 + lib/pleroma/web/mastodon_api/views/instance_view.ex | 1 + test/web/mastodon_api/controllers/instance_controller_test.exs | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index c51884f3a..6b4f3b38a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -183,6 +183,7 @@ email: "example@example.com", notify_email: "noreply@example.com", description: "A Pleroma instance, an alternative fediverse server", + background_image: "/images/city.jpg", limit: 5_000, chat_limit: 5_000, remote_limit: 100_000, diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index a329ffc28..8088306c3 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -33,6 +33,7 @@ def render("show.json", _) do avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), background_upload_limit: Keyword.get(instance, :background_upload_limit), banner_upload_limit: Keyword.get(instance, :banner_upload_limit), + background_image: Keyword.get(instance, :background_image), pleroma: %{ metadata: %{ features: features(), diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 2c61dc5ba..8bdfdddd1 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -31,7 +31,8 @@ test "get instance information", %{conn: conn} do "upload_limit" => _, "avatar_upload_limit" => _, "background_upload_limit" => _, - "banner_upload_limit" => _ + "banner_upload_limit" => _, + "background_image" => _ } = result assert result["pleroma"]["metadata"]["features"] -- cgit v1.2.3 From 7b500d6b4fdad59c7242dc41646bd0bb5cc04e67 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 13:56:58 +0200 Subject: Load Testing: adjust to new CommonAPI.post format. --- benchmarks/load_testing/activities.ex | 41 +++++++++++++++++------------------ 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index 482e42fc1..ff0d481a8 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -123,7 +123,7 @@ def generate_tagged_activities(opts \\ []) do 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}"}) + CommonAPI.post(Enum.random(users), %{status: "a post with the tag #tag_#{i}"}) end) end @@ -137,8 +137,8 @@ defp generate_long_thread(visibility, user, friends, non_friends, _opts) do {:ok, activity} = CommonAPI.post(user, %{ - "status" => "Start of #{visibility} long thread", - "visibility" => visibility + status: "Start of #{visibility} long thread", + visibility: visibility }) Agent.update(:benchmark_state, fn state -> @@ -186,7 +186,7 @@ defp insert_activity("simple", visibility, group, user, friends, non_friends, _o {:ok, _activity} = group |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{"status" => "Simple status", "visibility" => visibility}) + |> CommonAPI.post(%{status: "Simple status", visibility: visibility}) end defp insert_activity("emoji", visibility, group, user, friends, non_friends, _opts) do @@ -194,8 +194,8 @@ defp insert_activity("emoji", visibility, group, user, friends, non_friends, _op group |> get_actor(user, friends, non_friends) |> CommonAPI.post(%{ - "status" => "Simple status with emoji :firefox:", - "visibility" => visibility + status: "Simple status with emoji :firefox:", + visibility: visibility }) end @@ -213,8 +213,8 @@ defp insert_activity("mentions", visibility, group, user, friends, non_friends, group |> get_actor(user, friends, non_friends) |> CommonAPI.post(%{ - "status" => Enum.join(user_mentions, ", ") <> " simple status with mentions", - "visibility" => visibility + status: Enum.join(user_mentions, ", ") <> " simple status with mentions", + visibility: visibility }) end @@ -236,8 +236,8 @@ defp insert_activity("hell_thread", visibility, group, user, friends, non_friend group |> get_actor(user, friends, non_friends) |> CommonAPI.post(%{ - "status" => mentions <> " hell thread status", - "visibility" => visibility + status: mentions <> " hell thread status", + visibility: visibility }) end @@ -262,9 +262,9 @@ defp insert_activity("attachment", visibility, group, user, friends, non_friends {:ok, _activity} = CommonAPI.post(actor, %{ - "status" => "Post with attachment", - "visibility" => visibility, - "media_ids" => [object.id] + status: "Post with attachment", + visibility: visibility, + media_ids: [object.id] }) end @@ -272,7 +272,7 @@ defp insert_activity("tag", visibility, group, user, friends, non_friends, _opts {:ok, _activity} = group |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{"status" => "Status with #tag", "visibility" => visibility}) + |> CommonAPI.post(%{status: "Status with #tag", visibility: visibility}) end defp insert_activity("like", visibility, group, user, friends, non_friends, opts) do @@ -312,8 +312,7 @@ defp insert_activity("simple_thread", visibility, group, user, friends, non_frie actor = get_actor(group, user, friends, non_friends) tasks = get_reply_tasks(visibility, group) - {:ok, activity} = - CommonAPI.post(user, %{"status" => "Simple status", "visibility" => visibility}) + {:ok, activity} = CommonAPI.post(user, %{status: "Simple status", visibility: visibility}) acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} insert_replies(tasks, visibility, user, friends, non_friends, acc) @@ -336,8 +335,8 @@ defp insert_activity("simple_thread", "direct", group, user, friends, non_friend {:ok, activity} = CommonAPI.post(actor, %{ - "status" => Enum.join(data, ", ") <> "simple status", - "visibility" => "direct" + status: Enum.join(data, ", ") <> "simple status", + visibility: "direct" }) acc = {activity.id, ["@" <> user.nickname | data] ++ ["reply to status"]} @@ -527,9 +526,9 @@ defp insert_direct_replies(tasks, user, list, acc) do 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 + status: Enum.join(data, ", "), + visibility: visibility, + in_reply_to_status_id: activity_id }) {reply.id, ["@" <> actor.nickname | data]} -- cgit v1.2.3 From 215daabdb4441ca6620366cc06c6827c8dc69bc5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 18 May 2020 15:15:51 +0300 Subject: copyright update --- lib/pleroma/mfa.ex | 2 +- lib/pleroma/mfa/backup_codes.ex | 2 +- lib/pleroma/mfa/changeset.ex | 2 +- lib/pleroma/mfa/settings.ex | 2 +- lib/pleroma/mfa/token.ex | 2 +- lib/pleroma/mfa/totp.ex | 2 +- lib/pleroma/web/auth/totp_authenticator.ex | 2 +- lib/pleroma/web/oauth/mfa_controller.ex | 2 +- lib/pleroma/web/oauth/mfa_view.ex | 2 +- lib/pleroma/web/oauth/token/clean_worker.ex | 2 +- .../web/pleroma_api/controllers/two_factor_authentication_controller.ex | 2 +- test/web/auth/pleroma_authenticator_test.exs | 2 +- test/web/auth/totp_authenticator_test.exs | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex index 2b77f5426..01b743f4f 100644 --- a/lib/pleroma/mfa.ex +++ b/lib/pleroma/mfa.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.MFA do diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex index 2b5ec34f8..9875310ff 100644 --- a/lib/pleroma/mfa/backup_codes.ex +++ b/lib/pleroma/mfa/backup_codes.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.MFA.BackupCodes do diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex index 9b020aa8e..77c4fa202 100644 --- a/lib/pleroma/mfa/changeset.ex +++ b/lib/pleroma/mfa/changeset.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.MFA.Changeset do diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex index 2764b889c..de6e2228f 100644 --- a/lib/pleroma/mfa/settings.ex +++ b/lib/pleroma/mfa/settings.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.MFA.Settings do diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex index 25ff7fb29..0b2449971 100644 --- a/lib/pleroma/mfa/token.ex +++ b/lib/pleroma/mfa/token.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.MFA.Token do diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex index 1407afc57..d2ea2b3aa 100644 --- a/lib/pleroma/mfa/totp.ex +++ b/lib/pleroma/mfa/totp.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.MFA.TOTP do diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex index ce8a76219..1794e407c 100644 --- a/lib/pleroma/web/auth/totp_authenticator.ex +++ b/lib/pleroma/web/auth/totp_authenticator.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.Auth.TOTPAuthenticator do diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex index e52cccd85..53e19f82e 100644 --- a/lib/pleroma/web/oauth/mfa_controller.ex +++ b/lib/pleroma/web/oauth/mfa_controller.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.OAuth.MFAController do diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex index e88e7066b..41d5578dc 100644 --- a/lib/pleroma/web/oauth/mfa_view.ex +++ b/lib/pleroma/web/oauth/mfa_view.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.OAuth.MFAView do diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex index 2c3bb9ded..e3aa4eb7e 100644 --- a/lib/pleroma/web/oauth/token/clean_worker.ex +++ b/lib/pleroma/web/oauth/token/clean_worker.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.OAuth.Token.CleanWorker do diff --git a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex index eb9989cdf..b86791d09 100644 --- a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.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.PleromaAPI.TwoFactorAuthenticationController do diff --git a/test/web/auth/pleroma_authenticator_test.exs b/test/web/auth/pleroma_authenticator_test.exs index 731bd5932..1ba0dfecc 100644 --- a/test/web/auth/pleroma_authenticator_test.exs +++ b/test/web/auth/pleroma_authenticator_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.Auth.PleromaAuthenticatorTest do diff --git a/test/web/auth/totp_authenticator_test.exs b/test/web/auth/totp_authenticator_test.exs index e502e0ae8..84d4cd840 100644 --- a/test/web/auth/totp_authenticator_test.exs +++ b/test/web/auth/totp_authenticator_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.Auth.TOTPAuthenticatorTest do -- cgit v1.2.3 From 7d381b16b7b80a22dd9964fb5618998ae41b9c08 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 14:48:37 +0200 Subject: Transmogrifier Test: Extract Announce handling. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 9 +- .../fixtures/kroeg-announce-with-inline-actor.json | 89 +++++++- .../transmogrifier/announce_handling_test.exs | 158 +++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 223 ++++++--------------- 4 files changed, 308 insertions(+), 171 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/announce_handling_test.exs diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 80701bb63..6104af4f9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -677,13 +677,14 @@ def handle_incoming( _options ) do with actor <- Containment.get_actor(data), - {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), - {:ok, object} <- get_embedded_obj_helper(object_id, actor), + {_, {:ok, %User{} = actor}} <- {:fetch_user, User.get_or_fetch_by_ap_id(actor)}, + {_, {:ok, object}} <- {:get_embedded, get_embedded_obj_helper(object_id, actor)}, public <- Visibility.is_public?(data), - {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do + {_, {:ok, activity, _object}} <- + {:announce, ActivityPub.announce(actor, object, id, false, public)} do {:ok, activity} else - _e -> :error + e -> {:error, e} end end diff --git a/test/fixtures/kroeg-announce-with-inline-actor.json b/test/fixtures/kroeg-announce-with-inline-actor.json index 7bd6e8199..f73f93410 100644 --- a/test/fixtures/kroeg-announce-with-inline-actor.json +++ b/test/fixtures/kroeg-announce-with-inline-actor.json @@ -1 +1,88 @@ -{"@context":["https://www.w3.org/ns/activitystreams","https://puckipedia.com/-/context"],"actor":{"endpoints":"https://puckipedia.com/#endpoints","followers":"https://puckipedia.com/followers","following":"https://puckipedia.com/following","icon":{"mediaType":"image/png","type":"Image","url":"https://puckipedia.com/images/avatar.png"},"id":"https://puckipedia.com/","inbox":"https://puckipedia.com/inbox","kroeg:blocks":{"id":"https://puckipedia.com/blocks"},"liked":"https://puckipedia.com/liked","manuallyApprovesFollowers":false,"name":"HACKER TEEN PUCKIPEDIA 👩‍💻","outbox":"https://puckipedia.com/outbox","preferredUsername":"puckipedia","publicKey":{"id":"https://puckipedia.com/#key","owner":"https://puckipedia.com/","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvN05xIcFE0Qgany7Rht4\n0ZI5wu++IT7K5iSqRimBYkpoeHbVcT9RFlW+aWH/QJJW/YgZ7+LMr8AMCrKrwSpS\nCndyrpx4O4lZ3FNRLu7tbklh01rGZfE6R1SFfYBpvMvImc9nYT6iezYDbv6NkHku\no3aVhjql216XlA0OhIrqQme9sAdrLbjbMrTUS8douCTkDOX+JFj1ghHCqdYEMZJI\nOY9kovtgnqyxFLm0RsPGsO1+g/OVojqG+VqHz6O2lceaTVQLlnZ4gOhLVG1tVsA2\nRfXQK+R/VgXncYE+BlQVd/tcdGAz7CDL7PP3rP65gmARnafhGR96cCOi/KzlAXSO\nMwIDAQAB\n-----END PUBLIC KEY-----","type":[]},"summary":"

    federated hacker teen
    \n[she/they]

    ","type":"Person","updated":"2017-12-19T16:56:29.7576707+00:00"},"cc":"http://mastodon.example.org/users/admin","id":"https://puckipedia.com/cc56a9658e","object":{"as:sensitive":false,"attributedTo":{"endpoints":{"sharedInbox":"https://mastodon.social/inbox","type":[]},"followers":"http://mastodon.example.org/users/admin/followers","following":"http://mastodon.example.org/users/admin/following","icon":{"mediaType":"image/png","type":"Image","url":"https://files.mastodon.social/accounts/avatars/000/015/163/original/70ca6c52b01ca913.png"},"id":"http://mastodon.example.org/users/admin","inbox":"http://mastodon.example.org/users/admin/inbox","manuallyApprovesFollowers":{"@value":"False","type":"xsd:boolean"},"name":"","outbox":"http://mastodon.example.org/users/admin/outbox","preferredUsername":"revenant","publicKey":{"id":"http://mastodon.example.org/users/admin#main-key","owner":"http://mastodon.example.org/users/admin","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0gEN3wPW7gkE2gQqnmfB\n1ychjmFIf2LIwY0oCJLiGE/xpZrUKoq+eWH30AP7mATw4LD0gOYABL/ijqPUrPqR\nDXLL+0CqMP8HsZKvRlj9KArMK3YtNiSGGj2U7iReiRrD7nJzjJlsjjJXflLZhZ7/\nenSv1CcaeK8tB0PoAgShy/MyfhPF7WI5/Zm9DmmDQFvUEnDYKXAf/vG/IWw1EyMC\nkbaEYJeIowQU3GsbPxzRGI22bQtfotm431Ch2MbNo+kyzmYVFLAVoSGNMzvJwOPg\nTxLIIBeQXG7MinRyK887yPKhxhcALea4yCcALaa+3jPE7yqwIKYwTHtSlblsHDAo\nmQIDAQAB\n-----END PUBLIC KEY-----\n","type":[]},"summary":"

    neatly partitioned meats and cheeses appeal to me on an aesthetic level | any pronouns | revenant1.net

    ","type":"Person","url":"https://mastodon.social/@revenant"},"cc":"http://mastodon.example.org/users/admin/followers","content":"

    the name's jond (jeans bond)

    ","contentMap":{"en":"

    the name's jond (jeans bond)

    "},"conversation":"tag:mastodon.social,2018-09-25:objectId=55659382:objectType=Conversation","id":"http://mastodon.example.org/users/admin/statuses/100787282858396771","ostatus:atomUri":"http://mastodon.example.org/users/admin/statuses/100787282858396771","published":"2018-09-25T16:11:29Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"https://mastodon.social/@revenant/100787282858396771"},"to":["https://www.w3.org/ns/activitystreams#Public","https://puckipedia.com/followers"],"type":"Announce"} +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://puckipedia.com/-/context" + ], + "actor" : { + "endpoints" : "https://puckipedia.com/#endpoints", + "followers" : "https://puckipedia.com/followers", + "following" : "https://puckipedia.com/following", + "icon" : { + "mediaType" : "image/png", + "type" : "Image", + "url" : "https://puckipedia.com/images/avatar.png" + }, + "id" : "https://puckipedia.com/", + "inbox" : "https://puckipedia.com/inbox", + "kroeg:blocks" : { + "id" : "https://puckipedia.com/blocks" + }, + "liked" : "https://puckipedia.com/liked", + "manuallyApprovesFollowers" : false, + "name" : "HACKER TEEN PUCKIPEDIA 👩‍💻", + "outbox" : "https://puckipedia.com/outbox", + "preferredUsername" : "puckipedia", + "publicKey" : { + "id" : "https://puckipedia.com/#key", + "owner" : "https://puckipedia.com/", + "publicKeyPem" : "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvN05xIcFE0Qgany7Rht4\n0ZI5wu++IT7K5iSqRimBYkpoeHbVcT9RFlW+aWH/QJJW/YgZ7+LMr8AMCrKrwSpS\nCndyrpx4O4lZ3FNRLu7tbklh01rGZfE6R1SFfYBpvMvImc9nYT6iezYDbv6NkHku\no3aVhjql216XlA0OhIrqQme9sAdrLbjbMrTUS8douCTkDOX+JFj1ghHCqdYEMZJI\nOY9kovtgnqyxFLm0RsPGsO1+g/OVojqG+VqHz6O2lceaTVQLlnZ4gOhLVG1tVsA2\nRfXQK+R/VgXncYE+BlQVd/tcdGAz7CDL7PP3rP65gmARnafhGR96cCOi/KzlAXSO\nMwIDAQAB\n-----END PUBLIC KEY-----", + "type" : [] + }, + "summary" : "

    federated hacker teen
    \n[she/they]

    ", + "type" : "Person", + "updated" : "2017-12-19T16:56:29.7576707+00:00" + }, + "cc" : "http://mastodon.example.org/users/admin", + "id" : "https://puckipedia.com/cc56a9658e", + "object" : { + "as:sensitive" : false, + "attributedTo" : { + "endpoints" : { + "sharedInbox" : "https://mastodon.social/inbox", + "type" : [] + }, + "followers" : "http://mastodon.example.org/users/admin/followers", + "following" : "http://mastodon.example.org/users/admin/following", + "icon" : { + "mediaType" : "image/png", + "type" : "Image", + "url" : "https://files.mastodon.social/accounts/avatars/000/015/163/original/70ca6c52b01ca913.png" + }, + "id" : "http://mastodon.example.org/users/admin", + "inbox" : "http://mastodon.example.org/users/admin/inbox", + "manuallyApprovesFollowers" : { + "@value" : "False", + "type" : "xsd:boolean" + }, + "name" : "", + "outbox" : "http://mastodon.example.org/users/admin/outbox", + "preferredUsername" : "revenant", + "publicKey" : { + "id" : "http://mastodon.example.org/users/admin#main-key", + "owner" : "http://mastodon.example.org/users/admin", + "publicKeyPem" : "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0gEN3wPW7gkE2gQqnmfB\n1ychjmFIf2LIwY0oCJLiGE/xpZrUKoq+eWH30AP7mATw4LD0gOYABL/ijqPUrPqR\nDXLL+0CqMP8HsZKvRlj9KArMK3YtNiSGGj2U7iReiRrD7nJzjJlsjjJXflLZhZ7/\nenSv1CcaeK8tB0PoAgShy/MyfhPF7WI5/Zm9DmmDQFvUEnDYKXAf/vG/IWw1EyMC\nkbaEYJeIowQU3GsbPxzRGI22bQtfotm431Ch2MbNo+kyzmYVFLAVoSGNMzvJwOPg\nTxLIIBeQXG7MinRyK887yPKhxhcALea4yCcALaa+3jPE7yqwIKYwTHtSlblsHDAo\nmQIDAQAB\n-----END PUBLIC KEY-----\n", + "type" : [] + }, + "summary" : "

    neatly partitioned meats and cheeses appeal to me on an aesthetic level | any pronouns | revenant1.net

    ", + "type" : "Person", + "url" : "https://mastodon.social/@revenant" + }, + "cc" : "http://mastodon.example.org/users/admin/followers", + "content" : "

    the name's jond (jeans bond)

    ", + "contentMap" : { + "en" : "

    the name's jond (jeans bond)

    " + }, + "conversation" : "tag:mastodon.social,2018-09-25:objectId=55659382:objectType=Conversation", + "id" : "http://mastodon.example.org/users/admin/statuses/100787282858396771", + "ostatus:atomUri" : "http://mastodon.example.org/users/admin/statuses/100787282858396771", + "published" : "2018-09-25T16:11:29Z", + "to" : "https://www.w3.org/ns/activitystreams#Public", + "type" : "Note", + "url" : "https://mastodon.social/@revenant/100787282858396771" + }, + "to" : [ + "https://www.w3.org/ns/activitystreams#Public", + "https://puckipedia.com/followers" + ], + "type" : "Announce" +} diff --git a/test/web/activity_pub/transmogrifier/announce_handling_test.exs b/test/web/activity_pub/transmogrifier/announce_handling_test.exs new file mode 100644 index 000000000..8a4af6546 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/announce_handling_test.exs @@ -0,0 +1,158 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.AnnounceHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + + test "it works for incoming honk announces" do + _user = insert(:user, ap_id: "https://honktest/u/test", local: false) + other_user = insert(:user) + {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"}) + + announce = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => "https://honktest/u/test", + "id" => "https://honktest/u/test/bonk/1793M7B9MQ48847vdx", + "object" => post.data["object"], + "published" => "2019-06-25T19:33:58Z", + "to" => "https://www.w3.org/ns/activitystreams#Public", + "type" => "Announce" + } + + {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(announce) + end + + test "it works for incoming announces with actor being inlined (kroeg)" do + data = File.read!("test/fixtures/kroeg-announce-with-inline-actor.json") |> Poison.decode!() + + _user = insert(:user, local: false, ap_id: data["actor"]["id"]) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(other_user, %{status: "kroegeroeg"}) + + data = + data + |> put_in(["object", "id"], post.data["object"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "https://puckipedia.com/" + end + + test "it works for incoming announces, fetching the announced object" do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() + + _user = insert(:user, local: false, ap_id: data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["type"] == "Announce" + + assert data["id"] == + "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" + + assert data["object"] == + "http://mastodon.example.org/users/admin/statuses/99541947525187367" + + assert(Activity.get_create_by_object_ap_id(data["object"])) + end + + @tag capture_log: true + test "it works for incoming announces with an existing activity" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) + + data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + + _user = insert(:user, local: false, ap_id: data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["type"] == "Announce" + + assert data["id"] == + "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" + + assert data["object"] == activity.data["object"] + + assert Activity.get_create_by_object_ap_id(data["object"]).id == activity.id + end + + test "it works for incoming announces with an inlined activity" do + data = + File.read!("test/fixtures/mastodon-announce-private.json") + |> Poison.decode!() + + _user = + insert(:user, + local: false, + ap_id: data["actor"], + follower_address: data["actor"] <> "/followers" + ) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["actor"] == "http://mastodon.example.org/users/admin" + assert data["type"] == "Announce" + + assert data["id"] == + "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" + + object = Object.normalize(data["object"]) + + assert object.data["id"] == "http://mastodon.example.org/@admin/99541947525187368" + assert object.data["content"] == "this is a private toot" + end + + @tag capture_log: true + test "it rejects incoming announces with an inlined activity from another origin" do + Tesla.Mock.mock(fn + %{method: :get} -> %Tesla.Env{status: 404, body: ""} + end) + + data = + File.read!("test/fixtures/bogus-mastodon-announce.json") + |> Poison.decode!() + + _user = insert(:user, local: false, ap_id: data["actor"]) + + assert {:error, e} = Transmogrifier.handle_incoming(data) + end + + test "it does not clobber the addressing on announce activities" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) + + data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", Object.normalize(activity).data["id"]) + |> Map.put("to", ["http://mastodon.example.org/users/admin/followers"]) + |> Map.put("cc", []) + + _user = + insert(:user, + local: false, + ap_id: data["actor"], + follower_address: "http://mastodon.example.org/users/admin/followers" + ) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["to"] == ["http://mastodon.example.org/users/admin/followers"] + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 0a54e3bb9..ae88a4480 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -28,6 +28,63 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do setup do: clear_config([:instance, :max_remote_account_fields]) describe "handle_incoming" do + test "it works for incoming notices with tag not being an array (kroeg)" do + data = File.read!("test/fixtures/kroeg-array-less-emoji.json") |> Poison.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert object.data["emoji"] == %{ + "icon_e_smile" => "https://puckipedia.com/forum/images/smilies/icon_e_smile.png" + } + + data = File.read!("test/fixtures/kroeg-array-less-hashtag.json") |> Poison.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert "test" in object.data["tag"] + end + + test "it works for incoming notices with url not being a string (prismo)" do + data = File.read!("test/fixtures/prismo-url-map.json") |> Poison.decode!() + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + object = Object.normalize(data["object"]) + + assert object.data["url"] == "https://prismo.news/posts/83" + end + + test "it cleans up incoming notices which are not really DMs" do + user = insert(:user) + other_user = insert(:user) + + to = [user.ap_id, other_user.ap_id] + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("to", to) + |> Map.put("cc", []) + + object = + data["object"] + |> Map.put("to", to) + |> Map.put("cc", []) + + data = Map.put(data, "object", object) + + {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) + + assert data["to"] == [] + assert data["cc"] == to + + object_data = Object.normalize(activity).data + + assert object_data["to"] == [] + assert object_data["cc"] == to + end + test "it ignores an incoming notice if we already have it" do activity = insert(:note_activity) @@ -260,172 +317,6 @@ test "it works for incoming notices with to/cc not being an array (kroeg)" do "

    henlo from my Psion netBook

    message sent from my Psion netBook

    " end - test "it works for incoming honk announces" do - _user = insert(:user, ap_id: "https://honktest/u/test", local: false) - other_user = insert(:user) - {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"}) - - announce = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "actor" => "https://honktest/u/test", - "id" => "https://honktest/u/test/bonk/1793M7B9MQ48847vdx", - "object" => post.data["object"], - "published" => "2019-06-25T19:33:58Z", - "to" => "https://www.w3.org/ns/activitystreams#Public", - "type" => "Announce" - } - - {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(announce) - end - - test "it works for incoming announces with actor being inlined (kroeg)" do - data = File.read!("test/fixtures/kroeg-announce-with-inline-actor.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "https://puckipedia.com/" - end - - test "it works for incoming notices with tag not being an array (kroeg)" do - data = File.read!("test/fixtures/kroeg-array-less-emoji.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert object.data["emoji"] == %{ - "icon_e_smile" => "https://puckipedia.com/forum/images/smilies/icon_e_smile.png" - } - - data = File.read!("test/fixtures/kroeg-array-less-hashtag.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert "test" in object.data["tag"] - end - - test "it works for incoming notices with url not being a string (prismo)" do - data = File.read!("test/fixtures/prismo-url-map.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - object = Object.normalize(data["object"]) - - assert object.data["url"] == "https://prismo.news/posts/83" - end - - test "it cleans up incoming notices which are not really DMs" do - user = insert(:user) - other_user = insert(:user) - - to = [user.ap_id, other_user.ap_id] - - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() - |> Map.put("to", to) - |> Map.put("cc", []) - - object = - data["object"] - |> Map.put("to", to) - |> Map.put("cc", []) - - data = Map.put(data, "object", object) - - {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) - - assert data["to"] == [] - assert data["cc"] == to - - object_data = Object.normalize(activity).data - - assert object_data["to"] == [] - assert object_data["cc"] == to - end - - test "it works for incoming announces" do - data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Announce" - - assert data["id"] == - "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" - - assert data["object"] == - "http://mastodon.example.org/users/admin/statuses/99541947525187367" - - assert Activity.get_create_by_object_ap_id(data["object"]) - end - - test "it works for incoming announces with an existing activity" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) - - data = - File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() - |> Map.put("object", activity.data["object"]) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Announce" - - assert data["id"] == - "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" - - assert data["object"] == activity.data["object"] - - assert Activity.get_create_by_object_ap_id(data["object"]).id == activity.id - end - - test "it works for incoming announces with an inlined activity" do - data = - File.read!("test/fixtures/mastodon-announce-private.json") - |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["actor"] == "http://mastodon.example.org/users/admin" - assert data["type"] == "Announce" - - assert data["id"] == - "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" - - object = Object.normalize(data["object"]) - - assert object.data["id"] == "http://mastodon.example.org/@admin/99541947525187368" - assert object.data["content"] == "this is a private toot" - end - - @tag capture_log: true - test "it rejects incoming announces with an inlined activity from another origin" do - data = - File.read!("test/fixtures/bogus-mastodon-announce.json") - |> Poison.decode!() - - assert :error = Transmogrifier.handle_incoming(data) - end - - test "it does not clobber the addressing on announce activities" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) - - data = - File.read!("test/fixtures/mastodon-announce.json") - |> Poison.decode!() - |> Map.put("object", Object.normalize(activity).data["id"]) - |> Map.put("to", ["http://mastodon.example.org/users/admin/followers"]) - |> Map.put("cc", []) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["to"] == ["http://mastodon.example.org/users/admin/followers"] - end - test "it ensures that as:Public activities make it to their followers collection" do user = insert(:user) -- cgit v1.2.3 From 63ab2743ce7f33b8072d5addc3e6545d12df27fd Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 15:47:26 +0200 Subject: TransmogrifierTest: Fix tests. --- test/web/activity_pub/transmogrifier_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index ae88a4480..81f966ad9 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1329,7 +1329,7 @@ test "it rejects activities which reference objects with bogus origins" do } assert capture_log(fn -> - :error = Transmogrifier.handle_incoming(data) + {:error, _} = Transmogrifier.handle_incoming(data) end) =~ "Object containment failed" end @@ -1344,7 +1344,7 @@ test "it rejects activities which reference objects that have an incorrect attri } assert capture_log(fn -> - :error = Transmogrifier.handle_incoming(data) + {:error, _} = Transmogrifier.handle_incoming(data) end) =~ "Object containment failed" end @@ -1359,7 +1359,7 @@ test "it rejects activities which reference objects that have an incorrect attri } assert capture_log(fn -> - :error = Transmogrifier.handle_incoming(data) + {:error, _} = Transmogrifier.handle_incoming(data) end) =~ "Object containment failed" end end -- cgit v1.2.3 From 17a8342c1e2bd615edb8e41535aa96c1b22d440a Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 16:45:11 +0200 Subject: ObjectValidators: Add basic Announce validator. --- lib/pleroma/web/activity_pub/builder.ex | 14 ++++++ lib/pleroma/web/activity_pub/object_validator.ex | 11 +++++ .../object_validators/announce_validator.ex | 53 ++++++++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 50 ++++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/announce_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 4a247ad0c..63f89c2b4 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -83,6 +83,20 @@ def like(actor, object) do end end + def announce(actor, object) do + to = [actor.follower_address, object.data["actor"]] + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "object" => object.data["id"], + "to" => to, + "context" => object.data["context"], + "type" => "Announce" + }, []} + end + @spec object_action(User.t(), Object.t()) :: {:ok, map(), keyword()} defp object_action(actor, object) do object_actor = User.get_cached_by_ap_id(object.data["actor"]) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 549e5e761..600e58123 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -58,6 +59,16 @@ def validate(%{"type" => "EmojiReact"} = object, meta) do end end + def validate(%{"type" => "Announce"} = object, meta) do + with {:ok, object} <- + object + |> AnnounceValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object |> Map.from_struct()) + {:ok, object, meta} + end + end + def stringify_keys(%{__struct__: _} = object) do object |> Map.from_struct() diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex new file mode 100644 index 000000000..fbefaf257 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:type, :string) + field(:object, Types.ObjectID) + field(:actor, Types.ObjectID) + field(:context, :string) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> fix_after_cast() + end + + def fix_after_cast(cng) do + cng + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Announce"]) + |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) + |> validate_actor_presence() + |> validate_object_presence() + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 96eff1c30..9313015f1 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -280,4 +280,54 @@ test "it works when actor or object are wrapped in maps", %{valid_like: valid_li assert {:object, valid_like["object"]} in validated.changes end end + + describe "announces" do + setup do + user = insert(:user) + announcer = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) + + object = Object.normalize(post_activity, false) + {:ok, valid_announce, []} = Builder.announce(announcer, object) + + %{ + valid_announce: valid_announce, + user: user, + post_activity: post_activity, + announcer: announcer + } + end + + test "returns ok for a valid announce", %{valid_announce: valid_announce} do + assert {:ok, _object, _meta} = ObjectValidator.validate(valid_announce, []) + end + + test "returns an error if the object can't be found", %{valid_announce: valid_announce} do + without_object = + valid_announce + |> Map.delete("object") + + {:error, cng} = ObjectValidator.validate(without_object, []) + + assert {:object, {"can't be blank", [validation: :required]}} in cng.errors + + nonexisting_object = + valid_announce + |> Map.put("object", "https://gensokyo.2hu/objects/99999999") + + {:error, cng} = ObjectValidator.validate(nonexisting_object, []) + + assert {:object, {"can't find object", []}} in cng.errors + end + + test "returns an error if we don't have the actor", %{valid_announce: valid_announce} do + nonexisting_actor = + valid_announce + |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") + + {:error, cng} = ObjectValidator.validate(nonexisting_actor, []) + + assert {:actor, {"can't find user", []}} in cng.errors + end + end end -- cgit v1.2.3 From 0d5bce018df9c99c771daaaa1de3ab0efc0cba5c Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 16:54:10 +0200 Subject: AnnounceValidator: Validate for existing announce --- .../object_validators/announce_validator.ex | 17 ++++++++++++++++- test/web/activity_pub/object_validator_test.exs | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index fbefaf257..158ae199d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -6,9 +6,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do use Ecto.Schema alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Utils - import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @primary_key false @@ -49,5 +50,19 @@ def validate_data(data_cng) do |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) |> validate_actor_presence() |> validate_object_presence() + |> validate_existing_announce() + end + + def validate_existing_announce(cng) do + actor = get_field(cng, :actor) + object = get_field(cng, :object) + + if actor && object && Utils.get_existing_announce(actor, %{data: %{"id" => object}}) do + cng + |> add_error(:actor, "already announced this object") + |> add_error(:object, "already announced by this actor") + else + cng + end end end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 9313015f1..e24e0f913 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -329,5 +329,18 @@ test "returns an error if we don't have the actor", %{valid_announce: valid_anno assert {:actor, {"can't find user", []}} in cng.errors end + + test "returns an error if the actor already announced the object", %{ + valid_announce: valid_announce, + announcer: announcer, + post_activity: post_activity + } do + _announce = CommonAPI.repeat(post_activity.id, announcer) + + {:error, cng} = ObjectValidator.validate(valid_announce, []) + + assert {:actor, {"already announced this object", []}} in cng.errors + assert {:object, {"already announced by this actor", []}} in cng.errors + end end end -- cgit v1.2.3 From 6e4de715b3ae2523fc90c2f5660a47fdda03bd6b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 14 May 2020 19:21:51 +0400 Subject: Add OpenAPI spec for PleromaAPI.EmojiAPIController --- lib/pleroma/emoji/pack.ex | 4 +- .../api_spec/operations/pleroma_emoji_operation.ex | 390 +++++++++++++++++++++ .../controllers/emoji_api_controller.ex | 54 +-- lib/pleroma/web/router.ex | 3 +- test/web/api_spec/schema_examples_test.exs | 2 +- .../controllers/emoji_api_controller_test.exs | 184 ++++++---- 6 files changed, 541 insertions(+), 96 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 242344374..c7b423fbd 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -443,10 +443,10 @@ def update_metadata(name, data) do pack = load_pack(name) fb_sha_changed? = - not is_nil(data["fallback-src"]) and data["fallback-src"] != pack.pack["fallback-src"] + not is_nil(data[:"fallback-src"]) and data[:"fallback-src"] != pack.pack[:"fallback-src"] with {_, true} <- {:update?, fb_sha_changed?}, - {:ok, %{body: zip}} <- Tesla.get(data["fallback-src"]), + {:ok, %{body: zip}} <- Tesla.get(data[:"fallback-src"]), {:ok, f_list} <- :zip.unzip(zip, [:memory]), {_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16() diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex new file mode 100644 index 000000000..fc881e657 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex @@ -0,0 +1,390 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaEmojiOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def remote_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Make request to another instance for emoji packs list", + security: [%{"oAuth" => ["write"]}], + parameters: [url_param()], + operationId: "PleromaAPI.EmojiAPIController.remote", + responses: %{ + 200 => emoji_packs_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def index_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Lists local custom emoji packs", + operationId: "PleromaAPI.EmojiAPIController.index", + responses: %{ + 200 => emoji_packs_response() + } + } + end + + def show_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Show emoji pack", + operationId: "PleromaAPI.EmojiAPIController.show", + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def archive_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Requests a local pack archive from the instance", + operationId: "PleromaAPI.EmojiAPIController.archive", + parameters: [name_param()], + responses: %{ + 200 => + Operation.response("Archive file", "application/octet-stream", %Schema{ + type: :string, + format: :binary + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def download_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Download pack from another instance", + operationId: "PleromaAPI.EmojiAPIController.download", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", download_request(), required: true), + responses: %{ + 200 => ok_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp download_request do + %Schema{ + type: :object, + required: [:url, :name], + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + description: "URL of the instance to download from" + }, + name: %Schema{type: :string, format: :uri, description: "Pack Name"}, + as: %Schema{type: :string, format: :uri, description: "Save as"} + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Create an empty pack", + operationId: "PleromaAPI.EmojiAPIController.create", + security: [%{"oAuth" => ["write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Not Found", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Delete a custom emoji pack", + operationId: "PleromaAPI.EmojiAPIController.delete", + security: [%{"oAuth" => ["write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Updates (replaces) pack metadata", + operationId: "PleromaAPI.EmojiAPIController.update", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Metadata", "application/json", metadata()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def add_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiAPIController.add_file", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", add_file_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError) + } + } + end + + defp add_file_request do + %Schema{ + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + description: + "File needs to be uploaded with the multipart request or link to remote file", + anyOf: [ + %Schema{type: :string, format: :binary}, + %Schema{type: :string, format: :uri} + ] + }, + shortcode: %Schema{ + type: :string, + description: + "Shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename." + }, + filename: %Schema{ + type: :string, + description: + "New emoji file name. If not specified will be taken from original filename." + } + } + } + end + + def update_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiAPIController.update_file", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_file_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError) + } + } + end + + defp update_file_request do + %Schema{ + type: :object, + required: [:shortcode, :new_shortcode, :new_filename], + properties: %{ + shortcode: %Schema{ + type: :string, + description: "Emoji file shortcode" + }, + new_shortcode: %Schema{ + type: :string, + description: "New emoji file shortcode" + }, + new_filename: %Schema{ + type: :string, + description: "New filename for emoji file" + }, + force: %Schema{ + type: :boolean, + description: "With true value to overwrite existing emoji with new shortcode", + default: false + } + } + } + end + + def delete_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Delete emoji file from pack", + operationId: "PleromaAPI.EmojiAPIController.delete_file", + security: [%{"oAuth" => ["write"]}], + parameters: [ + name_param(), + Operation.parameter(:shortcode, :query, :string, "File shortcode", + example: "cofe", + required: true + ) + ], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def import_from_filesystem_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Imports packs from filesystem", + operationId: "PleromaAPI.EmojiAPIController.import", + security: [%{"oAuth" => ["write"]}], + responses: %{ + 200 => + Operation.response("Array of imported pack names", "application/json", %Schema{ + type: :array, + items: %Schema{type: :string} + }) + } + } + end + + defp name_param do + Operation.parameter(:name, :path, :string, "Pack Name", example: "cofe", required: true) + end + + defp url_param do + Operation.parameter( + :url, + :query, + %Schema{type: :string, format: :uri}, + "URL of the instance", + required: true + ) + end + + defp ok_response do + Operation.response("Ok", "application/json", %Schema{type: :string, example: "ok"}) + end + + defp emoji_packs_response do + Operation.response( + "Object with pack names as keys and pack contents as values", + "application/json", + %Schema{ + type: :object, + additionalProperties: emoji_pack(), + example: %{ + "emojos" => emoji_pack().example + } + } + ) + end + + defp emoji_pack do + %Schema{ + title: "EmojiPack", + type: :object, + properties: %{ + files: files_object(), + pack: %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "can-download": %Schema{type: :boolean}, + "share-files": %Schema{type: :boolean}, + "download-sha256": %Schema{type: :string} + } + } + }, + example: %{ + "files" => %{"emacs" => "emacs.png", "guix" => "guix.png"}, + "pack" => %{ + "license" => "Test license", + "homepage" => "https://pleroma.social", + "description" => "Test description", + "can-download" => true, + "share-files" => true, + "download-sha256" => "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238" + } + } + } + end + + defp files_object do + %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: "Object with emoji names as keys and filenames as values" + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + metadata: %Schema{ + type: :object, + description: "Metadata to replace the old one", + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + } + } + end + + defp metadata do + %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index d276b96a4..e20c11860 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -3,6 +3,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do alias Pleroma.Emoji.Pack + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"], admin: true} @@ -19,13 +21,12 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do ] ) - plug( - :skip_plug, - [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] - when action in [:archive, :show, :list] - ) + @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] + plug(:skip_plug, @skip_plugs when action in [:archive, :show, :list]) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiOperation - def remote(conn, %{"url" => url}) do + def remote(conn, %{url: url}) do with {:ok, packs} <- Pack.list_remote(url) do json(conn, packs) else @@ -36,12 +37,11 @@ def remote(conn, %{"url" => url}) do end end - def list(conn, _params) do + def index(conn, _params) do emoji_path = - Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) + [:instance, :static_dir] + |> Pleroma.Config.get!() + |> Path.join("emoji") with {:ok, packs} <- Pack.list_local() do json(conn, packs) @@ -60,7 +60,7 @@ def list(conn, _params) do end end - def show(conn, %{"name" => name}) do + def show(conn, %{name: name}) do name = String.trim(name) with {:ok, pack} <- Pack.show(name) do @@ -78,7 +78,7 @@ def show(conn, %{"name" => name}) do end end - def archive(conn, %{"name" => name}) do + def archive(conn, %{name: name}) do with {:ok, archive} <- Pack.get_archive(name) do send_download(conn, {:binary, archive}, filename: "#{name}.zip") else @@ -97,8 +97,8 @@ def archive(conn, %{"name" => name}) do end end - def download(conn, %{"url" => url, "name" => name} = params) do - with :ok <- Pack.download(name, url, params["as"]) do + def download(%{body_params: %{url: url, name: name} = params} = conn, _) do + with :ok <- Pack.download(name, url, params[:as]) do json(conn, "ok") else {:shareable, _} -> @@ -118,7 +118,7 @@ def download(conn, %{"url" => url, "name" => name} = params) do end end - def create(conn, %{"name" => name}) do + def create(conn, %{name: name}) do name = String.trim(name) with :ok <- Pack.create(name) do @@ -143,7 +143,7 @@ def create(conn, %{"name" => name}) do end end - def delete(conn, %{"name" => name}) do + def delete(conn, %{name: name}) do name = String.trim(name) with {:ok, deleted} when deleted != [] <- Pack.delete(name) do @@ -166,7 +166,7 @@ def delete(conn, %{"name" => name}) do end end - def update(conn, %{"name" => name, "metadata" => metadata}) do + def update(%{body_params: %{metadata: metadata}} = conn, %{name: name}) do with {:ok, pack} <- Pack.update_metadata(name, metadata) do json(conn, pack.pack) else @@ -184,11 +184,11 @@ def update(conn, %{"name" => name, "metadata" => metadata}) do end end - def add_file(conn, %{"name" => name} = params) do - filename = params["filename"] || get_filename(params["file"]) - shortcode = params["shortcode"] || Path.basename(filename, Path.extname(filename)) + def add_file(%{body_params: params} = conn, %{name: name}) do + filename = params[:filename] || get_filename(params[:file]) + shortcode = params[:shortcode] || Path.basename(filename, Path.extname(filename)) - with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params["file"]) do + with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params[:file]) do json(conn, pack.files) else {:exists, _} -> @@ -215,10 +215,10 @@ def add_file(conn, %{"name" => name} = params) do end end - def update_file(conn, %{"name" => name, "shortcode" => shortcode} = params) do - new_shortcode = params["new_shortcode"] - new_filename = params["new_filename"] - force = params["force"] == true + def update_file(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: name}) do + new_shortcode = params[:new_shortcode] + new_filename = params[:new_filename] + force = params[:force] with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do json(conn, pack.files) @@ -255,7 +255,7 @@ def update_file(conn, %{"name" => name, "shortcode" => shortcode} = params) do end end - def delete_file(conn, %{"name" => name, "shortcode" => shortcode}) do + def delete_file(conn, %{name: name, shortcode: shortcode}) do with {:ok, pack} <- Pack.delete_file(name, shortcode) do json(conn, pack.files) else diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d77a61361..0d4ebf4ce 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -231,7 +231,8 @@ defmodule Pleroma.Web.Router do # Pack info / downloading scope "/packs" do - get("/", EmojiAPIController, :list) + pipe_through(:api) + get("/", EmojiAPIController, :index) get("/:name", EmojiAPIController, :show) get("/:name/archive", EmojiAPIController, :archive) end diff --git a/test/web/api_spec/schema_examples_test.exs b/test/web/api_spec/schema_examples_test.exs index 88b6f07cb..f00e834fc 100644 --- a/test/web/api_spec/schema_examples_test.exs +++ b/test/web/api_spec/schema_examples_test.exs @@ -28,7 +28,7 @@ test "request body schema example matches schema" do end end - for {status, response} <- operation.responses do + for {status, response} <- operation.responses, is_map(response.content[@content_type]) do describe "#{operation.operationId} - #{status} Response" do @schema resolve_schema(response.content[@content_type].schema) diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index d343256fe..c625a5c43 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -28,7 +28,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do end test "GET /api/pleroma/emoji/packs", %{conn: conn} do - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) shared = resp["test_pack"] assert shared["files"] == %{"blank" => "blank.png"} @@ -46,7 +46,7 @@ test "shareable instance", %{admin_conn: admin_conn, conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") - |> json_response(200) + |> json_response_and_validate_schema(200) mock(fn %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> @@ -60,10 +60,8 @@ test "shareable instance", %{admin_conn: admin_conn, conn: conn} do end) assert admin_conn - |> get("/api/pleroma/emoji/packs/remote", %{ - url: "https://example.com" - }) - |> json_response(200) == resp + |> get("/api/pleroma/emoji/packs/remote?url=https://example.com") + |> json_response_and_validate_schema(200) == resp end test "non shareable instance", %{admin_conn: admin_conn} do @@ -76,8 +74,8 @@ test "non shareable instance", %{admin_conn: admin_conn} do end) assert admin_conn - |> get("/api/pleroma/emoji/packs/remote", %{url: "https://example.com"}) - |> json_response(500) == %{ + |> get("/api/pleroma/emoji/packs/remote?url=https://example.com") + |> json_response_and_validate_schema(500) == %{ "error" => "The requested instance does not support sharing emoji packs" } end @@ -99,7 +97,7 @@ test "download shared pack", %{conn: conn} do test "non existing pack", %{conn: conn} do assert conn |> get("/api/pleroma/emoji/packs/test_pack_for_import/archive") - |> json_response(:not_found) == %{ + |> json_response_and_validate_schema(:not_found) == %{ "error" => "Pack test_pack_for_import does not exist" } end @@ -107,7 +105,7 @@ test "non existing pack", %{conn: conn} do test "non downloadable pack", %{conn: conn} do assert conn |> get("/api/pleroma/emoji/packs/test_pack_nonshared/archive") - |> json_response(:forbidden) == %{ + |> json_response_and_validate_schema(:forbidden) == %{ "error" => "Pack test_pack_nonshared cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" } @@ -132,7 +130,7 @@ test "shared pack from remote and non shared from fallback-src", %{ } -> conn |> get("/api/pleroma/emoji/packs/test_pack") - |> json_response(200) + |> json_response_and_validate_schema(200) |> json() %{ @@ -150,7 +148,7 @@ test "shared pack from remote and non shared from fallback-src", %{ } -> conn |> get("/api/pleroma/emoji/packs/test_pack_nonshared") - |> json_response(200) + |> json_response_and_validate_schema(200) |> json() %{ @@ -161,23 +159,25 @@ test "shared pack from remote and non shared from fallback-src", %{ end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/download", %{ url: "https://example.com", name: "test_pack", as: "test_pack2" }) - |> json_response(200) == "ok" + |> json_response_and_validate_schema(200) == "ok" assert File.exists?("#{@emoji_path}/test_pack2/pack.json") assert File.exists?("#{@emoji_path}/test_pack2/blank.png") assert admin_conn |> delete("/api/pleroma/emoji/packs/test_pack2") - |> json_response(200) == "ok" + |> json_response_and_validate_schema(200) == "ok" refute File.exists?("#{@emoji_path}/test_pack2") assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post( "/api/pleroma/emoji/packs/download", %{ @@ -186,14 +186,14 @@ test "shared pack from remote and non shared from fallback-src", %{ as: "test_pack_nonshared2" } ) - |> json_response(200) == "ok" + |> json_response_and_validate_schema(200) == "ok" assert File.exists?("#{@emoji_path}/test_pack_nonshared2/pack.json") assert File.exists?("#{@emoji_path}/test_pack_nonshared2/blank.png") assert admin_conn |> delete("/api/pleroma/emoji/packs/test_pack_nonshared2") - |> json_response(200) == "ok" + |> json_response_and_validate_schema(200) == "ok" refute File.exists?("#{@emoji_path}/test_pack_nonshared2") end @@ -208,6 +208,7 @@ test "nonshareable instance", %{admin_conn: admin_conn} do end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post( "/api/pleroma/emoji/packs/download", %{ @@ -216,7 +217,7 @@ test "nonshareable instance", %{admin_conn: admin_conn} do as: "test_pack2" } ) - |> json_response(500) == %{ + |> json_response_and_validate_schema(500) == %{ "error" => "The requested instance does not support sharing emoji packs" } end @@ -249,12 +250,13 @@ test "checksum fail", %{admin_conn: admin_conn} do end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/download", %{ url: "https://example.com", name: "pack_bad_sha", as: "pack_bad_sha2" }) - |> json_response(:internal_server_error) == %{ + |> json_response_and_validate_schema(:internal_server_error) == %{ "error" => "SHA256 for the pack doesn't match the one sent by the server" } end @@ -278,12 +280,13 @@ test "other error", %{admin_conn: admin_conn} do end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/download", %{ url: "https://example.com", name: "test_pack", as: "test_pack2" }) - |> json_response(:internal_server_error) == %{ + |> json_response_and_validate_schema(:internal_server_error) == %{ "error" => "The pack was not set as shared and there is no fallback src to download from" } @@ -311,8 +314,9 @@ test "other error", %{admin_conn: admin_conn} do test "for a pack without a fallback source", ctx do assert ctx[:admin_conn] + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack", %{"metadata" => ctx[:new_data]}) - |> json_response(200) == ctx[:new_data] + |> json_response_and_validate_schema(200) == ctx[:new_data] assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == ctx[:new_data] end @@ -336,8 +340,9 @@ test "for a pack with a fallback source", ctx do ) assert ctx[:admin_conn] + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) - |> json_response(200) == new_data_with_sha + |> json_response_and_validate_schema(200) == new_data_with_sha assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == new_data_with_sha end @@ -355,8 +360,9 @@ test "when the fallback source doesn't have all the files", ctx do new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") assert ctx[:admin_conn] + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) - |> json_response(:bad_request) == %{ + |> json_response_and_validate_schema(:bad_request) == %{ "error" => "The fallback archive does not have all files specified in pack.json" } end @@ -376,6 +382,7 @@ test "when the fallback source doesn't have all the files", ctx do test "create shortcode exists", %{admin_conn: admin_conn} do assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank", filename: "dir/blank.png", @@ -384,7 +391,7 @@ test "create shortcode exists", %{admin_conn: admin_conn} do path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(:conflict) == %{ + |> json_response_and_validate_schema(:conflict) == %{ "error" => "An emoji with the \"blank\" shortcode already exists" } end @@ -393,6 +400,7 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir/") end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank2", filename: "dir/blank.png", @@ -401,17 +409,21 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(200) == %{"blank" => "blank.png", "blank2" => "dir/blank.png"} + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank2" => "dir/blank.png" + } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank", new_shortcode: "blank2", new_filename: "dir_2/blank_3.png" }) - |> json_response(:conflict) == %{ + |> json_response_and_validate_schema(:conflict) == %{ "error" => "New shortcode \"blank2\" is already used. If you want to override emoji use 'force' option" } @@ -421,6 +433,7 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir_2/") end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank2", filename: "dir/blank.png", @@ -429,18 +442,22 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(200) == %{"blank" => "blank.png", "blank2" => "dir/blank.png"} + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank2" => "dir/blank.png" + } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank2", new_shortcode: "blank3", new_filename: "dir_2/blank_3.png", force: true }) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", "blank3" => "dir_2/blank_3.png" } @@ -450,6 +467,7 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do test "with empty filename", %{admin_conn: admin_conn} do assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank2", filename: "", @@ -458,13 +476,14 @@ test "with empty filename", %{admin_conn: admin_conn} do path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(:bad_request) == %{ + |> json_response_and_validate_schema(:bad_request) == %{ "error" => "pack name, shortcode or filename cannot be empty" } end test "add file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/not_loaded/files", %{ shortcode: "blank2", filename: "dir/blank.png", @@ -473,37 +492,43 @@ test "add file with not loaded pack", %{admin_conn: admin_conn} do path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(:bad_request) == %{ + |> json_response_and_validate_schema(:bad_request) == %{ "error" => "pack \"not_loaded\" is not found" } end test "remove file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn - |> delete("/api/pleroma/emoji/packs/not_loaded/files", %{shortcode: "blank3"}) - |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"} + |> delete("/api/pleroma/emoji/packs/not_loaded/files?shortcode=blank3") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack \"not_loaded\" is not found" + } end test "remove file with empty shortcode", %{admin_conn: admin_conn} do assert admin_conn - |> delete("/api/pleroma/emoji/packs/not_loaded/files", %{shortcode: ""}) - |> json_response(:bad_request) == %{ + |> delete("/api/pleroma/emoji/packs/not_loaded/files?shortcode=") + |> json_response_and_validate_schema(:bad_request) == %{ "error" => "pack name or shortcode cannot be empty" } end test "update file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/not_loaded/files", %{ shortcode: "blank4", new_shortcode: "blank3", new_filename: "dir_2/blank_3.png" }) - |> json_response(:bad_request) == %{"error" => "pack \"not_loaded\" is not found"} + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack \"not_loaded\" is not found" + } end test "new with shortcode as file with update", %{admin_conn: admin_conn} do assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank4", filename: "dir/blank.png", @@ -512,24 +537,31 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do path: "#{@emoji_path}/test_pack/blank.png" } }) - |> json_response(200) == %{"blank" => "blank.png", "blank4" => "dir/blank.png"} + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank4" => "dir/blank.png" + } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank4", new_shortcode: "blank3", new_filename: "dir_2/blank_3.png" }) - |> json_response(200) == %{"blank3" => "dir_2/blank_3.png", "blank" => "blank.png"} + |> json_response_and_validate_schema(200) == %{ + "blank3" => "dir_2/blank_3.png", + "blank" => "blank.png" + } refute File.exists?("#{@emoji_path}/test_pack/dir/") assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack/files", %{shortcode: "blank3"}) - |> json_response(200) == %{"blank" => "blank.png"} + |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") + |> json_response_and_validate_schema(200) == %{"blank" => "blank.png"} refute File.exists?("#{@emoji_path}/test_pack/dir_2/") @@ -546,11 +578,12 @@ test "new with shortcode from url", %{admin_conn: admin_conn} do end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank_url", file: "https://test-blank/blank_url.png" }) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "blank_url" => "blank_url.png", "blank" => "blank.png" } @@ -564,40 +597,51 @@ test "new without shortcode", %{admin_conn: admin_conn} do on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/shortcode.png") end) assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ file: %Plug.Upload{ filename: "shortcode.png", path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" } }) - |> json_response(200) == %{"shortcode" => "shortcode.png", "blank" => "blank.png"} + |> json_response_and_validate_schema(200) == %{ + "shortcode" => "shortcode.png", + "blank" => "blank.png" + } end test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack/files", %{shortcode: "blank2"}) - |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"} + |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank2") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "Emoji \"blank2\" does not exist" + } end test "update non existing emoji", %{admin_conn: admin_conn} do assert admin_conn + |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ shortcode: "blank2", new_shortcode: "blank3", new_filename: "dir_2/blank_3.png" }) - |> json_response(:bad_request) == %{"error" => "Emoji \"blank2\" does not exist"} + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "Emoji \"blank2\" does not exist" + } end test "update with empty shortcode", %{admin_conn: admin_conn} do - assert admin_conn - |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank", - new_filename: "dir_2/blank_3.png" - }) - |> json_response(:bad_request) == %{ - "error" => "new_shortcode or new_filename cannot be empty" - } + assert %{ + "error" => "Missing field: new_shortcode." + } = + admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + new_filename: "dir_2/blank_3.png" + }) + |> json_response_and_validate_schema(:bad_request) end end @@ -605,7 +649,7 @@ test "update with empty shortcode", %{admin_conn: admin_conn} do test "creating and deleting a pack", %{admin_conn: admin_conn} do assert admin_conn |> post("/api/pleroma/emoji/packs/test_created") - |> json_response(200) == "ok" + |> json_response_and_validate_schema(200) == "ok" assert File.exists?("#{@emoji_path}/test_created/pack.json") @@ -616,7 +660,7 @@ test "creating and deleting a pack", %{admin_conn: admin_conn} do assert admin_conn |> delete("/api/pleroma/emoji/packs/test_created") - |> json_response(200) == "ok" + |> json_response_and_validate_schema(200) == "ok" refute File.exists?("#{@emoji_path}/test_created/pack.json") end @@ -629,7 +673,7 @@ test "if pack exists", %{admin_conn: admin_conn} do assert admin_conn |> post("/api/pleroma/emoji/packs/test_created") - |> json_response(:conflict) == %{ + |> json_response_and_validate_schema(:conflict) == %{ "error" => "A pack named \"test_created\" already exists" } @@ -639,20 +683,26 @@ test "if pack exists", %{admin_conn: admin_conn} do test "with empty name", %{admin_conn: admin_conn} do assert admin_conn |> post("/api/pleroma/emoji/packs/ ") - |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name cannot be empty" + } end end test "deleting nonexisting pack", %{admin_conn: admin_conn} do assert admin_conn |> delete("/api/pleroma/emoji/packs/non_existing") - |> json_response(:not_found) == %{"error" => "Pack non_existing does not exist"} + |> json_response_and_validate_schema(:not_found) == %{ + "error" => "Pack non_existing does not exist" + } end test "deleting with empty name", %{admin_conn: admin_conn} do assert admin_conn |> delete("/api/pleroma/emoji/packs/ ") - |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name cannot be empty" + } end test "filesystem import", %{admin_conn: admin_conn, conn: conn} do @@ -661,15 +711,15 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") end) - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) refute Map.has_key?(resp, "test_pack_for_import") assert admin_conn |> get("/api/pleroma/emoji/packs/import") - |> json_response(200) == ["test_pack_for_import"] + |> json_response_and_validate_schema(200) == ["test_pack_for_import"] - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") @@ -686,9 +736,9 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do assert admin_conn |> get("/api/pleroma/emoji/packs/import") - |> json_response(200) == ["test_pack_for_import"] + |> json_response_and_validate_schema(200) == ["test_pack_for_import"] - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response(200) + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) assert resp["test_pack_for_import"]["files"] == %{ "blank" => "blank.png", @@ -712,19 +762,23 @@ test "shows pack.json", %{conn: conn} do } = conn |> get("/api/pleroma/emoji/packs/test_pack") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "non existing pack", %{conn: conn} do assert conn |> get("/api/pleroma/emoji/packs/non_existing") - |> json_response(:not_found) == %{"error" => "Pack non_existing does not exist"} + |> json_response_and_validate_schema(:not_found) == %{ + "error" => "Pack non_existing does not exist" + } end test "error name", %{conn: conn} do assert conn |> get("/api/pleroma/emoji/packs/ ") - |> json_response(:bad_request) == %{"error" => "pack name cannot be empty"} + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name cannot be empty" + } end end end -- cgit v1.2.3 From 8bde8dfec21dbc83bc73ea6f7ea43a432eea116b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 18 May 2020 19:43:23 +0400 Subject: Cleanup Pleroma.Emoji.Pack --- lib/pleroma/emoji/pack.ex | 688 +++++++++++---------- .../controllers/emoji_api_controller.ex | 36 +- .../controllers/emoji_api_controller_test.exs | 12 +- 3 files changed, 383 insertions(+), 353 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index c7b423fbd..eb7d598c6 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -16,162 +16,78 @@ defmodule Pleroma.Emoji.Pack do alias Pleroma.Emoji - @spec emoji_path() :: Path.t() - def emoji_path do - static = Pleroma.Config.get!([:instance, :static_dir]) - Path.join(static, "emoji") - end - @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} - def create(name) when byte_size(name) > 0 do - dir = Path.join(emoji_path(), name) - - with :ok <- File.mkdir(dir) do - %__MODULE__{ - pack_file: Path.join(dir, "pack.json") - } + def create(name) do + with :ok <- validate_not_empty([name]), + dir <- Path.join(emoji_path(), name), + :ok <- File.mkdir(dir) do + %__MODULE__{pack_file: Path.join(dir, "pack.json")} |> save_pack() end end - def create(_), do: {:error, :empty_values} - - @spec show(String.t()) :: {:ok, t()} | {:loaded, nil} | {:error, :empty_values} - def show(name) when byte_size(name) > 0 do - with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, - {_, pack} <- validate_pack(pack) do - {:ok, pack} + @spec show(String.t()) :: {:ok, t()} | {:error, atom()} + def show(name) do + with :ok <- validate_not_empty([name]), + {:ok, pack} <- load_pack(name) do + {:ok, validate_pack(pack)} end end - def show(_), do: {:error, :empty_values} - @spec delete(String.t()) :: {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} - def delete(name) when byte_size(name) > 0 do - emoji_path() - |> Path.join(name) - |> File.rm_rf() - end - - def delete(_), do: {:error, :empty_values} - - @spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) :: - {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} - def add_file(name, shortcode, filename, file) - when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(filename) > 0 do - with {_, nil} <- {:exists, Emoji.get(shortcode)}, - {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)} do - file_path = Path.join(pack.path, filename) - - create_subdirs(file_path) - - case file do - %Plug.Upload{path: upload_path} -> - # Copy the uploaded file from the temporary directory - File.copy!(upload_path, file_path) - - url when is_binary(url) -> - # Download and write the file - file_contents = Tesla.get!(url).body - File.write!(file_path, file_contents) - end - - files = Map.put(pack.files, shortcode, filename) - - updated_pack = %{pack | files: files} - - case save_pack(updated_pack) do - :ok -> - Emoji.reload() - {:ok, updated_pack} - - e -> - e - end + def delete(name) do + with :ok <- validate_not_empty([name]) do + emoji_path() + |> Path.join(name) + |> File.rm_rf() end end - def add_file(_, _, _, _), do: {:error, :empty_values} - - defp create_subdirs(file_path) do - if String.contains?(file_path, "/") do - file_path - |> Path.dirname() - |> File.mkdir_p!() + @spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) :: + {:ok, t()} | {:error, File.posix() | atom()} + def add_file(name, shortcode, filename, file) do + with :ok <- validate_not_empty([name, shortcode, filename]), + :ok <- validate_emoji_not_exists(shortcode), + {:ok, pack} <- load_pack(name), + :ok <- save_file(file, pack, filename), + {:ok, updated_pack} <- pack |> put_emoji(shortcode, filename) |> save_pack() do + Emoji.reload() + {:ok, updated_pack} end end @spec delete_file(String.t(), String.t()) :: - {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} - def delete_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do - with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, - {_, {filename, files}} when not is_nil(filename) <- - {:exists, Map.pop(pack.files, shortcode)}, - emoji <- Path.join(pack.path, filename), - {_, true} <- {:exists, File.exists?(emoji)} do - emoji_dir = Path.dirname(emoji) - - File.rm!(emoji) - - if String.contains?(filename, "/") and File.ls!(emoji_dir) == [] do - File.rmdir!(emoji_dir) - end - - updated_pack = %{pack | files: files} - - case save_pack(updated_pack) do - :ok -> - Emoji.reload() - {:ok, updated_pack} - - e -> - e - end + {:ok, t()} | {:error, File.posix() | atom()} + def delete_file(name, shortcode) do + with :ok <- validate_not_empty([name, shortcode]), + {:ok, pack} <- load_pack(name), + :ok <- remove_file(pack, shortcode), + {:ok, updated_pack} <- pack |> delete_emoji(shortcode) |> save_pack() do + Emoji.reload() + {:ok, updated_pack} end end - def delete_file(_, _), do: {:error, :empty_values} - @spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) :: - {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} - def update_file(name, shortcode, new_shortcode, new_filename, force) - when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(new_shortcode) > 0 and - byte_size(new_filename) > 0 do - with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, - {_, {filename, files}} when not is_nil(filename) <- - {:exists, Map.pop(pack.files, shortcode)}, - {_, true} <- {:not_used, force or is_nil(Emoji.get(new_shortcode))} do - old_path = Path.join(pack.path, filename) - old_dir = Path.dirname(old_path) - new_path = Path.join(pack.path, new_filename) - - create_subdirs(new_path) - - :ok = File.rename(old_path, new_path) - - if String.contains?(filename, "/") and File.ls!(old_dir) == [] do - File.rmdir!(old_dir) - end - - files = Map.put(files, new_shortcode, new_filename) - - updated_pack = %{pack | files: files} - - case save_pack(updated_pack) do - :ok -> - Emoji.reload() - {:ok, updated_pack} - - e -> - e - end + {:ok, t()} | {:error, File.posix() | atom()} + def update_file(name, shortcode, new_shortcode, new_filename, force) do + with :ok <- validate_not_empty([name, shortcode, new_shortcode, new_filename]), + {:ok, pack} <- load_pack(name), + {:ok, filename} <- get_filename(pack, shortcode), + :ok <- validate_emoji_not_exists(new_shortcode, force), + :ok <- rename_file(pack, filename, new_filename), + {:ok, updated_pack} <- + pack + |> delete_emoji(shortcode) + |> put_emoji(new_shortcode, new_filename) + |> save_pack() do + Emoji.reload() + {:ok, updated_pack} end end - def update_file(_, _, _, _, _), do: {:error, :empty_values} - - @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, atom()} + @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, File.posix() | atom()} def import_from_filesystem do emoji_path = emoji_path() @@ -184,7 +100,7 @@ def import_from_filesystem do File.dir?(path) and File.exists?(Path.join(path, "pack.json")) end) |> Enum.map(&write_pack_contents/1) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) {:ok, names} else @@ -193,6 +109,117 @@ def import_from_filesystem do end end + @spec list_remote(String.t()) :: {:ok, map()} | {:error, atom()} + def list_remote(url) do + uri = url |> String.trim() |> URI.parse() + + with :ok <- validate_shareable_packs_available(uri) do + uri + |> URI.merge("/api/pleroma/emoji/packs") + |> http_get() + end + end + + @spec list_local() :: {:ok, map()} + def list_local do + with {:ok, results} <- list_packs_dir() do + packs = + results + |> Enum.map(fn name -> + case load_pack(name) do + {:ok, pack} -> pack + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) + + {:ok, packs} + end + end + + @spec get_archive(String.t()) :: {:ok, binary()} | {:error, atom()} + def get_archive(name) do + with {:ok, pack} <- load_pack(name), + :ok <- validate_downloadable(pack) do + {:ok, fetch_archive(pack)} + end + end + + @spec download(String.t(), String.t(), String.t()) :: :ok | {:error, atom()} + def download(name, url, as) do + uri = url |> String.trim() |> URI.parse() + + with :ok <- validate_shareable_packs_available(uri), + {:ok, remote_pack} <- uri |> URI.merge("/api/pleroma/emoji/packs/#{name}") |> http_get(), + {:ok, %{sha: sha, url: url} = pack_info} <- fetch_pack_info(remote_pack, uri, name), + {:ok, archive} <- download_archive(url, sha), + pack <- copy_as(remote_pack, as || name), + {:ok, _} = unzip(archive, pack_info, remote_pack, pack) do + # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 + # in it to depend on itself + if pack_info[:fallback] do + save_pack(pack) + else + {:ok, pack} + end + end + end + + @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()} + def save_metadata(metadata, %__MODULE__{} = pack) do + pack + |> Map.put(:pack, metadata) + |> save_pack() + end + + @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()} + def update_metadata(name, data) do + with {:ok, pack} <- load_pack(name) do + if fallback_sha_changed?(pack, data) do + update_sha_and_save_metadata(pack, data) + else + save_metadata(data, pack) + end + end + end + + @spec load_pack(String.t()) :: {:ok, t()} | {:error, :not_found} + def load_pack(name) do + pack_file = Path.join([emoji_path(), name, "pack.json"]) + + if File.exists?(pack_file) do + pack = + pack_file + |> File.read!() + |> from_json() + |> Map.put(:pack_file, pack_file) + |> Map.put(:path, Path.dirname(pack_file)) + |> Map.put(:name, name) + + {:ok, pack} + else + {:error, :not_found} + end + end + + @spec emoji_path() :: Path.t() + defp emoji_path do + [:instance, :static_dir] + |> Pleroma.Config.get!() + |> Path.join("emoji") + end + + defp validate_emoji_not_exists(shortcode, force \\ false) + defp validate_emoji_not_exists(_shortcode, true), do: :ok + + defp validate_emoji_not_exists(shortcode, _) do + case Emoji.get(shortcode) do + nil -> :ok + _ -> {:error, :already_exists} + end + end + defp write_pack_contents(path) do pack = %__MODULE__{ files: files_from_path(path), @@ -201,7 +228,7 @@ defp write_pack_contents(path) do } case save_pack(pack) do - :ok -> Path.basename(path) + {:ok, _pack} -> Path.basename(path) _ -> nil end end @@ -216,7 +243,8 @@ defp files_from_path(path) do # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 # Create a map of shortcodes to filenames from emoji.txt - File.read!(txt_path) + txt_path + |> File.read!() |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.map(fn line -> @@ -226,21 +254,18 @@ defp files_from_path(path) do [name, file | _] -> file_dir_name = Path.dirname(file) - file = - if String.ends_with?(path, file_dir_name) do - Path.basename(file) - else - file - end - - {name, file} + if String.ends_with?(path, file_dir_name) do + {name, Path.basename(file)} + else + {name, file} + end _ -> nil end end) - |> Enum.filter(& &1) - |> Enum.into(%{}) + |> Enum.reject(&is_nil/1) + |> Map.new() else # If there's no emoji.txt, assume all files # that are of certain extensions from the config are emojis and import them all @@ -249,60 +274,20 @@ defp files_from_path(path) do end end - @spec list_remote(String.t()) :: {:ok, map()} - def list_remote(url) do - uri = - url - |> String.trim() - |> URI.parse() - - with {_, true} <- {:shareable, shareable_packs_available?(uri)} do - packs = - uri - |> URI.merge("/api/pleroma/emoji/packs") - |> to_string() - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - - {:ok, packs} - end - end - - @spec list_local() :: {:ok, map()} - def list_local do - emoji_path = emoji_path() - - # Create the directory first if it does not exist. This is probably the first request made - # with the API so it should be sufficient - with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, - {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do - packs = - results - |> Enum.map(&load_pack/1) - |> Enum.filter(& &1) - |> Enum.map(&validate_pack/1) - |> Map.new() - - {:ok, packs} - end - end - defp validate_pack(pack) do - if downloadable?(pack) do - archive = fetch_archive(pack) - archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() + info = + if downloadable?(pack) do + archive = fetch_archive(pack) + archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() - info = pack.pack |> Map.put("can-download", true) |> Map.put("download-sha256", archive_sha) + else + Map.put(pack.pack, "can-download", false) + end - {pack.name, Map.put(pack, :pack, info)} - else - info = Map.put(pack.pack, "can-download", false) - {pack.name, Map.put(pack, :pack, info)} - end + Map.put(pack, :pack, info) end defp downloadable?(pack) do @@ -315,26 +300,6 @@ defp downloadable?(pack) do end) end - @spec get_archive(String.t()) :: {:ok, binary()} - def get_archive(name) do - with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)}, - {_, true} <- {:can_download?, downloadable?(pack)} do - {:ok, fetch_archive(pack)} - end - end - - defp fetch_archive(pack) do - hash = :crypto.hash(:md5, File.read!(pack.pack_file)) - - case Cachex.get!(:emoji_packs_cache, pack.name) do - %{hash: ^hash, pack_data: archive} -> - archive - - _ -> - create_archive_and_cache(pack, hash) - end - end - defp create_archive_and_cache(pack, hash) do files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] @@ -356,152 +321,221 @@ defp create_archive_and_cache(pack, hash) do result end - @spec download(String.t(), String.t(), String.t()) :: :ok - def download(name, url, as) do - uri = - url - |> String.trim() - |> URI.parse() - - with {_, true} <- {:shareable, shareable_packs_available?(uri)} do - remote_pack = - uri - |> URI.merge("/api/pleroma/emoji/packs/#{name}") - |> to_string() - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - - result = - case remote_pack["pack"] do - %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> - {:ok, - %{ - sha: sha, - url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string() - }} - - %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> - {:ok, - %{ - sha: sha, - url: src, - fallback: true - }} + defp save_pack(pack) do + with {:ok, json} <- Jason.encode(pack, pretty: true), + :ok <- File.write(pack.pack_file, json) do + {:ok, pack} + end + end - _ -> - {:error, - "The pack was not set as shared and there is no fallback src to download from"} - end + defp from_json(json) do + map = Jason.decode!(json) - with {:ok, %{sha: sha, url: url} = pinfo} <- result, - %{body: archive} <- Tesla.get!(url), - {_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, archive)} do - local_name = as || name + struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) + end - path = Path.join(emoji_path(), local_name) + defp validate_shareable_packs_available(uri) do + with {:ok, %{"links" => links}} <- uri |> URI.merge("/.well-known/nodeinfo") |> http_get(), + # Get the actual nodeinfo address and fetch it + {:ok, %{"metadata" => %{"features" => features}}} <- + links |> List.last() |> Map.get("href") |> http_get() do + if Enum.member?(features, "shareable_emoji_packs") do + :ok + else + {:error, :not_shareable} + end + end + end - pack = %__MODULE__{ - name: local_name, - path: path, - files: remote_pack["files"], - pack_file: Path.join(path, "pack.json") - } + defp validate_not_empty(list) do + if Enum.all?(list, fn i -> is_binary(i) and i != "" end) do + :ok + else + {:error, :empty_values} + end + end - File.mkdir_p!(pack.path) + defp save_file(file, pack, filename) do + file_path = Path.join(pack.path, filename) + create_subdirs(file_path) - files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) - # Fallback cannot contain a pack.json file - files = if pinfo[:fallback], do: files, else: ['pack.json' | files] + case file do + %Plug.Upload{path: upload_path} -> + # Copy the uploaded file from the temporary directory + with {:ok, _} <- File.copy(upload_path, file_path), do: :ok - {:ok, _} = :zip.unzip(archive, cwd: to_charlist(pack.path), file_list: files) + url when is_binary(url) -> + # Download and write the file + file_contents = Tesla.get!(url).body + File.write(file_path, file_contents) + end + end - # Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256 - # in it to depend on itself - if pinfo[:fallback] do - save_pack(pack) - end + defp put_emoji(pack, shortcode, filename) do + files = Map.put(pack.files, shortcode, filename) + %{pack | files: files} + end - :ok - end + defp delete_emoji(pack, shortcode) do + files = Map.delete(pack.files, shortcode) + %{pack | files: files} + end + + defp rename_file(pack, filename, new_filename) do + old_path = Path.join(pack.path, filename) + new_path = Path.join(pack.path, new_filename) + create_subdirs(new_path) + + with :ok <- File.rename(old_path, new_path) do + remove_dir_if_empty(old_path, filename) end end - defp save_pack(pack), do: File.write(pack.pack_file, Jason.encode!(pack, pretty: true)) + defp create_subdirs(file_path) do + if String.contains?(file_path, "/") do + file_path + |> Path.dirname() + |> File.mkdir_p!() + end + end - @spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()} - def save_metadata(metadata, %__MODULE__{} = pack) do - pack = Map.put(pack, :pack, metadata) + defp remove_file(pack, shortcode) do + with {:ok, filename} <- get_filename(pack, shortcode), + emoji <- Path.join(pack.path, filename), + :ok <- File.rm(emoji) do + remove_dir_if_empty(emoji, filename) + end + end - with :ok <- save_pack(pack) do - {:ok, pack} + defp remove_dir_if_empty(emoji, filename) do + dir = Path.dirname(emoji) + + if String.contains?(filename, "/") and File.ls!(dir) == [] do + File.rmdir!(dir) + else + :ok end end - @spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()} - def update_metadata(name, data) do - pack = load_pack(name) + defp get_filename(pack, shortcode) do + with %{^shortcode => filename} when is_binary(filename) <- pack.files, + true <- pack.path |> Path.join(filename) |> File.exists?() do + {:ok, filename} + else + _ -> {:error, :doesnt_exist} + end + end - fb_sha_changed? = - not is_nil(data[:"fallback-src"]) and data[:"fallback-src"] != pack.pack[:"fallback-src"] + defp http_get(%URI{} = url), do: url |> to_string() |> http_get() - with {_, true} <- {:update?, fb_sha_changed?}, - {:ok, %{body: zip}} <- Tesla.get(data[:"fallback-src"]), - {:ok, f_list} <- :zip.unzip(zip, [:memory]), - {_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do - fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16() + defp http_get(url) do + with {:ok, %{body: body}} <- url |> Pleroma.HTTP.get() do + Jason.decode(body) + end + end - data - |> Map.put("fallback-src-sha256", fallback_sha) - |> save_metadata(pack) + defp list_packs_dir do + emoji_path = emoji_path() + # Create the directory first if it does not exist. This is probably the first request made + # with the API so it should be sufficient + with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, + {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do + {:ok, results} else - {:update?, _} -> save_metadata(data, pack) - e -> e + {:create_dir, {:error, e}} -> {:error, :create_dir, e} + {:ls, {:error, e}} -> {:error, :ls, e} end end - # Check if all files from the pack.json are in the archive - defp has_all_files?(files, f_list) do - Enum.all?(files, fn {_, from_manifest} -> - List.keyfind(f_list, to_charlist(from_manifest), 0) - end) + defp validate_downloadable(pack) do + if downloadable?(pack), do: :ok, else: {:error, :cant_download} end - @spec load_pack(String.t()) :: t() | nil - def load_pack(name) do - pack_file = Path.join([emoji_path(), name, "pack.json"]) + defp copy_as(remote_pack, local_name) do + path = Path.join(emoji_path(), local_name) - if File.exists?(pack_file) do - pack_file - |> File.read!() - |> from_json() - |> Map.put(:pack_file, pack_file) - |> Map.put(:path, Path.dirname(pack_file)) - |> Map.put(:name, name) + %__MODULE__{ + name: local_name, + path: path, + files: remote_pack["files"], + pack_file: Path.join(path, "pack.json") + } + end + + defp unzip(archive, pack_info, remote_pack, local_pack) do + with :ok <- File.mkdir_p!(local_pack.path) do + files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) + # Fallback cannot contain a pack.json file + files = if pack_info[:fallback], do: files, else: ['pack.json' | files] + + :zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files) end end - defp from_json(json) do - map = Jason.decode!(json) + defp fetch_pack_info(remote_pack, uri, name) do + case remote_pack["pack"] do + %{"share-files" => true, "can-download" => true, "download-sha256" => sha} -> + {:ok, + %{ + sha: sha, + url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string() + }} + + %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> + {:ok, + %{ + sha: sha, + url: src, + fallback: true + }} - struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) + _ -> + {:error, "The pack was not set as shared and there is no fallback src to download from"} + end + end + + defp download_archive(url, sha) do + with {:ok, %{body: archive}} <- Tesla.get(url) do + if Base.decode16!(sha) == :crypto.hash(:sha256, archive) do + {:ok, archive} + else + {:error, :imvalid_checksum} + end + end + end + + defp fetch_archive(pack) do + hash = :crypto.hash(:md5, File.read!(pack.pack_file)) + + case Cachex.get!(:emoji_packs_cache, pack.name) do + %{hash: ^hash, pack_data: archive} -> archive + _ -> create_archive_and_cache(pack, hash) + end + end + + defp fallback_sha_changed?(pack, data) do + is_binary(data[:"fallback-src"]) and data[:"fallback-src"] != pack.pack["fallback-src"] + end + + defp update_sha_and_save_metadata(pack, data) do + with {:ok, %{body: zip}} <- Tesla.get(data[:"fallback-src"]), + :ok <- validate_has_all_files(pack, zip) do + fallback_sha = :sha256 |> :crypto.hash(zip) |> Base.encode16() + + data + |> Map.put("fallback-src-sha256", fallback_sha) + |> save_metadata(pack) + end end - defp shareable_packs_available?(uri) do - uri - |> URI.merge("/.well-known/nodeinfo") - |> to_string() - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> Map.get("links") - |> List.last() - |> Map.get("href") - # Get the actual nodeinfo address and fetch it - |> Tesla.get!() - |> Map.get(:body) - |> Jason.decode!() - |> get_in(["metadata", "features"]) - |> Enum.member?("shareable_emoji_packs") + defp validate_has_all_files(pack, zip) do + with {:ok, f_list} <- :zip.unzip(zip, [:memory]) do + # Check if all files from the pack.json are in the archive + pack.files + |> Enum.all?(fn {_, from_manifest} -> + List.keyfind(f_list, to_charlist(from_manifest), 0) + end) + |> if(do: :ok, else: {:error, :incomplete}) + end end end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex index e20c11860..834fc717e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex @@ -30,7 +30,7 @@ def remote(conn, %{url: url}) do with {:ok, packs} <- Pack.list_remote(url) do json(conn, packs) else - {:shareable, _} -> + {:error, :not_shareable} -> conn |> put_status(:internal_server_error) |> json(%{error: "The requested instance does not support sharing emoji packs"}) @@ -46,12 +46,12 @@ def index(conn, _params) do with {:ok, packs} <- Pack.list_local() do json(conn, packs) else - {:create_dir, {:error, e}} -> + {:error, :create_dir, e} -> conn |> put_status(:internal_server_error) |> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"}) - {:ls, {:error, e}} -> + {:error, :ls, e} -> conn |> put_status(:internal_server_error) |> json(%{ @@ -66,7 +66,7 @@ def show(conn, %{name: name}) do with {:ok, pack} <- Pack.show(name) do json(conn, pack) else - {:loaded, _} -> + {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Pack #{name} does not exist"}) @@ -82,7 +82,7 @@ def archive(conn, %{name: name}) do with {:ok, archive} <- Pack.get_archive(name) do send_download(conn, {:binary, archive}, filename: "#{name}.zip") else - {:can_download?, _} -> + {:error, :cant_download} -> conn |> put_status(:forbidden) |> json(%{ @@ -90,7 +90,7 @@ def archive(conn, %{name: name}) do "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" }) - {:exists?, _} -> + {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Pack #{name} does not exist"}) @@ -98,15 +98,15 @@ def archive(conn, %{name: name}) do end def download(%{body_params: %{url: url, name: name} = params} = conn, _) do - with :ok <- Pack.download(name, url, params[:as]) do + with {:ok, _pack} <- Pack.download(name, url, params[:as]) do json(conn, "ok") else - {:shareable, _} -> + {:error, :not_shareable} -> conn |> put_status(:internal_server_error) |> json(%{error: "The requested instance does not support sharing emoji packs"}) - {:checksum, _} -> + {:error, :imvalid_checksum} -> conn |> put_status(:internal_server_error) |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) @@ -121,7 +121,7 @@ def download(%{body_params: %{url: url, name: name} = params} = conn, _) do def create(conn, %{name: name}) do name = String.trim(name) - with :ok <- Pack.create(name) do + with {:ok, _pack} <- Pack.create(name) do json(conn, "ok") else {:error, :eexist} -> @@ -170,7 +170,7 @@ def update(%{body_params: %{metadata: metadata}} = conn, %{name: name}) do with {:ok, pack} <- Pack.update_metadata(name, metadata) do json(conn, pack.pack) else - {:has_all_files?, _} -> + {:error, :incomplete} -> conn |> put_status(:bad_request) |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) @@ -191,12 +191,12 @@ def add_file(%{body_params: params} = conn, %{name: name}) do with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params[:file]) do json(conn, pack.files) else - {:exists, _} -> + {:error, :already_exists} -> conn |> put_status(:conflict) |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) - {:loaded, _} -> + {:error, :not_found} -> conn |> put_status(:bad_request) |> json(%{error: "pack \"#{name}\" is not found"}) @@ -223,12 +223,12 @@ def update_file(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do json(conn, pack.files) else - {:exists, _} -> + {:error, :doesnt_exist} -> conn |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - {:not_used, _} -> + {:error, :already_exists} -> conn |> put_status(:conflict) |> json(%{ @@ -236,7 +236,7 @@ def update_file(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" }) - {:loaded, _} -> + {:error, :not_found} -> conn |> put_status(:bad_request) |> json(%{error: "pack \"#{name}\" is not found"}) @@ -259,12 +259,12 @@ def delete_file(conn, %{name: name, shortcode: shortcode}) do with {:ok, pack} <- Pack.delete_file(name, shortcode) do json(conn, pack.files) else - {:exists, _} -> + {:error, :doesnt_exist} -> conn |> put_status(:bad_request) |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - {:loaded, _} -> + {:error, :not_found} -> conn |> put_status(:bad_request) |> json(%{error: "pack \"#{name}\" is not found"}) diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs index c625a5c43..6871111d7 100644 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs @@ -234,10 +234,8 @@ test "checksum fail", %{admin_conn: admin_conn} do method: :get, url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha" } -> - %Tesla.Env{ - status: 200, - body: Pleroma.Emoji.Pack.load_pack("pack_bad_sha") |> Jason.encode!() - } + {:ok, pack} = Pleroma.Emoji.Pack.load_pack("pack_bad_sha") + %Tesla.Env{status: 200, body: Jason.encode!(pack)} %{ method: :get, @@ -273,10 +271,8 @@ test "other error", %{admin_conn: admin_conn} do method: :get, url: "https://example.com/api/pleroma/emoji/packs/test_pack" } -> - %Tesla.Env{ - status: 200, - body: Pleroma.Emoji.Pack.load_pack("test_pack") |> Jason.encode!() - } + {:ok, pack} = Pleroma.Emoji.Pack.load_pack("test_pack") + %Tesla.Env{status: 200, body: Jason.encode!(pack)} end) assert admin_conn -- cgit v1.2.3 From aef31c69df0424491a3c0bf45fbf46e2da132580 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 18 May 2020 19:38:22 +0400 Subject: Rename EmojiAPIController to EmojiPackController --- .../api_spec/operations/pleroma_emoji_operation.ex | 390 ----------- .../operations/pleroma_emoji_pack_operation.ex | 390 +++++++++++ .../controllers/emoji_api_controller.ex | 304 -------- .../controllers/emoji_pack_controller.ex | 304 ++++++++ lib/pleroma/web/router.ex | 24 +- .../controllers/emoji_api_controller_test.exs | 780 --------------------- .../controllers/emoji_pack_controller_test.exs | 780 +++++++++++++++++++++ 7 files changed, 1486 insertions(+), 1486 deletions(-) delete mode 100644 lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex delete mode 100644 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex delete mode 100644 test/web/pleroma_api/controllers/emoji_api_controller_test.exs create mode 100644 test/web/pleroma_api/controllers/emoji_pack_controller_test.exs diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex deleted file mode 100644 index fc881e657..000000000 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_operation.ex +++ /dev/null @@ -1,390 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.PleromaEmojiOperation do - alias OpenApiSpex.Operation - alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.ApiError - - import Pleroma.Web.ApiSpec.Helpers - - def open_api_operation(action) do - operation = String.to_existing_atom("#{action}_operation") - apply(__MODULE__, operation, []) - end - - def remote_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Make request to another instance for emoji packs list", - security: [%{"oAuth" => ["write"]}], - parameters: [url_param()], - operationId: "PleromaAPI.EmojiAPIController.remote", - responses: %{ - 200 => emoji_packs_response(), - 500 => Operation.response("Error", "application/json", ApiError) - } - } - end - - def index_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Lists local custom emoji packs", - operationId: "PleromaAPI.EmojiAPIController.index", - responses: %{ - 200 => emoji_packs_response() - } - } - end - - def show_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Show emoji pack", - operationId: "PleromaAPI.EmojiAPIController.show", - parameters: [name_param()], - responses: %{ - 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), - 400 => Operation.response("Bad Request", "application/json", ApiError), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - - def archive_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Requests a local pack archive from the instance", - operationId: "PleromaAPI.EmojiAPIController.archive", - parameters: [name_param()], - responses: %{ - 200 => - Operation.response("Archive file", "application/octet-stream", %Schema{ - type: :string, - format: :binary - }), - 403 => Operation.response("Forbidden", "application/json", ApiError), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - - def download_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Download pack from another instance", - operationId: "PleromaAPI.EmojiAPIController.download", - security: [%{"oAuth" => ["write"]}], - requestBody: request_body("Parameters", download_request(), required: true), - responses: %{ - 200 => ok_response(), - 500 => Operation.response("Error", "application/json", ApiError) - } - } - end - - defp download_request do - %Schema{ - type: :object, - required: [:url, :name], - properties: %{ - url: %Schema{ - type: :string, - format: :uri, - description: "URL of the instance to download from" - }, - name: %Schema{type: :string, format: :uri, description: "Pack Name"}, - as: %Schema{type: :string, format: :uri, description: "Save as"} - } - } - end - - def create_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Create an empty pack", - operationId: "PleromaAPI.EmojiAPIController.create", - security: [%{"oAuth" => ["write"]}], - parameters: [name_param()], - responses: %{ - 200 => ok_response(), - 400 => Operation.response("Not Found", "application/json", ApiError), - 409 => Operation.response("Conflict", "application/json", ApiError), - 500 => Operation.response("Error", "application/json", ApiError) - } - } - end - - def delete_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Delete a custom emoji pack", - operationId: "PleromaAPI.EmojiAPIController.delete", - security: [%{"oAuth" => ["write"]}], - parameters: [name_param()], - responses: %{ - 200 => ok_response(), - 400 => Operation.response("Bad Request", "application/json", ApiError), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - - def update_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Updates (replaces) pack metadata", - operationId: "PleromaAPI.EmojiAPIController.update", - security: [%{"oAuth" => ["write"]}], - requestBody: request_body("Parameters", update_request(), required: true), - parameters: [name_param()], - responses: %{ - 200 => Operation.response("Metadata", "application/json", metadata()), - 400 => Operation.response("Bad Request", "application/json", ApiError) - } - } - end - - def add_file_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Add new file to the pack", - operationId: "PleromaAPI.EmojiAPIController.add_file", - security: [%{"oAuth" => ["write"]}], - requestBody: request_body("Parameters", add_file_request(), required: true), - parameters: [name_param()], - responses: %{ - 200 => Operation.response("Files Object", "application/json", files_object()), - 400 => Operation.response("Bad Request", "application/json", ApiError), - 409 => Operation.response("Conflict", "application/json", ApiError) - } - } - end - - defp add_file_request do - %Schema{ - type: :object, - required: [:file], - properties: %{ - file: %Schema{ - description: - "File needs to be uploaded with the multipart request or link to remote file", - anyOf: [ - %Schema{type: :string, format: :binary}, - %Schema{type: :string, format: :uri} - ] - }, - shortcode: %Schema{ - type: :string, - description: - "Shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename." - }, - filename: %Schema{ - type: :string, - description: - "New emoji file name. If not specified will be taken from original filename." - } - } - } - end - - def update_file_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Add new file to the pack", - operationId: "PleromaAPI.EmojiAPIController.update_file", - security: [%{"oAuth" => ["write"]}], - requestBody: request_body("Parameters", update_file_request(), required: true), - parameters: [name_param()], - responses: %{ - 200 => Operation.response("Files Object", "application/json", files_object()), - 400 => Operation.response("Bad Request", "application/json", ApiError), - 409 => Operation.response("Conflict", "application/json", ApiError) - } - } - end - - defp update_file_request do - %Schema{ - type: :object, - required: [:shortcode, :new_shortcode, :new_filename], - properties: %{ - shortcode: %Schema{ - type: :string, - description: "Emoji file shortcode" - }, - new_shortcode: %Schema{ - type: :string, - description: "New emoji file shortcode" - }, - new_filename: %Schema{ - type: :string, - description: "New filename for emoji file" - }, - force: %Schema{ - type: :boolean, - description: "With true value to overwrite existing emoji with new shortcode", - default: false - } - } - } - end - - def delete_file_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Delete emoji file from pack", - operationId: "PleromaAPI.EmojiAPIController.delete_file", - security: [%{"oAuth" => ["write"]}], - parameters: [ - name_param(), - Operation.parameter(:shortcode, :query, :string, "File shortcode", - example: "cofe", - required: true - ) - ], - responses: %{ - 200 => Operation.response("Files Object", "application/json", files_object()), - 400 => Operation.response("Bad Request", "application/json", ApiError) - } - } - end - - def import_from_filesystem_operation do - %Operation{ - tags: ["Emoji Packs"], - summary: "Imports packs from filesystem", - operationId: "PleromaAPI.EmojiAPIController.import", - security: [%{"oAuth" => ["write"]}], - responses: %{ - 200 => - Operation.response("Array of imported pack names", "application/json", %Schema{ - type: :array, - items: %Schema{type: :string} - }) - } - } - end - - defp name_param do - Operation.parameter(:name, :path, :string, "Pack Name", example: "cofe", required: true) - end - - defp url_param do - Operation.parameter( - :url, - :query, - %Schema{type: :string, format: :uri}, - "URL of the instance", - required: true - ) - end - - defp ok_response do - Operation.response("Ok", "application/json", %Schema{type: :string, example: "ok"}) - end - - defp emoji_packs_response do - Operation.response( - "Object with pack names as keys and pack contents as values", - "application/json", - %Schema{ - type: :object, - additionalProperties: emoji_pack(), - example: %{ - "emojos" => emoji_pack().example - } - } - ) - end - - defp emoji_pack do - %Schema{ - title: "EmojiPack", - type: :object, - properties: %{ - files: files_object(), - pack: %Schema{ - type: :object, - properties: %{ - license: %Schema{type: :string}, - homepage: %Schema{type: :string, format: :uri}, - description: %Schema{type: :string}, - "can-download": %Schema{type: :boolean}, - "share-files": %Schema{type: :boolean}, - "download-sha256": %Schema{type: :string} - } - } - }, - example: %{ - "files" => %{"emacs" => "emacs.png", "guix" => "guix.png"}, - "pack" => %{ - "license" => "Test license", - "homepage" => "https://pleroma.social", - "description" => "Test description", - "can-download" => true, - "share-files" => true, - "download-sha256" => "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238" - } - } - } - end - - defp files_object do - %Schema{ - type: :object, - additionalProperties: %Schema{type: :string}, - description: "Object with emoji names as keys and filenames as values" - } - end - - defp update_request do - %Schema{ - type: :object, - properties: %{ - metadata: %Schema{ - type: :object, - description: "Metadata to replace the old one", - properties: %{ - license: %Schema{type: :string}, - homepage: %Schema{type: :string, format: :uri}, - description: %Schema{type: :string}, - "fallback-src": %Schema{ - type: :string, - format: :uri, - description: "Fallback url to download pack from" - }, - "fallback-src-sha256": %Schema{ - type: :string, - description: "SHA256 encoded for fallback pack archive" - }, - "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} - } - } - } - } - end - - defp metadata do - %Schema{ - type: :object, - properties: %{ - license: %Schema{type: :string}, - homepage: %Schema{type: :string, format: :uri}, - description: %Schema{type: :string}, - "fallback-src": %Schema{ - type: :string, - format: :uri, - description: "Fallback url to download pack from" - }, - "fallback-src-sha256": %Schema{ - type: :string, - description: "SHA256 encoded for fallback pack archive" - }, - "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} - } - } - end -end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex new file mode 100644 index 000000000..439127935 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -0,0 +1,390 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaEmojiPackOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def remote_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Make request to another instance for emoji packs list", + security: [%{"oAuth" => ["write"]}], + parameters: [url_param()], + operationId: "PleromaAPI.EmojiPackController.remote", + responses: %{ + 200 => emoji_packs_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def index_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Lists local custom emoji packs", + operationId: "PleromaAPI.EmojiPackController.index", + responses: %{ + 200 => emoji_packs_response() + } + } + end + + def show_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Show emoji pack", + operationId: "PleromaAPI.EmojiPackController.show", + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def archive_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Requests a local pack archive from the instance", + operationId: "PleromaAPI.EmojiPackController.archive", + parameters: [name_param()], + responses: %{ + 200 => + Operation.response("Archive file", "application/octet-stream", %Schema{ + type: :string, + format: :binary + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def download_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Download pack from another instance", + operationId: "PleromaAPI.EmojiPackController.download", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", download_request(), required: true), + responses: %{ + 200 => ok_response(), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp download_request do + %Schema{ + type: :object, + required: [:url, :name], + properties: %{ + url: %Schema{ + type: :string, + format: :uri, + description: "URL of the instance to download from" + }, + name: %Schema{type: :string, format: :uri, description: "Pack Name"}, + as: %Schema{type: :string, format: :uri, description: "Save as"} + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Create an empty pack", + operationId: "PleromaAPI.EmojiPackController.create", + security: [%{"oAuth" => ["write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Not Found", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError), + 500 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Delete a custom emoji pack", + operationId: "PleromaAPI.EmojiPackController.delete", + security: [%{"oAuth" => ["write"]}], + parameters: [name_param()], + responses: %{ + 200 => ok_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Updates (replaces) pack metadata", + operationId: "PleromaAPI.EmojiPackController.update", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Metadata", "application/json", metadata()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def add_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiPackController.add_file", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", add_file_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError) + } + } + end + + defp add_file_request do + %Schema{ + type: :object, + required: [:file], + properties: %{ + file: %Schema{ + description: + "File needs to be uploaded with the multipart request or link to remote file", + anyOf: [ + %Schema{type: :string, format: :binary}, + %Schema{type: :string, format: :uri} + ] + }, + shortcode: %Schema{ + type: :string, + description: + "Shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename." + }, + filename: %Schema{ + type: :string, + description: + "New emoji file name. If not specified will be taken from original filename." + } + } + } + end + + def update_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Add new file to the pack", + operationId: "PleromaAPI.EmojiPackController.update_file", + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_file_request(), required: true), + parameters: [name_param()], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 409 => Operation.response("Conflict", "application/json", ApiError) + } + } + end + + defp update_file_request do + %Schema{ + type: :object, + required: [:shortcode, :new_shortcode, :new_filename], + properties: %{ + shortcode: %Schema{ + type: :string, + description: "Emoji file shortcode" + }, + new_shortcode: %Schema{ + type: :string, + description: "New emoji file shortcode" + }, + new_filename: %Schema{ + type: :string, + description: "New filename for emoji file" + }, + force: %Schema{ + type: :boolean, + description: "With true value to overwrite existing emoji with new shortcode", + default: false + } + } + } + end + + def delete_file_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Delete emoji file from pack", + operationId: "PleromaAPI.EmojiPackController.delete_file", + security: [%{"oAuth" => ["write"]}], + parameters: [ + name_param(), + Operation.parameter(:shortcode, :query, :string, "File shortcode", + example: "cofe", + required: true + ) + ], + responses: %{ + 200 => Operation.response("Files Object", "application/json", files_object()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def import_from_filesystem_operation do + %Operation{ + tags: ["Emoji Packs"], + summary: "Imports packs from filesystem", + operationId: "PleromaAPI.EmojiPackController.import", + security: [%{"oAuth" => ["write"]}], + responses: %{ + 200 => + Operation.response("Array of imported pack names", "application/json", %Schema{ + type: :array, + items: %Schema{type: :string} + }) + } + } + end + + defp name_param do + Operation.parameter(:name, :path, :string, "Pack Name", example: "cofe", required: true) + end + + defp url_param do + Operation.parameter( + :url, + :query, + %Schema{type: :string, format: :uri}, + "URL of the instance", + required: true + ) + end + + defp ok_response do + Operation.response("Ok", "application/json", %Schema{type: :string, example: "ok"}) + end + + defp emoji_packs_response do + Operation.response( + "Object with pack names as keys and pack contents as values", + "application/json", + %Schema{ + type: :object, + additionalProperties: emoji_pack(), + example: %{ + "emojos" => emoji_pack().example + } + } + ) + end + + defp emoji_pack do + %Schema{ + title: "EmojiPack", + type: :object, + properties: %{ + files: files_object(), + pack: %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "can-download": %Schema{type: :boolean}, + "share-files": %Schema{type: :boolean}, + "download-sha256": %Schema{type: :string} + } + } + }, + example: %{ + "files" => %{"emacs" => "emacs.png", "guix" => "guix.png"}, + "pack" => %{ + "license" => "Test license", + "homepage" => "https://pleroma.social", + "description" => "Test description", + "can-download" => true, + "share-files" => true, + "download-sha256" => "57482F30674FD3DE821FF48C81C00DA4D4AF1F300209253684ABA7075E5FC238" + } + } + } + end + + defp files_object do + %Schema{ + type: :object, + additionalProperties: %Schema{type: :string}, + description: "Object with emoji names as keys and filenames as values" + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + metadata: %Schema{ + type: :object, + description: "Metadata to replace the old one", + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + } + } + end + + defp metadata do + %Schema{ + type: :object, + properties: %{ + license: %Schema{type: :string}, + homepage: %Schema{type: :string, format: :uri}, + description: %Schema{type: :string}, + "fallback-src": %Schema{ + type: :string, + format: :uri, + description: "Fallback url to download pack from" + }, + "fallback-src-sha256": %Schema{ + type: :string, + description: "SHA256 encoded for fallback pack archive" + }, + "share-files": %Schema{type: :boolean, description: "Is pack allowed for sharing?"} + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex deleted file mode 100644 index 834fc717e..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex +++ /dev/null @@ -1,304 +0,0 @@ -defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do - use Pleroma.Web, :controller - - alias Pleroma.Emoji.Pack - - plug(Pleroma.Web.ApiSpec.CastAndValidate) - - plug( - Pleroma.Plugs.OAuthScopesPlug, - %{scopes: ["write"], admin: true} - when action in [ - :import_from_filesystem, - :remote, - :download, - :create, - :update, - :delete, - :add_file, - :update_file, - :delete_file - ] - ) - - @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] - plug(:skip_plug, @skip_plugs when action in [:archive, :show, :list]) - - defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiOperation - - def remote(conn, %{url: url}) do - with {:ok, packs} <- Pack.list_remote(url) do - json(conn, packs) - else - {:error, :not_shareable} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) - end - end - - def index(conn, _params) do - emoji_path = - [:instance, :static_dir] - |> Pleroma.Config.get!() - |> Path.join("emoji") - - with {:ok, packs} <- Pack.list_local() do - json(conn, packs) - else - {:error, :create_dir, e} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"}) - - {:error, :ls, e} -> - conn - |> put_status(:internal_server_error) - |> json(%{ - error: "Failed to get the contents of the emoji pack directory at #{emoji_path}: #{e}" - }) - end - end - - def show(conn, %{name: name}) do - name = String.trim(name) - - with {:ok, pack} <- Pack.show(name) do - json(conn, pack) - else - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Pack #{name} does not exist"}) - - {:error, :empty_values} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack name cannot be empty"}) - end - end - - def archive(conn, %{name: name}) do - with {:ok, archive} <- Pack.get_archive(name) do - send_download(conn, {:binary, archive}, filename: "#{name}.zip") - else - {:error, :cant_download} -> - conn - |> put_status(:forbidden) - |> json(%{ - error: - "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" - }) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Pack #{name} does not exist"}) - end - end - - def download(%{body_params: %{url: url, name: name} = params} = conn, _) do - with {:ok, _pack} <- Pack.download(name, url, params[:as]) do - json(conn, "ok") - else - {:error, :not_shareable} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "The requested instance does not support sharing emoji packs"}) - - {:error, :imvalid_checksum} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) - - {:error, e} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: e}) - end - end - - def create(conn, %{name: name}) do - name = String.trim(name) - - with {:ok, _pack} <- Pack.create(name) do - json(conn, "ok") - else - {:error, :eexist} -> - conn - |> put_status(:conflict) - |> json(%{error: "A pack named \"#{name}\" already exists"}) - - {:error, :empty_values} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack name cannot be empty"}) - - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while creating pack." - ) - end - end - - def delete(conn, %{name: name}) do - name = String.trim(name) - - with {:ok, deleted} when deleted != [] <- Pack.delete(name) do - json(conn, "ok") - else - {:ok, []} -> - conn - |> put_status(:not_found) - |> json(%{error: "Pack #{name} does not exist"}) - - {:error, :empty_values} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack name cannot be empty"}) - - {:error, _, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Couldn't delete the pack #{name}"}) - end - end - - def update(%{body_params: %{metadata: metadata}} = conn, %{name: name}) do - with {:ok, pack} <- Pack.update_metadata(name, metadata) do - json(conn, pack.pack) - else - {:error, :incomplete} -> - conn - |> put_status(:bad_request) - |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) - - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while updating pack metadata." - ) - end - end - - def add_file(%{body_params: params} = conn, %{name: name}) do - filename = params[:filename] || get_filename(params[:file]) - shortcode = params[:shortcode] || Path.basename(filename, Path.extname(filename)) - - with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params[:file]) do - json(conn, pack.files) - else - {:error, :already_exists} -> - conn - |> put_status(:conflict) - |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) - - {:error, :not_found} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack \"#{name}\" is not found"}) - - {:error, :empty_values} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack name, shortcode or filename cannot be empty"}) - - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while adding file to pack." - ) - end - end - - def update_file(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: name}) do - new_shortcode = params[:new_shortcode] - new_filename = params[:new_filename] - force = params[:force] - - with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do - json(conn, pack.files) - else - {:error, :doesnt_exist} -> - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - - {:error, :already_exists} -> - conn - |> put_status(:conflict) - |> json(%{ - error: - "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" - }) - - {:error, :not_found} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack \"#{name}\" is not found"}) - - {:error, :empty_values} -> - conn - |> put_status(:bad_request) - |> json(%{error: "new_shortcode or new_filename cannot be empty"}) - - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while updating file in pack." - ) - end - end - - def delete_file(conn, %{name: name, shortcode: shortcode}) do - with {:ok, pack} <- Pack.delete_file(name, shortcode) do - json(conn, pack.files) - else - {:error, :doesnt_exist} -> - conn - |> put_status(:bad_request) - |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) - - {:error, :not_found} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack \"#{name}\" is not found"}) - - {:error, :empty_values} -> - conn - |> put_status(:bad_request) - |> json(%{error: "pack name or shortcode cannot be empty"}) - - {:error, _} -> - render_error( - conn, - :internal_server_error, - "Unexpected error occurred while removing file from pack." - ) - end - end - - def import_from_filesystem(conn, _params) do - with {:ok, names} <- Pack.import_from_filesystem() do - json(conn, names) - else - {:error, :no_read_write} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Error: emoji pack directory must be writable"}) - - {:error, _} -> - conn - |> put_status(:internal_server_error) - |> json(%{error: "Error accessing emoji pack directory"}) - end - end - - defp get_filename(%Plug.Upload{filename: filename}), do: filename - defp get_filename(url) when is_binary(url), do: Path.basename(url) -end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex new file mode 100644 index 000000000..2c53dcde1 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -0,0 +1,304 @@ +defmodule Pleroma.Web.PleromaAPI.EmojiPackController do + use Pleroma.Web, :controller + + alias Pleroma.Emoji.Pack + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + Pleroma.Plugs.OAuthScopesPlug, + %{scopes: ["write"], admin: true} + when action in [ + :import_from_filesystem, + :remote, + :download, + :create, + :update, + :delete, + :add_file, + :update_file, + :delete_file + ] + ) + + @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] + plug(:skip_plug, @skip_plugs when action in [:archive, :show, :list]) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiPackOperation + + def remote(conn, %{url: url}) do + with {:ok, packs} <- Pack.list_remote(url) do + json(conn, packs) + else + {:error, :not_shareable} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + end + end + + def index(conn, _params) do + emoji_path = + [:instance, :static_dir] + |> Pleroma.Config.get!() + |> Path.join("emoji") + + with {:ok, packs} <- Pack.list_local() do + json(conn, packs) + else + {:error, :create_dir, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Failed to create the emoji pack directory at #{emoji_path}: #{e}"}) + + {:error, :ls, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{ + error: "Failed to get the contents of the emoji pack directory at #{emoji_path}: #{e}" + }) + end + end + + def show(conn, %{name: name}) do + name = String.trim(name) + + with {:ok, pack} <- Pack.show(name) do + json(conn, pack) + else + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) + end + end + + def archive(conn, %{name: name}) do + with {:ok, archive} <- Pack.get_archive(name) do + send_download(conn, {:binary, archive}, filename: "#{name}.zip") + else + {:error, :cant_download} -> + conn + |> put_status(:forbidden) + |> json(%{ + error: + "Pack #{name} cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" + }) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + end + end + + def download(%{body_params: %{url: url, name: name} = params} = conn, _) do + with {:ok, _pack} <- Pack.download(name, url, params[:as]) do + json(conn, "ok") + else + {:error, :not_shareable} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "The requested instance does not support sharing emoji packs"}) + + {:error, :imvalid_checksum} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) + + {:error, e} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: e}) + end + end + + def create(conn, %{name: name}) do + name = String.trim(name) + + with {:ok, _pack} <- Pack.create(name) do + json(conn, "ok") + else + {:error, :eexist} -> + conn + |> put_status(:conflict) + |> json(%{error: "A pack named \"#{name}\" already exists"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while creating pack." + ) + end + end + + def delete(conn, %{name: name}) do + name = String.trim(name) + + with {:ok, deleted} when deleted != [] <- Pack.delete(name) do + json(conn, "ok") + else + {:ok, []} -> + conn + |> put_status(:not_found) + |> json(%{error: "Pack #{name} does not exist"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name cannot be empty"}) + + {:error, _, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Couldn't delete the pack #{name}"}) + end + end + + def update(%{body_params: %{metadata: metadata}} = conn, %{name: name}) do + with {:ok, pack} <- Pack.update_metadata(name, metadata) do + json(conn, pack.pack) + else + {:error, :incomplete} -> + conn + |> put_status(:bad_request) + |> json(%{error: "The fallback archive does not have all files specified in pack.json"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating pack metadata." + ) + end + end + + def add_file(%{body_params: params} = conn, %{name: name}) do + filename = params[:filename] || get_filename(params[:file]) + shortcode = params[:shortcode] || Path.basename(filename, Path.extname(filename)) + + with {:ok, pack} <- Pack.add_file(name, shortcode, filename, params[:file]) do + json(conn, pack.files) + else + {:error, :already_exists} -> + conn + |> put_status(:conflict) + |> json(%{error: "An emoji with the \"#{shortcode}\" shortcode already exists"}) + + {:error, :not_found} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name, shortcode or filename cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while adding file to pack." + ) + end + end + + def update_file(%{body_params: %{shortcode: shortcode} = params} = conn, %{name: name}) do + new_shortcode = params[:new_shortcode] + new_filename = params[:new_filename] + force = params[:force] + + with {:ok, pack} <- Pack.update_file(name, shortcode, new_shortcode, new_filename, force) do + json(conn, pack.files) + else + {:error, :doesnt_exist} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + + {:error, :already_exists} -> + conn + |> put_status(:conflict) + |> json(%{ + error: + "New shortcode \"#{new_shortcode}\" is already used. If you want to override emoji use 'force' option" + }) + + {:error, :not_found} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "new_shortcode or new_filename cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while updating file in pack." + ) + end + end + + def delete_file(conn, %{name: name, shortcode: shortcode}) do + with {:ok, pack} <- Pack.delete_file(name, shortcode) do + json(conn, pack.files) + else + {:error, :doesnt_exist} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Emoji \"#{shortcode}\" does not exist"}) + + {:error, :not_found} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack \"#{name}\" is not found"}) + + {:error, :empty_values} -> + conn + |> put_status(:bad_request) + |> json(%{error: "pack name or shortcode cannot be empty"}) + + {:error, _} -> + render_error( + conn, + :internal_server_error, + "Unexpected error occurred while removing file from pack." + ) + end + end + + def import_from_filesystem(conn, _params) do + with {:ok, names} <- Pack.import_from_filesystem() do + json(conn, names) + else + {:error, :no_read_write} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Error: emoji pack directory must be writable"}) + + {:error, _} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Error accessing emoji pack directory"}) + end + end + + defp get_filename(%Plug.Upload{filename: filename}), do: filename + defp get_filename(url) when is_binary(url), do: Path.basename(url) +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0d4ebf4ce..9eec66e65 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -216,25 +216,25 @@ defmodule Pleroma.Web.Router do scope "/packs" do pipe_through(:admin_api) - get("/import", EmojiAPIController, :import_from_filesystem) - get("/remote", EmojiAPIController, :remote) - post("/download", EmojiAPIController, :download) + get("/import", EmojiPackController, :import_from_filesystem) + get("/remote", EmojiPackController, :remote) + post("/download", EmojiPackController, :download) - post("/:name", EmojiAPIController, :create) - patch("/:name", EmojiAPIController, :update) - delete("/:name", EmojiAPIController, :delete) + post("/:name", EmojiPackController, :create) + patch("/:name", EmojiPackController, :update) + delete("/:name", EmojiPackController, :delete) - post("/:name/files", EmojiAPIController, :add_file) - patch("/:name/files", EmojiAPIController, :update_file) - delete("/:name/files", EmojiAPIController, :delete_file) + post("/:name/files", EmojiPackController, :add_file) + patch("/:name/files", EmojiPackController, :update_file) + delete("/:name/files", EmojiPackController, :delete_file) end # Pack info / downloading scope "/packs" do pipe_through(:api) - get("/", EmojiAPIController, :index) - get("/:name", EmojiAPIController, :show) - get("/:name/archive", EmojiAPIController, :archive) + get("/", EmojiPackController, :index) + get("/:name", EmojiPackController, :show) + get("/:name/archive", EmojiPackController, :archive) end end diff --git a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs b/test/web/pleroma_api/controllers/emoji_api_controller_test.exs deleted file mode 100644 index 6871111d7..000000000 --- a/test/web/pleroma_api/controllers/emoji_api_controller_test.exs +++ /dev/null @@ -1,780 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.EmojiAPIControllerTest do - use Pleroma.Web.ConnCase - - import Tesla.Mock - import Pleroma.Factory - - @emoji_path Path.join( - Pleroma.Config.get!([:instance, :static_dir]), - "emoji" - ) - setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) - - setup do - admin = insert(:user, is_admin: true) - token = insert(:oauth_admin_token, user: admin) - - admin_conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - - Pleroma.Emoji.reload() - {:ok, %{admin_conn: admin_conn}} - end - - test "GET /api/pleroma/emoji/packs", %{conn: conn} do - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - - shared = resp["test_pack"] - assert shared["files"] == %{"blank" => "blank.png"} - assert Map.has_key?(shared["pack"], "download-sha256") - assert shared["pack"]["can-download"] - assert shared["pack"]["share-files"] - - non_shared = resp["test_pack_nonshared"] - assert non_shared["pack"]["share-files"] == false - assert non_shared["pack"]["can-download"] == false - end - - describe "GET /api/pleroma/emoji/packs/remote" do - test "shareable instance", %{admin_conn: admin_conn, conn: conn} do - resp = - conn - |> get("/api/pleroma/emoji/packs") - |> json_response_and_validate_schema(200) - - mock(fn - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - - %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} -> - json(resp) - end) - - assert admin_conn - |> get("/api/pleroma/emoji/packs/remote?url=https://example.com") - |> json_response_and_validate_schema(200) == resp - end - - test "non shareable instance", %{admin_conn: admin_conn} do - mock(fn - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: []}}) - end) - - assert admin_conn - |> get("/api/pleroma/emoji/packs/remote?url=https://example.com") - |> json_response_and_validate_schema(500) == %{ - "error" => "The requested instance does not support sharing emoji packs" - } - end - end - - describe "GET /api/pleroma/emoji/packs/:name/archive" do - test "download shared pack", %{conn: conn} do - resp = - conn - |> get("/api/pleroma/emoji/packs/test_pack/archive") - |> response(200) - - {:ok, arch} = :zip.unzip(resp, [:memory]) - - assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end) - assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end) - end - - test "non existing pack", %{conn: conn} do - assert conn - |> get("/api/pleroma/emoji/packs/test_pack_for_import/archive") - |> json_response_and_validate_schema(:not_found) == %{ - "error" => "Pack test_pack_for_import does not exist" - } - end - - test "non downloadable pack", %{conn: conn} do - assert conn - |> get("/api/pleroma/emoji/packs/test_pack_nonshared/archive") - |> json_response_and_validate_schema(:forbidden) == %{ - "error" => - "Pack test_pack_nonshared cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" - } - end - end - - describe "POST /api/pleroma/emoji/packs/download" do - test "shared pack from remote and non shared from fallback-src", %{ - admin_conn: admin_conn, - conn: conn - } do - mock(fn - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/test_pack" - } -> - conn - |> get("/api/pleroma/emoji/packs/test_pack") - |> json_response_and_validate_schema(200) - |> json() - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/test_pack/archive" - } -> - conn - |> get("/api/pleroma/emoji/packs/test_pack/archive") - |> response(200) - |> text() - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/test_pack_nonshared" - } -> - conn - |> get("/api/pleroma/emoji/packs/test_pack_nonshared") - |> json_response_and_validate_schema(200) - |> json() - - %{ - method: :get, - url: "https://nonshared-pack" - } -> - text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip")) - end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/download", %{ - url: "https://example.com", - name: "test_pack", - as: "test_pack2" - }) - |> json_response_and_validate_schema(200) == "ok" - - assert File.exists?("#{@emoji_path}/test_pack2/pack.json") - assert File.exists?("#{@emoji_path}/test_pack2/blank.png") - - assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack2") - |> json_response_and_validate_schema(200) == "ok" - - refute File.exists?("#{@emoji_path}/test_pack2") - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post( - "/api/pleroma/emoji/packs/download", - %{ - url: "https://example.com", - name: "test_pack_nonshared", - as: "test_pack_nonshared2" - } - ) - |> json_response_and_validate_schema(200) == "ok" - - assert File.exists?("#{@emoji_path}/test_pack_nonshared2/pack.json") - assert File.exists?("#{@emoji_path}/test_pack_nonshared2/blank.png") - - assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack_nonshared2") - |> json_response_and_validate_schema(200) == "ok" - - refute File.exists?("#{@emoji_path}/test_pack_nonshared2") - end - - test "nonshareable instance", %{admin_conn: admin_conn} do - mock(fn - %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]}) - - %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: []}}) - end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post( - "/api/pleroma/emoji/packs/download", - %{ - url: "https://old-instance", - name: "test_pack", - as: "test_pack2" - } - ) - |> json_response_and_validate_schema(500) == %{ - "error" => "The requested instance does not support sharing emoji packs" - } - end - - test "checksum fail", %{admin_conn: admin_conn} do - mock(fn - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha" - } -> - {:ok, pack} = Pleroma.Emoji.Pack.load_pack("pack_bad_sha") - %Tesla.Env{status: 200, body: Jason.encode!(pack)} - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha/archive" - } -> - %Tesla.Env{ - status: 200, - body: File.read!("test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip") - } - end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/download", %{ - url: "https://example.com", - name: "pack_bad_sha", - as: "pack_bad_sha2" - }) - |> json_response_and_validate_schema(:internal_server_error) == %{ - "error" => "SHA256 for the pack doesn't match the one sent by the server" - } - end - - test "other error", %{admin_conn: admin_conn} do - mock(fn - %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> - json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) - - %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> - json(%{metadata: %{features: ["shareable_emoji_packs"]}}) - - %{ - method: :get, - url: "https://example.com/api/pleroma/emoji/packs/test_pack" - } -> - {:ok, pack} = Pleroma.Emoji.Pack.load_pack("test_pack") - %Tesla.Env{status: 200, body: Jason.encode!(pack)} - end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/download", %{ - url: "https://example.com", - name: "test_pack", - as: "test_pack2" - }) - |> json_response_and_validate_schema(:internal_server_error) == %{ - "error" => - "The pack was not set as shared and there is no fallback src to download from" - } - end - end - - describe "PATCH /api/pleroma/emoji/packs/:name" do - setup do - pack_file = "#{@emoji_path}/test_pack/pack.json" - original_content = File.read!(pack_file) - - on_exit(fn -> - File.write!(pack_file, original_content) - end) - - {:ok, - pack_file: pack_file, - new_data: %{ - "license" => "Test license changed", - "homepage" => "https://pleroma.social", - "description" => "Test description", - "share-files" => false - }} - end - - test "for a pack without a fallback source", ctx do - assert ctx[:admin_conn] - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack", %{"metadata" => ctx[:new_data]}) - |> json_response_and_validate_schema(200) == ctx[:new_data] - - assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == ctx[:new_data] - end - - test "for a pack with a fallback source", ctx do - mock(fn - %{ - method: :get, - url: "https://nonshared-pack" - } -> - text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip")) - end) - - new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") - - new_data_with_sha = - Map.put( - new_data, - "fallback-src-sha256", - "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF" - ) - - assert ctx[:admin_conn] - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) - |> json_response_and_validate_schema(200) == new_data_with_sha - - assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == new_data_with_sha - end - - test "when the fallback source doesn't have all the files", ctx do - mock(fn - %{ - method: :get, - url: "https://nonshared-pack" - } -> - {:ok, {'empty.zip', empty_arch}} = :zip.zip('empty.zip', [], [:memory]) - text(empty_arch) - end) - - new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") - - assert ctx[:admin_conn] - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "The fallback archive does not have all files specified in pack.json" - } - end - end - - describe "POST/PATCH/DELETE /api/pleroma/emoji/packs/:name/files" do - setup do - pack_file = "#{@emoji_path}/test_pack/pack.json" - original_content = File.read!(pack_file) - - on_exit(fn -> - File.write!(pack_file, original_content) - end) - - :ok - end - - test "create shortcode exists", %{admin_conn: admin_conn} do - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank", - filename: "dir/blank.png", - file: %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_path}/test_pack/blank.png" - } - }) - |> json_response_and_validate_schema(:conflict) == %{ - "error" => "An emoji with the \"blank\" shortcode already exists" - } - end - - test "don't rewrite old emoji", %{admin_conn: admin_conn} do - on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir/") end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - filename: "dir/blank.png", - file: %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_path}/test_pack/blank.png" - } - }) - |> json_response_and_validate_schema(200) == %{ - "blank" => "blank.png", - "blank2" => "dir/blank.png" - } - - assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank", - new_shortcode: "blank2", - new_filename: "dir_2/blank_3.png" - }) - |> json_response_and_validate_schema(:conflict) == %{ - "error" => - "New shortcode \"blank2\" is already used. If you want to override emoji use 'force' option" - } - end - - test "rewrite old emoji with force option", %{admin_conn: admin_conn} do - on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir_2/") end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - filename: "dir/blank.png", - file: %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_path}/test_pack/blank.png" - } - }) - |> json_response_and_validate_schema(200) == %{ - "blank" => "blank.png", - "blank2" => "dir/blank.png" - } - - assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - new_shortcode: "blank3", - new_filename: "dir_2/blank_3.png", - force: true - }) - |> json_response_and_validate_schema(200) == %{ - "blank" => "blank.png", - "blank3" => "dir_2/blank_3.png" - } - - assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") - end - - test "with empty filename", %{admin_conn: admin_conn} do - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - filename: "", - file: %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_path}/test_pack/blank.png" - } - }) - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack name, shortcode or filename cannot be empty" - } - end - - test "add file with not loaded pack", %{admin_conn: admin_conn} do - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/not_loaded/files", %{ - shortcode: "blank2", - filename: "dir/blank.png", - file: %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_path}/test_pack/blank.png" - } - }) - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack \"not_loaded\" is not found" - } - end - - test "remove file with not loaded pack", %{admin_conn: admin_conn} do - assert admin_conn - |> delete("/api/pleroma/emoji/packs/not_loaded/files?shortcode=blank3") - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack \"not_loaded\" is not found" - } - end - - test "remove file with empty shortcode", %{admin_conn: admin_conn} do - assert admin_conn - |> delete("/api/pleroma/emoji/packs/not_loaded/files?shortcode=") - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack name or shortcode cannot be empty" - } - end - - test "update file with not loaded pack", %{admin_conn: admin_conn} do - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/not_loaded/files", %{ - shortcode: "blank4", - new_shortcode: "blank3", - new_filename: "dir_2/blank_3.png" - }) - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack \"not_loaded\" is not found" - } - end - - test "new with shortcode as file with update", %{admin_conn: admin_conn} do - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank4", - filename: "dir/blank.png", - file: %Plug.Upload{ - filename: "blank.png", - path: "#{@emoji_path}/test_pack/blank.png" - } - }) - |> json_response_and_validate_schema(200) == %{ - "blank" => "blank.png", - "blank4" => "dir/blank.png" - } - - assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank4", - new_shortcode: "blank3", - new_filename: "dir_2/blank_3.png" - }) - |> json_response_and_validate_schema(200) == %{ - "blank3" => "dir_2/blank_3.png", - "blank" => "blank.png" - } - - refute File.exists?("#{@emoji_path}/test_pack/dir/") - assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") - - assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") - |> json_response_and_validate_schema(200) == %{"blank" => "blank.png"} - - refute File.exists?("#{@emoji_path}/test_pack/dir_2/") - - on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir") end) - end - - test "new with shortcode from url", %{admin_conn: admin_conn} do - mock(fn - %{ - method: :get, - url: "https://test-blank/blank_url.png" - } -> - text(File.read!("#{@emoji_path}/test_pack/blank.png")) - end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank_url", - file: "https://test-blank/blank_url.png" - }) - |> json_response_and_validate_schema(200) == %{ - "blank_url" => "blank_url.png", - "blank" => "blank.png" - } - - assert File.exists?("#{@emoji_path}/test_pack/blank_url.png") - - on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/blank_url.png") end) - end - - test "new without shortcode", %{admin_conn: admin_conn} do - on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/shortcode.png") end) - - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - file: %Plug.Upload{ - filename: "shortcode.png", - path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" - } - }) - |> json_response_and_validate_schema(200) == %{ - "shortcode" => "shortcode.png", - "blank" => "blank.png" - } - end - - test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do - assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank2") - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "Emoji \"blank2\" does not exist" - } - end - - test "update non existing emoji", %{admin_conn: admin_conn} do - assert admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - new_shortcode: "blank3", - new_filename: "dir_2/blank_3.png" - }) - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "Emoji \"blank2\" does not exist" - } - end - - test "update with empty shortcode", %{admin_conn: admin_conn} do - assert %{ - "error" => "Missing field: new_shortcode." - } = - admin_conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank", - new_filename: "dir_2/blank_3.png" - }) - |> json_response_and_validate_schema(:bad_request) - end - end - - describe "POST/DELETE /api/pleroma/emoji/packs/:name" do - test "creating and deleting a pack", %{admin_conn: admin_conn} do - assert admin_conn - |> post("/api/pleroma/emoji/packs/test_created") - |> json_response_and_validate_schema(200) == "ok" - - assert File.exists?("#{@emoji_path}/test_created/pack.json") - - assert Jason.decode!(File.read!("#{@emoji_path}/test_created/pack.json")) == %{ - "pack" => %{}, - "files" => %{} - } - - assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_created") - |> json_response_and_validate_schema(200) == "ok" - - refute File.exists?("#{@emoji_path}/test_created/pack.json") - end - - test "if pack exists", %{admin_conn: admin_conn} do - path = Path.join(@emoji_path, "test_created") - File.mkdir(path) - pack_file = Jason.encode!(%{files: %{}, pack: %{}}) - File.write!(Path.join(path, "pack.json"), pack_file) - - assert admin_conn - |> post("/api/pleroma/emoji/packs/test_created") - |> json_response_and_validate_schema(:conflict) == %{ - "error" => "A pack named \"test_created\" already exists" - } - - on_exit(fn -> File.rm_rf(path) end) - end - - test "with empty name", %{admin_conn: admin_conn} do - assert admin_conn - |> post("/api/pleroma/emoji/packs/ ") - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack name cannot be empty" - } - end - end - - test "deleting nonexisting pack", %{admin_conn: admin_conn} do - assert admin_conn - |> delete("/api/pleroma/emoji/packs/non_existing") - |> json_response_and_validate_schema(:not_found) == %{ - "error" => "Pack non_existing does not exist" - } - end - - test "deleting with empty name", %{admin_conn: admin_conn} do - assert admin_conn - |> delete("/api/pleroma/emoji/packs/ ") - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack name cannot be empty" - } - end - - test "filesystem import", %{admin_conn: admin_conn, conn: conn} do - on_exit(fn -> - File.rm!("#{@emoji_path}/test_pack_for_import/emoji.txt") - File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") - end) - - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - - refute Map.has_key?(resp, "test_pack_for_import") - - assert admin_conn - |> get("/api/pleroma/emoji/packs/import") - |> json_response_and_validate_schema(200) == ["test_pack_for_import"] - - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} - - File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") - refute File.exists?("#{@emoji_path}/test_pack_for_import/pack.json") - - emoji_txt_content = """ - blank, blank.png, Fun - blank2, blank.png - foo, /emoji/test_pack_for_import/blank.png - bar - """ - - File.write!("#{@emoji_path}/test_pack_for_import/emoji.txt", emoji_txt_content) - - assert admin_conn - |> get("/api/pleroma/emoji/packs/import") - |> json_response_and_validate_schema(200) == ["test_pack_for_import"] - - resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - - assert resp["test_pack_for_import"]["files"] == %{ - "blank" => "blank.png", - "blank2" => "blank.png", - "foo" => "blank.png" - } - end - - describe "GET /api/pleroma/emoji/packs/:name" do - test "shows pack.json", %{conn: conn} do - assert %{ - "files" => %{"blank" => "blank.png"}, - "pack" => %{ - "can-download" => true, - "description" => "Test description", - "download-sha256" => _, - "homepage" => "https://pleroma.social", - "license" => "Test license", - "share-files" => true - } - } = - conn - |> get("/api/pleroma/emoji/packs/test_pack") - |> json_response_and_validate_schema(200) - end - - test "non existing pack", %{conn: conn} do - assert conn - |> get("/api/pleroma/emoji/packs/non_existing") - |> json_response_and_validate_schema(:not_found) == %{ - "error" => "Pack non_existing does not exist" - } - end - - test "error name", %{conn: conn} do - assert conn - |> get("/api/pleroma/emoji/packs/ ") - |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "pack name cannot be empty" - } - end - end -end diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs new file mode 100644 index 000000000..ee3d281a0 --- /dev/null +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -0,0 +1,780 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerTest do + use Pleroma.Web.ConnCase + + import Tesla.Mock + import Pleroma.Factory + + @emoji_path Path.join( + Pleroma.Config.get!([:instance, :static_dir]), + "emoji" + ) + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + admin_conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + Pleroma.Emoji.reload() + {:ok, %{admin_conn: admin_conn}} + end + + test "GET /api/pleroma/emoji/packs", %{conn: conn} do + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) + + shared = resp["test_pack"] + assert shared["files"] == %{"blank" => "blank.png"} + assert Map.has_key?(shared["pack"], "download-sha256") + assert shared["pack"]["can-download"] + assert shared["pack"]["share-files"] + + non_shared = resp["test_pack_nonshared"] + assert non_shared["pack"]["share-files"] == false + assert non_shared["pack"]["can-download"] == false + end + + describe "GET /api/pleroma/emoji/packs/remote" do + test "shareable instance", %{admin_conn: admin_conn, conn: conn} do + resp = + conn + |> get("/api/pleroma/emoji/packs") + |> json_response_and_validate_schema(200) + + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + + %{method: :get, url: "https://example.com/api/pleroma/emoji/packs"} -> + json(resp) + end) + + assert admin_conn + |> get("/api/pleroma/emoji/packs/remote?url=https://example.com") + |> json_response_and_validate_schema(200) == resp + end + + test "non shareable instance", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: []}}) + end) + + assert admin_conn + |> get("/api/pleroma/emoji/packs/remote?url=https://example.com") + |> json_response_and_validate_schema(500) == %{ + "error" => "The requested instance does not support sharing emoji packs" + } + end + end + + describe "GET /api/pleroma/emoji/packs/:name/archive" do + test "download shared pack", %{conn: conn} do + resp = + conn + |> get("/api/pleroma/emoji/packs/test_pack/archive") + |> response(200) + + {:ok, arch} = :zip.unzip(resp, [:memory]) + + assert Enum.find(arch, fn {n, _} -> n == 'pack.json' end) + assert Enum.find(arch, fn {n, _} -> n == 'blank.png' end) + end + + test "non existing pack", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/test_pack_for_import/archive") + |> json_response_and_validate_schema(:not_found) == %{ + "error" => "Pack test_pack_for_import does not exist" + } + end + + test "non downloadable pack", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/test_pack_nonshared/archive") + |> json_response_and_validate_schema(:forbidden) == %{ + "error" => + "Pack test_pack_nonshared cannot be downloaded from this instance, either pack sharing was disabled for this pack or some files are missing" + } + end + end + + describe "POST /api/pleroma/emoji/packs/download" do + test "shared pack from remote and non shared from fallback-src", %{ + admin_conn: admin_conn, + conn: conn + } do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack" + } -> + conn + |> get("/api/pleroma/emoji/packs/test_pack") + |> json_response_and_validate_schema(200) + |> json() + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack/archive" + } -> + conn + |> get("/api/pleroma/emoji/packs/test_pack/archive") + |> response(200) + |> text() + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack_nonshared" + } -> + conn + |> get("/api/pleroma/emoji/packs/test_pack_nonshared") + |> json_response_and_validate_schema(200) + |> json() + + %{ + method: :get, + url: "https://nonshared-pack" + } -> + text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip")) + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download", %{ + url: "https://example.com", + name: "test_pack", + as: "test_pack2" + }) + |> json_response_and_validate_schema(200) == "ok" + + assert File.exists?("#{@emoji_path}/test_pack2/pack.json") + assert File.exists?("#{@emoji_path}/test_pack2/blank.png") + + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_pack2") + |> json_response_and_validate_schema(200) == "ok" + + refute File.exists?("#{@emoji_path}/test_pack2") + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post( + "/api/pleroma/emoji/packs/download", + %{ + url: "https://example.com", + name: "test_pack_nonshared", + as: "test_pack_nonshared2" + } + ) + |> json_response_and_validate_schema(200) == "ok" + + assert File.exists?("#{@emoji_path}/test_pack_nonshared2/pack.json") + assert File.exists?("#{@emoji_path}/test_pack_nonshared2/blank.png") + + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_pack_nonshared2") + |> json_response_and_validate_schema(200) == "ok" + + refute File.exists?("#{@emoji_path}/test_pack_nonshared2") + end + + test "nonshareable instance", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://old-instance/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://old-instance/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://old-instance/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: []}}) + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post( + "/api/pleroma/emoji/packs/download", + %{ + url: "https://old-instance", + name: "test_pack", + as: "test_pack2" + } + ) + |> json_response_and_validate_schema(500) == %{ + "error" => "The requested instance does not support sharing emoji packs" + } + end + + test "checksum fail", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha" + } -> + {:ok, pack} = Pleroma.Emoji.Pack.load_pack("pack_bad_sha") + %Tesla.Env{status: 200, body: Jason.encode!(pack)} + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/pack_bad_sha/archive" + } -> + %Tesla.Env{ + status: 200, + body: File.read!("test/instance_static/emoji/pack_bad_sha/pack_bad_sha.zip") + } + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download", %{ + url: "https://example.com", + name: "pack_bad_sha", + as: "pack_bad_sha2" + }) + |> json_response_and_validate_schema(:internal_server_error) == %{ + "error" => "SHA256 for the pack doesn't match the one sent by the server" + } + end + + test "other error", %{admin_conn: admin_conn} do + mock(fn + %{method: :get, url: "https://example.com/.well-known/nodeinfo"} -> + json(%{links: [%{href: "https://example.com/nodeinfo/2.1.json"}]}) + + %{method: :get, url: "https://example.com/nodeinfo/2.1.json"} -> + json(%{metadata: %{features: ["shareable_emoji_packs"]}}) + + %{ + method: :get, + url: "https://example.com/api/pleroma/emoji/packs/test_pack" + } -> + {:ok, pack} = Pleroma.Emoji.Pack.load_pack("test_pack") + %Tesla.Env{status: 200, body: Jason.encode!(pack)} + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/download", %{ + url: "https://example.com", + name: "test_pack", + as: "test_pack2" + }) + |> json_response_and_validate_schema(:internal_server_error) == %{ + "error" => + "The pack was not set as shared and there is no fallback src to download from" + } + end + end + + describe "PATCH /api/pleroma/emoji/packs/:name" do + setup do + pack_file = "#{@emoji_path}/test_pack/pack.json" + original_content = File.read!(pack_file) + + on_exit(fn -> + File.write!(pack_file, original_content) + end) + + {:ok, + pack_file: pack_file, + new_data: %{ + "license" => "Test license changed", + "homepage" => "https://pleroma.social", + "description" => "Test description", + "share-files" => false + }} + end + + test "for a pack without a fallback source", ctx do + assert ctx[:admin_conn] + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack", %{"metadata" => ctx[:new_data]}) + |> json_response_and_validate_schema(200) == ctx[:new_data] + + assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == ctx[:new_data] + end + + test "for a pack with a fallback source", ctx do + mock(fn + %{ + method: :get, + url: "https://nonshared-pack" + } -> + text(File.read!("#{@emoji_path}/test_pack_nonshared/nonshared.zip")) + end) + + new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") + + new_data_with_sha = + Map.put( + new_data, + "fallback-src-sha256", + "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF" + ) + + assert ctx[:admin_conn] + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) + |> json_response_and_validate_schema(200) == new_data_with_sha + + assert Jason.decode!(File.read!(ctx[:pack_file]))["pack"] == new_data_with_sha + end + + test "when the fallback source doesn't have all the files", ctx do + mock(fn + %{ + method: :get, + url: "https://nonshared-pack" + } -> + {:ok, {'empty.zip', empty_arch}} = :zip.zip('empty.zip', [], [:memory]) + text(empty_arch) + end) + + new_data = Map.put(ctx[:new_data], "fallback-src", "https://nonshared-pack") + + assert ctx[:admin_conn] + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack", %{metadata: new_data}) + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "The fallback archive does not have all files specified in pack.json" + } + end + end + + describe "POST/PATCH/DELETE /api/pleroma/emoji/packs/:name/files" do + setup do + pack_file = "#{@emoji_path}/test_pack/pack.json" + original_content = File.read!(pack_file) + + on_exit(fn -> + File.write!(pack_file, original_content) + end) + + :ok + end + + test "create shortcode exists", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + filename: "dir/blank.png", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(:conflict) == %{ + "error" => "An emoji with the \"blank\" shortcode already exists" + } + end + + test "don't rewrite old emoji", %{admin_conn: admin_conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir/") end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + filename: "dir/blank.png", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank2" => "dir/blank.png" + } + + assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + new_shortcode: "blank2", + new_filename: "dir_2/blank_3.png" + }) + |> json_response_and_validate_schema(:conflict) == %{ + "error" => + "New shortcode \"blank2\" is already used. If you want to override emoji use 'force' option" + } + end + + test "rewrite old emoji with force option", %{admin_conn: admin_conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir_2/") end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + filename: "dir/blank.png", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank2" => "dir/blank.png" + } + + assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png", + force: true + }) + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank3" => "dir_2/blank_3.png" + } + + assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") + end + + test "with empty filename", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + filename: "", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name, shortcode or filename cannot be empty" + } + end + + test "add file with not loaded pack", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/not_loaded/files", %{ + shortcode: "blank2", + filename: "dir/blank.png", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack \"not_loaded\" is not found" + } + end + + test "remove file with not loaded pack", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/not_loaded/files?shortcode=blank3") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack \"not_loaded\" is not found" + } + end + + test "remove file with empty shortcode", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/not_loaded/files?shortcode=") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name or shortcode cannot be empty" + } + end + + test "update file with not loaded pack", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/not_loaded/files", %{ + shortcode: "blank4", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png" + }) + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack \"not_loaded\" is not found" + } + end + + test "new with shortcode as file with update", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank4", + filename: "dir/blank.png", + file: %Plug.Upload{ + filename: "blank.png", + path: "#{@emoji_path}/test_pack/blank.png" + } + }) + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank4" => "dir/blank.png" + } + + assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank4", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png" + }) + |> json_response_and_validate_schema(200) == %{ + "blank3" => "dir_2/blank_3.png", + "blank" => "blank.png" + } + + refute File.exists?("#{@emoji_path}/test_pack/dir/") + assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") + + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") + |> json_response_and_validate_schema(200) == %{"blank" => "blank.png"} + + refute File.exists?("#{@emoji_path}/test_pack/dir_2/") + + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/dir") end) + end + + test "new with shortcode from url", %{admin_conn: admin_conn} do + mock(fn + %{ + method: :get, + url: "https://test-blank/blank_url.png" + } -> + text(File.read!("#{@emoji_path}/test_pack/blank.png")) + end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank_url", + file: "https://test-blank/blank_url.png" + }) + |> json_response_and_validate_schema(200) == %{ + "blank_url" => "blank_url.png", + "blank" => "blank.png" + } + + assert File.exists?("#{@emoji_path}/test_pack/blank_url.png") + + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/blank_url.png") end) + end + + test "new without shortcode", %{admin_conn: admin_conn} do + on_exit(fn -> File.rm_rf!("#{@emoji_path}/test_pack/shortcode.png") end) + + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/pleroma/emoji/packs/test_pack/files", %{ + file: %Plug.Upload{ + filename: "shortcode.png", + path: "#{Pleroma.Config.get([:instance, :static_dir])}/add/shortcode.png" + } + }) + |> json_response_and_validate_schema(200) == %{ + "shortcode" => "shortcode.png", + "blank" => "blank.png" + } + end + + test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank2") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "Emoji \"blank2\" does not exist" + } + end + + test "update non existing emoji", %{admin_conn: admin_conn} do + assert admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank2", + new_shortcode: "blank3", + new_filename: "dir_2/blank_3.png" + }) + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "Emoji \"blank2\" does not exist" + } + end + + test "update with empty shortcode", %{admin_conn: admin_conn} do + assert %{ + "error" => "Missing field: new_shortcode." + } = + admin_conn + |> put_req_header("content-type", "multipart/form-data") + |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ + shortcode: "blank", + new_filename: "dir_2/blank_3.png" + }) + |> json_response_and_validate_schema(:bad_request) + end + end + + describe "POST/DELETE /api/pleroma/emoji/packs/:name" do + test "creating and deleting a pack", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_created") + |> json_response_and_validate_schema(200) == "ok" + + assert File.exists?("#{@emoji_path}/test_created/pack.json") + + assert Jason.decode!(File.read!("#{@emoji_path}/test_created/pack.json")) == %{ + "pack" => %{}, + "files" => %{} + } + + assert admin_conn + |> delete("/api/pleroma/emoji/packs/test_created") + |> json_response_and_validate_schema(200) == "ok" + + refute File.exists?("#{@emoji_path}/test_created/pack.json") + end + + test "if pack exists", %{admin_conn: admin_conn} do + path = Path.join(@emoji_path, "test_created") + File.mkdir(path) + pack_file = Jason.encode!(%{files: %{}, pack: %{}}) + File.write!(Path.join(path, "pack.json"), pack_file) + + assert admin_conn + |> post("/api/pleroma/emoji/packs/test_created") + |> json_response_and_validate_schema(:conflict) == %{ + "error" => "A pack named \"test_created\" already exists" + } + + on_exit(fn -> File.rm_rf(path) end) + end + + test "with empty name", %{admin_conn: admin_conn} do + assert admin_conn + |> post("/api/pleroma/emoji/packs/ ") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name cannot be empty" + } + end + end + + test "deleting nonexisting pack", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/non_existing") + |> json_response_and_validate_schema(:not_found) == %{ + "error" => "Pack non_existing does not exist" + } + end + + test "deleting with empty name", %{admin_conn: admin_conn} do + assert admin_conn + |> delete("/api/pleroma/emoji/packs/ ") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name cannot be empty" + } + end + + test "filesystem import", %{admin_conn: admin_conn, conn: conn} do + on_exit(fn -> + File.rm!("#{@emoji_path}/test_pack_for_import/emoji.txt") + File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") + end) + + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) + + refute Map.has_key?(resp, "test_pack_for_import") + + assert admin_conn + |> get("/api/pleroma/emoji/packs/import") + |> json_response_and_validate_schema(200) == ["test_pack_for_import"] + + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) + assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} + + File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") + refute File.exists?("#{@emoji_path}/test_pack_for_import/pack.json") + + emoji_txt_content = """ + blank, blank.png, Fun + blank2, blank.png + foo, /emoji/test_pack_for_import/blank.png + bar + """ + + File.write!("#{@emoji_path}/test_pack_for_import/emoji.txt", emoji_txt_content) + + assert admin_conn + |> get("/api/pleroma/emoji/packs/import") + |> json_response_and_validate_schema(200) == ["test_pack_for_import"] + + resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) + + assert resp["test_pack_for_import"]["files"] == %{ + "blank" => "blank.png", + "blank2" => "blank.png", + "foo" => "blank.png" + } + end + + describe "GET /api/pleroma/emoji/packs/:name" do + test "shows pack.json", %{conn: conn} do + assert %{ + "files" => %{"blank" => "blank.png"}, + "pack" => %{ + "can-download" => true, + "description" => "Test description", + "download-sha256" => _, + "homepage" => "https://pleroma.social", + "license" => "Test license", + "share-files" => true + } + } = + conn + |> get("/api/pleroma/emoji/packs/test_pack") + |> json_response_and_validate_schema(200) + end + + test "non existing pack", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/non_existing") + |> json_response_and_validate_schema(:not_found) == %{ + "error" => "Pack non_existing does not exist" + } + end + + test "error name", %{conn: conn} do + assert conn + |> get("/api/pleroma/emoji/packs/ ") + |> json_response_and_validate_schema(:bad_request) == %{ + "error" => "pack name cannot be empty" + } + end + end +end -- cgit v1.2.3 From 5735b5c8beccf7c5ff1cd6586d7e4bb6bc8bda12 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 18 May 2020 19:00:00 +0300 Subject: Fixed missing support for `with_muted` param in direct timeline. --- lib/pleroma/web/api_spec/operations/timeline_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index cb9d75841..8e19bace7 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -43,7 +43,7 @@ def direct_operation do description: "View statuses with a “direct” privacy, from your account or in your notifications", deprecated: true, - parameters: pagination_params(), + parameters: [with_muted_param() | pagination_params()], security: [%{"oAuth" => ["read:statuses"]}], operationId: "TimelineController.direct", responses: %{ -- cgit v1.2.3 From be4db41d713f981cc464e5fa7bc7191d3ff776d6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 18:45:33 +0200 Subject: ChatMessageValidator: Allow one message in an array, too. --- .../object_validators/chat_message_validator.ex | 9 ++++++ test/web/activity_pub/object_validator_test.exs | 35 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 9c20c188a..138736f23 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -47,9 +47,18 @@ def cast_data(data) do def fix(data) do data |> fix_emoji() + |> fix_attachment() |> Map.put_new("actor", data["attributedTo"]) end + # Throws everything but the first one away + def fix_attachment(%{"attachment" => [attachment | _]} = data) do + data + |> Map.put("attachment", attachment) + end + + def fix_attachment(data), do: data + def changeset(struct, data) do data = fix(data) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index da33d3dbc..a79e50a29 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -13,6 +13,20 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory describe "attachments" do + test "works with honkerific attachments" do + attachment = %{ + "mediaType" => "image/jpeg", + "name" => "298p3RG7j27tfsZ9RQ.jpg", + "summary" => "298p3RG7j27tfsZ9RQ.jpg", + "type" => "Document", + "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" + } + + assert {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + end + test "it turns mastodon attachments into our attachments" do attachment = %{ "url" => @@ -103,6 +117,27 @@ test "validates for a basic object with an attachment", %{ assert object["attachment"] end + test "validates for a basic object with an attachment in an array", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", [attachment.data]) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + test "validates for a basic object with an attachment but without content", %{ valid_chat_message: valid_chat_message, user: user -- cgit v1.2.3 From 45c3a7240449133176bf27bd2f753bb71d7f455b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 18 May 2020 20:58:59 +0400 Subject: [OpenAPI] Use BooleanLike in all request bodies --- .../web/api_spec/operations/account_operation.ex | 28 +++++++++++----------- .../web/api_spec/operations/filter_operation.ex | 7 +++--- .../web/api_spec/operations/report_operation.ex | 3 ++- .../web/api_spec/operations/status_operation.ex | 9 +++---- .../api_spec/operations/subscription_operation.ex | 21 ++++++++-------- .../controllers/status_controller_test.exs | 6 ++--- 6 files changed, 39 insertions(+), 35 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 934f6038e..20572f8ea 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -393,7 +393,7 @@ defp create_request do format: :password }, agreement: %Schema{ - type: :boolean, + allOf: [BooleanLike], description: "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." }, @@ -463,7 +463,7 @@ defp update_creadentials_request do type: :object, properties: %{ bot: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Whether the account has a bot flag." }, @@ -486,7 +486,7 @@ defp update_creadentials_request do format: :binary }, locked: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Whether manual approval of follow requests is required." }, @@ -510,37 +510,37 @@ defp update_creadentials_request do # Pleroma-specific fields no_rich_text: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "html tags are stripped from all statuses requested from the API" }, hide_followers: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "user's followers will be hidden" }, hide_follows: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "user's follows will be hidden" }, hide_followers_count: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "user's follower count will be hidden" }, hide_follows_count: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "user's follow count will be hidden" }, hide_favorites: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "user's favorites timeline will be hidden" }, show_role: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "user's role (e.g admin, moderator) will be exposed to anyone in the API" @@ -552,12 +552,12 @@ defp update_creadentials_request do description: "Opaque user settings to be saved on the backend." }, skip_thread_containment: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Skip filtering out broken threads" }, allow_following_move: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Allows automatically follow moved following accounts" }, @@ -568,7 +568,7 @@ defp update_creadentials_request do format: :binary }, discoverable: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Discovery of this account in search results and other services is allowed." @@ -678,7 +678,7 @@ defp mute_request do type: :object, properties: %{ notifications: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Mute notifications in addition to statuses? Defaults to true.", default: true diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex index 7310c1c4d..31e576f99 100644 --- a/lib/pleroma/web/api_spec/operations/filter_operation.ex +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ApiSpec.FilterOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -171,7 +172,7 @@ defp create_request do type: :object, properties: %{ irreversible: %Schema{ - type: :bolean, + allOf: [BooleanLike], description: "Should the server irreversibly drop matching entities from home and notifications?", default: false @@ -199,13 +200,13 @@ defp update_request do "Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified." }, irreversible: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Should the server irreversibly drop matching entities from home and notifications?" }, whole_word: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Consider word boundaries?", default: true diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex index 882177c96..b9b4c4f79 100644 --- a/lib/pleroma/web/api_spec/operations/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -47,7 +48,7 @@ defp create_request do description: "Reason for the report" }, forward: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, default: false, description: diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 4b284c537..0682ca6e5 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.AccountOperation alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus alias Pleroma.Web.ApiSpec.Schemas.Status @@ -394,12 +395,12 @@ defp create_request do "Duration the poll should be open, in seconds. Must be provided with `poll[options]`" }, multiple: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Allow multiple choices?" }, hide_totals: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Hide vote counts until the poll ends?" } @@ -411,7 +412,7 @@ defp create_request do description: "ID of the status being replied to, if status is a reply" }, sensitive: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Mark status and attached media as sensitive?" }, @@ -435,7 +436,7 @@ defp create_request do }, # Pleroma-specific properties: preview: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example" diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index cf6dcb068..c575a87e6 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ApiSpec.SubscriptionOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Helpers alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.PushSubscription def open_api_operation(action) do @@ -117,27 +118,27 @@ defp create_request do type: :object, properties: %{ follow: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive follow notifications?" }, favourite: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive favourite notifications?" }, reblog: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive reblog notifications?" }, mention: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive mention notifications?" }, poll: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive poll notifications?" } @@ -181,27 +182,27 @@ defp update_request do type: :object, properties: %{ follow: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive follow notifications?" }, favourite: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive favourite notifications?" }, reblog: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive reblog notifications?" }, mention: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive mention notifications?" }, poll: %Schema{ - type: :boolean, + allOf: [BooleanLike], nullable: true, description: "Receive poll notifications?" } diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index bdee88fd3..962e64b03 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -62,7 +62,7 @@ test "posting a status", %{conn: conn} do |> post("/api/v1/statuses", %{ "status" => "cofe", "spoiler_text" => "2hu", - "sensitive" => "false" + "sensitive" => "0" }) {:ok, ttl} = Cachex.ttl(:idempotency_cache, idempotency_key) @@ -81,7 +81,7 @@ test "posting a status", %{conn: conn} do |> post("/api/v1/statuses", %{ "status" => "cofe", "spoiler_text" => "2hu", - "sensitive" => "false" + "sensitive" => 0 }) assert %{"id" => second_id} = json_response(conn_two, 200) @@ -93,7 +93,7 @@ test "posting a status", %{conn: conn} do |> post("/api/v1/statuses", %{ "status" => "cofe", "spoiler_text" => "2hu", - "sensitive" => "false" + "sensitive" => "False" }) assert %{"id" => third_id} = json_response_and_validate_schema(conn_three, 200) -- cgit v1.2.3 From fe5d423b43d6d4c4cd63aaee27b8aa0966441e02 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 18 May 2020 22:00:32 +0400 Subject: Add OpenAPI spec for MascotController --- .../operations/pleroma_mascot_operation.ex | 79 ++++++++++++++++++++++ .../pleroma_api/controllers/mascot_controller.ex | 5 +- .../controllers/mascot_controller_test.exs | 25 ++++--- 3 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex new file mode 100644 index 000000000..8c5f37ea6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_mascot_operation.ex @@ -0,0 +1,79 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaMascotOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Mascot"], + summary: "Gets user mascot image", + security: [%{"oAuth" => ["read:accounts"]}], + operationId: "PleromaAPI.MascotController.show", + responses: %{ + 200 => Operation.response("Mascot", "application/json", mascot()) + } + } + end + + def update_operation do + %Operation{ + tags: ["Mascot"], + summary: "Set/clear user avatar image", + description: + "Behaves exactly the same as `POST /api/v1/upload`. Can only accept images - any attempt to upload non-image files will be met with `HTTP 415 Unsupported Media Type`.", + operationId: "PleromaAPI.MascotController.update", + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + file: %Schema{type: :string, format: :binary} + } + }, + required: true + ), + security: [%{"oAuth" => ["write:accounts"]}], + responses: %{ + 200 => Operation.response("Mascot", "application/json", mascot()), + 415 => Operation.response("Unsupported Media Type", "application/json", ApiError) + } + } + end + + defp mascot do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + url: %Schema{type: :string, format: :uri}, + type: %Schema{type: :string}, + pleroma: %Schema{ + type: :object, + properties: %{ + mime_type: %Schema{type: :string} + } + } + }, + example: %{ + "id" => "abcdefg", + "url" => "https://pleroma.example.org/media/abcdefg.png", + "type" => "image", + "pleroma" => %{ + "mime_type" => "image/png" + } + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex index d4e0d8b7c..df6c50ca5 100644 --- a/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex @@ -9,16 +9,19 @@ defmodule Pleroma.Web.PleromaAPI.MascotController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaMascotOperation + @doc "GET /api/v1/pleroma/mascot" def show(%{assigns: %{user: user}} = conn, _params) do json(conn, User.get_mascot(user)) end @doc "PUT /api/v1/pleroma/mascot" - def update(%{assigns: %{user: user}} = conn, %{"file" => file}) do + def update(%{assigns: %{user: user}, body_params: %{file: file}} = conn, _) do with {:ok, object} <- ActivityPub.upload(file, actor: User.ap_id(user)), # Reject if not an image %{type: "image"} = attachment <- render_attachment(object) do diff --git a/test/web/pleroma_api/controllers/mascot_controller_test.exs b/test/web/pleroma_api/controllers/mascot_controller_test.exs index 617831b02..e2ead6e15 100644 --- a/test/web/pleroma_api/controllers/mascot_controller_test.exs +++ b/test/web/pleroma_api/controllers/mascot_controller_test.exs @@ -16,9 +16,12 @@ test "mascot upload" do filename: "sound.mp3" } - ret_conn = put(conn, "/api/v1/pleroma/mascot", %{"file" => non_image_file}) + ret_conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> put("/api/v1/pleroma/mascot", %{"file" => non_image_file}) - assert json_response(ret_conn, 415) + assert json_response_and_validate_schema(ret_conn, 415) file = %Plug.Upload{ content_type: "image/jpg", @@ -26,9 +29,12 @@ test "mascot upload" do filename: "an_image.jpg" } - conn = put(conn, "/api/v1/pleroma/mascot", %{"file" => file}) + conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> put("/api/v1/pleroma/mascot", %{"file" => file}) - assert %{"id" => _, "type" => image} = json_response(conn, 200) + assert %{"id" => _, "type" => image} = json_response_and_validate_schema(conn, 200) end test "mascot retrieving" do @@ -37,7 +43,7 @@ test "mascot retrieving" do # When user hasn't set a mascot, we should just get pleroma tan back ret_conn = get(conn, "/api/v1/pleroma/mascot") - assert %{"url" => url} = json_response(ret_conn, 200) + assert %{"url" => url} = json_response_and_validate_schema(ret_conn, 200) assert url =~ "pleroma-fox-tan-smol" # When a user sets their mascot, we should get that back @@ -47,9 +53,12 @@ test "mascot retrieving" do filename: "an_image.jpg" } - ret_conn = put(conn, "/api/v1/pleroma/mascot", %{"file" => file}) + ret_conn = + conn + |> put_req_header("content-type", "multipart/form-data") + |> put("/api/v1/pleroma/mascot", %{"file" => file}) - assert json_response(ret_conn, 200) + assert json_response_and_validate_schema(ret_conn, 200) user = User.get_cached_by_id(user.id) @@ -58,7 +67,7 @@ test "mascot retrieving" do |> assign(:user, user) |> get("/api/v1/pleroma/mascot") - assert %{"url" => url, "type" => "image"} = json_response(conn, 200) + assert %{"url" => url, "type" => "image"} = json_response_and_validate_schema(conn, 200) assert url =~ "an_image" end end -- cgit v1.2.3 From d4a7577cdeeb86649dbab22a1addc57e6ed16e9c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 18 May 2020 22:01:38 +0400 Subject: Fix API documentation --- 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 5895613a3..867f59919 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -265,7 +265,7 @@ See [Admin-API](admin_api.md) * Method `PUT` * Authentication: required * Params: - * `image`: Multipart image + * `file`: Multipart image * Response: JSON. Returns a mastodon media attachment entity when successful, otherwise returns HTTP 415 `{"error": "error_msg"}` * Example response: -- cgit v1.2.3 From d19c7167704308df093f060082639c0a15996af7 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 18 May 2020 20:17:28 +0200 Subject: AttachmentValidator: Handle empty mediatypes --- .../object_validators/attachment_validator.ex | 14 ++++++-- test/web/activity_pub/object_validator_test.exs | 4 +-- .../transmogrifier/chat_message_test.exs | 37 ++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index 16ed49051..c4b502cb9 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do @primary_key false embedded_schema do field(:type, :string) - field(:mediaType, :string) + field(:mediaType, :string, default: "application/octet-stream") field(:name, :string) embeds_many(:url, UrlObjectValidator) @@ -41,8 +41,16 @@ def changeset(struct, data) do end def fix_media_type(data) do - data - |> Map.put_new("mediaType", data["mimeType"]) + data = + data + |> Map.put_new("mediaType", data["mimeType"]) + + if data["mediaType"] == "" do + data + |> Map.put("mediaType", "application/octet-stream") + else + data + end end def fix_url(data) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index a79e50a29..ed6b84e8e 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -15,8 +15,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do describe "attachments" do test "works with honkerific attachments" do attachment = %{ - "mediaType" => "image/jpeg", - "name" => "298p3RG7j27tfsZ9RQ.jpg", + "mediaType" => "", + "name" => "", "summary" => "298p3RG7j27tfsZ9RQ.jpg", "type" => "Document", "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 85644d787..820090de3 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -13,6 +13,43 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do alias Pleroma.Web.ActivityPub.Transmogrifier describe "handle_incoming" do + test "handles this" do + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => "https://honk.tedunangst.com/u/tedu", + "id" => "https://honk.tedunangst.com/u/tedu/honk/x6gt8X8PcyGkQcXxzg1T", + "object" => %{ + "attachment" => [ + %{ + "mediaType" => "image/jpeg", + "name" => "298p3RG7j27tfsZ9RQ.jpg", + "summary" => "298p3RG7j27tfsZ9RQ.jpg", + "type" => "Document", + "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" + } + ], + "attributedTo" => "https://honk.tedunangst.com/u/tedu", + "content" => "", + "id" => "https://honk.tedunangst.com/u/tedu/chonk/26L4wl5yCbn4dr4y1b", + "published" => "2020-05-18T01:13:03Z", + "to" => [ + "https://dontbulling.me/users/lain" + ], + "type" => "ChatMessage" + }, + "published" => "2020-05-18T01:13:03Z", + "to" => [ + "https://dontbulling.me/users/lain" + ], + "type" => "Create" + } + + _user = insert(:user, ap_id: data["actor"]) + _user = insert(:user, ap_id: hd(data["to"])) + + assert {:ok, _activity} = Transmogrifier.handle_incoming(data) + end + test "it rejects messages that don't contain content" do data = File.read!("test/fixtures/create-chat-message.json") -- cgit v1.2.3 From 5f0a3ac74d51333a778e6be26876fe26b0ff625b Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 18 May 2020 09:22:26 +0300 Subject: added tests --- docs/configuration/cheatsheet.md | 14 +++++++++ lib/pleroma/web/media_proxy/invalidation.ex | 8 ++++- lib/pleroma/web/media_proxy/invalidations/http.ex | 26 +++++++++++++++- .../web/media_proxy/invalidations/script.ex | 30 +++++++++++++++++-- test/web/media_proxy/invalidations/http_test.exs | 35 ++++++++++++++++++++++ test/web/media_proxy/invalidations/script_test.exs | 20 +++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 test/web/media_proxy/invalidations/http_test.exs create mode 100644 test/web/media_proxy/invalidations/script_test.exs diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index aaea3f46c..ddea6a4fb 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -262,6 +262,12 @@ Urls of attachments pass to script as arguments. * `script_path`: path to external script. +Example: +```elixir +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, + script_path: "./installation/nginx-cache-purge.example" +``` + #### Pleroma.Web.MediaProxy.Invalidation.Http This strategy allow perform custom http request to purge cache. @@ -270,6 +276,14 @@ This strategy allow perform custom http request to purge cache. * `headers`: http headers. default is empty * `options`: request options. default is empty +Example: +```elixir +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http, + method: :purge, + headers: [], + options: [] +``` + ## Link previews ### Pleroma.Web.Metadata (provider) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index 371aa8ae0..c037ff13e 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -1,8 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.MediaProxy.Invalidation do + @moduledoc false + @callback purge(list(String.t()), map()) :: {:ok, String.t()} | {:error, String.t()} alias Pleroma.Config + @spec purge(list(String.t())) :: {:ok, String.t()} | {:error, String.t()} def purge(urls) do [:media_proxy, :invalidation, :enabled] |> Config.get() @@ -13,7 +20,6 @@ defp do_purge(true, urls) do provider = Config.get([:media_proxy, :invalidation, :provider]) options = Config.get(provider) provider.purge(urls, options) - :ok end defp do_purge(_, _), do: :ok diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 66fafa7ba..07248df6e 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -1,16 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.MediaProxy.Invalidation.Http do + @moduledoc false @behaviour Pleroma.Web.MediaProxy.Invalidation + require Logger + @impl Pleroma.Web.MediaProxy.Invalidation def purge(urls, opts) do method = Map.get(opts, :method, :purge) headers = Map.get(opts, :headers, []) options = Map.get(opts, :options, []) + Logger.debug("Running cache purge: #{inspect(urls)}") + Enum.each(urls, fn url -> - Pleroma.HTTP.request(method, url, "", headers, options) + with {:error, error} <- do_purge(method, url, headers, options) do + Logger.error("Error while cache purge: url - #{url}, error: #{inspect(error)}") + end end) {:ok, "success"} end + + defp do_purge(method, url, headers, options) do + case Pleroma.HTTP.request(method, url, "", headers, options) do + {:ok, %{status: status} = env} when 400 <= status and status < 500 -> + {:error, env} + + {:error, error} = error -> + error + + _ -> + {:ok, "success"} + end + end end diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index 94c79511a..6be782132 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -1,6 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.MediaProxy.Invalidation.Script do + @moduledoc false + @behaviour Pleroma.Web.MediaProxy.Invalidation + require Logger + @impl Pleroma.Web.MediaProxy.Invalidation def purge(urls, %{script_path: script_path} = _options) do args = @@ -9,7 +17,25 @@ def purge(urls, %{script_path: script_path} = _options) do |> Enum.uniq() |> Enum.join(" ") - System.cmd(Path.expand(script_path), [args]) - {:ok, "success"} + path = Path.expand(script_path) + + Logger.debug("Running cache purge: #{inspect(urls)}, #{path}") + + case do_purge(path, [args]) do + {result, exit_status} when exit_status > 0 -> + Logger.error("Error while cache purge: #{inspect(result)}") + {:error, inspect(result)} + + _ -> + {:ok, "success"} + end + end + + def purge(_, _), do: {:error, "not found script path"} + + defp do_purge(path, args) do + System.cmd(path, args) + rescue + error -> {inspect(error), 1} end end diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs new file mode 100644 index 000000000..8a3b4141c --- /dev/null +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -0,0 +1,35 @@ +defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do + use ExUnit.Case + alias Pleroma.Web.MediaProxy.Invalidation + + import ExUnit.CaptureLog + import Tesla.Mock + + test "logs hasn't error message when request is valid" do + mock(fn + %{method: :purge, url: "http://example.com/media/example.jpg"} -> + %Tesla.Env{status: 200} + end) + + refute capture_log(fn -> + assert Invalidation.Http.purge( + ["http://example.com/media/example.jpg"], + %{} + ) == {:ok, "success"} + end) =~ "Error while cache purge" + end + + test "it write error message in logs when request invalid" do + mock(fn + %{method: :purge, url: "http://example.com/media/example1.jpg"} -> + %Tesla.Env{status: 404} + end) + + assert capture_log(fn -> + assert Invalidation.Http.purge( + ["http://example.com/media/example1.jpg"], + %{} + ) == {:ok, "success"} + end) =~ "Error while cache purge: url - http://example.com/media/example1.jpg" + end +end diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs new file mode 100644 index 000000000..1358963ab --- /dev/null +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do + use ExUnit.Case + alias Pleroma.Web.MediaProxy.Invalidation + + import ExUnit.CaptureLog + + test "it logger error when script not found" do + assert capture_log(fn -> + assert Invalidation.Script.purge( + ["http://example.com/media/example.jpg"], + %{script_path: "./example"} + ) == {:error, "\"%ErlangError{original: :enoent}\""} + end) =~ "Error while cache purge: \"%ErlangError{original: :enoent}\"" + + assert Invalidation.Script.purge( + ["http://example.com/media/example.jpg"], + %{} + ) == {:error, "not found script path"} + end +end -- cgit v1.2.3 From ed442a225ae1a6a524d18149698f2238b394b948 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 19 May 2020 06:15:42 +0300 Subject: removed Quantum from docs --- docs/configuration/cheatsheet.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 1078c4e87..ce3bf3af7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -619,24 +619,6 @@ config :pleroma, :workers, * `enabled: false` corresponds to `config :pleroma, :workers, retries: [federator_outgoing: 1]` * deprecated options: `max_jobs`, `initial_timeout` -### Pleroma.Scheduler - -Configuration for [Quantum](https://github.com/quantum-elixir/quantum-core) jobs scheduler. - -See [Quantum readme](https://github.com/quantum-elixir/quantum-core#usage) for the list of supported options. - -Example: - -```elixir -config :pleroma, Pleroma.Scheduler, - global: true, - overlap: true, - timezone: :utc, - jobs: [{"0 */6 * * * *", {Pleroma.Web.Websub, :refresh_subscriptions, []}}] -``` - -The above example defines a single job which invokes `Pleroma.Web.Websub.refresh_subscriptions()` every 6 hours ("0 */6 * * * *", [crontab format](https://en.wikipedia.org/wiki/Cron)). - ## :web_push_encryption, :vapid_details Web Push Notifications configuration. You can use the mix task `mix web_push.gen.keypair` to generate it. -- cgit v1.2.3 From 41f8f172609910efc9543632fecbd544d131535b Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 19 May 2020 09:42:41 +0300 Subject: fix api/v1/accounts/update_credentials --- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 4 +++- .../controllers/account_controller/update_credentials_test.exs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index ef41f9e96..75512442d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -177,6 +177,7 @@ def update_credentials(%{assigns: %{user: original_user}, body_params: params} = ) |> add_if_present(params, :pleroma_settings_store, :pleroma_settings_store) |> add_if_present(params, :default_scope, :default_scope) + |> add_if_present(params["source"], "privacy", :default_scope) |> add_if_present(params, :actor_type, :actor_type) changeset = User.update_changeset(user, user_params) @@ -189,7 +190,8 @@ def update_credentials(%{assigns: %{user: original_user}, body_params: params} = end defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do - with true <- Map.has_key?(params, params_field), + with true <- is_map(params), + true <- Map.has_key?(params, params_field), {:ok, new_value} <- value_function.(Map.get(params, params_field)) do Map.put(map, map_field, new_value) else 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 fdb6d4c5d..696228203 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 @@ -112,6 +112,13 @@ test "updates the user's default scope", %{conn: conn} do assert user_data["source"]["privacy"] == "unlisted" end + test "updates the user's privacy", %{conn: conn} do + conn = patch(conn, "/api/v1/accounts/update_credentials", %{source: %{privacy: "unlisted"}}) + + assert user_data = json_response_and_validate_schema(conn, 200) + assert user_data["source"]["privacy"] == "unlisted" + end + test "updates the user's hide_followers status", %{conn: conn} do conn = patch(conn, "/api/v1/accounts/update_credentials", %{hide_followers: "true"}) -- cgit v1.2.3 From e4c720f14c0760ff5863c58a2ed1aafb9bf1bdc5 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 19 May 2020 14:59:50 +0400 Subject: Fix typo --- docs/API/pleroma_api.md | 2 +- lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 5895613a3..8cdd5808c 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -426,7 +426,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Authentication: required * Params: * `file`: file needs to be uploaded with the multipart request or link to remote file. - * `shortcode`: (*optional*) shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename. + * `shortcode`: (*optional*) shortcode for new emoji, must be unique for all emoji. If not sended, shortcode will be taken from original filename. * `filename`: (*optional*) new emoji file name. If not specified will be taken from original filename. * Response: JSON, list of files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 439127935..567688ff5 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -179,7 +179,7 @@ defp add_file_request do shortcode: %Schema{ type: :string, description: - "Shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename." + "Shortcode for new emoji, must be unique for all emoji. If not sended, shortcode will be taken from original filename." }, filename: %Schema{ type: :string, -- cgit v1.2.3 From 512261c97de03ca0bea70acae0bd3c44692c4a00 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 19 May 2020 14:46:06 +0300 Subject: Update crypt library --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index b8e663a03..03b060bc0 100644 --- a/mix.exs +++ b/mix.exs @@ -155,7 +155,7 @@ defp deps do {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.3", only: :test}, {:crypt, - git: "https://github.com/msantos/crypt", ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"}, + git: "https://github.com/msantos/crypt", ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, {:cors_plug, "~> 1.5"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, {:web_push_encryption, "~> 0.2.1"}, diff --git a/mix.lock b/mix.lock index 955b2bb37..d6f6f48d9 100644 --- a/mix.lock +++ b/mix.lock @@ -21,7 +21,7 @@ "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, - "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, + "crypt": {:git, "https://github.com/msantos/crypt", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, -- cgit v1.2.3 From a985bd57b4f0b33639d20ce7db8dc8c574bfff67 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 19 May 2020 14:11:32 +0200 Subject: User.Query: Speed up recipients query. --- lib/pleroma/user.ex | 4 +++- lib/pleroma/user/query.ex | 25 ++++++++++++------------- mix.lock | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index cba391072..6ca1e9a79 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1204,7 +1204,9 @@ def get_users_from_set(ap_ids, local_only \\ true) do def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do to = [actor | to] - User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false}) + query = User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false}) + + query |> Repo.all() end diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 3a3b04793..9ef073dff 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -167,20 +167,19 @@ defp compose_query({:friends, %User{id: id}}, query) do end defp compose_query({:recipients_from_activity, to}, query) do - query - |> join(:left, [u], r in FollowingRelationship, - as: :relationships, - on: r.follower_id == u.id - ) - |> join(:left, [relationships: r], f in User, - as: :following, - on: f.id == r.following_id - ) - |> where( - [u, following: f, relationships: r], - u.ap_id in ^to or (f.follower_address in ^to and r.state == ^:follow_accept) + following_query = + from(u in User, + join: f in FollowingRelationship, + on: u.id == f.following_id, + where: f.state == ^:follow_accept, + where: u.follower_address in ^to, + select: f.follower_id + ) + + from(u in query, + where: u.ap_id in ^to or u.id in subquery(following_query), + distinct: true ) - |> distinct(true) end defp compose_query({:order_by, key}, query) do diff --git a/mix.lock b/mix.lock index 955b2bb37..f2c097e0c 100644 --- a/mix.lock +++ b/mix.lock @@ -27,7 +27,7 @@ "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.4.0", "a7a83ab8359bf816ce729e5e65981ce25b9fc5adfc89c2ea3980f4fed0bfd7c1", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5eed18252f5b5bbadec56a24112b531343507dbe046273133176b12190ce19cc"}, + "ecto": {:hex, :ecto, "3.4.4", "a2c881e80dc756d648197ae0d936216c0308370332c5e77a2325a10293eef845", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4bd3ad62abc3b21fb629f0f7a3dab23a192fca837d257dd08449fba7373561"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, @@ -57,7 +57,7 @@ "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.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"}, + "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, -- cgit v1.2.3 From 524d04d9218f8e72bf88ab5e7d4b407452ae40bc Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 19 May 2020 15:53:18 +0400 Subject: Add OpenAPI spec for PleromaAPI.ScrobbleController --- .../operations/pleroma_scrobble_operation.ex | 102 +++++++++++++++++++++ lib/pleroma/web/common_api/common_api.ex | 11 ++- .../pleroma_api/controllers/scrobble_controller.ex | 27 +++--- lib/pleroma/web/router.ex | 4 +- test/web/common_api/common_api_test.exs | 18 ++-- .../controllers/scrobble_controller_test.exs | 26 +++--- 6 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex new file mode 100644 index 000000000..779b8f84c --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Reference + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def create_operation do + %Operation{ + tags: ["Scrobbles"], + summary: "Gets user mascot image", + security: [%{"oAuth" => ["write"]}], + operationId: "PleromaAPI.ScrobbleController.create", + requestBody: request_body("Parameters", create_request(), requried: true), + responses: %{ + 200 => Operation.response("Scrobble", "application/json", scrobble()) + } + } + end + + def index_operation do + %Operation{ + tags: ["Scrobbles"], + summary: "Requests a list of current and recent Listen activities for an account", + operationId: "PleromaAPI.ScrobbleController.index", + parameters: [ + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params() + ], + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Array of Scrobble", "application/json", %Schema{ + type: :array, + items: scrobble() + }) + } + } + end + + defp create_request do + %Schema{ + type: :object, + required: [:title], + properties: %{ + title: %Schema{type: :string, description: "The title of the media playing"}, + album: %Schema{type: :string, description: "The album of the media playing"}, + artist: %Schema{type: :string, description: "The artist of the media playing"}, + length: %Schema{type: :integer, description: "The length of the media playing"}, + visibility: %Schema{ + allOf: [VisibilityScope], + default: "public", + description: "Scrobble visibility" + } + }, + example: %{ + "title" => "Some Title", + "artist" => "Some Artist", + "album" => "Some Album", + "length" => 180_000 + } + } + end + + defp scrobble do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + account: Account, + title: %Schema{type: :string, description: "The title of the media playing"}, + album: %Schema{type: :string, description: "The album of the media playing"}, + artist: %Schema{type: :string, description: "The artist of the media playing"}, + length: %Schema{ + type: :integer, + description: "The length of the media playing", + nullable: true + }, + created_at: %Schema{type: :string, format: :"date-time"} + }, + example: %{ + "id" => "1234", + "account" => Account.schema().example, + "title" => "Some Title", + "artist" => "Some Artist", + "album" => "Some Album", + "length" => 180_000, + "created_at" => "2019-09-28T12:40:45.000Z" + } + } + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 7c94f16b6..447dbe4e6 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -347,11 +347,14 @@ def check_expiry_date(expiry_str) do |> check_expiry_date() end - def listen(user, %{"title" => _} = data) do - with visibility <- data["visibility"] || "public", - {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), + def listen(user, data) do + visibility = Map.get(data, :visibility, "public") + + with {to, cc} <- get_to_and_cc(user, [], nil, visibility, nil), listen_data <- - Map.take(data, ["album", "artist", "title", "length"]) + data + |> Map.take([:album, :artist, :title, :length]) + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Audio") |> Map.put("to", to) |> Map.put("cc", cc) diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 22da6c0ad..35a37f69e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User @@ -13,22 +13,18 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, - %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :user_scrobbles + %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index ) - plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) + plug(OAuthScopesPlug, %{scopes: ["write"]} when action == :create) - def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do - params = - if !params["length"] do - params - else - params - |> Map.put("length", fetch_integer_param(params, "length")) - end + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaScrobbleOperation + def create(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, activity} <- CommonAPI.listen(user, params) do conn |> put_view(StatusView) @@ -41,9 +37,12 @@ def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do end end - def user_scrobbles(%{assigns: %{user: reading_user}} = conn, params) do - with %User{} = user <- User.get_cached_by_nickname_or_id(params["id"], for: reading_user) do - params = Map.put(params, "type", ["Listen"]) + def index(%{assigns: %{user: reading_user}} = conn, %{id: id} = params) do + with %User{} = user <- User.get_cached_by_nickname_or_id(id, for: reading_user) do + params = + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("type", ["Listen"]) activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d77a61361..369c54cf4 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -325,7 +325,7 @@ defmodule Pleroma.Web.Router do get("/mascot", MascotController, :show) put("/mascot", MascotController, :update) - post("/scrobble", ScrobbleController, :new_scrobble) + post("/scrobble", ScrobbleController, :create) end scope [] do @@ -345,7 +345,7 @@ defmodule Pleroma.Web.Router do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through(:api) - get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles) + get("/accounts/:id/scrobbles", ScrobbleController, :index) end scope "/api/v1", Pleroma.Web.MastodonAPI do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index fd8299013..52e95397c 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -841,10 +841,10 @@ test "returns a valid activity" do {:ok, activity} = CommonAPI.listen(user, %{ - "title" => "lain radio episode 1", - "album" => "lain radio", - "artist" => "lain", - "length" => 180_000 + title: "lain radio episode 1", + album: "lain radio", + artist: "lain", + length: 180_000 }) object = Object.normalize(activity) @@ -859,11 +859,11 @@ test "respects visibility=private" do {:ok, activity} = CommonAPI.listen(user, %{ - "title" => "lain radio episode 1", - "album" => "lain radio", - "artist" => "lain", - "length" => 180_000, - "visibility" => "private" + title: "lain radio episode 1", + album: "lain radio", + artist: "lain", + length: 180_000, + visibility: "private" }) object = Object.normalize(activity) diff --git a/test/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/web/pleroma_api/controllers/scrobble_controller_test.exs index 1b945040c..f39c07ac6 100644 --- a/test/web/pleroma_api/controllers/scrobble_controller_test.exs +++ b/test/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -12,14 +12,16 @@ test "works correctly" do %{conn: conn} = oauth_access(["write"]) conn = - post(conn, "/api/v1/pleroma/scrobble", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/scrobble", %{ "title" => "lain radio episode 1", "artist" => "lain", "album" => "lain radio", "length" => "180000" }) - assert %{"title" => "lain radio episode 1"} = json_response(conn, 200) + assert %{"title" => "lain radio episode 1"} = json_response_and_validate_schema(conn, 200) end end @@ -29,28 +31,28 @@ test "works correctly" do {:ok, _activity} = CommonAPI.listen(user, %{ - "title" => "lain radio episode 1", - "artist" => "lain", - "album" => "lain radio" + title: "lain radio episode 1", + artist: "lain", + album: "lain radio" }) {:ok, _activity} = CommonAPI.listen(user, %{ - "title" => "lain radio episode 2", - "artist" => "lain", - "album" => "lain radio" + title: "lain radio episode 2", + artist: "lain", + album: "lain radio" }) {:ok, _activity} = CommonAPI.listen(user, %{ - "title" => "lain radio episode 3", - "artist" => "lain", - "album" => "lain radio" + title: "lain radio episode 3", + artist: "lain", + album: "lain radio" }) conn = get(conn, "/api/v1/pleroma/accounts/#{user.id}/scrobbles") - result = json_response(conn, 200) + result = json_response_and_validate_schema(conn, 200) assert length(result) == 3 end -- cgit v1.2.3 From 6609714d6694058e28ed789dd65eb50ca816c425 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 19 May 2020 16:11:59 +0400 Subject: Move Scrobble views to ScrobbleView --- lib/pleroma/web/mastodon_api/views/status_view.ex | 21 ------------ .../pleroma_api/controllers/scrobble_controller.ex | 8 ++--- lib/pleroma/web/pleroma_api/views/scrobble_view.ex | 37 ++++++++++++++++++++++ test/web/mastodon_api/views/status_view_test.exs | 10 ------ test/web/pleroma_api/views/scrobble_view_test.exs | 20 ++++++++++++ 5 files changed, 59 insertions(+), 37 deletions(-) create mode 100644 lib/pleroma/web/pleroma_api/views/scrobble_view.ex create mode 100644 test/web/pleroma_api/views/scrobble_view_test.exs diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 05a26017a..8e3715093 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -436,27 +436,6 @@ def render("attachment.json", %{attachment: attachment}) do } end - def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do - object = Object.normalize(activity) - - user = get_user(activity.data["actor"]) - created_at = Utils.to_masto_date(activity.data["published"]) - - %{ - id: activity.id, - account: AccountView.render("show.json", %{user: user, for: opts[:for]}), - created_at: created_at, - title: object.data["title"] |> HTML.strip_tags(), - artist: object.data["artist"] |> HTML.strip_tags(), - album: object.data["album"] |> HTML.strip_tags(), - length: object.data["length"] - } - end - - def render("listens.json", opts) do - safe_render_many(opts.activities, StatusView, "listen.json", opts) - end - def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = activities diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 35a37f69e..8665ca56c 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.StatusView plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -26,9 +25,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do def create(%{assigns: %{user: user}, body_params: params} = conn, _) do with {:ok, activity} <- CommonAPI.listen(user, params) do - conn - |> put_view(StatusView) - |> render("listen.json", %{activity: activity, for: user}) + render(conn, "show.json", activity: activity, for: user) else {:error, message} -> conn @@ -48,8 +45,7 @@ def index(%{assigns: %{user: reading_user}} = conn, %{id: id} = params) do conn |> add_link_headers(activities) - |> put_view(StatusView) - |> render("listens.json", %{ + |> render("index.json", %{ activities: activities, for: reading_user, as: :activity diff --git a/lib/pleroma/web/pleroma_api/views/scrobble_view.ex b/lib/pleroma/web/pleroma_api/views/scrobble_view.ex new file mode 100644 index 000000000..bbff93abe --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/scrobble_view.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ScrobbleView do + use Pleroma.Web, :view + + require Pleroma.Constants + + alias Pleroma.Activity + alias Pleroma.HTML + alias Pleroma.Object + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI.StatusView + + def render("show.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do + object = Object.normalize(activity) + + user = StatusView.get_user(activity.data["actor"]) + created_at = Utils.to_masto_date(activity.data["published"]) + + %{ + id: activity.id, + account: AccountView.render("show.json", %{user: user, for: opts[:for]}), + created_at: created_at, + title: object.data["title"] |> HTML.strip_tags(), + artist: object.data["artist"] |> HTML.strip_tags(), + album: object.data["album"] |> HTML.strip_tags(), + length: object.data["length"] + } + end + + def render("index.json", opts) do + safe_render_many(opts.activities, __MODULE__, "show.json", opts) + 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 5d7adbe29..43e3bdca1 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -620,14 +620,4 @@ test "visibility/list" do assert status.visibility == "list" end - - test "successfully renders a Listen activity (pleroma extension)" do - listen_activity = insert(:listen) - - status = StatusView.render("listen.json", activity: listen_activity) - - assert status.length == listen_activity.data["object"]["length"] - assert status.title == listen_activity.data["object"]["title"] - assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec()) - end end diff --git a/test/web/pleroma_api/views/scrobble_view_test.exs b/test/web/pleroma_api/views/scrobble_view_test.exs new file mode 100644 index 000000000..6bdb56509 --- /dev/null +++ b/test/web/pleroma_api/views/scrobble_view_test.exs @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.StatusViewTest do + use Pleroma.DataCase + + alias Pleroma.Web.PleromaAPI.ScrobbleView + + import Pleroma.Factory + + test "successfully renders a Listen activity (pleroma extension)" do + listen_activity = insert(:listen) + + status = ScrobbleView.render("show.json", activity: listen_activity) + + assert status.length == listen_activity.data["object"]["length"] + assert status.title == listen_activity.data["object"]["title"] + end +end -- cgit v1.2.3 From 2328eff09c4f3fac8c1c6eea5920ef58b42ac377 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 19 May 2020 14:36:13 +0200 Subject: UserTest: Hide warning in tests. --- test/user_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/user_test.exs b/test/user_test.exs index 6b9df60a4..239d16799 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -555,6 +555,7 @@ test "gets an existing user by fully qualified nickname, case insensitive" do assert user == fetched_user end + @tag capture_log: true test "returns nil if no user could be fetched" do {:error, fetched_user} = User.get_or_fetch_by_nickname("nonexistant@social.heldscal.la") assert fetched_user == "not found nonexistant@social.heldscal.la" -- cgit v1.2.3 From be322541c8a3de1b6311bda340e5af151fe28c6c Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 19 May 2020 14:36:34 +0200 Subject: User.Query: Remove superfluous `distinct` --- lib/pleroma/user/query.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 9ef073dff..293bbc082 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -177,8 +177,7 @@ defp compose_query({:recipients_from_activity, to}, query) do ) from(u in query, - where: u.ap_id in ^to or u.id in subquery(following_query), - distinct: true + where: u.ap_id in ^to or u.id in subquery(following_query) ) end -- cgit v1.2.3 From 47ed9ee4411613cac81235f9751a8ccd0974e927 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 19 May 2020 17:29:58 +0400 Subject: Fix summary --- lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex index 779b8f84c..85a22aa0b 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -19,7 +19,7 @@ def open_api_operation(action) do def create_operation do %Operation{ tags: ["Scrobbles"], - summary: "Gets user mascot image", + summary: "Creates a new Listen activity for an account", security: [%{"oAuth" => ["write"]}], operationId: "PleromaAPI.ScrobbleController.create", requestBody: request_body("Parameters", create_request(), requried: true), -- cgit v1.2.3 From 918ee46417036e3325b6a1d6e8d51ca78bcb31ac Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 19 May 2020 16:05:39 -0500 Subject: Synchronize :fe settings in config.exs --- config/config.exs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/config/config.exs b/config/config.exs index c51884f3a..7b99a41aa 100644 --- a/config/config.exs +++ b/config/config.exs @@ -271,20 +271,32 @@ config :pleroma, :frontend_configurations, pleroma_fe: %{ - theme: "pleroma-dark", - logo: "/static/logo.png", - background: "/images/city.jpg", - redirectRootNoLogin: "/main/all", - redirectRootLogin: "/main/friends", - showInstanceSpecificPanel: true, - scopeOptionsEnabled: false, - formattingOptionsEnabled: false, + alwaysShowSubjectInput: true, + background: "/static/aurora_borealis.jpg", collapseMessageWithSubject: false, + disableChat: false, + greentext: false, + hideFilteredStatuses: false, + hideMutedPosts: false, hidePostStats: false, + hideSitename: false, hideUserStats: false, + loginMethod: "password", + logo: "/static/logo.png", + logoMargin: ".1em", + logoMask: true, + minimalScopesMode: false, + noAttachmentLinks: false, + nsfwCensorImage: "", + postContentType: "text/plain", + redirectRootLogin: "/main/friends", + redirectRootNoLogin: "/main/all", scopeCopy: true, + showFeaturesPanel: true, + showInstanceSpecificPanel: false, subjectLineBehavior: "email", - alwaysShowSubjectInput: true + theme: "pleroma-dark", + webPushNotifications: false }, masto_fe: %{ showInstanceSpecificPanel: true -- cgit v1.2.3 From b5b9d161cddd1b6650cde00cf0f3cbf56ab7a4a3 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 20 May 2020 06:56:04 +0300 Subject: update purge script --- installation/nginx-cache-purge.example | 39 ------------------------------ installation/nginx-cache-purge.sh.example | 40 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 39 deletions(-) delete mode 100755 installation/nginx-cache-purge.example create mode 100755 installation/nginx-cache-purge.sh.example diff --git a/installation/nginx-cache-purge.example b/installation/nginx-cache-purge.example deleted file mode 100755 index 12dfa733c..000000000 --- a/installation/nginx-cache-purge.example +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -# A simple Bash script to delete an media from the Nginx cache. - -SCRIPTNAME=${0##*/} - -# NGINX cache directory -CACHE_DIRECTORY="/tmp/pleroma-media-cache" - -function get_cache_files() { - local max_parallel=${3-16} - find $2 -maxdepth 1 -type d | xargs -P $max_parallel -n 1 grep -ERl "^KEY:.*$1" | sort -u -} - -function purge_item() { - local cache_files - cache_files=$(get_cache_files "$1" "$2") - - if [ -n "$cache_files" ]; then - for i in $cache_files; do - [ -f $i ] || continue - echo "Deleting $i from $2." - rm $i - done - else - echo "$1 is not cached." - fi -} - -function purge() { - for url in "$@" - do - echo "$SCRIPTNAME delete $url from cache ($CACHE_DIRECTORY)" - purge_item $url $CACHE_DIRECTORY - done - -} - -purge $1 diff --git a/installation/nginx-cache-purge.sh.example b/installation/nginx-cache-purge.sh.example new file mode 100755 index 000000000..aaa195324 --- /dev/null +++ b/installation/nginx-cache-purge.sh.example @@ -0,0 +1,40 @@ +#!/bin/sh + +# A simple shell script to delete a media from the Nginx cache. + +SCRIPTNAME=${0##*/} + +# NGINX cache directory +CACHE_DIRECTORY="/tmp/pleroma-media-cache" + +## Return the files where the items are cached. +## $1 - the filename, can be a pattern . +## $2 - the cache directory. +## $3 - (optional) the number of parallel processes to run for grep. +get_cache_files() { + local max_parallel=${3-16} + find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -ERl "^KEY:.*$1" | sort -u +} + +## Removes an item from the given cache zone. +## $1 - the filename, can be a pattern . +## $2 - the cache directory. +purge_item() { + for f in $(get_cache_files $1 $2); do + echo "found file: $f" + [ -f $f ] || continue + echo "Deleting $f from $2." + rm $f + done +} # purge_item + +purge() { + for url in "$@" + do + echo "$SCRIPTNAME delete \`$url\` from cache ($CACHE_DIRECTORY)" + purge_item $url $CACHE_DIRECTORY + done + +} + +purge $1 -- cgit v1.2.3 From 376147fb828a75b5000262a376cee173bfc98551 Mon Sep 17 00:00:00 2001 From: Maksim Date: Wed, 20 May 2020 04:12:21 +0000 Subject: Apply suggestion to installation/nginx-cache-purge.sh.example --- installation/nginx-cache-purge.sh.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installation/nginx-cache-purge.sh.example b/installation/nginx-cache-purge.sh.example index aaa195324..b2915321c 100755 --- a/installation/nginx-cache-purge.sh.example +++ b/installation/nginx-cache-purge.sh.example @@ -13,7 +13,7 @@ CACHE_DIRECTORY="/tmp/pleroma-media-cache" ## $3 - (optional) the number of parallel processes to run for grep. get_cache_files() { local max_parallel=${3-16} - find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -ERl "^KEY:.*$1" | sort -u + find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E Rl "^KEY:.*$1" | sort -u } ## Removes an item from the given cache zone. -- cgit v1.2.3 From c2dd4639d675df8cff944956677acbd872224acb Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 20 May 2020 07:55:14 +0200 Subject: MastoFE: update to bundle-2020-05-20 --- priv/static/packs/arrow-key-navigation.js | Bin 1822 -> 1822 bytes priv/static/packs/base_polyfills.js | Bin 95519 -> 104571 bytes priv/static/packs/base_polyfills.js.LICENSE | 5 - priv/static/packs/base_polyfills.js.LICENSE.txt | 5 + priv/static/packs/base_polyfills.js.map | Bin 370310 -> 394300 bytes priv/static/packs/common.js | Bin 1139526 -> 1164991 bytes priv/static/packs/common.js.LICENSE | 1 - priv/static/packs/common.js.LICENSE.txt | 1 + priv/static/packs/common.js.map | Bin 1845728 -> 1909562 bytes priv/static/packs/containers/media_container.js | Bin 723085 -> 718372 bytes .../packs/containers/media_container.js.LICENSE | 149 --------------- .../containers/media_container.js.LICENSE.txt | 149 +++++++++++++++ .../static/packs/containers/media_container.js.map | Bin 1267749 -> 1258866 bytes priv/static/packs/core/admin.js | Bin 15731 -> 16121 bytes priv/static/packs/core/admin.js.map | Bin 49627 -> 50938 bytes priv/static/packs/core/auth.js | Bin 220006 -> 220287 bytes priv/static/packs/core/auth.js.LICENSE | 7 - priv/static/packs/core/auth.js.LICENSE.txt | 7 + priv/static/packs/core/auth.js.map | Bin 72375 -> 73501 bytes priv/static/packs/core/common.js | Bin 6936 -> 6936 bytes priv/static/packs/core/common.js.map | Bin 12016 -> 12016 bytes priv/static/packs/core/embed.js | Bin 470 -> 470 bytes priv/static/packs/core/mailer.js | Bin 175 -> 175 bytes priv/static/packs/core/modal.js | Bin 17376 -> 17567 bytes priv/static/packs/core/modal.js.map | Bin 57478 -> 59789 bytes priv/static/packs/core/public.js | Bin 17377 -> 17568 bytes priv/static/packs/core/public.js.map | Bin 57479 -> 59790 bytes priv/static/packs/core/settings.js | Bin 220014 -> 220295 bytes priv/static/packs/core/settings.js.LICENSE | 7 - priv/static/packs/core/settings.js.LICENSE.txt | 7 + priv/static/packs/core/settings.js.map | Bin 72379 -> 73505 bytes priv/static/packs/emoji_picker.js | Bin 252 -> 252 bytes priv/static/packs/extra_polyfills.js | Bin 12756 -> 12760 bytes priv/static/packs/extra_polyfills.js.LICENSE | 1 - priv/static/packs/extra_polyfills.js.LICENSE.txt | 1 + priv/static/packs/extra_polyfills.js.map | Bin 55130 -> 55136 bytes priv/static/packs/features/account_gallery.js | Bin 7641 -> 8122 bytes priv/static/packs/features/account_gallery.js.map | Bin 18264 -> 18264 bytes priv/static/packs/features/account_timeline.js | Bin 3041 -> 3089 bytes priv/static/packs/features/account_timeline.js.map | Bin 7747 -> 7809 bytes priv/static/packs/features/blocks.js | Bin 1773 -> 1800 bytes priv/static/packs/features/blocks.js.map | Bin 4225 -> 4231 bytes priv/static/packs/features/bookmarked_statuses.js | Bin 2471 -> 2497 bytes .../packs/features/bookmarked_statuses.js.map | Bin 5785 -> 5790 bytes priv/static/packs/features/community_timeline.js | Bin 3295 -> 3317 bytes .../packs/features/community_timeline.js.map | Bin 8982 -> 8981 bytes priv/static/packs/features/compose.js | Bin 58754 -> 49970 bytes priv/static/packs/features/compose.js.map | Bin 143021 -> 120681 bytes priv/static/packs/features/direct_timeline.js | Bin 10370 -> 10466 bytes priv/static/packs/features/direct_timeline.js.map | Bin 25604 -> 25695 bytes priv/static/packs/features/directory.js | Bin 9647 -> 9779 bytes priv/static/packs/features/directory.js.map | Bin 23153 -> 23264 bytes priv/static/packs/features/domain_blocks.js | Bin 3579 -> 3636 bytes priv/static/packs/features/domain_blocks.js.map | Bin 8275 -> 8301 bytes priv/static/packs/features/favourited_statuses.js | Bin 2473 -> 2499 bytes .../packs/features/favourited_statuses.js.map | Bin 5790 -> 5795 bytes priv/static/packs/features/favourites.js | Bin 2122 -> 2149 bytes priv/static/packs/features/favourites.js.map | Bin 4994 -> 5000 bytes priv/static/packs/features/follow_requests.js | Bin 3487 -> 3995 bytes priv/static/packs/features/follow_requests.js.map | Bin 8863 -> 9922 bytes priv/static/packs/features/followers.js | Bin 2477 -> 2504 bytes priv/static/packs/features/followers.js.map | Bin 5957 -> 5963 bytes priv/static/packs/features/following.js | Bin 2482 -> 2509 bytes priv/static/packs/features/following.js.map | Bin 5964 -> 5970 bytes priv/static/packs/features/generic_not_found.js | Bin 272 -> 272 bytes priv/static/packs/features/getting_started.js | Bin 8957 -> 9036 bytes priv/static/packs/features/getting_started.js.map | Bin 19042 -> 19056 bytes .../packs/features/glitch/async/directory.js | Bin 9675 -> 9808 bytes .../packs/features/glitch/async/directory.js.map | Bin 23384 -> 23495 bytes .../packs/features/glitch/async/list_adder.js | Bin 3606 -> 3658 bytes .../packs/features/glitch/async/list_adder.js.map | Bin 9207 -> 9221 bytes priv/static/packs/features/glitch/async/search.js | Bin 417 -> 417 bytes priv/static/packs/features/hashtag_timeline.js | Bin 20667 -> 20705 bytes priv/static/packs/features/hashtag_timeline.js.map | Bin 100170 -> 100178 bytes priv/static/packs/features/home_timeline.js | Bin 3732 -> 17195 bytes priv/static/packs/features/home_timeline.js.map | Bin 9521 -> 40444 bytes priv/static/packs/features/keyboard_shortcuts.js | Bin 8096 -> 8123 bytes .../packs/features/keyboard_shortcuts.js.map | Bin 10766 -> 10771 bytes priv/static/packs/features/list_adder.js | Bin 3608 -> 3660 bytes priv/static/packs/features/list_adder.js.map | Bin 9166 -> 9180 bytes priv/static/packs/features/list_editor.js | Bin 6250 -> 6316 bytes priv/static/packs/features/list_editor.js.map | Bin 15469 -> 15490 bytes priv/static/packs/features/list_timeline.js | Bin 3999 -> 4025 bytes priv/static/packs/features/list_timeline.js.map | Bin 9907 -> 9912 bytes priv/static/packs/features/lists.js | Bin 1823 -> 1850 bytes priv/static/packs/features/lists.js.map | Bin 4684 -> 4690 bytes priv/static/packs/features/mutes.js | Bin 1770 -> 1797 bytes priv/static/packs/features/mutes.js.map | Bin 4212 -> 4218 bytes priv/static/packs/features/notifications.js | Bin 25499 -> 25576 bytes priv/static/packs/features/notifications.js.map | Bin 61467 -> 61489 bytes priv/static/packs/features/pinned_statuses.js | Bin 1547 -> 1574 bytes priv/static/packs/features/pinned_statuses.js.map | Bin 3396 -> 3401 bytes priv/static/packs/features/public_timeline.js | Bin 3297 -> 3319 bytes priv/static/packs/features/public_timeline.js.map | Bin 8967 -> 8966 bytes priv/static/packs/features/reblogs.js | Bin 2109 -> 2136 bytes priv/static/packs/features/reblogs.js.map | Bin 4952 -> 4958 bytes priv/static/packs/features/search.js | Bin 417 -> 417 bytes priv/static/packs/features/status.js | Bin 27311 -> 27366 bytes priv/static/packs/features/status.js.map | Bin 63729 -> 63709 bytes priv/static/packs/flavours/glitch/about.js | Bin 943098 -> 955237 bytes priv/static/packs/flavours/glitch/about.js.LICENSE | 193 -------------------- .../packs/flavours/glitch/about.js.LICENSE.txt | 193 ++++++++++++++++++++ priv/static/packs/flavours/glitch/about.js.map | Bin 2768859 -> 2820874 bytes priv/static/packs/flavours/glitch/admin.js | Bin 424002 -> 427272 bytes priv/static/packs/flavours/glitch/admin.js.LICENSE | 41 ----- .../packs/flavours/glitch/admin.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/glitch/admin.js.map | Bin 778584 -> 786777 bytes .../packs/flavours/glitch/async/account_gallery.js | Bin 7990 -> 8472 bytes .../flavours/glitch/async/account_gallery.js.map | Bin 19009 -> 19009 bytes .../flavours/glitch/async/account_timeline.js | Bin 2989 -> 3036 bytes .../flavours/glitch/async/account_timeline.js.map | Bin 7540 -> 7602 bytes .../packs/flavours/glitch/async/block_modal.js | Bin 2218 -> 2246 bytes .../packs/flavours/glitch/async/block_modal.js.map | Bin 4643 -> 4648 bytes priv/static/packs/flavours/glitch/async/blocks.js | Bin 1714 -> 1741 bytes .../packs/flavours/glitch/async/blocks.js.map | Bin 4149 -> 4155 bytes .../flavours/glitch/async/bookmarked_statuses.js | Bin 2383 -> 2409 bytes .../glitch/async/bookmarked_statuses.js.map | Bin 5625 -> 5630 bytes .../flavours/glitch/async/community_timeline.js | Bin 3264 -> 3286 bytes .../glitch/async/community_timeline.js.map | Bin 8960 -> 8959 bytes priv/static/packs/flavours/glitch/async/compose.js | Bin 22503 -> 22854 bytes .../packs/flavours/glitch/async/compose.js.map | Bin 68726 -> 70093 bytes .../packs/flavours/glitch/async/direct_timeline.js | Bin 13343 -> 13455 bytes .../flavours/glitch/async/direct_timeline.js.map | Bin 33551 -> 33651 bytes .../packs/flavours/glitch/async/domain_blocks.js | Bin 3588 -> 3645 bytes .../flavours/glitch/async/domain_blocks.js.map | Bin 8440 -> 8466 bytes .../packs/flavours/glitch/async/embed_modal.js | Bin 2414 -> 2440 bytes .../packs/flavours/glitch/async/embed_modal.js.map | Bin 5158 -> 5163 bytes .../packs/flavours/glitch/async/emoji_picker.js | Bin 252 -> 252 bytes .../flavours/glitch/async/favourited_statuses.js | Bin 2419 -> 2445 bytes .../glitch/async/favourited_statuses.js.map | Bin 5716 -> 5721 bytes .../packs/flavours/glitch/async/favourites.js | Bin 2344 -> 2371 bytes .../packs/flavours/glitch/async/favourites.js.map | Bin 5419 -> 5425 bytes .../packs/flavours/glitch/async/follow_requests.js | Bin 3458 -> 3966 bytes .../flavours/glitch/async/follow_requests.js.map | Bin 8895 -> 9969 bytes .../packs/flavours/glitch/async/followers.js | Bin 2590 -> 2617 bytes .../packs/flavours/glitch/async/followers.js.map | Bin 6274 -> 6280 bytes .../packs/flavours/glitch/async/following.js | Bin 2595 -> 2622 bytes .../packs/flavours/glitch/async/following.js.map | Bin 6281 -> 6287 bytes .../flavours/glitch/async/generic_not_found.js | Bin 272 -> 272 bytes .../packs/flavours/glitch/async/getting_started.js | Bin 8630 -> 9293 bytes .../flavours/glitch/async/getting_started.js.map | Bin 19908 -> 21487 bytes .../flavours/glitch/async/getting_started_misc.js | Bin 2933 -> 2959 bytes .../glitch/async/getting_started_misc.js.map | Bin 5587 -> 5592 bytes .../flavours/glitch/async/hashtag_timeline.js | Bin 6855 -> 6891 bytes .../flavours/glitch/async/hashtag_timeline.js.map | Bin 16538 -> 16545 bytes .../packs/flavours/glitch/async/home_timeline.js | Bin 4487 -> 17950 bytes .../flavours/glitch/async/home_timeline.js.map | Bin 10922 -> 41930 bytes .../flavours/glitch/async/keyboard_shortcuts.js | Bin 5746 -> 6053 bytes .../glitch/async/keyboard_shortcuts.js.map | Bin 8322 -> 8653 bytes .../packs/flavours/glitch/async/list_editor.js | Bin 3907 -> 3944 bytes .../packs/flavours/glitch/async/list_editor.js.map | Bin 10721 -> 10728 bytes .../packs/flavours/glitch/async/list_timeline.js | Bin 5083 -> 5109 bytes .../flavours/glitch/async/list_timeline.js.map | Bin 12200 -> 12205 bytes priv/static/packs/flavours/glitch/async/lists.js | Bin 1768 -> 1795 bytes .../packs/flavours/glitch/async/lists.js.map | Bin 4648 -> 4654 bytes .../packs/flavours/glitch/async/mute_modal.js | Bin 2666 -> 2693 bytes .../packs/flavours/glitch/async/mute_modal.js.map | Bin 5311 -> 5316 bytes priv/static/packs/flavours/glitch/async/mutes.js | Bin 1710 -> 1737 bytes .../packs/flavours/glitch/async/mutes.js.map | Bin 4145 -> 4151 bytes .../packs/flavours/glitch/async/notifications.js | Bin 23478 -> 26733 bytes .../flavours/glitch/async/notifications.js.map | Bin 58689 -> 66620 bytes .../flavours/glitch/async/onboarding_modal.js | Bin 10096 -> 10128 bytes .../flavours/glitch/async/onboarding_modal.js.map | Bin 18575 -> 18580 bytes .../glitch/async/pinned_accounts_editor.js | Bin 2831 -> 2854 bytes .../glitch/async/pinned_accounts_editor.js.map | Bin 8087 -> 8087 bytes .../packs/flavours/glitch/async/pinned_statuses.js | Bin 1475 -> 1502 bytes .../flavours/glitch/async/pinned_statuses.js.map | Bin 3277 -> 3282 bytes .../packs/flavours/glitch/async/public_timeline.js | Bin 3270 -> 3292 bytes .../flavours/glitch/async/public_timeline.js.map | Bin 8967 -> 8966 bytes priv/static/packs/flavours/glitch/async/reblogs.js | Bin 2330 -> 2357 bytes .../packs/flavours/glitch/async/reblogs.js.map | Bin 5388 -> 5394 bytes .../packs/flavours/glitch/async/report_modal.js | Bin 5583 -> 5622 bytes .../flavours/glitch/async/report_modal.js.map | Bin 13907 -> 13907 bytes .../packs/flavours/glitch/async/settings_modal.js | Bin 21246 -> 21336 bytes .../flavours/glitch/async/settings_modal.js.map | Bin 46478 -> 46493 bytes priv/static/packs/flavours/glitch/async/status.js | Bin 27348 -> 27463 bytes .../packs/flavours/glitch/async/status.js.map | Bin 65166 -> 65327 bytes priv/static/packs/flavours/glitch/common.css | Bin 247045 -> 250850 bytes priv/static/packs/flavours/glitch/common.css.map | 2 +- priv/static/packs/flavours/glitch/common.js | Bin 14583 -> 14860 bytes priv/static/packs/flavours/glitch/common.js.map | Bin 46844 -> 47969 bytes priv/static/packs/flavours/glitch/embed.js | Bin 424002 -> 427272 bytes priv/static/packs/flavours/glitch/embed.js.LICENSE | 41 ----- .../packs/flavours/glitch/embed.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/glitch/embed.js.map | Bin 778584 -> 786777 bytes priv/static/packs/flavours/glitch/error.js | Bin 562 -> 562 bytes priv/static/packs/flavours/glitch/home.js | Bin 1201067 -> 1246808 bytes priv/static/packs/flavours/glitch/home.js.LICENSE | 202 --------------------- .../packs/flavours/glitch/home.js.LICENSE.txt | 202 +++++++++++++++++++++ priv/static/packs/flavours/glitch/home.js.map | Bin 3666660 -> 3864088 bytes priv/static/packs/flavours/glitch/public.js | Bin 424004 -> 427274 bytes .../static/packs/flavours/glitch/public.js.LICENSE | 41 ----- .../packs/flavours/glitch/public.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/glitch/public.js.map | Bin 778585 -> 786778 bytes priv/static/packs/flavours/glitch/settings.js | Bin 14909 -> 15213 bytes priv/static/packs/flavours/glitch/settings.js.map | Bin 49001 -> 50190 bytes priv/static/packs/flavours/glitch/share.js | Bin 988695 -> 995402 bytes priv/static/packs/flavours/glitch/share.js.LICENSE | 193 -------------------- .../packs/flavours/glitch/share.js.LICENSE.txt | 193 ++++++++++++++++++++ priv/static/packs/flavours/glitch/share.js.map | Bin 2879510 -> 2899599 bytes priv/static/packs/flavours/vanilla/about.js | Bin 909235 -> 920656 bytes .../static/packs/flavours/vanilla/about.js.LICENSE | 193 -------------------- .../packs/flavours/vanilla/about.js.LICENSE.txt | 193 ++++++++++++++++++++ priv/static/packs/flavours/vanilla/about.js.map | Bin 2588174 -> 2637353 bytes priv/static/packs/flavours/vanilla/admin.js | Bin 430841 -> 434106 bytes .../static/packs/flavours/vanilla/admin.js.LICENSE | 41 ----- .../packs/flavours/vanilla/admin.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/vanilla/admin.js.map | Bin 791180 -> 799931 bytes priv/static/packs/flavours/vanilla/common.css | Bin 231660 -> 235813 bytes priv/static/packs/flavours/vanilla/common.css.map | 2 +- priv/static/packs/flavours/vanilla/common.js | Bin 176 -> 176 bytes priv/static/packs/flavours/vanilla/embed.js | Bin 430841 -> 434106 bytes .../static/packs/flavours/vanilla/embed.js.LICENSE | 41 ----- .../packs/flavours/vanilla/embed.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/vanilla/embed.js.map | Bin 791180 -> 799931 bytes priv/static/packs/flavours/vanilla/error.js | Bin 562 -> 562 bytes priv/static/packs/flavours/vanilla/home.js | Bin 1139264 -> 1187877 bytes priv/static/packs/flavours/vanilla/home.js.LICENSE | 193 -------------------- .../packs/flavours/vanilla/home.js.LICENSE.txt | 193 ++++++++++++++++++++ priv/static/packs/flavours/vanilla/home.js.map | Bin 3379034 -> 3581475 bytes priv/static/packs/flavours/vanilla/public.js | Bin 430843 -> 434108 bytes .../packs/flavours/vanilla/public.js.LICENSE | 41 ----- .../packs/flavours/vanilla/public.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/vanilla/public.js.map | Bin 791181 -> 799932 bytes priv/static/packs/flavours/vanilla/settings.js | Bin 430847 -> 434112 bytes .../packs/flavours/vanilla/settings.js.LICENSE | 41 ----- .../packs/flavours/vanilla/settings.js.LICENSE.txt | 41 +++++ priv/static/packs/flavours/vanilla/settings.js.map | Bin 791183 -> 799934 bytes priv/static/packs/flavours/vanilla/share.js | Bin 948270 -> 954684 bytes .../static/packs/flavours/vanilla/share.js.LICENSE | 191 ------------------- .../packs/flavours/vanilla/share.js.LICENSE.txt | 191 +++++++++++++++++++ priv/static/packs/flavours/vanilla/share.js.map | Bin 2657447 -> 2674501 bytes priv/static/packs/locales.js | Bin 5118 -> 5120 bytes priv/static/packs/locales.js.map | Bin 16911 -> 16912 bytes priv/static/packs/locales/glitch/ar.js | Bin 44213 -> 44703 bytes priv/static/packs/locales/glitch/ar.js.map | Bin 30784 -> 30780 bytes priv/static/packs/locales/glitch/ast.js | Bin 27317 -> 27810 bytes priv/static/packs/locales/glitch/ast.js.map | Bin 8887 -> 8883 bytes priv/static/packs/locales/glitch/bg.js | Bin 27614 -> 27776 bytes priv/static/packs/locales/glitch/bg.js.map | Bin 9460 -> 9456 bytes priv/static/packs/locales/glitch/bn.js | Bin 47453 -> 47622 bytes priv/static/packs/locales/glitch/bn.js.map | Bin 10232 -> 10229 bytes priv/static/packs/locales/glitch/br.js | Bin 28305 -> 29702 bytes priv/static/packs/locales/glitch/br.js.map | Bin 13119 -> 13115 bytes priv/static/packs/locales/glitch/ca.js | Bin 31014 -> 31197 bytes priv/static/packs/locales/glitch/ca.js.map | Bin 16464 -> 16460 bytes priv/static/packs/locales/glitch/co.js | Bin 44309 -> 44467 bytes priv/static/packs/locales/glitch/co.js.map | Bin 55039 -> 55035 bytes priv/static/packs/locales/glitch/cs.js | Bin 29843 -> 30026 bytes priv/static/packs/locales/glitch/cs.js.map | Bin 11463 -> 11459 bytes priv/static/packs/locales/glitch/cy.js | Bin 30301 -> 30470 bytes priv/static/packs/locales/glitch/cy.js.map | Bin 14362 -> 14358 bytes priv/static/packs/locales/glitch/da.js | Bin 27677 -> 27846 bytes priv/static/packs/locales/glitch/da.js.map | Bin 9307 -> 9303 bytes priv/static/packs/locales/glitch/de.js | Bin 29197 -> 29382 bytes priv/static/packs/locales/glitch/de.js.map | Bin 9519 -> 9515 bytes priv/static/packs/locales/glitch/el.js | Bin 39242 -> 39574 bytes priv/static/packs/locales/glitch/el.js.map | Bin 9710 -> 9707 bytes priv/static/packs/locales/glitch/en.js | Bin 45677 -> 46062 bytes priv/static/packs/locales/glitch/en.js.map | Bin 59184 -> 59180 bytes priv/static/packs/locales/glitch/eo.js | Bin 26672 -> 26851 bytes priv/static/packs/locales/glitch/eo.js.map | Bin 7283 -> 7279 bytes priv/static/packs/locales/glitch/es-AR.js | Bin 44402 -> 44556 bytes priv/static/packs/locales/glitch/es-AR.js.map | Bin 52308 -> 52311 bytes priv/static/packs/locales/glitch/es.js | Bin 43791 -> 43704 bytes priv/static/packs/locales/glitch/es.js.map | Bin 52290 -> 52293 bytes priv/static/packs/locales/glitch/et.js | Bin 27239 -> 27401 bytes priv/static/packs/locales/glitch/et.js.map | Bin 9034 -> 9030 bytes priv/static/packs/locales/glitch/eu.js | Bin 28222 -> 28384 bytes priv/static/packs/locales/glitch/eu.js.map | Bin 8991 -> 8987 bytes priv/static/packs/locales/glitch/fa.js | Bin 35066 -> 35491 bytes priv/static/packs/locales/glitch/fa.js.map | Bin 9370 -> 9367 bytes priv/static/packs/locales/glitch/fi.js | Bin 27793 -> 27965 bytes priv/static/packs/locales/glitch/fi.js.map | Bin 9172 -> 9168 bytes priv/static/packs/locales/glitch/fr.js | Bin 36512 -> 36432 bytes priv/static/packs/locales/glitch/fr.js.map | Bin 26632 -> 26631 bytes priv/static/packs/locales/glitch/ga.js | Bin 28618 -> 28780 bytes priv/static/packs/locales/glitch/ga.js.map | Bin 13519 -> 13515 bytes priv/static/packs/locales/glitch/gl.js | Bin 27995 -> 28041 bytes priv/static/packs/locales/glitch/gl.js.map | Bin 8825 -> 8821 bytes priv/static/packs/locales/glitch/he.js | Bin 29831 -> 30000 bytes priv/static/packs/locales/glitch/he.js.map | Bin 12453 -> 12449 bytes priv/static/packs/locales/glitch/hi.js | Bin 37092 -> 37254 bytes priv/static/packs/locales/glitch/hi.js.map | Bin 9887 -> 9883 bytes priv/static/packs/locales/glitch/hr.js | Bin 27003 -> 27172 bytes priv/static/packs/locales/glitch/hr.js.map | Bin 10782 -> 10778 bytes priv/static/packs/locales/glitch/hu.js | Bin 29018 -> 29222 bytes priv/static/packs/locales/glitch/hu.js.map | Bin 9193 -> 9187 bytes priv/static/packs/locales/glitch/hy.js | Bin 34740 -> 37305 bytes priv/static/packs/locales/glitch/hy.js.map | Bin 9301 -> 9295 bytes priv/static/packs/locales/glitch/id.js | Bin 26501 -> 26679 bytes priv/static/packs/locales/glitch/id.js.map | Bin 7598 -> 7592 bytes priv/static/packs/locales/glitch/io.js | Bin 25468 -> 25639 bytes priv/static/packs/locales/glitch/io.js.map | Bin 7398 -> 7392 bytes priv/static/packs/locales/glitch/is.js | Bin 29665 -> 29860 bytes priv/static/packs/locales/glitch/is.js.map | Bin 9501 -> 9495 bytes priv/static/packs/locales/glitch/it.js | Bin 28344 -> 28511 bytes priv/static/packs/locales/glitch/it.js.map | Bin 9239 -> 9233 bytes priv/static/packs/locales/glitch/ja.js | Bin 35628 -> 35830 bytes priv/static/packs/locales/glitch/ja.js.map | Bin 14539 -> 14533 bytes priv/static/packs/locales/glitch/ka.js | Bin 39001 -> 39172 bytes priv/static/packs/locales/glitch/ka.js.map | Bin 10025 -> 10019 bytes priv/static/packs/locales/glitch/kab.js | Bin 27441 -> 27632 bytes priv/static/packs/locales/glitch/kab.js.map | Bin 7319 -> 7313 bytes priv/static/packs/locales/glitch/kk.js | Bin 34600 -> 34804 bytes priv/static/packs/locales/glitch/kk.js.map | Bin 9913 -> 9907 bytes priv/static/packs/locales/glitch/kn.js | Bin 27903 -> 28067 bytes priv/static/packs/locales/glitch/kn.js.map | Bin 10431 -> 10425 bytes priv/static/packs/locales/glitch/ko.js | Bin 27974 -> 28140 bytes priv/static/packs/locales/glitch/ko.js.map | Bin 7564 -> 7558 bytes priv/static/packs/locales/glitch/lt.js | Bin 27663 -> 27827 bytes priv/static/packs/locales/glitch/lt.js.map | Bin 11966 -> 11960 bytes priv/static/packs/locales/glitch/lv.js | Bin 27787 -> 27951 bytes priv/static/packs/locales/glitch/lv.js.map | Bin 11105 -> 11099 bytes priv/static/packs/locales/glitch/mk.js | Bin 31340 -> 31504 bytes priv/static/packs/locales/glitch/mk.js.map | Bin 10200 -> 10194 bytes priv/static/packs/locales/glitch/ml.js | Bin 37893 -> 38057 bytes priv/static/packs/locales/glitch/ml.js.map | Bin 10480 -> 10474 bytes priv/static/packs/locales/glitch/mr.js | Bin 29710 -> 29874 bytes priv/static/packs/locales/glitch/mr.js.map | Bin 10456 -> 10450 bytes priv/static/packs/locales/glitch/ms.js | Bin 27538 -> 27702 bytes priv/static/packs/locales/glitch/ms.js.map | Bin 13155 -> 13149 bytes priv/static/packs/locales/glitch/nl.js | Bin 28697 -> 28968 bytes priv/static/packs/locales/glitch/nl.js.map | Bin 9609 -> 9603 bytes priv/static/packs/locales/glitch/nn.js | Bin 27223 -> 27395 bytes priv/static/packs/locales/glitch/nn.js.map | Bin 8879 -> 8873 bytes priv/static/packs/locales/glitch/no.js | Bin 26055 -> 26230 bytes priv/static/packs/locales/glitch/no.js.map | Bin 7283 -> 7277 bytes priv/static/packs/locales/glitch/oc.js | Bin 27102 -> 27324 bytes priv/static/packs/locales/glitch/oc.js.map | Bin 4909 -> 4903 bytes priv/static/packs/locales/glitch/pl.js | Bin 34340 -> 34540 bytes priv/static/packs/locales/glitch/pl.js.map | Bin 17092 -> 17086 bytes priv/static/packs/locales/glitch/pt-BR.js | Bin 29838 -> 29990 bytes priv/static/packs/locales/glitch/pt-BR.js.map | Bin 16989 -> 16981 bytes priv/static/packs/locales/glitch/pt-PT.js | Bin 31326 -> 31532 bytes priv/static/packs/locales/glitch/pt-PT.js.map | Bin 16989 -> 16981 bytes priv/static/packs/locales/glitch/ro.js | Bin 28611 -> 28782 bytes priv/static/packs/locales/glitch/ro.js.map | Bin 10601 -> 10595 bytes priv/static/packs/locales/glitch/ru.js | Bin 39858 -> 40144 bytes priv/static/packs/locales/glitch/ru.js.map | Bin 13520 -> 13514 bytes priv/static/packs/locales/glitch/sk.js | Bin 29746 -> 29924 bytes priv/static/packs/locales/glitch/sk.js.map | Bin 11350 -> 11344 bytes priv/static/packs/locales/glitch/sl.js | Bin 28289 -> 28458 bytes priv/static/packs/locales/glitch/sl.js.map | Bin 11607 -> 11601 bytes priv/static/packs/locales/glitch/sq.js | Bin 28991 -> 29160 bytes priv/static/packs/locales/glitch/sq.js.map | Bin 9413 -> 9407 bytes priv/static/packs/locales/glitch/sr-Latn.js | Bin 31421 -> 31590 bytes priv/static/packs/locales/glitch/sr-Latn.js.map | Bin 20058 -> 20050 bytes priv/static/packs/locales/glitch/sr.js | Bin 37731 -> 39479 bytes priv/static/packs/locales/glitch/sr.js.map | Bin 20028 -> 20020 bytes priv/static/packs/locales/glitch/sv.js | Bin 27876 -> 28041 bytes priv/static/packs/locales/glitch/sv.js.map | Bin 9568 -> 9562 bytes priv/static/packs/locales/glitch/ta.js | Bin 51319 -> 52647 bytes priv/static/packs/locales/glitch/ta.js.map | Bin 10878 -> 10873 bytes priv/static/packs/locales/glitch/te.js | Bin 46306 -> 46475 bytes priv/static/packs/locales/glitch/te.js.map | Bin 10100 -> 10094 bytes priv/static/packs/locales/glitch/th.js | Bin 44898 -> 45568 bytes priv/static/packs/locales/glitch/th.js.map | Bin 8585 -> 8579 bytes priv/static/packs/locales/glitch/tr.js | Bin 28298 -> 28468 bytes priv/static/packs/locales/glitch/tr.js.map | Bin 8927 -> 8922 bytes priv/static/packs/locales/glitch/uk.js | Bin 39758 -> 40094 bytes priv/static/packs/locales/glitch/uk.js.map | Bin 13262 -> 13256 bytes priv/static/packs/locales/glitch/ur.js | Bin 32413 -> 32575 bytes priv/static/packs/locales/glitch/ur.js.map | Bin 16741 -> 16735 bytes priv/static/packs/locales/glitch/vi.js | Bin 29713 -> 29882 bytes priv/static/packs/locales/glitch/vi.js.map | Bin 7789 -> 7783 bytes priv/static/packs/locales/glitch/zh-CN.js | Bin 34585 -> 34685 bytes priv/static/packs/locales/glitch/zh-CN.js.map | Bin 35364 -> 35356 bytes priv/static/packs/locales/glitch/zh-HK.js | Bin 34222 -> 34364 bytes priv/static/packs/locales/glitch/zh-HK.js.map | Bin 35364 -> 35356 bytes priv/static/packs/locales/glitch/zh-TW.js | Bin 34481 -> 34623 bytes priv/static/packs/locales/glitch/zh-TW.js.map | Bin 35364 -> 35356 bytes priv/static/packs/locales/vanilla/ar.js | Bin 44188 -> 44678 bytes priv/static/packs/locales/vanilla/ar.js.map | Bin 30431 -> 30427 bytes priv/static/packs/locales/vanilla/ast.js | Bin 27292 -> 27785 bytes priv/static/packs/locales/vanilla/ast.js.map | Bin 8532 -> 8528 bytes priv/static/packs/locales/vanilla/bg.js | Bin 27589 -> 27751 bytes priv/static/packs/locales/vanilla/bg.js.map | Bin 9107 -> 9103 bytes priv/static/packs/locales/vanilla/bn.js | Bin 47428 -> 47597 bytes priv/static/packs/locales/vanilla/bn.js.map | Bin 9879 -> 9876 bytes priv/static/packs/locales/vanilla/br.js | Bin 28280 -> 29677 bytes priv/static/packs/locales/vanilla/br.js.map | Bin 12766 -> 12762 bytes priv/static/packs/locales/vanilla/ca.js | Bin 30989 -> 31172 bytes priv/static/packs/locales/vanilla/ca.js.map | Bin 16111 -> 16107 bytes priv/static/packs/locales/vanilla/co.js | Bin 44284 -> 44442 bytes priv/static/packs/locales/vanilla/co.js.map | Bin 54686 -> 54682 bytes priv/static/packs/locales/vanilla/cs.js | Bin 29818 -> 30001 bytes priv/static/packs/locales/vanilla/cs.js.map | Bin 11110 -> 11106 bytes priv/static/packs/locales/vanilla/cy.js | Bin 30276 -> 30445 bytes priv/static/packs/locales/vanilla/cy.js.map | Bin 14009 -> 14005 bytes priv/static/packs/locales/vanilla/da.js | Bin 27652 -> 27821 bytes priv/static/packs/locales/vanilla/da.js.map | Bin 8954 -> 8950 bytes priv/static/packs/locales/vanilla/de.js | Bin 29172 -> 29357 bytes priv/static/packs/locales/vanilla/de.js.map | Bin 9166 -> 9162 bytes priv/static/packs/locales/vanilla/el.js | Bin 39217 -> 39549 bytes priv/static/packs/locales/vanilla/el.js.map | Bin 9357 -> 9354 bytes priv/static/packs/locales/vanilla/en.js | Bin 42503 -> 42888 bytes priv/static/packs/locales/vanilla/en.js.map | Bin 54686 -> 54682 bytes priv/static/packs/locales/vanilla/eo.js | Bin 26647 -> 26826 bytes priv/static/packs/locales/vanilla/eo.js.map | Bin 6930 -> 6926 bytes priv/static/packs/locales/vanilla/es-AR.js | Bin 44377 -> 44531 bytes priv/static/packs/locales/vanilla/es-AR.js.map | Bin 51949 -> 51952 bytes priv/static/packs/locales/vanilla/es.js | Bin 43766 -> 43679 bytes priv/static/packs/locales/vanilla/es.js.map | Bin 51937 -> 51940 bytes priv/static/packs/locales/vanilla/et.js | Bin 27214 -> 27376 bytes priv/static/packs/locales/vanilla/et.js.map | Bin 8681 -> 8677 bytes priv/static/packs/locales/vanilla/eu.js | Bin 28197 -> 28359 bytes priv/static/packs/locales/vanilla/eu.js.map | Bin 8638 -> 8634 bytes priv/static/packs/locales/vanilla/fa.js | Bin 35041 -> 35466 bytes priv/static/packs/locales/vanilla/fa.js.map | Bin 9017 -> 9014 bytes priv/static/packs/locales/vanilla/fi.js | Bin 27768 -> 27940 bytes priv/static/packs/locales/vanilla/fi.js.map | Bin 8819 -> 8815 bytes priv/static/packs/locales/vanilla/fr.js | Bin 36487 -> 36407 bytes priv/static/packs/locales/vanilla/fr.js.map | Bin 26279 -> 26278 bytes priv/static/packs/locales/vanilla/ga.js | Bin 28593 -> 28755 bytes priv/static/packs/locales/vanilla/ga.js.map | Bin 13166 -> 13162 bytes priv/static/packs/locales/vanilla/gl.js | Bin 27970 -> 28016 bytes priv/static/packs/locales/vanilla/gl.js.map | Bin 8472 -> 8468 bytes priv/static/packs/locales/vanilla/he.js | Bin 29806 -> 29975 bytes priv/static/packs/locales/vanilla/he.js.map | Bin 12100 -> 12096 bytes priv/static/packs/locales/vanilla/hi.js | Bin 37067 -> 37229 bytes priv/static/packs/locales/vanilla/hi.js.map | Bin 9534 -> 9530 bytes priv/static/packs/locales/vanilla/hr.js | Bin 26978 -> 27147 bytes priv/static/packs/locales/vanilla/hr.js.map | Bin 10429 -> 10425 bytes priv/static/packs/locales/vanilla/hu.js | Bin 28993 -> 29197 bytes priv/static/packs/locales/vanilla/hu.js.map | Bin 8840 -> 8836 bytes priv/static/packs/locales/vanilla/hy.js | Bin 34715 -> 37278 bytes priv/static/packs/locales/vanilla/hy.js.map | Bin 8948 -> 8944 bytes priv/static/packs/locales/vanilla/id.js | Bin 26476 -> 26652 bytes priv/static/packs/locales/vanilla/id.js.map | Bin 7245 -> 7241 bytes priv/static/packs/locales/vanilla/io.js | Bin 25443 -> 25612 bytes priv/static/packs/locales/vanilla/io.js.map | Bin 7045 -> 7041 bytes priv/static/packs/locales/vanilla/is.js | Bin 29640 -> 29833 bytes priv/static/packs/locales/vanilla/is.js.map | Bin 9148 -> 9144 bytes priv/static/packs/locales/vanilla/it.js | Bin 28319 -> 28484 bytes priv/static/packs/locales/vanilla/it.js.map | Bin 8886 -> 8882 bytes priv/static/packs/locales/vanilla/ja.js | Bin 29888 -> 30088 bytes priv/static/packs/locales/vanilla/ja.js.map | Bin 7091 -> 7087 bytes priv/static/packs/locales/vanilla/ka.js | Bin 38976 -> 39145 bytes priv/static/packs/locales/vanilla/ka.js.map | Bin 9672 -> 9668 bytes priv/static/packs/locales/vanilla/kab.js | Bin 27416 -> 27605 bytes priv/static/packs/locales/vanilla/kab.js.map | Bin 6964 -> 6960 bytes priv/static/packs/locales/vanilla/kk.js | Bin 34575 -> 34777 bytes priv/static/packs/locales/vanilla/kk.js.map | Bin 9560 -> 9556 bytes priv/static/packs/locales/vanilla/kn.js | Bin 27878 -> 28040 bytes priv/static/packs/locales/vanilla/kn.js.map | Bin 10078 -> 10074 bytes priv/static/packs/locales/vanilla/ko.js | Bin 27949 -> 28113 bytes priv/static/packs/locales/vanilla/ko.js.map | Bin 7211 -> 7207 bytes priv/static/packs/locales/vanilla/lt.js | Bin 27638 -> 27800 bytes priv/static/packs/locales/vanilla/lt.js.map | Bin 11613 -> 11609 bytes priv/static/packs/locales/vanilla/lv.js | Bin 27762 -> 27924 bytes priv/static/packs/locales/vanilla/lv.js.map | Bin 10752 -> 10748 bytes priv/static/packs/locales/vanilla/mk.js | Bin 31315 -> 31477 bytes priv/static/packs/locales/vanilla/mk.js.map | Bin 9847 -> 9843 bytes priv/static/packs/locales/vanilla/ml.js | Bin 37868 -> 38030 bytes priv/static/packs/locales/vanilla/ml.js.map | Bin 10127 -> 10123 bytes priv/static/packs/locales/vanilla/mr.js | Bin 29685 -> 29847 bytes priv/static/packs/locales/vanilla/mr.js.map | Bin 10103 -> 10099 bytes priv/static/packs/locales/vanilla/ms.js | Bin 27513 -> 27675 bytes priv/static/packs/locales/vanilla/ms.js.map | Bin 12802 -> 12798 bytes priv/static/packs/locales/vanilla/nl.js | Bin 28672 -> 28941 bytes priv/static/packs/locales/vanilla/nl.js.map | Bin 9256 -> 9252 bytes priv/static/packs/locales/vanilla/nn.js | Bin 27198 -> 27368 bytes priv/static/packs/locales/vanilla/nn.js.map | Bin 8526 -> 8522 bytes priv/static/packs/locales/vanilla/no.js | Bin 26030 -> 26203 bytes priv/static/packs/locales/vanilla/no.js.map | Bin 6930 -> 6926 bytes priv/static/packs/locales/vanilla/oc.js | Bin 27077 -> 27297 bytes priv/static/packs/locales/vanilla/oc.js.map | Bin 4556 -> 4552 bytes priv/static/packs/locales/vanilla/pl.js | Bin 30314 -> 30512 bytes priv/static/packs/locales/vanilla/pl.js.map | Bin 11543 -> 11539 bytes priv/static/packs/locales/vanilla/pt-BR.js | Bin 29813 -> 29963 bytes priv/static/packs/locales/vanilla/pt-BR.js.map | Bin 16630 -> 16626 bytes priv/static/packs/locales/vanilla/pt-PT.js | Bin 31301 -> 31505 bytes priv/static/packs/locales/vanilla/pt-PT.js.map | Bin 16630 -> 16626 bytes priv/static/packs/locales/vanilla/ro.js | Bin 28586 -> 28755 bytes priv/static/packs/locales/vanilla/ro.js.map | Bin 10248 -> 10244 bytes priv/static/packs/locales/vanilla/ru.js | Bin 39833 -> 40117 bytes priv/static/packs/locales/vanilla/ru.js.map | Bin 13167 -> 13163 bytes priv/static/packs/locales/vanilla/sk.js | Bin 29719 -> 29897 bytes priv/static/packs/locales/vanilla/sk.js.map | Bin 10997 -> 10993 bytes priv/static/packs/locales/vanilla/sl.js | Bin 28262 -> 28431 bytes priv/static/packs/locales/vanilla/sl.js.map | Bin 11254 -> 11250 bytes priv/static/packs/locales/vanilla/sq.js | Bin 28964 -> 29133 bytes priv/static/packs/locales/vanilla/sq.js.map | Bin 9060 -> 9056 bytes priv/static/packs/locales/vanilla/sr-Latn.js | Bin 31394 -> 31563 bytes priv/static/packs/locales/vanilla/sr-Latn.js.map | Bin 19695 -> 19691 bytes priv/static/packs/locales/vanilla/sr.js | Bin 37704 -> 39452 bytes priv/static/packs/locales/vanilla/sr.js.map | Bin 19675 -> 19671 bytes priv/static/packs/locales/vanilla/sv.js | Bin 27849 -> 28014 bytes priv/static/packs/locales/vanilla/sv.js.map | Bin 9215 -> 9211 bytes priv/static/packs/locales/vanilla/ta.js | Bin 51292 -> 52620 bytes priv/static/packs/locales/vanilla/ta.js.map | Bin 10525 -> 10522 bytes priv/static/packs/locales/vanilla/te.js | Bin 46279 -> 46448 bytes priv/static/packs/locales/vanilla/te.js.map | Bin 9747 -> 9743 bytes priv/static/packs/locales/vanilla/th.js | Bin 44871 -> 45541 bytes priv/static/packs/locales/vanilla/th.js.map | Bin 8232 -> 8228 bytes priv/static/packs/locales/vanilla/tr.js | Bin 28271 -> 28441 bytes priv/static/packs/locales/vanilla/tr.js.map | Bin 8574 -> 8571 bytes priv/static/packs/locales/vanilla/uk.js | Bin 39731 -> 40067 bytes priv/static/packs/locales/vanilla/uk.js.map | Bin 12909 -> 12905 bytes priv/static/packs/locales/vanilla/ur.js | Bin 32386 -> 32548 bytes priv/static/packs/locales/vanilla/ur.js.map | Bin 16388 -> 16384 bytes priv/static/packs/locales/vanilla/vi.js | Bin 29686 -> 29855 bytes priv/static/packs/locales/vanilla/vi.js.map | Bin 7436 -> 7432 bytes priv/static/packs/locales/vanilla/zh-CN.js | Bin 34558 -> 34658 bytes priv/static/packs/locales/vanilla/zh-CN.js.map | Bin 35005 -> 35001 bytes priv/static/packs/locales/vanilla/zh-HK.js | Bin 34195 -> 34337 bytes priv/static/packs/locales/vanilla/zh-HK.js.map | Bin 35005 -> 35001 bytes priv/static/packs/locales/vanilla/zh-TW.js | Bin 34454 -> 34596 bytes priv/static/packs/locales/vanilla/zh-TW.js.map | Bin 35005 -> 35001 bytes priv/static/packs/modals/block_modal.js | Bin 2221 -> 2248 bytes priv/static/packs/modals/block_modal.js.map | Bin 4621 -> 4626 bytes priv/static/packs/modals/embed_modal.js | Bin 2415 -> 2441 bytes priv/static/packs/modals/embed_modal.js.map | Bin 5117 -> 5122 bytes priv/static/packs/modals/mute_modal.js | Bin 2668 -> 2695 bytes priv/static/packs/modals/mute_modal.js.map | Bin 5261 -> 5266 bytes priv/static/packs/modals/report_modal.js | Bin 5556 -> 5594 bytes priv/static/packs/modals/report_modal.js.map | Bin 13662 -> 13662 bytes priv/static/packs/skins/glitch/contrast/common.css | Bin 250372 -> 254066 bytes .../packs/skins/glitch/contrast/common.css.map | 2 +- priv/static/packs/skins/glitch/contrast/common.js | Bin 129 -> 129 bytes .../packs/skins/glitch/mastodon-light/common.css | Bin 255724 -> 259304 bytes .../skins/glitch/mastodon-light/common.css.map | 2 +- .../packs/skins/glitch/mastodon-light/common.js | Bin 129 -> 129 bytes .../static/packs/skins/vanilla/contrast/common.css | Bin 235001 -> 239042 bytes .../packs/skins/vanilla/contrast/common.css.map | 2 +- priv/static/packs/skins/vanilla/contrast/common.js | Bin 129 -> 129 bytes .../packs/skins/vanilla/mastodon-light/common.css | Bin 248548 -> 252587 bytes .../skins/vanilla/mastodon-light/common.css.map | 2 +- .../packs/skins/vanilla/mastodon-light/common.js | Bin 129 -> 129 bytes priv/static/packs/skins/vanilla/win95/common.css | Bin 263906 -> 268059 bytes .../packs/skins/vanilla/win95/common.css.map | 2 +- priv/static/packs/skins/vanilla/win95/common.js | Bin 129 -> 129 bytes priv/static/packs/tesseract.js | Bin 86718 -> 86549 bytes priv/static/packs/tesseract.js.LICENSE | 8 - priv/static/packs/tesseract.js.LICENSE.txt | 8 + priv/static/packs/tesseract.js.map | Bin 338915 -> 338346 bytes priv/static/sw.js | Bin 69958 -> 69965 bytes 538 files changed, 1637 insertions(+), 1637 deletions(-) delete mode 100644 priv/static/packs/base_polyfills.js.LICENSE create mode 100644 priv/static/packs/base_polyfills.js.LICENSE.txt delete mode 100644 priv/static/packs/common.js.LICENSE create mode 100644 priv/static/packs/common.js.LICENSE.txt delete mode 100644 priv/static/packs/containers/media_container.js.LICENSE create mode 100644 priv/static/packs/containers/media_container.js.LICENSE.txt delete mode 100644 priv/static/packs/core/auth.js.LICENSE create mode 100644 priv/static/packs/core/auth.js.LICENSE.txt delete mode 100644 priv/static/packs/core/settings.js.LICENSE create mode 100644 priv/static/packs/core/settings.js.LICENSE.txt delete mode 100644 priv/static/packs/extra_polyfills.js.LICENSE create mode 100644 priv/static/packs/extra_polyfills.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/glitch/about.js.LICENSE create mode 100644 priv/static/packs/flavours/glitch/about.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/glitch/admin.js.LICENSE create mode 100644 priv/static/packs/flavours/glitch/admin.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/glitch/embed.js.LICENSE create mode 100644 priv/static/packs/flavours/glitch/embed.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/glitch/home.js.LICENSE create mode 100644 priv/static/packs/flavours/glitch/home.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/glitch/public.js.LICENSE create mode 100644 priv/static/packs/flavours/glitch/public.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/glitch/share.js.LICENSE create mode 100644 priv/static/packs/flavours/glitch/share.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/about.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/about.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/admin.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/admin.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/embed.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/embed.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/home.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/home.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/public.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/public.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/settings.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/settings.js.LICENSE.txt delete mode 100644 priv/static/packs/flavours/vanilla/share.js.LICENSE create mode 100644 priv/static/packs/flavours/vanilla/share.js.LICENSE.txt delete mode 100644 priv/static/packs/tesseract.js.LICENSE create mode 100644 priv/static/packs/tesseract.js.LICENSE.txt diff --git a/priv/static/packs/arrow-key-navigation.js b/priv/static/packs/arrow-key-navigation.js index 710bab007..6f05ce3e1 100644 Binary files a/priv/static/packs/arrow-key-navigation.js and b/priv/static/packs/arrow-key-navigation.js differ diff --git a/priv/static/packs/base_polyfills.js b/priv/static/packs/base_polyfills.js index 092ef3b0a..04e0f921c 100644 Binary files a/priv/static/packs/base_polyfills.js and b/priv/static/packs/base_polyfills.js differ diff --git a/priv/static/packs/base_polyfills.js.LICENSE b/priv/static/packs/base_polyfills.js.LICENSE deleted file mode 100644 index b5fb77398..000000000 --- a/priv/static/packs/base_polyfills.js.LICENSE +++ /dev/null @@ -1,5 +0,0 @@ -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ diff --git a/priv/static/packs/base_polyfills.js.LICENSE.txt b/priv/static/packs/base_polyfills.js.LICENSE.txt new file mode 100644 index 000000000..b5fb77398 --- /dev/null +++ b/priv/static/packs/base_polyfills.js.LICENSE.txt @@ -0,0 +1,5 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ diff --git a/priv/static/packs/base_polyfills.js.map b/priv/static/packs/base_polyfills.js.map index 21b2fa20e..a16ae5010 100644 Binary files a/priv/static/packs/base_polyfills.js.map and b/priv/static/packs/base_polyfills.js.map differ diff --git a/priv/static/packs/common.js b/priv/static/packs/common.js index 989a740a4..372dc3b82 100644 Binary files a/priv/static/packs/common.js and b/priv/static/packs/common.js differ diff --git a/priv/static/packs/common.js.LICENSE b/priv/static/packs/common.js.LICENSE deleted file mode 100644 index 396b1a10e..000000000 --- a/priv/static/packs/common.js.LICENSE +++ /dev/null @@ -1 +0,0 @@ -/*! https://mths.be/punycode v1.4.1 by @mathias */ diff --git a/priv/static/packs/common.js.LICENSE.txt b/priv/static/packs/common.js.LICENSE.txt new file mode 100644 index 000000000..396b1a10e --- /dev/null +++ b/priv/static/packs/common.js.LICENSE.txt @@ -0,0 +1 @@ +/*! https://mths.be/punycode v1.4.1 by @mathias */ diff --git a/priv/static/packs/common.js.map b/priv/static/packs/common.js.map index 682fdbcf5..b077d20c4 100644 Binary files a/priv/static/packs/common.js.map and b/priv/static/packs/common.js.map differ diff --git a/priv/static/packs/containers/media_container.js b/priv/static/packs/containers/media_container.js index 6f0042d4c..d55f51c04 100644 Binary files a/priv/static/packs/containers/media_container.js and b/priv/static/packs/containers/media_container.js differ diff --git a/priv/static/packs/containers/media_container.js.LICENSE b/priv/static/packs/containers/media_container.js.LICENSE deleted file mode 100644 index e72838f69..000000000 --- a/priv/static/packs/containers/media_container.js.LICENSE +++ /dev/null @@ -1,149 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ diff --git a/priv/static/packs/containers/media_container.js.LICENSE.txt b/priv/static/packs/containers/media_container.js.LICENSE.txt new file mode 100644 index 000000000..1fdf87e4c --- /dev/null +++ b/priv/static/packs/containers/media_container.js.LICENSE.txt @@ -0,0 +1,149 @@ +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ diff --git a/priv/static/packs/containers/media_container.js.map b/priv/static/packs/containers/media_container.js.map index 26ccbae0b..bfe37b1d7 100644 Binary files a/priv/static/packs/containers/media_container.js.map and b/priv/static/packs/containers/media_container.js.map differ diff --git a/priv/static/packs/core/admin.js b/priv/static/packs/core/admin.js index 74b3a4573..000669e56 100644 Binary files a/priv/static/packs/core/admin.js and b/priv/static/packs/core/admin.js differ diff --git a/priv/static/packs/core/admin.js.map b/priv/static/packs/core/admin.js.map index aa80e7ea4..cdae04586 100644 Binary files a/priv/static/packs/core/admin.js.map and b/priv/static/packs/core/admin.js.map differ diff --git a/priv/static/packs/core/auth.js b/priv/static/packs/core/auth.js index 082b72b93..e682011a6 100644 Binary files a/priv/static/packs/core/auth.js and b/priv/static/packs/core/auth.js differ diff --git a/priv/static/packs/core/auth.js.LICENSE b/priv/static/packs/core/auth.js.LICENSE deleted file mode 100644 index 4402748a5..000000000 --- a/priv/static/packs/core/auth.js.LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ diff --git a/priv/static/packs/core/auth.js.LICENSE.txt b/priv/static/packs/core/auth.js.LICENSE.txt new file mode 100644 index 000000000..4402748a5 --- /dev/null +++ b/priv/static/packs/core/auth.js.LICENSE.txt @@ -0,0 +1,7 @@ +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ diff --git a/priv/static/packs/core/auth.js.map b/priv/static/packs/core/auth.js.map index 57c319165..ddbefa466 100644 Binary files a/priv/static/packs/core/auth.js.map and b/priv/static/packs/core/auth.js.map differ diff --git a/priv/static/packs/core/common.js b/priv/static/packs/core/common.js index a74ef3128..94224c402 100644 Binary files a/priv/static/packs/core/common.js and b/priv/static/packs/core/common.js differ diff --git a/priv/static/packs/core/common.js.map b/priv/static/packs/core/common.js.map index 0a1f6d68d..bd4f5a5e2 100644 Binary files a/priv/static/packs/core/common.js.map and b/priv/static/packs/core/common.js.map differ diff --git a/priv/static/packs/core/embed.js b/priv/static/packs/core/embed.js index c136c2652..6a16743d4 100644 Binary files a/priv/static/packs/core/embed.js and b/priv/static/packs/core/embed.js differ diff --git a/priv/static/packs/core/mailer.js b/priv/static/packs/core/mailer.js index 50a41cefa..503710f5d 100644 Binary files a/priv/static/packs/core/mailer.js and b/priv/static/packs/core/mailer.js differ diff --git a/priv/static/packs/core/modal.js b/priv/static/packs/core/modal.js index 70b7c2498..a610ed274 100644 Binary files a/priv/static/packs/core/modal.js and b/priv/static/packs/core/modal.js differ diff --git a/priv/static/packs/core/modal.js.map b/priv/static/packs/core/modal.js.map index ac406915e..0e77ac03d 100644 Binary files a/priv/static/packs/core/modal.js.map and b/priv/static/packs/core/modal.js.map differ diff --git a/priv/static/packs/core/public.js b/priv/static/packs/core/public.js index fd535b14e..00ba6b774 100644 Binary files a/priv/static/packs/core/public.js and b/priv/static/packs/core/public.js differ diff --git a/priv/static/packs/core/public.js.map b/priv/static/packs/core/public.js.map index abcf37ca0..e89df0775 100644 Binary files a/priv/static/packs/core/public.js.map and b/priv/static/packs/core/public.js.map differ diff --git a/priv/static/packs/core/settings.js b/priv/static/packs/core/settings.js index 22b6c9f12..2c496dfa8 100644 Binary files a/priv/static/packs/core/settings.js and b/priv/static/packs/core/settings.js differ diff --git a/priv/static/packs/core/settings.js.LICENSE b/priv/static/packs/core/settings.js.LICENSE deleted file mode 100644 index 4402748a5..000000000 --- a/priv/static/packs/core/settings.js.LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ diff --git a/priv/static/packs/core/settings.js.LICENSE.txt b/priv/static/packs/core/settings.js.LICENSE.txt new file mode 100644 index 000000000..4402748a5 --- /dev/null +++ b/priv/static/packs/core/settings.js.LICENSE.txt @@ -0,0 +1,7 @@ +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ diff --git a/priv/static/packs/core/settings.js.map b/priv/static/packs/core/settings.js.map index d61b87412..cd1dd9bf8 100644 Binary files a/priv/static/packs/core/settings.js.map and b/priv/static/packs/core/settings.js.map differ diff --git a/priv/static/packs/emoji_picker.js b/priv/static/packs/emoji_picker.js index 281bc43fd..959ae18e1 100644 Binary files a/priv/static/packs/emoji_picker.js and b/priv/static/packs/emoji_picker.js differ diff --git a/priv/static/packs/extra_polyfills.js b/priv/static/packs/extra_polyfills.js index 614adafe1..3e613035a 100644 Binary files a/priv/static/packs/extra_polyfills.js and b/priv/static/packs/extra_polyfills.js differ diff --git a/priv/static/packs/extra_polyfills.js.LICENSE b/priv/static/packs/extra_polyfills.js.LICENSE deleted file mode 100644 index c56b92413..000000000 --- a/priv/static/packs/extra_polyfills.js.LICENSE +++ /dev/null @@ -1 +0,0 @@ -/*! npm.im/object-fit-images 3.2.4 */ diff --git a/priv/static/packs/extra_polyfills.js.LICENSE.txt b/priv/static/packs/extra_polyfills.js.LICENSE.txt new file mode 100644 index 000000000..c56b92413 --- /dev/null +++ b/priv/static/packs/extra_polyfills.js.LICENSE.txt @@ -0,0 +1 @@ +/*! npm.im/object-fit-images 3.2.4 */ diff --git a/priv/static/packs/extra_polyfills.js.map b/priv/static/packs/extra_polyfills.js.map index 4d96e42cd..524bdd7e1 100644 Binary files a/priv/static/packs/extra_polyfills.js.map and b/priv/static/packs/extra_polyfills.js.map differ diff --git a/priv/static/packs/features/account_gallery.js b/priv/static/packs/features/account_gallery.js index 19dbf1fec..6afdba220 100644 Binary files a/priv/static/packs/features/account_gallery.js and b/priv/static/packs/features/account_gallery.js differ diff --git a/priv/static/packs/features/account_gallery.js.map b/priv/static/packs/features/account_gallery.js.map index 6e91f9d92..6c6a30c94 100644 Binary files a/priv/static/packs/features/account_gallery.js.map and b/priv/static/packs/features/account_gallery.js.map differ diff --git a/priv/static/packs/features/account_timeline.js b/priv/static/packs/features/account_timeline.js index c8b46f89c..792bf05f0 100644 Binary files a/priv/static/packs/features/account_timeline.js and b/priv/static/packs/features/account_timeline.js differ diff --git a/priv/static/packs/features/account_timeline.js.map b/priv/static/packs/features/account_timeline.js.map index 779a67ad6..8766fb489 100644 Binary files a/priv/static/packs/features/account_timeline.js.map and b/priv/static/packs/features/account_timeline.js.map differ diff --git a/priv/static/packs/features/blocks.js b/priv/static/packs/features/blocks.js index 5aacfc455..b41df4ce4 100644 Binary files a/priv/static/packs/features/blocks.js and b/priv/static/packs/features/blocks.js differ diff --git a/priv/static/packs/features/blocks.js.map b/priv/static/packs/features/blocks.js.map index 57ff7e878..3935acd77 100644 Binary files a/priv/static/packs/features/blocks.js.map and b/priv/static/packs/features/blocks.js.map differ diff --git a/priv/static/packs/features/bookmarked_statuses.js b/priv/static/packs/features/bookmarked_statuses.js index 7a2eb9ded..15ff22331 100644 Binary files a/priv/static/packs/features/bookmarked_statuses.js and b/priv/static/packs/features/bookmarked_statuses.js differ diff --git a/priv/static/packs/features/bookmarked_statuses.js.map b/priv/static/packs/features/bookmarked_statuses.js.map index d6ab7f248..01b5594a6 100644 Binary files a/priv/static/packs/features/bookmarked_statuses.js.map and b/priv/static/packs/features/bookmarked_statuses.js.map differ diff --git a/priv/static/packs/features/community_timeline.js b/priv/static/packs/features/community_timeline.js index 88cb0bd86..69d215446 100644 Binary files a/priv/static/packs/features/community_timeline.js and b/priv/static/packs/features/community_timeline.js differ diff --git a/priv/static/packs/features/community_timeline.js.map b/priv/static/packs/features/community_timeline.js.map index 5f98a8561..66c4b57b4 100644 Binary files a/priv/static/packs/features/community_timeline.js.map and b/priv/static/packs/features/community_timeline.js.map differ diff --git a/priv/static/packs/features/compose.js b/priv/static/packs/features/compose.js index f6ebb3c06..fc3398284 100644 Binary files a/priv/static/packs/features/compose.js and b/priv/static/packs/features/compose.js differ diff --git a/priv/static/packs/features/compose.js.map b/priv/static/packs/features/compose.js.map index 3397e47c7..c0c9b14bd 100644 Binary files a/priv/static/packs/features/compose.js.map and b/priv/static/packs/features/compose.js.map differ diff --git a/priv/static/packs/features/direct_timeline.js b/priv/static/packs/features/direct_timeline.js index 76620a29a..ff330c383 100644 Binary files a/priv/static/packs/features/direct_timeline.js and b/priv/static/packs/features/direct_timeline.js differ diff --git a/priv/static/packs/features/direct_timeline.js.map b/priv/static/packs/features/direct_timeline.js.map index d0ef3c943..fd6845a06 100644 Binary files a/priv/static/packs/features/direct_timeline.js.map and b/priv/static/packs/features/direct_timeline.js.map differ diff --git a/priv/static/packs/features/directory.js b/priv/static/packs/features/directory.js index e02e54d5e..2c0111bc3 100644 Binary files a/priv/static/packs/features/directory.js and b/priv/static/packs/features/directory.js differ diff --git a/priv/static/packs/features/directory.js.map b/priv/static/packs/features/directory.js.map index eb45753be..be594d23d 100644 Binary files a/priv/static/packs/features/directory.js.map and b/priv/static/packs/features/directory.js.map differ diff --git a/priv/static/packs/features/domain_blocks.js b/priv/static/packs/features/domain_blocks.js index efa714703..2e59340de 100644 Binary files a/priv/static/packs/features/domain_blocks.js and b/priv/static/packs/features/domain_blocks.js differ diff --git a/priv/static/packs/features/domain_blocks.js.map b/priv/static/packs/features/domain_blocks.js.map index 2ffb7e1f2..687c9b1bc 100644 Binary files a/priv/static/packs/features/domain_blocks.js.map and b/priv/static/packs/features/domain_blocks.js.map differ diff --git a/priv/static/packs/features/favourited_statuses.js b/priv/static/packs/features/favourited_statuses.js index 747c47ea1..fcc60050f 100644 Binary files a/priv/static/packs/features/favourited_statuses.js and b/priv/static/packs/features/favourited_statuses.js differ diff --git a/priv/static/packs/features/favourited_statuses.js.map b/priv/static/packs/features/favourited_statuses.js.map index 136949ae3..5d1a473bf 100644 Binary files a/priv/static/packs/features/favourited_statuses.js.map and b/priv/static/packs/features/favourited_statuses.js.map differ diff --git a/priv/static/packs/features/favourites.js b/priv/static/packs/features/favourites.js index 605a1ed64..337ad7f6f 100644 Binary files a/priv/static/packs/features/favourites.js and b/priv/static/packs/features/favourites.js differ diff --git a/priv/static/packs/features/favourites.js.map b/priv/static/packs/features/favourites.js.map index d1586da7a..9f9501be2 100644 Binary files a/priv/static/packs/features/favourites.js.map and b/priv/static/packs/features/favourites.js.map differ diff --git a/priv/static/packs/features/follow_requests.js b/priv/static/packs/features/follow_requests.js index c022a1eee..902c02d13 100644 Binary files a/priv/static/packs/features/follow_requests.js and b/priv/static/packs/features/follow_requests.js differ diff --git a/priv/static/packs/features/follow_requests.js.map b/priv/static/packs/features/follow_requests.js.map index dd58baf4d..407dbd78e 100644 Binary files a/priv/static/packs/features/follow_requests.js.map and b/priv/static/packs/features/follow_requests.js.map differ diff --git a/priv/static/packs/features/followers.js b/priv/static/packs/features/followers.js index 67c5826cd..a74070322 100644 Binary files a/priv/static/packs/features/followers.js and b/priv/static/packs/features/followers.js differ diff --git a/priv/static/packs/features/followers.js.map b/priv/static/packs/features/followers.js.map index 44384dbc1..4e5cf5a2e 100644 Binary files a/priv/static/packs/features/followers.js.map and b/priv/static/packs/features/followers.js.map differ diff --git a/priv/static/packs/features/following.js b/priv/static/packs/features/following.js index e7bf1afb6..5189467a3 100644 Binary files a/priv/static/packs/features/following.js and b/priv/static/packs/features/following.js differ diff --git a/priv/static/packs/features/following.js.map b/priv/static/packs/features/following.js.map index 7f94461b6..dc4b4badb 100644 Binary files a/priv/static/packs/features/following.js.map and b/priv/static/packs/features/following.js.map differ diff --git a/priv/static/packs/features/generic_not_found.js b/priv/static/packs/features/generic_not_found.js index 4b6f0f228..7dce6d8e4 100644 Binary files a/priv/static/packs/features/generic_not_found.js and b/priv/static/packs/features/generic_not_found.js differ diff --git a/priv/static/packs/features/getting_started.js b/priv/static/packs/features/getting_started.js index 5b3f3471d..21450c8e1 100644 Binary files a/priv/static/packs/features/getting_started.js and b/priv/static/packs/features/getting_started.js differ diff --git a/priv/static/packs/features/getting_started.js.map b/priv/static/packs/features/getting_started.js.map index f53c06c10..808b2d8ba 100644 Binary files a/priv/static/packs/features/getting_started.js.map and b/priv/static/packs/features/getting_started.js.map differ diff --git a/priv/static/packs/features/glitch/async/directory.js b/priv/static/packs/features/glitch/async/directory.js index db8c2a912..725142be0 100644 Binary files a/priv/static/packs/features/glitch/async/directory.js and b/priv/static/packs/features/glitch/async/directory.js differ diff --git a/priv/static/packs/features/glitch/async/directory.js.map b/priv/static/packs/features/glitch/async/directory.js.map index 218c65282..e8c157894 100644 Binary files a/priv/static/packs/features/glitch/async/directory.js.map and b/priv/static/packs/features/glitch/async/directory.js.map differ diff --git a/priv/static/packs/features/glitch/async/list_adder.js b/priv/static/packs/features/glitch/async/list_adder.js index 1dc96e38c..456fbfb9a 100644 Binary files a/priv/static/packs/features/glitch/async/list_adder.js and b/priv/static/packs/features/glitch/async/list_adder.js differ diff --git a/priv/static/packs/features/glitch/async/list_adder.js.map b/priv/static/packs/features/glitch/async/list_adder.js.map index 956b752d1..cff691afb 100644 Binary files a/priv/static/packs/features/glitch/async/list_adder.js.map and b/priv/static/packs/features/glitch/async/list_adder.js.map differ diff --git a/priv/static/packs/features/glitch/async/search.js b/priv/static/packs/features/glitch/async/search.js index f39dc3db0..2edb95c49 100644 Binary files a/priv/static/packs/features/glitch/async/search.js and b/priv/static/packs/features/glitch/async/search.js differ diff --git a/priv/static/packs/features/hashtag_timeline.js b/priv/static/packs/features/hashtag_timeline.js index f54b99ce8..139ca9b33 100644 Binary files a/priv/static/packs/features/hashtag_timeline.js and b/priv/static/packs/features/hashtag_timeline.js differ diff --git a/priv/static/packs/features/hashtag_timeline.js.map b/priv/static/packs/features/hashtag_timeline.js.map index 909563919..3bd79924b 100644 Binary files a/priv/static/packs/features/hashtag_timeline.js.map and b/priv/static/packs/features/hashtag_timeline.js.map differ diff --git a/priv/static/packs/features/home_timeline.js b/priv/static/packs/features/home_timeline.js index 33237345b..9286699bd 100644 Binary files a/priv/static/packs/features/home_timeline.js and b/priv/static/packs/features/home_timeline.js differ diff --git a/priv/static/packs/features/home_timeline.js.map b/priv/static/packs/features/home_timeline.js.map index 20a487956..156d44782 100644 Binary files a/priv/static/packs/features/home_timeline.js.map and b/priv/static/packs/features/home_timeline.js.map differ diff --git a/priv/static/packs/features/keyboard_shortcuts.js b/priv/static/packs/features/keyboard_shortcuts.js index 484204569..cfeb074bd 100644 Binary files a/priv/static/packs/features/keyboard_shortcuts.js and b/priv/static/packs/features/keyboard_shortcuts.js differ diff --git a/priv/static/packs/features/keyboard_shortcuts.js.map b/priv/static/packs/features/keyboard_shortcuts.js.map index a383cb935..d736f454e 100644 Binary files a/priv/static/packs/features/keyboard_shortcuts.js.map and b/priv/static/packs/features/keyboard_shortcuts.js.map differ diff --git a/priv/static/packs/features/list_adder.js b/priv/static/packs/features/list_adder.js index 84fe9926c..0b4da1969 100644 Binary files a/priv/static/packs/features/list_adder.js and b/priv/static/packs/features/list_adder.js differ diff --git a/priv/static/packs/features/list_adder.js.map b/priv/static/packs/features/list_adder.js.map index f29e5f9f8..f3b671cad 100644 Binary files a/priv/static/packs/features/list_adder.js.map and b/priv/static/packs/features/list_adder.js.map differ diff --git a/priv/static/packs/features/list_editor.js b/priv/static/packs/features/list_editor.js index 76f0f6599..f63276d6c 100644 Binary files a/priv/static/packs/features/list_editor.js and b/priv/static/packs/features/list_editor.js differ diff --git a/priv/static/packs/features/list_editor.js.map b/priv/static/packs/features/list_editor.js.map index c2ab32c4a..ee536303a 100644 Binary files a/priv/static/packs/features/list_editor.js.map and b/priv/static/packs/features/list_editor.js.map differ diff --git a/priv/static/packs/features/list_timeline.js b/priv/static/packs/features/list_timeline.js index 391d15b87..8592d85a1 100644 Binary files a/priv/static/packs/features/list_timeline.js and b/priv/static/packs/features/list_timeline.js differ diff --git a/priv/static/packs/features/list_timeline.js.map b/priv/static/packs/features/list_timeline.js.map index 766670bd2..6e55f0a0c 100644 Binary files a/priv/static/packs/features/list_timeline.js.map and b/priv/static/packs/features/list_timeline.js.map differ diff --git a/priv/static/packs/features/lists.js b/priv/static/packs/features/lists.js index fa26c7031..702c3adcf 100644 Binary files a/priv/static/packs/features/lists.js and b/priv/static/packs/features/lists.js differ diff --git a/priv/static/packs/features/lists.js.map b/priv/static/packs/features/lists.js.map index 2e4811657..58c03a95b 100644 Binary files a/priv/static/packs/features/lists.js.map and b/priv/static/packs/features/lists.js.map differ diff --git a/priv/static/packs/features/mutes.js b/priv/static/packs/features/mutes.js index 3ed714fbd..444d6cd06 100644 Binary files a/priv/static/packs/features/mutes.js and b/priv/static/packs/features/mutes.js differ diff --git a/priv/static/packs/features/mutes.js.map b/priv/static/packs/features/mutes.js.map index 83d73d273..24155f586 100644 Binary files a/priv/static/packs/features/mutes.js.map and b/priv/static/packs/features/mutes.js.map differ diff --git a/priv/static/packs/features/notifications.js b/priv/static/packs/features/notifications.js index 49f828901..419aeeaac 100644 Binary files a/priv/static/packs/features/notifications.js and b/priv/static/packs/features/notifications.js differ diff --git a/priv/static/packs/features/notifications.js.map b/priv/static/packs/features/notifications.js.map index d466060de..80ef6305b 100644 Binary files a/priv/static/packs/features/notifications.js.map and b/priv/static/packs/features/notifications.js.map differ diff --git a/priv/static/packs/features/pinned_statuses.js b/priv/static/packs/features/pinned_statuses.js index b5a366202..1aa2d549a 100644 Binary files a/priv/static/packs/features/pinned_statuses.js and b/priv/static/packs/features/pinned_statuses.js differ diff --git a/priv/static/packs/features/pinned_statuses.js.map b/priv/static/packs/features/pinned_statuses.js.map index 2920e023c..898fcfd27 100644 Binary files a/priv/static/packs/features/pinned_statuses.js.map and b/priv/static/packs/features/pinned_statuses.js.map differ diff --git a/priv/static/packs/features/public_timeline.js b/priv/static/packs/features/public_timeline.js index eb800e73e..a32594de9 100644 Binary files a/priv/static/packs/features/public_timeline.js and b/priv/static/packs/features/public_timeline.js differ diff --git a/priv/static/packs/features/public_timeline.js.map b/priv/static/packs/features/public_timeline.js.map index eb39bab9d..92bd1de46 100644 Binary files a/priv/static/packs/features/public_timeline.js.map and b/priv/static/packs/features/public_timeline.js.map differ diff --git a/priv/static/packs/features/reblogs.js b/priv/static/packs/features/reblogs.js index d8598f305..857ed6a60 100644 Binary files a/priv/static/packs/features/reblogs.js and b/priv/static/packs/features/reblogs.js differ diff --git a/priv/static/packs/features/reblogs.js.map b/priv/static/packs/features/reblogs.js.map index ec193dd4f..211b4a843 100644 Binary files a/priv/static/packs/features/reblogs.js.map and b/priv/static/packs/features/reblogs.js.map differ diff --git a/priv/static/packs/features/search.js b/priv/static/packs/features/search.js index 7efd675f3..64bbc4984 100644 Binary files a/priv/static/packs/features/search.js and b/priv/static/packs/features/search.js differ diff --git a/priv/static/packs/features/status.js b/priv/static/packs/features/status.js index bd8c71d8f..dace442de 100644 Binary files a/priv/static/packs/features/status.js and b/priv/static/packs/features/status.js differ diff --git a/priv/static/packs/features/status.js.map b/priv/static/packs/features/status.js.map index 55d5f08e7..f4688ee17 100644 Binary files a/priv/static/packs/features/status.js.map and b/priv/static/packs/features/status.js.map differ diff --git a/priv/static/packs/flavours/glitch/about.js b/priv/static/packs/flavours/glitch/about.js index b858f1220..19611cdda 100644 Binary files a/priv/static/packs/flavours/glitch/about.js and b/priv/static/packs/flavours/glitch/about.js differ diff --git a/priv/static/packs/flavours/glitch/about.js.LICENSE b/priv/static/packs/flavours/glitch/about.js.LICENSE deleted file mode 100644 index 0a0301353..000000000 --- a/priv/static/packs/flavours/glitch/about.js.LICENSE +++ /dev/null @@ -1,193 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ - -/*! https://mths.be/punycode v1.4.1 by @mathias */ diff --git a/priv/static/packs/flavours/glitch/about.js.LICENSE.txt b/priv/static/packs/flavours/glitch/about.js.LICENSE.txt new file mode 100644 index 000000000..90a9a7678 --- /dev/null +++ b/priv/static/packs/flavours/glitch/about.js.LICENSE.txt @@ -0,0 +1,193 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! https://mths.be/punycode v1.4.1 by @mathias */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/glitch/about.js.map b/priv/static/packs/flavours/glitch/about.js.map index bc9d0864b..4914692f5 100644 Binary files a/priv/static/packs/flavours/glitch/about.js.map and b/priv/static/packs/flavours/glitch/about.js.map differ diff --git a/priv/static/packs/flavours/glitch/admin.js b/priv/static/packs/flavours/glitch/admin.js index b6d94af56..82d7437f1 100644 Binary files a/priv/static/packs/flavours/glitch/admin.js and b/priv/static/packs/flavours/glitch/admin.js differ diff --git a/priv/static/packs/flavours/glitch/admin.js.LICENSE b/priv/static/packs/flavours/glitch/admin.js.LICENSE deleted file mode 100644 index 448b94017..000000000 --- a/priv/static/packs/flavours/glitch/admin.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ diff --git a/priv/static/packs/flavours/glitch/admin.js.LICENSE.txt b/priv/static/packs/flavours/glitch/admin.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/glitch/admin.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/glitch/admin.js.map b/priv/static/packs/flavours/glitch/admin.js.map index 203550c99..df66cbc6a 100644 Binary files a/priv/static/packs/flavours/glitch/admin.js.map and b/priv/static/packs/flavours/glitch/admin.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/account_gallery.js b/priv/static/packs/flavours/glitch/async/account_gallery.js index 50c23a1cf..a282e6ebc 100644 Binary files a/priv/static/packs/flavours/glitch/async/account_gallery.js and b/priv/static/packs/flavours/glitch/async/account_gallery.js differ diff --git a/priv/static/packs/flavours/glitch/async/account_gallery.js.map b/priv/static/packs/flavours/glitch/async/account_gallery.js.map index 2fcee6618..0aeddd9ef 100644 Binary files a/priv/static/packs/flavours/glitch/async/account_gallery.js.map and b/priv/static/packs/flavours/glitch/async/account_gallery.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/account_timeline.js b/priv/static/packs/flavours/glitch/async/account_timeline.js index ebf40ef53..80863af8d 100644 Binary files a/priv/static/packs/flavours/glitch/async/account_timeline.js and b/priv/static/packs/flavours/glitch/async/account_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/account_timeline.js.map b/priv/static/packs/flavours/glitch/async/account_timeline.js.map index 2288ad945..af4549145 100644 Binary files a/priv/static/packs/flavours/glitch/async/account_timeline.js.map and b/priv/static/packs/flavours/glitch/async/account_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/block_modal.js b/priv/static/packs/flavours/glitch/async/block_modal.js index 7a5c639aa..ff78212cd 100644 Binary files a/priv/static/packs/flavours/glitch/async/block_modal.js and b/priv/static/packs/flavours/glitch/async/block_modal.js differ diff --git a/priv/static/packs/flavours/glitch/async/block_modal.js.map b/priv/static/packs/flavours/glitch/async/block_modal.js.map index 4214332d9..a99478646 100644 Binary files a/priv/static/packs/flavours/glitch/async/block_modal.js.map and b/priv/static/packs/flavours/glitch/async/block_modal.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/blocks.js b/priv/static/packs/flavours/glitch/async/blocks.js index 868309ca2..7287b772f 100644 Binary files a/priv/static/packs/flavours/glitch/async/blocks.js and b/priv/static/packs/flavours/glitch/async/blocks.js differ diff --git a/priv/static/packs/flavours/glitch/async/blocks.js.map b/priv/static/packs/flavours/glitch/async/blocks.js.map index bf2305454..0f1981357 100644 Binary files a/priv/static/packs/flavours/glitch/async/blocks.js.map and b/priv/static/packs/flavours/glitch/async/blocks.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js b/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js index 7621abf44..45e9d1417 100644 Binary files a/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js and b/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js differ diff --git a/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js.map b/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js.map index d784d0af2..050a31135 100644 Binary files a/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js.map and b/priv/static/packs/flavours/glitch/async/bookmarked_statuses.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/community_timeline.js b/priv/static/packs/flavours/glitch/async/community_timeline.js index 73d1c24bc..75e21406f 100644 Binary files a/priv/static/packs/flavours/glitch/async/community_timeline.js and b/priv/static/packs/flavours/glitch/async/community_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/community_timeline.js.map b/priv/static/packs/flavours/glitch/async/community_timeline.js.map index 690da36f0..da2a24bdf 100644 Binary files a/priv/static/packs/flavours/glitch/async/community_timeline.js.map and b/priv/static/packs/flavours/glitch/async/community_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/compose.js b/priv/static/packs/flavours/glitch/async/compose.js index 18da703a5..dbbe43090 100644 Binary files a/priv/static/packs/flavours/glitch/async/compose.js and b/priv/static/packs/flavours/glitch/async/compose.js differ diff --git a/priv/static/packs/flavours/glitch/async/compose.js.map b/priv/static/packs/flavours/glitch/async/compose.js.map index 8df71764e..5770307aa 100644 Binary files a/priv/static/packs/flavours/glitch/async/compose.js.map and b/priv/static/packs/flavours/glitch/async/compose.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/direct_timeline.js b/priv/static/packs/flavours/glitch/async/direct_timeline.js index 66e783179..4b19a1383 100644 Binary files a/priv/static/packs/flavours/glitch/async/direct_timeline.js and b/priv/static/packs/flavours/glitch/async/direct_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/direct_timeline.js.map b/priv/static/packs/flavours/glitch/async/direct_timeline.js.map index da686195a..83b0d0c28 100644 Binary files a/priv/static/packs/flavours/glitch/async/direct_timeline.js.map and b/priv/static/packs/flavours/glitch/async/direct_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/domain_blocks.js b/priv/static/packs/flavours/glitch/async/domain_blocks.js index d78750fe9..74fac4c3e 100644 Binary files a/priv/static/packs/flavours/glitch/async/domain_blocks.js and b/priv/static/packs/flavours/glitch/async/domain_blocks.js differ diff --git a/priv/static/packs/flavours/glitch/async/domain_blocks.js.map b/priv/static/packs/flavours/glitch/async/domain_blocks.js.map index a877d0ea5..9534e788e 100644 Binary files a/priv/static/packs/flavours/glitch/async/domain_blocks.js.map and b/priv/static/packs/flavours/glitch/async/domain_blocks.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/embed_modal.js b/priv/static/packs/flavours/glitch/async/embed_modal.js index 770375048..6b2709250 100644 Binary files a/priv/static/packs/flavours/glitch/async/embed_modal.js and b/priv/static/packs/flavours/glitch/async/embed_modal.js differ diff --git a/priv/static/packs/flavours/glitch/async/embed_modal.js.map b/priv/static/packs/flavours/glitch/async/embed_modal.js.map index ea025afc1..848f507d6 100644 Binary files a/priv/static/packs/flavours/glitch/async/embed_modal.js.map and b/priv/static/packs/flavours/glitch/async/embed_modal.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/emoji_picker.js b/priv/static/packs/flavours/glitch/async/emoji_picker.js index 21db597b1..57833a531 100644 Binary files a/priv/static/packs/flavours/glitch/async/emoji_picker.js and b/priv/static/packs/flavours/glitch/async/emoji_picker.js differ diff --git a/priv/static/packs/flavours/glitch/async/favourited_statuses.js b/priv/static/packs/flavours/glitch/async/favourited_statuses.js index aaf19c371..4401228f6 100644 Binary files a/priv/static/packs/flavours/glitch/async/favourited_statuses.js and b/priv/static/packs/flavours/glitch/async/favourited_statuses.js differ diff --git a/priv/static/packs/flavours/glitch/async/favourited_statuses.js.map b/priv/static/packs/flavours/glitch/async/favourited_statuses.js.map index 148d77388..8fbbf1484 100644 Binary files a/priv/static/packs/flavours/glitch/async/favourited_statuses.js.map and b/priv/static/packs/flavours/glitch/async/favourited_statuses.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/favourites.js b/priv/static/packs/flavours/glitch/async/favourites.js index 168e30029..59b9cec75 100644 Binary files a/priv/static/packs/flavours/glitch/async/favourites.js and b/priv/static/packs/flavours/glitch/async/favourites.js differ diff --git a/priv/static/packs/flavours/glitch/async/favourites.js.map b/priv/static/packs/flavours/glitch/async/favourites.js.map index d9b11d0cb..4819f60ec 100644 Binary files a/priv/static/packs/flavours/glitch/async/favourites.js.map and b/priv/static/packs/flavours/glitch/async/favourites.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/follow_requests.js b/priv/static/packs/flavours/glitch/async/follow_requests.js index 40bde6f6f..57ed8204e 100644 Binary files a/priv/static/packs/flavours/glitch/async/follow_requests.js and b/priv/static/packs/flavours/glitch/async/follow_requests.js differ diff --git a/priv/static/packs/flavours/glitch/async/follow_requests.js.map b/priv/static/packs/flavours/glitch/async/follow_requests.js.map index 9b3fcd854..c0a2db075 100644 Binary files a/priv/static/packs/flavours/glitch/async/follow_requests.js.map and b/priv/static/packs/flavours/glitch/async/follow_requests.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/followers.js b/priv/static/packs/flavours/glitch/async/followers.js index de0905eb3..fdb647ef2 100644 Binary files a/priv/static/packs/flavours/glitch/async/followers.js and b/priv/static/packs/flavours/glitch/async/followers.js differ diff --git a/priv/static/packs/flavours/glitch/async/followers.js.map b/priv/static/packs/flavours/glitch/async/followers.js.map index 89f1588f9..cc1470d15 100644 Binary files a/priv/static/packs/flavours/glitch/async/followers.js.map and b/priv/static/packs/flavours/glitch/async/followers.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/following.js b/priv/static/packs/flavours/glitch/async/following.js index 98589dd95..60f78c45c 100644 Binary files a/priv/static/packs/flavours/glitch/async/following.js and b/priv/static/packs/flavours/glitch/async/following.js differ diff --git a/priv/static/packs/flavours/glitch/async/following.js.map b/priv/static/packs/flavours/glitch/async/following.js.map index 66ded9c53..7fff787bb 100644 Binary files a/priv/static/packs/flavours/glitch/async/following.js.map and b/priv/static/packs/flavours/glitch/async/following.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/generic_not_found.js b/priv/static/packs/flavours/glitch/async/generic_not_found.js index 4546a62ef..dca1026b6 100644 Binary files a/priv/static/packs/flavours/glitch/async/generic_not_found.js and b/priv/static/packs/flavours/glitch/async/generic_not_found.js differ diff --git a/priv/static/packs/flavours/glitch/async/getting_started.js b/priv/static/packs/flavours/glitch/async/getting_started.js index cf8a8c738..b1e5d2309 100644 Binary files a/priv/static/packs/flavours/glitch/async/getting_started.js and b/priv/static/packs/flavours/glitch/async/getting_started.js differ diff --git a/priv/static/packs/flavours/glitch/async/getting_started.js.map b/priv/static/packs/flavours/glitch/async/getting_started.js.map index e63299ca7..5c9294fff 100644 Binary files a/priv/static/packs/flavours/glitch/async/getting_started.js.map and b/priv/static/packs/flavours/glitch/async/getting_started.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/getting_started_misc.js b/priv/static/packs/flavours/glitch/async/getting_started_misc.js index 1efef99bf..2b17ff3bc 100644 Binary files a/priv/static/packs/flavours/glitch/async/getting_started_misc.js and b/priv/static/packs/flavours/glitch/async/getting_started_misc.js differ diff --git a/priv/static/packs/flavours/glitch/async/getting_started_misc.js.map b/priv/static/packs/flavours/glitch/async/getting_started_misc.js.map index 6917180c4..ba253da51 100644 Binary files a/priv/static/packs/flavours/glitch/async/getting_started_misc.js.map and b/priv/static/packs/flavours/glitch/async/getting_started_misc.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/hashtag_timeline.js b/priv/static/packs/flavours/glitch/async/hashtag_timeline.js index b997808d8..70c5b1c02 100644 Binary files a/priv/static/packs/flavours/glitch/async/hashtag_timeline.js and b/priv/static/packs/flavours/glitch/async/hashtag_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/hashtag_timeline.js.map b/priv/static/packs/flavours/glitch/async/hashtag_timeline.js.map index 602538af9..f495dd459 100644 Binary files a/priv/static/packs/flavours/glitch/async/hashtag_timeline.js.map and b/priv/static/packs/flavours/glitch/async/hashtag_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/home_timeline.js b/priv/static/packs/flavours/glitch/async/home_timeline.js index 63d338bb1..778f336ed 100644 Binary files a/priv/static/packs/flavours/glitch/async/home_timeline.js and b/priv/static/packs/flavours/glitch/async/home_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/home_timeline.js.map b/priv/static/packs/flavours/glitch/async/home_timeline.js.map index c063eebe2..37db6a195 100644 Binary files a/priv/static/packs/flavours/glitch/async/home_timeline.js.map and b/priv/static/packs/flavours/glitch/async/home_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js b/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js index 0d59ee5ec..b6037c53e 100644 Binary files a/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js and b/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js differ diff --git a/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js.map b/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js.map index 18b367cfd..2db9f69d1 100644 Binary files a/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js.map and b/priv/static/packs/flavours/glitch/async/keyboard_shortcuts.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/list_editor.js b/priv/static/packs/flavours/glitch/async/list_editor.js index 59bcc86a6..d80cb005a 100644 Binary files a/priv/static/packs/flavours/glitch/async/list_editor.js and b/priv/static/packs/flavours/glitch/async/list_editor.js differ diff --git a/priv/static/packs/flavours/glitch/async/list_editor.js.map b/priv/static/packs/flavours/glitch/async/list_editor.js.map index c0b2041fd..32db1fab1 100644 Binary files a/priv/static/packs/flavours/glitch/async/list_editor.js.map and b/priv/static/packs/flavours/glitch/async/list_editor.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/list_timeline.js b/priv/static/packs/flavours/glitch/async/list_timeline.js index 8f1d12946..7be347902 100644 Binary files a/priv/static/packs/flavours/glitch/async/list_timeline.js and b/priv/static/packs/flavours/glitch/async/list_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/list_timeline.js.map b/priv/static/packs/flavours/glitch/async/list_timeline.js.map index 109de2301..82e887f76 100644 Binary files a/priv/static/packs/flavours/glitch/async/list_timeline.js.map and b/priv/static/packs/flavours/glitch/async/list_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/lists.js b/priv/static/packs/flavours/glitch/async/lists.js index 09e9c2090..01359b617 100644 Binary files a/priv/static/packs/flavours/glitch/async/lists.js and b/priv/static/packs/flavours/glitch/async/lists.js differ diff --git a/priv/static/packs/flavours/glitch/async/lists.js.map b/priv/static/packs/flavours/glitch/async/lists.js.map index 6d0e57545..94a638597 100644 Binary files a/priv/static/packs/flavours/glitch/async/lists.js.map and b/priv/static/packs/flavours/glitch/async/lists.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/mute_modal.js b/priv/static/packs/flavours/glitch/async/mute_modal.js index 8799086c4..4563b7c03 100644 Binary files a/priv/static/packs/flavours/glitch/async/mute_modal.js and b/priv/static/packs/flavours/glitch/async/mute_modal.js differ diff --git a/priv/static/packs/flavours/glitch/async/mute_modal.js.map b/priv/static/packs/flavours/glitch/async/mute_modal.js.map index 2cef662e9..b8a427cc5 100644 Binary files a/priv/static/packs/flavours/glitch/async/mute_modal.js.map and b/priv/static/packs/flavours/glitch/async/mute_modal.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/mutes.js b/priv/static/packs/flavours/glitch/async/mutes.js index bff20ce26..561bff711 100644 Binary files a/priv/static/packs/flavours/glitch/async/mutes.js and b/priv/static/packs/flavours/glitch/async/mutes.js differ diff --git a/priv/static/packs/flavours/glitch/async/mutes.js.map b/priv/static/packs/flavours/glitch/async/mutes.js.map index 63d70481c..288221494 100644 Binary files a/priv/static/packs/flavours/glitch/async/mutes.js.map and b/priv/static/packs/flavours/glitch/async/mutes.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/notifications.js b/priv/static/packs/flavours/glitch/async/notifications.js index d894ca4d3..775819241 100644 Binary files a/priv/static/packs/flavours/glitch/async/notifications.js and b/priv/static/packs/flavours/glitch/async/notifications.js differ diff --git a/priv/static/packs/flavours/glitch/async/notifications.js.map b/priv/static/packs/flavours/glitch/async/notifications.js.map index 340227645..7a3ae0be5 100644 Binary files a/priv/static/packs/flavours/glitch/async/notifications.js.map and b/priv/static/packs/flavours/glitch/async/notifications.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/onboarding_modal.js b/priv/static/packs/flavours/glitch/async/onboarding_modal.js index fbe875eed..4e5acb0c0 100644 Binary files a/priv/static/packs/flavours/glitch/async/onboarding_modal.js and b/priv/static/packs/flavours/glitch/async/onboarding_modal.js differ diff --git a/priv/static/packs/flavours/glitch/async/onboarding_modal.js.map b/priv/static/packs/flavours/glitch/async/onboarding_modal.js.map index 9e11b9f3a..4b7124f38 100644 Binary files a/priv/static/packs/flavours/glitch/async/onboarding_modal.js.map and b/priv/static/packs/flavours/glitch/async/onboarding_modal.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js b/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js index a8dbe836b..799270f5b 100644 Binary files a/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js and b/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js differ diff --git a/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js.map b/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js.map index a620906cd..c0677599f 100644 Binary files a/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js.map and b/priv/static/packs/flavours/glitch/async/pinned_accounts_editor.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/pinned_statuses.js b/priv/static/packs/flavours/glitch/async/pinned_statuses.js index 58de4fa48..f2f32ada7 100644 Binary files a/priv/static/packs/flavours/glitch/async/pinned_statuses.js and b/priv/static/packs/flavours/glitch/async/pinned_statuses.js differ diff --git a/priv/static/packs/flavours/glitch/async/pinned_statuses.js.map b/priv/static/packs/flavours/glitch/async/pinned_statuses.js.map index 01838825d..20582528c 100644 Binary files a/priv/static/packs/flavours/glitch/async/pinned_statuses.js.map and b/priv/static/packs/flavours/glitch/async/pinned_statuses.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/public_timeline.js b/priv/static/packs/flavours/glitch/async/public_timeline.js index 8a9678a53..d48e77f7e 100644 Binary files a/priv/static/packs/flavours/glitch/async/public_timeline.js and b/priv/static/packs/flavours/glitch/async/public_timeline.js differ diff --git a/priv/static/packs/flavours/glitch/async/public_timeline.js.map b/priv/static/packs/flavours/glitch/async/public_timeline.js.map index c8fa69daa..77fa4cf1f 100644 Binary files a/priv/static/packs/flavours/glitch/async/public_timeline.js.map and b/priv/static/packs/flavours/glitch/async/public_timeline.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/reblogs.js b/priv/static/packs/flavours/glitch/async/reblogs.js index 3e6c806f4..59ca6d0af 100644 Binary files a/priv/static/packs/flavours/glitch/async/reblogs.js and b/priv/static/packs/flavours/glitch/async/reblogs.js differ diff --git a/priv/static/packs/flavours/glitch/async/reblogs.js.map b/priv/static/packs/flavours/glitch/async/reblogs.js.map index 696da7f09..150c0e1ab 100644 Binary files a/priv/static/packs/flavours/glitch/async/reblogs.js.map and b/priv/static/packs/flavours/glitch/async/reblogs.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/report_modal.js b/priv/static/packs/flavours/glitch/async/report_modal.js index 92601ff8c..d4d94eda1 100644 Binary files a/priv/static/packs/flavours/glitch/async/report_modal.js and b/priv/static/packs/flavours/glitch/async/report_modal.js differ diff --git a/priv/static/packs/flavours/glitch/async/report_modal.js.map b/priv/static/packs/flavours/glitch/async/report_modal.js.map index 40cd55a9c..50065b0ba 100644 Binary files a/priv/static/packs/flavours/glitch/async/report_modal.js.map and b/priv/static/packs/flavours/glitch/async/report_modal.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/settings_modal.js b/priv/static/packs/flavours/glitch/async/settings_modal.js index de55ebceb..7aa1fb5a8 100644 Binary files a/priv/static/packs/flavours/glitch/async/settings_modal.js and b/priv/static/packs/flavours/glitch/async/settings_modal.js differ diff --git a/priv/static/packs/flavours/glitch/async/settings_modal.js.map b/priv/static/packs/flavours/glitch/async/settings_modal.js.map index 70ebb8ebe..eec31e79a 100644 Binary files a/priv/static/packs/flavours/glitch/async/settings_modal.js.map and b/priv/static/packs/flavours/glitch/async/settings_modal.js.map differ diff --git a/priv/static/packs/flavours/glitch/async/status.js b/priv/static/packs/flavours/glitch/async/status.js index f82c91fd6..bfb80c63d 100644 Binary files a/priv/static/packs/flavours/glitch/async/status.js and b/priv/static/packs/flavours/glitch/async/status.js differ diff --git a/priv/static/packs/flavours/glitch/async/status.js.map b/priv/static/packs/flavours/glitch/async/status.js.map index 012698efb..a8c110f98 100644 Binary files a/priv/static/packs/flavours/glitch/async/status.js.map and b/priv/static/packs/flavours/glitch/async/status.js.map differ diff --git a/priv/static/packs/flavours/glitch/common.css b/priv/static/packs/flavours/glitch/common.css index 98f5564e2..f25155ee5 100644 Binary files a/priv/static/packs/flavours/glitch/common.css and b/priv/static/packs/flavours/glitch/common.css differ diff --git a/priv/static/packs/flavours/glitch/common.css.map b/priv/static/packs/flavours/glitch/common.css.map index a44590ee3..2d608b89f 100644 --- a/priv/static/packs/flavours/glitch/common.css.map +++ b/priv/static/packs/flavours/glitch/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///index.scss","webpack:///./app/javascript/flavours/glitch/styles/reset.scss","webpack:///./app/javascript/flavours/glitch/styles/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/basics.scss","webpack:///./app/javascript/flavours/glitch/styles/containers.scss","webpack:///./app/javascript/flavours/glitch/styles/_mixins.scss","webpack:///./app/javascript/flavours/glitch/styles/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/footer.scss","webpack:///./app/javascript/flavours/glitch/styles/compact_header.scss","webpack:///./app/javascript/flavours/glitch/styles/widgets.scss","webpack:///./app/javascript/flavours/glitch/styles/forms.scss","webpack:///./app/javascript/flavours/glitch/styles/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/statuses.scss","webpack:///./app/javascript/flavours/glitch/styles/components/index.scss","webpack:///./app/javascript/flavours/glitch/styles/components/boost.scss","webpack:///./app/javascript/flavours/glitch/styles/components/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/components/domains.scss","webpack:///./app/javascript/flavours/glitch/styles/components/status.scss","webpack:///./app/javascript/flavours/glitch/styles/components/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/components/composer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/columns.scss","webpack:///./app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss","webpack:///./app/javascript/flavours/glitch/styles/components/directory.scss","webpack:///./app/javascript/flavours/glitch/styles/components/search.scss","webpack:///","webpack:///./app/javascript/flavours/glitch/styles/components/emoji.scss","webpack:///./app/javascript/flavours/glitch/styles/components/doodle.scss","webpack:///./app/javascript/flavours/glitch/styles/components/drawer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/media.scss","webpack:///./app/javascript/flavours/glitch/styles/components/sensitive.scss","webpack:///./app/javascript/flavours/glitch/styles/components/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/components/emoji_picker.scss","webpack:///./app/javascript/flavours/glitch/styles/components/local_settings.scss","webpack:///./app/javascript/flavours/glitch/styles/components/error_boundary.scss","webpack:///./app/javascript/flavours/glitch/styles/components/single_column.scss","webpack:///./app/javascript/flavours/glitch/styles/polls.scss","webpack:///./app/javascript/flavours/glitch/styles/about.scss","webpack:///./app/javascript/flavours/glitch/styles/tables.scss","webpack:///./app/javascript/flavours/glitch/styles/admin.scss","webpack:///./app/javascript/flavours/glitch/styles/accessibility.scss","webpack:///./app/javascript/flavours/glitch/styles/rtl.scss","webpack:///./app/javascript/flavours/glitch/styles/dashboard.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CCtEW,iED6Eb,kBC7Ea,4BDiFb,sBACE,MEtFF,sBACE,mBACA,eACA,iBACA,gBACA,WDVM,kCCYN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,sIAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBD5CW,kBCgDX,iBACE,kBACA,0BAEA,iBACE,YAIJ,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDtEoB,mBAPX,WCgFT,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,wBAEA,aACE,gBACA,WACA,YACA,kBACA,uBAGF,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,6BAKF,YAEE,WACA,mBACA,uBACA,oBACA,yEAKF,gBAEE,+EAKF,WAEE,gBCrJJ,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFrBI,YEuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF9BE,qBEgCF,UACA,kBACA,iBACA,uBACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAKA,UACqB,sCC3EvB,iBD4EE,6BAEA,UACE,YACA,cACA,SACA,kBACA,iBF5BkB,wBG9DtB,4BACA,uBD8FA,aACE,cF/EsB,wBEiFtB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UF1UA,qCE6UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF/UkB,mBEiVlB,kBACA,uHAEA,yBAGE,WFvWA,qCE2WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFxaoB,8CE6atB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,CAEA,WACqB,yCCzgB3B,kBD0gBM,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,iBF7dc,wBG9DtB,4BACA,qCD+hBI,2CAvCF,YAwCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAKA,UACqB,sCCtiB7B,CDuiBQ,sBACA,wDAEA,QACE,kBACA,iBFrfY,wBG9DtB,4BACA,2DDsjBQ,mDAbF,YAcI,sCAKN,2CApEF,eAqEI,sCAGF,2CAxEF,cAyEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WFhlBF,gBEklBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WF1lBJ,gBE4lBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aFlmBY,oDEymBlB,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cF9nBc,aEgoBd,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BF/pBc,wEEqqBd,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFzsBJ,uBE2sBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cF/tBgB,uDEkuBhB,oBACE,cFnuBc,qBEquBd,aACA,gBACA,8DAEA,eACE,WF1vBJ,qCEgwBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aF3yBU,8DEizBV,mBACA,WFnzBE,qFEuzBJ,YAEE,eACA,cF1yBkB,2CE8yBpB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBFj3BK,+IEo3BH,kBAGE,WEl4BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,eChBJ,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,yBCrBF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cNhFkB,6BMmFlB,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cPeoB,gBObpB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cPFoB,wBOMtB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBRPI,uBQUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBRVW,aQYT,0BACA,eACA,cRPoB,iBQSpB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aRtCsB,qBQwCpB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cR9DoB,+BQkEtB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aR3FsB,aQgGxB,YACE,kBACA,mBRzGW,mCQ2GX,qBAGF,YACE,kBACA,0BACA,kBACA,cR3GsB,mBQ6GtB,iBAGF,eACE,eACA,cRlHsB,iBQoHtB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cR7HoB,0BQiItB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cR1IoB,qBQ4IpB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBRnKW,mCQqKX,cR7JwB,gBQ+JxB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cRzMkB,8DQ+MpB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eRlPM,CQoPN,cACA,cRpOsB,mBQsOtB,+BANA,iBACA,CRlPM,kCQgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,URjQM,eQmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cR1PoB,qCQ8PtB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBR/Qa,kBQiRX,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBR5RO,kBQ8RP,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBRpSsB,eQsSpB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WRnUE,mBQqUF,gBACA,uBACA,wBAEA,aRzTkB,0BQ6TlB,aACE,gBACA,eACA,eACA,cRjUgB,yFQuUlB,URvVE,+BQ8VJ,aACE,YACA,uDAGF,oBRjVsB,eQuV1B,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cRnYoB,gBQqYpB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WR3aI,8BQ8aJ,aACE,cR/ZkB,gBQialB,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aRhgBsB,iCQ+fxB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cRnhBsB,4JQshBtB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WR9jBI,gCQgkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MC/kBJ,+BACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WThDA,cSkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aTnDoB,0BSqDlB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aTpFsB,sBSuFpB,aTrFsB,yBSyFtB,iBACE,kBACA,gBACA,wBAIJ,aACE,eACA,eACA,qBAGF,kBACE,cTzGoB,iCS4GpB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WTzJA,gBS2JA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WT/KE,cSiLF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WTrME,cSuMF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WTnRI,cSqRJ,WACA,2CAKE,mBACE,eACA,WT7RA,qBS+RA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WT7TI,cS+TJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBTpVY,oLSwVZ,iBACE,4WAGF,oBT3UsB,mBS8UpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBT5XsB,WAlBlB,eSiZJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBT5ZoB,gGSgapB,kBT9aQ,kHSibN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WTjcI,cSmcJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cTjckB,oBSmclB,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,8DACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UT/gBF,aSyhBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cTjhBsB,kBSmhBtB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cTziBY,sBS6iBd,mCACE,+BACA,cT9iBQ,kBSkjBV,oBACE,cTriBoB,qBSuiBpB,wBAEA,UTzjBI,0BS2jBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gCACA,mBTjkBS,WATL,eS6kBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aTlmBsB,qBSomBpB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aTpnBwB,yBSsnBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cTtoBsB,oCSyoBtB,cACE,mBACA,kBACA,4CAGF,aT9oBwB,gBSgpBtB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBTtrBM,YSwrBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cTnrBwB,WSqrBxB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WTluBI,qCSouBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UT1uBI,0BS4uBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cTzwBsB,0BS4wBtB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WTtyBI,kBSwyBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aThzBc,0SS0zBZ,+BACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gCACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBTp2Bc,gBSs2BZ,2BAEA,kBTx2BY,gBS02BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC36BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,CAEA,UACqB,sCPpDzB,gBOqDI,wBAEA,UACE,YACA,cACA,SACA,kBACA,iBVLgB,wBG9DtB,4BACA,mBOoEM,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WV/EA,gBUiFA,gBACA,uBACA,+BAGF,aACE,eACA,cVxEgB,gBU0EhB,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WV7GI,gBU+GJ,qBACA,iBACA,qBACA,sBAGF,eVrHM,oBUuHJ,cV9GS,eUgHT,cACA,kBAGF,cACE,uCAGF,wBAEE,cVlHsB,oBUsHxB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBVrKa,mCUuKX,cVhKsB,eUkKtB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cV9LwB,sCUgMxB,sCACA,6DAEA,aVnNc,sCUqNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cV7OsB,0BU+OtB,6BAGF,aACE,cVpPoB,4BUwPtB,aVtPwB,qBUwPtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aVtRY,gBUwRV,0CAGF,aV3RY,wCUgSd,eACE,wCAIJ,UACE,0BAIA,aV3RsB,4BU8RpB,aV7RsB,qBU+RpB,qGAEA,yBAGE,iCAIJ,UVzTI,gBU2TF,wBAIJ,eACE,kBClUJ,kCACE,kBACA,gBACA,mBACA,qCAEA,iBANF,eAOI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBXhBW,6GWmBT,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBXzDwB,WAlBlB,oBW8EN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UXxFI,gFW4FN,kBAGE,qNAKA,kBXpFoB,4IW4FpB,kBX1GQ,qCWiHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAKA,cACA,iBACA,mBACA,mFAGF,iBACE,eACA,WACA,WACA,WACA,qMAGF,eAGE,mEASF,cACE,gBACA,qFAGF,aXhJoB,YWkJlB,eACA,WACA,eACA,gBACA,+GAGF,aACE,eACA,CACA,sBACA,eACA,yJAEA,cACE,uEAIJ,WACE,kBACA,WACA,eACA,iDAQF,iBACE,mBACA,yHAEA,iBACE,gBACA,+FAGF,UACE,WC3NR,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cZYwB,SYVxB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aZFsB,eYIpB,SAIJ,wBACE,YACA,kBACA,sBACA,WZ5BM,eY8BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,qCACA,mBACA,WACA,4CAEA,wBAGE,4BACA,qCACA,sBAGF,eACE,mFAEA,wBZ3DQ,gBY+DN,kBAIJ,wBZrDsB,eYuDpB,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,aZ5EW,mBAOW,qGYyEpB,wBAGE,8BAIJ,kBZlEsB,2GYqEpB,wBAGE,0BAIJ,cACE,iBACA,YACA,cZ7FoB,oBY+FpB,uBACA,iBACA,kBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,cAIJ,oBACE,UACA,cZ3GsB,SY6GtB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,qCACA,4BACA,2CACA,oBAGF,mCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aZnJwB,gCYuJxB,QACE,uEAGF,mBAGE,uBAGF,aZrJsB,sFYwJpB,aAGE,qCACA,6BAGF,mCACE,gCAGF,aACE,6BACA,8BAGF,aZpLsB,uCYuLpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,aZ9LwB,SYgMtB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,qCACA,4BACA,2CACA,yBAGF,mCACE,4BAGF,aACE,6BACA,eACA,0BAGF,aZ3OwB,qCY+OxB,QACE,sFAGF,mBAGE,gBAIJ,iBACE,uBACA,YAGF,WACE,cACA,qBACA,QACA,SACA,kBACA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,uCAIJ,MACE,kBACA,CZvSU,sEY8SZ,aZ9SY,uBYkTZ,aZnTc,4DYyTV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UZ7UM,0BY+UJ,eAIJ,aACE,eACA,gBACA,uBACA,mBACA,iBAEA,aACE,wBACA,sBAIA,cACA,gBAKA,yCAPF,WACE,CAEA,gBACA,uBACA,gBACA,mBAWA,CAVA,mBAGF,aACE,CACA,cAKA,8BAIA,yBACE,sBAIJ,SACE,YACA,eACA,iBACA,uBACA,mBACA,gBACA,CAME,sDAGF,cACE,YACA,kBACA,oBACA,qBAKN,eACE,wBAGF,cACE,eAGF,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cZhX4B,eAEC,0DYiX3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cZxY4B,eAEC,WYyY3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,WAIJ,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBZlc0B,cYocxB,kBACA,uCACA,mBAEA,eACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BZtdsB,2BY0dxB,WACE,iBACA,uBACA,yBZ7dsB,8BYiexB,QACE,iBACA,uBACA,4BZpesB,6BYwexB,SACE,gBACA,2BACA,2BZ3esB,wBYifxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZvfsB,cARb,gBYkgBT,uBACA,mBACA,yFAEA,kBZ7fsB,cADA,UYmgBpB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBZjhBsB,cYmhBtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ1iBsB,cARb,gBYqjBT,uBACA,mBACA,oDAEA,SACE,oDAGF,kBZpjBsB,cADA,iBY4jB1B,qBACE,iBAIA,sBACA,cZrjBsB,oBYwjBtB,cACE,gBACA,mBACA,kBACA,mBAGF,cACE,mBACA,iBAIJ,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WZnpBM,qBYqpBN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCThoBA,6GADF,kBSwoBI,4BACA,kHTpoBJ,kBSmoBI,4BACA,wBAIJ,+BACE,cZ1pBsB,sBY8pBxB,eACE,aACA,2BAGF,aACE,eACA,kBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBZ/qBsB,yBYirBtB,gBACA,kBACA,eACA,gBACA,iBACA,WZxsBI,mDY6sBR,oBACE,aAGF,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,gBAIJ,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBZzwBW,qCY2wBX,sEAGF,wBACE,4CAGF,wBZzwB0B,+EY6wB1B,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBZr0BwB,yDYy0B1B,kBZl1Ba,2BYw1Bb,iBACE,gBACA,cAGF,aACE,kBAGF,kBZj2Ba,cYm2BX,oBAEA,aZ71BwB,oBYi2BxB,aZp1BsB,yBYw1BtB,0BACE,CADF,uBACE,CADF,kBACE,kDAKA,sBACA,cACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,aZ12BoB,eY42BlB,0DAEA,aZ92BkB,0BYg3BhB,sDAIJ,oBACE,cZn4BkB,sMYs4BlB,yBAGE,0BAKN,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cZt5BkB,aYw5BlB,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,aZj7BkB,qBYw7BxB,oBACE,kBACA,eACA,iBACA,gBACA,mBZp8BW,gBYs8BX,iBACA,qBAGF,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aZx9BwB,uBY09BtB,CACA,WACA,CADA,+BACA,sBACA,cACA,oBACA,mBACA,cACA,WACA,0CAEA,UZp/BM,4BAkBkB,qCGKtB,yDADF,cSq+BE,sBAGF,aZr/BW,gCYu/BT,sDAEA,aZz/BS,4BASa,mDYw/B1B,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAIJ,uBACE,2BACA,gDAGF,aZ9/BwB,6BYggCtB,uDAGF,aZ/gC0B,yDYmhC1B,aACE,YAGF,aACE,cZ5gCsB,6BY8gCtB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,oBAGF,gBACE,qEAGF,4BACE,gCAGF,eACE,kBACA,MACA,QACA,YACA,kBACA,YAEA,mBACA,yBACA,eACA,aAEA,wCAEA,UZvhCsB,mBYyhCpB,aACA,sBACA,mBACA,uBACA,mBACA,8BACA,wBACA,gCACA,uCAGF,wBACE,kBACA,WACA,YACA,eACA,cZ1lCoB,yBY4lCpB,aACA,uBACA,mBACA,sCAGF,mBACE,6CAEA,8BACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,oBAGF,aZxmCwB,eY0mCtB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,6BAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,YACE,SACA,QACA,WACA,YACA,mBACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WZnyCE,gBYqyCF,eACA,+LAMA,yBACE,mEAKF,yBACE,iBAMR,aACE,iBACA,mEAGF,aZ9yCwB,qBYkzCtB,mBACA,gBACA,sBACA,gBAGF,aACE,iBACA,uBAGF,eACE,8BAGF,aZj0CwB,eYm0CtB,cACA,gBACA,gBACA,uBAGF,qBACE,sBAGF,WACE,8BAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA3BF,qBAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,iBAIJ,0DACE,CADF,kDACE,cAGF,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBZ35CW,kCY65CX,uBAGF,MACE,aACA,mBACA,uBACA,cZ55CwB,eY85CxB,gBACA,0BACA,kBACA,qCAGF,SACE,oBACA,CADA,WACA,cAGF,wBZx6C0B,WY06CxB,kBACA,MACA,OACA,aACA,qBAGF,iBACE,aAGF,iBACE,cACA,aACA,WACA,yBZz7CwB,kBY27CxB,cACA,UACA,WACA,eAGF,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBZp9CsB,kBYs9CtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cZ5+CkB,kBY8+ClB,+BAGF,aZj/CoB,eYm/ClB,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UZ3gDE,qBY6gDA,oHAEA,yBAGE,yCAKN,QACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UZviDI,oBYgjDN,kBACA,cACA,2BAGF,eACE,UAGF,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cZnjDsB,gBYqjDtB,gBAEA,aZtjDsB,0BYwjDpB,sBAEA,oBACE,gBAIJ,qBACE,4BAKN,GACE,cACA,eACA,WARI,mBAKN,GACE,cACA,eACA,2CC5lDF,u+KACE,uCAEA,u+KACE,CAOA,8MAMF,okBACE,UClBJ,YACE,gCACA,cACA,qBACA,iCAEA,aACE,cACA,cdUoB,gBcRpB,qBACA,eACA,gBAGF,WACE,UACA,yCAEA,8CAEA,WACE,iBACA,mBAKN,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,gBd0BwB,wBG9DtB,4BACA,kBWqCA,eACA,yBAEA,oBACE,sBACA,iBACA,4BX3CF,eWgDE,CACA,cACA,2DAJF,gBdesB,wBG9DtB,4BACA,CWgDE,iBAQE,CANF,+BXlDF,UWsDI,CACA,qBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WdjEE,6BcmEF,gBACA,eACA,0BAKN,iBACE,WACqB,sCXrErB,+BANA,UW+EuB,sCXzEvB,gEWuEA,gBdhBsB,wBG9DtB,4BW0FE,CXnFF,iCANA,UWoFuB,sCX9EvB,kBWgFE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,2BAGF,aACE,mBACA,sBAGF,YACE,cd5EsB,6Bc+EtB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,4BAGF,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,8BACA,eACA,oCACA,uCAEA,aACE,kCAGF,+BACE,gCAGF,aACE,yBACA,eACA,cdvJoB,kCc2JtB,aACE,eACA,gBACA,Wd9KI,CcmLA,2NADF,eACE,gCAKN,adtKwB,oBc2K1B,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,wBAGF,gBACE,qBACA,eACA,cd/LsB,eciMtB,kBACA,4BAEA,adlMwB,6BcsMxB,aACE,gBACA,uBACA,iBAIJ,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,adnOwB,ecqOtB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SX7MF,sBACA,WACA,YACA,gBACA,oBACA,mBHhDW,cAOW,eG4CtB,SACA,+EWuMI,aACE,CXxMN,qEWuMI,aACE,CXxMN,yEWuMI,aACE,CXxMN,0EWuMI,aACE,CXxMN,gEWuMI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,ad7PoB,iBc+PlB,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,adpSoB,0HcySpB,cAEE,gBACA,cd9RkB,kZciSlB,aAGE,gEAIJ,wBACE,iDAGF,ed1UI,kBGkEN,CAEA,eACA,cHrDsB,uCGuDtB,UWqQI,mBd3ToB,oDGwDxB,wBACE,cH1DoB,eG4DpB,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,cHjFS,sDcuUT,WACE,mDAGF,ad3US,kBc6UP,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UdvWQ,kBcyWN,cACA,mBACA,sBd5WM,yBc8WN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,6BAIJ,YACE,eACA,gBACA,wBAGF,WACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cdlZoB,ecoZpB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,ad1ZsB,qWc6ZpB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,sBAQR,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cdlcoB,CcqcpB,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,2BAIJ,ad5ewB,ec8etB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WdhnBA,gBcknBA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cdzmBc,gBc2mBd,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,Wd7oBE,gDcipBJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,ad5pBU,yBckqBd,cACE,gCAEA,cACE,cdvpBkB,ecypBlB,kCAEA,oBACE,cd5pBgB,qBc8pBhB,iBACA,gBACA,yCAEA,eACE,WdnrBF,SeFR,YACE,gCACA,8BAEA,aACE,cACA,WfJI,qBeMJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,mCCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,EDGF,0BCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,qCAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,EAtBA,2BAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,gCAIJ,cACE,kBAGF,iBACE,cACA,eACA,iBACA,qBACA,gBACA,iBACA,gBACA,wBAEA,SACE,4BAGF,UACE,YACA,gBACA,sBAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,qEAGF,kBACE,qBACA,sGAEA,eACE,qEAIJ,eAEE,qJAEA,kBAEE,mXAGF,eACE,mBACA,qJAGF,eACE,gBACA,2EAGF,eACE,+NAGF,eACE,2FAGF,iBACE,8BACA,chB9FkB,mBgBgGlB,qHAEA,eACE,2JAIJ,eACE,mJAGF,iBACE,6EAGF,iBACE,eACA,6EAGF,iBACE,qBACA,qJAGF,eACE,6JAEA,QACE,2EAIJ,oBACE,2EAGF,uBACE,oBAIJ,ahB9Ic,qBgBgJZ,0BAEA,yBACE,8BAEA,aACE,kCAKF,oBACE,uCAEA,yBACE,wBAKN,ahBlJoB,4CgBuJtB,YACE,8EAEA,aACE,mCAIJ,aACE,oDAEA,ahB5LQ,egB8LN,iDAIJ,kBACE,uDAEA,kBACE,qBACA,gCAKN,oBACE,kBACA,mBACA,YACA,chB3MW,gBgB6MX,eACA,cACA,yBACA,oBACA,eACA,sBACA,sCAEA,kBACE,qBACA,+DAGF,oBACE,iBACA,sBACA,kBACA,eACA,oBACA,2GAKF,oBAGE,4BAIJ,ahBvNwB,SgByNtB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,mDAGF,iBAEE,gCAGA,qEAEA,eACE,kBAKF,SACE,mBACA,kDAEA,kBACE,wDAEA,sBACE,iFAIJ,kBAEE,SAKN,iBACE,kBACA,YACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QAPF,kBAUI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,ahB1SoB,CArBX,uEgBwUP,ahBxUO,kCgB4UP,ahBvTkB,gCgB4TpB,ahBjVS,kCgBoVP,ahB3UoB,gEgB+UpB,UhBjWE,mBAgBgB,sEgBqVhB,kBACE,mBAMR,uBACE,sBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,yCAEA,aACE,kBACA,OACA,QACA,MACA,SACA,6FACA,oBACA,WACA,2DAGF,oBACE,oCAGF,WACE,gBACA,uBACA,cACA,0CAEA,UACE,kBACA,MACA,gBACA,6DACA,oBACA,4CAGF,oBACE,gDAGJ,oDACE,mEAEF,oDACE,0CAGF,eACE,6DAGF,kBACE,gCAIJ,mBACE,+CAKF,sBACE,qEAEA,aACE,wBAKN,oBACE,YACA,ChBvZsB,cgByZtB,iBACA,mBACA,CACA,sBACA,8CANA,ahBvZsB,CgB2ZtB,eAOA,8CAGF,aACE,eACA,eAGF,YACE,8BACA,eACA,oBAEA,sBACE,gBACA,2CAGF,oBACE,sBAIJ,YACE,mBACA,WACA,chB5bsB,iIgB+btB,gBAGE,kBACA,0EAGF,yBACE,yEAMA,0CACE,CADF,kCACE,2EAKF,2CACE,CADF,mCACE,wBAKN,YACE,mBACA,2BACA,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,mBACA,iBACA,chBvgBsB,CgBygBtB,iBACA,eACA,kBACA,+CAEA,ahB9gBsB,uBgBkhBtB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,chB3iBkB,4BgBijBxB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,+DAIJ,cAEE,wBAIJ,eACE,chBpmBsB,egBsmBtB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,6JAGF,oBAME,4DAKA,UhBzqBM,kBgB+qBN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,ahB3rB0B,cgB6rBxB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WhB3tBI,kCgBguBR,UACE,kBACA,iBAGF,SACE,kBACA,YACA,WACA,ChB3sBsB,8IgBstBtB,ahBttBsB,wBgB0tBtB,UACE,wCAGF,kBhB9tBsB,cArBX,8CgBuvBT,kBACE,qBACA,+DAOJ,yBACE,cAIJ,YACE,eACA,yBACA,kBACA,chBpvBsB,gBgBsvBtB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,chB7xBoB,uBgB+xBpB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UhBvzBE,yBgB8zBJ,cACE,kBACA,YACA,+DAGF,aACE,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACE,YACA,SACA,2BAIF,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,chBr1BsB,gBgBu1BtB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,ahBn2BwB,oBgBu2BxB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,kBAGF,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,chB96BoB,iBgBg7BpB,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,chB58BkB,gBgB88BlB,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,ahB/9BoB,oCgBq+BxB,cACE,cACA,SACA,uBACA,UACA,kBACA,oBACA,oFAEA,yBAEE,6BChhCJ,kBACE,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,6CAGF,kBjBvB0B,cARb,kBiBoCX,gBACA,aACA,sBACA,0BAGF,WACE,WACA,gBACA,iBACA,8DAEA,UACE,YACA,sBACA,aACA,sBACA,mBACA,uBACA,aACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAIJ,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,qCAGF,kBACE,UACE,YACA,gBACA,0BAGF,UACE,YACA,eACA,gBACA,cACA,oDAIJ,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,ajBpGsB,SiBuGpB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,ajB7IS,qwDiBiJP,aAGE,sBAMR,sBACE,yBAGF,aACE,aACA,mBACA,uBACA,wBAGF,UACE,YACA,mBACA,mBACA,aACA,eACA,8BAEA,kBACE,+BAGF,cACE,mBACA,kCAIJ,mBACE,CACA,mBACA,0EAEA,mBACE,yBAIJ,cACE,iBACA,4BAEA,cACE,gBACA,cjBvMS,mBiByMT,2BAGF,ajBnMwB,kGiBsMtB,aAGE,2CAIJ,aACE,2BAGF,cACE,cjBtMoB,gBiBwMpB,mBACA,sCAEA,eACE,kCAGF,eACE,mBjBrOO,cAQa,kBiBgOpB,eACA,gBACA,CAII,2NADF,eACE,oCAOV,WACE,UACA,mCAME,mBACA,mBACA,sCAEA,cACE,iBACA,kBACA,qCAGF,eACE,oCAIJ,kBACE,mBACA,kBACA,eAIJ,iBACE,eACA,mBACA,sBAEA,eACE,cjBzRS,kBiB2RT,yBACA,eACA,qBAGF,kBjBhSW,cAQa,gBiB2RtB,aACA,kBACA,6HAQF,eACE,qJAGF,kBACE,cjB1SsB,mBiB4StB,kBACA,aACA,kBACA,eACA,sCACA,yPAEA,iBACE,mBACA,qNAGF,mBACE,gBACA,4CAMJ,YACE,mBACA,gDAEA,UACE,cACA,4DAEA,aACE,2DAGF,cACE,kDAGF,iBACE,uDAIJ,eACE,sDAIJ,UjB3WM,2DiBgXR,0BACE,cACE,iBACA,qJAGF,cAIE,mBACA,4CAGF,kBACE,sDAGF,WACE,eACA,mBAIJ,oBACE,eACA,gBACA,iBACA,uHAGF,kBAOE,cjB7YW,kBiB+YX,gBACA,eACA,YACA,kBACA,sBACA,+SAEA,ajBjYsB,YiBmYpB,eACA,WACA,eACA,gBACA,uSAGF,YACE,uPAGF,WACE,WACA,+WAGF,aACE,wBAKF,ejBvbM,CAiBkB,gBiByatB,oBACA,iEjB3bI,2BAiBkB,qDiBkb1B,iBAEE,aACA,qEAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,kKAIJ,YAKE,8BACA,mBjBncwB,aiBqcxB,iBACA,0LAEA,aACE,iBACA,cjB7boB,mBiB+bpB,kNAGF,aACE,6DAIJ,cAEE,yDAGF,WAEE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,ajBzgBwB,qCiB6gBxB,oDAZF,eAaI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,ajBhjBS,gBATL,aiB4jBJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,ejB5kBI,yBiB8kBF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,ajBnlBO,oBiBqlBL,eACA,gBjB/lBA,+CiBomBJ,YACE,8BACA,mBACA,4CAIJ,aACE,cjBnmBS,eiBqmBT,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,ajB9mBS,eiBgnBP,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eAWE,eACA,wBAXA,eACE,iBACA,uBAGF,aACE,gBACA,2CAMF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,ajB/pBO,aiBiqBL,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBjBvqBgB,WAlBlB,iJiBgsBA,iBAGE,oMAUR,aACE,iIAIJ,4BAIE,cjBxrBsB,eiB0rBtB,gBACA,6cAEA,aAGE,6BACA,uCAIJ,iBACE,mBACA,oBACA,eAEA,yFAEA,qBACE,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,ajBzwBS,CiB2wBP,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gDACA,mBjBpzBO,WATL,eiBg0BF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,cAKN,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBjB12BM,yDiB62BN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBjBr3BI,uBiBy3BN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UjBt5BI,eiBw5BF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,wBAKN,gBACE,sCAEA,eACE,gCAGF,eACE,qDAGF,ajBl7BW,iDiBs7BX,YACE,0DAEA,YACE,0BAIJ,YACE,iBACA,uBACA,kDAGF,ajB/6BsB,qBiBi7BpB,wDAEA,yBACE,WCp9BN,YACE,oBAGF,cACE,uBACA,eACA,gBACA,clBwBsB,4CkBrBtB,alBNY,sCkBWd,2CACE,oBAGF,QACE,wBACA,UACA,+CAEA,WACE,mBACA,UACA,0BAGF,aACE,sBACA,SACA,YACA,kBACA,aACA,WACA,UACA,clB5BS,gBATL,ekBwCJ,oBACA,gBACA,qDAEA,alBdoB,CkBYpB,2CAEA,alBdoB,CkBYpB,+CAEA,alBdoB,CkBYpB,gDAEA,alBdoB,CkBYpB,sCAEA,alBdoB,gCkBkBpB,8CfpCA,uCADF,cesC4D,0CfjC5D,ceiC4D,oBAI9D,alB5Ca,mBkB8CX,mBlBvCsB,oCkByCtB,iBACA,kBACA,eACA,gBACA,sBAEA,alBjCsB,gBkBmCpB,0BACA,mFAEA,oBAEU,iCAKZ,mBACA,eAEA,gBACA,wCAEA,alB/DwB,sDkBmExB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,gBACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBlBrFsB,qCkB4F1B,eACE,kBACA,aACA,mBlBjGsB,gBkBmGtB,gBACA,cACA,yBAEA,iBACE,gBACA,wCAEA,alBlHS,iCkBoHT,WACE,iBACA,2BAIJ,iBACE,cACA,CACA,cACA,iBACA,clB/HS,qBkBiIT,gBACA,iBACA,qBACA,mBACA,gBACA,gGAEA,kBACE,qBACA,iIAEA,eACE,kJAIJ,eACE,mBACA,2DAGF,eACE,eACA,8BAGF,cACE,wFAGF,eACE,sCAGF,iBACE,8BACA,clBrKO,mBkBuKP,mDAEA,eACE,8DAIJ,eACE,0DAGF,iBACE,+BAGF,iBACE,eACA,2DAGF,eACE,+DAEA,QACE,8BAIJ,oBACE,8BAGF,uBACE,6BAGF,alBtLoB,qBkBwLlB,mCAEA,oEAGE,oBACE,gDAEA,qDAMR,UACE,YACA,gBACA,wBAIJ,iBACE,UACA,QACA,gHAEA,+BAEE,uDAIJ,iBAEE,WACA,mIAGE,aACE,sBACA,SACA,YACA,0BACA,yBACA,WACA,iBACA,UACA,clB5PO,gBATL,ekBwQF,oBACA,YACA,qBACA,yLAEA,alB/OkB,CkB6OlB,sKAEA,alB/OkB,CkB6OlB,8KAEA,alB/OkB,CkB6OlB,gLAEA,alB/OkB,CkB6OlB,4JAEA,alB/OkB,yKkBmPlB,SACE,qJAGF,kBlBpQoB,+IkBqQpB,8Cf1QF,8JADF,ce4Q8D,kKfvQ9D,ceuQ8D,qCfhQ5D,8TADF,sBeoQM,gBACA,6BAMR,aACE,kBACA,SACA,UACA,WACA,gBACA,2CAEA,aACE,mBACA,WACA,YACA,clB/QoB,ekBiRpB,iBACA,kBACA,WACA,4CAIJ,iBACE,SACA,oCAGF,aACE,kBACA,sBACA,SACA,0BACA,YACA,WACA,clBzTW,mBAQa,sCkBoTxB,eACA,WACA,aACA,6CAGF,aACE,0CAGF,YACE,eACA,kBACA,iMAEA,kBAGa,iKAEb,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,+DAGF,6BACE,qEAEA,aACE,gBACA,uBACA,mBACA,sEAGF,eACE,qEAGF,aACE,iBACA,gBACA,uBACA,mBACA,4EAMA,alB/VkB,wBkBoWxB,eACE,iCAEA,YACE,mBACA,eACA,oBACA,YACA,gBACA,8BAIJ,UACE,WACA,cACA,kCAEA,iBACE,kBACA,aACA,WACA,sBlBzZI,wBkB2ZJ,sBACA,4BACA,gBACA,2CAEA,aACE,kBACA,sBACA,SACA,OACA,SACA,SACA,aACA,WACA,clBvZoB,gFkByZpB,eACA,oBACA,gBACA,UACA,UACA,4BACA,iDAEA,UlBlbE,sEkBobF,WACE,clBpakB,CAjBlB,4DkBobF,WACE,clBpakB,CAjBlB,gEkBobF,WACE,clBpakB,CAjBlB,iEkBobF,WACE,clBpakB,CAjBlB,uDkBobF,WACE,clBpakB,yCkByatB,2EAKE,0CAKN,iFACE,aACA,uBACA,8BACA,UACA,4BACA,8CAEA,aACE,clB5bsB,ekB8btB,gBACA,aACA,oBACA,2JAEA,aAGE,wCAIJ,SACE,kCAIJ,YACE,aACA,clBldsB,gBkBodtB,sCAEA,cACE,kBACA,2CAGF,aACE,gDAEA,aACE,eACA,gBACA,yBACA,qDAGF,iBACE,eACA,kBACA,WACA,WACA,mBlB5dkB,8DkB+dlB,iBACE,MACA,OACA,WACA,kBACA,mBlBhfkB,0BkBuf1B,alBhgBa,oBkBkgBX,eACA,gBlB5gBM,4BkBghBR,YACE,mBACA,0BACA,YACA,aACA,8BACA,cACA,oBAGF,YACE,cACA,sBAEA,oBACE,uBACA,cACA,YACA,iBACA,sBACA,uBAGF,oBACE,aACA,CAEA,oBACA,CADA,6BACA,UACA,QACA,YACA,uBACA,2BAIJ,iBACE,iBACA,0CAKE,yBACE,qCACA,WlB7jBE,mBAkBkB,gBkB8iBpB,8CAGA,yBACE,oCACA,uCAMR,iBACE,kBACA,uCACA,gBlB9kBM,gBkBglBN,uBACA,6CAGF,YACE,mBACA,aACA,clB9kBW,ekBglBX,sDAEA,aACE,clB9jBoB,wEkBikBpB,6EAEA,aACE,clBzlBO,gBkB2lBP,sGAIJ,kBlBtlBwB,WAlBlB,6PkBgnBF,UlBhnBE,0DkBonBN,wCAGF,gBACE,iBACA,mBACA,gBACA,yBACA,cACA,+BAEA,oBACE,SACA,eACA,kBACA,gCAGF,oBACE,aACA,UACA,WACA,kBACA,kCAIA,alB5oBU,CmBFZ,+BAHF,YACE,cACA,kBAUA,CATA,cAKA,kBACA,2BACA,gBAEA,uBAEA,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,wHAMJ,WAGE,mCAIJ,YACE,mBACA,uBACA,YACA,CnB7EW,ImB4Fb,aACE,aACA,sBACA,WACA,YACA,CAIA,oBAGF,qBACE,WACA,mBACA,cnBlGwB,emBoGxB,cACA,eACA,SACA,iBACA,aACA,SACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cnBrHwB,emBuHxB,cACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,cAGF,kBACE,WnB5KM,cmB8KN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cnB7KsB,kGmBgLtB,sBAGE,WnBpME,kCmBwMJ,anBtLsB,oBmB4L1B,oBACE,iBACA,oBAGF,kBnB1Ma,cAqBW,iBmBwLtB,eACA,gBACA,yBACA,eACA,yBAGF,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,oFACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,uBAEA,QACE,YACA,aACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,anB1PwB,uBmB8PxB,uCACE,4CAEA,anBjQsB,0CmBmQpB,4CAIJ,SAEE,SAIJ,WACE,kBACA,sBACA,aACA,sBACA,gBACA,wDAEA,SACE,gBACA,gBACA,qBAGF,kBnBpSW,yBmBySb,WACE,aACA,cACA,uBAGF,kBACE,iCAGF,iBACE,sEAGF,kBACE,SACA,cnBlTsB,emBoTtB,eACA,eACA,kFAEA,aACE,CAKA,kLAEA,UnBhVI,mBmBkVF,kFAKJ,2BACE,wCAIJ,YACE,oBACA,6BACA,+CAEA,sBAEE,kBACA,eACA,qBACA,0CAGF,eACE,gDAKJ,SACE,6BAGF,eACE,gBACA,gBACA,cnBtWsB,0DmBwWtB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,iCAIF,eACE,2CACA,YACE,WACA,mCAKN,kBACE,aACA,mCAIA,anB5YsB,0BmB8YpB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,uBAKN,oBACE,uBACA,gBACA,mBACA,OACA,sBAGF,oBACE,iBACA,uCAGF,anB7ZwB,mBArBX,kBmBsbX,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBACA,sCAbF,cAcI,kDAGF,eACE,2CAGF,anB9bwB,qBmBgctB,uDAEA,yBACE,eAKN,qBACE,uCAKA,sBACE,6BACA,qCASF,qCAXA,sBACE,6BACA,sCAgBF,mJAFF,qBAGI,sBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,kBACA,uCAEA,SACE,kCAKN,aACE,aACA,yBChhBJ,iBACE,eACA,gBACA,cpB6BsB,mBArBX,eoBLX,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cpBDkB,qCoBKpB,cACE,gBACA,kBCtCJ,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WrB1EF,gBqB4EE,gBACA,uBACA,0CAGF,aACE,eACA,crBnEc,gBqBqEd,gBACA,uBACA,yBAKN,kBrBnFS,aqBqFP,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,cAOV,kBrBzHa,sBqB4HX,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,CC/KJ,eAGF,SnBkDE,sBACA,WACA,YACA,gBACA,oBACA,mBHhDW,cAOW,eG4CtB,SACA,cmBxDA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,4CACA,eACA,WACA,YACA,ctBtCsB,esBwCtB,oBACA,0BAEA,mBACE,WACA,0BAIJ,sBACE,iCAEA,mBACE,WACA,gCAIJ,QACE,uBACA,ctB/CoB,esBiDpB,uCAEA,uBACE,sCAGF,aACE,yBAKN,atB7DwB,mBsB+DtB,gCACA,kBACA,eACA,gBACA,uBAGF,YACE,ctBrFsB,kBsBuFtB,iBAIA,atB7EsB,mBsB+EpB,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,ctBvGkB,gBsByGlB,uBACA,mBACA,4BAEA,eACE,uBAGF,atB/HkB,qBsBiIhB,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,ctBzJoB,0BsB6JtB,aACE,WACA,2CAEA,oCACE,yBACA,0CAGF,wBACE,WC1LR,yCCCE,qBACA,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,8BAIJ,exBXQ,kBwBaN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBxBvCM,kBwByCN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,eAIJ,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,0EAMA,SACE,oBACA,CADA,WACA,eCpGN,WAEE,0BAGF,kBANW,kBAQT,cACA,iCACA,wBACE,mCAOF,WACE,SACA,UACA,2CAGF,aACE,aAEA,sBACA,YACA,6BACA,6DAGE,oBACE,WACA,iBACA,iBACA,iJAGF,UACE,gEAEF,oBACE,gBACA,WACA,2CAKN,yBACE,sBACA,kBACA,YACA,gBACA,kDAEA,uBACE,CADF,oBACE,CADF,eACE,WACA,YACA,SACA,4BACA,WACA,yBACA,eACA,4CACA,sBACA,oBACA,6DAEA,uBACE,6DAGF,sBACE,wEAGF,sBACE,kBACA,SCjFR,WACE,sBACA,aACA,sBACA,kBACA,iBACA,UACA,qBAEA,iBACE,oBAGF,kBACE,2DvBDF,SuBI0D,yBvBC1D,SuBD0D,qCvBQxD,qLuBLA,yBAGF,eACE,gBACA,eACA,qCvBZA,4BuBgBA,SACE,WACA,YACA,eACA,UACA,+BALF,SACE,WACA,YACA,eACA,UACA,yCAIJ,4BAGF,YACE,mBACA,mBACA,UACA,mBACA,eACA,mBAEA,aACE,sBACA,oCACA,sBACA,YACA,cACA,c1BtCoB,kB0BwCpB,qBACA,eACA,mBAGF,iCACE,iDAEA,YAEE,mBACA,mCACA,SAKN,iBACE,mBACA,UACA,qCvBrDE,6CADF,euBwDkF,sCvBlEhF,sBADF,cuBoE0D,yBvB/D1D,cuB+D0D,gBAG5D,e1BlFQ,kBGkEN,CACA,sBACA,gBACA,cHrDsB,uCGuDtB,mBAEA,wBACE,cH1DoB,eG4DpB,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,cHjFS,kB0B6Eb,YACE,c1BvEsB,a0ByEtB,mBACA,oBAEA,aACE,qBACA,wBAGF,aACE,c1BjFsB,gB0BmFtB,mBACA,gBACA,uBACA,0BAIJ,aACE,gBACA,gBACA,kBAGF,kB1BxGa,kB0B0GX,gBACA,yBAEA,a1BxFsB,mB0B0FpB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,c1BhHkB,iC0BmHlB,oBACE,iBACA,8FAIJ,eAEE,mCAGF,aACE,aACA,c1B7IoB,qB0B+IpB,0HAEA,aAGE,0BACA,gBAQN,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAgBA,CAfA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,uBAEA,kB1B1LW,0B0B+Lb,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oCAGF,aACE,WACA,YACA,YACA,eACA,sCAGF,yBAzBF,aA0BI,iBAIJ,kBACE,eACA,gBACA,mBAGF,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,oBCrPF,kBACE,gB3BAM,WACA,e2BEN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,e3BdQ,cAiBgB,oB2BCtB,YACA,iEAEA,aAGE,iCAGF,eACE,2BxBcF,iBACE,mBACA,cACA,eACA,aACA,gBACA,yBwBfJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,6BAGF,aACE,kBACA,W3B7CM,0B2B+CN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,WACE,aACA,sBACA,4BAEA,iBACE,c3BjEoB,a2BmEpB,YACA,mBACA,CAGE,yDAIJ,UACE,gBAIJ,qBACE,eACA,gBACA,kBACA,kBACA,WACA,aACA,2BxB/DA,iBACE,mBACA,cACA,eACA,aACA,gBACA,sBwB8DJ,WACE,sBACA,cACA,WACA,kBACA,kBACA,gBACA,kCAEA,eACE,qEAIA,cACE,MACA,gCAIJ,e3BlIM,gC2BuIR,cACE,cACA,qBACA,c3BxHwB,kB2B0HxB,UACA,mEAEA,WAEE,WACA,sBACA,CADA,gCACA,CADA,kBACA,CAIE,0HAFF,WACE,oBACA,CADA,8BACA,CADA,gB3BtJE,C2BuJF,wBAKN,UACE,CAEA,iBACA,MACA,OACA,UACA,gB3BnKM,iC2BsKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,aACA,uBACA,qCAGF,cACE,YACA,WACA,kBACA,UACA,sBACA,CADA,gCACA,CADA,kBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,qDAEA,WACE,oBACA,CADA,8BACA,CADA,gBACA,sCAIJ,0BACE,2BACA,gBACA,kBACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,c3B3K0B,eAEC,C2BqL7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,W3BvQM,e2ByQN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,c3BpSsB,mF2BuStB,yBAGE,wBAKN,oBACE,sBAGF,qB3BpUQ,Y2BsUN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wB3B7T0B,qB2BiU1B,iBACE,UACA,QACA,YACA,qKAKA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,qCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gB3BzZM,e2B2ZN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,gCAGF,UACE,YACA,0BxBjYF,iBACE,mBACA,cACA,eACA,aACA,gBACA,qBwBgYF,eACE,gBACA,UACA,kBACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gB3B9eI,cAiBgB,gB2BgepB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,CAGE,gUAEA,aAIE,wBAKN,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,U3BpjBE,+E2B4jBN,cAGE,gBACA,6BAGF,U3BnkBM,iB2BqkBJ,yBAGF,oBACE,aACA,mDAGF,U3B7kBM,uB2BklBN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,W3BloBE,sF2BqoBF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,iBC5sBR,YACE,mBACA,mBACA,kBACA,QACA,SACA,YACA,mBAGF,YACE,kBACA,gBACA,yBACA,0BACA,eACA,iBACA,yBACA,WACA,4BACA,wCAEA,uBCtBF,kB7BWa,sB6BTX,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kB7B3Ca,sB6B6CX,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,aC3FJ,cAOE,qBACA,c9BGW,2B8BVX,qBAEE,iBACA,+BAOF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mB9BHsB,4B8BOxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,c9BLsB,c8BOtB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,a9BpC0B,mC8BuCxB,aACE,oDAGF,QACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBACA,uBAIA,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gB9B5FM,sB8B8FN,sGAEA,+BAEE,oBAKF,2BACA,gB9BxGM,0B8B2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,c9BzGS,yB8B2GT,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gB9BpKI,mB8ByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,c9BvKsB,mD8B0KtB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,wBC1MF,iBACE,aACA,mBACA,mB/BgBwB,cARb,kB+BLX,YACA,WACA,gBACA,iBACA,gBACA,4DAEA,aACE,eACA,mFAGF,iBACE,kBACA,gBACA,+FAEA,iBACE,OACA,MACA,kCAIJ,aACE,cACA,2BAGF,cACE,gBACA,iBACA,mBACA,2BAGF,cACE,gBACA,iBACA,gBACA,mBACA,0CAIJ,aACE,kBACA,cACA,mBACA,gCACA,eACA,qBACA,aACA,0BACA,4DAEA,aACE,iBACA,gDAGF,kB/BhDwB,iD+BoDxB,kB/BnDwB,WAlBlB,qG+B0EN,kB/BxEU,WAFJ,oC+BgFR,kBACE,YACA,eACA,iBACA,gBACA,8BAGF,aACE,UACA,kBACA,YACA,gBACA,oCAGF,iBACE,4FAGF,eAEE,mBACA,qCAGF,mCACE,UACE,cACA,0CAGF,YACE,4DAEA,YACE,kBCtHN,UhCEQ,gCgCCN,oBAEA,cACE,iBACA,gBACA,kBACA,mBAGF,UhCVM,0BgCYJ,oBAGF,eACE,cACA,iBACA,mDAGF,UACE,YACA,gBACA,gCACA,gBC3BJ,WACE,gBACA,aACA,sBACA,yBACA,kBACA,+BAEA,gBACE,eACA,CACA,2BACA,kCAGF,QACE,iCAGF,aACE,6BAGF,sBACE,0BAGF,MACE,kBACA,aACA,sBACA,iBACA,mDAGF,eACE,sBjClCI,0BiCoCJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,YACE,gBACA,gLAEA,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,CAIA,yFAGF,eACE,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WjCjNM,kBiCmNN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,mCAGF,kBAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,QC1QJ,eACE,eACA,8BAEA,QAEE,gBACA,UAGF,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBlCJsB,akCSxB,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAIA,qBACA,WACA,eACA,clCvCO,ckCyCP,UACA,oBACA,gBlCpDE,yBkCsDF,kBACA,iBACA,oCAEA,oBlCxCoB,wBkC6CtB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBlC7FY,8EkCkGZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,clC1GoB,akC8GtB,cACE,uBACA,UACA,SACA,SACA,clCnHoB,0BkCqHpB,kBACA,mBAEA,oBACE,sCAGF,mCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,UAEE,aACA,+GAEA,oBlCxKoB,sDkC8KxB,cACE,gBACA,iBACA,YACA,oBACA,clCvKoB,sCkC0KpB,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,clChNS,qBkCkNT,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,alC7MwB,qBkCgNtB,+BACE,6BAEA,+BACE,YC/ON,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,sBACE,eACA,gBACA,gBACA,qBACA,cnCJsB,oBmCOtB,anCLwB,0BmCOtB,6EAEA,oBAGE,wCAIJ,anClBsB,oBmCuBtB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cnChCsB,qBmCoCxB,iBACE,cnCrCsB,uBmCyCxB,eACE,mBACA,kBACA,kBACA,yHAGF,sBAME,mBACA,oBACA,gBACA,cnCzDsB,qBmC6DxB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cnCrIkB,iCmCyIpB,uBACE,gBACA,gBACA,cnC9HkB,qDmCkIpB,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,uBACA,eACA,iBACA,WnCpNI,iBmCsNJ,kBACA,qEAEA,aAEE,6CAIA,anC9MoB,oCmCmNtB,sBACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,sBACE,eACA,iBACA,gBACA,cnC/OkB,mBmCiPlB,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAIE,UACqB,sChCnRzB,CgCoRI,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iBnCpOgB,wBG9DtB,4BACA,iCgCsSE,cACE,mCAEA,aACE,WnC3SA,qBmC6SA,uDAGE,yBACE,2CAKN,aACE,cnCvSgB,kCmC+StB,sBAEE,CACA,eACA,eACA,iBACA,mBACA,cnCtToB,sCmCyTpB,anCvTsB,0BmCyTpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,sBACE,eACA,iBACA,gBACA,mBACA,cnC/UsB,wBmCkVtB,sBACE,cACA,eACA,gBACA,cACA,kBAKF,cACA,iBnC7VsB,mCmC2VxB,sBACE,CAEA,eACA,mBACA,cnChWsB,kBmCqWtB,cACA,iBnCtWsB,kBmC8WtB,cnC9WsB,mCmC6WxB,sBACE,CACA,gBACA,gBACA,mBACA,cnClXsB,kBmCuXtB,cnCvXsB,kBmC+XxB,sBACE,eACA,iBACA,gBACA,mBACA,cnCpYsB,mCmCwYxB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,2CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBnC5bW,kBmC8bT,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAMA,UACqB,sChC9hB3B,mDgCiiBI,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA9DF,iBA+DI,mFAIJ,qBAGE,mBnCtjBS,kBmCwjBT,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,anC9kBsB,qCmCklBtB,eACE,WnCpmBE,gBmCsmBF,2CAEA,anCxlBkB,gDmC2lBhB,anC1lBkB,+CmCgmBtB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SnC1rBI,YmC4rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,cnCpsBkB,6BmCwsBpB,eACE,iBACA,+BAGF,kBnCptBS,amCstBP,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,sBACE,eACA,gBACA,cACA,qCAGF,cACE,cnCnvBgB,uFmCyvBtB,eACE,cASA,CnCnwBoB,2CmCgwBpB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cnCh2BsB,qBmCk2BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cnC11BoB,SoChCxB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBpChBW,UoCqBX,apCZwB,0BoCctB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBpCzDS,6BoC2DP,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+BACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,cpC5FsB,gBoC8FtB,0DAEA,UpChHM,wDoCoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBpCvJS,sBoCyJT,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBpCtKS,gCoCyKT,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBpC9LS,uCoCiMP,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,cpC/NgB,gBoCiOhB,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,sCAEA,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBCzRN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBrCPO,YqCSP,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SrCxCA,YqC0CE,kBACA,YACA,uCAIJ,aACE,crCjCgB,qBqCmChB,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,crC3EgB,qBqC6EhB,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UrCzGA,yBqC2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UrCjIE,yBAkBkB,gBqCkHlB,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,arCnMsB,eqCqMpB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,arC9MsB,eqCgNpB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,crC1NkB,mBqC4NlB,kBACA,gCACA,4BAGF,cACE,crCjOoB,iBqCmOpB,gBACA,0CAGF,UrCxPI,gBqC0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WrCxQE,oBqC0QF,iBACA,gBACA,mBACA,2BAGF,cACE,iBACA,crCjQoB,mBqCmQpB,kCAEA,UrCtRE,gBqCwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,2CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BrCjUoB,YqCwU1B,UACE,SACA,cACA,WACA,sDAKA,arCnVsB,0DqCsVpB,arCpVsB,4DqCyVxB,arC1Wc,gBqC4WZ,4DAGF,arC9WU,gBqCgXR,0DAGF,arCvVsB,gBqCyVpB,0DAGF,arCtXU,gBqCwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,2BAKN,oBACE,crCnZkB,qBqCqZlB,yBACA,eACA,gBACA,gCACA,iCAEA,UrC3aE,gCqC6aA,oCAGF,arC9ZoB,gCqCgalB,iBAMR,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,crCxcsB,CqC6clB,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,arCthBwB,qBqCwhBtB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBrCrjBS,cAOW,0BqCijBpB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,arC5jBsB,oBqCgkBtB,kBACE,0BACA,aACA,crCjlBoB,gCqCmlBpB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,crC7kBoB,2BqCilBtB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBrCtnBY,oCqC0nBZ,kBACE,mCAGF,kBrC7mBsB,sDqCknBxB,arCnnBwB,qBqCunBtB,gBACA,sBAGF,aACE,0BAGF,arC/nBwB,sBqCmoBxB,arCnpBc,yDqCwpBhB,oBAIE,crC5oBwB,iGqC+oBxB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBrCxsBc,yBqC4sBd,yBACE,wBAGF,yBrC7sBU,wBqCktBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,arC9sBoB,uBqCotBpB,wBACA,qBAGF,arC1sBsB,cqC+sBxB,kBrCpuBa,kBqCsuBX,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,crCnvBkB,yBqCqvBlB,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,arCjxBM,6BqCwxBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,crCxxBgB,mLqC2xBhB,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,arCzxBgB,iBqC2xBd,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,crCnzBgB,WqC0zBxB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,arCv3BY,8CqC43Bd,qBACE,aACA,WrC/3BI,cqCo4BR,iBACE,kkECr4BF,kIACE,CADF,sIACE,uIAYA,aAEE,yIAGF,aAEE,qIAGF,aAEE,6IAGF,aAEE,UChCJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,+BAGF,eACE,2EAGF,UAEE,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,+BAGF,UACE,0BAGF,gBACE,eACA,UAGA,WACA,yCAGF,iBACE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,gBACA,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,+DACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,sBChbJ,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WxCrCI,uBwCuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,cxCjCoB,kBwCmCpB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,axCrDwB,gBwCuDtB,qBACA,0D","file":"flavours/glitch/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#192432 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#192432;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#1c2938}::-webkit-scrollbar-thumb:active{background:#192432}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#121a24}::-webkit-scrollbar-track:active{background:#121a24}::-webkit-scrollbar-corner{background:transparent}body{font-family:sans-serif,sans-serif;background:#06090c;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",sans-serif,sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#121a24}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.embed{background:#192432;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#0b1016;padding:0}body.error{position:absolute;text-align:center;color:#9baec8;background:#121a24;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:sans-serif,sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;width:40px;height:40px;background-size:40px 40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}.account-header .name{flex:1 1 auto;color:#d9e1e8;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#26374d}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#9baec8;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#2d415a;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#344b68}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#d9e1e8}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#000}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#192432;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;width:120px;height:120px;background-size:120px 120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #192432;background:#040609;border-radius:8%;background-position:50%;background-clip:padding-box}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#192432;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#9baec8}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#9baec8;padding:10px;border-right:1px solid #192432;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #d8a070;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#d9e1e8}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:sans-serif,sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #26374d}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#9baec8}.public-layout .public-account-header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#e1b590}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#9baec8}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #202e3f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #202e3f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#121a24}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#192432}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.modal-layout{background:#121a24 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#4c6d98}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#4c6d98}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#9baec8}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#4c6d98}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#4c6d98}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#5377a5}.compact-header h1{font-size:24px;line-height:28px;color:#9baec8;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#d9e1e8}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#121a24;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.hero-widget__text a{color:#d9e1e8;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#9baec8}.box-widget{padding:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #3e5a7c;text-align:center;color:#9baec8;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#9baec8;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#d9e1e8;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#9baec8}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#9baec8;margin-bottom:10px}.page-header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#9baec8}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#192432}.page-header h1{font-size:24px}}.directory{background:#121a24;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#121a24;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#202e3f}.directory__tag.active>a{background:#d8a070;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#9baec8}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#9baec8}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#d8a070}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#040609;border:2px solid #121a24}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#9baec8;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #202e3f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#9baec8;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #2d415a}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#9baec8}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#d8a070}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#0b1016;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #192432}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #192432}code{font-family:monospace,monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#d8a070;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#9baec8}.simple_form .hint a{color:#d8a070}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#000}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#9baec8}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #3e5a7c;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102;border:1px solid #000;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#a8b9cf}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#d8a070;background:#040609}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#d8a070;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#ddad84}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#d3935c}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #000;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#3e5a7c;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(1, 1, 2, 0), #010102)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(18,26,36,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#202e3f;color:#9baec8;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#9baec8;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:monospace,monospace;background:#121a24;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#192432}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#9baec8;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#d8a070;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#e1b590}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#9baec8}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#d9e1e8;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#d9e1e8;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#9baec8}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:monospace,monospace}.input-copy{background:#010102;border:1px solid #000;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:monospace,monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#0b1016;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#0b1016;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#202e3f}.card__img{height:130px;position:relative;background:#000;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#192432;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box;background:#040609;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#121a24;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#d9e1e8}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#233346}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#9baec8;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #26374d;border-bottom:1px solid #26374d;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #26374d}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#d9e1e8;background:rgba(4,6,9,.5)}.account__header__fields dd{flex:1 1 auto;color:#9baec8}.account__header__fields a{color:#d8a070;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#9baec8}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#121a24}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#202e3f}.button.logo-button{flex:0 auto;font-size:14px;background:#d8a070;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#e3bb98}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{padding:8px 0;padding-bottom:2px;margin:initial;margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{position:absolute;margin:initial;float:initial;width:auto;left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}.embed .status .status__info,.public-layout .status .status__info{font-size:15px;display:initial}.embed .status .status__relative-time,.public-layout .status .status__relative-time{color:#3e5a7c;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.embed .status .status__info .status__display-name,.public-layout .status .status__info .status__display-name{display:block;max-width:100%;padding:6px 0;padding-right:25px;margin:initial}.embed .status .status__info .status__display-name .display-name strong,.public-layout .status .status__info .status__display-name .display-name strong{display:inline}.embed .status .status__avatar,.public-layout .status .status__avatar{height:48px;position:absolute;width:48px;margin:initial}.rtl .embed .status,.rtl .public-layout .status{padding-left:10px;padding-right:68px}.rtl .embed .status .status__info .status__display-name,.rtl .public-layout .status .status__info .status__display-name{padding-left:25px;padding-right:0}.rtl .embed .status .status__relative-time,.rtl .public-layout .status .status__relative-time{float:left}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#d8a070;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#d59864;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;transition-property:background-color;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#e0b38c;transition:all 200ms ease-out;transition-property:background-color}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled{background-color:#9baec8;cursor:default}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#121a24;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#3e5a7c}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#45648a}.button.button-secondary{font-size:16px;line-height:36px;height:auto;color:#9baec8;text-transform:none;background:transparent;padding:3px 15px;border-radius:4px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#a8b9cf}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.icon-button{display:inline-block;padding:0;color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#4a6b94;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(62,90,124,.3)}.icon-button.disabled{color:#283a50;background-color:transparent;cursor:default}.icon-button.active{color:#d8a070}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#3e5a7c}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#324965;background-color:rgba(62,90,124,.15)}.icon-button.inverted:focus{background-color:rgba(62,90,124,.3)}.icon-button.inverted.disabled{color:#4a6b94;background-color:transparent}.icon-button.inverted.active{color:#d8a070}.icon-button.inverted.active.disabled{color:#e6c3a4}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#324965;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(62,90,124,.3)}.text-icon-button.disabled{color:#6b8cb5;background-color:transparent;cursor:default}.text-icon-button.active{color:#d8a070}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute;transform-origin:50% 0}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.notification__favourite-icon-wrapper{left:0;position:absolute}.notification__favourite-icon-wrapper .fa.star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name a{color:inherit;text-decoration:inherit}.display-name strong{height:18px;font-size:16px;font-weight:500;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name span{display:block;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name>a:hover strong{text-decoration:underline}.display-name.inline{padding:0;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.display-name.inline strong{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name.inline span{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.dropdown-menu ul{list-style:none}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#d8a070;color:#d9e1e8;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#d8a070;color:#d9e1e8}.dropdown__icon{vertical-align:middle}.static-content{padding:10px;padding-top:20px;color:#3e5a7c}.static-content h1{font-size:16px;font-weight:500;margin-bottom:40px;text-align:center}.static-content p{font-size:13px;margin-bottom:20px}.column,.drawer{flex:1 1 100%;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#202e3f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #202e3f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.auto-columns .tabs-bar__link:hover,.auto-columns .tabs-bar__link:focus,.auto-columns .tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}}.multi-columns .tabs-bar__link:hover,.multi-columns .tabs-bar__link:focus,.multi-columns .tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}.tabs-bar__link.active{border-bottom:2px solid #d8a070;color:#d8a070}.tabs-bar__link span{margin-left:5px;display:none}.tabs-bar__link span.icon{margin-left:0;display:inline}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#d8a070;border:2px solid #202e3f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#040609}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#121a24;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#010102}.react-toggle--checked .react-toggle-track{background-color:#d8a070}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#e3bb98}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #121a24;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#d8a070}.getting-started__wrapper,.getting_started,.flex-spacer{background:#121a24}.getting-started__wrapper{position:relative;overflow-y:auto}.flex-spacer{flex:1 1 auto}.getting-started{background:#121a24;flex:1 0 auto}.getting-started p{color:#d9e1e8}.getting-started a{color:#3e5a7c}.getting-started__panel{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex:0 1 auto}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{color:#3e5a7c;font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#3e5a7c;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#9baec8}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#9baec8;padding:10px;font-weight:500;border-bottom:1px solid #202e3f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#9baec8}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#121a24;padding:4px 8px;margin:-6px 10px}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#202e3f;border:1px solid #0b1016}.setting-text{color:#9baec8;background:transparent;border:none;border-bottom:2px solid #9baec8;box-sizing:border-box;display:block;font-family:inherit;margin-bottom:10px;padding:7px 0;width:100%}.setting-text:focus,.setting-text:active{color:#fff;border-bottom-color:#d8a070}@media screen and (max-width: 600px){.auto-columns .setting-text,.single-column .setting-text{font-size:16px}}.setting-text.light{color:#121a24;border-bottom:2px solid #405c80}.setting-text.light:focus,.setting-text.light:active{color:#121a24;border-bottom-color:#d8a070}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#3e5a7c;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#d8a070}.reduce-motion button.icon-button.disabled i.fa-retweet{color:#283a50}.load-more{display:block;color:#3e5a7c;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#151f2b}.load-gap{border-bottom:1px solid #202e3f}.missing-indicator{padding-top:68px}.scrollable>div>:first-child .notification__dismiss-overlay>.wrappy{border-top:1px solid #121a24}.notification__dismiss-overlay{overflow:hidden;position:absolute;top:0;right:0;bottom:-1px;padding-left:15px;z-index:999;align-items:center;justify-content:flex-end;cursor:pointer;display:flex}.notification__dismiss-overlay .wrappy{width:4rem;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#202e3f;border-left:1px solid #344b68;box-shadow:0 0 5px #000;border-bottom:1px solid #121a24}.notification__dismiss-overlay .ckbox{border:2px solid #9baec8;border-radius:2px;width:30px;height:30px;font-size:20px;color:#9baec8;text-shadow:0 0 5px #000;display:flex;justify-content:center;align-items:center}.notification__dismiss-overlay:focus{outline:0 !important}.notification__dismiss-overlay:focus .ckbox{box-shadow:0 0 1px 1px #d8a070}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.loading-indicator{color:#3e5a7c;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #3e5a7c;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#3e5a7c}29%{background-color:#3e5a7c}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:flex;left:4px;top:4px;width:auto;height:auto;align-items:center}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.setting-toggle{display:block;line-height:24px}.setting-toggle__label,.setting-radio__label,.setting-meta__label{color:#9baec8;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.setting-radio{display:block;line-height:18px}.setting-radio__label{margin-bottom:0}.column-settings__row legend{color:#9baec8;cursor:default;display:block;font-weight:500;margin-top:10px}.setting-radio__input{vertical-align:middle}.setting-meta__label{float:right}@keyframes heartbeat{from{transform:scale(1);transform-origin:center center;animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.pulse-loading{animation:heartbeat 1.5s ease-in-out infinite both}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#121a24;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#d9e1e8;font-size:18px;font-weight:500;border:2px dashed #3e5a7c;border-radius:4px}.dropdown--active .emoji-button img{opacity:1;filter:none}.loading-bar{background-color:#d8a070;height:3px;position:absolute;top:0;left:0;z-index:9999}.icon-badge-wrapper{position:relative}.icon-badge{position:absolute;display:block;right:-0.25em;top:-0.25em;background-color:#d8a070;border-radius:50%;font-size:75%;width:1em;height:1em}.conversation{display:flex;border-bottom:1px solid #202e3f;padding:5px;padding-bottom:0}.conversation:focus{background:#151f2b;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#d8a070;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#9baec8;padding-left:15px}.conversation__content__names{color:#9baec8;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content .status__content{margin:0}.conversation--unread{background:#151f2b}.conversation--unread:focus{background:#192432}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.ui .flash-message{margin-top:10px;margin-left:auto;margin-right:auto;margin-bottom:0;min-width:75%}::-webkit-scrollbar-thumb{border-radius:0}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#d9e1e8;max-width:400px}noscript div a{color:#d8a070;text-decoration:underline}noscript div a:hover{text-decoration:none}noscript div a{word-break:break-word}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet,button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.status-direct button.icon-button.disabled i.fa-retweet,.status-direct button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.account{padding:10px;border-bottom:1px solid #202e3f;color:inherit;text-decoration:none}.account .account__display-name{flex:1 1 auto;display:block;color:#9baec8;overflow:hidden;text-decoration:none;font-size:14px}.account.small{border:none;padding:0}.account.small>.account__avatar-wrapper{margin:0 8px 0 0}.account.small>.display-name{height:24px;line-height:24px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:8%;background-position:50%;background-clip:padding-box;position:relative;cursor:pointer}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:8%;background-position:50%;background-clip:padding-box;overflow:hidden;position:relative;cursor:default}.account__avatar-composite div{border-radius:8%;background-position:50%;background-clip:padding-box;float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}.account__avatar-overlay{position:relative;width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:8%;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:8%;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__header__wrapper{flex:0 0 auto;background:#192432}.account__disclaimer{padding:10px;color:#3e5a7c}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-left:1px solid #202e3f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab:first-child{border-left:0}.account__action-bar__tab.active{border-bottom:4px solid #d8a070}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#9baec8}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account__action-bar__tab abbr{color:#d8a070}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.notification__message{margin-left:42px;padding:8px 0 0 26px;cursor:default;color:#9baec8;font-size:15px;position:relative}.notification__message .fa{color:#d8a070}.notification__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account--panel{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#202e3f;padding:15px}.column-settings__section{color:#9baec8;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#a8b9cf}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#192432}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#3e5a7c;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#202e3f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#26374d;color:#a8b9cf}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#9baec8}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#3e5a7c}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#45648a}.column-settings__hashtags .column-select__indicator-separator{background-color:#202e3f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#121a24}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#121a24;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:none;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#0b1016;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#9baec8;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#d9e1e8}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #202e3f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #121a24}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#0f151d;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #06090c}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f}.account__moved-note__message{position:relative;margin-left:58px;color:#3e5a7c;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.account__header__content{color:#9baec8;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#0b1016}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#192432;padding:5px;border-bottom:1px solid #26374d}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#040609;border:2px solid #192432}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #26374d;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #26374d}.account__header__bio .account__header__fields a{color:#e1b590}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#9baec8;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.domain{padding:10px;border-bottom:1px solid #202e3f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}@keyframes spring-flip-in{0%{transform:rotate(0deg)}30%{transform:rotate(-242.4deg)}60%{transform:rotate(-158.35deg)}90%{transform:rotate(-187.5deg)}100%{transform:rotate(-180deg)}}@keyframes spring-flip-out{0%{transform:rotate(-180deg)}30%{transform:rotate(62.4deg)}60%{transform:rotate(-21.635deg)}90%{transform:rotate(7.5deg)}100%{transform:rotate(0deg)}}.status__content--with-action{cursor:pointer}.status__content{position:relative;margin:10px 0;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:visible;padding-top:5px}.status__content:focus{outline:0}.status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.status__content pre,.status__content blockquote{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.status__content pre:last-child,.status__content blockquote:last-child{margin-bottom:0}.status__content .status__content__text,.status__content .e-content{overflow:hidden}.status__content .status__content__text>ul,.status__content .status__content__text>ol,.status__content .e-content>ul,.status__content .e-content>ol{margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h1,.status__content .e-content h2,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{margin-top:20px;margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .e-content h1,.status__content .e-content h2{font-weight:700;font-size:1.2em}.status__content .status__content__text h2,.status__content .e-content h2{font-size:1.1em}.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{font-weight:500}.status__content .status__content__text blockquote,.status__content .e-content blockquote{padding-left:10px;border-left:3px solid #9baec8;color:#9baec8;white-space:normal}.status__content .status__content__text blockquote p:last-child,.status__content .e-content blockquote p:last-child{margin-bottom:0}.status__content .status__content__text b,.status__content .status__content__text strong,.status__content .e-content b,.status__content .e-content strong{font-weight:700}.status__content .status__content__text em,.status__content .status__content__text i,.status__content .e-content em,.status__content .e-content i{font-style:italic}.status__content .status__content__text sub,.status__content .e-content sub{font-size:smaller;text-align:sub}.status__content .status__content__text sup,.status__content .e-content sup{font-size:smaller;vertical-align:super}.status__content .status__content__text ul,.status__content .status__content__text ol,.status__content .e-content ul,.status__content .e-content ol{margin-left:1em}.status__content .status__content__text ul p,.status__content .status__content__text ol p,.status__content .e-content ul p,.status__content .e-content ol p{margin:0}.status__content .status__content__text ul,.status__content .e-content ul{list-style-type:disc}.status__content .status__content__text ol,.status__content .e-content ol{list-style-type:decimal}.status__content a{color:#d8a070;text-decoration:none}.status__content a:hover{text-decoration:underline}.status__content a:hover .fa{color:#4a6b94}.status__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span{text-decoration:underline}.status__content a .fa{color:#3e5a7c}.status__content .status__content__spoiler{display:none}.status__content .status__content__spoiler.status__content__spoiler--visible{display:block}.status__content a.unhandled-link{color:#e1b590}.status__content a.unhandled-link .link-origin-tag{color:#ca8f04;font-size:.8em}.status__content .status__content__spoiler-link{background:#45648a}.status__content .status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:#45648a;border:none;color:#121a24;font-weight:500;font-size:11px;padding:0 5px;text-transform:uppercase;line-height:inherit;cursor:pointer;vertical-align:bottom}.status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.status__content__spoiler-link .status__content__spoiler-icon{display:inline-block;margin:0 0 0 5px;border-left:1px solid currentColor;padding:0 0 0 4px;font-size:16px;vertical-align:-2px}.notif-cleaning .status,.notif-cleaning .notification-follow,.notif-cleaning .notification-follow-request{padding-right:4.5rem}.status__wrapper--filtered{color:#3e5a7c;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #202e3f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.notification-follow,.notification-follow-request{position:relative;border-bottom:1px solid #202e3f}.notification-follow .account,.notification-follow-request .account{border-bottom:0 none}.focusable:focus{outline:0;background:#192432}.focusable:focus.status.status-direct:not(.read){background:#26374d}.focusable:focus.status.status-direct:not(.read).muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#202e3f}.status{padding:10px 14px;position:relative;height:auto;border-bottom:1px solid #202e3f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:28px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#202e3f;border-bottom-color:#26374d}.status.light .status__relative-time{color:#3e5a7c}.status.light .status__display-name{color:#121a24}.status.light .display-name strong{color:#121a24}.status.light .display-name span{color:#3e5a7c}.status.light .status__content{color:#121a24}.status.light .status__content a{color:#d8a070}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.status.collapsed{background-position:center;background-size:cover;user-select:none}.status.collapsed.has-background::before{display:block;position:absolute;left:0;right:0;top:0;bottom:0;background-image:linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.65) 24px, rgba(0, 0, 0, 0.8));pointer-events:none;content:\"\"}.status.collapsed .display-name:hover .display-name__html{text-decoration:none}.status.collapsed .status__content{height:20px;overflow:hidden;text-overflow:ellipsis;padding-top:0}.status.collapsed .status__content:after{content:\"\";position:absolute;top:0;bottom:0;left:0;right:0;background:linear-gradient(rgba(18, 26, 36, 0), #121a24);pointer-events:none}.status.collapsed .status__content a:hover{text-decoration:none}.status.collapsed:focus>.status__content:after{background:linear-gradient(rgba(25, 36, 50, 0), #192432)}.status.collapsed.status-direct:not(.read)>.status__content:after{background:linear-gradient(rgba(32, 46, 63, 0), #202e3f)}.status.collapsed .notification__message{margin-bottom:0}.status.collapsed .status__info .notification__message>span{white-space:nowrap}.status .notification__message{margin:-10px 0px 10px 0}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#547aa9}.status__relative-time{display:inline-block;flex-grow:1;color:#3e5a7c;font-size:14px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status__display-name{color:#3e5a7c;overflow:hidden}.status__info__account .status__display-name{display:block;max-width:100%}.status__info{display:flex;justify-content:space-between;font-size:15px}.status__info>span{text-overflow:ellipsis;overflow:hidden}.status__info .notification__message>span{word-wrap:break-word}.status__info__icons{display:flex;align-items:center;height:1em;color:#3e5a7c}.status__info__icons .status__media-icon,.status__info__icons .status__visibility-icon,.status__info__icons .status__reply-icon{padding-left:2px;padding-right:2px}.status__info__icons .status__collapse-button.active>.fa-angle-double-up{transform:rotate(-180deg)}.no-reduce-motion .status__collapse-button.activate>.fa-angle-double-up{animation:spring-flip-in 1s linear}.no-reduce-motion .status__collapse-button.deactivate>.fa-angle-double-up{animation:spring-flip-out 1s linear}.status__info__account{display:flex;align-items:center;justify-content:flex-start}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-top:-10px;margin-bottom:10px;margin-left:58px;color:#3e5a7c;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#3e5a7c}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#3e5a7c}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#192432;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#3e5a7c;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}a.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#d9e1e8;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{flex:none;margin:0 10px 0 0;height:48px;width:48px}.muted .status__content,.muted .status__content p,.muted .status__content a,.muted .status__content__text{color:#3e5a7c}.muted .status__display-name strong{color:#3e5a7c}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#3e5a7c;color:#121a24}.muted a.status__content__spoiler-link:hover{background:#436187;text-decoration:none}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.status-card{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;color:#3e5a7c;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#d9e1e8;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}.status-card__actions a .fa,.status-card__actions a:hover .fa{color:inherit}a.status-card{cursor:pointer}a.status-card:hover{background:#202e3f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#9baec8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#9baec8}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#202e3f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#192432}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#192432}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.attachment-list{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#3e5a7c;padding:8px 18px;cursor:default;border-right:1px solid #202e3f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#3e5a7c;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#3e5a7c}.status__wrapper--filtered__button{display:inline;color:#e1b590;border:0;background:transparent;padding:0;font-size:inherit;line-height:inherit}.status__wrapper--filtered__button:hover,.status__wrapper--filtered__button:active{text-decoration:underline}.modal-container--preloader{background:#202e3f}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#121a24;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.onboarding-modal__pager{height:80vh;width:80vw;max-width:520px;max-height:470px}.onboarding-modal__pager .react-swipeable-view-container>div{width:100%;height:100%;box-sizing:border-box;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;user-select:text}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}@media screen and (max-width: 550px){.onboarding-modal{width:100%;height:100%;border-radius:0}.onboarding-modal__pager{width:100%;height:auto;max-width:none;max-height:none;flex:1 1 auto}}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#3e5a7c;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#37506f;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#121a24}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#192432}.error-modal__footer{justify-content:center}.onboarding-modal__dots{flex:1 1 auto;display:flex;align-items:center;justify-content:center}.onboarding-modal__dot{width:14px;height:14px;border-radius:14px;background:#a6b9c9;margin:0 3px;cursor:pointer}.onboarding-modal__dot:hover{background:#a0b4c5}.onboarding-modal__dot.active{cursor:default;background:#8da5ba}.onboarding-modal__page__wrapper{pointer-events:none;padding:25px;padding-bottom:0}.onboarding-modal__page__wrapper.onboarding-modal__page__wrapper--active{pointer-events:auto}.onboarding-modal__page{cursor:default;line-height:21px}.onboarding-modal__page h1{font-size:18px;font-weight:500;color:#121a24;margin-bottom:20px}.onboarding-modal__page a{color:#d8a070}.onboarding-modal__page a:hover,.onboarding-modal__page a:focus,.onboarding-modal__page a:active{color:#dcab80}.onboarding-modal__page .navigation-bar a{color:inherit}.onboarding-modal__page p{font-size:16px;color:#3e5a7c;margin-top:10px;margin-bottom:10px}.onboarding-modal__page p:last-child{margin-bottom:0}.onboarding-modal__page p strong{font-weight:500;background:#121a24;color:#d9e1e8;border-radius:4px;font-size:14px;padding:3px 6px}.onboarding-modal__page p strong:lang(ja){font-weight:700}.onboarding-modal__page p strong:lang(ko){font-weight:700}.onboarding-modal__page p strong:lang(zh-CN){font-weight:700}.onboarding-modal__page p strong:lang(zh-HK){font-weight:700}.onboarding-modal__page p strong:lang(zh-TW){font-weight:700}.onboarding-modal__page__wrapper-0{height:100%;padding:0}.onboarding-modal__page-one__lead{padding:65px;padding-top:45px;padding-bottom:0;margin-bottom:10px}.onboarding-modal__page-one__lead h1{font-size:26px;line-height:36px;margin-bottom:8px}.onboarding-modal__page-one__lead p{margin-bottom:0}.onboarding-modal__page-one__extra{padding-right:65px;padding-left:185px;text-align:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#121a24;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#121a24;color:#d9e1e8;font-weight:500;padding:10px;border-radius:4px}.onboarding-modal__page-two p,.onboarding-modal__page-three p,.onboarding-modal__page-four p,.onboarding-modal__page-five p{text-align:left}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{background:#040609;color:#d9e1e8;margin-bottom:20px;border-radius:4px;padding:10px;text-align:center;font-size:14px;box-shadow:1px 2px 6px rgba(0,0,0,.3)}.onboarding-modal__page-two .figure .onboarding-modal__image,.onboarding-modal__page-three .figure .onboarding-modal__image,.onboarding-modal__page-four .figure .onboarding-modal__image,.onboarding-modal__page-five .figure .onboarding-modal__image{border-radius:4px;margin-bottom:10px}.onboarding-modal__page-two .figure.non-interactive,.onboarding-modal__page-three .figure.non-interactive,.onboarding-modal__page-four .figure.non-interactive,.onboarding-modal__page-five .figure.non-interactive{pointer-events:none;text-align:left}.onboarding-modal__page-four__columns .row{display:flex;margin-bottom:20px}.onboarding-modal__page-four__columns .row>div{flex:1 1 0;margin:0 10px}.onboarding-modal__page-four__columns .row>div:first-child{margin-left:0}.onboarding-modal__page-four__columns .row>div:last-child{margin-right:0}.onboarding-modal__page-four__columns .row>div p{text-align:center}.onboarding-modal__page-four__columns .row:last-child{margin-bottom:0}.onboarding-modal__page-four__columns .column-header{color:#fff}@media screen and (max-width: 320px)and (max-height: 600px){.onboarding-modal__page p{font-size:14px;line-height:20px}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{font-size:12px;margin-bottom:10px}.onboarding-modal__page-four__columns .row{margin-bottom:10px}.onboarding-modal__page-four__columns .column-header{padding:5px;font-size:12px}}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.doodle-modal,.favourite-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#121a24;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__relative-time,.doodle-modal .status__relative-time,.favourite-modal .status__relative-time,.confirmation-modal .status__relative-time,.report-modal .status__relative-time,.actions-modal .status__relative-time,.mute-modal .status__relative-time,.block-modal .status__relative-time{color:#3e5a7c;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.boost-modal .status__display-name,.doodle-modal .status__display-name,.favourite-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:flex}.boost-modal .status__avatar,.doodle-modal .status__avatar,.favourite-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:48px;width:48px}.boost-modal .status__content__spoiler-link,.doodle-modal .status__content__spoiler-link,.favourite-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#f2f5f7}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container,.favourite-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status,.favourite-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.doodle-modal__action-bar,.favourite-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.doodle-modal__action-bar>div,.favourite-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#3e5a7c;padding-right:10px}.boost-modal__action-bar .button,.doodle-modal__action-bar .button,.favourite-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header,.favourite-modal__status-header{font-size:15px}.boost-modal__status-time,.favourite-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#d8a070}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#121a24;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#121a24;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal strong{display:block;font-weight:500}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#121a24;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#d8a070;color:#fff}.actions-modal ul li:not(:empty) a>.react-toggle,.actions-modal ul li:not(:empty) a>.icon,.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#3e5a7c;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#37506f;background-color:transparent}.confirmation-modal__do_not_ask_again{padding-left:20px;padding-right:20px;padding-bottom:10px;font-size:14px}.confirmation-modal__do_not_ask_again label,.confirmation-modal__do_not_ask_again input{vertical-align:middle}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#121a24;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#192432}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.filtered-status-info{text-align:start}.filtered-status-info .spoiler__text{margin-top:20px}.filtered-status-info .account{border-bottom:0}.filtered-status-info .account__display-name strong{color:#121a24}.filtered-status-info .status__content__spoiler{display:none}.filtered-status-info .status__content__spoiler--visible{display:flex}.filtered-status-info ul{padding:10px;margin-left:12px;list-style:disc inside}.filtered-status-info .filtered-status-edit-link{color:#3e5a7c;text-decoration:none}.filtered-status-info .filtered-status-edit-link:hover{text-decoration:underline}.composer{padding:10px}.character-counter{cursor:default;font-family:sans-serif,sans-serif;font-size:14px;font-weight:600;color:#3e5a7c}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .composer--spoiler{transition:height .4s ease,opacity .4s ease}.composer--spoiler{height:0;transform-origin:bottom;opacity:0}.composer--spoiler.composer--spoiler--visible{height:36px;margin-bottom:11px;opacity:1}.composer--spoiler input{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px;padding:10px;width:100%;outline:0;color:#121a24;background:#fff;font-size:14px;font-family:inherit;resize:vertical}.composer--spoiler input::placeholder{color:#3e5a7c}.composer--spoiler input:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .composer--spoiler input{font-size:16px}}.single-column .composer--spoiler input{font-size:16px}.composer--warning{color:#121a24;margin-bottom:15px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.composer--warning a{color:#3e5a7c;font-weight:500;text-decoration:underline}.composer--warning a:active,.composer--warning a:focus,.composer--warning a:hover{text-decoration:none}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#d8a070}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-left:5px;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#d8a070;background:#d8a070}.composer--reply{margin:0 0 10px;border-radius:4px;padding:10px;background:#9baec8;min-height:23px;overflow-y:auto;flex:0 2 auto}.composer--reply>header{margin-bottom:5px;overflow:hidden}.composer--reply>header>.account.small{color:#121a24}.composer--reply>header>.cancel{float:right;line-height:24px}.composer--reply>.content{position:relative;margin:10px 0;padding:0 12px;font-size:14px;line-height:20px;color:#121a24;word-wrap:break-word;font-weight:400;overflow:visible;white-space:pre-wrap;padding-top:5px;overflow:hidden}.composer--reply>.content p,.composer--reply>.content pre,.composer--reply>.content blockquote{margin-bottom:20px;white-space:pre-wrap}.composer--reply>.content p:last-child,.composer--reply>.content pre:last-child,.composer--reply>.content blockquote:last-child{margin-bottom:0}.composer--reply>.content h1,.composer--reply>.content h2,.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{margin-top:20px;margin-bottom:20px}.composer--reply>.content h1,.composer--reply>.content h2{font-weight:700;font-size:18px}.composer--reply>.content h2{font-size:16px}.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{font-weight:500}.composer--reply>.content blockquote{padding-left:10px;border-left:3px solid #121a24;color:#121a24;white-space:normal}.composer--reply>.content blockquote p:last-child{margin-bottom:0}.composer--reply>.content b,.composer--reply>.content strong{font-weight:700}.composer--reply>.content em,.composer--reply>.content i{font-style:italic}.composer--reply>.content sub{font-size:smaller;text-align:sub}.composer--reply>.content ul,.composer--reply>.content ol{margin-left:1em}.composer--reply>.content ul p,.composer--reply>.content ol p{margin:0}.composer--reply>.content ul{list-style-type:disc}.composer--reply>.content ol{list-style-type:decimal}.composer--reply>.content a{color:#3e5a7c;text-decoration:none}.composer--reply>.content a:hover{text-decoration:underline}.composer--reply>.content a.mention:hover{text-decoration:none}.composer--reply>.content a.mention:hover span{text-decoration:underline}.composer--reply .emojione{width:20px;height:20px;margin:-5px 0 0}.emoji-picker-dropdown{position:absolute;right:5px;top:5px}.emoji-picker-dropdown ::-webkit-scrollbar-track:hover,.emoji-picker-dropdown ::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.compose-form__autosuggest-wrapper,.autosuggest-input{position:relative;width:100%}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.autosuggest-input label .autosuggest-textarea__textarea{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px 4px 0 0;padding:10px 32px 0 10px;width:100%;min-height:100px;outline:0;color:#121a24;background:#fff;font-size:14px;font-family:inherit;resize:none;scrollbar-color:initial}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::placeholder,.autosuggest-input label .autosuggest-textarea__textarea::placeholder{color:#3e5a7c}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::-webkit-scrollbar,.autosuggest-input label .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:disabled,.autosuggest-input label .autosuggest-textarea__textarea:disabled{background:#d9e1e8}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:focus,.autosuggest-input label .autosuggest-textarea__textarea:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}}.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}@media screen and (max-width: 600px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.composer--textarea--icons{display:block;position:absolute;top:29px;right:5px;bottom:5px;overflow:hidden}.composer--textarea--icons>.textarea_icon{display:block;margin:2px 0 0 2px;width:24px;height:24px;color:#3e5a7c;font-size:18px;line-height:24px;text-align:center;opacity:.8}.autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.autosuggest-textarea__suggestions{display:block;position:absolute;box-sizing:border-box;top:100%;border-radius:0 0 4px 4px;padding:6px;width:100%;color:#121a24;background:#d9e1e8;box-shadow:4px 4px 6px rgba(0,0,0,.4);font-size:14px;z-index:99;display:none}.autosuggest-textarea__suggestions--visible{display:block}.autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.autosuggest-textarea__suggestions__item>.account,.autosuggest-textarea__suggestions__item>.emoji,.autosuggest-textarea__suggestions__item>.autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.autosuggest-textarea__suggestions__item .autosuggest-hashtag{justify-content:space-between}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item .autosuggest-hashtag strong{font-weight:500}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item>.account.small .display-name>span{color:#3e5a7c}.composer--upload_form{overflow:hidden}.composer--upload_form>.content{display:flex;flex-direction:row;flex-wrap:wrap;font-family:inherit;padding:5px;overflow:hidden}.composer--upload_form--item{flex:1 1 0;margin:5px;min-width:40%}.composer--upload_form--item>div{position:relative;border-radius:4px;height:140px;width:100%;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;overflow:hidden}.composer--upload_form--item>div textarea{display:block;position:absolute;box-sizing:border-box;bottom:0;left:0;margin:0;border:0;padding:10px;width:100%;color:#d9e1e8;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);font-size:14px;font-family:inherit;font-weight:500;opacity:0;z-index:2;transition:opacity .1s ease}.composer--upload_form--item>div textarea:focus{color:#fff}.composer--upload_form--item>div textarea::placeholder{opacity:.54;color:#d9e1e8}.composer--upload_form--item>div>.close{mix-blend-mode:difference}.composer--upload_form--item.active>div textarea{opacity:1}.composer--upload_form--actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.composer--upload_form--actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.composer--upload_form--actions .icon-button:hover,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:active{color:#e6ebf0}.composer--upload_form--actions.active{opacity:1}.composer--upload_form--progress{display:flex;padding:10px;color:#9baec8;overflow:hidden}.composer--upload_form--progress>.fa{font-size:34px;margin-right:10px}.composer--upload_form--progress>.message{flex:1 1 auto}.composer--upload_form--progress>.message>span{display:block;font-size:12px;font-weight:500;text-transform:uppercase}.composer--upload_form--progress>.message>.backdrop{position:relative;margin-top:5px;border-radius:6px;width:100%;height:6px;background:#3e5a7c}.composer--upload_form--progress>.message>.backdrop>.tracker{position:absolute;top:0;left:0;height:6px;border-radius:6px;background:#d8a070}.compose-form__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.composer--options-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;height:27px;display:flex;justify-content:space-between;flex:0 0 auto}.composer--options{display:flex;flex:0 0 auto}.composer--options>*{display:inline-block;box-sizing:content-box;padding:0 3px;height:27px;line-height:27px;vertical-align:bottom}.composer--options>hr{display:inline-block;margin:0 3px;border-width:0 0 0 1px;border-style:none none none solid;border-color:transparent transparent transparent #c2c2c2;padding:0;width:0;height:27px;background:transparent}.compose--counter-wrapper{align-self:center;margin-right:4px}.composer--options--dropdown.open>.value{border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1);color:#fff;background:#d8a070;transition:none}.composer--options--dropdown.open.top>.value{border-radius:0 0 4px 4px;box-shadow:0 4px 4px rgba(0,0,0,.1)}.composer--options--dropdown--content{position:absolute;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);background:#fff;overflow:hidden;transform-origin:50% 0}.composer--options--dropdown--content--item{display:flex;align-items:center;padding:10px;color:#121a24;cursor:pointer}.composer--options--dropdown--content--item>.content{flex:1 1 auto;color:#3e5a7c}.composer--options--dropdown--content--item>.content:not(:first-child){margin-left:10px}.composer--options--dropdown--content--item>.content strong{display:block;color:#121a24;font-weight:500}.composer--options--dropdown--content--item:hover,.composer--options--dropdown--content--item.active{background:#d8a070;color:#fff}.composer--options--dropdown--content--item:hover>.content,.composer--options--dropdown--content--item.active>.content{color:#fff}.composer--options--dropdown--content--item:hover>.content strong,.composer--options--dropdown--content--item.active>.content strong{color:#fff}.composer--options--dropdown--content--item.active:hover{background:#dcab80}.composer--publisher{padding-top:10px;text-align:right;white-space:nowrap;overflow:hidden;justify-content:flex-end;flex:0 0 auto}.composer--publisher>.primary{display:inline-block;margin:0;padding:0 10px;text-align:center}.composer--publisher>.side_arm{display:inline-block;margin:0 2px;padding:0;width:36px;text-align:center}.composer--publisher.over>.count{color:#ff5050}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#040609;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#121a24}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.column{overflow:hidden}.column-back-button{box-sizing:border-box;width:100%;background:#192432;color:#d8a070;cursor:pointer;flex:0 0 auto;font-size:16px;border:0;text-align:unset;padding:15px;margin:0;z-index:3}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#192432;border:0;font-family:inherit;color:#d8a070;cursor:pointer;flex:0 0 auto;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.column-link{background:#202e3f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#253549}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#d8a070}.column-link__icon{display:inline-block;margin-right:5px}.column-subheading{background:#121a24;color:#3e5a7c;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(216, 160, 112, 0.23) 0%, rgba(216, 160, 112, 0) 60%)}.column-header{display:flex;font-size:16px;background:#192432;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden}.column-header>button{margin:0;border:none;padding:15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#d8a070}.column-header.active{box-shadow:0 1px 0 rgba(216,160,112,.3)}.column-header.active .column-header__icon{color:#d8a070;text-shadow:0 0 10px rgba(216,160,112,.4)}.column-header:focus,.column-header:active{outline:0}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden}.wide .columns-area:not(.columns-area--mobile) .column{flex:auto;min-width:330px;max-width:400px}.column>.scrollable{background:#121a24}.column-header__buttons{height:48px;display:flex;margin-left:0}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button,.column-header__notif-cleaning-buttons button{background:#192432;border:0;color:#9baec8;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover,.column-header__notif-cleaning-buttons button:hover{color:#b2c1d5}.column-header__button.active,.column-header__notif-cleaning-buttons button.active{color:#fff;background:#202e3f}.column-header__button.active:hover,.column-header__notif-cleaning-buttons button.active:hover{color:#fff;background:#202e3f}.column-header__button:focus,.column-header__notif-cleaning-buttons button:focus{text-shadow:0 0 4px #d3935c}.column-header__notif-cleaning-buttons{display:flex;align-items:stretch;justify-content:space-around}.column-header__notif-cleaning-buttons button{background:transparent;text-align:center;padding:10px 0;white-space:pre-wrap}.column-header__notif-cleaning-buttons b{font-weight:bold}.column-header__collapsible-inner.nopad-drawer{padding:0}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#9baec8;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #26374d;margin:10px 0}.column-header__collapsible.ncd{transition:none}.column-header__collapsible.ncd.collapsed{max-height:0;opacity:.7}.column-header__collapsible-inner{background:#202e3f;padding:15px}.column-header__setting-btn:hover{color:#9baec8;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.column-header__title{display:inline-block;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header__icon{display:inline-block;margin-right:5px}.empty-column-indicator,.error-column{color:#3e5a7c;background:#121a24;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#d8a070;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}.single-column.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}@media screen and (max-width: 415px){.auto-columns.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}}@media screen and (max-width: 415px){.auto-columns.navbar-under .react-swipeable-view-container .columns-area,.single-column.navbar-under .react-swipeable-view-container .columns-area{height:100% !important}}.column-inline-form{padding:7px 15px;padding-right:5px;display:flex;justify-content:flex-start;align-items:center;background:#192432}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%;margin-bottom:6px}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 5px}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#3e5a7c;background:#121a24;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#3e5a7c}.regeneration-indicator__label span{font-size:15px;font-weight:400}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#000;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#192432;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#121a24;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #202e3f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.filter-form{background:#121a24}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#e1b590;background:#e1b590}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#a8b9cf}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#192432}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:color,transform,opacity;font-size:18px;width:18px;height:18px;color:#d9e1e8;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(0deg)}.search__icon .fa-search.active{pointer-events:auto;opacity:.3}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#3e5a7c;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#4a6b94}.search-results__header{color:#3e5a7c;background:#151f2b;border-bottom:1px solid #0b1016;padding:15px 10px;font-size:14px;font-weight:500}.search-results__info{padding:20px;color:#9baec8;text-align:center}.trends__header{color:#3e5a7c;background:#151f2b;border-bottom:1px solid #0b1016;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #202e3f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#3e5a7c;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#9baec8;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#d9e1e8}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(216,160,112,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#dfb088 !important}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.doodle-modal{width:unset}.doodle-modal__container{background:#d9e1e8;text-align:center;line-height:0}.doodle-modal__container canvas{border:5px solid #d9e1e8}.doodle-modal__action-bar .filler{flex-grow:1;margin:0;padding:0}.doodle-modal__action-bar .doodle-toolbar{line-height:1;display:flex;flex-direction:column;flex-grow:0;justify-content:space-around}.doodle-modal__action-bar .doodle-toolbar.with-inputs label{display:inline-block;width:70px;text-align:right;margin-right:2px}.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=number],.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=text]{width:40px}.doodle-modal__action-bar .doodle-toolbar.with-inputs span.val{display:inline-block;text-align:left;width:50px}.doodle-modal__action-bar .doodle-palette{padding-right:0 !important;border:1px solid #000;line-height:.2rem;flex-grow:0;background:#fff}.doodle-modal__action-bar .doodle-palette button{appearance:none;width:1rem;height:1rem;margin:0;padding:0;text-align:center;color:#000;text-shadow:0 0 1px #fff;cursor:pointer;box-shadow:inset 0 0 1px rgba(255,255,255,.5);border:1px solid #000;outline-offset:-1px}.doodle-modal__action-bar .doodle-palette button.foreground{outline:1px dashed #fff}.doodle-modal__action-bar .doodle-palette button.background{outline:1px dashed red}.doodle-modal__action-bar .doodle-palette button.foreground.background{outline:1px dashed red;border-color:#fff}.drawer{width:300px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden;padding:10px 5px;flex:none}.drawer:first-child{padding-left:10px}.drawer:last-child{padding-right:10px}@media screen and (max-width: 630px){.auto-columns .drawer{flex:auto}}.single-column .drawer{flex:auto}@media screen and (max-width: 630px){.auto-columns .drawer,.auto-columns .drawer:first-child,.auto-columns .drawer:last-child,.single-column .drawer,.single-column .drawer:first-child,.single-column .drawer:last-child{padding:0}}.wide .drawer{min-width:300px;max-width:400px;flex:1 1 200px}@media screen and (max-width: 630px){:root .auto-columns .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}}:root .single-column .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}.react-swipeable-view-container .drawer{height:100%}.drawer--header{display:flex;flex-direction:row;margin-bottom:10px;flex:none;background:#202e3f;font-size:16px}.drawer--header>*{display:block;box-sizing:border-box;border-bottom:2px solid transparent;padding:15px 5px 13px;height:48px;flex:1 1 auto;color:#9baec8;text-align:center;text-decoration:none;cursor:pointer}.drawer--header a{transition:background 100ms ease-in}.drawer--header a:focus,.drawer--header a:hover{outline:none;background:#17212e;transition:background 200ms ease-out}.search{position:relative;margin-bottom:10px;flex:none}@media screen and (max-width: 415px){.auto-columns .search,.single-column .search{margin-bottom:0}}@media screen and (max-width: 630px){.auto-columns .search{font-size:16px}}.single-column .search{font-size:16px}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#121a24}.drawer--account{padding:10px;color:#9baec8;display:flex;align-items:center}.drawer--account a{color:inherit;text-decoration:none}.drawer--account .acct{display:block;color:#d9e1e8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;overflow:hidden}.drawer--results{background:#121a24;overflow-x:hidden;overflow-y:auto}.drawer--results>header{color:#3e5a7c;background:#151f2b;padding:15px;font-weight:500;font-size:16px;cursor:default}.drawer--results>header .fa{display:inline-block;margin-right:5px}.drawer--results>section{margin-bottom:5px}.drawer--results>section h5{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#3e5a7c}.drawer--results>section h5 .fa{display:inline-block;margin-right:5px}.drawer--results>section .account:last-child,.drawer--results>section>div:last-child .status{border-bottom:0}.drawer--results>section>.hashtag{display:block;padding:10px;color:#d9e1e8;text-decoration:none}.drawer--results>section>.hashtag:hover,.drawer--results>section>.hashtag:active,.drawer--results>section>.hashtag:focus{color:#e6ebf0;text-decoration:underline}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#283a50;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%}.drawer__inner.darker{background:#121a24}.drawer__inner__mastodon{background:#283a50 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}.drawer__inner__mastodon>.mastodon{display:block;width:100%;height:100%;border:none;cursor:inherit}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#283a50;font-size:13px;text-align:left}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#9baec8;border:0;width:100%;height:100%}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{color:#b5c3d6}.status__content>.media-spoiler{margin-top:15px}.media-spoiler.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:500}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{height:100%;display:flex;flex-direction:column}.media-gallery__audio span{text-align:center;color:#9baec8;display:flex;height:100%;align-items:center}.media-gallery__audio span p{width:100%}.media-gallery__audio audio{width:100%}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%;height:110px}.media-gallery.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-gallery__item{border:none;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.full-width .media-gallery__item{border-radius:0}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item.letterbox{background:#000}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#d9e1e8;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%;object-fit:contain}.media-gallery__item-thumbnail:not(.letterbox),.media-gallery__item-thumbnail img:not(.letterbox){height:100%;object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%;display:flex;justify-content:center}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;width:100%;position:relative;z-index:1;object-fit:contain;user-select:none}.media-gallery__item-gifv-thumbnail:not(.letterbox){height:100%;object-fit:cover}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#d8a070}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#040609;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #192432;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(225,181,144,.5)}.audio-player__wave-placeholder{background-color:#2d415a}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#040609;border-top:1px solid #192432;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.detailed-status .video-player{width:100%;height:100%}.video-player.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.video-player video{max-width:100vw;max-height:80vh;z-index:1;position:relative}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#9baec8;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#b2c1d5}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons-bar .video-player__download__icon .fa,.video-player__buttons-bar .video-player__download__icon:active .fa,.video-player__buttons-bar .video-player__download__icon:hover .fa,.video-player__buttons-bar .video-player__download__icon:focus .fa{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#e1b590}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#e1b590}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.sensitive-info{display:flex;flex-direction:row;align-items:center;position:absolute;top:4px;left:4px;z-index:100}.sensitive-marker{margin:0 3px;border-radius:2px;padding:2px 6px;color:rgba(255,255,255,.8);background:rgba(0,0,0,.5);font-size:12px;line-height:18px;text-transform:uppercase;opacity:.9;transition:opacity .1s ease}.media-gallery:hover .sensitive-marker{opacity:1}.list-editor{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#283a50;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#283a50}.list-adder__lists{background:#283a50;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #202e3f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.emoji-mart{font-size:13px;display:inline-block;color:#121a24}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#3e5a7c;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#37506f}.emoji-mart-anchor-selected{color:#d8a070}.emoji-mart-anchor-selected:hover{color:#d49560}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#d59864}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#121a24;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#9baec8}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.glitch.local-settings{position:relative;display:flex;flex-direction:row;background:#d9e1e8;color:#121a24;border-radius:8px;height:80vh;width:80vw;max-width:740px;max-height:450px;overflow:hidden}.glitch.local-settings label,.glitch.local-settings legend{display:block;font-size:14px}.glitch.local-settings .boolean label,.glitch.local-settings .radio_buttons label{position:relative;padding-left:28px;padding-top:3px}.glitch.local-settings .boolean label input,.glitch.local-settings .radio_buttons label input{position:absolute;left:0;top:0}.glitch.local-settings span.hint{display:block;color:#3e5a7c}.glitch.local-settings h1{font-size:18px;font-weight:500;line-height:24px;margin-bottom:20px}.glitch.local-settings h2{font-size:15px;font-weight:500;line-height:20px;margin-top:20px;margin-bottom:10px}.glitch.local-settings__navigation__item{display:block;padding:15px 20px;color:inherit;background:#f2f5f7;border-bottom:1px #d9e1e8 solid;cursor:pointer;text-decoration:none;outline:none;transition:background .3s}.glitch.local-settings__navigation__item .text-icon-button{color:inherit;transition:unset}.glitch.local-settings__navigation__item:hover{background:#d9e1e8}.glitch.local-settings__navigation__item.active{background:#d8a070;color:#fff}.glitch.local-settings__navigation__item.close,.glitch.local-settings__navigation__item.close:hover{background:#df405a;color:#fff}.glitch.local-settings__navigation{background:#f2f5f7;width:212px;font-size:15px;line-height:20px;overflow-y:auto}.glitch.local-settings__page{display:block;flex:auto;padding:15px 20px 15px 20px;width:360px;overflow-y:auto}.glitch.local-settings__page__item{margin-bottom:2px}.glitch.local-settings__page__item.string,.glitch.local-settings__page__item.radio_buttons{margin-top:10px;margin-bottom:10px}@media screen and (max-width: 630px){.glitch.local-settings__navigation{width:40px;flex-shrink:0}.glitch.local-settings__navigation__item{padding:10px}.glitch.local-settings__navigation__item span:last-of-type{display:none}}.error-boundary{color:#fff;font-size:15px;line-height:20px}.error-boundary h1{font-size:26px;line-height:36px;font-weight:400;margin-bottom:8px}.error-boundary a{color:#fff;text-decoration:underline}.error-boundary ul{list-style:disc;margin-left:0;padding-left:1em}.error-boundary textarea.web_app_crash-stacktrace{width:100%;resize:none;white-space:pre;font-family:monospace,monospace}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.compose-panel .search__icon .fa{top:15px}.compose-panel .drawer--account{flex:0 1 48px}.compose-panel .flex-spacer{background:transparent}.compose-panel .composer{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #192432;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px;min-height:50px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{padding-top:15px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#d59864;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#e0b38c}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.search{margin-bottom:10px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.poll{margin-top:16px;font-size:14px}.poll ul,.e-content .poll ul{margin:0;list-style:none}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#6d89af}.poll__chart.leading{background:#d8a070}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#121a24;display:block;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#d8a070}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#3e5a7c}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#3e5a7c;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(62,90,124,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{width:100%;flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#d8a070}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#3e5a7c;border-color:#3e5a7c;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#121a24;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#3e5a7c}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(216,160,112,.2)}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:sans-serif,sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#9baec8}.rich-formatting a{color:#d8a070;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#9baec8}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#d9e1e8}.rich-formatting em{font-style:italic;color:#d9e1e8}.rich-formatting code{font-size:.85em;background:#040609;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:sans-serif,sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#d9e1e8}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #192432;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #192432;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#9baec8}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#3e5a7c}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#0b1016;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:sans-serif,sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#d9e1e8}.information-board__section strong{font-family:sans-serif,sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#040609;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#9baec8;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #192432;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#7a93b6}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;width:80px;height:80px;background-size:80px 80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px;border-radius:8%;background-position:50%;background-clip:padding-box}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#9baec8}.landing-page p,.landing-page li{font-family:sans-serif,sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#9baec8}.landing-page p a,.landing-page li a{color:#d8a070;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.landing-page h1{font-family:sans-serif,sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h1 small{font-family:sans-serif,sans-serif;display:block;font-size:18px;font-weight:400;color:#bcc9da}.landing-page h2{font-family:sans-serif,sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h3{font-family:sans-serif,sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h4{font-family:sans-serif,sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h5{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h6{font-family:sans-serif,sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#121a24;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#bcc9da}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px;width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#121a24;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#d9e1e8}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#9baec8}.landing-page__short-description h1 small span{color:#d9e1e8}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#121a24;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:sans-serif,sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#9baec8}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#9baec8}.landing .simple_form p.lead{color:#9baec8;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #202e3f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#3e5a7c}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #121a24;text-align:left;background:#0b1016}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #121a24;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#121a24}.table a{color:#d8a070;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#121a24;border-top:1px solid #040609;border-bottom:1px solid #040609}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #040609}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #040609}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:monospace,monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#9baec8;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #040609;background:#121a24;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #040609;border-top:0;background:#121a24}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #040609;border-top:0;background:#0b1016}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #040609}}.batch-table__row:hover{background:#0f151d}.batch-table__row:nth-child(even){background:#121a24}.batch-table__row:nth-child(even):hover{background:#151f2b}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#9baec8;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #040609;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #040609}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#121a24;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#202e3f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#9baec8;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#26374d}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#9baec8;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#0a0e13;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#0f151d;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#0b1016;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#d8a070;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#ddad84}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #202e3f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#d9e1e8;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#d9e1e8;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#9baec8;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #202e3f}.admin-wrapper .content h6{font-size:16px;color:#d9e1e8;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:none}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#d9e1e8;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #192432;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#d8a070}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#9baec8}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#d8a070}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#3e5a7c;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#9baec8;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #121a24}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #1b2635}.filters .filter-subset a.selected{color:#d8a070;border-bottom:2px solid #d8a070}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#d9e1e8}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#d8a070;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#121a24;color:#9baec8;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#3e5a7c}.log-entry__extras{background:#1c2938;border-radius:0 0 4px 4px;padding:10px;color:#9baec8;font-family:monospace,monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#3e5a7c}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#79bd9a}.log-entry__icon__overlay.negative{background:#e87487}.log-entry__icon__overlay.neutral{background:#d8a070}.log-entry a,.log-entry .username,.log-entry .target{color:#d9e1e8;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#e87487}.log-entry .diff-neutral{color:#d9e1e8}.log-entry .diff-new{color:#79bd9a}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#d9e1e8}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #d8a070}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#9baec8}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#3e5a7c}.report-card{background:#121a24;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#9baec8;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#b5c3d6}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #0b1016}.report-card__summary__item:hover{background:#151f2b}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#9baec8}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#3e5a7c;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#9baec8}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#202e3f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#e1b590}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff)}.hicolor-privacy-icons .status__visibility-icon.fa-globe,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-globe{color:#1976d2}.hicolor-privacy-icons .status__visibility-icon.fa-unlock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-unlock{color:#388e3c}.hicolor-privacy-icons .status__visibility-icon.fa-lock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-lock{color:#ffa000}.hicolor-privacy-icons .status__visibility-icon.fa-envelope,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-envelope{color:#d32f2f}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .composer--publisher{text-align:left}body.rtl .boost-modal__status-time,body.rtl .favourite-modal__status-time{float:left}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .setting-meta__label{float:left}body.rtl .status__avatar{margin-left:10px;margin-right:0;left:auto;right:10px}body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:58px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left;text-align:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(1, 1, 2, 0), #010102)}body.rtl .simple_form select{background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#192432;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#202e3f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:sans-serif,sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#9baec8;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a; // Padua\n$error-red: #df405a; // Cerise\n$warning-red: #ff5050; // Sunset Orange\n$gold-star: #ca8f04; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: sans-serif !default;\n$font-display: sans-serif !default;\n$font-monospace: monospace !default;\n\n// Avatar border size (8% default, 100% for rounded avatars)\n$ui-avatar-border-size: 8%;\n\n// More variables\n$dismiss-overlay-width: 4rem;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n @return '%23' + unquote($color)\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n @include avatar-size(40px);\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1/3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1/3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n @include avatar-size(120px);\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n @include avatar-radius();\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n","@mixin avatar-radius() {\n border-radius: $ui-avatar-border-size;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size:48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin single-column($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .single-column #{$parent} {\n @content;\n }\n}\n\n@mixin limited-single-column($media, $parent: '&') {\n .auto-columns #{$parent}, .single-column #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n}\n\n@mixin multi-columns($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .multi-columns #{$parent} {\n @content;\n }\n}\n\n@mixin fullwidth-gallery {\n &.full-width {\n margin-left: -14px;\n margin-right: -14px;\n width: inherit;\n max-width: none;\n height: 250px;\n border-radius: 0px;\n }\n}\n\n@mixin search-input() {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: none;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout() {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n padding: 8px 0;\n padding-bottom: 2px;\n margin: initial;\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n position: absolute;\n margin: initial;\n float: initial;\n width: auto;\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n\n// Styling from upstream's WebUI, as public pages use the same layout\n.embed,\n.public-layout {\n .status {\n .status__info {\n font-size: 15px;\n display: initial;\n }\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding: 6px 0;\n padding-right: 25px;\n margin: initial;\n\n .display-name strong {\n display: inline;\n }\n }\n\n .status__avatar {\n height: 48px;\n position: absolute;\n width: 48px;\n margin: initial;\n }\n }\n}\n\n.rtl {\n .embed,\n .public-layout {\n .status {\n padding-left: 10px;\n padding-right: 68px;\n\n .status__info .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .status__relative-time {\n float: left;\n }\n }\n }\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: darken($ui-highlight-color, 3%);\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n transition-property: background-color;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 7%);\n transition: all 200ms ease-out;\n transition-property: background-color;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n color: $darker-text-color;\n text-transform: none;\n background: transparent;\n padding: 3px 15px;\n border-radius: 4px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n transform-origin: 50% 0;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: 0;\n position: absolute;\n\n .fa.star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n a {\n color: inherit;\n text-decoration: inherit;\n }\n\n strong {\n height: 18px;\n font-size: 16px;\n font-weight: 500;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n span {\n display: block;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n > a:hover {\n strong {\n text-decoration: underline;\n }\n }\n\n &.inline {\n padding: 0;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n strong {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n\n span {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n }\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n ul {\n list-style: none;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.static-content {\n padding: 10px;\n padding-top: 20px;\n color: $dark-text-color;\n\n h1 {\n font-size: 16px;\n font-weight: 500;\n margin-bottom: 40px;\n text-align: center;\n }\n\n p {\n font-size: 13px;\n margin-bottom: 20px;\n }\n}\n\n.column,\n.drawer {\n flex: 1 1 100%;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @include multi-columns('screen and (min-width: 631px)') {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $ui-highlight-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n\n span.icon {\n margin-left: 0;\n display: inline;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.getting-started__wrapper,\n.getting_started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.getting-started__wrapper {\n position: relative;\n overflow-y: auto;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n background: $ui-base-color;\n flex: 1 0 auto;\n\n p {\n color: $secondary-text-color;\n }\n\n a {\n color: $dark-text-color;\n }\n\n &__panel {\n height: min-content;\n }\n\n &__panel,\n &__footer {\n padding: 10px;\n padding-top: 20px;\n flex: 0 1 auto;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n color: $darker-text-color;\n background: transparent;\n border: none;\n border-bottom: 2px solid $ui-primary-color;\n box-sizing: border-box;\n display: block;\n font-family: inherit;\n margin-bottom: 10px;\n padding: 7px 0;\n width: 100%;\n\n &:focus,\n &:active {\n color: $primary-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n font-size: 16px;\n }\n\n &.light {\n color: $inverted-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 27%);\n\n &:focus,\n &:active {\n color: $inverted-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.reduce-motion button.icon-button.disabled i.fa-retweet {\n color: darken($action-button-color, 13%);\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.missing-indicator {\n padding-top: 20px + 48px;\n}\n\n.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {\n border-top: 1px solid $ui-base-color;\n}\n\n.notification__dismiss-overlay {\n overflow: hidden;\n position: absolute;\n top: 0;\n right: 0;\n bottom: -1px;\n padding-left: 15px; // space for the box shadow to be visible\n\n z-index: 999;\n align-items: center;\n justify-content: flex-end;\n cursor: pointer;\n\n display: flex;\n\n .wrappy {\n width: $dismiss-overlay-width;\n align-self: stretch;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: lighten($ui-base-color, 8%);\n border-left: 1px solid lighten($ui-base-color, 20%);\n box-shadow: 0 0 5px black;\n border-bottom: 1px solid $ui-base-color;\n }\n\n .ckbox {\n border: 2px solid $ui-primary-color;\n border-radius: 2px;\n width: 30px;\n height: 30px;\n font-size: 20px;\n color: $darker-text-color;\n text-shadow: 0 0 5px black;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n &:focus {\n outline: 0 !important;\n\n .ckbox {\n box-shadow: 0 0 1px 1px $ui-highlight-color;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: flex;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n align-items: center;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label,\n.setting-radio__label,\n.setting-meta__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.setting-radio {\n display: block;\n line-height: 18px;\n}\n\n.setting-radio__label {\n margin-bottom: 0;\n}\n\n.column-settings__row legend {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-top: 10px;\n}\n\n.setting-radio__input {\n vertical-align: middle;\n}\n\n.setting-meta__label {\n float: right;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n transform-origin: center center;\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.pulse-loading {\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.loading-bar {\n background-color: $ui-highlight-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.icon-badge-wrapper {\n position: relative;\n}\n\n.icon-badge {\n position: absolute;\n display: block;\n right: -.25em;\n top: -.25em;\n background-color: $ui-highlight-color;\n border-radius: 50%;\n font-size: 75%;\n width: 1em;\n height: 1em;\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n .status__content {\n margin: 0;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.ui .flash-message {\n margin-top: 10px;\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 0;\n min-width: 75%;\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@import 'boost';\n@import 'accounts';\n@import 'domains';\n@import 'status';\n@import 'modal';\n@import 'composer';\n@import 'columns';\n@import 'regeneration_indicator';\n@import 'directory';\n@import 'search';\n@import 'emoji';\n@import 'doodle';\n@import 'drawer';\n@import 'media';\n@import 'sensitive';\n@import 'lists';\n@import 'emoji_picker';\n@import 'local_settings';\n@import 'error_boundary';\n@import 'single_column';\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant\nbutton.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant for use with DMs\n.status-direct button.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n",".account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n color: inherit;\n text-decoration: none;\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n\n &.small {\n border: none;\n padding: 0;\n\n & > .account__avatar-wrapper { margin: 0 8px 0 0 }\n\n & > .display-name {\n height: 24px;\n line-height: 24px;\n }\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius();\n position: relative;\n cursor: pointer;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & div {\n @include avatar-radius;\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\n.account__avatar-overlay {\n position: relative;\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius();\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius();\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__header__wrapper {\n flex: 0 0 auto;\n background: lighten($ui-base-color, 4%);\n}\n\n.account__disclaimer {\n padding: 10px;\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-left: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &:first-child {\n border-left: 0;\n }\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n abbr {\n color: $highlight-text-color;\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.notification__message {\n margin-left: 42px;\n padding: 8px 0 0 26px;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input();\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout();\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n",".domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n","@keyframes spring-flip-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-242.4deg);\n }\n\n 60% {\n transform: rotate(-158.35deg);\n }\n\n 90% {\n transform: rotate(-187.5deg);\n }\n\n 100% {\n transform: rotate(-180deg);\n }\n}\n\n@keyframes spring-flip-out {\n 0% {\n transform: rotate(-180deg);\n }\n\n 30% {\n transform: rotate(62.4deg);\n }\n\n 60% {\n transform: rotate(-21.635deg);\n }\n\n 90% {\n transform: rotate(7.5deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content {\n position: relative;\n margin: 10px 0;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n padding-top: 5px;\n\n &:focus {\n outline: 0;\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .status__content__text,\n .e-content {\n overflow: hidden;\n\n & > ul,\n & > ol {\n margin-bottom: 20px;\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 1.2em;\n }\n\n h2 {\n font-size: 1.1em;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $darker-text-color;\n color: $darker-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n sup {\n font-size: smaller;\n vertical-align: super;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n .status__content__spoiler {\n display: none;\n\n &.status__content__spoiler--visible {\n display: block;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n\n .link-origin-tag {\n color: $gold-star;\n font-size: 0.8em;\n }\n }\n\n .status__content__spoiler-link {\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: lighten($ui-base-color, 30%);\n border: none;\n color: $inverted-text-color;\n font-weight: 500;\n font-size: 11px;\n padding: 0 5px;\n text-transform: uppercase;\n line-height: inherit;\n cursor: pointer;\n vertical-align: bottom;\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n\n .status__content__spoiler-icon {\n display: inline-block;\n margin: 0 0 0 5px;\n border-left: 1px solid currentColor;\n padding: 0 0 0 4px;\n font-size: 16px;\n vertical-align: -2px;\n }\n}\n\n.notif-cleaning {\n .status,\n .notification-follow,\n .notification-follow-request {\n padding-right: ($dismiss-overlay-width + 0.5rem);\n }\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.notification-follow,\n.notification-follow-request {\n position: relative;\n\n // same like Status\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .account {\n border-bottom: 0 none;\n }\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n &.status.status-direct:not(.read) {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 10px 14px;\n position: relative;\n height: auto;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 28px; // 12px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $lighter-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $lighter-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n\n &.collapsed {\n background-position: center;\n background-size: cover;\n user-select: none;\n\n &.has-background::before {\n display: block;\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));\n pointer-events: none;\n content: \"\";\n }\n\n .display-name:hover .display-name__html {\n text-decoration: none;\n }\n\n .status__content {\n height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 0;\n\n &:after {\n content: \"\";\n position: absolute;\n top: 0; bottom: 0;\n left: 0; right: 0;\n background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));\n pointer-events: none;\n }\n \n a:hover {\n text-decoration: none;\n }\n }\n &:focus > .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));\n }\n &.status-direct:not(.read)> .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));\n }\n\n .notification__message {\n margin-bottom: 0;\n }\n\n .status__info .notification__message > span {\n white-space: nowrap;\n }\n }\n\n .notification__message {\n margin: -10px 0px 10px 0;\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time {\n display: inline-block;\n flex-grow: 1;\n color: $dark-text-color;\n font-size: 14px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.status__display-name {\n color: $dark-text-color;\n overflow: hidden;\n}\n\n.status__info__account .status__display-name {\n display: block;\n max-width: 100%;\n}\n\n.status__info {\n display: flex;\n justify-content: space-between;\n font-size: 15px;\n\n > span {\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .notification__message > span {\n word-wrap: break-word;\n }\n}\n\n.status__info__icons {\n display: flex;\n align-items: center;\n height: 1em;\n color: $action-button-color;\n\n .status__media-icon,\n .status__visibility-icon,\n .status__reply-icon {\n padding-left: 2px;\n padding-right: 2px;\n }\n\n .status__collapse-button.active > .fa-angle-double-up {\n transform: rotate(-180deg);\n }\n}\n\n.no-reduce-motion .status__collapse-button {\n &.activate {\n & > .fa-angle-double-up {\n animation: spring-flip-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-angle-double-up {\n animation: spring-flip-out 1s linear;\n }\n }\n}\n\n.status__info__account {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-top: -10px;\n margin-bottom: 10px;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\na.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\n.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n flex: none;\n margin: 0 10px 0 0;\n height: 48px;\n width: 48px;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a,\n .status__content__text {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-color, 29%);\n text-decoration: none;\n }\n }\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n\n a .fa, a:hover .fa {\n color: inherit;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n.status__wrapper--filtered__button {\n display: inline;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n font-size: inherit;\n line-height: inherit;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.onboarding-modal__pager {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 470px;\n\n .react-swipeable-view-container > div {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n user-select: text;\n }\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n@media screen and (max-width: 550px) {\n .onboarding-modal {\n width: 100%;\n height: 100%;\n border-radius: 0;\n }\n\n .onboarding-modal__pager {\n width: 100%;\n height: auto;\n max-width: none;\n max-height: none;\n flex: 1 1 auto;\n }\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.onboarding-modal__dots {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.onboarding-modal__dot {\n width: 14px;\n height: 14px;\n border-radius: 14px;\n background: darken($ui-secondary-color, 16%);\n margin: 0 3px;\n cursor: pointer;\n\n &:hover {\n background: darken($ui-secondary-color, 18%);\n }\n\n &.active {\n cursor: default;\n background: darken($ui-secondary-color, 24%);\n }\n}\n\n.onboarding-modal__page__wrapper {\n pointer-events: none;\n padding: 25px;\n padding-bottom: 0;\n\n &.onboarding-modal__page__wrapper--active {\n pointer-events: auto;\n }\n}\n\n.onboarding-modal__page {\n cursor: default;\n line-height: 21px;\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 20px;\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 4%);\n }\n }\n\n .navigation-bar a {\n color: inherit;\n }\n\n p {\n font-size: 16px;\n color: $lighter-text-color;\n margin-top: 10px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n background: $ui-base-color;\n color: $secondary-text-color;\n border-radius: 4px;\n font-size: 14px;\n padding: 3px 6px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.onboarding-modal__page__wrapper-0 {\n height: 100%;\n padding: 0;\n}\n\n.onboarding-modal__page-one {\n &__lead {\n padding: 65px;\n padding-top: 45px;\n padding-bottom: 0;\n margin-bottom: 10px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n margin-bottom: 8px;\n }\n\n p {\n margin-bottom: 0;\n }\n }\n\n &__extra {\n padding-right: 65px;\n padding-left: 185px;\n text-align: center;\n }\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboarding-modal__page-two,\n.onboarding-modal__page-three,\n.onboarding-modal__page-four,\n.onboarding-modal__page-five {\n p {\n text-align: left;\n }\n\n .figure {\n background: darken($ui-base-color, 8%);\n color: $secondary-text-color;\n margin-bottom: 20px;\n border-radius: 4px;\n padding: 10px;\n text-align: center;\n font-size: 14px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);\n\n .onboarding-modal__image {\n border-radius: 4px;\n margin-bottom: 10px;\n }\n\n &.non-interactive {\n pointer-events: none;\n text-align: left;\n }\n }\n}\n\n.onboarding-modal__page-four__columns {\n .row {\n display: flex;\n margin-bottom: 20px;\n\n & > div {\n flex: 1 1 0;\n margin: 0 10px;\n\n &:first-child {\n margin-left: 0;\n }\n\n &:last-child {\n margin-right: 0;\n }\n\n p {\n text-align: center;\n }\n }\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .column-header {\n color: $primary-text-color;\n }\n}\n\n@media screen and (max-width: 320px) and (max-height: 600px) {\n .onboarding-modal__page p {\n font-size: 14px;\n line-height: 20px;\n }\n\n .onboarding-modal__page-two .figure,\n .onboarding-modal__page-three .figure,\n .onboarding-modal__page-four .figure,\n .onboarding-modal__page-five .figure {\n font-size: 12px;\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .row {\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .column-header {\n padding: 5px;\n font-size: 12px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.favourite-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__display-name {\n display: flex;\n }\n\n .status__avatar {\n height: 48px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container,\n.favourite-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.favourite-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header,\n.favourite-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time,\n.favourite-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n strong {\n display: block;\n font-weight: 500;\n }\n\n max-height: 80vh;\n max-width: 80vw;\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n & > .react-toggle,\n & > .icon,\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__do_not_ask_again {\n padding-left: 20px;\n padding-right: 20px;\n padding-bottom: 10px;\n\n font-size: 14px;\n\n label, input {\n vertical-align: middle;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: 'mastodon-font-monospace', monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.filtered-status-info {\n text-align: start;\n\n .spoiler__text {\n margin-top: 20px;\n }\n\n .account {\n border-bottom: 0;\n }\n\n .account__display-name strong {\n color: $inverted-text-color;\n }\n\n .status__content__spoiler {\n display: none;\n\n &--visible {\n display: flex;\n }\n }\n\n ul {\n padding: 10px;\n margin-left: 12px;\n list-style: disc inside;\n }\n\n .filtered-status-edit-link {\n color: $action-button-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline\n }\n }\n}\n",".composer {\n padding: 10px;\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .composer--spoiler {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.composer--spoiler {\n height: 0;\n transform-origin: bottom;\n opacity: 0.0;\n\n &.composer--spoiler--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1.0;\n }\n\n input {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px;\n padding: 10px;\n width: 100%;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: vertical;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n }\n}\n\n.composer--warning {\n color: $inverted-text-color;\n margin-bottom: 15px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:active,\n &:focus,\n &:hover { text-decoration: none }\n }\n}\n\n.compose-form__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-left: 5px;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n}\n\n.composer--reply {\n margin: 0 0 10px;\n border-radius: 4px;\n padding: 10px;\n background: $ui-primary-color;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n\n & > header {\n margin-bottom: 5px;\n overflow: hidden;\n\n & > .account.small { color: $inverted-text-color; }\n\n & > .cancel {\n float: right;\n line-height: 24px;\n }\n }\n\n & > .content {\n position: relative;\n margin: 10px 0;\n padding: 0 12px;\n font-size: 14px;\n line-height: 20px;\n color: $inverted-text-color;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n white-space: pre-wrap;\n padding-top: 5px;\n overflow: hidden;\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 18px;\n }\n\n h2 {\n font-size: 16px;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $inverted-text-color;\n color: $inverted-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n\n a {\n color: $lighter-text-color;\n text-decoration: none;\n\n &:hover { text-decoration: underline }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span { text-decoration: underline }\n }\n }\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -5px 0 0;\n }\n}\n\n.emoji-picker-dropdown {\n position: absolute;\n right: 5px;\n top: 5px;\n\n ::-webkit-scrollbar-track:hover,\n ::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.autosuggest-input {\n position: relative;\n width: 100%;\n\n label {\n .autosuggest-textarea__textarea {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px 4px 0 0;\n padding: 10px 32px 0 10px;\n width: 100%;\n min-height: 100px;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: none;\n scrollbar-color: initial;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n &:disabled { background: $ui-secondary-color }\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n }\n}\n\n.composer--textarea--icons {\n display: block;\n position: absolute;\n top: 29px;\n right: 5px;\n bottom: 5px;\n overflow: hidden;\n\n & > .textarea_icon {\n display: block;\n margin: 2px 0 0 2px;\n width: 24px;\n height: 24px;\n color: $lighter-text-color;\n font-size: 18px;\n line-height: 24px;\n text-align: center;\n opacity: .8;\n }\n}\n\n.autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n}\n\n.autosuggest-textarea__suggestions {\n display: block;\n position: absolute;\n box-sizing: border-box;\n top: 100%;\n border-radius: 0 0 4px 4px;\n padding: 6px;\n width: 100%;\n color: $inverted-text-color;\n background: $ui-secondary-color;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n font-size: 14px;\n z-index: 99;\n display: none;\n}\n\n.autosuggest-textarea__suggestions--visible {\n display: block;\n}\n\n.autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected { background: darken($ui-secondary-color, 10%) }\n\n > .account,\n > .emoji,\n > .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n & > .account.small {\n .display-name {\n & > span { color: $lighter-text-color }\n }\n }\n}\n\n.composer--upload_form {\n overflow: hidden;\n\n & > .content {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n font-family: inherit;\n padding: 5px;\n overflow: hidden;\n }\n}\n\n.composer--upload_form--item {\n flex: 1 1 0;\n margin: 5px;\n min-width: 40%;\n\n & > div {\n position: relative;\n border-radius: 4px;\n height: 140px;\n width: 100%;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n overflow: hidden;\n\n textarea {\n display: block;\n position: absolute;\n box-sizing: border-box;\n bottom: 0;\n left: 0;\n margin: 0;\n border: 0;\n padding: 10px;\n width: 100%;\n color: $secondary-text-color;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n font-size: 14px;\n font-family: inherit;\n font-weight: 500;\n opacity: 0;\n z-index: 2;\n transition: opacity .1s ease;\n\n &:focus { color: $white }\n\n &::placeholder {\n opacity: 0.54;\n color: $secondary-text-color;\n }\n }\n\n & > .close { mix-blend-mode: difference }\n }\n\n &.active {\n & > div {\n textarea { opacity: 1 }\n }\n }\n}\n\n.composer--upload_form--actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $ui-secondary-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($ui-secondary-color, 4%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n}\n\n.composer--upload_form--progress {\n display: flex;\n padding: 10px;\n color: $darker-text-color;\n overflow: hidden;\n\n & > .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n & > .message {\n flex: 1 1 auto;\n\n & > span {\n display: block;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n & > .backdrop {\n position: relative;\n margin-top: 5px;\n border-radius: 6px;\n width: 100%;\n height: 6px;\n background: $ui-base-lighter-color;\n\n & > .tracker {\n position: absolute;\n top: 0;\n left: 0;\n height: 6px;\n border-radius: 6px;\n background: $ui-highlight-color;\n }\n }\n }\n}\n\n.compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n}\n\n.composer--options-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n height: 27px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n}\n\n.composer--options {\n display: flex;\n flex: 0 0 auto;\n\n & > * {\n display: inline-block;\n box-sizing: content-box;\n padding: 0 3px;\n height: 27px;\n line-height: 27px;\n vertical-align: bottom;\n }\n\n & > hr {\n display: inline-block;\n margin: 0 3px;\n border-width: 0 0 0 1px;\n border-style: none none none solid;\n border-color: transparent transparent transparent darken($simple-background-color, 24%);\n padding: 0;\n width: 0;\n height: 27px;\n background: transparent;\n }\n}\n\n.compose--counter-wrapper {\n align-self: center;\n margin-right: 4px;\n}\n\n.composer--options--dropdown {\n &.open {\n & > .value {\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n color: $primary-text-color;\n background: $ui-highlight-color;\n transition: none;\n }\n &.top {\n & > .value {\n border-radius: 0 0 4px 4px;\n box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);\n }\n }\n }\n}\n\n.composer--options--dropdown--content {\n position: absolute;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n background: $simple-background-color;\n overflow: hidden;\n transform-origin: 50% 0;\n}\n\n.composer--options--dropdown--content--item {\n display: flex;\n align-items: center;\n padding: 10px;\n color: $inverted-text-color;\n cursor: pointer;\n\n & > .content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n &:not(:first-child) { margin-left: 10px }\n\n strong {\n display: block;\n color: $inverted-text-color;\n font-weight: 500;\n }\n }\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n\n & > .content {\n color: $primary-text-color;\n\n strong { color: $primary-text-color }\n }\n }\n\n &.active:hover { background: lighten($ui-highlight-color, 4%) }\n}\n\n.composer--publisher {\n padding-top: 10px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n justify-content: flex-end;\n flex: 0 0 auto;\n\n & > .primary {\n display: inline-block;\n margin: 0;\n padding: 0 10px;\n text-align: center;\n }\n\n & > .side_arm {\n display: inline-block;\n margin: 0 2px;\n padding: 0;\n width: 36px;\n text-align: center;\n }\n\n &.over {\n & > .count { color: $warning-red }\n }\n}\n",".column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.column {\n overflow: hidden;\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n\n & > button {\n margin: 0;\n border: none;\n padding: 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n\n .wide .columns-area:not(.columns-area--mobile) & {\n flex: auto;\n min-width: 330px;\n max-width: 400px;\n }\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n margin-left: 0;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n\n // glitch - added focus ring for keyboard navigation\n &:focus {\n text-shadow: 0 0 4px darken($ui-highlight-color, 5%);\n }\n}\n\n.column-header__notif-cleaning-buttons {\n display: flex;\n align-items: stretch;\n justify-content: space-around;\n\n button {\n @extend .column-header__button;\n background: transparent;\n text-align: center;\n padding: 10px 0;\n white-space: pre-wrap;\n }\n\n b {\n font-weight: bold;\n }\n}\n\n// The notifs drawer with no padding to have more space for the buttons\n.column-header__collapsible-inner.nopad-drawer {\n padding: 0;\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n\n // notif cleaning drawer\n &.ncd {\n transition: none;\n &.collapsed {\n max-height: 0;\n opacity: 0.7;\n }\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.column-header__title {\n display: inline-block;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n// more fixes for the navbar-under mode\n@mixin fix-margins-for-navbar-under {\n .tabs-bar {\n margin-top: 0 !important;\n margin-bottom: -6px !important;\n }\n}\n\n.single-column.navbar-under {\n @include fix-margins-for-navbar-under;\n}\n\n.auto-columns.navbar-under {\n @media screen and (max-width: $no-gap-breakpoint) {\n @include fix-margins-for-navbar-under;\n }\n}\n\n.auto-columns.navbar-under .react-swipeable-view-container .columns-area,\n.single-column.navbar-under .react-swipeable-view-container .columns-area {\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 100% !important;\n }\n}\n\n.column-inline-form {\n padding: 7px 15px;\n padding-right: 5px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n margin-bottom: 6px;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 5px;\n }\n}\n",".regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n",".directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n",".search {\n position: relative;\n}\n\n.search__input {\n @include search-input();\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: color, transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(0deg);\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n padding: 15px 10px;\n font-size: 14px;\n font-weight: 500;\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n",null,".emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n","$doodleBg: #d9e1e8;\n.doodle-modal {\n @extend .boost-modal;\n width: unset;\n}\n\n.doodle-modal__container {\n background: $doodleBg;\n text-align: center;\n line-height: 0; // remove weird gap under canvas\n canvas {\n border: 5px solid $doodleBg;\n }\n}\n\n.doodle-modal__action-bar {\n @extend .boost-modal__action-bar;\n\n .filler {\n flex-grow: 1;\n margin: 0;\n padding: 0;\n }\n\n .doodle-toolbar {\n line-height: 1;\n\n display: flex;\n flex-direction: column;\n flex-grow: 0;\n justify-content: space-around;\n\n &.with-inputs {\n label {\n display: inline-block;\n width: 70px;\n text-align: right;\n margin-right: 2px;\n }\n\n input[type=\"number\"],input[type=\"text\"] {\n width: 40px;\n }\n span.val {\n display: inline-block;\n text-align: left;\n width: 50px;\n }\n }\n }\n\n .doodle-palette {\n padding-right: 0 !important;\n border: 1px solid black;\n line-height: .2rem;\n flex-grow: 0;\n background: white;\n\n button {\n appearance: none;\n width: 1rem;\n height: 1rem;\n margin: 0; padding: 0;\n text-align: center;\n color: black;\n text-shadow: 0 0 1px white;\n cursor: pointer;\n box-shadow: inset 0 0 1px rgba(white, .5);\n border: 1px solid black;\n outline-offset:-1px;\n\n &.foreground {\n outline: 1px dashed white;\n }\n\n &.background {\n outline: 1px dashed red;\n }\n\n &.foreground.background {\n outline: 1px dashed red;\n border-color: white;\n }\n }\n }\n}\n",".drawer {\n width: 300px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n padding: 10px 5px;\n flex: none;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n\n @include single-column('screen and (max-width: 630px)') { flex: auto }\n\n @include limited-single-column('screen and (max-width: 630px)') {\n &, &:first-child, &:last-child { padding: 0 }\n }\n\n .wide & {\n min-width: 300px;\n max-width: 400px;\n flex: 1 1 200px;\n }\n\n @include single-column('screen and (max-width: 630px)') {\n :root & { // Overrides `.wide` for single-column view\n flex: auto;\n width: 100%;\n min-width: 0;\n max-width: none;\n padding: 0;\n }\n }\n\n .react-swipeable-view-container & { height: 100% }\n}\n\n.drawer--header {\n display: flex;\n flex-direction: row;\n margin-bottom: 10px;\n flex: none;\n background: lighten($ui-base-color, 8%);\n font-size: 16px;\n\n & > * {\n display: block;\n box-sizing: border-box;\n border-bottom: 2px solid transparent;\n padding: 15px 5px 13px;\n height: 48px;\n flex: 1 1 auto;\n color: $darker-text-color;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n }\n\n a {\n transition: background 100ms ease-in;\n\n &:focus,\n &:hover {\n outline: none;\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.search {\n position: relative;\n margin-bottom: 10px;\n flex: none;\n\n @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n}\n\n.search-popout {\n @include search-popout();\n}\n\n.drawer--account {\n padding: 10px;\n color: $darker-text-color;\n display: flex;\n align-items: center;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n .acct {\n display: block;\n color: $secondary-text-color;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n overflow: hidden;\n}\n\n.drawer--results {\n background: $ui-base-color;\n overflow-x: hidden;\n overflow-y: auto;\n\n & > header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n & > section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n\n & > .hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n }\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n > .mastodon {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n cursor: inherit;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n",".video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n width: 100%;\n height: 100%;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 8%);\n }\n\n .status__content > & {\n margin-top: 15px; // Add margin when used bare for NSFW video player\n }\n @include fullwidth-gallery;\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 500;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n height: 100%;\n display: flex;\n flex-direction: column;\n\n span {\n text-align: center;\n color: $darker-text-color;\n display: flex;\n height: 100%;\n align-items: center;\n\n p {\n width: 100%;\n }\n }\n\n audio {\n width: 100%;\n }\n}\n\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n height: 110px;\n\n @include fullwidth-gallery;\n}\n\n.media-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n .full-width & {\n border-radius: 0;\n }\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n\n &.letterbox {\n background: $base-shadow-color;\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n object-fit: contain;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n display: flex;\n justify-content: center;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n width: 100%;\n position: relative;\n z-index: 1;\n object-fit: contain;\n user-select: none;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $white;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $ui-highlight-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n .detailed-status & {\n width: 100%;\n height: 100%;\n }\n\n @include fullwidth-gallery;\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n position: relative;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-shadow-color;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n\n .fa,\n &:active .fa,\n &:hover .fa,\n &:focus .fa {\n color: inherit;\n }\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n",".sensitive-info {\n display: flex;\n flex-direction: row;\n align-items: center;\n position: absolute;\n top: 4px;\n left: 4px;\n z-index: 100;\n}\n\n.sensitive-marker {\n margin: 0 3px;\n border-radius: 2px;\n padding: 2px 6px;\n color: rgba($primary-text-color, 0.8);\n background: rgba($base-overlay-background, 0.5);\n font-size: 12px;\n line-height: 18px;\n text-transform: uppercase;\n opacity: .9;\n transition: opacity .1s ease;\n\n .media-gallery:hover & { opacity: 1 }\n}\n",".list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n",".emoji-mart {\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: 0;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -3px;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: darken($ui-highlight-color, 3%);\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n",".glitch.local-settings {\n position: relative;\n display: flex;\n flex-direction: row;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n height: 80vh;\n width: 80vw;\n max-width: 740px;\n max-height: 450px;\n overflow: hidden;\n\n label, legend {\n display: block;\n font-size: 14px;\n }\n\n .boolean label, .radio_buttons label {\n position: relative;\n padding-left: 28px;\n padding-top: 3px;\n\n input {\n position: absolute;\n left: 0;\n top: 0;\n }\n }\n\n span.hint {\n display: block;\n color: $lighter-text-color;\n }\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n line-height: 24px;\n margin-bottom: 20px;\n }\n\n h2 {\n font-size: 15px;\n font-weight: 500;\n line-height: 20px;\n margin-top: 20px;\n margin-bottom: 10px;\n }\n}\n\n.glitch.local-settings__navigation__item {\n display: block;\n padding: 15px 20px;\n color: inherit;\n background: lighten($ui-secondary-color, 8%);\n border-bottom: 1px $ui-secondary-color solid;\n cursor: pointer;\n text-decoration: none;\n outline: none;\n transition: background .3s;\n\n .text-icon-button {\n color: inherit;\n transition: unset;\n }\n\n &:hover {\n background: $ui-secondary-color;\n }\n\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n\n &.close, &.close:hover {\n background: $error-value-color;\n color: $primary-text-color;\n }\n}\n\n.glitch.local-settings__navigation {\n background: lighten($ui-secondary-color, 8%);\n width: 212px;\n font-size: 15px;\n line-height: 20px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page {\n display: block;\n flex: auto;\n padding: 15px 20px 15px 20px;\n width: 360px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page__item {\n margin-bottom: 2px;\n}\n\n.glitch.local-settings__page__item.string,\n.glitch.local-settings__page__item.radio_buttons {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n@media screen and (max-width: 630px) {\n .glitch.local-settings__navigation {\n width: 40px;\n flex-shrink: 0;\n }\n\n .glitch.local-settings__navigation__item {\n padding: 10px;\n\n span:last-of-type {\n display: none;\n }\n }\n}\n",".error-boundary {\n color: $primary-text-color;\n font-size: 15px;\n line-height: 20px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n font-weight: 400;\n margin-bottom: 8px;\n }\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n }\n\n ul {\n list-style: disc;\n margin-left: 0;\n padding-left: 1em;\n }\n\n textarea.web_app_crash-stacktrace {\n width: 100%;\n resize: none;\n white-space: pre;\n font-family: $font-monospace, monospace;\n }\n}\n",".compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .drawer--account {\n flex: 0 1 48px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .composer {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px;\n min-height: 48px + 2px;\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n padding-top: 15px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .search {\n margin-bottom: 10px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n ul,\n .e-content & ul {\n margin: 0;\n list-style: none;\n }\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n display: block;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n width: 100%;\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n @include avatar-size(80px);\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n @include avatar-radius();\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n @include avatar-size(44px);\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: none;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n","$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;\n\n%emoji-outline {\n filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);\n}\n\n.emojione {\n @each $emoji in $emojis-requiring-outlines {\n &[title=':#{$emoji}:'] {\n @extend %emoji-outline;\n }\n }\n}\n\n.hicolor-privacy-icons {\n .status__visibility-icon.fa-globe,\n .composer--options--dropdown--content--item .fa-globe {\n color: #1976D2;\n }\n\n .status__visibility-icon.fa-unlock,\n .composer--options--dropdown--content--item .fa-unlock {\n color: #388E3C;\n }\n\n .status__visibility-icon.fa-lock,\n .composer--options--dropdown--content--item .fa-lock {\n color: #FFA000;\n }\n\n .status__visibility-icon.fa-envelope,\n .composer--options--dropdown--content--item .fa-envelope {\n color: #D32F2F;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .composer--publisher {\n text-align: left;\n }\n\n .boost-modal__status-time,\n .favourite-modal__status-time {\n float: left;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .setting-meta__label {\n float: left;\n }\n\n .status__avatar {\n margin-left: 10px;\n margin-right: 0;\n\n // Those are used for public pages\n left: auto;\n right: 10px;\n }\n\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 58px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n text-align: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///index.scss","webpack:///./app/javascript/flavours/glitch/styles/reset.scss","webpack:///./app/javascript/flavours/glitch/styles/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/basics.scss","webpack:///./app/javascript/flavours/glitch/styles/containers.scss","webpack:///./app/javascript/flavours/glitch/styles/_mixins.scss","webpack:///./app/javascript/flavours/glitch/styles/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/footer.scss","webpack:///./app/javascript/flavours/glitch/styles/compact_header.scss","webpack:///./app/javascript/flavours/glitch/styles/widgets.scss","webpack:///./app/javascript/flavours/glitch/styles/forms.scss","webpack:///./app/javascript/flavours/glitch/styles/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/statuses.scss","webpack:///./app/javascript/flavours/glitch/styles/components/index.scss","webpack:///./app/javascript/flavours/glitch/styles/components/boost.scss","webpack:///./app/javascript/flavours/glitch/styles/components/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/components/domains.scss","webpack:///./app/javascript/flavours/glitch/styles/components/status.scss","webpack:///./app/javascript/flavours/glitch/styles/components/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/components/composer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/columns.scss","webpack:///./app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss","webpack:///./app/javascript/flavours/glitch/styles/components/directory.scss","webpack:///./app/javascript/flavours/glitch/styles/components/search.scss","webpack:///","webpack:///./app/javascript/flavours/glitch/styles/components/emoji.scss","webpack:///./app/javascript/flavours/glitch/styles/components/doodle.scss","webpack:///./app/javascript/flavours/glitch/styles/components/drawer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/media.scss","webpack:///./app/javascript/flavours/glitch/styles/components/sensitive.scss","webpack:///./app/javascript/flavours/glitch/styles/components/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/components/emoji_picker.scss","webpack:///./app/javascript/flavours/glitch/styles/components/local_settings.scss","webpack:///./app/javascript/flavours/glitch/styles/components/error_boundary.scss","webpack:///./app/javascript/flavours/glitch/styles/components/single_column.scss","webpack:///./app/javascript/flavours/glitch/styles/components/announcements.scss","webpack:///./app/javascript/flavours/glitch/styles/polls.scss","webpack:///./app/javascript/flavours/glitch/styles/about.scss","webpack:///./app/javascript/flavours/glitch/styles/tables.scss","webpack:///./app/javascript/flavours/glitch/styles/admin.scss","webpack:///./app/javascript/flavours/glitch/styles/accessibility.scss","webpack:///./app/javascript/flavours/glitch/styles/rtl.scss","webpack:///./app/javascript/flavours/glitch/styles/dashboard.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CCtEW,iED6Eb,kBC7Ea,4BDiFb,sBACE,MEtFF,sBACE,mBACA,eACA,iBACA,gBACA,WDVM,kCCYN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,sIAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBD5CW,kBCgDX,iBACE,kBACA,0BAEA,iBACE,YAIJ,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDtEoB,mBAPX,WCgFT,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,wBAEA,aACE,gBACA,WACA,YACA,kBACA,uBAGF,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,6BAKF,YAEE,WACA,mBACA,uBACA,oBACA,yEAKF,gBAEE,+EAKF,WAEE,gBCrJJ,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFrBI,YEuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF9BE,qBEgCF,UACA,kBACA,iBACA,uBACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAKA,UACqB,sCC3EvB,iBD4EE,6BAEA,UACE,YACA,cACA,SACA,kBACA,iBF5BkB,wBG9DtB,4BACA,uBD8FA,aACE,cF/EsB,wBEiFtB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UF1UA,qCE6UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF/UkB,mBEiVlB,kBACA,uHAEA,yBAGE,WFvWA,qCE2WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFxaoB,8CE6atB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,CAEA,WACqB,yCCzgB3B,kBD0gBM,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,iBF7dc,wBG9DtB,4BACA,qCD+hBI,2CAvCF,YAwCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAKA,UACqB,sCCtiB7B,CDuiBQ,sBACA,wDAEA,QACE,kBACA,iBFrfY,wBG9DtB,4BACA,2DDsjBQ,mDAbF,YAcI,sCAKN,2CApEF,eAqEI,sCAGF,2CAxEF,cAyEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WFhlBF,gBEklBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WF1lBJ,gBE4lBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aFlmBY,oDEymBlB,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cF9nBc,aEgoBd,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BF/pBc,wEEqqBd,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFzsBJ,uBE2sBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cF/tBgB,uDEkuBhB,oBACE,cFnuBc,qBEquBd,aACA,gBACA,8DAEA,eACE,WF1vBJ,qCEgwBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aF3yBU,8DEizBV,mBACA,WFnzBE,qFEuzBJ,YAEE,eACA,cF1yBkB,2CE8yBpB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBFj3BK,+IEo3BH,kBAGE,WEl4BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,eChBJ,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,yBCrBF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cNhFkB,6BMmFlB,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cPeoB,gBObpB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cPFoB,wBOMtB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBRPI,uBQUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBRVW,aQYT,0BACA,eACA,cRPoB,iBQSpB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aRtCsB,qBQwCpB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cR9DoB,+BQkEtB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aR3FsB,aQgGxB,YACE,kBACA,mBRzGW,mCQ2GX,qBAGF,YACE,kBACA,0BACA,kBACA,cR3GsB,mBQ6GtB,iBAGF,eACE,eACA,cRlHsB,iBQoHtB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cR7HoB,0BQiItB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cR1IoB,qBQ4IpB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBRnKW,mCQqKX,cR7JwB,gBQ+JxB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cRzMkB,8DQ+MpB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eRlPM,CQoPN,cACA,cRpOsB,mBQsOtB,+BANA,iBACA,CRlPM,kCQgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,URjQM,eQmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cR1PoB,qCQ8PtB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBR/Qa,kBQiRX,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBR5RO,kBQ8RP,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBRpSsB,eQsSpB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WRnUE,mBQqUF,gBACA,uBACA,wBAEA,aRzTkB,0BQ6TlB,aACE,gBACA,eACA,eACA,cRjUgB,yFQuUlB,URvVE,+BQ8VJ,aACE,YACA,uDAGF,oBRjVsB,eQuV1B,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cRnYoB,gBQqYpB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WR3aI,8BQ8aJ,aACE,cR/ZkB,gBQialB,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aRhgBsB,iCQ+fxB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cRnhBsB,4JQshBtB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WR9jBI,gCQgkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MC/kBJ,+BACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WThDA,cSkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aTnDoB,0BSqDlB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aTpFsB,sBSuFpB,aTrFsB,yBSyFtB,iBACE,kBACA,gBACA,wBAIJ,aACE,eACA,eACA,qBAGF,kBACE,cTzGoB,iCS4GpB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WTzJA,gBS2JA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WT/KE,cSiLF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WTrME,cSuMF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WTzRI,cS2RJ,WACA,2CAKE,mBACE,eACA,WTnSA,qBSqSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WTnUI,cSqUJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBT1VY,oLS8VZ,iBACE,4WAGF,oBTjVsB,mBSoVpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBTlYsB,WAlBlB,eSuZJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBTlaoB,gGSsapB,kBTpbQ,kHSubN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WTvcI,cSycJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cTvckB,oBSyclB,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,8DACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UTrhBF,aS+hBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cTvhBsB,kBSyhBtB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cT/iBY,sBSmjBd,mCACE,+BACA,cTpjBQ,kBSwjBV,oBACE,cT3iBoB,qBS6iBpB,wBAEA,UT/jBI,0BSikBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gCACA,mBTvkBS,WATL,eSmlBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aTxmBsB,qBS0mBpB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aT1nBwB,yBS4nBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cT5oBsB,oCS+oBtB,cACE,mBACA,kBACA,4CAGF,aTppBwB,gBSspBtB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBT5rBM,YS8rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cTzrBwB,WS2rBxB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WTxuBI,qCS0uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UThvBI,0BSkvBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cT/wBsB,0BSkxBtB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WT5yBI,kBS8yBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aTtzBc,0SSg0BZ,+BACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gCACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBT12Bc,gBS42BZ,2BAEA,kBT92BY,gBSg3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCj7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,CAEA,UACqB,sCPpDzB,gBOqDI,wBAEA,UACE,YACA,cACA,SACA,kBACA,iBVLgB,wBG9DtB,4BACA,mBOoEM,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WV/EA,gBUiFA,gBACA,uBACA,+BAGF,aACE,eACA,cVxEgB,gBU0EhB,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WV7GI,gBU+GJ,qBACA,iBACA,qBACA,sBAGF,eVrHM,oBUuHJ,cV9GS,eUgHT,cACA,kBAGF,cACE,uCAGF,wBAEE,cVlHsB,oBUsHxB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBVrKa,mCUuKX,cVhKsB,eUkKtB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cV9LwB,sCUgMxB,sCACA,6DAEA,aVnNc,sCUqNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cV7OsB,0BU+OtB,6BAGF,aACE,cVpPoB,4BUwPtB,aVtPwB,qBUwPtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aVtRY,gBUwRV,0CAGF,aV3RY,wCUgSd,eACE,wCAIJ,UACE,0BAIA,aV3RsB,4BU8RpB,aV7RsB,qBU+RpB,qGAEA,yBAGE,iCAIJ,UVzTI,gBU2TF,wBAIJ,eACE,kBClUJ,kCACE,kBACA,gBACA,mBACA,qCAEA,iBANF,eAOI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBXhBW,6GWmBT,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBXzDwB,WAlBlB,oBW8EN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UXxFI,gFW4FN,kBAGE,qNAKA,kBXpFoB,4IW4FpB,kBX1GQ,qCWiHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAKA,cACA,iBACA,mBACA,mFAGF,iBACE,eACA,WACA,WACA,WACA,qMAGF,eAGE,mEASF,cACE,gBACA,qFAGF,aXhJoB,YWkJlB,eACA,WACA,eACA,gBACA,+GAGF,aACE,eACA,CACA,sBACA,eACA,yJAEA,cACE,uEAIJ,WACE,kBACA,WACA,eACA,iDAQF,iBACE,mBACA,yHAEA,iBACE,gBACA,+FAGF,UACE,oCAMR,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,WC9OJ,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cZIwB,SYFxB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aZVsB,eYYpB,SAIJ,wBACE,YACA,kBACA,sBACA,WZpCM,eYsCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,qCACA,mBACA,WACA,4CAEA,wBAGE,4BACA,qCACA,sBAGF,eACE,mFAEA,wBZnEQ,gBYuEN,kBAIJ,wBZ7DsB,eY+DpB,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,aZpFW,mBAOW,qGYiFpB,wBAGE,8BAIJ,kBZ1EsB,2GY6EpB,wBAGE,0BAIJ,cACE,iBACA,YACA,cZrGoB,oBYuGpB,uBACA,iBACA,kBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,cAIJ,oBACE,UACA,cZnHsB,SYqHtB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,qCACA,4BACA,2CACA,oBAGF,mCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aZ3JwB,gCY+JxB,QACE,uEAGF,mBAGE,uBAGF,aZ7JsB,sFYgKpB,aAGE,qCACA,6BAGF,mCACE,gCAGF,aACE,6BACA,8BAGF,aZ5LsB,uCY+LpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,aZtMwB,SYwMtB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,qCACA,4BACA,2CACA,yBAGF,mCACE,4BAGF,aACE,6BACA,eACA,0BAGF,aZnPwB,qCYuPxB,QACE,sFAGF,mBAGE,gBAIJ,iBACE,uBACA,YAGF,WACE,cACA,qBACA,QACA,SACA,kBACA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,uCAIJ,MACE,kBACA,CZ/SU,sEYsTZ,aZtTY,uBY0TZ,aZ3Tc,4DYiUV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UZrVM,0BYuVJ,eAIJ,aACE,eACA,gBACA,uBACA,mBACA,iBAEA,aACE,wBACA,sBAIA,cACA,gBAKA,yCAPF,WACE,CAEA,gBACA,uBACA,gBACA,mBAWA,CAVA,mBAGF,aACE,CACA,cAKA,8BAIA,yBACE,sBAIJ,SACE,YACA,eACA,iBACA,uBACA,mBACA,gBACA,CAME,sDAGF,cACE,YACA,kBACA,oBACA,qBAKN,eACE,wBAGF,cACE,eAGF,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cZxX4B,eAEC,0DYyX3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cZhZ4B,eAEC,WYiZ3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,WAIJ,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBZ1c0B,cY4cxB,kBACA,uCACA,mBAEA,eACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BZ9dsB,2BYkexB,WACE,iBACA,uBACA,yBZresB,8BYyexB,QACE,iBACA,uBACA,4BZ5esB,6BYgfxB,SACE,gBACA,2BACA,2BZnfsB,wBYyfxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ/fsB,cARb,gBY0gBT,uBACA,mBACA,yFAEA,kBZrgBsB,cADA,UY2gBpB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBZzhBsB,cY2hBtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZljBsB,cARb,gBY6jBT,uBACA,mBACA,oDAEA,SACE,oDAGF,kBZ5jBsB,cADA,iBYokB1B,qBACE,iBAIA,sBACA,cZ7jBsB,oBYgkBtB,cACE,gBACA,mBACA,kBACA,mBAGF,cACE,mBACA,iBAIJ,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WZ3pBM,qBY6pBN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCTxoBA,6GADF,kBSgpBI,4BACA,kHT5oBJ,kBS2oBI,4BACA,wBAIJ,+BACE,cZlqBsB,sBYsqBxB,eACE,aACA,2BAGF,aACE,eACA,kBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBZvrBsB,yBYyrBtB,gBACA,kBACA,eACA,gBACA,iBACA,WZhtBI,mDYqtBR,oBACE,aAGF,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,gBAIJ,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBZjxBW,qCYmxBX,sEAGF,wBACE,4CAGF,wBZjxB0B,+EYqxB1B,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBZ70BwB,yDYi1B1B,kBZ11Ba,2BYg2Bb,iBACE,gBACA,cAGF,aACE,kBAGF,kBZz2Ba,cY22BX,oBAEA,aZr2BwB,oBYy2BxB,aZ51BsB,yBYg2BtB,0BACE,CADF,uBACE,CADF,kBACE,kDAKA,sBACA,cACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,aZl3BoB,eYo3BlB,0DAEA,aZt3BkB,0BYw3BhB,sDAIJ,oBACE,cZ34BkB,sMY84BlB,yBAGE,0BAKN,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cZ95BkB,aYg6BlB,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,aZz7BkB,qBYg8BxB,oBACE,kBACA,eACA,iBACA,gBACA,mBZ58BW,gBY88BX,iBACA,qBAGF,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aZh+BwB,uBYk+BtB,CACA,WACA,CADA,+BACA,sBACA,cACA,oBACA,mBACA,cACA,WACA,0CAEA,UZ5/BM,4BAkBkB,qCGKtB,yDADF,cS6+BE,sBAGF,aZ7/BW,gCY+/BT,sDAEA,aZjgCS,4BASa,mDYggC1B,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAIJ,uBACE,2BACA,gDAGF,aZtgCwB,6BYwgCtB,uDAGF,aZvhC0B,yDY2hC1B,aACE,YAGF,aACE,cZphCsB,6BYshCtB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,oBAGF,gBACE,qEAGF,4BACE,gCAGF,eACE,kBACA,MACA,QACA,YACA,kBACA,YAEA,mBACA,yBACA,eACA,aAEA,wCAEA,UZ/hCsB,mBYiiCpB,aACA,sBACA,mBACA,uBACA,mBACA,8BACA,wBACA,gCACA,uCAGF,wBACE,kBACA,WACA,YACA,eACA,cZlmCoB,yBYomCpB,aACA,uBACA,mBACA,sCAGF,mBACE,6CAEA,8BACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,oBAGF,aZhnCwB,eYknCtB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,6BAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,YACE,SACA,QACA,WACA,YACA,mBACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WZ3yCE,gBY6yCF,eACA,+LAMA,yBACE,mEAKF,yBACE,iBAMR,aACE,iBACA,mEAGF,aZtzCwB,qBY0zCtB,mBACA,gBACA,sBACA,gBAGF,aACE,iBACA,uBAGF,eACE,8BAGF,aZz0CwB,eY20CtB,cACA,gBACA,gBACA,uBAGF,qBACE,sBAGF,WACE,8BAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA3BF,qBAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,iBAIJ,0DACE,CADF,kDACE,cAGF,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBZn6CW,kCYq6CX,uBAGF,MACE,aACA,mBACA,uBACA,cZp6CwB,eYs6CxB,gBACA,0BACA,kBACA,qCAGF,SACE,oBACA,CADA,WACA,cAGF,wBZh7C0B,WYk7CxB,kBACA,MACA,OACA,aACA,qBAGF,iBACE,aAGF,iBACE,cACA,aACA,WACA,yBZj8CwB,kBYm8CxB,cACA,UACA,WACA,eAGF,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBZ79CsB,kBY+9CtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cZr/CkB,kBYu/ClB,+BAGF,aZ1/CoB,eY4/ClB,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UZphDE,qBYshDA,oHAEA,yBAGE,yCAKN,QACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UZhjDI,oBYyjDN,kBACA,cACA,2BAGF,eACE,UAGF,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cZ5jDsB,gBY8jDtB,gBAEA,aZ/jDsB,0BYikDpB,sBAEA,oBACE,gBAIJ,qBACE,4BAKN,GACE,cACA,eACA,WARI,mBAKN,GACE,cACA,eACA,2CCrmDF,u+KACE,uCAEA,u+KACE,CAOA,8MAMF,okBACE,UClBJ,YACE,gCACA,cACA,qBACA,iCAEA,aACE,cACA,cdUoB,gBcRpB,qBACA,eACA,gBAGF,WACE,UACA,yCAEA,8CAEA,WACE,iBACA,mBAKN,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,gBd0BwB,wBG9DtB,4BACA,kBWqCA,eACA,yBAEA,oBACE,sBACA,iBACA,4BX3CF,eWiDE,2DAHF,gBdesB,wBG9DtB,4BACA,CWgDE,iBAOE,CANF,+BXjDF,UWqDI,CACA,qBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WdhEE,6BckEF,gBACA,eACA,0BAKN,iBACE,WACqB,sCXpErB,+BANA,UW8EuB,sCXxEvB,gEWsEA,gBdfsB,wBG9DtB,4BWyFE,CXlFF,iCANA,UWmFuB,sCX7EvB,kBW+EE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,2BAGF,aACE,mBACA,sBAGF,YACE,cd3EsB,6Bc8EtB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,4BAGF,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,8BACA,eACA,oCACA,uCAEA,aACE,kCAGF,+BACE,gCAGF,aACE,yBACA,eACA,cdtJoB,kCc0JtB,aACE,eACA,gBACA,Wd7KI,CckLA,2NADF,eACE,gCAKN,adrKwB,oBc0K1B,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,wBAGF,gBACE,qBACA,eACA,cd9LsB,ecgMtB,kBACA,4BAEA,adjMwB,6BcqMxB,aACE,gBACA,uBACA,iBAIJ,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,adlOwB,ecoOtB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SX5MF,sBACA,WACA,YACA,gBACA,oBACA,mBHhDW,cAOW,eG4CtB,SACA,+EWsMI,aACE,CXvMN,qEWsMI,aACE,CXvMN,yEWsMI,aACE,CXvMN,gEWsMI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,ad5PoB,iBc8PlB,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,adnSoB,0HcwSpB,cAEE,gBACA,cd7RkB,kZcgSlB,aAGE,gEAIJ,wBACE,iDAGF,edzUI,kBGkEN,CAEA,eACA,cHrDsB,uCGuDtB,UWoQI,mBd1ToB,oDGwDxB,wBACE,cH1DoB,eG4DpB,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,cHjFS,sDcsUT,WACE,mDAGF,ad1US,kBc4UP,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UdtWQ,kBcwWN,cACA,mBACA,sBd3WM,yBc6WN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,6BAIJ,YACE,eACA,gBACA,wBAGF,WACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cdjZoB,ecmZpB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,adzZsB,qWc4ZpB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,sBAQR,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cdjcoB,CcocpB,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,2BAIJ,ad3ewB,ec6etB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,Wd/mBA,gBcinBA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cdxmBc,gBc0mBd,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,Wd5oBE,gDcgpBJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,ad3pBU,yBciqBd,cACE,gCAEA,cACE,cdtpBkB,ecwpBlB,kCAEA,oBACE,cd3pBgB,qBc6pBhB,iBACA,gBACA,yCAEA,eACE,WdlrBF,SeFR,YACE,gCACA,8BAEA,aACE,cACA,WfJI,qBeMJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,mCCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,EDGF,0BCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,qCAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,EAtBA,2BAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,gCAIJ,cACE,kBAGF,iBACE,cACA,eACA,iBACA,qBACA,gBACA,iBACA,gBACA,wBAEA,SACE,4BAGF,UACE,YACA,gBACA,sBAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,qEAGF,kBACE,qBACA,sGAEA,eACE,qEAIJ,eAEE,qJAEA,kBAEE,mXAGF,eACE,mBACA,qJAGF,eACE,gBACA,2EAGF,eACE,+NAGF,eACE,2FAGF,iBACE,8BACA,chB9FkB,mBgBgGlB,qHAEA,eACE,2JAIJ,eACE,mJAGF,iBACE,6EAGF,iBACE,eACA,6EAGF,iBACE,qBACA,qJAGF,eACE,6JAEA,QACE,2EAIJ,oBACE,2EAGF,uBACE,oBAIJ,ahB9Ic,qBgBgJZ,0BAEA,yBACE,8BAEA,aACE,kCAKF,oBACE,uCAEA,yBACE,wBAKN,ahBlJoB,4CgBuJtB,YACE,8EAEA,aACE,mCAIJ,aACE,oDAEA,ahB5LQ,egB8LN,iDAIJ,kBACE,uDAEA,kBACE,qBACA,gCAKN,oBACE,kBACA,mBACA,YACA,chB3MW,gBgB6MX,eACA,cACA,yBACA,oBACA,eACA,sBACA,sCAEA,kBACE,qBACA,+DAGF,oBACE,iBACA,sBACA,kBACA,eACA,oBACA,2GAKF,oBAGE,4BAIJ,ahBvNwB,SgByNtB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,mDAGF,iBAEE,gCAGA,qEAEA,eACE,kBAKF,SACE,mBACA,kDAEA,kBACE,wDAEA,sBACE,iFAIJ,kBAEE,SAKN,iBACE,kBACA,YACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QAPF,kBAUI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,ahB1SoB,qCgB8SpB,ahBnUS,6BgBuUT,ahBhUoB,CAPX,kEgB+UT,ahB/US,kCgBkVP,ahBzUoB,gEgB6UpB,UhB/VE,mBAgBgB,sEgBmVhB,kBACE,mBAMR,uBACE,sBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,yCAEA,aACE,kBACA,OACA,QACA,MACA,SACA,6FACA,oBACA,WACA,2DAGF,oBACE,oCAGF,WACE,gBACA,uBACA,cACA,0CAEA,UACE,kBACA,MACA,gBACA,6DACA,oBACA,4CAGF,oBACE,gDAGJ,oDACE,mEAEF,oDACE,0CAGF,eACE,6DAGF,kBACE,gCAIJ,mBACE,+CAKF,sBACE,qEAEA,aACE,wBAKN,oBACE,YACA,ChBrZsB,cgBuZtB,iBACA,mBACA,CACA,sBACA,8CANA,ahBrZsB,CgByZtB,eAOA,8CAGF,aACE,eACA,eAGF,YACE,8BACA,eACA,oBAEA,sBACE,gBACA,2CAGF,oBACE,sBAIJ,YACE,mBACA,WACA,chB1bsB,iIgB6btB,gBAGE,kBACA,0EAGF,yBACE,yEAMA,0CACE,CADF,kCACE,2EAKF,2CACE,CADF,mCACE,wBAKN,YACE,mBACA,2BACA,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,mBACA,iBACA,chBtgBsB,CgBwgBtB,iBACA,eACA,kBACA,+CAEA,ahB7gBsB,uBgBihBtB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,chB1iBkB,4BgBgjBxB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,+DAIJ,cAEE,wBAIJ,eACE,chBnmBsB,egBqmBtB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,6JAGF,oBAME,4DAKA,UhBxqBM,kBgB8qBN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,ahB1rB0B,cgB4rBxB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WhB1tBI,kCgB+tBR,UACE,kBACA,iBAGF,SACE,kBACA,YACA,WACA,ChB1sBsB,8IgBqtBtB,ahBrtBsB,wBgBytBtB,UACE,wCAGF,kBhB7tBsB,cArBX,8CgBsvBT,kBACE,qBACA,+DAOJ,yBACE,cAIJ,YACE,eACA,yBACA,kBACA,chBnvBsB,gBgBqvBtB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,chB5xBoB,uBgB8xBpB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UhBtzBE,yBgB6zBJ,cACE,kBACA,YACA,+DAGF,aACE,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACE,YACA,SACA,2BAIF,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,chBp1BsB,gBgBs1BtB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,ahBl2BwB,oBgBs2BxB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,kBAGF,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,chB76BoB,iBgB+6BpB,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,chB38BkB,gBgB68BlB,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,ahB99BoB,oCgBo+BxB,cACE,cACA,SACA,uBACA,UACA,kBACA,oBACA,oFAEA,yBAEE,6BC/gCJ,kBACE,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,6CAGF,kBjBvB0B,cARb,kBiBoCX,gBACA,aACA,sBACA,0BAGF,WACE,WACA,gBACA,iBACA,8DAEA,UACE,YACA,sBACA,aACA,sBACA,mBACA,uBACA,aACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAIJ,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,qCAGF,kBACE,UACE,YACA,gBACA,0BAGF,UACE,YACA,eACA,gBACA,cACA,oDAIJ,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,ajBpGsB,SiBuGpB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,ajB7IS,qwDiBiJP,aAGE,sBAMR,sBACE,yBAGF,aACE,aACA,mBACA,uBACA,wBAGF,UACE,YACA,mBACA,mBACA,aACA,eACA,8BAEA,kBACE,+BAGF,cACE,mBACA,kCAIJ,mBACE,CACA,mBACA,0EAEA,mBACE,yBAIJ,cACE,iBACA,4BAEA,cACE,gBACA,cjBvMS,mBiByMT,2BAGF,ajBnMwB,kGiBsMtB,aAGE,2CAIJ,aACE,2BAGF,cACE,cjBtMoB,gBiBwMpB,mBACA,sCAEA,eACE,kCAGF,eACE,mBjBrOO,cAQa,kBiBgOpB,eACA,gBACA,CAII,2NADF,eACE,oCAOV,WACE,UACA,mCAME,mBACA,mBACA,sCAEA,cACE,iBACA,kBACA,qCAGF,eACE,oCAIJ,kBACE,mBACA,kBACA,eAIJ,iBACE,eACA,mBACA,sBAEA,eACE,cjBzRS,kBiB2RT,yBACA,eACA,qBAGF,kBjBhSW,cAQa,gBiB2RtB,aACA,kBACA,6HAQF,eACE,qJAGF,kBACE,cjB1SsB,mBiB4StB,kBACA,aACA,kBACA,eACA,sCACA,yPAEA,iBACE,mBACA,qNAGF,mBACE,gBACA,4CAMJ,YACE,mBACA,gDAEA,UACE,cACA,4DAEA,aACE,2DAGF,cACE,kDAGF,iBACE,uDAIJ,eACE,sDAIJ,UjB3WM,2DiBgXR,0BACE,cACE,iBACA,qJAGF,cAIE,mBACA,4CAGF,kBACE,sDAGF,WACE,eACA,mBAIJ,oBACE,eACA,gBACA,iBACA,uHAGF,kBAOE,cjB7YW,kBiB+YX,gBACA,eACA,YACA,kBACA,sBACA,+SAEA,ajBjYsB,YiBmYpB,eACA,WACA,eACA,gBACA,uSAGF,YACE,uPAGF,WACE,WACA,+WAGF,aACE,wBAKF,ejBvbM,CAiBkB,gBiByatB,oBACA,iEjB3bI,2BAiBkB,qDiBkb1B,iBAEE,aACA,qEAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,kKAIJ,YAKE,8BACA,mBjBncwB,aiBqcxB,iBACA,0LAEA,aACE,iBACA,cjB7boB,mBiB+bpB,kNAGF,aACE,6DAIJ,cAEE,yDAGF,WAEE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,ajBzgBwB,qCiB6gBxB,oDAZF,eAaI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,ajBhjBS,gBATL,aiB4jBJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,ejB5kBI,yBiB8kBF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,ajBnlBO,oBiBqlBL,eACA,gBjB/lBA,+CiBomBJ,YACE,8BACA,mBACA,4CAIJ,aACE,cjBnmBS,eiBqmBT,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,ajB9mBS,eiBgnBP,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eAWE,eACA,wBAXA,eACE,iBACA,uBAGF,aACE,gBACA,2CAMF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,ajB/pBO,aiBiqBL,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBjBvqBgB,WAlBlB,iJiBgsBA,iBAGE,oMAUR,aACE,iIAIJ,4BAIE,cjBxrBsB,eiB0rBtB,gBACA,6cAEA,aAGE,6BACA,uCAIJ,iBACE,mBACA,oBACA,eAEA,yFAEA,qBACE,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,ajBzwBS,CiB2wBP,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gDACA,mBjBpzBO,WATL,eiBg0BF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,cAKN,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBjB12BM,yDiB62BN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBjBr3BI,uBiBy3BN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UjBt5BI,eiBw5BF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,wBAKN,gBACE,sCAEA,eACE,gCAGF,eACE,qDAGF,ajBl7BW,iDiBs7BX,YACE,0DAEA,YACE,0BAIJ,YACE,iBACA,uBACA,kDAGF,ajB/6BsB,qBiBi7BpB,wDAEA,yBACE,WCp9BN,YACE,kCAEA,iBACE,MACA,QACA,oIAEA,+BAEE,oBAKN,cACE,uBACA,eACA,gBACA,clBasB,4CkBVtB,alBjBY,sCkBsBd,2CACE,oBAGF,QACE,wBACA,UACA,+CAEA,WACE,mBACA,UACA,0BAGF,aACE,sBACA,SACA,YACA,kBACA,aACA,WACA,UACA,clBvCS,gBATL,ekBmDJ,oBACA,gBACA,qDAEA,alBzBoB,CkBuBpB,2CAEA,alBzBoB,CkBuBpB,+CAEA,alBzBoB,CkBuBpB,sCAEA,alBzBoB,gCkB6BpB,8Cf/CA,uCADF,ceiD4D,0Cf5C5D,ce4C4D,oBAI9D,alBvDa,mBkByDX,mBlBlDsB,oCkBoDtB,iBACA,kBACA,eACA,gBACA,sBAEA,alB5CsB,gBkB8CpB,0BACA,mFAEA,oBAEU,iCAKZ,mBACA,eAEA,gBACA,wCAEA,alB1EwB,sDkB8ExB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,gBACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBlBhGsB,qCkBuG1B,eACE,kBACA,aACA,mBlB5GsB,gBkB8GtB,gBACA,cACA,yBAEA,iBACE,gBACA,wCAEA,alB7HS,iCkB+HT,WACE,iBACA,2BAIJ,iBACE,cACA,CACA,cACA,iBACA,clB1IS,qBkB4IT,gBACA,iBACA,qBACA,mBACA,gBACA,gGAEA,kBACE,qBACA,iIAEA,eACE,kJAIJ,eACE,mBACA,2DAGF,eACE,eACA,8BAGF,cACE,wFAGF,eACE,sCAGF,iBACE,8BACA,clBhLO,mBkBkLP,mDAEA,eACE,8DAIJ,eACE,0DAGF,iBACE,+BAGF,iBACE,eACA,2DAGF,eACE,+DAEA,QACE,8BAIJ,oBACE,8BAGF,uBACE,6BAGF,alBjMoB,qBkBmMlB,mCAEA,oEAGE,oBACE,gDAEA,qDAMR,UACE,YACA,gBACA,uDAIJ,iBAEE,WACA,mIAGE,aACE,sBACA,SACA,YACA,0BACA,yBACA,WACA,iBACA,UACA,clB5PO,gBATL,ekBwQF,oBACA,YACA,qBACA,yLAEA,alB/OkB,CkB6OlB,sKAEA,alB/OkB,CkB6OlB,8KAEA,alB/OkB,CkB6OlB,4JAEA,alB/OkB,yKkBmPlB,SACE,qJAGF,kBlBpQoB,+IkBqQpB,8Cf1QF,8JADF,ce4Q8D,kKfvQ9D,ceuQ8D,qCfhQ5D,8TADF,sBeoQM,gBACA,6BAMR,aACE,kBACA,SACA,UACA,WACA,gBACA,2CAEA,aACE,mBACA,WACA,YACA,clB/QoB,ekBiRpB,iBACA,kBACA,WACA,4CAIJ,iBACE,SACA,oCAGF,aACE,kBACA,sBACA,SACA,0BACA,YACA,WACA,clBzTW,mBAQa,sCkBoTxB,eACA,WACA,aACA,6CAGF,aACE,0CAGF,YACE,eACA,kBACA,iMAEA,kBAGa,iKAEb,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,+DAGF,6BACE,qEAEA,aACE,gBACA,uBACA,mBACA,sEAGF,eACE,qEAGF,aACE,iBACA,gBACA,uBACA,mBACA,4EAMA,alB/VkB,wBkBoWxB,eACE,iCAEA,YACE,mBACA,eACA,oBACA,YACA,gBACA,8BAIJ,UACE,WACA,cACA,kCAEA,iBACE,kBACA,aACA,WACA,sBlBzZI,wBkB2ZJ,sBACA,4BACA,gBACA,2CAEA,aACE,kBACA,sBACA,SACA,OACA,SACA,SACA,aACA,WACA,clBvZoB,gFkByZpB,eACA,oBACA,gBACA,UACA,UACA,4BACA,iDAEA,UlBlbE,sEkBobF,WACE,clBpakB,CAjBlB,4DkBobF,WACE,clBpakB,CAjBlB,gEkBobF,WACE,clBpakB,CAjBlB,uDkBobF,WACE,clBpakB,yCkByatB,2EAKE,0CAKN,iFACE,aACA,uBACA,8BACA,UACA,4BACA,8CAEA,aACE,clB5bsB,ekB8btB,gBACA,aACA,oBACA,2JAEA,aAGE,wCAIJ,SACE,kCAIJ,YACE,aACA,clBldsB,gBkBodtB,sCAEA,cACE,kBACA,2CAGF,aACE,gDAEA,aACE,eACA,gBACA,yBACA,qDAGF,iBACE,eACA,kBACA,WACA,WACA,mBlB5dkB,8DkB+dlB,iBACE,MACA,OACA,WACA,kBACA,mBlBhfkB,0BkBuf1B,alBhgBa,oBkBkgBX,eACA,gBlB5gBM,4BkBghBR,YACE,mBACA,0BACA,YACA,aACA,8BACA,cACA,oBAGF,YACE,cACA,sBAEA,oBACE,uBACA,cACA,YACA,iBACA,sBACA,uBAGF,oBACE,aACA,CAEA,oBACA,CADA,6BACA,UACA,QACA,YACA,uBACA,2BAIJ,iBACE,iBACA,0CAKE,yBACE,qCACA,WlB7jBE,mBAkBkB,gBkB8iBpB,8CAGA,yBACE,oCACA,uCAMR,iBACE,kBACA,uCACA,gBlB9kBM,gBkBglBN,uBACA,6CAGF,YACE,mBACA,aACA,clB9kBW,ekBglBX,sDAEA,aACE,clB9jBoB,wEkBikBpB,6EAEA,aACE,clBzlBO,gBkB2lBP,sGAIJ,kBlBtlBwB,WAlBlB,6PkBgnBF,UlBhnBE,0DkBonBN,wCAGF,gBACE,iBACA,mBACA,gBACA,yBACA,cACA,+BAEA,oBACE,SACA,eACA,kBACA,gCAGF,oBACE,aACA,UACA,WACA,kBACA,kCAIA,alB5oBU,CmBFZ,+BAHF,YACE,cACA,kBAUA,CATA,cAKA,kBACA,2BACA,gBAEA,uBAEA,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,wHAMJ,WAGE,mCAIJ,YACE,mBACA,uBACA,YACA,CnB7EW,ImB4Fb,aACE,aACA,sBACA,WACA,YACA,CAIA,oBAGF,qBACE,WACA,mBACA,cnBlGwB,emBoGxB,cACA,eACA,SACA,iBACA,aACA,SACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cnBrHwB,emBuHxB,cACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,cAGF,kBACE,WnB5KM,cmB8KN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cnB7KsB,kGmBgLtB,sBAGE,WnBpME,kCmBwMJ,anBtLsB,oBmB4L1B,oBACE,iBACA,oBAGF,kBnB1Ma,cAqBW,iBmBwLtB,eACA,gBACA,yBACA,eACA,yBAGF,iBACE,cACA,UACA,gCAEA,uCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,oFACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,uBAEA,QACE,YACA,aACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,anBlQwB,4CmBuQtB,anBvQsB,0CmByQpB,4CAIJ,SAEE,SAIJ,WACE,kBACA,sBACA,aACA,sBACA,gBACA,wDAEA,SACE,gBACA,gBACA,qBAGF,kBnB1SW,yBmB+Sb,WACE,aACA,cACA,uBAGF,kBACE,iCAGF,iBACE,sEAGF,kBACE,SACA,cnBxTsB,emB0TtB,eACA,eACA,kFAEA,aACE,CAKA,kLAEA,UnBtVI,mBmBwVF,kFAKJ,2BACE,wCAIJ,YACE,oBACA,6BACA,+CAEA,sBAEE,kBACA,eACA,qBACA,0CAGF,eACE,gDAKJ,SACE,6BAGF,eACE,gBACA,gBACA,cnB5WsB,0DmB8WtB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,iCAIF,eACE,2CACA,YACE,WACA,mCAKN,kBACE,aACA,mCAIA,anBpZsB,0BmBsZpB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,uBAKN,oBACE,uBACA,gBACA,mBACA,OACA,sBAGF,oBACE,iBACA,6EAGF,anBrawB,mBArBX,kBmB+bX,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBACA,4EAdF,cAeI,6FAGF,eACE,mFAGF,anBvcwB,qBmByctB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,uCAKA,sBACE,6BACA,qCASF,qCAXA,sBACE,6BACA,sCAgBF,mJAFF,qBAGI,sBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,kBACA,uCAEA,SACE,kCAKN,aACE,aACA,yBC9hBJ,iBACE,eACA,gBACA,cpB6BsB,mBArBX,eoBLX,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cpBDkB,qCoBKpB,cACE,gBACA,kBCtCJ,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WrB1EF,gBqB4EE,gBACA,uBACA,0CAGF,aACE,eACA,crBnEc,gBqBqEd,gBACA,uBACA,yBAKN,kBrBnFS,aqBqFP,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,cAOV,kBrBzHa,sBqB4HX,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,CC/KJ,eAGF,SnBkDE,sBACA,WACA,YACA,gBACA,oBACA,mBHhDW,cAOW,eG4CtB,SACA,cmBxDA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,4CACA,eACA,WACA,YACA,ctBtCsB,esBwCtB,oBACA,0BAEA,mBACE,WACA,0BAIJ,sBACE,iCAEA,mBACE,WACA,gCAIJ,QACE,uBACA,ctB/CoB,esBiDpB,uCAEA,uBACE,sCAGF,aACE,yBAKN,atB7DwB,mBsB+DtB,gCACA,kBACA,eACA,gBACA,uBAGF,YACE,ctBrFsB,kBsBuFtB,iBAIA,atB7EsB,mBsB+EpB,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,ctBvGkB,gBsByGlB,uBACA,mBACA,4BAEA,eACE,uBAGF,atB/HkB,qBsBiIhB,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,ctBzJoB,0BsB6JtB,aACE,WACA,2CAEA,oCACE,yBACA,0CAGF,wBACE,WC1LR,yCCCE,qBACA,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,8BAIJ,exBXQ,kBwBaN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBxBvCM,kBwByCN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,eAIJ,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,0EAMA,SACE,oBACA,CADA,WACA,eChGN,WAEE,0BAGF,kBANW,kBAQT,cACA,iCACA,wBACE,mCAOF,WACE,SACA,UACA,2CAGF,aACE,aAEA,sBACA,YACA,6BACA,6DAGE,oBACE,WACA,iBACA,iBACA,iJAGF,UACE,gEAEF,oBACE,gBACA,WACA,2CAKN,yBACE,sBACA,kBACA,YACA,gBACA,kDAEA,uBACE,CADF,oBACE,CADF,eACE,WACA,YACA,SACA,4BACA,WACA,yBACA,eACA,4CACA,sBACA,oBACA,6DAEA,uBACE,6DAGF,sBACE,wEAGF,sBACE,kBACA,SCjFR,WACE,sBACA,aACA,sBACA,kBACA,iBACA,UACA,qBAEA,iBACE,oBAGF,kBACE,2DvBDF,SuBI0D,yBvBC1D,SuBD0D,qCvBQxD,qLuBLA,yBAGF,eACE,gBACA,eACA,qCvBZA,4BuBgBA,SACE,WACA,YACA,eACA,UACA,+BALF,SACE,WACA,YACA,eACA,UACA,yCAIJ,4BAGF,YACE,mBACA,mBACA,UACA,mBACA,eACA,mBAEA,aACE,sBACA,oCACA,sBACA,YACA,cACA,c1BtCoB,kB0BwCpB,qBACA,eACA,mBAGF,iCACE,iDAEA,YAEE,mBACA,mCACA,SAKN,iBACE,mBACA,UACA,qCvBrDE,6CADF,euBwDkF,sCvBlEhF,sBADF,cuBoE0D,yBvB/D1D,cuB+D0D,gBAG5D,e1BlFQ,kBGkEN,CACA,sBACA,gBACA,cHrDsB,uCGuDtB,mBAEA,wBACE,cH1DoB,eG4DpB,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,cHjFS,kB0B6Eb,YACE,c1BvEsB,a0ByEtB,mBACA,oBAEA,aACE,qBACA,wBAGF,aACE,c1BjFsB,gB0BmFtB,mBACA,gBACA,uBACA,0BAIJ,aACE,gBACA,gBACA,kBAGF,kB1BxGa,kB0B0GX,gBACA,yBAEA,a1BxFsB,mB0B0FpB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,c1BhHkB,iC0BmHlB,oBACE,iBACA,8FAIJ,eAEE,mCAGF,aACE,aACA,c1B7IoB,qB0B+IpB,0HAEA,aAGE,0BACA,gBAQN,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAgBA,CAfA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,uBAEA,kB1B1LW,0B0B+Lb,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oCAGF,aACE,WACA,YACA,YACA,eACA,sCAGF,yBAzBF,aA0BI,iBAIJ,kBACE,eACA,gBACA,mBAGF,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,oBCrPF,kBACE,gB3BAM,WACA,e2BEN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,e3BdQ,cAiBgB,oB2BCtB,YACA,iEAEA,aAGE,iCAGF,eACE,2BxBcF,iBACE,mBACA,cACA,eACA,aACA,gBACA,yBwBfJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,6BAGF,aACE,kBACA,W3B7CM,0B2B+CN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,WACE,aACA,sBACA,4BAEA,iBACE,c3B3DoB,a2B6DpB,YACA,mBACA,CAGE,yDAIJ,UACE,gBAIJ,qBACE,eACA,gBACA,kBACA,kBACA,WACA,aACA,2BxBzDA,iBACE,mBACA,cACA,eACA,aACA,gBACA,sBwBwDJ,WACE,sBACA,cACA,WACA,kBACA,kBACA,gBACA,kCAEA,eACE,qEAIA,cACE,MACA,gCAIJ,e3B5HM,gC2BiIR,cACE,cACA,qBACA,c3BlHwB,kB2BoHxB,UACA,mEAEA,WAEE,WACA,sBACA,CADA,gCACA,CADA,kBACA,CAIE,0HAFF,WACE,oBACA,CADA,8BACA,CADA,gB3BhJE,C2BiJF,wBAKN,UACE,CAEA,iBACA,MACA,OACA,UACA,gB3B7JM,iC2BgKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,aACA,uBACA,qCAGF,cACE,YACA,WACA,kBACA,UACA,sBACA,CADA,gCACA,CADA,kBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,qDAEA,WACE,oBACA,CADA,8BACA,CADA,gBACA,sCAIJ,0BACE,2BACA,gBACA,kBACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,c3BrK0B,eAEC,C2B+K7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,W3BjQM,e2BmQN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,c3B9RsB,mF2BiStB,yBAGE,wBAKN,oBACE,sBAGF,qB3B9TQ,Y2BgUN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wB3BvT0B,qB2B2T1B,iBACE,UACA,QACA,YACA,qKAKA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,qCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gB3BnZM,e2BqZN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,gCAGF,UACE,YACA,0BxB3XF,iBACE,mBACA,cACA,eACA,aACA,gBACA,qBwB0XF,eACE,gBACA,UACA,kBACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gB3BxeI,cAiBgB,gB2B0dpB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,CAGE,gUAEA,aAIE,wBAKN,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,U3B9iBE,+E2BsjBN,cAGE,gBACA,6BAGF,U3B7jBM,iB2B+jBJ,yBAGF,oBACE,aACA,mDAGF,U3BvkBM,uB2B4kBN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,W3B5nBE,sF2B+nBF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,iBCtsBR,YACE,mBACA,mBACA,kBACA,QACA,SACA,YACA,mBAGF,YACE,kBACA,gBACA,yBACA,0BACA,eACA,iBACA,yBACA,WACA,4BACA,wCAEA,uBCtBF,kB7BWa,sB6BTX,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kB7B3Ca,sB6B6CX,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,aC3FJ,cAOE,qBACA,c9BGW,2B8BVX,qBAEE,iBACA,+BAOF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mB9BHsB,4B8BOxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,c9BLsB,c8BOtB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,a9BpC0B,mC8BuCxB,aACE,oDAGF,QACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBACA,uBAIA,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gB9B5FM,sB8B8FN,sGAEA,+BAEE,oBAKF,2BACA,gB9BxGM,0B8B2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,c9BzGS,yB8B2GT,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gB9BpKI,mB8ByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,c9BvKsB,mD8B0KtB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,wBC1MF,iBACE,aACA,mBACA,mB/BgBwB,cARb,kB+BLX,YACA,WACA,gBACA,iBACA,gBACA,4DAEA,aACE,eACA,mFAGF,iBACE,kBACA,gBACA,+FAEA,iBACE,OACA,MACA,kCAIJ,aACE,cACA,2BAGF,cACE,gBACA,iBACA,mBACA,2BAGF,cACE,gBACA,iBACA,gBACA,mBACA,0CAIJ,aACE,kBACA,cACA,mBACA,gCACA,eACA,qBACA,aACA,0BACA,4DAEA,aACE,iBACA,gDAGF,kB/BhDwB,iD+BoDxB,kB/BnDwB,WAlBlB,qG+B0EN,kB/BxEU,WAFJ,oC+BgFR,kBACE,YACA,eACA,iBACA,gBACA,8BAGF,aACE,UACA,kBACA,YACA,gBACA,oCAGF,iBACE,4FAGF,eAEE,mBACA,qCAGF,mCACE,UACE,cACA,0CAGF,YACE,4DAEA,YACE,kBCtHN,UhCEQ,gCgCCN,oBAEA,cACE,iBACA,gBACA,kBACA,mBAGF,UhCVM,0BgCYJ,oBAGF,eACE,cACA,iBACA,mDAGF,UACE,YACA,gBACA,gCACA,gBC3BJ,WACE,gBACA,aACA,sBACA,yBACA,kBACA,+BAEA,gBACE,eACA,CACA,2BACA,kCAGF,QACE,iCAGF,aACE,6BAGF,sBACE,0BAGF,MACE,kBACA,aACA,sBACA,iBACA,mDAGF,eACE,sBjClCI,0BiCoCJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,YACE,gBACA,gLAEA,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,CAIA,yFAGF,eACE,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WjCjNM,kBiCmNN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,mCAGF,kBAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,gCC1QJ,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,alCAwB,qBkCEtB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,gBAKN,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBlCzEoB,kBkC2EpB,cACA,eACA,4BAIJ,YACE,clCpFoB,kBkCsFpB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,clClJkB,mFkCsJpB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,alCrLsB,SkCuLpB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OCjON,eACE,eACA,8BAEA,QAEE,gBACA,UAGF,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBnCFsB,emCOxB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAIA,qBACA,WACA,eACA,cnC5CO,cmC8CP,UACA,oBACA,gBnCzDE,yBmC2DF,kBACA,iBACA,sCAEA,oBnC7CoB,0BmCkDtB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBnClGY,8EmCuGZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,cnC9GoB,amCkHtB,cACE,uBACA,UACA,SACA,SACA,cnCvHoB,0BmCyHpB,kBACA,mBAEA,oBACE,sCAGF,mCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,kBACA,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,UAEE,aACA,+GAEA,oBnC7KoB,sDmCmLxB,cACE,gBACA,iBACA,YACA,oBACA,cnC5KoB,sCmC+KpB,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,cnCrNS,qBmCuNT,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,anClNwB,qBmCqNtB,+BACE,6BAEA,+BACE,YCpPN,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,sBACE,eACA,gBACA,gBACA,qBACA,cpCJsB,oBoCOtB,apCLwB,0BoCOtB,6EAEA,oBAGE,wCAIJ,apClBsB,oBoCuBtB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cpChCsB,qBoCoCxB,iBACE,cpCrCsB,uBoCyCxB,eACE,mBACA,kBACA,kBACA,yHAGF,sBAME,mBACA,oBACA,gBACA,cpCzDsB,qBoC6DxB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cpCrIkB,iCoCyIpB,uBACE,gBACA,gBACA,cpC9HkB,qDoCkIpB,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,uBACA,eACA,iBACA,WpCpNI,iBoCsNJ,kBACA,qEAEA,aAEE,6CAIA,apC9MoB,oCoCmNtB,sBACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,sBACE,eACA,iBACA,gBACA,cpC/OkB,mBoCiPlB,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAIE,UACqB,sCjCnRzB,CiCoRI,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iBpCpOgB,wBG9DtB,4BACA,iCiCsSE,cACE,mCAEA,aACE,WpC3SA,qBoC6SA,uDAGE,yBACE,2CAKN,aACE,cpCvSgB,kCoC+StB,sBAEE,CACA,eACA,eACA,iBACA,mBACA,cpCtToB,sCoCyTpB,apCvTsB,0BoCyTpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,sBACE,eACA,iBACA,gBACA,mBACA,cpC/UsB,wBoCkVtB,sBACE,cACA,eACA,gBACA,cACA,kBAKF,cACA,iBpC7VsB,mCoC2VxB,sBACE,CAEA,eACA,mBACA,cpChWsB,kBoCqWtB,cACA,iBpCtWsB,kBoC8WtB,cpC9WsB,mCoC6WxB,sBACE,CACA,gBACA,gBACA,mBACA,cpClXsB,kBoCuXtB,cpCvXsB,kBoC+XxB,sBACE,eACA,iBACA,gBACA,mBACA,cpCpYsB,mCoCwYxB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,2CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBpC5bW,kBoC8bT,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAMA,UACqB,sCjC9hB3B,mDiCiiBI,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA9DF,iBA+DI,mFAIJ,qBAGE,mBpCtjBS,kBoCwjBT,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,apC9kBsB,qCoCklBtB,eACE,WpCpmBE,gBoCsmBF,2CAEA,apCxlBkB,gDoC2lBhB,apC1lBkB,+CoCgmBtB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SpC1rBI,YoC4rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,cpCpsBkB,6BoCwsBpB,eACE,iBACA,+BAGF,kBpCptBS,aoCstBP,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,sBACE,eACA,gBACA,cACA,qCAGF,cACE,cpCnvBgB,uFoCyvBtB,eACE,cASA,CpCnwBoB,2CoCgwBpB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cpCh2BsB,qBoCk2BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cpC11BoB,SqChCxB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBrChBW,UqCqBX,arCZwB,0BqCctB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBrCzDS,6BqC2DP,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+BACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,crC5FsB,gBqC8FtB,0DAEA,UrChHM,wDqCoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBrCvJS,sBqCyJT,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBrCtKS,gCqCyKT,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBrC9LS,uCqCiMP,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,crC/NgB,gBqCiOhB,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,sCAEA,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBCzRN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBtCPO,YsCSP,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,StCxCA,YsC0CE,kBACA,YACA,uCAIJ,aACE,ctCjCgB,qBsCmChB,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,ctC3EgB,qBsC6EhB,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UtCzGA,yBsC2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UtCjIE,yBAkBkB,gBsCkHlB,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,atCnMsB,esCqMpB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,atC9MsB,esCgNpB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,ctC1NkB,mBsC4NlB,kBACA,gCACA,4BAGF,cACE,ctCjOoB,iBsCmOpB,gBACA,0CAGF,UtCxPI,gBsC0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WtCxQE,oBsC0QF,iBACA,gBACA,mBACA,2BAGF,cACE,iBACA,ctCjQoB,mBsCmQpB,kCAEA,UtCtRE,gBsCwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,2CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BtCjUoB,YsCwU1B,UACE,SACA,cACA,WACA,sDAKA,atCnVsB,0DsCsVpB,atCpVsB,4DsCyVxB,atC1Wc,gBsC4WZ,4DAGF,atC9WU,gBsCgXR,0DAGF,atCvVsB,gBsCyVpB,0DAGF,atCtXU,gBsCwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,ctCxZkB,qBsC0ZlB,yBACA,eACA,gBACA,gCACA,iCAEA,UtChbE,gCsCkbA,oCAGF,atCnaoB,gCsCqalB,iBAMR,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,ctC7csB,CsCkdlB,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,atC3hBwB,qBsC6hBtB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBtCpjBW,gCsCsjBX,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,ctCxjBoB,esC0jBpB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,atCpkBsB,sDsCwkBtB,atCrlBwB,qBsCylBtB,gBACA,yDAIJ,oBAIE,ctClmBwB,iGsCqmBxB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBtC9pBc,yBsCkqBd,yBACE,wBAGF,yBtCnqBU,wBsCwqBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,atCpqBoB,uBsC0qBpB,wBACA,qBAGF,atChqBsB,csCqqBxB,kBtC1rBa,kBsC4rBX,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,ctCzsBkB,yBsC2sBlB,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,atCvuBM,6BsC8uBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,ctC9uBgB,mLsCivBhB,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,atC/uBgB,iBsCivBd,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,ctCzwBgB,WsCgxBxB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,atC70BY,8CsCk1Bd,qBACE,aACA,WtCr1BI,csC01BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBtC11BS,gCsC41BT,kCAEA,cACE,cACA,gBACA,eACA,gBACA,ctC31BoB,qBsC61BpB,mBACA,uHAEA,UtCj3BE,iCsCw3BJ,cACE,ctC31BkB,uCsC+1BpB,YACE,8BACA,mBACA,sCAGF,eACE,kkECp4BN,kIACE,CADF,sIACE,uIAYA,aAEE,yIAGF,aAEE,qIAGF,aAEE,6IAGF,aAEE,UChCJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,+BAGF,eACE,2EAGF,UAEE,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,+BAGF,UACE,0BAGF,gBACE,eACA,UAGA,WACA,yCAGF,iBACE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,gBACA,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,+DACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,sBChbJ,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WzCrCI,uByCuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,czCjCoB,kByCmCpB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,azCrDwB,gByCuDtB,qBACA,0D","file":"flavours/glitch/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#192432 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#192432;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#1c2938}::-webkit-scrollbar-thumb:active{background:#192432}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#121a24}::-webkit-scrollbar-track:active{background:#121a24}::-webkit-scrollbar-corner{background:transparent}body{font-family:sans-serif,sans-serif;background:#06090c;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",sans-serif,sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#121a24}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.embed{background:#192432;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#0b1016;padding:0}body.error{position:absolute;text-align:center;color:#9baec8;background:#121a24;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:sans-serif,sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;width:40px;height:40px;background-size:40px 40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}.account-header .name{flex:1 1 auto;color:#d9e1e8;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#26374d}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#9baec8;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#2d415a;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#344b68}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#d9e1e8}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#000}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#192432;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;width:120px;height:120px;background-size:120px 120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #192432;background:#040609;border-radius:8%;background-position:50%;background-clip:padding-box}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#192432;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#9baec8}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#9baec8;padding:10px;border-right:1px solid #192432;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #d8a070;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#d9e1e8}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:sans-serif,sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #26374d}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#9baec8}.public-layout .public-account-header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#e1b590}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#9baec8}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #202e3f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #202e3f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#121a24}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#192432}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.modal-layout{background:#121a24 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#4c6d98}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#4c6d98}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#9baec8}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#4c6d98}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#4c6d98}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#5377a5}.compact-header h1{font-size:24px;line-height:28px;color:#9baec8;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#d9e1e8}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#121a24;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.hero-widget__text a{color:#d9e1e8;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#9baec8}.box-widget{padding:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #3e5a7c;text-align:center;color:#9baec8;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#9baec8;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#d9e1e8;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#9baec8}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#9baec8;margin-bottom:10px}.page-header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#9baec8}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#192432}.page-header h1{font-size:24px}}.directory{background:#121a24;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#121a24;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#202e3f}.directory__tag.active>a{background:#d8a070;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#9baec8}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#9baec8}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#d8a070}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#040609;border:2px solid #121a24}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#9baec8;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #202e3f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#9baec8;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #2d415a}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#9baec8}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#d8a070}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#0b1016;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #192432}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #192432}code{font-family:monospace,monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#d8a070;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#9baec8}.simple_form .hint a{color:#d8a070}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#000}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#9baec8}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #3e5a7c;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102;border:1px solid #000;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#a8b9cf}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#d8a070;background:#040609}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#d8a070;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#ddad84}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#d3935c}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #000;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#3e5a7c;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(1, 1, 2, 0), #010102)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(18,26,36,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#202e3f;color:#9baec8;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#9baec8;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:monospace,monospace;background:#121a24;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#192432}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#9baec8;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#d8a070;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#e1b590}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#9baec8}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#d9e1e8;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#d9e1e8;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#9baec8}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:monospace,monospace}.input-copy{background:#010102;border:1px solid #000;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:monospace,monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#0b1016;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#0b1016;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#202e3f}.card__img{height:130px;position:relative;background:#000;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#192432;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box;background:#040609;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#121a24;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#d9e1e8}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#233346}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#9baec8;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #26374d;border-bottom:1px solid #26374d;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #26374d}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#d9e1e8;background:rgba(4,6,9,.5)}.account__header__fields dd{flex:1 1 auto;color:#9baec8}.account__header__fields a{color:#d8a070;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#9baec8}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#121a24}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#202e3f}.button.logo-button{flex:0 auto;font-size:14px;background:#d8a070;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#e3bb98}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{padding:8px 0;padding-bottom:2px;margin:initial;margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{position:absolute;margin:initial;float:initial;width:auto;left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}.embed .status .status__info,.public-layout .status .status__info{font-size:15px;display:initial}.embed .status .status__relative-time,.public-layout .status .status__relative-time{color:#3e5a7c;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.embed .status .status__info .status__display-name,.public-layout .status .status__info .status__display-name{display:block;max-width:100%;padding:6px 0;padding-right:25px;margin:initial}.embed .status .status__info .status__display-name .display-name strong,.public-layout .status .status__info .status__display-name .display-name strong{display:inline}.embed .status .status__avatar,.public-layout .status .status__avatar{height:48px;position:absolute;width:48px;margin:initial}.rtl .embed .status,.rtl .public-layout .status{padding-left:10px;padding-right:68px}.rtl .embed .status .status__info .status__display-name,.rtl .public-layout .status .status__info .status__display-name{padding-left:25px;padding-right:0}.rtl .embed .status .status__relative-time,.rtl .public-layout .status .status__relative-time{float:left}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#e1b590;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#d8a070;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#d59864;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;transition-property:background-color;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#e0b38c;transition:all 200ms ease-out;transition-property:background-color}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled{background-color:#9baec8;cursor:default}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#121a24;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#3e5a7c}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#45648a}.button.button-secondary{font-size:16px;line-height:36px;height:auto;color:#9baec8;text-transform:none;background:transparent;padding:3px 15px;border-radius:4px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#a8b9cf}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.icon-button{display:inline-block;padding:0;color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#4a6b94;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(62,90,124,.3)}.icon-button.disabled{color:#283a50;background-color:transparent;cursor:default}.icon-button.active{color:#d8a070}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#3e5a7c}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#324965;background-color:rgba(62,90,124,.15)}.icon-button.inverted:focus{background-color:rgba(62,90,124,.3)}.icon-button.inverted.disabled{color:#4a6b94;background-color:transparent}.icon-button.inverted.active{color:#d8a070}.icon-button.inverted.active.disabled{color:#e6c3a4}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#324965;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(62,90,124,.3)}.text-icon-button.disabled{color:#6b8cb5;background-color:transparent;cursor:default}.text-icon-button.active{color:#d8a070}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute;transform-origin:50% 0}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.notification__favourite-icon-wrapper{left:0;position:absolute}.notification__favourite-icon-wrapper .fa.star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name a{color:inherit;text-decoration:inherit}.display-name strong{height:18px;font-size:16px;font-weight:500;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name span{display:block;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name>a:hover strong{text-decoration:underline}.display-name.inline{padding:0;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.display-name.inline strong{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name.inline span{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.dropdown-menu ul{list-style:none}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#d8a070;color:#d9e1e8;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#d8a070;color:#d9e1e8}.dropdown__icon{vertical-align:middle}.static-content{padding:10px;padding-top:20px;color:#3e5a7c}.static-content h1{font-size:16px;font-weight:500;margin-bottom:40px;text-align:center}.static-content p{font-size:13px;margin-bottom:20px}.column,.drawer{flex:1 1 100%;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#202e3f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #202e3f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.auto-columns .tabs-bar__link:hover,.auto-columns .tabs-bar__link:focus,.auto-columns .tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}}.multi-columns .tabs-bar__link:hover,.multi-columns .tabs-bar__link:focus,.multi-columns .tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}.tabs-bar__link.active{border-bottom:2px solid #d8a070;color:#d8a070}.tabs-bar__link span{margin-left:5px;display:none}.tabs-bar__link span.icon{margin-left:0;display:inline}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#d8a070;border:2px solid #202e3f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#040609}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#121a24;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#010102}.react-toggle--checked .react-toggle-track{background-color:#d8a070}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#e3bb98}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #121a24;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#d8a070}.getting-started__wrapper,.getting_started,.flex-spacer{background:#121a24}.getting-started__wrapper{position:relative;overflow-y:auto}.flex-spacer{flex:1 1 auto}.getting-started{background:#121a24;flex:1 0 auto}.getting-started p{color:#d9e1e8}.getting-started a{color:#3e5a7c}.getting-started__panel{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex:0 1 auto}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{color:#3e5a7c;font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#3e5a7c;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#9baec8}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#9baec8;padding:10px;font-weight:500;border-bottom:1px solid #202e3f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#9baec8}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#121a24;padding:4px 8px;margin:-6px 10px}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#202e3f;border:1px solid #0b1016}.setting-text{color:#9baec8;background:transparent;border:none;border-bottom:2px solid #9baec8;box-sizing:border-box;display:block;font-family:inherit;margin-bottom:10px;padding:7px 0;width:100%}.setting-text:focus,.setting-text:active{color:#fff;border-bottom-color:#d8a070}@media screen and (max-width: 600px){.auto-columns .setting-text,.single-column .setting-text{font-size:16px}}.setting-text.light{color:#121a24;border-bottom:2px solid #405c80}.setting-text.light:focus,.setting-text.light:active{color:#121a24;border-bottom-color:#d8a070}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#3e5a7c;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#d8a070}.reduce-motion button.icon-button.disabled i.fa-retweet{color:#283a50}.load-more{display:block;color:#3e5a7c;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#151f2b}.load-gap{border-bottom:1px solid #202e3f}.missing-indicator{padding-top:68px}.scrollable>div>:first-child .notification__dismiss-overlay>.wrappy{border-top:1px solid #121a24}.notification__dismiss-overlay{overflow:hidden;position:absolute;top:0;right:0;bottom:-1px;padding-left:15px;z-index:999;align-items:center;justify-content:flex-end;cursor:pointer;display:flex}.notification__dismiss-overlay .wrappy{width:4rem;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#202e3f;border-left:1px solid #344b68;box-shadow:0 0 5px #000;border-bottom:1px solid #121a24}.notification__dismiss-overlay .ckbox{border:2px solid #9baec8;border-radius:2px;width:30px;height:30px;font-size:20px;color:#9baec8;text-shadow:0 0 5px #000;display:flex;justify-content:center;align-items:center}.notification__dismiss-overlay:focus{outline:0 !important}.notification__dismiss-overlay:focus .ckbox{box-shadow:0 0 1px 1px #d8a070}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.loading-indicator{color:#3e5a7c;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #3e5a7c;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#3e5a7c}29%{background-color:#3e5a7c}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:flex;left:4px;top:4px;width:auto;height:auto;align-items:center}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.setting-toggle{display:block;line-height:24px}.setting-toggle__label,.setting-radio__label,.setting-meta__label{color:#9baec8;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.setting-radio{display:block;line-height:18px}.setting-radio__label{margin-bottom:0}.column-settings__row legend{color:#9baec8;cursor:default;display:block;font-weight:500;margin-top:10px}.setting-radio__input{vertical-align:middle}.setting-meta__label{float:right}@keyframes heartbeat{from{transform:scale(1);transform-origin:center center;animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.pulse-loading{animation:heartbeat 1.5s ease-in-out infinite both}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#121a24;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#d9e1e8;font-size:18px;font-weight:500;border:2px dashed #3e5a7c;border-radius:4px}.dropdown--active .emoji-button img{opacity:1;filter:none}.loading-bar{background-color:#d8a070;height:3px;position:absolute;top:0;left:0;z-index:9999}.icon-badge-wrapper{position:relative}.icon-badge{position:absolute;display:block;right:-0.25em;top:-0.25em;background-color:#d8a070;border-radius:50%;font-size:75%;width:1em;height:1em}.conversation{display:flex;border-bottom:1px solid #202e3f;padding:5px;padding-bottom:0}.conversation:focus{background:#151f2b;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#d8a070;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#9baec8;padding-left:15px}.conversation__content__names{color:#9baec8;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content .status__content{margin:0}.conversation--unread{background:#151f2b}.conversation--unread:focus{background:#192432}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.ui .flash-message{margin-top:10px;margin-left:auto;margin-right:auto;margin-bottom:0;min-width:75%}::-webkit-scrollbar-thumb{border-radius:0}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#d9e1e8;max-width:400px}noscript div a{color:#d8a070;text-decoration:underline}noscript div a:hover{text-decoration:none}noscript div a{word-break:break-word}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet,button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.status-direct button.icon-button.disabled i.fa-retweet,.status-direct button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.account{padding:10px;border-bottom:1px solid #202e3f;color:inherit;text-decoration:none}.account .account__display-name{flex:1 1 auto;display:block;color:#9baec8;overflow:hidden;text-decoration:none;font-size:14px}.account.small{border:none;padding:0}.account.small>.account__avatar-wrapper{margin:0 8px 0 0}.account.small>.display-name{height:24px;line-height:24px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:8%;background-position:50%;background-clip:padding-box;position:relative;cursor:pointer}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:8%;background-position:50%;background-clip:padding-box;overflow:hidden;position:relative}.account__avatar-composite div{border-radius:8%;background-position:50%;background-clip:padding-box;float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}.account__avatar-overlay{position:relative;width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:8%;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:8%;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__header__wrapper{flex:0 0 auto;background:#192432}.account__disclaimer{padding:10px;color:#3e5a7c}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-left:1px solid #202e3f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab:first-child{border-left:0}.account__action-bar__tab.active{border-bottom:4px solid #d8a070}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#9baec8}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account__action-bar__tab abbr{color:#d8a070}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.notification__message{margin-left:42px;padding:8px 0 0 26px;cursor:default;color:#9baec8;font-size:15px;position:relative}.notification__message .fa{color:#d8a070}.notification__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account--panel{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#202e3f;padding:15px}.column-settings__section{color:#9baec8;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#a8b9cf}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#192432}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#3e5a7c;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#202e3f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#26374d;color:#a8b9cf}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#9baec8}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#3e5a7c}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#45648a}.column-settings__hashtags .column-select__indicator-separator{background-color:#202e3f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#121a24}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#121a24;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:none;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#0b1016;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#9baec8;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#d9e1e8}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #202e3f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #121a24}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#0f151d;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #06090c}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f}.account__moved-note__message{position:relative;margin-left:58px;color:#3e5a7c;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.account__header__content{color:#9baec8;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#0b1016}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#192432;padding:5px;border-bottom:1px solid #26374d}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#040609;border:2px solid #192432}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #26374d;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #26374d}.account__header__bio .account__header__fields a{color:#e1b590}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#9baec8;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.domain{padding:10px;border-bottom:1px solid #202e3f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}@keyframes spring-flip-in{0%{transform:rotate(0deg)}30%{transform:rotate(-242.4deg)}60%{transform:rotate(-158.35deg)}90%{transform:rotate(-187.5deg)}100%{transform:rotate(-180deg)}}@keyframes spring-flip-out{0%{transform:rotate(-180deg)}30%{transform:rotate(62.4deg)}60%{transform:rotate(-21.635deg)}90%{transform:rotate(7.5deg)}100%{transform:rotate(0deg)}}.status__content--with-action{cursor:pointer}.status__content{position:relative;margin:10px 0;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:visible;padding-top:5px}.status__content:focus{outline:0}.status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.status__content pre,.status__content blockquote{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.status__content pre:last-child,.status__content blockquote:last-child{margin-bottom:0}.status__content .status__content__text,.status__content .e-content{overflow:hidden}.status__content .status__content__text>ul,.status__content .status__content__text>ol,.status__content .e-content>ul,.status__content .e-content>ol{margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h1,.status__content .e-content h2,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{margin-top:20px;margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .e-content h1,.status__content .e-content h2{font-weight:700;font-size:1.2em}.status__content .status__content__text h2,.status__content .e-content h2{font-size:1.1em}.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{font-weight:500}.status__content .status__content__text blockquote,.status__content .e-content blockquote{padding-left:10px;border-left:3px solid #9baec8;color:#9baec8;white-space:normal}.status__content .status__content__text blockquote p:last-child,.status__content .e-content blockquote p:last-child{margin-bottom:0}.status__content .status__content__text b,.status__content .status__content__text strong,.status__content .e-content b,.status__content .e-content strong{font-weight:700}.status__content .status__content__text em,.status__content .status__content__text i,.status__content .e-content em,.status__content .e-content i{font-style:italic}.status__content .status__content__text sub,.status__content .e-content sub{font-size:smaller;text-align:sub}.status__content .status__content__text sup,.status__content .e-content sup{font-size:smaller;vertical-align:super}.status__content .status__content__text ul,.status__content .status__content__text ol,.status__content .e-content ul,.status__content .e-content ol{margin-left:1em}.status__content .status__content__text ul p,.status__content .status__content__text ol p,.status__content .e-content ul p,.status__content .e-content ol p{margin:0}.status__content .status__content__text ul,.status__content .e-content ul{list-style-type:disc}.status__content .status__content__text ol,.status__content .e-content ol{list-style-type:decimal}.status__content a{color:#d8a070;text-decoration:none}.status__content a:hover{text-decoration:underline}.status__content a:hover .fa{color:#4a6b94}.status__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span{text-decoration:underline}.status__content a .fa{color:#3e5a7c}.status__content .status__content__spoiler{display:none}.status__content .status__content__spoiler.status__content__spoiler--visible{display:block}.status__content a.unhandled-link{color:#e1b590}.status__content a.unhandled-link .link-origin-tag{color:#ca8f04;font-size:.8em}.status__content .status__content__spoiler-link{background:#45648a}.status__content .status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:#45648a;border:none;color:#121a24;font-weight:500;font-size:11px;padding:0 5px;text-transform:uppercase;line-height:inherit;cursor:pointer;vertical-align:bottom}.status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.status__content__spoiler-link .status__content__spoiler-icon{display:inline-block;margin:0 0 0 5px;border-left:1px solid currentColor;padding:0 0 0 4px;font-size:16px;vertical-align:-2px}.notif-cleaning .status,.notif-cleaning .notification-follow,.notif-cleaning .notification-follow-request{padding-right:4.5rem}.status__wrapper--filtered{color:#3e5a7c;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #202e3f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.notification-follow,.notification-follow-request{position:relative;border-bottom:1px solid #202e3f}.notification-follow .account,.notification-follow-request .account{border-bottom:0 none}.focusable:focus{outline:0;background:#192432}.focusable:focus.status.status-direct:not(.read){background:#26374d}.focusable:focus.status.status-direct:not(.read).muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#202e3f}.status{padding:10px 14px;position:relative;height:auto;border-bottom:1px solid #202e3f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:28px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#202e3f;border-bottom-color:#26374d}.status.light .status__relative-time{color:#3e5a7c}.status.light .status__display-name{color:#121a24}.status.light .display-name{color:#9baec8}.status.light .display-name strong{color:#121a24}.status.light .status__content{color:#121a24}.status.light .status__content a{color:#d8a070}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.status.collapsed{background-position:center;background-size:cover;user-select:none}.status.collapsed.has-background::before{display:block;position:absolute;left:0;right:0;top:0;bottom:0;background-image:linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.65) 24px, rgba(0, 0, 0, 0.8));pointer-events:none;content:\"\"}.status.collapsed .display-name:hover .display-name__html{text-decoration:none}.status.collapsed .status__content{height:20px;overflow:hidden;text-overflow:ellipsis;padding-top:0}.status.collapsed .status__content:after{content:\"\";position:absolute;top:0;bottom:0;left:0;right:0;background:linear-gradient(rgba(18, 26, 36, 0), #121a24);pointer-events:none}.status.collapsed .status__content a:hover{text-decoration:none}.status.collapsed:focus>.status__content:after{background:linear-gradient(rgba(25, 36, 50, 0), #192432)}.status.collapsed.status-direct:not(.read)>.status__content:after{background:linear-gradient(rgba(32, 46, 63, 0), #202e3f)}.status.collapsed .notification__message{margin-bottom:0}.status.collapsed .status__info .notification__message>span{white-space:nowrap}.status .notification__message{margin:-10px 0px 10px 0}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#547aa9}.status__relative-time{display:inline-block;flex-grow:1;color:#3e5a7c;font-size:14px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status__display-name{color:#3e5a7c;overflow:hidden}.status__info__account .status__display-name{display:block;max-width:100%}.status__info{display:flex;justify-content:space-between;font-size:15px}.status__info>span{text-overflow:ellipsis;overflow:hidden}.status__info .notification__message>span{word-wrap:break-word}.status__info__icons{display:flex;align-items:center;height:1em;color:#3e5a7c}.status__info__icons .status__media-icon,.status__info__icons .status__visibility-icon,.status__info__icons .status__reply-icon{padding-left:2px;padding-right:2px}.status__info__icons .status__collapse-button.active>.fa-angle-double-up{transform:rotate(-180deg)}.no-reduce-motion .status__collapse-button.activate>.fa-angle-double-up{animation:spring-flip-in 1s linear}.no-reduce-motion .status__collapse-button.deactivate>.fa-angle-double-up{animation:spring-flip-out 1s linear}.status__info__account{display:flex;align-items:center;justify-content:flex-start}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-top:-10px;margin-bottom:10px;margin-left:58px;color:#3e5a7c;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#3e5a7c}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#3e5a7c}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#192432;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#3e5a7c;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}a.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#d9e1e8;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{flex:none;margin:0 10px 0 0;height:48px;width:48px}.muted .status__content,.muted .status__content p,.muted .status__content a,.muted .status__content__text{color:#3e5a7c}.muted .status__display-name strong{color:#3e5a7c}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#3e5a7c;color:#121a24}.muted a.status__content__spoiler-link:hover{background:#436187;text-decoration:none}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.status-card{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;color:#3e5a7c;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#d9e1e8;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}.status-card__actions a .fa,.status-card__actions a:hover .fa{color:inherit}a.status-card{cursor:pointer}a.status-card:hover{background:#202e3f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#9baec8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#9baec8}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#202e3f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#192432}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#192432}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.attachment-list{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#3e5a7c;padding:8px 18px;cursor:default;border-right:1px solid #202e3f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#3e5a7c;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#3e5a7c}.status__wrapper--filtered__button{display:inline;color:#e1b590;border:0;background:transparent;padding:0;font-size:inherit;line-height:inherit}.status__wrapper--filtered__button:hover,.status__wrapper--filtered__button:active{text-decoration:underline}.modal-container--preloader{background:#202e3f}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#121a24;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.onboarding-modal__pager{height:80vh;width:80vw;max-width:520px;max-height:470px}.onboarding-modal__pager .react-swipeable-view-container>div{width:100%;height:100%;box-sizing:border-box;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;user-select:text}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}@media screen and (max-width: 550px){.onboarding-modal{width:100%;height:100%;border-radius:0}.onboarding-modal__pager{width:100%;height:auto;max-width:none;max-height:none;flex:1 1 auto}}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#3e5a7c;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#37506f;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#121a24}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#192432}.error-modal__footer{justify-content:center}.onboarding-modal__dots{flex:1 1 auto;display:flex;align-items:center;justify-content:center}.onboarding-modal__dot{width:14px;height:14px;border-radius:14px;background:#a6b9c9;margin:0 3px;cursor:pointer}.onboarding-modal__dot:hover{background:#a0b4c5}.onboarding-modal__dot.active{cursor:default;background:#8da5ba}.onboarding-modal__page__wrapper{pointer-events:none;padding:25px;padding-bottom:0}.onboarding-modal__page__wrapper.onboarding-modal__page__wrapper--active{pointer-events:auto}.onboarding-modal__page{cursor:default;line-height:21px}.onboarding-modal__page h1{font-size:18px;font-weight:500;color:#121a24;margin-bottom:20px}.onboarding-modal__page a{color:#d8a070}.onboarding-modal__page a:hover,.onboarding-modal__page a:focus,.onboarding-modal__page a:active{color:#dcab80}.onboarding-modal__page .navigation-bar a{color:inherit}.onboarding-modal__page p{font-size:16px;color:#3e5a7c;margin-top:10px;margin-bottom:10px}.onboarding-modal__page p:last-child{margin-bottom:0}.onboarding-modal__page p strong{font-weight:500;background:#121a24;color:#d9e1e8;border-radius:4px;font-size:14px;padding:3px 6px}.onboarding-modal__page p strong:lang(ja){font-weight:700}.onboarding-modal__page p strong:lang(ko){font-weight:700}.onboarding-modal__page p strong:lang(zh-CN){font-weight:700}.onboarding-modal__page p strong:lang(zh-HK){font-weight:700}.onboarding-modal__page p strong:lang(zh-TW){font-weight:700}.onboarding-modal__page__wrapper-0{height:100%;padding:0}.onboarding-modal__page-one__lead{padding:65px;padding-top:45px;padding-bottom:0;margin-bottom:10px}.onboarding-modal__page-one__lead h1{font-size:26px;line-height:36px;margin-bottom:8px}.onboarding-modal__page-one__lead p{margin-bottom:0}.onboarding-modal__page-one__extra{padding-right:65px;padding-left:185px;text-align:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#121a24;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#121a24;color:#d9e1e8;font-weight:500;padding:10px;border-radius:4px}.onboarding-modal__page-two p,.onboarding-modal__page-three p,.onboarding-modal__page-four p,.onboarding-modal__page-five p{text-align:left}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{background:#040609;color:#d9e1e8;margin-bottom:20px;border-radius:4px;padding:10px;text-align:center;font-size:14px;box-shadow:1px 2px 6px rgba(0,0,0,.3)}.onboarding-modal__page-two .figure .onboarding-modal__image,.onboarding-modal__page-three .figure .onboarding-modal__image,.onboarding-modal__page-four .figure .onboarding-modal__image,.onboarding-modal__page-five .figure .onboarding-modal__image{border-radius:4px;margin-bottom:10px}.onboarding-modal__page-two .figure.non-interactive,.onboarding-modal__page-three .figure.non-interactive,.onboarding-modal__page-four .figure.non-interactive,.onboarding-modal__page-five .figure.non-interactive{pointer-events:none;text-align:left}.onboarding-modal__page-four__columns .row{display:flex;margin-bottom:20px}.onboarding-modal__page-four__columns .row>div{flex:1 1 0;margin:0 10px}.onboarding-modal__page-four__columns .row>div:first-child{margin-left:0}.onboarding-modal__page-four__columns .row>div:last-child{margin-right:0}.onboarding-modal__page-four__columns .row>div p{text-align:center}.onboarding-modal__page-four__columns .row:last-child{margin-bottom:0}.onboarding-modal__page-four__columns .column-header{color:#fff}@media screen and (max-width: 320px)and (max-height: 600px){.onboarding-modal__page p{font-size:14px;line-height:20px}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{font-size:12px;margin-bottom:10px}.onboarding-modal__page-four__columns .row{margin-bottom:10px}.onboarding-modal__page-four__columns .column-header{padding:5px;font-size:12px}}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.doodle-modal,.favourite-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#121a24;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__relative-time,.doodle-modal .status__relative-time,.favourite-modal .status__relative-time,.confirmation-modal .status__relative-time,.report-modal .status__relative-time,.actions-modal .status__relative-time,.mute-modal .status__relative-time,.block-modal .status__relative-time{color:#3e5a7c;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.boost-modal .status__display-name,.doodle-modal .status__display-name,.favourite-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:flex}.boost-modal .status__avatar,.doodle-modal .status__avatar,.favourite-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:48px;width:48px}.boost-modal .status__content__spoiler-link,.doodle-modal .status__content__spoiler-link,.favourite-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#f2f5f7}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container,.favourite-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status,.favourite-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.doodle-modal__action-bar,.favourite-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.doodle-modal__action-bar>div,.favourite-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#3e5a7c;padding-right:10px}.boost-modal__action-bar .button,.doodle-modal__action-bar .button,.favourite-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header,.favourite-modal__status-header{font-size:15px}.boost-modal__status-time,.favourite-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#d8a070}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#121a24;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#121a24;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal strong{display:block;font-weight:500}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#121a24;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#d8a070;color:#fff}.actions-modal ul li:not(:empty) a>.react-toggle,.actions-modal ul li:not(:empty) a>.icon,.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#3e5a7c;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#37506f;background-color:transparent}.confirmation-modal__do_not_ask_again{padding-left:20px;padding-right:20px;padding-bottom:10px;font-size:14px}.confirmation-modal__do_not_ask_again label,.confirmation-modal__do_not_ask_again input{vertical-align:middle}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#121a24;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#192432}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.filtered-status-info{text-align:start}.filtered-status-info .spoiler__text{margin-top:20px}.filtered-status-info .account{border-bottom:0}.filtered-status-info .account__display-name strong{color:#121a24}.filtered-status-info .status__content__spoiler{display:none}.filtered-status-info .status__content__spoiler--visible{display:flex}.filtered-status-info ul{padding:10px;margin-left:12px;list-style:disc inside}.filtered-status-info .filtered-status-edit-link{color:#3e5a7c;text-decoration:none}.filtered-status-info .filtered-status-edit-link:hover{text-decoration:underline}.composer{padding:10px}.composer .emoji-picker-dropdown{position:absolute;top:0;right:0}.composer .emoji-picker-dropdown ::-webkit-scrollbar-track:hover,.composer .emoji-picker-dropdown ::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.character-counter{cursor:default;font-family:sans-serif,sans-serif;font-size:14px;font-weight:600;color:#3e5a7c}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .composer--spoiler{transition:height .4s ease,opacity .4s ease}.composer--spoiler{height:0;transform-origin:bottom;opacity:0}.composer--spoiler.composer--spoiler--visible{height:36px;margin-bottom:11px;opacity:1}.composer--spoiler input{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px;padding:10px;width:100%;outline:0;color:#121a24;background:#fff;font-size:14px;font-family:inherit;resize:vertical}.composer--spoiler input::placeholder{color:#3e5a7c}.composer--spoiler input:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .composer--spoiler input{font-size:16px}}.single-column .composer--spoiler input{font-size:16px}.composer--warning{color:#121a24;margin-bottom:15px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.composer--warning a{color:#3e5a7c;font-weight:500;text-decoration:underline}.composer--warning a:active,.composer--warning a:focus,.composer--warning a:hover{text-decoration:none}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#d8a070}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-left:5px;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#d8a070;background:#d8a070}.composer--reply{margin:0 0 10px;border-radius:4px;padding:10px;background:#9baec8;min-height:23px;overflow-y:auto;flex:0 2 auto}.composer--reply>header{margin-bottom:5px;overflow:hidden}.composer--reply>header>.account.small{color:#121a24}.composer--reply>header>.cancel{float:right;line-height:24px}.composer--reply>.content{position:relative;margin:10px 0;padding:0 12px;font-size:14px;line-height:20px;color:#121a24;word-wrap:break-word;font-weight:400;overflow:visible;white-space:pre-wrap;padding-top:5px;overflow:hidden}.composer--reply>.content p,.composer--reply>.content pre,.composer--reply>.content blockquote{margin-bottom:20px;white-space:pre-wrap}.composer--reply>.content p:last-child,.composer--reply>.content pre:last-child,.composer--reply>.content blockquote:last-child{margin-bottom:0}.composer--reply>.content h1,.composer--reply>.content h2,.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{margin-top:20px;margin-bottom:20px}.composer--reply>.content h1,.composer--reply>.content h2{font-weight:700;font-size:18px}.composer--reply>.content h2{font-size:16px}.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{font-weight:500}.composer--reply>.content blockquote{padding-left:10px;border-left:3px solid #121a24;color:#121a24;white-space:normal}.composer--reply>.content blockquote p:last-child{margin-bottom:0}.composer--reply>.content b,.composer--reply>.content strong{font-weight:700}.composer--reply>.content em,.composer--reply>.content i{font-style:italic}.composer--reply>.content sub{font-size:smaller;text-align:sub}.composer--reply>.content ul,.composer--reply>.content ol{margin-left:1em}.composer--reply>.content ul p,.composer--reply>.content ol p{margin:0}.composer--reply>.content ul{list-style-type:disc}.composer--reply>.content ol{list-style-type:decimal}.composer--reply>.content a{color:#3e5a7c;text-decoration:none}.composer--reply>.content a:hover{text-decoration:underline}.composer--reply>.content a.mention:hover{text-decoration:none}.composer--reply>.content a.mention:hover span{text-decoration:underline}.composer--reply .emojione{width:20px;height:20px;margin:-5px 0 0}.compose-form__autosuggest-wrapper,.autosuggest-input{position:relative;width:100%}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.autosuggest-input label .autosuggest-textarea__textarea{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px 4px 0 0;padding:10px 32px 0 10px;width:100%;min-height:100px;outline:0;color:#121a24;background:#fff;font-size:14px;font-family:inherit;resize:none;scrollbar-color:initial}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::placeholder,.autosuggest-input label .autosuggest-textarea__textarea::placeholder{color:#3e5a7c}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::-webkit-scrollbar,.autosuggest-input label .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:disabled,.autosuggest-input label .autosuggest-textarea__textarea:disabled{background:#d9e1e8}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:focus,.autosuggest-input label .autosuggest-textarea__textarea:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}}.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}@media screen and (max-width: 600px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.composer--textarea--icons{display:block;position:absolute;top:29px;right:5px;bottom:5px;overflow:hidden}.composer--textarea--icons>.textarea_icon{display:block;margin:2px 0 0 2px;width:24px;height:24px;color:#3e5a7c;font-size:18px;line-height:24px;text-align:center;opacity:.8}.autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.autosuggest-textarea__suggestions{display:block;position:absolute;box-sizing:border-box;top:100%;border-radius:0 0 4px 4px;padding:6px;width:100%;color:#121a24;background:#d9e1e8;box-shadow:4px 4px 6px rgba(0,0,0,.4);font-size:14px;z-index:99;display:none}.autosuggest-textarea__suggestions--visible{display:block}.autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.autosuggest-textarea__suggestions__item>.account,.autosuggest-textarea__suggestions__item>.emoji,.autosuggest-textarea__suggestions__item>.autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.autosuggest-textarea__suggestions__item .autosuggest-hashtag{justify-content:space-between}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item .autosuggest-hashtag strong{font-weight:500}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item>.account.small .display-name>span{color:#3e5a7c}.composer--upload_form{overflow:hidden}.composer--upload_form>.content{display:flex;flex-direction:row;flex-wrap:wrap;font-family:inherit;padding:5px;overflow:hidden}.composer--upload_form--item{flex:1 1 0;margin:5px;min-width:40%}.composer--upload_form--item>div{position:relative;border-radius:4px;height:140px;width:100%;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;overflow:hidden}.composer--upload_form--item>div textarea{display:block;position:absolute;box-sizing:border-box;bottom:0;left:0;margin:0;border:0;padding:10px;width:100%;color:#d9e1e8;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);font-size:14px;font-family:inherit;font-weight:500;opacity:0;z-index:2;transition:opacity .1s ease}.composer--upload_form--item>div textarea:focus{color:#fff}.composer--upload_form--item>div textarea::placeholder{opacity:.54;color:#d9e1e8}.composer--upload_form--item>div>.close{mix-blend-mode:difference}.composer--upload_form--item.active>div textarea{opacity:1}.composer--upload_form--actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.composer--upload_form--actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.composer--upload_form--actions .icon-button:hover,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:active{color:#e6ebf0}.composer--upload_form--actions.active{opacity:1}.composer--upload_form--progress{display:flex;padding:10px;color:#9baec8;overflow:hidden}.composer--upload_form--progress>.fa{font-size:34px;margin-right:10px}.composer--upload_form--progress>.message{flex:1 1 auto}.composer--upload_form--progress>.message>span{display:block;font-size:12px;font-weight:500;text-transform:uppercase}.composer--upload_form--progress>.message>.backdrop{position:relative;margin-top:5px;border-radius:6px;width:100%;height:6px;background:#3e5a7c}.composer--upload_form--progress>.message>.backdrop>.tracker{position:absolute;top:0;left:0;height:6px;border-radius:6px;background:#d8a070}.compose-form__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.composer--options-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;height:27px;display:flex;justify-content:space-between;flex:0 0 auto}.composer--options{display:flex;flex:0 0 auto}.composer--options>*{display:inline-block;box-sizing:content-box;padding:0 3px;height:27px;line-height:27px;vertical-align:bottom}.composer--options>hr{display:inline-block;margin:0 3px;border-width:0 0 0 1px;border-style:none none none solid;border-color:transparent transparent transparent #c2c2c2;padding:0;width:0;height:27px;background:transparent}.compose--counter-wrapper{align-self:center;margin-right:4px}.composer--options--dropdown.open>.value{border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1);color:#fff;background:#d8a070;transition:none}.composer--options--dropdown.open.top>.value{border-radius:0 0 4px 4px;box-shadow:0 4px 4px rgba(0,0,0,.1)}.composer--options--dropdown--content{position:absolute;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);background:#fff;overflow:hidden;transform-origin:50% 0}.composer--options--dropdown--content--item{display:flex;align-items:center;padding:10px;color:#121a24;cursor:pointer}.composer--options--dropdown--content--item>.content{flex:1 1 auto;color:#3e5a7c}.composer--options--dropdown--content--item>.content:not(:first-child){margin-left:10px}.composer--options--dropdown--content--item>.content strong{display:block;color:#121a24;font-weight:500}.composer--options--dropdown--content--item:hover,.composer--options--dropdown--content--item.active{background:#d8a070;color:#fff}.composer--options--dropdown--content--item:hover>.content,.composer--options--dropdown--content--item.active>.content{color:#fff}.composer--options--dropdown--content--item:hover>.content strong,.composer--options--dropdown--content--item.active>.content strong{color:#fff}.composer--options--dropdown--content--item.active:hover{background:#dcab80}.composer--publisher{padding-top:10px;text-align:right;white-space:nowrap;overflow:hidden;justify-content:flex-end;flex:0 0 auto}.composer--publisher>.primary{display:inline-block;margin:0;padding:0 10px;text-align:center}.composer--publisher>.side_arm{display:inline-block;margin:0 2px;padding:0;width:36px;text-align:center}.composer--publisher.over>.count{color:#ff5050}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#040609;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#121a24}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.column{overflow:hidden}.column-back-button{box-sizing:border-box;width:100%;background:#192432;color:#d8a070;cursor:pointer;flex:0 0 auto;font-size:16px;border:0;text-align:unset;padding:15px;margin:0;z-index:3}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#192432;border:0;font-family:inherit;color:#d8a070;cursor:pointer;flex:0 0 auto;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.column-link{background:#202e3f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#253549}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#d8a070}.column-link__icon{display:inline-block;margin-right:5px}.column-subheading{background:#121a24;color:#3e5a7c;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(216,160,112,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(216, 160, 112, 0.23) 0%, rgba(216, 160, 112, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#192432;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden}.column-header>button{margin:0;border:none;padding:15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#d8a070}.column-header.active .column-header__icon{color:#d8a070;text-shadow:0 0 10px rgba(216,160,112,.4)}.column-header:focus,.column-header:active{outline:0}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden}.wide .columns-area:not(.columns-area--mobile) .column{flex:auto;min-width:330px;max-width:400px}.column>.scrollable{background:#121a24}.column-header__buttons{height:48px;display:flex;margin-left:0}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button,.column-header__notif-cleaning-buttons button{background:#192432;border:0;color:#9baec8;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover,.column-header__notif-cleaning-buttons button:hover{color:#b2c1d5}.column-header__button.active,.column-header__notif-cleaning-buttons button.active{color:#fff;background:#202e3f}.column-header__button.active:hover,.column-header__notif-cleaning-buttons button.active:hover{color:#fff;background:#202e3f}.column-header__button:focus,.column-header__notif-cleaning-buttons button:focus{text-shadow:0 0 4px #d3935c}.column-header__notif-cleaning-buttons{display:flex;align-items:stretch;justify-content:space-around}.column-header__notif-cleaning-buttons button{background:transparent;text-align:center;padding:10px 0;white-space:pre-wrap}.column-header__notif-cleaning-buttons b{font-weight:bold}.column-header__collapsible-inner.nopad-drawer{padding:0}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#9baec8;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #26374d;margin:10px 0}.column-header__collapsible.ncd{transition:none}.column-header__collapsible.ncd.collapsed{max-height:0;opacity:.7}.column-header__collapsible-inner{background:#202e3f;padding:15px}.column-header__setting-btn:hover{color:#9baec8;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.column-header__title{display:inline-block;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header__icon{display:inline-block;margin-right:5px}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#3e5a7c;background:#121a24;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#d8a070;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#0b1016;contain:initial}.error-column{flex-direction:column}.single-column.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}@media screen and (max-width: 415px){.auto-columns.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}}@media screen and (max-width: 415px){.auto-columns.navbar-under .react-swipeable-view-container .columns-area,.single-column.navbar-under .react-swipeable-view-container .columns-area{height:100% !important}}.column-inline-form{padding:7px 15px;padding-right:5px;display:flex;justify-content:flex-start;align-items:center;background:#192432}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%;margin-bottom:6px}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 5px}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#3e5a7c;background:#121a24;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#3e5a7c}.regeneration-indicator__label span{font-size:15px;font-weight:400}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#000;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#192432;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#121a24;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #202e3f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.filter-form{background:#121a24}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#e1b590;background:#e1b590}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#a8b9cf}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#192432}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:color,transform,opacity;font-size:18px;width:18px;height:18px;color:#d9e1e8;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(0deg)}.search__icon .fa-search.active{pointer-events:auto;opacity:.3}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#3e5a7c;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#4a6b94}.search-results__header{color:#3e5a7c;background:#151f2b;border-bottom:1px solid #0b1016;padding:15px 10px;font-size:14px;font-weight:500}.search-results__info{padding:20px;color:#9baec8;text-align:center}.trends__header{color:#3e5a7c;background:#151f2b;border-bottom:1px solid #0b1016;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #202e3f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#3e5a7c;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#9baec8;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#d9e1e8}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(216,160,112,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#dfb088 !important}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.doodle-modal{width:unset}.doodle-modal__container{background:#d9e1e8;text-align:center;line-height:0}.doodle-modal__container canvas{border:5px solid #d9e1e8}.doodle-modal__action-bar .filler{flex-grow:1;margin:0;padding:0}.doodle-modal__action-bar .doodle-toolbar{line-height:1;display:flex;flex-direction:column;flex-grow:0;justify-content:space-around}.doodle-modal__action-bar .doodle-toolbar.with-inputs label{display:inline-block;width:70px;text-align:right;margin-right:2px}.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=number],.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=text]{width:40px}.doodle-modal__action-bar .doodle-toolbar.with-inputs span.val{display:inline-block;text-align:left;width:50px}.doodle-modal__action-bar .doodle-palette{padding-right:0 !important;border:1px solid #000;line-height:.2rem;flex-grow:0;background:#fff}.doodle-modal__action-bar .doodle-palette button{appearance:none;width:1rem;height:1rem;margin:0;padding:0;text-align:center;color:#000;text-shadow:0 0 1px #fff;cursor:pointer;box-shadow:inset 0 0 1px rgba(255,255,255,.5);border:1px solid #000;outline-offset:-1px}.doodle-modal__action-bar .doodle-palette button.foreground{outline:1px dashed #fff}.doodle-modal__action-bar .doodle-palette button.background{outline:1px dashed red}.doodle-modal__action-bar .doodle-palette button.foreground.background{outline:1px dashed red;border-color:#fff}.drawer{width:300px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden;padding:10px 5px;flex:none}.drawer:first-child{padding-left:10px}.drawer:last-child{padding-right:10px}@media screen and (max-width: 630px){.auto-columns .drawer{flex:auto}}.single-column .drawer{flex:auto}@media screen and (max-width: 630px){.auto-columns .drawer,.auto-columns .drawer:first-child,.auto-columns .drawer:last-child,.single-column .drawer,.single-column .drawer:first-child,.single-column .drawer:last-child{padding:0}}.wide .drawer{min-width:300px;max-width:400px;flex:1 1 200px}@media screen and (max-width: 630px){:root .auto-columns .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}}:root .single-column .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}.react-swipeable-view-container .drawer{height:100%}.drawer--header{display:flex;flex-direction:row;margin-bottom:10px;flex:none;background:#202e3f;font-size:16px}.drawer--header>*{display:block;box-sizing:border-box;border-bottom:2px solid transparent;padding:15px 5px 13px;height:48px;flex:1 1 auto;color:#9baec8;text-align:center;text-decoration:none;cursor:pointer}.drawer--header a{transition:background 100ms ease-in}.drawer--header a:focus,.drawer--header a:hover{outline:none;background:#17212e;transition:background 200ms ease-out}.search{position:relative;margin-bottom:10px;flex:none}@media screen and (max-width: 415px){.auto-columns .search,.single-column .search{margin-bottom:0}}@media screen and (max-width: 630px){.auto-columns .search{font-size:16px}}.single-column .search{font-size:16px}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#121a24}.drawer--account{padding:10px;color:#9baec8;display:flex;align-items:center}.drawer--account a{color:inherit;text-decoration:none}.drawer--account .acct{display:block;color:#d9e1e8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;overflow:hidden}.drawer--results{background:#121a24;overflow-x:hidden;overflow-y:auto}.drawer--results>header{color:#3e5a7c;background:#151f2b;padding:15px;font-weight:500;font-size:16px;cursor:default}.drawer--results>header .fa{display:inline-block;margin-right:5px}.drawer--results>section{margin-bottom:5px}.drawer--results>section h5{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#3e5a7c}.drawer--results>section h5 .fa{display:inline-block;margin-right:5px}.drawer--results>section .account:last-child,.drawer--results>section>div:last-child .status{border-bottom:0}.drawer--results>section>.hashtag{display:block;padding:10px;color:#d9e1e8;text-decoration:none}.drawer--results>section>.hashtag:hover,.drawer--results>section>.hashtag:active,.drawer--results>section>.hashtag:focus{color:#e6ebf0;text-decoration:underline}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#283a50;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%}.drawer__inner.darker{background:#121a24}.drawer__inner__mastodon{background:#283a50 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}.drawer__inner__mastodon>.mastodon{display:block;width:100%;height:100%;border:none;cursor:inherit}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#283a50;font-size:13px;text-align:left}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#9baec8;border:0;width:100%;height:100%}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{color:#b5c3d6}.status__content>.media-spoiler{margin-top:15px}.media-spoiler.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:500}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{height:100%;display:flex;flex-direction:column}.media-gallery__audio span{text-align:center;color:#9baec8;display:flex;height:100%;align-items:center}.media-gallery__audio span p{width:100%}.media-gallery__audio audio{width:100%}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%;height:110px}.media-gallery.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-gallery__item{border:none;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.full-width .media-gallery__item{border-radius:0}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item.letterbox{background:#000}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#d9e1e8;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%;object-fit:contain}.media-gallery__item-thumbnail:not(.letterbox),.media-gallery__item-thumbnail img:not(.letterbox){height:100%;object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%;display:flex;justify-content:center}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;width:100%;position:relative;z-index:1;object-fit:contain;user-select:none}.media-gallery__item-gifv-thumbnail:not(.letterbox){height:100%;object-fit:cover}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#d8a070}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#040609;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #192432;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(225,181,144,.5)}.audio-player__wave-placeholder{background-color:#2d415a}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#040609;border-top:1px solid #192432;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.detailed-status .video-player{width:100%;height:100%}.video-player.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.video-player video{max-width:100vw;max-height:80vh;z-index:1;position:relative}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#9baec8;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#b2c1d5}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons-bar .video-player__download__icon .fa,.video-player__buttons-bar .video-player__download__icon:active .fa,.video-player__buttons-bar .video-player__download__icon:hover .fa,.video-player__buttons-bar .video-player__download__icon:focus .fa{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#e1b590}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#e1b590}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.sensitive-info{display:flex;flex-direction:row;align-items:center;position:absolute;top:4px;left:4px;z-index:100}.sensitive-marker{margin:0 3px;border-radius:2px;padding:2px 6px;color:rgba(255,255,255,.8);background:rgba(0,0,0,.5);font-size:12px;line-height:18px;text-transform:uppercase;opacity:.9;transition:opacity .1s ease}.media-gallery:hover .sensitive-marker{opacity:1}.list-editor{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#283a50;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#283a50}.list-adder__lists{background:#283a50;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #202e3f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.emoji-mart{font-size:13px;display:inline-block;color:#121a24}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#3e5a7c;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#37506f}.emoji-mart-anchor-selected{color:#d8a070}.emoji-mart-anchor-selected:hover{color:#d49560}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#d59864}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#121a24;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#9baec8}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.glitch.local-settings{position:relative;display:flex;flex-direction:row;background:#d9e1e8;color:#121a24;border-radius:8px;height:80vh;width:80vw;max-width:740px;max-height:450px;overflow:hidden}.glitch.local-settings label,.glitch.local-settings legend{display:block;font-size:14px}.glitch.local-settings .boolean label,.glitch.local-settings .radio_buttons label{position:relative;padding-left:28px;padding-top:3px}.glitch.local-settings .boolean label input,.glitch.local-settings .radio_buttons label input{position:absolute;left:0;top:0}.glitch.local-settings span.hint{display:block;color:#3e5a7c}.glitch.local-settings h1{font-size:18px;font-weight:500;line-height:24px;margin-bottom:20px}.glitch.local-settings h2{font-size:15px;font-weight:500;line-height:20px;margin-top:20px;margin-bottom:10px}.glitch.local-settings__navigation__item{display:block;padding:15px 20px;color:inherit;background:#f2f5f7;border-bottom:1px #d9e1e8 solid;cursor:pointer;text-decoration:none;outline:none;transition:background .3s}.glitch.local-settings__navigation__item .text-icon-button{color:inherit;transition:unset}.glitch.local-settings__navigation__item:hover{background:#d9e1e8}.glitch.local-settings__navigation__item.active{background:#d8a070;color:#fff}.glitch.local-settings__navigation__item.close,.glitch.local-settings__navigation__item.close:hover{background:#df405a;color:#fff}.glitch.local-settings__navigation{background:#f2f5f7;width:212px;font-size:15px;line-height:20px;overflow-y:auto}.glitch.local-settings__page{display:block;flex:auto;padding:15px 20px 15px 20px;width:360px;overflow-y:auto}.glitch.local-settings__page__item{margin-bottom:2px}.glitch.local-settings__page__item.string,.glitch.local-settings__page__item.radio_buttons{margin-top:10px;margin-bottom:10px}@media screen and (max-width: 630px){.glitch.local-settings__navigation{width:40px;flex-shrink:0}.glitch.local-settings__navigation__item{padding:10px}.glitch.local-settings__navigation__item span:last-of-type{display:none}}.error-boundary{color:#fff;font-size:15px;line-height:20px}.error-boundary h1{font-size:26px;line-height:36px;font-weight:400;margin-bottom:8px}.error-boundary a{color:#fff;text-decoration:underline}.error-boundary ul{list-style:disc;margin-left:0;padding-left:1em}.error-boundary textarea.web_app_crash-stacktrace{width:100%;resize:none;white-space:pre;font-family:monospace,monospace}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.compose-panel .search__icon .fa{top:15px}.compose-panel .drawer--account{flex:0 1 48px}.compose-panel .flex-spacer{background:transparent}.compose-panel .composer{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #192432;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px;min-height:50px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{padding-top:15px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#d59864;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#e0b38c}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.search{margin-bottom:10px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#d9e1e8;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#e1b590}.announcements{background:#202e3f;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#d8a070;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#9baec8;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#26374d;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#9baec8}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#2d415a;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#a8b9cf}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#4a4c54}.reactions-bar__item.active .reactions-bar__item__count{color:#e1b590}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#9baec8;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#a8b9cf;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll ul,.e-content .poll ul{margin:0;list-style:none}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#8ba1bf;height:5px;min-width:1%}.poll__chart.leading{background:#d8a070}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#121a24;display:block;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#d8a070}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#acd6c1;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#3e5a7c}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#3e5a7c;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(62,90,124,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb;overflow-x:hidden}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{width:100%;flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#d8a070}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#3e5a7c;border-color:#3e5a7c;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#121a24;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#3e5a7c}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(216,160,112,.2)}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:sans-serif,sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#9baec8}.rich-formatting a{color:#d8a070;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#9baec8}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#d9e1e8}.rich-formatting em{font-style:italic;color:#d9e1e8}.rich-formatting code{font-size:.85em;background:#040609;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:sans-serif,sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#d9e1e8}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #192432;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #192432;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#9baec8}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#3e5a7c}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#0b1016;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:sans-serif,sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#d9e1e8}.information-board__section strong{font-family:sans-serif,sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#040609;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#9baec8;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #192432;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#7a93b6}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;width:80px;height:80px;background-size:80px 80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px;border-radius:8%;background-position:50%;background-clip:padding-box}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#9baec8}.landing-page p,.landing-page li{font-family:sans-serif,sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#9baec8}.landing-page p a,.landing-page li a{color:#d8a070;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.landing-page h1{font-family:sans-serif,sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h1 small{font-family:sans-serif,sans-serif;display:block;font-size:18px;font-weight:400;color:#bcc9da}.landing-page h2{font-family:sans-serif,sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h3{font-family:sans-serif,sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h4{font-family:sans-serif,sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h5{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h6{font-family:sans-serif,sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#121a24;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#bcc9da}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px;width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#121a24;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#d9e1e8}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#9baec8}.landing-page__short-description h1 small span{color:#d9e1e8}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#121a24;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:sans-serif,sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#9baec8}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#9baec8}.landing .simple_form p.lead{color:#9baec8;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #202e3f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#3e5a7c}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #121a24;text-align:left;background:#0b1016}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #121a24;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#121a24}.table a{color:#d8a070;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#121a24;border-top:1px solid #040609;border-bottom:1px solid #040609}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #040609}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #040609}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:monospace,monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#9baec8;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #040609;background:#121a24;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #040609;border-top:0;background:#121a24}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #040609;border-top:0;background:#0b1016}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #040609}}.batch-table__row:hover{background:#0f151d}.batch-table__row:nth-child(even){background:#121a24}.batch-table__row:nth-child(even):hover{background:#151f2b}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#9baec8;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #040609;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #040609}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#121a24;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#202e3f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#9baec8;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#26374d}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#9baec8;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#0a0e13;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#0f151d;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#0b1016;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#d8a070;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#ddad84}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #202e3f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#d9e1e8;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#d9e1e8;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#9baec8;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #202e3f}.admin-wrapper .content h6{font-size:16px;color:#d9e1e8;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:none}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#d9e1e8;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #192432;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#d8a070}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#9baec8}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#d8a070}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#3e5a7c;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#9baec8;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #121a24}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #1b2635}.filters .filter-subset a.selected{color:#d8a070;border-bottom:2px solid #d8a070}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#d9e1e8}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#d8a070;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#121a24;border-bottom:1px solid #192432}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#9baec8;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#3e5a7c}.log-entry a,.log-entry .username,.log-entry .target{color:#d9e1e8;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#d9e1e8}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #d8a070}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#9baec8}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#3e5a7c}.report-card{background:#121a24;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#9baec8;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#b5c3d6}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #0b1016}.report-card__summary__item:hover{background:#151f2b}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#9baec8}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#3e5a7c;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#9baec8}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#202e3f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#e1b590}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.announcements-list{border:1px solid #192432;border-radius:4px}.announcements-list__item{padding:15px 0;background:#121a24;border-bottom:1px solid #192432}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#d9e1e8;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#fff}.announcements-list__item__meta{padding:0 15px;color:#3e5a7c}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff)}.hicolor-privacy-icons .status__visibility-icon.fa-globe,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-globe{color:#1976d2}.hicolor-privacy-icons .status__visibility-icon.fa-unlock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-unlock{color:#388e3c}.hicolor-privacy-icons .status__visibility-icon.fa-lock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-lock{color:#ffa000}.hicolor-privacy-icons .status__visibility-icon.fa-envelope,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-envelope{color:#d32f2f}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .composer--publisher{text-align:left}body.rtl .boost-modal__status-time,body.rtl .favourite-modal__status-time{float:left}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .setting-meta__label{float:left}body.rtl .status__avatar{margin-left:10px;margin-right:0;left:auto;right:10px}body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:58px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left;text-align:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(1, 1, 2, 0), #010102)}body.rtl .simple_form select{background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#192432;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#202e3f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:sans-serif,sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#9baec8;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a; // Padua\n$error-red: #df405a; // Cerise\n$warning-red: #ff5050; // Sunset Orange\n$gold-star: #ca8f04; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: sans-serif !default;\n$font-display: sans-serif !default;\n$font-monospace: monospace !default;\n\n// Avatar border size (8% default, 100% for rounded avatars)\n$ui-avatar-border-size: 8%;\n\n// More variables\n$dismiss-overlay-width: 4rem;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n @return '%23' + unquote($color)\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n @include avatar-size(40px);\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1/3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1/3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n @include avatar-size(120px);\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n @include avatar-radius();\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n","@mixin avatar-radius() {\n border-radius: $ui-avatar-border-size;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size:48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin single-column($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .single-column #{$parent} {\n @content;\n }\n}\n\n@mixin limited-single-column($media, $parent: '&') {\n .auto-columns #{$parent}, .single-column #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n}\n\n@mixin multi-columns($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .multi-columns #{$parent} {\n @content;\n }\n}\n\n@mixin fullwidth-gallery {\n &.full-width {\n margin-left: -14px;\n margin-right: -14px;\n width: inherit;\n max-width: none;\n height: 250px;\n border-radius: 0px;\n }\n}\n\n@mixin search-input() {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: none;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout() {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n padding: 8px 0;\n padding-bottom: 2px;\n margin: initial;\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n position: absolute;\n margin: initial;\n float: initial;\n width: auto;\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n\n// Styling from upstream's WebUI, as public pages use the same layout\n.embed,\n.public-layout {\n .status {\n .status__info {\n font-size: 15px;\n display: initial;\n }\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding: 6px 0;\n padding-right: 25px;\n margin: initial;\n\n .display-name strong {\n display: inline;\n }\n }\n\n .status__avatar {\n height: 48px;\n position: absolute;\n width: 48px;\n margin: initial;\n }\n }\n}\n\n.rtl {\n .embed,\n .public-layout {\n .status {\n padding-left: 10px;\n padding-right: 68px;\n\n .status__info .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .status__relative-time {\n float: left;\n }\n }\n }\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: darken($ui-highlight-color, 3%);\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n transition-property: background-color;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 7%);\n transition: all 200ms ease-out;\n transition-property: background-color;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n color: $darker-text-color;\n text-transform: none;\n background: transparent;\n padding: 3px 15px;\n border-radius: 4px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n transform-origin: 50% 0;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: 0;\n position: absolute;\n\n .fa.star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n a {\n color: inherit;\n text-decoration: inherit;\n }\n\n strong {\n height: 18px;\n font-size: 16px;\n font-weight: 500;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n span {\n display: block;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n > a:hover {\n strong {\n text-decoration: underline;\n }\n }\n\n &.inline {\n padding: 0;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n strong {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n\n span {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n }\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n ul {\n list-style: none;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.static-content {\n padding: 10px;\n padding-top: 20px;\n color: $dark-text-color;\n\n h1 {\n font-size: 16px;\n font-weight: 500;\n margin-bottom: 40px;\n text-align: center;\n }\n\n p {\n font-size: 13px;\n margin-bottom: 20px;\n }\n}\n\n.column,\n.drawer {\n flex: 1 1 100%;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @include multi-columns('screen and (min-width: 631px)') {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $ui-highlight-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n\n span.icon {\n margin-left: 0;\n display: inline;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.getting-started__wrapper,\n.getting_started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.getting-started__wrapper {\n position: relative;\n overflow-y: auto;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n background: $ui-base-color;\n flex: 1 0 auto;\n\n p {\n color: $secondary-text-color;\n }\n\n a {\n color: $dark-text-color;\n }\n\n &__panel {\n height: min-content;\n }\n\n &__panel,\n &__footer {\n padding: 10px;\n padding-top: 20px;\n flex: 0 1 auto;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n color: $darker-text-color;\n background: transparent;\n border: none;\n border-bottom: 2px solid $ui-primary-color;\n box-sizing: border-box;\n display: block;\n font-family: inherit;\n margin-bottom: 10px;\n padding: 7px 0;\n width: 100%;\n\n &:focus,\n &:active {\n color: $primary-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n font-size: 16px;\n }\n\n &.light {\n color: $inverted-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 27%);\n\n &:focus,\n &:active {\n color: $inverted-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.reduce-motion button.icon-button.disabled i.fa-retweet {\n color: darken($action-button-color, 13%);\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.missing-indicator {\n padding-top: 20px + 48px;\n}\n\n.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {\n border-top: 1px solid $ui-base-color;\n}\n\n.notification__dismiss-overlay {\n overflow: hidden;\n position: absolute;\n top: 0;\n right: 0;\n bottom: -1px;\n padding-left: 15px; // space for the box shadow to be visible\n\n z-index: 999;\n align-items: center;\n justify-content: flex-end;\n cursor: pointer;\n\n display: flex;\n\n .wrappy {\n width: $dismiss-overlay-width;\n align-self: stretch;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: lighten($ui-base-color, 8%);\n border-left: 1px solid lighten($ui-base-color, 20%);\n box-shadow: 0 0 5px black;\n border-bottom: 1px solid $ui-base-color;\n }\n\n .ckbox {\n border: 2px solid $ui-primary-color;\n border-radius: 2px;\n width: 30px;\n height: 30px;\n font-size: 20px;\n color: $darker-text-color;\n text-shadow: 0 0 5px black;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n &:focus {\n outline: 0 !important;\n\n .ckbox {\n box-shadow: 0 0 1px 1px $ui-highlight-color;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: flex;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n align-items: center;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label,\n.setting-radio__label,\n.setting-meta__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.setting-radio {\n display: block;\n line-height: 18px;\n}\n\n.setting-radio__label {\n margin-bottom: 0;\n}\n\n.column-settings__row legend {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-top: 10px;\n}\n\n.setting-radio__input {\n vertical-align: middle;\n}\n\n.setting-meta__label {\n float: right;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n transform-origin: center center;\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.pulse-loading {\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.loading-bar {\n background-color: $ui-highlight-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.icon-badge-wrapper {\n position: relative;\n}\n\n.icon-badge {\n position: absolute;\n display: block;\n right: -.25em;\n top: -.25em;\n background-color: $ui-highlight-color;\n border-radius: 50%;\n font-size: 75%;\n width: 1em;\n height: 1em;\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n .status__content {\n margin: 0;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.ui .flash-message {\n margin-top: 10px;\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 0;\n min-width: 75%;\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@import 'boost';\n@import 'accounts';\n@import 'domains';\n@import 'status';\n@import 'modal';\n@import 'composer';\n@import 'columns';\n@import 'regeneration_indicator';\n@import 'directory';\n@import 'search';\n@import 'emoji';\n@import 'doodle';\n@import 'drawer';\n@import 'media';\n@import 'sensitive';\n@import 'lists';\n@import 'emoji_picker';\n@import 'local_settings';\n@import 'error_boundary';\n@import 'single_column';\n@import 'announcements';\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant\nbutton.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant for use with DMs\n.status-direct button.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n",".account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n color: inherit;\n text-decoration: none;\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n\n &.small {\n border: none;\n padding: 0;\n\n & > .account__avatar-wrapper { margin: 0 8px 0 0 }\n\n & > .display-name {\n height: 24px;\n line-height: 24px;\n }\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius();\n position: relative;\n cursor: pointer;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n overflow: hidden;\n position: relative;\n\n & div {\n @include avatar-radius;\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\n.account__avatar-overlay {\n position: relative;\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius();\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius();\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__header__wrapper {\n flex: 0 0 auto;\n background: lighten($ui-base-color, 4%);\n}\n\n.account__disclaimer {\n padding: 10px;\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-left: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &:first-child {\n border-left: 0;\n }\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n abbr {\n color: $highlight-text-color;\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.notification__message {\n margin-left: 42px;\n padding: 8px 0 0 26px;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input();\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout();\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n",".domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n","@keyframes spring-flip-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-242.4deg);\n }\n\n 60% {\n transform: rotate(-158.35deg);\n }\n\n 90% {\n transform: rotate(-187.5deg);\n }\n\n 100% {\n transform: rotate(-180deg);\n }\n}\n\n@keyframes spring-flip-out {\n 0% {\n transform: rotate(-180deg);\n }\n\n 30% {\n transform: rotate(62.4deg);\n }\n\n 60% {\n transform: rotate(-21.635deg);\n }\n\n 90% {\n transform: rotate(7.5deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content {\n position: relative;\n margin: 10px 0;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n padding-top: 5px;\n\n &:focus {\n outline: 0;\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .status__content__text,\n .e-content {\n overflow: hidden;\n\n & > ul,\n & > ol {\n margin-bottom: 20px;\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 1.2em;\n }\n\n h2 {\n font-size: 1.1em;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $darker-text-color;\n color: $darker-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n sup {\n font-size: smaller;\n vertical-align: super;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n .status__content__spoiler {\n display: none;\n\n &.status__content__spoiler--visible {\n display: block;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n\n .link-origin-tag {\n color: $gold-star;\n font-size: 0.8em;\n }\n }\n\n .status__content__spoiler-link {\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: lighten($ui-base-color, 30%);\n border: none;\n color: $inverted-text-color;\n font-weight: 500;\n font-size: 11px;\n padding: 0 5px;\n text-transform: uppercase;\n line-height: inherit;\n cursor: pointer;\n vertical-align: bottom;\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n\n .status__content__spoiler-icon {\n display: inline-block;\n margin: 0 0 0 5px;\n border-left: 1px solid currentColor;\n padding: 0 0 0 4px;\n font-size: 16px;\n vertical-align: -2px;\n }\n}\n\n.notif-cleaning {\n .status,\n .notification-follow,\n .notification-follow-request {\n padding-right: ($dismiss-overlay-width + 0.5rem);\n }\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.notification-follow,\n.notification-follow-request {\n position: relative;\n\n // same like Status\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .account {\n border-bottom: 0 none;\n }\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n &.status.status-direct:not(.read) {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 10px 14px;\n position: relative;\n height: auto;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 28px; // 12px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $lighter-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n\n &.collapsed {\n background-position: center;\n background-size: cover;\n user-select: none;\n\n &.has-background::before {\n display: block;\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));\n pointer-events: none;\n content: \"\";\n }\n\n .display-name:hover .display-name__html {\n text-decoration: none;\n }\n\n .status__content {\n height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 0;\n\n &:after {\n content: \"\";\n position: absolute;\n top: 0; bottom: 0;\n left: 0; right: 0;\n background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));\n pointer-events: none;\n }\n \n a:hover {\n text-decoration: none;\n }\n }\n &:focus > .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));\n }\n &.status-direct:not(.read)> .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));\n }\n\n .notification__message {\n margin-bottom: 0;\n }\n\n .status__info .notification__message > span {\n white-space: nowrap;\n }\n }\n\n .notification__message {\n margin: -10px 0px 10px 0;\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time {\n display: inline-block;\n flex-grow: 1;\n color: $dark-text-color;\n font-size: 14px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.status__display-name {\n color: $dark-text-color;\n overflow: hidden;\n}\n\n.status__info__account .status__display-name {\n display: block;\n max-width: 100%;\n}\n\n.status__info {\n display: flex;\n justify-content: space-between;\n font-size: 15px;\n\n > span {\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .notification__message > span {\n word-wrap: break-word;\n }\n}\n\n.status__info__icons {\n display: flex;\n align-items: center;\n height: 1em;\n color: $action-button-color;\n\n .status__media-icon,\n .status__visibility-icon,\n .status__reply-icon {\n padding-left: 2px;\n padding-right: 2px;\n }\n\n .status__collapse-button.active > .fa-angle-double-up {\n transform: rotate(-180deg);\n }\n}\n\n.no-reduce-motion .status__collapse-button {\n &.activate {\n & > .fa-angle-double-up {\n animation: spring-flip-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-angle-double-up {\n animation: spring-flip-out 1s linear;\n }\n }\n}\n\n.status__info__account {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-top: -10px;\n margin-bottom: 10px;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\na.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\n.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n flex: none;\n margin: 0 10px 0 0;\n height: 48px;\n width: 48px;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a,\n .status__content__text {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-color, 29%);\n text-decoration: none;\n }\n }\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n\n a .fa, a:hover .fa {\n color: inherit;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n.status__wrapper--filtered__button {\n display: inline;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n font-size: inherit;\n line-height: inherit;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.onboarding-modal__pager {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 470px;\n\n .react-swipeable-view-container > div {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n user-select: text;\n }\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n@media screen and (max-width: 550px) {\n .onboarding-modal {\n width: 100%;\n height: 100%;\n border-radius: 0;\n }\n\n .onboarding-modal__pager {\n width: 100%;\n height: auto;\n max-width: none;\n max-height: none;\n flex: 1 1 auto;\n }\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.onboarding-modal__dots {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.onboarding-modal__dot {\n width: 14px;\n height: 14px;\n border-radius: 14px;\n background: darken($ui-secondary-color, 16%);\n margin: 0 3px;\n cursor: pointer;\n\n &:hover {\n background: darken($ui-secondary-color, 18%);\n }\n\n &.active {\n cursor: default;\n background: darken($ui-secondary-color, 24%);\n }\n}\n\n.onboarding-modal__page__wrapper {\n pointer-events: none;\n padding: 25px;\n padding-bottom: 0;\n\n &.onboarding-modal__page__wrapper--active {\n pointer-events: auto;\n }\n}\n\n.onboarding-modal__page {\n cursor: default;\n line-height: 21px;\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 20px;\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 4%);\n }\n }\n\n .navigation-bar a {\n color: inherit;\n }\n\n p {\n font-size: 16px;\n color: $lighter-text-color;\n margin-top: 10px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n background: $ui-base-color;\n color: $secondary-text-color;\n border-radius: 4px;\n font-size: 14px;\n padding: 3px 6px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.onboarding-modal__page__wrapper-0 {\n height: 100%;\n padding: 0;\n}\n\n.onboarding-modal__page-one {\n &__lead {\n padding: 65px;\n padding-top: 45px;\n padding-bottom: 0;\n margin-bottom: 10px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n margin-bottom: 8px;\n }\n\n p {\n margin-bottom: 0;\n }\n }\n\n &__extra {\n padding-right: 65px;\n padding-left: 185px;\n text-align: center;\n }\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboarding-modal__page-two,\n.onboarding-modal__page-three,\n.onboarding-modal__page-four,\n.onboarding-modal__page-five {\n p {\n text-align: left;\n }\n\n .figure {\n background: darken($ui-base-color, 8%);\n color: $secondary-text-color;\n margin-bottom: 20px;\n border-radius: 4px;\n padding: 10px;\n text-align: center;\n font-size: 14px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);\n\n .onboarding-modal__image {\n border-radius: 4px;\n margin-bottom: 10px;\n }\n\n &.non-interactive {\n pointer-events: none;\n text-align: left;\n }\n }\n}\n\n.onboarding-modal__page-four__columns {\n .row {\n display: flex;\n margin-bottom: 20px;\n\n & > div {\n flex: 1 1 0;\n margin: 0 10px;\n\n &:first-child {\n margin-left: 0;\n }\n\n &:last-child {\n margin-right: 0;\n }\n\n p {\n text-align: center;\n }\n }\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .column-header {\n color: $primary-text-color;\n }\n}\n\n@media screen and (max-width: 320px) and (max-height: 600px) {\n .onboarding-modal__page p {\n font-size: 14px;\n line-height: 20px;\n }\n\n .onboarding-modal__page-two .figure,\n .onboarding-modal__page-three .figure,\n .onboarding-modal__page-four .figure,\n .onboarding-modal__page-five .figure {\n font-size: 12px;\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .row {\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .column-header {\n padding: 5px;\n font-size: 12px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.favourite-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__display-name {\n display: flex;\n }\n\n .status__avatar {\n height: 48px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container,\n.favourite-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.favourite-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header,\n.favourite-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time,\n.favourite-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n strong {\n display: block;\n font-weight: 500;\n }\n\n max-height: 80vh;\n max-width: 80vw;\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n & > .react-toggle,\n & > .icon,\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__do_not_ask_again {\n padding-left: 20px;\n padding-right: 20px;\n padding-bottom: 10px;\n\n font-size: 14px;\n\n label, input {\n vertical-align: middle;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: 'mastodon-font-monospace', monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.filtered-status-info {\n text-align: start;\n\n .spoiler__text {\n margin-top: 20px;\n }\n\n .account {\n border-bottom: 0;\n }\n\n .account__display-name strong {\n color: $inverted-text-color;\n }\n\n .status__content__spoiler {\n display: none;\n\n &--visible {\n display: flex;\n }\n }\n\n ul {\n padding: 10px;\n margin-left: 12px;\n list-style: disc inside;\n }\n\n .filtered-status-edit-link {\n color: $action-button-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline\n }\n }\n}\n",".composer {\n padding: 10px;\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n\n ::-webkit-scrollbar-track:hover,\n ::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .composer--spoiler {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.composer--spoiler {\n height: 0;\n transform-origin: bottom;\n opacity: 0.0;\n\n &.composer--spoiler--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1.0;\n }\n\n input {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px;\n padding: 10px;\n width: 100%;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: vertical;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n }\n}\n\n.composer--warning {\n color: $inverted-text-color;\n margin-bottom: 15px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:active,\n &:focus,\n &:hover { text-decoration: none }\n }\n}\n\n.compose-form__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-left: 5px;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n}\n\n.composer--reply {\n margin: 0 0 10px;\n border-radius: 4px;\n padding: 10px;\n background: $ui-primary-color;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n\n & > header {\n margin-bottom: 5px;\n overflow: hidden;\n\n & > .account.small { color: $inverted-text-color; }\n\n & > .cancel {\n float: right;\n line-height: 24px;\n }\n }\n\n & > .content {\n position: relative;\n margin: 10px 0;\n padding: 0 12px;\n font-size: 14px;\n line-height: 20px;\n color: $inverted-text-color;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n white-space: pre-wrap;\n padding-top: 5px;\n overflow: hidden;\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 18px;\n }\n\n h2 {\n font-size: 16px;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $inverted-text-color;\n color: $inverted-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n\n a {\n color: $lighter-text-color;\n text-decoration: none;\n\n &:hover { text-decoration: underline }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span { text-decoration: underline }\n }\n }\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -5px 0 0;\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.autosuggest-input {\n position: relative;\n width: 100%;\n\n label {\n .autosuggest-textarea__textarea {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px 4px 0 0;\n padding: 10px 32px 0 10px;\n width: 100%;\n min-height: 100px;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: none;\n scrollbar-color: initial;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n &:disabled { background: $ui-secondary-color }\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n }\n}\n\n.composer--textarea--icons {\n display: block;\n position: absolute;\n top: 29px;\n right: 5px;\n bottom: 5px;\n overflow: hidden;\n\n & > .textarea_icon {\n display: block;\n margin: 2px 0 0 2px;\n width: 24px;\n height: 24px;\n color: $lighter-text-color;\n font-size: 18px;\n line-height: 24px;\n text-align: center;\n opacity: .8;\n }\n}\n\n.autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n}\n\n.autosuggest-textarea__suggestions {\n display: block;\n position: absolute;\n box-sizing: border-box;\n top: 100%;\n border-radius: 0 0 4px 4px;\n padding: 6px;\n width: 100%;\n color: $inverted-text-color;\n background: $ui-secondary-color;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n font-size: 14px;\n z-index: 99;\n display: none;\n}\n\n.autosuggest-textarea__suggestions--visible {\n display: block;\n}\n\n.autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected { background: darken($ui-secondary-color, 10%) }\n\n > .account,\n > .emoji,\n > .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n & > .account.small {\n .display-name {\n & > span { color: $lighter-text-color }\n }\n }\n}\n\n.composer--upload_form {\n overflow: hidden;\n\n & > .content {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n font-family: inherit;\n padding: 5px;\n overflow: hidden;\n }\n}\n\n.composer--upload_form--item {\n flex: 1 1 0;\n margin: 5px;\n min-width: 40%;\n\n & > div {\n position: relative;\n border-radius: 4px;\n height: 140px;\n width: 100%;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n overflow: hidden;\n\n textarea {\n display: block;\n position: absolute;\n box-sizing: border-box;\n bottom: 0;\n left: 0;\n margin: 0;\n border: 0;\n padding: 10px;\n width: 100%;\n color: $secondary-text-color;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n font-size: 14px;\n font-family: inherit;\n font-weight: 500;\n opacity: 0;\n z-index: 2;\n transition: opacity .1s ease;\n\n &:focus { color: $white }\n\n &::placeholder {\n opacity: 0.54;\n color: $secondary-text-color;\n }\n }\n\n & > .close { mix-blend-mode: difference }\n }\n\n &.active {\n & > div {\n textarea { opacity: 1 }\n }\n }\n}\n\n.composer--upload_form--actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $ui-secondary-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($ui-secondary-color, 4%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n}\n\n.composer--upload_form--progress {\n display: flex;\n padding: 10px;\n color: $darker-text-color;\n overflow: hidden;\n\n & > .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n & > .message {\n flex: 1 1 auto;\n\n & > span {\n display: block;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n & > .backdrop {\n position: relative;\n margin-top: 5px;\n border-radius: 6px;\n width: 100%;\n height: 6px;\n background: $ui-base-lighter-color;\n\n & > .tracker {\n position: absolute;\n top: 0;\n left: 0;\n height: 6px;\n border-radius: 6px;\n background: $ui-highlight-color;\n }\n }\n }\n}\n\n.compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n}\n\n.composer--options-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n height: 27px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n}\n\n.composer--options {\n display: flex;\n flex: 0 0 auto;\n\n & > * {\n display: inline-block;\n box-sizing: content-box;\n padding: 0 3px;\n height: 27px;\n line-height: 27px;\n vertical-align: bottom;\n }\n\n & > hr {\n display: inline-block;\n margin: 0 3px;\n border-width: 0 0 0 1px;\n border-style: none none none solid;\n border-color: transparent transparent transparent darken($simple-background-color, 24%);\n padding: 0;\n width: 0;\n height: 27px;\n background: transparent;\n }\n}\n\n.compose--counter-wrapper {\n align-self: center;\n margin-right: 4px;\n}\n\n.composer--options--dropdown {\n &.open {\n & > .value {\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n color: $primary-text-color;\n background: $ui-highlight-color;\n transition: none;\n }\n &.top {\n & > .value {\n border-radius: 0 0 4px 4px;\n box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);\n }\n }\n }\n}\n\n.composer--options--dropdown--content {\n position: absolute;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n background: $simple-background-color;\n overflow: hidden;\n transform-origin: 50% 0;\n}\n\n.composer--options--dropdown--content--item {\n display: flex;\n align-items: center;\n padding: 10px;\n color: $inverted-text-color;\n cursor: pointer;\n\n & > .content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n &:not(:first-child) { margin-left: 10px }\n\n strong {\n display: block;\n color: $inverted-text-color;\n font-weight: 500;\n }\n }\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n\n & > .content {\n color: $primary-text-color;\n\n strong { color: $primary-text-color }\n }\n }\n\n &.active:hover { background: lighten($ui-highlight-color, 4%) }\n}\n\n.composer--publisher {\n padding-top: 10px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n justify-content: flex-end;\n flex: 0 0 auto;\n\n & > .primary {\n display: inline-block;\n margin: 0;\n padding: 0 10px;\n text-align: center;\n }\n\n & > .side_arm {\n display: inline-block;\n margin: 0 2px;\n padding: 0;\n width: 36px;\n text-align: center;\n }\n\n &.over {\n & > .count { color: $warning-red }\n }\n}\n",".column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.column {\n overflow: hidden;\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n\n & > button {\n margin: 0;\n border: none;\n padding: 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n\n .wide .columns-area:not(.columns-area--mobile) & {\n flex: auto;\n min-width: 330px;\n max-width: 400px;\n }\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n margin-left: 0;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n\n // glitch - added focus ring for keyboard navigation\n &:focus {\n text-shadow: 0 0 4px darken($ui-highlight-color, 5%);\n }\n}\n\n.column-header__notif-cleaning-buttons {\n display: flex;\n align-items: stretch;\n justify-content: space-around;\n\n button {\n @extend .column-header__button;\n background: transparent;\n text-align: center;\n padding: 10px 0;\n white-space: pre-wrap;\n }\n\n b {\n font-weight: bold;\n }\n}\n\n// The notifs drawer with no padding to have more space for the buttons\n.column-header__collapsible-inner.nopad-drawer {\n padding: 0;\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n\n // notif cleaning drawer\n &.ncd {\n transition: none;\n &.collapsed {\n max-height: 0;\n opacity: 0.7;\n }\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.column-header__title {\n display: inline-block;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n// more fixes for the navbar-under mode\n@mixin fix-margins-for-navbar-under {\n .tabs-bar {\n margin-top: 0 !important;\n margin-bottom: -6px !important;\n }\n}\n\n.single-column.navbar-under {\n @include fix-margins-for-navbar-under;\n}\n\n.auto-columns.navbar-under {\n @media screen and (max-width: $no-gap-breakpoint) {\n @include fix-margins-for-navbar-under;\n }\n}\n\n.auto-columns.navbar-under .react-swipeable-view-container .columns-area,\n.single-column.navbar-under .react-swipeable-view-container .columns-area {\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 100% !important;\n }\n}\n\n.column-inline-form {\n padding: 7px 15px;\n padding-right: 5px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n margin-bottom: 6px;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 5px;\n }\n}\n",".regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n",".directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n",".search {\n position: relative;\n}\n\n.search__input {\n @include search-input();\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: color, transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(0deg);\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n padding: 15px 10px;\n font-size: 14px;\n font-weight: 500;\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n",null,".emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n","$doodleBg: #d9e1e8;\n.doodle-modal {\n @extend .boost-modal;\n width: unset;\n}\n\n.doodle-modal__container {\n background: $doodleBg;\n text-align: center;\n line-height: 0; // remove weird gap under canvas\n canvas {\n border: 5px solid $doodleBg;\n }\n}\n\n.doodle-modal__action-bar {\n @extend .boost-modal__action-bar;\n\n .filler {\n flex-grow: 1;\n margin: 0;\n padding: 0;\n }\n\n .doodle-toolbar {\n line-height: 1;\n\n display: flex;\n flex-direction: column;\n flex-grow: 0;\n justify-content: space-around;\n\n &.with-inputs {\n label {\n display: inline-block;\n width: 70px;\n text-align: right;\n margin-right: 2px;\n }\n\n input[type=\"number\"],input[type=\"text\"] {\n width: 40px;\n }\n span.val {\n display: inline-block;\n text-align: left;\n width: 50px;\n }\n }\n }\n\n .doodle-palette {\n padding-right: 0 !important;\n border: 1px solid black;\n line-height: .2rem;\n flex-grow: 0;\n background: white;\n\n button {\n appearance: none;\n width: 1rem;\n height: 1rem;\n margin: 0; padding: 0;\n text-align: center;\n color: black;\n text-shadow: 0 0 1px white;\n cursor: pointer;\n box-shadow: inset 0 0 1px rgba(white, .5);\n border: 1px solid black;\n outline-offset:-1px;\n\n &.foreground {\n outline: 1px dashed white;\n }\n\n &.background {\n outline: 1px dashed red;\n }\n\n &.foreground.background {\n outline: 1px dashed red;\n border-color: white;\n }\n }\n }\n}\n",".drawer {\n width: 300px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n padding: 10px 5px;\n flex: none;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n\n @include single-column('screen and (max-width: 630px)') { flex: auto }\n\n @include limited-single-column('screen and (max-width: 630px)') {\n &, &:first-child, &:last-child { padding: 0 }\n }\n\n .wide & {\n min-width: 300px;\n max-width: 400px;\n flex: 1 1 200px;\n }\n\n @include single-column('screen and (max-width: 630px)') {\n :root & { // Overrides `.wide` for single-column view\n flex: auto;\n width: 100%;\n min-width: 0;\n max-width: none;\n padding: 0;\n }\n }\n\n .react-swipeable-view-container & { height: 100% }\n}\n\n.drawer--header {\n display: flex;\n flex-direction: row;\n margin-bottom: 10px;\n flex: none;\n background: lighten($ui-base-color, 8%);\n font-size: 16px;\n\n & > * {\n display: block;\n box-sizing: border-box;\n border-bottom: 2px solid transparent;\n padding: 15px 5px 13px;\n height: 48px;\n flex: 1 1 auto;\n color: $darker-text-color;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n }\n\n a {\n transition: background 100ms ease-in;\n\n &:focus,\n &:hover {\n outline: none;\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.search {\n position: relative;\n margin-bottom: 10px;\n flex: none;\n\n @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n}\n\n.search-popout {\n @include search-popout();\n}\n\n.drawer--account {\n padding: 10px;\n color: $darker-text-color;\n display: flex;\n align-items: center;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n .acct {\n display: block;\n color: $secondary-text-color;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n overflow: hidden;\n}\n\n.drawer--results {\n background: $ui-base-color;\n overflow-x: hidden;\n overflow-y: auto;\n\n & > header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n & > section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n\n & > .hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n }\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n > .mastodon {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n cursor: inherit;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n",".video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n width: 100%;\n height: 100%;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 8%);\n }\n\n .status__content > & {\n margin-top: 15px; // Add margin when used bare for NSFW video player\n }\n @include fullwidth-gallery;\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 500;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n height: 100%;\n display: flex;\n flex-direction: column;\n\n span {\n text-align: center;\n color: $darker-text-color;\n display: flex;\n height: 100%;\n align-items: center;\n\n p {\n width: 100%;\n }\n }\n\n audio {\n width: 100%;\n }\n}\n\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n height: 110px;\n\n @include fullwidth-gallery;\n}\n\n.media-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n .full-width & {\n border-radius: 0;\n }\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n\n &.letterbox {\n background: $base-shadow-color;\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n object-fit: contain;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n display: flex;\n justify-content: center;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n width: 100%;\n position: relative;\n z-index: 1;\n object-fit: contain;\n user-select: none;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $white;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $ui-highlight-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n .detailed-status & {\n width: 100%;\n height: 100%;\n }\n\n @include fullwidth-gallery;\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n position: relative;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-shadow-color;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n\n .fa,\n &:active .fa,\n &:hover .fa,\n &:focus .fa {\n color: inherit;\n }\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n",".sensitive-info {\n display: flex;\n flex-direction: row;\n align-items: center;\n position: absolute;\n top: 4px;\n left: 4px;\n z-index: 100;\n}\n\n.sensitive-marker {\n margin: 0 3px;\n border-radius: 2px;\n padding: 2px 6px;\n color: rgba($primary-text-color, 0.8);\n background: rgba($base-overlay-background, 0.5);\n font-size: 12px;\n line-height: 18px;\n text-transform: uppercase;\n opacity: .9;\n transition: opacity .1s ease;\n\n .media-gallery:hover & { opacity: 1 }\n}\n",".list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n",".emoji-mart {\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: 0;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -3px;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: darken($ui-highlight-color, 3%);\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n",".glitch.local-settings {\n position: relative;\n display: flex;\n flex-direction: row;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n height: 80vh;\n width: 80vw;\n max-width: 740px;\n max-height: 450px;\n overflow: hidden;\n\n label, legend {\n display: block;\n font-size: 14px;\n }\n\n .boolean label, .radio_buttons label {\n position: relative;\n padding-left: 28px;\n padding-top: 3px;\n\n input {\n position: absolute;\n left: 0;\n top: 0;\n }\n }\n\n span.hint {\n display: block;\n color: $lighter-text-color;\n }\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n line-height: 24px;\n margin-bottom: 20px;\n }\n\n h2 {\n font-size: 15px;\n font-weight: 500;\n line-height: 20px;\n margin-top: 20px;\n margin-bottom: 10px;\n }\n}\n\n.glitch.local-settings__navigation__item {\n display: block;\n padding: 15px 20px;\n color: inherit;\n background: lighten($ui-secondary-color, 8%);\n border-bottom: 1px $ui-secondary-color solid;\n cursor: pointer;\n text-decoration: none;\n outline: none;\n transition: background .3s;\n\n .text-icon-button {\n color: inherit;\n transition: unset;\n }\n\n &:hover {\n background: $ui-secondary-color;\n }\n\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n\n &.close, &.close:hover {\n background: $error-value-color;\n color: $primary-text-color;\n }\n}\n\n.glitch.local-settings__navigation {\n background: lighten($ui-secondary-color, 8%);\n width: 212px;\n font-size: 15px;\n line-height: 20px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page {\n display: block;\n flex: auto;\n padding: 15px 20px 15px 20px;\n width: 360px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page__item {\n margin-bottom: 2px;\n}\n\n.glitch.local-settings__page__item.string,\n.glitch.local-settings__page__item.radio_buttons {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n@media screen and (max-width: 630px) {\n .glitch.local-settings__navigation {\n width: 40px;\n flex-shrink: 0;\n }\n\n .glitch.local-settings__navigation__item {\n padding: 10px;\n\n span:last-of-type {\n display: none;\n }\n }\n}\n",".error-boundary {\n color: $primary-text-color;\n font-size: 15px;\n line-height: 20px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n font-weight: 400;\n margin-bottom: 8px;\n }\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n }\n\n ul {\n list-style: disc;\n margin-left: 0;\n padding-left: 1em;\n }\n\n textarea.web_app_crash-stacktrace {\n width: 100%;\n resize: none;\n white-space: pre;\n font-family: $font-monospace, monospace;\n }\n}\n",".compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .drawer--account {\n flex: 0 1 48px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .composer {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px;\n min-height: 48px + 2px;\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n padding-top: 15px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .search {\n margin-bottom: 10px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n",".announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n ul,\n .e-content & ul {\n margin: 0;\n list-style: none;\n }\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n display: block;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n overflow-x: hidden;\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n width: 100%;\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n @include avatar-size(80px);\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n @include avatar-radius();\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n @include avatar-size(44px);\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: none;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n","$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;\n\n%emoji-outline {\n filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);\n}\n\n.emojione {\n @each $emoji in $emojis-requiring-outlines {\n &[title=':#{$emoji}:'] {\n @extend %emoji-outline;\n }\n }\n}\n\n.hicolor-privacy-icons {\n .status__visibility-icon.fa-globe,\n .composer--options--dropdown--content--item .fa-globe {\n color: #1976D2;\n }\n\n .status__visibility-icon.fa-unlock,\n .composer--options--dropdown--content--item .fa-unlock {\n color: #388E3C;\n }\n\n .status__visibility-icon.fa-lock,\n .composer--options--dropdown--content--item .fa-lock {\n color: #FFA000;\n }\n\n .status__visibility-icon.fa-envelope,\n .composer--options--dropdown--content--item .fa-envelope {\n color: #D32F2F;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .composer--publisher {\n text-align: left;\n }\n\n .boost-modal__status-time,\n .favourite-modal__status-time {\n float: left;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .setting-meta__label {\n float: left;\n }\n\n .status__avatar {\n margin-left: 10px;\n margin-right: 0;\n\n // Those are used for public pages\n left: auto;\n right: 10px;\n }\n\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 58px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n text-align: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/flavours/glitch/common.js b/priv/static/packs/flavours/glitch/common.js index ac5adaf84..6535dc828 100644 Binary files a/priv/static/packs/flavours/glitch/common.js and b/priv/static/packs/flavours/glitch/common.js differ diff --git a/priv/static/packs/flavours/glitch/common.js.map b/priv/static/packs/flavours/glitch/common.js.map index f079b32e5..4090e336c 100644 Binary files a/priv/static/packs/flavours/glitch/common.js.map and b/priv/static/packs/flavours/glitch/common.js.map differ diff --git a/priv/static/packs/flavours/glitch/embed.js b/priv/static/packs/flavours/glitch/embed.js index d72d5eb4a..fa84055a4 100644 Binary files a/priv/static/packs/flavours/glitch/embed.js and b/priv/static/packs/flavours/glitch/embed.js differ diff --git a/priv/static/packs/flavours/glitch/embed.js.LICENSE b/priv/static/packs/flavours/glitch/embed.js.LICENSE deleted file mode 100644 index 448b94017..000000000 --- a/priv/static/packs/flavours/glitch/embed.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ diff --git a/priv/static/packs/flavours/glitch/embed.js.LICENSE.txt b/priv/static/packs/flavours/glitch/embed.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/glitch/embed.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/glitch/embed.js.map b/priv/static/packs/flavours/glitch/embed.js.map index 72ef31ea0..18537f622 100644 Binary files a/priv/static/packs/flavours/glitch/embed.js.map and b/priv/static/packs/flavours/glitch/embed.js.map differ diff --git a/priv/static/packs/flavours/glitch/error.js b/priv/static/packs/flavours/glitch/error.js index 6cd2b3257..4b17ae7a4 100644 Binary files a/priv/static/packs/flavours/glitch/error.js and b/priv/static/packs/flavours/glitch/error.js differ diff --git a/priv/static/packs/flavours/glitch/home.js b/priv/static/packs/flavours/glitch/home.js index b1d7f479a..030a30c4d 100644 Binary files a/priv/static/packs/flavours/glitch/home.js and b/priv/static/packs/flavours/glitch/home.js differ diff --git a/priv/static/packs/flavours/glitch/home.js.LICENSE b/priv/static/packs/flavours/glitch/home.js.LICENSE deleted file mode 100644 index c81616ce6..000000000 --- a/priv/static/packs/flavours/glitch/home.js.LICENSE +++ /dev/null @@ -1,202 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ - -/*! https://mths.be/punycode v1.4.1 by @mathias */ - -/** - * @license MIT - * @fileOverview Favico animations - * @author Miroslav Magda, http://blog.ejci.net - * @version 0.3.10 - */ - -/*@cc_on!@*/ diff --git a/priv/static/packs/flavours/glitch/home.js.LICENSE.txt b/priv/static/packs/flavours/glitch/home.js.LICENSE.txt new file mode 100644 index 000000000..41a8734c9 --- /dev/null +++ b/priv/static/packs/flavours/glitch/home.js.LICENSE.txt @@ -0,0 +1,202 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! https://mths.be/punycode v1.4.1 by @mathias */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ + +/** + * @license MIT + * @fileOverview Favico animations + * @author Miroslav Magda, http://blog.ejci.net + * @version 0.3.10 + */ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/*@cc_on!@*/ diff --git a/priv/static/packs/flavours/glitch/home.js.map b/priv/static/packs/flavours/glitch/home.js.map index 09e328ab8..5a58c58f0 100644 Binary files a/priv/static/packs/flavours/glitch/home.js.map and b/priv/static/packs/flavours/glitch/home.js.map differ diff --git a/priv/static/packs/flavours/glitch/public.js b/priv/static/packs/flavours/glitch/public.js index 4bef46655..c983e7f64 100644 Binary files a/priv/static/packs/flavours/glitch/public.js and b/priv/static/packs/flavours/glitch/public.js differ diff --git a/priv/static/packs/flavours/glitch/public.js.LICENSE b/priv/static/packs/flavours/glitch/public.js.LICENSE deleted file mode 100644 index 448b94017..000000000 --- a/priv/static/packs/flavours/glitch/public.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ diff --git a/priv/static/packs/flavours/glitch/public.js.LICENSE.txt b/priv/static/packs/flavours/glitch/public.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/glitch/public.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/glitch/public.js.map b/priv/static/packs/flavours/glitch/public.js.map index d55977ee9..b34718a35 100644 Binary files a/priv/static/packs/flavours/glitch/public.js.map and b/priv/static/packs/flavours/glitch/public.js.map differ diff --git a/priv/static/packs/flavours/glitch/settings.js b/priv/static/packs/flavours/glitch/settings.js index 1a4cc7926..2cf26d61e 100644 Binary files a/priv/static/packs/flavours/glitch/settings.js and b/priv/static/packs/flavours/glitch/settings.js differ diff --git a/priv/static/packs/flavours/glitch/settings.js.map b/priv/static/packs/flavours/glitch/settings.js.map index a787cb63f..41ba698a5 100644 Binary files a/priv/static/packs/flavours/glitch/settings.js.map and b/priv/static/packs/flavours/glitch/settings.js.map differ diff --git a/priv/static/packs/flavours/glitch/share.js b/priv/static/packs/flavours/glitch/share.js index 7af26420d..67e6ff793 100644 Binary files a/priv/static/packs/flavours/glitch/share.js and b/priv/static/packs/flavours/glitch/share.js differ diff --git a/priv/static/packs/flavours/glitch/share.js.LICENSE b/priv/static/packs/flavours/glitch/share.js.LICENSE deleted file mode 100644 index 0a0301353..000000000 --- a/priv/static/packs/flavours/glitch/share.js.LICENSE +++ /dev/null @@ -1,193 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ - -/*! https://mths.be/punycode v1.4.1 by @mathias */ diff --git a/priv/static/packs/flavours/glitch/share.js.LICENSE.txt b/priv/static/packs/flavours/glitch/share.js.LICENSE.txt new file mode 100644 index 000000000..90a9a7678 --- /dev/null +++ b/priv/static/packs/flavours/glitch/share.js.LICENSE.txt @@ -0,0 +1,193 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! https://mths.be/punycode v1.4.1 by @mathias */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/glitch/share.js.map b/priv/static/packs/flavours/glitch/share.js.map index 7e35c663a..92384b100 100644 Binary files a/priv/static/packs/flavours/glitch/share.js.map and b/priv/static/packs/flavours/glitch/share.js.map differ diff --git a/priv/static/packs/flavours/vanilla/about.js b/priv/static/packs/flavours/vanilla/about.js index be2b2196b..715247a5e 100644 Binary files a/priv/static/packs/flavours/vanilla/about.js and b/priv/static/packs/flavours/vanilla/about.js differ diff --git a/priv/static/packs/flavours/vanilla/about.js.LICENSE b/priv/static/packs/flavours/vanilla/about.js.LICENSE deleted file mode 100644 index 0a0301353..000000000 --- a/priv/static/packs/flavours/vanilla/about.js.LICENSE +++ /dev/null @@ -1,193 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ - -/*! https://mths.be/punycode v1.4.1 by @mathias */ diff --git a/priv/static/packs/flavours/vanilla/about.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/about.js.LICENSE.txt new file mode 100644 index 000000000..90a9a7678 --- /dev/null +++ b/priv/static/packs/flavours/vanilla/about.js.LICENSE.txt @@ -0,0 +1,193 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! https://mths.be/punycode v1.4.1 by @mathias */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/about.js.map b/priv/static/packs/flavours/vanilla/about.js.map index df789e98d..42e76990e 100644 Binary files a/priv/static/packs/flavours/vanilla/about.js.map and b/priv/static/packs/flavours/vanilla/about.js.map differ diff --git a/priv/static/packs/flavours/vanilla/admin.js b/priv/static/packs/flavours/vanilla/admin.js index 1187d5f1d..5fbc17639 100644 Binary files a/priv/static/packs/flavours/vanilla/admin.js and b/priv/static/packs/flavours/vanilla/admin.js differ diff --git a/priv/static/packs/flavours/vanilla/admin.js.LICENSE b/priv/static/packs/flavours/vanilla/admin.js.LICENSE deleted file mode 100644 index 487bc60d8..000000000 --- a/priv/static/packs/flavours/vanilla/admin.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ diff --git a/priv/static/packs/flavours/vanilla/admin.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/admin.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/vanilla/admin.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/admin.js.map b/priv/static/packs/flavours/vanilla/admin.js.map index 742c31b83..781e07de0 100644 Binary files a/priv/static/packs/flavours/vanilla/admin.js.map and b/priv/static/packs/flavours/vanilla/admin.js.map differ diff --git a/priv/static/packs/flavours/vanilla/common.css b/priv/static/packs/flavours/vanilla/common.css index ce2318757..5f844cca6 100644 Binary files a/priv/static/packs/flavours/vanilla/common.css and b/priv/static/packs/flavours/vanilla/common.css differ diff --git a/priv/static/packs/flavours/vanilla/common.css.map b/priv/static/packs/flavours/vanilla/common.css.map index 4a6d0a5e6..9f54b0023 100644 --- a/priv/static/packs/flavours/vanilla/common.css.map +++ b/priv/static/packs/flavours/vanilla/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///application.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CCtEW,iED6Eb,kBC7Ea,4BDiFb,sBACE,MErFF,iDACE,mBACA,eACA,iBACA,gBACA,WDXM,kCCaN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBD7CW,kBCiDX,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cD3EoB,mBAPX,WCqFT,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aDtKwB,kKCyKtB,oBAGE,sDAIJ,aDpKsB,eCsKpB,0DAEA,aDxKoB,oDC6KtB,cACE,SACA,uBACA,cDhLoB,aCkLpB,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aD9NY,gBCgOV,gBCnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFrBI,YEuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF9BE,qBEgCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cF7EsB,wBE+EtB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UFxUA,qCE2UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF7UkB,mBE+UlB,kBACA,uHAEA,yBAGE,WFrWA,qCEyWF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFtaoB,8CE2atB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WF1kBF,gBE4kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WFplBJ,gBEslBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aF5lBY,oDEmmBlB,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cFxnBc,aE0nBd,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BFzpBc,wEE+pBd,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFnsBJ,6CEqsBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cFztBgB,uDE4tBhB,oBACE,cF7tBc,qBE+tBd,aACA,gBACA,8DAEA,eACE,WFpvBJ,qCE0vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aFryBU,8DE2yBV,mBACA,WF7yBE,qFEizBJ,YAEE,eACA,cFpyBkB,2CEwyBpB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBF32BK,+IE82BH,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,eACE,kBACA,cJ/EkB,6BIkFlB,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBCjIR,cACE,iBACA,cLeoB,gBKbpB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cLFoB,wBKMtB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBNPI,uBMUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBNVW,aMYT,0BACA,eACA,cNPoB,iBMSpB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aNtCsB,qBMwCpB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,gBACA,eACA,cN7DoB,+BMiEtB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aN1FsB,aM+FxB,YACE,kBACA,mBNxGW,mCM0GX,qBAGF,YACE,kBACA,0BACA,kBACA,cN1GsB,mBM4GtB,iBAGF,eACE,eACA,cNjHsB,iBMmHtB,qBACA,gBACA,UACA,oBAEA,YACE,gBACA,eACA,cN3HoB,0BM+HtB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cNxIoB,qBM0IpB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBNjKW,mCMmKX,cN3JwB,gBM6JxB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cNvMkB,8DM6MpB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eNhPM,CMkPN,cACA,cNlOsB,mBMoOtB,+BANA,iBACA,CNhPM,kCM8PN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UN/PM,eMiQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cNxPoB,qCM4PtB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBN7Qa,kBM+QX,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBN1RO,kBM4RP,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBNlSsB,eMoSpB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WNjUE,mBMmUF,gBACA,uBACA,wBAEA,aNvTkB,0BM2TlB,aACE,gBACA,eACA,eACA,cN/TgB,0IMqUlB,UNrVE,+BM6VJ,aACE,YACA,uDAGF,oBNhVsB,wCMoVtB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,cNrYoB,gBMuYpB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WN7aI,8BMgbJ,aACE,cNjakB,gBMmalB,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aNlgBsB,iCMigBxB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cNrhBsB,4JMwhBtB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WNhkBI,gCMkkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MCjlBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WPhDA,cOkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aPnDoB,0BOqDlB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aPpFsB,sBOuFpB,aPrFsB,yBOyFtB,iBACE,kBACA,gBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cPlHoB,iCOqHpB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WPlKA,gBOoKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WPxLE,cO0LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WP9ME,cOgNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WP5RI,cO8RJ,WACA,2CAKE,mBACE,eACA,WPtSA,qBOwSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WPtUI,cOwUJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBP7VY,oLOiWZ,iBACE,4WAGF,oBPpVsB,mBOuVpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBPrYsB,WAlBlB,eO0ZJ,oBACA,YACA,aACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBPpaoB,gGOwapB,kBPtbQ,kHOybN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WPzcI,cO2cJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cPzckB,oBO2clB,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,8DACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UPvhBF,aOiiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cPzhBsB,kBO2hBtB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cPjjBY,sBOqjBd,mCACE,+BACA,cPtjBQ,kBO0jBV,oBACE,cP7iBoB,qBO+iBpB,wBAEA,UPjkBI,0BOmkBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBPzkBS,WATL,eOqlBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aP1mBsB,qBO4mBpB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aP5nBwB,qBO8nBtB,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cP7oBsB,oCOgpBtB,cACE,mBACA,kBACA,4CAGF,aPrpBwB,gBOupBtB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBP7rBM,YO+rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cP1rBwB,WO4rBxB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WPzuBI,qCO2uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UPjvBI,0BOmvBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cPhxBsB,0BOmxBtB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WP7yBI,kBO+yBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aPvzBc,0SOi0BZ,+CACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBP32Bc,gBO62BZ,2BAEA,kBP/2BY,gBOi3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCl7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WR7EA,gBQ+EA,gBACA,uBACA,+BAGF,aACE,eACA,cRtEgB,gBQwEhB,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WR3GI,gBQ6GJ,qBACA,iBACA,qBACA,sBAGF,eRnHM,oBQqHJ,cR5GS,eQ8GT,cACA,kBAGF,cACE,uCAGF,aR9GwB,oBQmHxB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA7DF,iBA8DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBRlKa,mCQoKX,cR7JsB,eQ+JtB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cR3LwB,sCQ6LxB,sCACA,6DAEA,aRhNc,sCQkNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cR1OsB,0BQ4OtB,6BAGF,aACE,cRjPoB,4BQqPtB,aRnPwB,qBQqPtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aRnRY,gBQqRV,0CAGF,aRxRY,wCQ6Rd,eACE,wCAIJ,UACE,0BAIA,aRxRsB,4BQ2RpB,aR1RsB,qBQ4RpB,qGAEA,yBAGE,iCAIJ,URtTI,gBQwTF,wBAIJ,eACE,kBC/TJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBTpBW,6GSuBT,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBT7DwB,WAlBlB,oBSkFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UT5FI,gFSgGN,kBAGE,qNAKA,kBTxFoB,4ISgGpB,kBT9GQ,qCSqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cXYwB,SWVxB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aXFsB,eWIpB,SAIJ,wBXN0B,YWQxB,kBACA,sBACA,WX5BM,eW8BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBXxDQ,gBW4DN,mCAIJ,wBXlDsB,eWqDpB,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,aXpFW,mBAOW,qGWiFpB,wBAGE,8BAIJ,kBX1EsB,2GW6EpB,wBAGE,0BAIJ,aXlGsB,uBWoGpB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,UACA,cXpHsB,SWsHtB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,qCACA,4BACA,2CACA,oBAGF,mCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aX5JwB,gCWgKxB,QACE,uEAGF,mBAGE,uBAGF,aX9JsB,sFWiKpB,aAGE,qCACA,6BAGF,mCACE,gCAGF,aACE,6BACA,8BAGF,aX7LsB,uCWgMpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,aXvMwB,SWyMtB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,qCACA,4BACA,2CACA,yBAGF,mCACE,4BAGF,aACE,6BACA,eACA,0BAGF,aXpPwB,qCWwPxB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,aXzSsB,sDW6StB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBX9ToB,yDWqUxB,aX9UW,mBWgVT,mBXzUoB,oCW2UpB,iBACA,kBACA,eACA,gBACA,6CAEA,aXxVS,gBW0VP,CAII,kRADF,eACE,wCAKN,aX9UoB,gBWgVlB,0BACA,yIAEA,oBAGE,sCAKN,iBACE,QACA,UACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,cXlZS,gBATL,aW8ZJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,aXvYoB,CWqYpB,sHAEA,aXvYoB,CWqYpB,8HAEA,aXvYoB,CWqYpB,gIAEA,aXvYoB,CWqYpB,4GAEA,aXvYoB,+FW2YpB,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBXncsB,0BWqctB,cX7cS,eW+cT,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,aXzfsB,wCW6ftB,aXlhBW,oBWohBT,eACA,gBX9hBI,sEWiiBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cXziBgB,eW2iBhB,gBACA,aACA,oBACA,6QAEA,aAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cXzkBgB,SW2kBhB,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UXpmBF,8GWwmBE,WACE,cXxlBc,CAjBlB,oGWwmBE,WACE,cXxlBc,CAjBlB,wGWwmBE,WACE,cXxlBc,CAjBlB,yGWwmBE,WACE,cXxlBc,CAjBlB,+FWwmBE,WACE,cXxlBc,iFW6lBlB,SACE,wEAKN,iBACE,sBXtnBE,wBWwnBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,mBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cX3pBsB,4CW8pBtB,aXzrBY,kCW8rBd,2CACE,WCpsBF,8DDysBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBXpsBsB,aWssBtB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,aX7tBa,cW+tBX,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WXlwBM,wDWqwBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aX1xBc,qBW4xBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,aX9xBoB,8EWmyBtB,aACE,0GAGF,kBXvyBsB,sHW0yBpB,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,cXh3BW,gBWk3BX,eACA,cACA,iBACA,eACA,sBACA,4BAGF,aXr2BwB,SWu2BtB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aX37BoB,CAPX,uEW28BP,aX38BO,kCW+8BP,aXx8BkB,gCW68BpB,aXp9BS,kCWu9BP,aX98BoB,gEWk9BpB,UXp+BE,mBAgBgB,sEWw9BhB,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,aX59BwB,YW+9BtB,eACA,uBAGF,aXn+BwB,qCWu+BxB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cXvhCsB,CWyhCtB,iBACA,eACA,kBACA,+CAEA,aX9hCsB,uBWkiCtB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cX3jCkB,4BWikCxB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cXznCsB,eW2nCtB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,aX1qCa,eW4qCX,6BAEA,aXzpCsB,SW8pCxB,YACE,gCACA,8BAEA,aACE,cACA,WXlsCI,qBWosCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cXntCoB,gBWqtCpB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBEtvCE,iCACA,wBACA,4BACA,kBFqvCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBEhwCA,iCACA,wBACA,4BACA,kBF+vCE,gBACA,kBACA,eACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WXjxCE,6BWmxCF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCEvxCrB,+BFyxCA,iBElyCA,iCACA,wBACA,4BACA,WFiyCuB,sCE3xCvB,kCF8xCA,iBEvyCA,iCACA,wBACA,4BACA,WFsyCuB,sCEhyCvB,kBFkyCE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cX3xCsB,6BW8xCtB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,eACA,cXv3CoB,kCW23CtB,aACE,eACA,gBACA,WX94CI,CWm5CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UXn7CM,kBWy7CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aXr8C0B,cWu8CxB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WXr+CI,kCW0+CR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,CX/9CsB,gHWy+CtB,aXz+CsB,wBW6+CtB,UACE,wCAGF,kBXj/CsB,cArBX,8CW0gDT,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cX7gDsB,eW+gDtB,iBACA,kBACA,4BAEA,aXjhDwB,6BWqhDxB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CX5iDU,mEWmjDZ,aXnjDY,uBWujDZ,aXxjDc,4DW8jDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UXllDM,0BWolDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cXzkD4B,eAEC,0DW0kD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cXjmD4B,eAEC,WWkmD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cXlpDsB,wBWqpDtB,aXppDwB,mBWwpDxB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBXrtD0B,cWutDxB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BX1vDsB,2BW8vDxB,WACE,iBACA,uBACA,yBXjwDsB,8BWqwDxB,QACE,iBACA,uBACA,4BXxwDsB,6BW4wDxB,SACE,gBACA,2BACA,2BX/wDsB,wBWqxDxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBX3xDsB,cARb,gBWsyDT,uBACA,mBACA,yFAEA,kBXjyDsB,cADA,UWuyDpB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBXrzDsB,cWuzDtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBX90DsB,cARb,gBWy1DT,uBACA,mBACA,oDAEA,SACE,oDAGF,kBXx1DsB,cADA,iBWg2D1B,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBX78DW,8BW+8DT,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cXh+DsB,qBWk+DtB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WXxiEM,qBW0iEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cX/iEsB,sBWmjExB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WX/uEM,kBWivEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBXxyEsB,yBW0yEtB,gBACA,kBACA,eACA,gBACA,iBACA,WXj0EI,mDWs0ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBXx2EI,0BW02EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBX/5EW,0BWo6Eb,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cX7+EwB,eW++ExB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cXlgFwB,eWogFxB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBXhlFW,qCWklFX,sEAGF,wBACE,4CAGF,wBXhlF0B,+EWolF1B,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBX5oFwB,cWgpF1B,kBACE,WXnqFM,cWqqFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cXpqFsB,kGWuqFtB,sBAGE,WX3rFE,kCW+rFJ,aX7qFsB,oBWmrF1B,oBACE,iBACA,qBAGF,oBACE,kBACA,eACA,iBACA,gBACA,mBXtsFW,gBWwsFX,iBACA,oBAGF,kBX5sFa,cAqBW,iBW0rFtB,eACA,gBACA,eACA,yDAGF,kBXrtFa,cW2tFb,aACE,kBAGF,aX1sFwB,cW4sFtB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,aXxuFkB,0BW0uFhB,sDAIJ,oBACE,cX7vFkB,sMWgwFlB,yBAGE,oDAKN,aX1vFsB,0BWgwFtB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,cXrxFkB,aWuxFlB,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA1BF,YA2BI,yCAGF,eACE,aACA,iDAEA,aXhzFkB,qBWuzFxB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,cXv1FW,gBATL,aWm2FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,aXz2FwB,6BW22FtB,uDAGF,aX13F0B,cW83F1B,YACE,eACA,yBACA,kBACA,cXt3FsB,gBWw3FtB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cX/5FoB,uBWi6FpB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UXz7FE,yBWg8FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cXn9FsB,gBWq9FtB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aXj+FwB,oBWq+FxB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cXpiGsB,6BWsiGtB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cX9jGsB,mBArBX,eWslGX,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cX5lGkB,qCWgmGpB,cACE,gBACA,yBAKN,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,oFACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,aXpqGwB,uBWwqGxB,uCACE,4CAEA,aX3qGsB,0CW6qGpB,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cXxsGsB,eW0sGtB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UXtuGI,mBWwuGF,6BAKN,eACE,gBACA,gBACA,cXhuGsB,0DWkuGtB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aX7vGsB,0BW+vGpB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,aXjxGwB,eWmxGtB,gBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBXz6GM,WACA,eW26GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eXv7GQ,cAiBgB,SWy6GtB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WXt/GE,gBWw/GF,eACA,+LAMA,yBACE,mEAKF,yBACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aXphHwB,eWshHtB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtiHF,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,+EFgiHI,aACE,CEjiHN,qEFgiHI,aACE,CEjiHN,yEFgiHI,aACE,CEjiHN,0EFgiHI,aACE,CEjiHN,gEFgiHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,aX9iHoB,iBWgjHlB,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aXrlHoB,0HW0lHpB,cAEE,gBACA,cX/kHkB,kZWklHlB,aAGE,gEAIJ,wBACE,iDAGF,eX3nHI,kBa0BN,CAEA,eACA,cbbsB,uCaetB,UF8lHI,mBX5mHoB,oDagBxB,abjBsB,eamBpB,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,cbxCS,sDWwnHT,WACE,mDAGF,aX5nHS,kBW8nHP,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UXxpHQ,kBW0pHN,cACA,mBACA,sBX7pHM,eW+pHN,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aX9pHwB,qBWgqHtB,mBACA,gBACA,sBACA,uCAGF,aXxpHwB,mBArBX,kBWirHX,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,sCAdF,cAeI,kDAGF,eACE,2CAGF,aX1rHwB,qBW4rHtB,uDAEA,yBACE,eAKN,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eX/xHQ,kBWiyHN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBX3zHM,kBW6zHN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBXp3HW,kCWs3HX,uBAGF,MACE,aACA,mBACA,uBACA,cXr3HwB,eWu3HxB,gBACA,0BACA,kBACA,kBAGF,YACE,cXj3HsB,gBWm3HtB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBXz4HsB,kBW24HtB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBX/5HwB,kBWi6HxB,eAGF,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBX99HM,uCWg+HN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,aXr+Ha,aWu+HX,eACA,aACA,kEAEA,kBXl+HwB,WAlBlB,UWw/HJ,CXx/HI,4RW6/HF,UX7/HE,wCWmgIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cXl/HsB,2CWq/HtB,eACE,cACA,cX5gIS,CWihIL,wQADF,eACE,mDAON,eXjiIM,0BWmiIJ,qCACA,gEAEA,eACE,0DAGF,kBXxhIsB,uEW2hIpB,UX7iIE,uDWmjIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SErjIE,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,cF+iIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cXrmIsB,eWumItB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cX9mIoB,eWgnIpB,uCAEA,uBACE,sCAGF,aACE,yBAKN,aX5nIwB,mBW8nItB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cXppIoB,iCWupIpB,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cXlrIwB,qBWorIxB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cXhsIsB,kBWksItB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cX7tI0B,eAEC,CWuuI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,WXzzIM,eW2zIN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cXt1IsB,mFWy1ItB,yBAGE,wBAKN,oBACE,sBAGF,qBXt3IQ,YWw3IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBX/2I0B,qBWm3I1B,iBACE,UACA,QACA,YACA,6CAGF,kBX33I0B,cARb,kBWw4IX,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,aXr6IsB,SWw6IpB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,aX98IS,qwDWk9IP,aAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,cXr+IS,kBWu+IT,eACA,qBAGF,kBX3+IW,cAQa,gBWs+ItB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,cXjgJW,kBWmgJX,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,aACE,wBAKF,eXviJM,CAiBkB,gBWyhJtB,oBACA,iEX3iJI,2BAiBkB,yBWkiJ1B,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBXjjJwB,aWmjJxB,iBACA,2HAEA,aACE,iBACA,cX3iJoB,mBW6iJpB,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,aXrnJwB,iLWynJxB,aXloJW,qCWuoJX,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,aXjqJS,gBATL,aW6qJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eX7rJI,yBW+rJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,aXpsJO,oBWssJL,eACA,gBXhtJA,+CWqtJJ,YACE,8BACA,mBACA,4CAIJ,aACE,cXptJS,eWstJT,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,aX/tJS,eWiuJP,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,aX3wJO,aW6wJL,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBXnxJgB,WAlBlB,uDW4yJA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cXlyJsB,eWoyJtB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,aXv2JS,CWy2JP,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBX/2J0B,WWi3JxB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WX54JM,0BW84JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cX75JoB,iBW+5JpB,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cX37JkB,gBW67JlB,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,aX98JoB,gBWs9JtB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cX//JwB,kBWigKxB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBX5hKM,CW6hKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBXxiKM,iCW2iKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,qCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBXxoKM,eW0oKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBXrtKI,cAiBgB,gBWusKpB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UXpxKE,+EW4xKN,cAGE,gBACA,6BAGF,UXnyKM,iBWqyKJ,yBAGF,oBACE,aACA,mDAGF,UX7yKM,uBWkzKN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WXl2KE,sFWq2KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WX5/KF,gBW8/KE,gBACA,uBACA,0CAGF,aACE,eACA,cXr/Kc,gBWu/Kd,gBACA,uBACA,yBAKN,kBXrgLS,aWugLP,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cX7kLoB,eW+kLpB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,aXrlLsB,qWWwlLpB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBXzoLa,sBW4oLX,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eXvsLQ,kBa0BN,CACA,sBACA,gBACA,cbbsB,uCaetB,mBAEA,abjBsB,eamBpB,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,cbxCS,UWksLb,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cXrsLsB,gBWusLtB,gBAEA,aXxsLsB,0BW0sLpB,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBX70LO,WATL,eWy1LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cXt2LoB,CWy2LpB,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,cAGF,kBX37La,sBW67LX,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBXj/La,sBWm/LX,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBX3iMM,yDW8iMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBXtjMI,uBW0jMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UXvlMI,eWylMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aXjmMwB,eWmmMtB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WXruMA,gBWuuMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cX9tMc,gBWguMd,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WXlwME,gDWswMJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aXjxMU,yBWuxMd,cACE,gCAEA,cACE,cX5wMkB,eW8wMlB,kCAEA,oBACE,cXjxMgB,qBWmxMhB,iBACA,gBACA,yCAEA,eACE,WXxyMF,iBWizMN,aXnxMsB,mBWqxMpB,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cX7yMkB,gBW+yMlB,uBACA,mBACA,4BAEA,eACE,uBAGF,aXr0MkB,qBWu0MhB,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cX/1MoB,0BWm2MtB,aACE,WACA,2CAEA,oCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBXr4MsB,kBWu4MtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cX75MkB,kBW+5MlB,+BAGF,aXl6MoB,eWo6MlB,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UX57ME,qBW87MA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UXx9MI,OcFR,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBdEsB,acGxB,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAGF,aACE,sBACA,WACA,eACA,cdjCO,UcmCP,oBACA,gBd7CE,yBc+CF,kBACA,iBACA,oCAEA,oBdjCoB,wBcsCtB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBdtFY,8Ec2FZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,cdnGoB,acuGtB,cACE,uBACA,UACA,SACA,SACA,cd5GoB,0Bc8GpB,kBACA,mBAEA,oBACE,sCAGF,mCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBdhKoB,sDcsKxB,cACE,gBACA,iBACA,YACA,oBACA,cd/JoB,sCckKpB,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,cdxMS,qBc0MT,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,adrMwB,qBcwMtB,+BACE,6BAEA,+BACE,eC5ON,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,chBSW,2BgBNX,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBhBHsB,4BgBOxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,chBLsB,cgBOtB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,ahBpC0B,mCgBuCxB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBhBrDwB,uBgB0DxB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBhB5FM,sBgB8FN,sGAEA,+BAEE,oBAKF,2BACA,gBhBxGM,0BgB2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,chBzGS,yBgB2GT,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBhBpKI,mBgByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,chBvKsB,mDgB0KtB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,cjBJsB,oBiBOtB,ajBLwB,0BiBOtB,6EAEA,oBAGE,wCAIJ,ajBlBsB,oBiBuBtB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cjBhCsB,qBiBoCxB,iBACE,cjBrCsB,uBiByCxB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,cjBzDsB,qBiB6DxB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cjBrIkB,iCiByIpB,uBACE,gBACA,gBACA,cjB9HkB,qDiBkIpB,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WjBpNI,iBiBsNJ,kBACA,qEAEA,aAEE,6CAIA,ajB9MoB,oCiBmNtB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,cjB/OkB,mBiBiPlB,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WjBzSA,qBiB2SA,uDAGE,yBACE,2CAKN,aACE,cjBrSgB,kCiB6StB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,cjBpToB,sCiBuTpB,ajBrTsB,0BiBuTpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,cjB7UsB,wBiBgVtB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,cjB9VsB,kBiBmWtB,cjBnWsB,mCiBkWxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBvWsB,kBiB4WtB,cjB5WsB,kBiBqXtB,cjBrXsB,mCiBoXxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBzXsB,kBiB8XtB,cjB9XsB,mCiBsYxB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,2CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBjB1bW,kBiB4bT,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBjBnjBS,kBiBqjBT,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,ajB3kBsB,qCiB+kBtB,eACE,WjBjmBE,gBiBmmBF,2CAEA,ajBrlBkB,gDiBwlBhB,ajBvlBkB,+CiB6lBtB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SjBvrBI,YiByrBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,gBACA,eACA,cjBhsBkB,6BiBosBpB,eACE,iBACA,+BAGF,kBjBhtBS,aiBktBP,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,cjB/uBgB,uFiBqvBtB,eACE,cASA,CjB/vBoB,2CiB4vBpB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cjB51BsB,qBiB81BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cjBt1BoB,SkBhCxB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBlBhBW,UkBqBX,alBZwB,0BkBctB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBlBzDS,6BkB2DP,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,clB5FsB,gBkB8FtB,0DAEA,UlBhHM,wDkBoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBlBvJS,sBkByJT,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBlBtKS,gCkByKT,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBlB9LS,uCkBiMP,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,clB/NgB,gBkBiOhB,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBnBPO,YmBSP,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SnBxCA,YmB0CE,kBACA,YACA,uCAIJ,aACE,cnBjCgB,qBmBmChB,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cnB3EgB,qBmB6EhB,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UnBzGA,yBmB2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UnBjIE,yBAkBkB,gBmBkHlB,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,anBnMsB,emBqMpB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,anB9MsB,emBgNpB,iBACA,gBACA,mBACA,4BAGF,cACE,gBACA,cnBzNkB,mBmB2NlB,kBACA,gCACA,4BAGF,cACE,cnBhOoB,iBmBkOpB,gBACA,0CAGF,UnBvPI,gBmByPF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WnBvQE,oBmByQF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cnBhQoB,mBmBkQpB,kCAEA,UnBrRE,gBmBuRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,2CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA7SF,aA8SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BnBhUoB,YmBuU1B,UACE,SACA,cACA,WACA,sDAKA,anBlVsB,0DmBqVpB,anBnVsB,4DmBwVxB,anBzWc,gBmB2WZ,4DAGF,anB7WU,gBmB+WR,0DAGF,anBtVsB,gBmBwVpB,0DAGF,anBrXU,gBmBuXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,eACA,CAII,iNADF,eACE,2BAKN,oBACE,cnBjZkB,qBmBmZlB,eACA,gBACA,gCACA,iCAEA,UnBxaE,gCmB0aA,oCAGF,anB3ZoB,gCmB6ZlB,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cnBrdsB,CmB0dlB,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,anBniBwB,qBmBqiBtB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBnBlkBS,cAOW,0BmB8jBpB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,anBzkBsB,oBmB6kBtB,kBACE,0BACA,aACA,cnB9lBoB,gDmBgmBpB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,cnB1lBoB,2BmB8lBtB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBnBnoBY,oCmBuoBZ,kBACE,mCAGF,kBnB1nBsB,sDmB+nBxB,anBhoBwB,qBmBooBtB,gBACA,sBAGF,aACE,0BAGF,anB5oBwB,sBmBgpBxB,anBhqBc,yDmBqqBhB,oBAIE,cnBzpBwB,iGmB4pBxB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBnBrtBc,yBmBytBd,yBACE,wBAGF,yBnB1tBU,wBmB+tBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,anB3tBoB,uBmBiuBpB,wBACA,qBAGF,anBvtBsB,cmB4tBxB,kBnBjvBa,kBmBmvBX,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cnBhwBkB,iBmBkwBlB,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,anB7xBM,6BmBoyBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cnBpyBgB,mLmBuyBhB,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,anBryBgB,iBmBuyBd,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cnB/zBgB,WmBs0BxB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,anBn4BY,8CmBw4Bd,qBACE,aACA,WnB34BI,cmBg5BR,iBACE,sBCn5BF,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WpBrCI,6CoBuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,cpBjCoB,kBoBmCpB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,apBrDwB,gBoBuDtB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,+DACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,0D","file":"flavours/vanilla/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#192432 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#192432;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#1c2938}::-webkit-scrollbar-thumb:active{background:#192432}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#121a24}::-webkit-scrollbar-track:active{background:#121a24}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#06090c;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#121a24}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#192432;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#0b1016;padding:0}body.error{position:absolute;text-align:center;color:#9baec8;background:#121a24;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#e25169;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#d8a070}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#3e5a7c;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#3e5a7c}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#3e5a7c;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#79bd9a;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#d9e1e8;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#26374d}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#9baec8;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#2d415a;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#344b68}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#d9e1e8}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#000}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#192432;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #192432;background:#040609}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#192432;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#9baec8}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#9baec8;padding:10px;border-right:1px solid #192432;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #d8a070;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#d9e1e8}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #26374d}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#9baec8}.public-layout .public-account-header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#e1b590}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#9baec8}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #202e3f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #202e3f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#121a24}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#192432}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#4c6d98}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#4c6d98}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{font-weight:700;margin-bottom:8px;color:#9baec8}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#4c6d98}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#4c6d98}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#5377a5}.compact-header h1{font-size:24px;line-height:28px;color:#9baec8;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#d9e1e8}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#121a24;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.hero-widget__text a{color:#d9e1e8;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;font-weight:700;font-size:14px;color:#9baec8}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#9baec8}.box-widget{padding:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #3e5a7c;text-align:center;color:#9baec8;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;font-weight:700;font-size:14px;color:#9baec8}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#9baec8;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#d9e1e8;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#9baec8}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#9baec8;margin-bottom:10px}.page-header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#9baec8}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#192432}.page-header h1{font-size:24px}}.directory{background:#121a24;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#121a24;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#202e3f}.directory__tag.active>a{background:#d8a070;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#9baec8}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#9baec8}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#d8a070}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#040609;border:2px solid #121a24}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;color:#9baec8;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #202e3f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#9baec8;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #2d415a}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#9baec8}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#d8a070}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#0b1016;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #192432}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #192432}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#d8a070;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#9baec8}.simple_form .hint a{color:#d8a070}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#000}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#9baec8}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #3e5a7c;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102;border:1px solid #000;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#a8b9cf}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#d8a070;background:#040609}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#d8a070;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#ddad84}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#d3935c}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #000;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#3e5a7c;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(1, 1, 2, 0), #010102)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(18,26,36,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#202e3f;color:#9baec8;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#9baec8;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#192432}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#9baec8;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#d8a070;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#e1b590}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#9baec8}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#d9e1e8;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#d9e1e8;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#9baec8}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#010102;border:1px solid #000;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#0b1016;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#0b1016;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#202e3f}.card__img{height:130px;position:relative;background:#000;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#192432;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#121a24;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{color:#d9e1e8}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#233346}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#9baec8;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #26374d;border-bottom:1px solid #26374d;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #26374d}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#d9e1e8;background:rgba(4,6,9,.5)}.account__header__fields dd{flex:1 1 auto;color:#9baec8}.account__header__fields a{color:#d8a070;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#9baec8}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#121a24}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#202e3f}.button.logo-button{flex:0 auto;font-size:14px;background:#d8a070;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#e3bb98}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#d8a070;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#d8a070;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:15px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#e3bb98;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9baec8;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#121a24;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#3e5a7c}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#45648a}.button.button-secondary{color:#9baec8;background:transparent;padding:3px 15px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#a8b9cf}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#4a6b94;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(62,90,124,.3)}.icon-button.disabled{color:#283a50;background-color:transparent;cursor:default}.icon-button.active{color:#d8a070}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#3e5a7c}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#324965;background-color:rgba(62,90,124,.15)}.icon-button.inverted:focus{background-color:rgba(62,90,124,.3)}.icon-button.inverted.disabled{color:#4a6b94;background-color:transparent}.icon-button.inverted.active{color:#d8a070}.icon-button.inverted.active.disabled{color:#e6c3a4}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#324965;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(62,90,124,.3)}.text-icon-button.disabled{color:#6b8cb5;background-color:transparent;cursor:default}.text-icon-button.active{color:#d8a070}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#d8a070}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#d8a070;background:#d8a070}.compose-form .compose-form__warning{color:#121a24;margin-bottom:10px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#121a24;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#3e5a7c;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:5px;right:5px}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#3e5a7c}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#d9e1e8;border-radius:0 0 4px 4px;color:#121a24;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#3e5a7c}.compose-form .compose-form__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#eff3f5}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#d9e1e8;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#d9e1e8}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#3e5a7c}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9baec8;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#121a24;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#fff}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#4a6b94}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#3e5a7c}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#e1b590}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#3e5a7c}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#e1b590;border:0;background:transparent;padding:0;padding-top:8px}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#121a24;font-weight:700;font-size:12px;padding:0 6px;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#3e5a7c;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #202e3f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#192432}.focusable:focus .status.status-direct{background:#26374d}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#202e3f}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #202e3f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#202e3f;border-bottom-color:#26374d}.status.light .status__relative-time{color:#9baec8}.status.light .status__display-name{color:#121a24}.status.light .display-name strong{color:#121a24}.status.light .display-name span{color:#9baec8}.status.light .status__content{color:#121a24}.status.light .status__content a{color:#d8a070}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#547aa9}.status__relative-time,.notification__relative_time{color:#3e5a7c;float:right;font-size:14px}.status__display-name{color:#3e5a7c}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#3e5a7c;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#3e5a7c}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#3e5a7c}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#192432;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#3e5a7c;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#121a24;font-size:14px}.reply-indicator__content a{color:#3e5a7c}.domain{padding:10px;border-bottom:1px solid #202e3f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #202e3f}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#9baec8;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative;cursor:default}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #202e3f;color:#3e5a7c}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #202e3f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #d8a070}.account__action-bar__tab>span{display:block;font-size:12px;color:#9baec8}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#d9e1e8;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#3e5a7c}.muted .status__display-name strong{color:#3e5a7c}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#3e5a7c;color:#121a24}.muted a.status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#9baec8;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#d8a070}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#9baec8}.navigation-bar strong{color:#d9e1e8}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#d8a070;color:#d9e1e8;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#d8a070;color:#d9e1e8}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#040609;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#121a24;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#9baec8;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#202e3f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #202e3f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}}.tabs-bar__link.active{border-bottom:2px solid #d8a070;color:#d8a070}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#d59864;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#e0b38c}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#d8a070;border:2px solid #202e3f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#040609}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #192432;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#283a50;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#121a24}.drawer__inner__mastodon{background:#283a50 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#283a50;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#202e3f;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#17212e;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#192432;color:#d8a070;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#192432;border:0;font-family:inherit;color:#d8a070;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#121a24;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#010102}.react-toggle--checked .react-toggle-track{background-color:#d8a070}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#e3bb98}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #121a24;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#d8a070}.column-link{background:#202e3f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#253549}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#d8a070}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#121a24;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#121a24;color:#3e5a7c;padding:8px 20px;font-size:13px;font-weight:500;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#121a24}.flex-spacer{flex:1 1 auto}.getting-started{color:#3e5a7c;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#3e5a7c;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#9baec8}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#3e5a7c}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:13px;color:#9baec8;padding:10px;font-weight:500;border-bottom:1px solid #202e3f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#9baec8}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#202e3f;border:1px solid #0b1016}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#3e5a7c;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#d8a070}.status-card{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;color:#3e5a7c;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#d9e1e8;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#202e3f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#9baec8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#9baec8}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#202e3f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#192432}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#192432}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#3e5a7c;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#151f2b}.load-gap{border-bottom:1px solid #202e3f}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#3e5a7c;background:#121a24;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#3e5a7c}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(216, 160, 112, 0.23) 0%, rgba(216, 160, 112, 0) 60%)}.column-header{display:flex;font-size:16px;background:#192432;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#d8a070}.column-header.active{box-shadow:0 1px 0 rgba(216,160,112,.3)}.column-header.active .column-header__icon{color:#d8a070;text-shadow:0 0 10px rgba(216,160,112,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#192432;border:0;color:#9baec8;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#b2c1d5}.column-header__button.active{color:#fff;background:#202e3f}.column-header__button.active:hover{color:#fff;background:#202e3f}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#9baec8;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #26374d;margin:10px 0}.column-header__collapsible-inner{background:#202e3f;padding:15px}.column-header__setting-btn:hover{color:#9baec8;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#3e5a7c;font-size:13px;font-weight:400;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #3e5a7c;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#3e5a7c}29%{background-color:#3e5a7c}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#9baec8;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#b5c3d6}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.modal-container--preloader{background:#202e3f}.account--panel{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#202e3f;padding:15px}.column-settings__section{color:#9baec8;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#a8b9cf}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#192432}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#3e5a7c;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#202e3f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#26374d;color:#a8b9cf}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#9baec8}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#3e5a7c}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#45648a}.column-settings__hashtags .column-select__indicator-separator{background-color:#202e3f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{color:#9baec8;font-size:14px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#121a24}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#121a24;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;font-size:12px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#9baec8;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column{color:#3e5a7c;background:#121a24;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#d8a070;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#121a24;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#d9e1e8;font-size:18px;font-weight:500;border:2px dashed #3e5a7c;border-radius:4px}.upload-progress{padding:10px;color:#3e5a7c;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:13px;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#3e5a7c;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#d8a070;border-radius:6px}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#121a24;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#d8a070;color:#fff;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#fff}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#fff}.privacy-dropdown__option.active:hover{background:#dcab80}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#3e5a7c}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#121a24}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#d8a070}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#a8b9cf}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#192432}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#d9e1e8;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#3e5a7c;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#4a6b94}.search-results__header{color:#3e5a7c;background:#151f2b;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#3e5a7c}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#d9e1e8;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#e6ebf0;text-decoration:underline}.search-results__info{padding:20px;color:#9baec8;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#d8a070}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#121a24;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#3e5a7c;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#37506f;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#121a24}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#192432}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#121a24;margin-bottom:5px;font-size:13px}.display-case__case{background:#121a24;color:#d9e1e8;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#121a24;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#f2f5f7}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#3e5a7c;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#d8a070}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#121a24}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#121a24;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#121a24;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#121a24;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#d8a070;color:#fff}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#3e5a7c;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#37506f;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#121a24;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#d8a070;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#3e5a7c;padding:8px 18px;cursor:default;border-right:1px solid #202e3f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#3e5a7c;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#3e5a7c}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#d9e1e8;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#040609;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #192432;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(225,181,144,.5)}.audio-player__wave-placeholder{background-color:#2d415a}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#040609;border-top:1px solid #192432;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#9baec8;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#b2c1d5}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#e1b590}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#e1b590}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#000;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#192432;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#121a24;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #202e3f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#0b1016;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#9baec8;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#d9e1e8}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #202e3f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #121a24}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#0f151d;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #06090c}.filter-form{background:#121a24}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#e1b590;background:#e1b590}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{color:#9baec8;font-size:14px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#121a24}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#d9e1e8;max-width:400px}noscript div a{color:#d8a070;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#192432}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f}.account__moved-note__message{position:relative;margin-left:58px;color:#3e5a7c;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#192432}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.list-editor{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#283a50;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#283a50}.list-adder__lists{background:#283a50;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #202e3f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#9baec8;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#0b1016}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#192432;padding:5px;border-bottom:1px solid #26374d}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#040609;border:2px solid #192432}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #26374d;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #26374d}.account__header__bio .account__header__fields a{color:#e1b590}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#9baec8;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.trends__header{color:#3e5a7c;background:#151f2b;border-bottom:1px solid #0b1016;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #202e3f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#3e5a7c;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#9baec8;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#d9e1e8}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(216,160,112,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#dfb088 !important}.conversation{display:flex;border-bottom:1px solid #202e3f;padding:5px;padding-bottom:0}.conversation:focus{background:#151f2b;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#d8a070;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#9baec8;padding-left:15px}.conversation__content__names{color:#9baec8;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#151f2b}.conversation--unread:focus{background:#192432}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#6d89af}.poll__chart.leading{background:#d8a070}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#121a24;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#d8a070}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#3e5a7c}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#3e5a7c;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(62,90,124,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#d8a070}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#3e5a7c;border-color:#3e5a7c;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#121a24;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#3e5a7c}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(216,160,112,.2)}.modal-layout{background:#121a24 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#121a24}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#3e5a7c;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#37506f}.emoji-mart-anchor-selected{color:#d8a070}.emoji-mart-anchor-selected:hover{color:#d49560}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#d8a070}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#121a24;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#9baec8}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#9baec8}.rich-formatting a{color:#d8a070;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#9baec8}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#d9e1e8}.rich-formatting em{font-style:italic;color:#d9e1e8}.rich-formatting code{font-size:.85em;background:#040609;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#d9e1e8}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #192432;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #192432;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#9baec8}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#3e5a7c}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#0b1016;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#d9e1e8}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#040609;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#9baec8;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #192432;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#7a93b6}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#9baec8}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#9baec8}.landing-page p a,.landing-page li a{color:#d8a070;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#bcc9da}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#121a24;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#bcc9da}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#121a24;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#d9e1e8}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#9baec8}.landing-page__short-description h1 small span{color:#d9e1e8}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;font-weight:700;font-size:14px;color:#9baec8}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#121a24;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#9baec8}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#9baec8}.landing .simple_form p.lead{color:#9baec8;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #202e3f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#3e5a7c}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #121a24;text-align:left;background:#0b1016}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #121a24;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#121a24}.table a{color:#d8a070;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#121a24;border-top:1px solid #040609;border-bottom:1px solid #040609}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #040609}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #040609}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#9baec8;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #040609;background:#121a24;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #040609;border-top:0;background:#121a24}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #040609;border-top:0;background:#0b1016}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #040609}}.batch-table__row:hover{background:#0f151d}.batch-table__row:nth-child(even){background:#121a24}.batch-table__row:nth-child(even):hover{background:#151f2b}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#9baec8;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #040609;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #040609}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#121a24;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#202e3f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#9baec8;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#26374d}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#9baec8;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#0a0e13;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#0f151d;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#0b1016;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#d8a070;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#ddad84}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #202e3f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#d9e1e8;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#d9e1e8;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{font-size:14px;font-weight:700;color:#9baec8;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #202e3f}.admin-wrapper .content h6{font-size:16px;color:#d9e1e8;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#d9e1e8;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #192432;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#d8a070}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#9baec8}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#d8a070}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#3e5a7c;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;font-size:13px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#9baec8;text-decoration:none;font-size:13px;font-weight:500;border-bottom:2px solid #121a24}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #1b2635}.filters .filter-subset a.selected{color:#d8a070;border-bottom:2px solid #d8a070}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#d9e1e8}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#d8a070;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#121a24;color:#9baec8;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#3e5a7c}.log-entry__extras{background:#1c2938;border-radius:0 0 4px 4px;padding:10px;color:#9baec8;font-family:\"mastodon-font-monospace\",monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#3e5a7c}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#79bd9a}.log-entry__icon__overlay.negative{background:#e87487}.log-entry__icon__overlay.neutral{background:#d8a070}.log-entry a,.log-entry .username,.log-entry .target{color:#d9e1e8;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#e87487}.log-entry .diff-neutral{color:#d9e1e8}.log-entry .diff-new{color:#79bd9a}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#d9e1e8}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #d8a070}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#9baec8}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#3e5a7c}.report-card{background:#121a24;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#9baec8;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#b5c3d6}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #0b1016}.report-card__summary__item:hover{background:#151f2b}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#9baec8}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#3e5a7c;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#9baec8}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#202e3f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#e1b590}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#192432;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#202e3f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#9baec8;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(1, 1, 2, 0), #010102)}body.rtl .simple_form select{background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 15px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 5px;\n right: 5px;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 12px;\n padding: 0 6px;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $light-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n font-size: 12px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 13px;\n font-weight: 500;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 13px;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 13px;\n font-weight: 400;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n font-size: 12px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 13px;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n font-size: 13px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n font-size: 14px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n font-size: 13px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n font-size: 13px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///application.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CCtEW,iED6Eb,kBC7Ea,4BDiFb,sBACE,MErFF,iDACE,mBACA,eACA,iBACA,gBACA,WDXM,kCCaN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBD7CW,kBCiDX,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cD3EoB,mBAPX,WCqFT,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aDtKwB,kKCyKtB,oBAGE,sDAIJ,aDpKsB,eCsKpB,0DAEA,aDxKoB,oDC6KtB,cACE,SACA,uBACA,cDhLoB,aCkLpB,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aD9NY,gBCgOV,gBCnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFrBI,YEuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF9BE,qBEgCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cF7EsB,wBE+EtB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UFxUA,qCE2UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF7UkB,mBE+UlB,kBACA,uHAEA,yBAGE,WFrWA,qCEyWF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFtaoB,8CE2atB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WF1kBF,gBE4kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WFplBJ,gBEslBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aF5lBY,oDEmmBlB,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cFxnBc,aE0nBd,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BFzpBc,wEE+pBd,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFnsBJ,6CEqsBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cFztBgB,uDE4tBhB,oBACE,cF7tBc,qBE+tBd,aACA,gBACA,8DAEA,eACE,WFpvBJ,qCE0vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aFryBU,8DE2yBV,mBACA,WF7yBE,qFEizBJ,YAEE,eACA,cFpyBkB,2CEwyBpB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBF32BK,+IE82BH,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cJhFkB,6BImFlB,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cLeoB,gBKbpB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cLFoB,wBKMtB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBNPI,uBMUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBNVW,aMYT,0BACA,eACA,cNPoB,iBMSpB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aNtCsB,qBMwCpB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cN9DoB,+BMkEtB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aN3FsB,aMgGxB,YACE,kBACA,mBNzGW,mCM2GX,qBAGF,YACE,kBACA,0BACA,kBACA,cN3GsB,mBM6GtB,iBAGF,eACE,eACA,cNlHsB,iBMoHtB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cN7HoB,0BMiItB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cN1IoB,qBM4IpB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBNnKW,mCMqKX,cN7JwB,gBM+JxB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cNzMkB,8DM+MpB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eNlPM,CMoPN,cACA,cNpOsB,mBMsOtB,+BANA,iBACA,CNlPM,kCMgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UNjQM,eMmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cN1PoB,qCM8PtB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBN/Qa,kBMiRX,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBN5RO,kBM8RP,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBNpSsB,eMsSpB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WNnUE,mBMqUF,gBACA,uBACA,wBAEA,aNzTkB,0BM6TlB,aACE,gBACA,eACA,eACA,cNjUgB,0IMuUlB,UNvVE,+BM+VJ,aACE,YACA,uDAGF,oBNlVsB,wCMsVtB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cNxYoB,gBM0YpB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WNhbI,8BMmbJ,aACE,cNpakB,gBMsalB,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aNrgBsB,iCMogBxB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cNxhBsB,4JM2hBtB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WNnkBI,gCMqkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MCplBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WPhDA,cOkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aPnDoB,0BOqDlB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aPpFsB,sBOuFpB,aPrFsB,yBOyFtB,iBACE,kBACA,gBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cPlHoB,iCOqHpB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WPlKA,gBOoKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WPxLE,cO0LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WP9ME,cOgNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WPlSI,cOoSJ,WACA,2CAKE,mBACE,eACA,WP5SA,qBO8SA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WP5UI,cO8UJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBPnWY,oLOuWZ,iBACE,4WAGF,oBP1VsB,mBO6VpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBP3YsB,WAlBlB,eOgaJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBP3aoB,gGO+apB,kBP7bQ,kHOgcN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WPhdI,cOkdJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cPhdkB,oBOkdlB,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,8DACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UP9hBF,aOwiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cPhiBsB,kBOkiBtB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cPxjBY,sBO4jBd,mCACE,+BACA,cP7jBQ,kBOikBV,oBACE,cPpjBoB,qBOsjBpB,wBAEA,UPxkBI,0BO0kBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBPhlBS,WATL,eO4lBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aPjnBsB,qBOmnBpB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aPnoBwB,yBOqoBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cPrpBsB,oCOwpBtB,cACE,mBACA,kBACA,4CAGF,aP7pBwB,gBO+pBtB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBPrsBM,YOusBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cPlsBwB,WOosBxB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WPjvBI,qCOmvBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UPzvBI,0BO2vBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cPxxBsB,0BO2xBtB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WPrzBI,kBOuzBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aP/zBc,0SOy0BZ,+CACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBPn3Bc,gBOq3BZ,2BAEA,kBPv3BY,gBOy3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC17BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WR7EA,gBQ+EA,gBACA,uBACA,+BAGF,aACE,eACA,cRtEgB,gBQwEhB,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WR3GI,gBQ6GJ,qBACA,iBACA,qBACA,sBAGF,eRnHM,oBQqHJ,cR5GS,eQ8GT,cACA,kBAGF,cACE,uCAGF,wBAEE,cRhHsB,oBQoHxB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBRnKa,mCQqKX,cR9JsB,eQgKtB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cR5LwB,sCQ8LxB,sCACA,6DAEA,aRjNc,sCQmNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cR3OsB,0BQ6OtB,6BAGF,aACE,cRlPoB,4BQsPtB,aRpPwB,qBQsPtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aRpRY,gBQsRV,0CAGF,aRzRY,wCQ8Rd,eACE,wCAIJ,UACE,0BAIA,aRzRsB,4BQ4RpB,aR3RsB,qBQ6RpB,qGAEA,yBAGE,iCAIJ,URvTI,gBQyTF,wBAIJ,eACE,kBChUJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBTpBW,6GSuBT,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBT7DwB,WAlBlB,oBSkFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UT5FI,gFSgGN,kBAGE,qNAKA,kBTxFoB,4ISgGpB,kBT9GQ,qCSqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cXIwB,SWFxB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aXVsB,eWYpB,SAIJ,wBXd0B,YWgBxB,kBACA,sBACA,WXpCM,eWsCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBXjEQ,gBWqEN,mCAIJ,wBX3DsB,eW8DpB,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,aX7FW,mBAOW,qGW0FpB,wBAGE,8BAIJ,kBXnFsB,2GWsFpB,wBAGE,0BAIJ,aX3GsB,uBW6GpB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,UACA,cX7HsB,SW+HtB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,qCACA,4BACA,2CACA,oBAGF,mCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aXrKwB,gCWyKxB,QACE,uEAGF,mBAGE,uBAGF,aXvKsB,sFW0KpB,aAGE,qCACA,6BAGF,mCACE,gCAGF,aACE,6BACA,8BAGF,aXtMsB,uCWyMpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,aXhNwB,SWkNtB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,qCACA,4BACA,2CACA,yBAGF,mCACE,4BAGF,aACE,6BACA,eACA,0BAGF,aX7PwB,qCWiQxB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,aXlTsB,sDWsTtB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBXvUoB,yDW8UxB,aXvVW,mBWyVT,mBXlVoB,oCWoVpB,iBACA,kBACA,eACA,gBACA,6CAEA,aXjWS,gBWmWP,CAII,kRADF,eACE,wCAKN,aXvVoB,gBWyVlB,0BACA,yIAEA,oBAGE,sCAKN,iBACE,MACA,QACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,cX3ZS,gBATL,aWuaJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,aXhZoB,CW8YpB,sHAEA,aXhZoB,CW8YpB,8HAEA,aXhZoB,CW8YpB,4GAEA,aXhZoB,+FWoZpB,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBX5csB,0BW8ctB,cXtdS,eWwdT,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,aXlgBsB,wCWsgBtB,aX3hBW,oBW6hBT,eACA,gBXviBI,sEW0iBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cXljBgB,eWojBhB,gBACA,aACA,oBACA,6QAEA,aAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cXllBgB,SWolBhB,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UX7mBF,8GWinBE,WACE,cXjmBc,CAjBlB,oGWinBE,WACE,cXjmBc,CAjBlB,wGWinBE,WACE,cXjmBc,CAjBlB,+FWinBE,WACE,cXjmBc,iFWsmBlB,SACE,wEAKN,iBACE,sBX/nBE,wBWioBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,mBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cXpqBsB,4CWuqBtB,aXlsBY,kCWusBd,2CACE,WC7sBF,8DDktBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBX7sBsB,aW+sBtB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,aXtuBa,cWwuBX,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WX3wBM,wDW8wBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aXnyBc,qBWqyBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,aXvyBoB,8EW4yBtB,aACE,0GAGF,kBXhzBsB,sHWmzBpB,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,+BAKN,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,aX52BwB,qBW82BtB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,cXr6BW,gBWu6BX,eACA,cACA,yBACA,iBACA,eACA,sBACA,4BAGF,aX35BwB,SW65BtB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aXj/BoB,qCWq/BpB,aX5/BS,6BWggCT,aXz/BoB,CAPX,kEWwgCT,aXxgCS,kCW2gCP,aXlgCoB,gEWsgCpB,UXxhCE,mBAgBgB,sEW4gChB,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,aXhhCwB,YWmhCtB,eACA,uBAGF,aXvhCwB,qCW2hCxB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cX5kCsB,CW8kCtB,iBACA,eACA,kBACA,+CAEA,aXnlCsB,uBWulCtB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cXhnCkB,4BWsnCxB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cX9qCsB,eWgrCtB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,aX/tCa,eWiuCX,6BAEA,aX9sCsB,SWmtCxB,YACE,gCACA,8BAEA,aACE,cACA,WXvvCI,qBWyvCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cXxwCoB,gBW0wCpB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBE3yCE,iCACA,wBACA,4BACA,kBF0yCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBErzCA,iCACA,wBACA,4BACA,kBFozCE,gBACA,kBACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WXr0CE,6BWu0CF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCE30CrB,+BF60CA,iBEt1CA,iCACA,wBACA,4BACA,WFq1CuB,sCE/0CvB,kCFk1CA,iBE31CA,iCACA,wBACA,4BACA,WF01CuB,sCEp1CvB,kBFs1CE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cX/0CsB,6BWk1CtB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,yBACA,eACA,cX56CoB,kCWg7CtB,aACE,eACA,gBACA,WXn8CI,CWw8CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UXx+CM,kBW8+CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aX1/C0B,cW4/CxB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WX1hDI,kCW+hDR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,CXphDsB,gHW8hDtB,aX9hDsB,wBWkiDtB,UACE,wCAGF,kBXtiDsB,cArBX,8CW+jDT,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cXlkDsB,eWokDtB,iBACA,kBACA,4BAEA,aXtkDwB,6BW0kDxB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CXjmDU,mEWwmDZ,aXxmDY,uBW4mDZ,aX7mDc,4DWmnDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UXvoDM,0BWyoDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cX9nD4B,eAEC,0DW+nD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cXtpD4B,eAEC,WWupD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cXvsDsB,wBW0sDtB,aXzsDwB,mBW6sDxB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBX1wD0B,cW4wDxB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BX/yDsB,2BWmzDxB,WACE,iBACA,uBACA,yBXtzDsB,8BW0zDxB,QACE,iBACA,uBACA,4BX7zDsB,6BWi0DxB,SACE,gBACA,2BACA,2BXp0DsB,wBW00DxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBXh1DsB,cARb,gBW21DT,uBACA,mBACA,yFAEA,kBXt1DsB,cADA,UW41DpB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBX12DsB,cW42DtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBXn4DsB,cARb,gBW84DT,uBACA,mBACA,oDAEA,SACE,oDAGF,kBX74DsB,cADA,iBWq5D1B,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBXlgEW,8BWogET,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cXrhEsB,qBWuhEtB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WX7lEM,qBW+lEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cXpmEsB,sBWwmExB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WXpyEM,kBWsyEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBX71EsB,yBW+1EtB,gBACA,kBACA,eACA,gBACA,iBACA,WXt3EI,mDW23ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBX75EI,0BW+5EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBXp9EW,0BWy9Eb,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cXliFwB,eWoiFxB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cXvjFwB,eWyjFxB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBXroFW,qCWuoFX,sEAGF,wBACE,4CAGF,wBXroF0B,+EWyoF1B,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBXjsFwB,cWqsF1B,kBACE,WXxtFM,cW0tFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cXztFsB,kGW4tFtB,sBAGE,WXhvFE,kCWovFJ,aXluFsB,oBWwuF1B,oBACE,iBACA,qBAGF,oBACE,kBACA,CACA,gBACA,CX1vFW,eW6vFX,iBACA,wCANA,cACA,CACA,eACA,mBAaA,CAVA,mBX9vFW,aAqBW,iBW+uFtB,CAEA,wBACA,eACA,yDAGF,kBX3wFa,cWixFb,aACE,kBAGF,aXhwFwB,cWkwFtB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,aX9xFkB,0BWgyFhB,sDAIJ,oBACE,cXnzFkB,sMWszFlB,yBAGE,oDAKN,aXhzFsB,0BWszFtB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cX50FkB,aW80FlB,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,aXv2FkB,qBW82FxB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,cX94FW,gBATL,aW05FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,aXh6FwB,6BWk6FtB,uDAGF,aXj7F0B,cWq7F1B,YACE,eACA,yBACA,kBACA,cX76FsB,gBW+6FtB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cXt9FoB,uBWw9FpB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UXh/FE,yBWu/FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cX1gGsB,gBW4gGtB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aXxhGwB,oBW4hGxB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cX3lGsB,6BW6lGtB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cXrnGsB,mBArBX,eW6oGX,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cXnpGkB,qCWupGpB,cACE,gBACA,yBAKN,iBACE,cACA,UACA,gCAEA,uCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,oFACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,aXnuGwB,4CWwuGtB,aXxuGsB,0CW0uGpB,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cXrwGsB,eWuwGtB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UXnyGI,mBWqyGF,6BAKN,eACE,gBACA,gBACA,cX7xGsB,0DW+xGtB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aX5zGsB,0BW8zGpB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,aXh1GwB,eWk1GtB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBXz+GM,WACA,eW2+GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eXv/GQ,cAiBgB,SWy+GtB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WXtjHE,gBWwjHF,eACA,+LAMA,yBACE,mEAKF,yBACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aXplHwB,eWslHtB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtmHF,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,+EFgmHI,aACE,CEjmHN,qEFgmHI,aACE,CEjmHN,yEFgmHI,aACE,CEjmHN,gEFgmHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,aX9mHoB,iBWgnHlB,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aXrpHoB,0HW0pHpB,cAEE,gBACA,cX/oHkB,kZWkpHlB,aAGE,gEAIJ,wBACE,iDAGF,eX3rHI,kBa0BN,CAEA,eACA,cbbsB,uCaetB,UF8pHI,mBX5qHoB,oDagBxB,wBACE,cblBoB,eaoBpB,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,cbzCS,sDWwrHT,WACE,mDAGF,aX5rHS,kBW8rHP,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UXxtHQ,kBW0tHN,cACA,mBACA,sBX7tHM,yBW+tHN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aX/tHwB,qBWiuHtB,mBACA,gBACA,sBACA,6EAGF,aXztHwB,mBArBX,kBWmvHX,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,4EAfF,cAgBI,6FAGF,eACE,mFAGF,aX5vHwB,qBW8vHtB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eXt2HQ,kBWw2HN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBXl4HM,kBWo4HN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBX37HW,kCW67HX,uBAGF,MACE,aACA,mBACA,uBACA,cX57HwB,eW87HxB,gBACA,0BACA,kBACA,kBAGF,YACE,cXx7HsB,gBW07HtB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,yBACA,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBXj9HsB,kBWm9HtB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBXv+HwB,kBWy+HxB,eAGF,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBXliIM,uCWoiIN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,aXziIa,aW2iIX,eACA,aACA,kEAEA,kBXtiIwB,WAlBlB,UW4jIJ,CX5jII,4RWikIF,UXjkIE,wCWukIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cXtjIsB,2CWyjItB,eACE,cACA,cXhlIS,CWqlIL,wQADF,eACE,mDAON,eXrmIM,0BWumIJ,qCACA,gEAEA,eACE,0DAGF,kBX5lIsB,uEW+lIpB,UXjnIE,uDWunIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SEznIE,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,cFmnIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cXzqIsB,eW2qItB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cXlrIoB,eWorIpB,uCAEA,uBACE,sCAGF,aACE,yBAKN,aXhsIwB,mBWksItB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cXxtIoB,iCW2tIpB,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cXtvIwB,qBWwvIxB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cXpwIsB,kBWswItB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cXjyI0B,eAEC,CW2yI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,WX73IM,eW+3IN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cX15IsB,mFW65ItB,yBAGE,wBAKN,oBACE,sBAGF,qBX17IQ,YW47IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBXn7I0B,qBWu7I1B,iBACE,UACA,QACA,YACA,6CAGF,kBX/7I0B,cARb,kBW48IX,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,aXz+IsB,SW4+IpB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,aXlhJS,qwDWshJP,aAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,cXziJS,kBW2iJT,yBACA,eACA,qBAGF,kBXhjJW,cAQa,gBW2iJtB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,cXtkJW,kBWwkJX,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,aACE,wBAKF,eX5mJM,CAiBkB,gBW8lJtB,oBACA,iEXhnJI,2BAiBkB,yBWumJ1B,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBXtnJwB,aWwnJxB,iBACA,2HAEA,aACE,iBACA,cXhnJoB,mBWknJpB,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,aX1rJwB,iLW8rJxB,aXvsJW,qCW4sJX,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,aXtuJS,gBATL,aWkvJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eXlwJI,yBWowJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,aXzwJO,oBW2wJL,eACA,gBXrxJA,+CW0xJJ,YACE,8BACA,mBACA,4CAIJ,aACE,cXzxJS,eW2xJT,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,aXpyJS,eWsyJP,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,aXh1JO,aWk1JL,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBXx1JgB,WAlBlB,uDWi3JA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cXv2JsB,eWy2JtB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,aX56JS,CW86JP,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBXp7J0B,WWs7JxB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WXj9JM,0BWm9JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cX59JoB,iBW89JpB,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cX1/JkB,gBW4/JlB,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,aX7gKoB,gBWqhKtB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cX9jKwB,kBWgkKxB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBX3lKM,CW4lKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBXvmKM,iCW0mKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,qCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBXvsKM,eWysKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBXpxKI,cAiBgB,gBWswKpB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UXn1KE,+EW21KN,cAGE,gBACA,6BAGF,UXl2KM,iBWo2KJ,yBAGF,oBACE,aACA,mDAGF,UX52KM,uBWi3KN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WXj6KE,sFWo6KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WX3jLF,gBW6jLE,gBACA,uBACA,0CAGF,aACE,eACA,cXpjLc,gBWsjLd,gBACA,uBACA,yBAKN,kBXpkLS,aWskLP,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cX5oLoB,eW8oLpB,eACA,gBACA,kBACA,qBACA,kBACA,WACA,mBACA,yJAEA,aXtpLsB,qWWypLpB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBX1sLa,sBW6sLX,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eXxwLQ,kBa0BN,CACA,sBACA,gBACA,cbbsB,uCaetB,mBAEA,wBACE,cblBoB,eaoBpB,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,cbzCS,UWmwLb,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cXtwLsB,gBWwwLtB,gBAEA,aXzwLsB,0BW2wLpB,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBX94LO,WATL,eW05LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cXv6LoB,CW06LpB,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,cAGF,kBX5/La,sBW8/LX,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBXljMa,sBWojMX,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBX5mMM,yDW+mMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBXvnMI,uBW2nMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UXxpMI,eW0pMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aXlqMwB,eWoqMtB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WXtyMA,gBWwyMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cX/xMc,gBWiyMd,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WXn0ME,gDWu0MJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aXl1MU,yBWw1Md,cACE,gCAEA,cACE,cX70MkB,eW+0MlB,kCAEA,oBACE,cXl1MgB,qBWo1MhB,iBACA,gBACA,yCAEA,eACE,WXz2MF,iBWk3MN,aXp1MsB,mBWs1MpB,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cX92MkB,gBWg3MlB,uBACA,mBACA,4BAEA,eACE,uBAGF,aXt4MkB,qBWw4MhB,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cXh6MoB,0BWo6MtB,aACE,WACA,2CAEA,oCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBXv8MsB,kBWy8MtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cX/9MkB,kBWi+MlB,+BAGF,aXp+MoB,eWs+MlB,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UX9/ME,qBWggNA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UX1hNI,gBWgiNR,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBXhkNoB,kBWkkNpB,cACA,eACA,4BAIJ,YACE,cX3kNoB,kBW6kNpB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,cXzoNkB,mFW6oNpB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,aX5qNsB,SW8qNpB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OGxtNN,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBdIsB,ecCxB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAGF,aACE,sBACA,WACA,eACA,cdtCO,UcwCP,oBACA,gBdlDE,yBcoDF,kBACA,iBACA,sCAEA,oBdtCoB,0Bc2CtB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBd3FY,8EcgGZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,cdvGoB,ac2GtB,cACE,uBACA,UACA,SACA,SACA,cdhHoB,0BckHpB,kBACA,mBAEA,oBACE,sCAGF,mCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBdpKoB,sDc0KxB,cACE,gBACA,iBACA,YACA,oBACA,cdnKoB,sCcsKpB,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,cd5MS,qBc8MT,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,adzMwB,qBc4MtB,+BACE,6BAEA,+BACE,eChPN,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,chBSW,2BgBNX,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBhBHsB,4BgBOxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,chBLsB,cgBOtB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,ahBpC0B,mCgBuCxB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBhBrDwB,uBgB0DxB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBhB5FM,sBgB8FN,sGAEA,+BAEE,oBAKF,2BACA,gBhBxGM,0BgB2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,chBzGS,yBgB2GT,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBhBpKI,mBgByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,chBvKsB,mDgB0KtB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,cjBJsB,oBiBOtB,ajBLwB,0BiBOtB,6EAEA,oBAGE,wCAIJ,ajBlBsB,oBiBuBtB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cjBhCsB,qBiBoCxB,iBACE,cjBrCsB,uBiByCxB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,cjBzDsB,qBiB6DxB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cjBrIkB,iCiByIpB,uBACE,gBACA,gBACA,cjB9HkB,qDiBkIpB,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WjBpNI,iBiBsNJ,kBACA,qEAEA,aAEE,6CAIA,ajB9MoB,oCiBmNtB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,cjB/OkB,mBiBiPlB,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WjBzSA,qBiB2SA,uDAGE,yBACE,2CAKN,aACE,cjBrSgB,kCiB6StB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,cjBpToB,sCiBuTpB,ajBrTsB,0BiBuTpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,cjB7UsB,wBiBgVtB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,cjB9VsB,kBiBmWtB,cjBnWsB,mCiBkWxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBvWsB,kBiB4WtB,cjB5WsB,kBiBqXtB,cjBrXsB,mCiBoXxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBzXsB,kBiB8XtB,cjB9XsB,mCiBsYxB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,2CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBjB1bW,kBiB4bT,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBjBnjBS,kBiBqjBT,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,ajB3kBsB,qCiB+kBtB,eACE,WjBjmBE,gBiBmmBF,2CAEA,ajBrlBkB,gDiBwlBhB,ajBvlBkB,+CiB6lBtB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SjBvrBI,YiByrBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,cjBjsBkB,6BiBqsBpB,eACE,iBACA,+BAGF,kBjBjtBS,aiBmtBP,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,cjBhvBgB,uFiBsvBtB,eACE,cASA,CjBhwBoB,2CiB6vBpB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cjB71BsB,qBiB+1BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cjBv1BoB,SkBhCxB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBlBhBW,UkBqBX,alBZwB,0BkBctB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBlBzDS,6BkB2DP,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,clB5FsB,gBkB8FtB,0DAEA,UlBhHM,wDkBoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBlBvJS,sBkByJT,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBlBtKS,gCkByKT,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBlB9LS,uCkBiMP,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,clB/NgB,gBkBiOhB,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBnBPO,YmBSP,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SnBxCA,YmB0CE,kBACA,YACA,uCAIJ,aACE,cnBjCgB,qBmBmChB,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cnB3EgB,qBmB6EhB,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UnBzGA,yBmB2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UnBjIE,yBAkBkB,gBmBkHlB,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,anBnMsB,emBqMpB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,anB9MsB,emBgNpB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,cnB1NkB,mBmB4NlB,kBACA,gCACA,4BAGF,cACE,cnBjOoB,iBmBmOpB,gBACA,0CAGF,UnBxPI,gBmB0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WnBxQE,oBmB0QF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cnBjQoB,mBmBmQpB,kCAEA,UnBtRE,gBmBwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,2CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BnBjUoB,YmBwU1B,UACE,SACA,cACA,WACA,sDAKA,anBnVsB,0DmBsVpB,anBpVsB,4DmByVxB,anB1Wc,gBmB4WZ,4DAGF,anB9WU,gBmBgXR,0DAGF,anBvVsB,gBmByVpB,0DAGF,anBtXU,gBmBwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,cnBxZkB,qBmB0ZlB,yBACA,eACA,gBACA,gCACA,iCAEA,UnBhbE,gCmBkbA,oCAGF,anBnaoB,gCmBqalB,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cnB7dsB,CmBkelB,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,anB3iBwB,qBmB6iBtB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBnBpkBW,gCmBskBX,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,cnBxkBoB,emB0kBpB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,anBplBsB,sDmBwlBtB,anBrmBwB,qBmBymBtB,gBACA,yDAIJ,oBAIE,cnBlnBwB,iGmBqnBxB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBnB9qBc,yBmBkrBd,yBACE,wBAGF,yBnBnrBU,wBmBwrBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,anBprBoB,uBmB0rBpB,wBACA,qBAGF,anBhrBsB,cmBqrBxB,kBnB1sBa,kBmB4sBX,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cnBztBkB,yBmB2tBlB,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,anBvvBM,6BmB8vBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cnB9vBgB,mLmBiwBhB,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,anB/vBgB,iBmBiwBd,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cnBzxBgB,WmBgyBxB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,anB71BY,8CmBk2Bd,qBACE,aACA,WnBr2BI,cmB02BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBnB12BS,gCmB42BT,kCAEA,cACE,cACA,gBACA,eACA,gBACA,cnB32BoB,qBmB62BpB,mBACA,uHAEA,UnBj4BE,iCmBw4BJ,cACE,cnB32BkB,uCmB+2BpB,YACE,8BACA,mBACA,sCAGF,eACE,sBCt5BN,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WpBrCI,6CoBuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,cpBjCoB,kBoBmCpB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,apBrDwB,gBoBuDtB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,+DACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,0D","file":"flavours/vanilla/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#192432 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#192432;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#1c2938}::-webkit-scrollbar-thumb:active{background:#192432}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#121a24}::-webkit-scrollbar-track:active{background:#121a24}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#06090c;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#121a24}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#192432;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#0b1016;padding:0}body.error{position:absolute;text-align:center;color:#9baec8;background:#121a24;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#e25169;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#d8a070}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#3e5a7c;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#3e5a7c}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#3e5a7c;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#79bd9a;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#d9e1e8;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#26374d}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#9baec8;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#2d415a;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#344b68}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#d9e1e8}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#000}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#192432;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #192432;background:#040609}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#192432;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#9baec8}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#9baec8;padding:10px;border-right:1px solid #192432;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #d8a070;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#d9e1e8}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #26374d}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#9baec8}.public-layout .public-account-header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#e1b590}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#9baec8}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #202e3f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #202e3f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#121a24}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#192432}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#4c6d98}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#4c6d98}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#9baec8}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#4c6d98}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#4c6d98}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#5377a5}.compact-header h1{font-size:24px;line-height:28px;color:#9baec8;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#d9e1e8}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#121a24;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.hero-widget__text a{color:#d9e1e8;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#9baec8}.box-widget{padding:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #3e5a7c;text-align:center;color:#9baec8;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#9baec8;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#d9e1e8;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#9baec8}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#9baec8;margin-bottom:10px}.page-header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#9baec8}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#192432}.page-header h1{font-size:24px}}.directory{background:#121a24;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#121a24;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#202e3f}.directory__tag.active>a{background:#d8a070;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#9baec8}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#9baec8}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#d8a070}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#040609;border:2px solid #121a24}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#9baec8;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #202e3f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#9baec8;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #2d415a}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#9baec8}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#d8a070}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#0b1016;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #192432}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #192432}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#d8a070;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#9baec8}.simple_form .hint a{color:#d8a070}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#000}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#9baec8}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #3e5a7c;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102;border:1px solid #000;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#a8b9cf}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#d8a070;background:#040609}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#d8a070;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#ddad84}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#d3935c}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #000;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#3e5a7c;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(1, 1, 2, 0), #010102)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(18,26,36,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#202e3f;color:#9baec8;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#9baec8;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#192432}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#9baec8;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#d8a070;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#e1b590}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#9baec8}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#d9e1e8;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#d9e1e8;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#9baec8}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#010102;border:1px solid #000;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#0b1016;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#0b1016;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#202e3f}.card__img{height:130px;position:relative;background:#000;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#192432;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#121a24;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#d9e1e8}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#233346}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#9baec8;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #26374d;border-bottom:1px solid #26374d;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #26374d}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#d9e1e8;background:rgba(4,6,9,.5)}.account__header__fields dd{flex:1 1 auto;color:#9baec8}.account__header__fields a{color:#d8a070;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#9baec8}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#121a24}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#202e3f}.button.logo-button{flex:0 auto;font-size:14px;background:#d8a070;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#e3bb98}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#d8a070;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#d8a070;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#e3bb98;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9baec8;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#121a24;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#3e5a7c}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#45648a}.button.button-secondary{color:#9baec8;background:transparent;padding:3px 15px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#a8b9cf}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#4a6b94;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(62,90,124,.3)}.icon-button.disabled{color:#283a50;background-color:transparent;cursor:default}.icon-button.active{color:#d8a070}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#3e5a7c}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#324965;background-color:rgba(62,90,124,.15)}.icon-button.inverted:focus{background-color:rgba(62,90,124,.3)}.icon-button.inverted.disabled{color:#4a6b94;background-color:transparent}.icon-button.inverted.active{color:#d8a070}.icon-button.inverted.active.disabled{color:#e6c3a4}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#3e5a7c;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#324965;background-color:rgba(62,90,124,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(62,90,124,.3)}.text-icon-button.disabled{color:#6b8cb5;background-color:transparent;cursor:default}.text-icon-button.active{color:#d8a070}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#d8a070}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#d8a070;background:#d8a070}.compose-form .compose-form__warning{color:#121a24;margin-bottom:10px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#121a24;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#3e5a7c;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:0;right:0}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#3e5a7c}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#d9e1e8;border-radius:0 0 4px 4px;color:#121a24;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#3e5a7c}.compose-form .compose-form__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#eff3f5}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#d9e1e8;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#d9e1e8}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#3e5a7c}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9baec8;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#121a24;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#fff}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#4a6b94}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#3e5a7c}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#e1b590}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#3e5a7c}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#d9e1e8;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#e1b590}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#e1b590;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#121a24;font-weight:700;font-size:11px;padding:0 6px;text-transform:uppercase;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#3e5a7c;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #202e3f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#192432}.focusable:focus .status.status-direct{background:#26374d}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#202e3f}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #202e3f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#202e3f;border-bottom-color:#26374d}.status.light .status__relative-time{color:#9baec8}.status.light .status__display-name{color:#121a24}.status.light .display-name{color:#9baec8}.status.light .display-name strong{color:#121a24}.status.light .status__content{color:#121a24}.status.light .status__content a{color:#d8a070}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#547aa9}.status__relative-time,.notification__relative_time{color:#3e5a7c;float:right;font-size:14px}.status__display-name{color:#3e5a7c}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#3e5a7c;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#3e5a7c}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#3e5a7c}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#192432;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#3e5a7c;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#121a24;font-size:14px}.reply-indicator__content a{color:#3e5a7c}.domain{padding:10px;border-bottom:1px solid #202e3f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #202e3f}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#9baec8;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #202e3f;color:#3e5a7c}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #202e3f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #d8a070}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#9baec8}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#d9e1e8;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#3e5a7c}.muted .status__display-name strong{color:#3e5a7c}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#3e5a7c;color:#121a24}.muted a.status__content__spoiler-link:hover{background:#4a6b94;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#9baec8;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#d8a070}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#9baec8}.navigation-bar strong{color:#d9e1e8}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#d8a070;color:#d9e1e8;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#d8a070;color:#d9e1e8}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#040609;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#121a24;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#9baec8;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#202e3f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #202e3f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}}.tabs-bar__link.active{border-bottom:2px solid #d8a070;color:#d8a070}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#d59864;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#e0b38c}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#d8a070;border:2px solid #202e3f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#040609}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #192432;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#283a50;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#121a24}.drawer__inner__mastodon{background:#283a50 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#283a50;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#202e3f;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#17212e;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#192432;color:#d8a070;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#192432;border:0;font-family:inherit;color:#d8a070;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#121a24;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#010102}.react-toggle--checked .react-toggle-track{background-color:#d8a070}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#e3bb98}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #121a24;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#d8a070}.column-link{background:#202e3f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#253549}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#d8a070}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#121a24;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#121a24;color:#3e5a7c;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#121a24}.flex-spacer{flex:1 1 auto}.getting-started{color:#3e5a7c;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#3e5a7c;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#9baec8}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#3e5a7c}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#9baec8;padding:10px;font-weight:500;border-bottom:1px solid #202e3f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#9baec8}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#202e3f;border:1px solid #0b1016}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#3e5a7c;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#d8a070}.status-card{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;color:#3e5a7c;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#d9e1e8;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#202e3f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#9baec8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#9baec8}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#202e3f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#192432}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#192432}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#3e5a7c;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#151f2b}.load-gap{border-bottom:1px solid #202e3f}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#3e5a7c;background:#121a24;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#3e5a7c}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(216,160,112,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(216, 160, 112, 0.23) 0%, rgba(216, 160, 112, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#192432;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#d8a070}.column-header.active .column-header__icon{color:#d8a070;text-shadow:0 0 10px rgba(216,160,112,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#192432;border:0;color:#9baec8;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#b2c1d5}.column-header__button.active{color:#fff;background:#202e3f}.column-header__button.active:hover{color:#fff;background:#202e3f}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#9baec8;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #26374d;margin:10px 0}.column-header__collapsible-inner{background:#202e3f;padding:15px}.column-header__setting-btn:hover{color:#9baec8;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#3e5a7c;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #3e5a7c;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#3e5a7c}29%{background-color:#3e5a7c}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#9baec8;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#b5c3d6}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.modal-container--preloader{background:#202e3f}.account--panel{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#202e3f;padding:15px}.column-settings__section{color:#9baec8;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#a8b9cf}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#192432}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#3e5a7c;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#202e3f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#26374d;color:#a8b9cf}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#9baec8}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#3e5a7c}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#45648a}.column-settings__hashtags .column-select__indicator-separator{background-color:#202e3f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#121a24}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#121a24;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#9baec8;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#3e5a7c;background:#121a24;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#d8a070;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#0b1016;contain:initial}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#121a24;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#d9e1e8;font-size:18px;font-weight:500;border:2px dashed #3e5a7c;border-radius:4px}.upload-progress{padding:10px;color:#3e5a7c;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:12px;text-transform:uppercase;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#3e5a7c;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#d8a070;border-radius:6px}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#121a24;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#d8a070;color:#fff;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#fff}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#fff}.privacy-dropdown__option.active:hover{background:#dcab80}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#3e5a7c}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#121a24}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#d8a070}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#a8b9cf}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#192432}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#d9e1e8;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#3e5a7c;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#4a6b94}.search-results__header{color:#3e5a7c;background:#151f2b;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#3e5a7c}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#d9e1e8;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#e6ebf0;text-decoration:underline}.search-results__info{padding:20px;color:#9baec8;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#d8a070}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#121a24;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#3e5a7c;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#37506f;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#121a24}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#192432}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#121a24;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#121a24;color:#d9e1e8;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#121a24;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#f2f5f7}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#3e5a7c;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#d8a070}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#121a24}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#121a24;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#121a24;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#121a24;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#d8a070;color:#fff}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#3e5a7c;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#37506f;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#121a24;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#d8a070;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#3e5a7c;padding:8px 18px;cursor:default;border-right:1px solid #202e3f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#3e5a7c;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#3e5a7c}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#d9e1e8;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#040609;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #192432;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(225,181,144,.5)}.audio-player__wave-placeholder{background-color:#2d415a}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#040609;border-top:1px solid #192432;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#9baec8;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#b2c1d5}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#e1b590}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#e1b590}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#e1b590;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#000;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#192432;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#121a24;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #202e3f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#0b1016;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#9baec8;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative;width:100%;white-space:nowrap}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#d9e1e8}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #202e3f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #121a24}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#0f151d;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #06090c}.filter-form{background:#121a24}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#e1b590;background:#e1b590}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#121a24}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#d9e1e8;max-width:400px}noscript div a{color:#d8a070;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#192432}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f}.account__moved-note__message{position:relative;margin-left:58px;color:#3e5a7c;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#192432}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.list-editor{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#283a50;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#283a50}.list-adder__lists{background:#283a50;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #202e3f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#9baec8;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#0b1016}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#192432;padding:5px;border-bottom:1px solid #26374d}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#040609;border:2px solid #192432}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #26374d;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #26374d}.account__header__bio .account__header__fields a{color:#e1b590}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#9baec8;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.trends__header{color:#3e5a7c;background:#151f2b;border-bottom:1px solid #0b1016;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #202e3f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#3e5a7c;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#9baec8;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#d9e1e8}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(216,160,112,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#dfb088 !important}.conversation{display:flex;border-bottom:1px solid #202e3f;padding:5px;padding-bottom:0}.conversation:focus{background:#151f2b;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#d8a070;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#9baec8;padding-left:15px}.conversation__content__names{color:#9baec8;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#151f2b}.conversation--unread:focus{background:#192432}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.announcements{background:#202e3f;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#d8a070;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#9baec8;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#26374d;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#9baec8}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#2d415a;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#a8b9cf}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#4a4c54}.reactions-bar__item.active .reactions-bar__item__count{color:#e1b590}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#9baec8;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#a8b9cf;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#8ba1bf;height:5px;min-width:1%}.poll__chart.leading{background:#d8a070}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#121a24;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#d8a070}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#acd6c1;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#3e5a7c}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#3e5a7c;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(62,90,124,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#d8a070}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#3e5a7c;border-color:#3e5a7c;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#121a24;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#3e5a7c}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(216,160,112,.2)}.modal-layout{background:#121a24 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#121a24}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#3e5a7c;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#37506f}.emoji-mart-anchor-selected{color:#d8a070}.emoji-mart-anchor-selected:hover{color:#d49560}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#d8a070}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#121a24;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#9baec8}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#9baec8}.rich-formatting a{color:#d8a070;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#9baec8}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#d9e1e8}.rich-formatting em{font-style:italic;color:#d9e1e8}.rich-formatting code{font-size:.85em;background:#040609;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#d9e1e8}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #192432;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #192432;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#9baec8}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#3e5a7c}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#0b1016;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#d9e1e8}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#040609;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#9baec8;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #192432;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#7a93b6}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#9baec8}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#9baec8}.landing-page p a,.landing-page li a{color:#d8a070;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#bcc9da}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#121a24;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#bcc9da}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#121a24;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#d9e1e8}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#9baec8}.landing-page__short-description h1 small span{color:#d9e1e8}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#121a24;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#9baec8}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#9baec8}.landing .simple_form p.lead{color:#9baec8;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #202e3f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#3e5a7c}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #121a24;text-align:left;background:#0b1016}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #121a24;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#121a24}.table a{color:#d8a070;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#121a24;border-top:1px solid #040609;border-bottom:1px solid #040609}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #040609}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #040609}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#9baec8;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #040609;background:#121a24;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #040609;border-top:0;background:#121a24}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #040609;border-top:0;background:#0b1016}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #040609}}.batch-table__row:hover{background:#0f151d}.batch-table__row:nth-child(even){background:#121a24}.batch-table__row:nth-child(even):hover{background:#151f2b}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#9baec8;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #040609;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #040609}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#121a24;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#202e3f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#9baec8;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#26374d}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#9baec8;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#0a0e13;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#0f151d;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#0b1016;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#d8a070;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#ddad84}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #202e3f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#d9e1e8;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#d9e1e8;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#9baec8;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #202e3f}.admin-wrapper .content h6{font-size:16px;color:#d9e1e8;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#d9e1e8;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(62,90,124,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #192432;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#d8a070}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#9baec8}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#d8a070}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#3e5a7c;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#9baec8;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #121a24}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #1b2635}.filters .filter-subset a.selected{color:#d8a070;border-bottom:2px solid #d8a070}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#d9e1e8}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#d8a070;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#121a24;border-bottom:1px solid #192432}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#9baec8;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#3e5a7c}.log-entry a,.log-entry .username,.log-entry .target{color:#d9e1e8;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#d9e1e8}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #d8a070}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#9baec8}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#3e5a7c}.report-card{background:#121a24;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#9baec8;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#b5c3d6}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #0b1016}.report-card__summary__item:hover{background:#151f2b}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#9baec8}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#3e5a7c;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#9baec8}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#202e3f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#e1b590}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.announcements-list{border:1px solid #192432;border-radius:4px}.announcements-list__item{padding:15px 0;background:#121a24;border-bottom:1px solid #192432}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#d9e1e8;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#fff}.announcements-list__item__meta{padding:0 15px;color:#3e5a7c}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#192432;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#202e3f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#9baec8;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(1, 1, 2, 0), #010102)}body.rtl .simple_form select{background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 11px;\n padding: 0 6px;\n text-transform: uppercase;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 12px;\n text-transform: uppercase;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n width: 100%;\n white-space: nowrap;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/flavours/vanilla/common.js b/priv/static/packs/flavours/vanilla/common.js index 9918570ee..a69c77f8c 100644 Binary files a/priv/static/packs/flavours/vanilla/common.js and b/priv/static/packs/flavours/vanilla/common.js differ diff --git a/priv/static/packs/flavours/vanilla/embed.js b/priv/static/packs/flavours/vanilla/embed.js index d715c4ce0..428bde4a8 100644 Binary files a/priv/static/packs/flavours/vanilla/embed.js and b/priv/static/packs/flavours/vanilla/embed.js differ diff --git a/priv/static/packs/flavours/vanilla/embed.js.LICENSE b/priv/static/packs/flavours/vanilla/embed.js.LICENSE deleted file mode 100644 index 487bc60d8..000000000 --- a/priv/static/packs/flavours/vanilla/embed.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ diff --git a/priv/static/packs/flavours/vanilla/embed.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/embed.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/vanilla/embed.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/embed.js.map b/priv/static/packs/flavours/vanilla/embed.js.map index 99939ba39..11aaa64bb 100644 Binary files a/priv/static/packs/flavours/vanilla/embed.js.map and b/priv/static/packs/flavours/vanilla/embed.js.map differ diff --git a/priv/static/packs/flavours/vanilla/error.js b/priv/static/packs/flavours/vanilla/error.js index bb2bd90b3..7a5535676 100644 Binary files a/priv/static/packs/flavours/vanilla/error.js and b/priv/static/packs/flavours/vanilla/error.js differ diff --git a/priv/static/packs/flavours/vanilla/home.js b/priv/static/packs/flavours/vanilla/home.js index d3fdeb2aa..2412557a6 100644 Binary files a/priv/static/packs/flavours/vanilla/home.js and b/priv/static/packs/flavours/vanilla/home.js differ diff --git a/priv/static/packs/flavours/vanilla/home.js.LICENSE b/priv/static/packs/flavours/vanilla/home.js.LICENSE deleted file mode 100644 index 0a0301353..000000000 --- a/priv/static/packs/flavours/vanilla/home.js.LICENSE +++ /dev/null @@ -1,193 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ - -/*! https://mths.be/punycode v1.4.1 by @mathias */ diff --git a/priv/static/packs/flavours/vanilla/home.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/home.js.LICENSE.txt new file mode 100644 index 000000000..90a9a7678 --- /dev/null +++ b/priv/static/packs/flavours/vanilla/home.js.LICENSE.txt @@ -0,0 +1,193 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! https://mths.be/punycode v1.4.1 by @mathias */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/home.js.map b/priv/static/packs/flavours/vanilla/home.js.map index a32b3005b..28aab11ac 100644 Binary files a/priv/static/packs/flavours/vanilla/home.js.map and b/priv/static/packs/flavours/vanilla/home.js.map differ diff --git a/priv/static/packs/flavours/vanilla/public.js b/priv/static/packs/flavours/vanilla/public.js index 6810fccc7..7f1a585ee 100644 Binary files a/priv/static/packs/flavours/vanilla/public.js and b/priv/static/packs/flavours/vanilla/public.js differ diff --git a/priv/static/packs/flavours/vanilla/public.js.LICENSE b/priv/static/packs/flavours/vanilla/public.js.LICENSE deleted file mode 100644 index 487bc60d8..000000000 --- a/priv/static/packs/flavours/vanilla/public.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ diff --git a/priv/static/packs/flavours/vanilla/public.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/public.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/vanilla/public.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/public.js.map b/priv/static/packs/flavours/vanilla/public.js.map index 18eb23360..df384658a 100644 Binary files a/priv/static/packs/flavours/vanilla/public.js.map and b/priv/static/packs/flavours/vanilla/public.js.map differ diff --git a/priv/static/packs/flavours/vanilla/settings.js b/priv/static/packs/flavours/vanilla/settings.js index cd7983274..21cfd13f4 100644 Binary files a/priv/static/packs/flavours/vanilla/settings.js and b/priv/static/packs/flavours/vanilla/settings.js differ diff --git a/priv/static/packs/flavours/vanilla/settings.js.LICENSE b/priv/static/packs/flavours/vanilla/settings.js.LICENSE deleted file mode 100644 index 487bc60d8..000000000 --- a/priv/static/packs/flavours/vanilla/settings.js.LICENSE +++ /dev/null @@ -1,41 +0,0 @@ -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ diff --git a/priv/static/packs/flavours/vanilla/settings.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/settings.js.LICENSE.txt new file mode 100644 index 000000000..2196b2def --- /dev/null +++ b/priv/static/packs/flavours/vanilla/settings.js.LICENSE.txt @@ -0,0 +1,41 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/settings.js.map b/priv/static/packs/flavours/vanilla/settings.js.map index d2789d073..92c015611 100644 Binary files a/priv/static/packs/flavours/vanilla/settings.js.map and b/priv/static/packs/flavours/vanilla/settings.js.map differ diff --git a/priv/static/packs/flavours/vanilla/share.js b/priv/static/packs/flavours/vanilla/share.js index 7efe63b00..fdfa039cb 100644 Binary files a/priv/static/packs/flavours/vanilla/share.js and b/priv/static/packs/flavours/vanilla/share.js differ diff --git a/priv/static/packs/flavours/vanilla/share.js.LICENSE b/priv/static/packs/flavours/vanilla/share.js.LICENSE deleted file mode 100644 index 58e46bc71..000000000 --- a/priv/static/packs/flavours/vanilla/share.js.LICENSE +++ /dev/null @@ -1,191 +0,0 @@ -/*! - Copyright (c) 2017 Jed Watson. - Licensed under the MIT License (MIT), see - http://jedwatson.github.io/classnames -*/ - -/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */ - -/* -object-assign -(c) Sindre Sorhus -@license MIT -*/ - -/** @license React v16.12.0 - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v0.18.0 - * scheduler.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** @license React v16.12.0 - * react-is.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/*! - * wavesurfer.js 3.3.1 (2020-01-14) - * https://github.com/katspaugh/wavesurfer.js - * @license BSD-3-Clause - */ - -/*!****************************************!*\ - !*** ./node_modules/debounce/index.js ***! - \****************************************/ - -/*! no static exports found */ - -/*!***********************************!*\ - !*** ./src/drawer.canvasentry.js ***! - \***********************************/ - -/*! ./util/style */ - -/*! ./util/get-id */ - -/*!***********************!*\ - !*** ./src/drawer.js ***! - \***********************/ - -/*! ./util */ - -/*!***********************************!*\ - !*** ./src/drawer.multicanvas.js ***! - \***********************************/ - -/*! ./drawer */ - -/*! ./drawer.canvasentry */ - -/*!**************************************!*\ - !*** ./src/mediaelement-webaudio.js ***! - \**************************************/ - -/*! ./mediaelement */ - -/*!*****************************!*\ - !*** ./src/mediaelement.js ***! - \*****************************/ - -/*! ./webaudio */ - -/*!**************************!*\ - !*** ./src/peakcache.js ***! - \**************************/ - -/*!**************************!*\ - !*** ./src/util/ajax.js ***! - \**************************/ - -/*! ./observer */ - -/*!****************************!*\ - !*** ./src/util/extend.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/fetch.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/util/frame.js ***! - \***************************/ - -/*! ./request-animation-frame */ - -/*!****************************!*\ - !*** ./src/util/get-id.js ***! - \****************************/ - -/*!***************************!*\ - !*** ./src/util/index.js ***! - \***************************/ - -/*! ./ajax */ - -/*! ./get-id */ - -/*! ./max */ - -/*! ./min */ - -/*! ./extend */ - -/*! ./style */ - -/*! ./frame */ - -/*! debounce */ - -/*! ./prevent-click */ - -/*! ./fetch */ - -/*!*************************!*\ - !*** ./src/util/max.js ***! - \*************************/ - -/*!*************************!*\ - !*** ./src/util/min.js ***! - \*************************/ - -/*!******************************!*\ - !*** ./src/util/observer.js ***! - \******************************/ - -/*!***********************************!*\ - !*** ./src/util/prevent-click.js ***! - \***********************************/ - -/*!*********************************************!*\ - !*** ./src/util/request-animation-frame.js ***! - \*********************************************/ - -/*!***************************!*\ - !*** ./src/util/style.js ***! - \***************************/ - -/*!***************************!*\ - !*** ./src/wavesurfer.js ***! - \***************************/ - -/*! ./drawer.multicanvas */ - -/*! ./peakcache */ - -/*! ./mediaelement-webaudio */ - -/*!*************************!*\ - !*** ./src/webaudio.js ***! - \*************************/ diff --git a/priv/static/packs/flavours/vanilla/share.js.LICENSE.txt b/priv/static/packs/flavours/vanilla/share.js.LICENSE.txt new file mode 100644 index 000000000..2ea8cbae4 --- /dev/null +++ b/priv/static/packs/flavours/vanilla/share.js.LICENSE.txt @@ -0,0 +1,191 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + Copyright (c) 2017 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * wavesurfer.js 3.3.1 (2020-01-14) + * https://github.com/katspaugh/wavesurfer.js + * @license BSD-3-Clause + */ + +/*! ./ajax */ + +/*! ./drawer */ + +/*! ./drawer.canvasentry */ + +/*! ./drawer.multicanvas */ + +/*! ./extend */ + +/*! ./fetch */ + +/*! ./frame */ + +/*! ./get-id */ + +/*! ./max */ + +/*! ./mediaelement */ + +/*! ./mediaelement-webaudio */ + +/*! ./min */ + +/*! ./observer */ + +/*! ./peakcache */ + +/*! ./prevent-click */ + +/*! ./request-animation-frame */ + +/*! ./style */ + +/*! ./util */ + +/*! ./util/get-id */ + +/*! ./util/style */ + +/*! ./webaudio */ + +/*! debounce */ + +/*! no static exports found */ + +/*!***********************!*\ + !*** ./src/drawer.js ***! + \***********************/ + +/*!*************************!*\ + !*** ./src/util/max.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/util/min.js ***! + \*************************/ + +/*!*************************!*\ + !*** ./src/webaudio.js ***! + \*************************/ + +/*!**************************!*\ + !*** ./src/peakcache.js ***! + \**************************/ + +/*!**************************!*\ + !*** ./src/util/ajax.js ***! + \**************************/ + +/*!***************************!*\ + !*** ./src/util/fetch.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/frame.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/index.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/util/style.js ***! + \***************************/ + +/*!***************************!*\ + !*** ./src/wavesurfer.js ***! + \***************************/ + +/*!****************************!*\ + !*** ./src/util/extend.js ***! + \****************************/ + +/*!****************************!*\ + !*** ./src/util/get-id.js ***! + \****************************/ + +/*!*****************************!*\ + !*** ./src/mediaelement.js ***! + \*****************************/ + +/*!******************************!*\ + !*** ./src/util/observer.js ***! + \******************************/ + +/*!***********************************!*\ + !*** ./src/drawer.canvasentry.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/drawer.multicanvas.js ***! + \***********************************/ + +/*!***********************************!*\ + !*** ./src/util/prevent-click.js ***! + \***********************************/ + +/*!**************************************!*\ + !*** ./src/mediaelement-webaudio.js ***! + \**************************************/ + +/*!****************************************!*\ + !*** ./node_modules/debounce/index.js ***! + \****************************************/ + +/*!*********************************************!*\ + !*** ./src/util/request-animation-frame.js ***! + \*********************************************/ + +/** @license React v0.19.0 + * scheduler.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.12.0 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.0 + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** @license React v16.13.1 + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ diff --git a/priv/static/packs/flavours/vanilla/share.js.map b/priv/static/packs/flavours/vanilla/share.js.map index 52c37a2a1..8aefdd1c4 100644 Binary files a/priv/static/packs/flavours/vanilla/share.js.map and b/priv/static/packs/flavours/vanilla/share.js.map differ diff --git a/priv/static/packs/locales.js b/priv/static/packs/locales.js index 433c0d429..ad614f53b 100644 Binary files a/priv/static/packs/locales.js and b/priv/static/packs/locales.js differ diff --git a/priv/static/packs/locales.js.map b/priv/static/packs/locales.js.map index 134ab600d..45f8b2b61 100644 Binary files a/priv/static/packs/locales.js.map and b/priv/static/packs/locales.js.map differ diff --git a/priv/static/packs/locales/glitch/ar.js b/priv/static/packs/locales/glitch/ar.js index 8a0ba7b46..d99aca589 100644 Binary files a/priv/static/packs/locales/glitch/ar.js and b/priv/static/packs/locales/glitch/ar.js differ diff --git a/priv/static/packs/locales/glitch/ar.js.map b/priv/static/packs/locales/glitch/ar.js.map index c7eb80c27..86279ae70 100644 Binary files a/priv/static/packs/locales/glitch/ar.js.map and b/priv/static/packs/locales/glitch/ar.js.map differ diff --git a/priv/static/packs/locales/glitch/ast.js b/priv/static/packs/locales/glitch/ast.js index 85c8d227f..692011d30 100644 Binary files a/priv/static/packs/locales/glitch/ast.js and b/priv/static/packs/locales/glitch/ast.js differ diff --git a/priv/static/packs/locales/glitch/ast.js.map b/priv/static/packs/locales/glitch/ast.js.map index 7ae4686eb..7c0782e9d 100644 Binary files a/priv/static/packs/locales/glitch/ast.js.map and b/priv/static/packs/locales/glitch/ast.js.map differ diff --git a/priv/static/packs/locales/glitch/bg.js b/priv/static/packs/locales/glitch/bg.js index 5f093fc35..f657dbfdd 100644 Binary files a/priv/static/packs/locales/glitch/bg.js and b/priv/static/packs/locales/glitch/bg.js differ diff --git a/priv/static/packs/locales/glitch/bg.js.map b/priv/static/packs/locales/glitch/bg.js.map index 785293b7f..d881df95c 100644 Binary files a/priv/static/packs/locales/glitch/bg.js.map and b/priv/static/packs/locales/glitch/bg.js.map differ diff --git a/priv/static/packs/locales/glitch/bn.js b/priv/static/packs/locales/glitch/bn.js index af813f4f6..961c6ac85 100644 Binary files a/priv/static/packs/locales/glitch/bn.js and b/priv/static/packs/locales/glitch/bn.js differ diff --git a/priv/static/packs/locales/glitch/bn.js.map b/priv/static/packs/locales/glitch/bn.js.map index 319ae3adf..5af427b0f 100644 Binary files a/priv/static/packs/locales/glitch/bn.js.map and b/priv/static/packs/locales/glitch/bn.js.map differ diff --git a/priv/static/packs/locales/glitch/br.js b/priv/static/packs/locales/glitch/br.js index b4f8b1411..6db685b77 100644 Binary files a/priv/static/packs/locales/glitch/br.js and b/priv/static/packs/locales/glitch/br.js differ diff --git a/priv/static/packs/locales/glitch/br.js.map b/priv/static/packs/locales/glitch/br.js.map index ccef003ef..f1a4aa7fc 100644 Binary files a/priv/static/packs/locales/glitch/br.js.map and b/priv/static/packs/locales/glitch/br.js.map differ diff --git a/priv/static/packs/locales/glitch/ca.js b/priv/static/packs/locales/glitch/ca.js index 3f4a2544e..23b6e5a69 100644 Binary files a/priv/static/packs/locales/glitch/ca.js and b/priv/static/packs/locales/glitch/ca.js differ diff --git a/priv/static/packs/locales/glitch/ca.js.map b/priv/static/packs/locales/glitch/ca.js.map index 9322ae353..94b5ce457 100644 Binary files a/priv/static/packs/locales/glitch/ca.js.map and b/priv/static/packs/locales/glitch/ca.js.map differ diff --git a/priv/static/packs/locales/glitch/co.js b/priv/static/packs/locales/glitch/co.js index a9c0fdd98..986d0b547 100644 Binary files a/priv/static/packs/locales/glitch/co.js and b/priv/static/packs/locales/glitch/co.js differ diff --git a/priv/static/packs/locales/glitch/co.js.map b/priv/static/packs/locales/glitch/co.js.map index b959be024..e263b0566 100644 Binary files a/priv/static/packs/locales/glitch/co.js.map and b/priv/static/packs/locales/glitch/co.js.map differ diff --git a/priv/static/packs/locales/glitch/cs.js b/priv/static/packs/locales/glitch/cs.js index 1163f6e44..41b8c5938 100644 Binary files a/priv/static/packs/locales/glitch/cs.js and b/priv/static/packs/locales/glitch/cs.js differ diff --git a/priv/static/packs/locales/glitch/cs.js.map b/priv/static/packs/locales/glitch/cs.js.map index 9b382546f..04c3961a2 100644 Binary files a/priv/static/packs/locales/glitch/cs.js.map and b/priv/static/packs/locales/glitch/cs.js.map differ diff --git a/priv/static/packs/locales/glitch/cy.js b/priv/static/packs/locales/glitch/cy.js index b563d99c7..353d89efc 100644 Binary files a/priv/static/packs/locales/glitch/cy.js and b/priv/static/packs/locales/glitch/cy.js differ diff --git a/priv/static/packs/locales/glitch/cy.js.map b/priv/static/packs/locales/glitch/cy.js.map index 3ecea7c82..9f6f51fae 100644 Binary files a/priv/static/packs/locales/glitch/cy.js.map and b/priv/static/packs/locales/glitch/cy.js.map differ diff --git a/priv/static/packs/locales/glitch/da.js b/priv/static/packs/locales/glitch/da.js index c92db7ce5..6089d5e67 100644 Binary files a/priv/static/packs/locales/glitch/da.js and b/priv/static/packs/locales/glitch/da.js differ diff --git a/priv/static/packs/locales/glitch/da.js.map b/priv/static/packs/locales/glitch/da.js.map index ac132c436..4123e5095 100644 Binary files a/priv/static/packs/locales/glitch/da.js.map and b/priv/static/packs/locales/glitch/da.js.map differ diff --git a/priv/static/packs/locales/glitch/de.js b/priv/static/packs/locales/glitch/de.js index 7108ca09e..3692ac715 100644 Binary files a/priv/static/packs/locales/glitch/de.js and b/priv/static/packs/locales/glitch/de.js differ diff --git a/priv/static/packs/locales/glitch/de.js.map b/priv/static/packs/locales/glitch/de.js.map index 0f60052e3..0b379b1c0 100644 Binary files a/priv/static/packs/locales/glitch/de.js.map and b/priv/static/packs/locales/glitch/de.js.map differ diff --git a/priv/static/packs/locales/glitch/el.js b/priv/static/packs/locales/glitch/el.js index 65ca11a6e..2aa456032 100644 Binary files a/priv/static/packs/locales/glitch/el.js and b/priv/static/packs/locales/glitch/el.js differ diff --git a/priv/static/packs/locales/glitch/el.js.map b/priv/static/packs/locales/glitch/el.js.map index bb34a1d6e..628818b30 100644 Binary files a/priv/static/packs/locales/glitch/el.js.map and b/priv/static/packs/locales/glitch/el.js.map differ diff --git a/priv/static/packs/locales/glitch/en.js b/priv/static/packs/locales/glitch/en.js index 2c366a501..de94c6aa0 100644 Binary files a/priv/static/packs/locales/glitch/en.js and b/priv/static/packs/locales/glitch/en.js differ diff --git a/priv/static/packs/locales/glitch/en.js.map b/priv/static/packs/locales/glitch/en.js.map index cb9057773..a543edf11 100644 Binary files a/priv/static/packs/locales/glitch/en.js.map and b/priv/static/packs/locales/glitch/en.js.map differ diff --git a/priv/static/packs/locales/glitch/eo.js b/priv/static/packs/locales/glitch/eo.js index efa3859f4..406eb79cc 100644 Binary files a/priv/static/packs/locales/glitch/eo.js and b/priv/static/packs/locales/glitch/eo.js differ diff --git a/priv/static/packs/locales/glitch/eo.js.map b/priv/static/packs/locales/glitch/eo.js.map index 1b659c2f8..7e2ed6dfd 100644 Binary files a/priv/static/packs/locales/glitch/eo.js.map and b/priv/static/packs/locales/glitch/eo.js.map differ diff --git a/priv/static/packs/locales/glitch/es-AR.js b/priv/static/packs/locales/glitch/es-AR.js index f838642ef..b841aaaa6 100644 Binary files a/priv/static/packs/locales/glitch/es-AR.js and b/priv/static/packs/locales/glitch/es-AR.js differ diff --git a/priv/static/packs/locales/glitch/es-AR.js.map b/priv/static/packs/locales/glitch/es-AR.js.map index a0975a92e..414744318 100644 Binary files a/priv/static/packs/locales/glitch/es-AR.js.map and b/priv/static/packs/locales/glitch/es-AR.js.map differ diff --git a/priv/static/packs/locales/glitch/es.js b/priv/static/packs/locales/glitch/es.js index 4cbaa8f21..2395c7620 100644 Binary files a/priv/static/packs/locales/glitch/es.js and b/priv/static/packs/locales/glitch/es.js differ diff --git a/priv/static/packs/locales/glitch/es.js.map b/priv/static/packs/locales/glitch/es.js.map index 55feaa6f0..9f904dbd1 100644 Binary files a/priv/static/packs/locales/glitch/es.js.map and b/priv/static/packs/locales/glitch/es.js.map differ diff --git a/priv/static/packs/locales/glitch/et.js b/priv/static/packs/locales/glitch/et.js index d4ba1fe29..301d249d8 100644 Binary files a/priv/static/packs/locales/glitch/et.js and b/priv/static/packs/locales/glitch/et.js differ diff --git a/priv/static/packs/locales/glitch/et.js.map b/priv/static/packs/locales/glitch/et.js.map index 977da58e6..1ebdeed17 100644 Binary files a/priv/static/packs/locales/glitch/et.js.map and b/priv/static/packs/locales/glitch/et.js.map differ diff --git a/priv/static/packs/locales/glitch/eu.js b/priv/static/packs/locales/glitch/eu.js index 241b6563a..e4a114489 100644 Binary files a/priv/static/packs/locales/glitch/eu.js and b/priv/static/packs/locales/glitch/eu.js differ diff --git a/priv/static/packs/locales/glitch/eu.js.map b/priv/static/packs/locales/glitch/eu.js.map index 4f3ebea43..e19dfafed 100644 Binary files a/priv/static/packs/locales/glitch/eu.js.map and b/priv/static/packs/locales/glitch/eu.js.map differ diff --git a/priv/static/packs/locales/glitch/fa.js b/priv/static/packs/locales/glitch/fa.js index 7d80b332b..b39d7c8be 100644 Binary files a/priv/static/packs/locales/glitch/fa.js and b/priv/static/packs/locales/glitch/fa.js differ diff --git a/priv/static/packs/locales/glitch/fa.js.map b/priv/static/packs/locales/glitch/fa.js.map index a7d10fba0..9dc4cadc5 100644 Binary files a/priv/static/packs/locales/glitch/fa.js.map and b/priv/static/packs/locales/glitch/fa.js.map differ diff --git a/priv/static/packs/locales/glitch/fi.js b/priv/static/packs/locales/glitch/fi.js index f44bef546..891d5f510 100644 Binary files a/priv/static/packs/locales/glitch/fi.js and b/priv/static/packs/locales/glitch/fi.js differ diff --git a/priv/static/packs/locales/glitch/fi.js.map b/priv/static/packs/locales/glitch/fi.js.map index a1fd351f5..5257a095d 100644 Binary files a/priv/static/packs/locales/glitch/fi.js.map and b/priv/static/packs/locales/glitch/fi.js.map differ diff --git a/priv/static/packs/locales/glitch/fr.js b/priv/static/packs/locales/glitch/fr.js index 7b000a883..a347e27ca 100644 Binary files a/priv/static/packs/locales/glitch/fr.js and b/priv/static/packs/locales/glitch/fr.js differ diff --git a/priv/static/packs/locales/glitch/fr.js.map b/priv/static/packs/locales/glitch/fr.js.map index bc2b07d02..51f5f7911 100644 Binary files a/priv/static/packs/locales/glitch/fr.js.map and b/priv/static/packs/locales/glitch/fr.js.map differ diff --git a/priv/static/packs/locales/glitch/ga.js b/priv/static/packs/locales/glitch/ga.js index 927e24296..699637416 100644 Binary files a/priv/static/packs/locales/glitch/ga.js and b/priv/static/packs/locales/glitch/ga.js differ diff --git a/priv/static/packs/locales/glitch/ga.js.map b/priv/static/packs/locales/glitch/ga.js.map index 973a6b1d8..cd7be7d00 100644 Binary files a/priv/static/packs/locales/glitch/ga.js.map and b/priv/static/packs/locales/glitch/ga.js.map differ diff --git a/priv/static/packs/locales/glitch/gl.js b/priv/static/packs/locales/glitch/gl.js index 964b54a5b..f4194822b 100644 Binary files a/priv/static/packs/locales/glitch/gl.js and b/priv/static/packs/locales/glitch/gl.js differ diff --git a/priv/static/packs/locales/glitch/gl.js.map b/priv/static/packs/locales/glitch/gl.js.map index e87ec9a7b..3e6f840bd 100644 Binary files a/priv/static/packs/locales/glitch/gl.js.map and b/priv/static/packs/locales/glitch/gl.js.map differ diff --git a/priv/static/packs/locales/glitch/he.js b/priv/static/packs/locales/glitch/he.js index e433e68dc..76bf1117e 100644 Binary files a/priv/static/packs/locales/glitch/he.js and b/priv/static/packs/locales/glitch/he.js differ diff --git a/priv/static/packs/locales/glitch/he.js.map b/priv/static/packs/locales/glitch/he.js.map index c0c5bc33d..112537ef6 100644 Binary files a/priv/static/packs/locales/glitch/he.js.map and b/priv/static/packs/locales/glitch/he.js.map differ diff --git a/priv/static/packs/locales/glitch/hi.js b/priv/static/packs/locales/glitch/hi.js index db38a62b8..31abe01f0 100644 Binary files a/priv/static/packs/locales/glitch/hi.js and b/priv/static/packs/locales/glitch/hi.js differ diff --git a/priv/static/packs/locales/glitch/hi.js.map b/priv/static/packs/locales/glitch/hi.js.map index 4a944f819..b5417c255 100644 Binary files a/priv/static/packs/locales/glitch/hi.js.map and b/priv/static/packs/locales/glitch/hi.js.map differ diff --git a/priv/static/packs/locales/glitch/hr.js b/priv/static/packs/locales/glitch/hr.js index 1230ef6bd..fe52cd08c 100644 Binary files a/priv/static/packs/locales/glitch/hr.js and b/priv/static/packs/locales/glitch/hr.js differ diff --git a/priv/static/packs/locales/glitch/hr.js.map b/priv/static/packs/locales/glitch/hr.js.map index 8279a2ee3..fb5486977 100644 Binary files a/priv/static/packs/locales/glitch/hr.js.map and b/priv/static/packs/locales/glitch/hr.js.map differ diff --git a/priv/static/packs/locales/glitch/hu.js b/priv/static/packs/locales/glitch/hu.js index cb3f8fdc2..682ca2159 100644 Binary files a/priv/static/packs/locales/glitch/hu.js and b/priv/static/packs/locales/glitch/hu.js differ diff --git a/priv/static/packs/locales/glitch/hu.js.map b/priv/static/packs/locales/glitch/hu.js.map index 6cf72dd63..127ec071b 100644 Binary files a/priv/static/packs/locales/glitch/hu.js.map and b/priv/static/packs/locales/glitch/hu.js.map differ diff --git a/priv/static/packs/locales/glitch/hy.js b/priv/static/packs/locales/glitch/hy.js index cc7b75474..f2011b0e3 100644 Binary files a/priv/static/packs/locales/glitch/hy.js and b/priv/static/packs/locales/glitch/hy.js differ diff --git a/priv/static/packs/locales/glitch/hy.js.map b/priv/static/packs/locales/glitch/hy.js.map index 312575745..db9fdc1f9 100644 Binary files a/priv/static/packs/locales/glitch/hy.js.map and b/priv/static/packs/locales/glitch/hy.js.map differ diff --git a/priv/static/packs/locales/glitch/id.js b/priv/static/packs/locales/glitch/id.js index b6404585b..6ca78b22c 100644 Binary files a/priv/static/packs/locales/glitch/id.js and b/priv/static/packs/locales/glitch/id.js differ diff --git a/priv/static/packs/locales/glitch/id.js.map b/priv/static/packs/locales/glitch/id.js.map index b4dcf75b2..ab59c6e02 100644 Binary files a/priv/static/packs/locales/glitch/id.js.map and b/priv/static/packs/locales/glitch/id.js.map differ diff --git a/priv/static/packs/locales/glitch/io.js b/priv/static/packs/locales/glitch/io.js index 97a6738a0..074da73b8 100644 Binary files a/priv/static/packs/locales/glitch/io.js and b/priv/static/packs/locales/glitch/io.js differ diff --git a/priv/static/packs/locales/glitch/io.js.map b/priv/static/packs/locales/glitch/io.js.map index 5dab3979d..89999c4c1 100644 Binary files a/priv/static/packs/locales/glitch/io.js.map and b/priv/static/packs/locales/glitch/io.js.map differ diff --git a/priv/static/packs/locales/glitch/is.js b/priv/static/packs/locales/glitch/is.js index bc9b34f29..ec9436d27 100644 Binary files a/priv/static/packs/locales/glitch/is.js and b/priv/static/packs/locales/glitch/is.js differ diff --git a/priv/static/packs/locales/glitch/is.js.map b/priv/static/packs/locales/glitch/is.js.map index 30afc9926..054176ba2 100644 Binary files a/priv/static/packs/locales/glitch/is.js.map and b/priv/static/packs/locales/glitch/is.js.map differ diff --git a/priv/static/packs/locales/glitch/it.js b/priv/static/packs/locales/glitch/it.js index 9fbb63879..d43b84c3b 100644 Binary files a/priv/static/packs/locales/glitch/it.js and b/priv/static/packs/locales/glitch/it.js differ diff --git a/priv/static/packs/locales/glitch/it.js.map b/priv/static/packs/locales/glitch/it.js.map index 1b8965bcc..54575e8f4 100644 Binary files a/priv/static/packs/locales/glitch/it.js.map and b/priv/static/packs/locales/glitch/it.js.map differ diff --git a/priv/static/packs/locales/glitch/ja.js b/priv/static/packs/locales/glitch/ja.js index bb38c4897..a3b5e4e67 100644 Binary files a/priv/static/packs/locales/glitch/ja.js and b/priv/static/packs/locales/glitch/ja.js differ diff --git a/priv/static/packs/locales/glitch/ja.js.map b/priv/static/packs/locales/glitch/ja.js.map index 6cb25f836..85cff94e4 100644 Binary files a/priv/static/packs/locales/glitch/ja.js.map and b/priv/static/packs/locales/glitch/ja.js.map differ diff --git a/priv/static/packs/locales/glitch/ka.js b/priv/static/packs/locales/glitch/ka.js index e4b32a068..c68203d60 100644 Binary files a/priv/static/packs/locales/glitch/ka.js and b/priv/static/packs/locales/glitch/ka.js differ diff --git a/priv/static/packs/locales/glitch/ka.js.map b/priv/static/packs/locales/glitch/ka.js.map index 0e49e8a7a..51ec3c320 100644 Binary files a/priv/static/packs/locales/glitch/ka.js.map and b/priv/static/packs/locales/glitch/ka.js.map differ diff --git a/priv/static/packs/locales/glitch/kab.js b/priv/static/packs/locales/glitch/kab.js index 6cbce4c04..38367c712 100644 Binary files a/priv/static/packs/locales/glitch/kab.js and b/priv/static/packs/locales/glitch/kab.js differ diff --git a/priv/static/packs/locales/glitch/kab.js.map b/priv/static/packs/locales/glitch/kab.js.map index 28eed3207..0fb9d18d9 100644 Binary files a/priv/static/packs/locales/glitch/kab.js.map and b/priv/static/packs/locales/glitch/kab.js.map differ diff --git a/priv/static/packs/locales/glitch/kk.js b/priv/static/packs/locales/glitch/kk.js index 0faca2334..328c5caef 100644 Binary files a/priv/static/packs/locales/glitch/kk.js and b/priv/static/packs/locales/glitch/kk.js differ diff --git a/priv/static/packs/locales/glitch/kk.js.map b/priv/static/packs/locales/glitch/kk.js.map index e802ba374..71a3e55fd 100644 Binary files a/priv/static/packs/locales/glitch/kk.js.map and b/priv/static/packs/locales/glitch/kk.js.map differ diff --git a/priv/static/packs/locales/glitch/kn.js b/priv/static/packs/locales/glitch/kn.js index aeed46c13..d6920594d 100644 Binary files a/priv/static/packs/locales/glitch/kn.js and b/priv/static/packs/locales/glitch/kn.js differ diff --git a/priv/static/packs/locales/glitch/kn.js.map b/priv/static/packs/locales/glitch/kn.js.map index 77cb0271c..1d2cfb6e2 100644 Binary files a/priv/static/packs/locales/glitch/kn.js.map and b/priv/static/packs/locales/glitch/kn.js.map differ diff --git a/priv/static/packs/locales/glitch/ko.js b/priv/static/packs/locales/glitch/ko.js index 4600783d6..8ee071c52 100644 Binary files a/priv/static/packs/locales/glitch/ko.js and b/priv/static/packs/locales/glitch/ko.js differ diff --git a/priv/static/packs/locales/glitch/ko.js.map b/priv/static/packs/locales/glitch/ko.js.map index 4ced3f971..b3c6ad083 100644 Binary files a/priv/static/packs/locales/glitch/ko.js.map and b/priv/static/packs/locales/glitch/ko.js.map differ diff --git a/priv/static/packs/locales/glitch/lt.js b/priv/static/packs/locales/glitch/lt.js index 9c3f5bd03..f8374f9d2 100644 Binary files a/priv/static/packs/locales/glitch/lt.js and b/priv/static/packs/locales/glitch/lt.js differ diff --git a/priv/static/packs/locales/glitch/lt.js.map b/priv/static/packs/locales/glitch/lt.js.map index 7c0fc3d5b..93f2b59df 100644 Binary files a/priv/static/packs/locales/glitch/lt.js.map and b/priv/static/packs/locales/glitch/lt.js.map differ diff --git a/priv/static/packs/locales/glitch/lv.js b/priv/static/packs/locales/glitch/lv.js index 96162a9eb..a625417cd 100644 Binary files a/priv/static/packs/locales/glitch/lv.js and b/priv/static/packs/locales/glitch/lv.js differ diff --git a/priv/static/packs/locales/glitch/lv.js.map b/priv/static/packs/locales/glitch/lv.js.map index 0601ecdb6..034556730 100644 Binary files a/priv/static/packs/locales/glitch/lv.js.map and b/priv/static/packs/locales/glitch/lv.js.map differ diff --git a/priv/static/packs/locales/glitch/mk.js b/priv/static/packs/locales/glitch/mk.js index 3ab2cdcb2..d1aa2c57d 100644 Binary files a/priv/static/packs/locales/glitch/mk.js and b/priv/static/packs/locales/glitch/mk.js differ diff --git a/priv/static/packs/locales/glitch/mk.js.map b/priv/static/packs/locales/glitch/mk.js.map index 915ef6d06..650c0520c 100644 Binary files a/priv/static/packs/locales/glitch/mk.js.map and b/priv/static/packs/locales/glitch/mk.js.map differ diff --git a/priv/static/packs/locales/glitch/ml.js b/priv/static/packs/locales/glitch/ml.js index 28123c440..817c72647 100644 Binary files a/priv/static/packs/locales/glitch/ml.js and b/priv/static/packs/locales/glitch/ml.js differ diff --git a/priv/static/packs/locales/glitch/ml.js.map b/priv/static/packs/locales/glitch/ml.js.map index 3711ae7df..319cca685 100644 Binary files a/priv/static/packs/locales/glitch/ml.js.map and b/priv/static/packs/locales/glitch/ml.js.map differ diff --git a/priv/static/packs/locales/glitch/mr.js b/priv/static/packs/locales/glitch/mr.js index 7f30dc141..73ede96fe 100644 Binary files a/priv/static/packs/locales/glitch/mr.js and b/priv/static/packs/locales/glitch/mr.js differ diff --git a/priv/static/packs/locales/glitch/mr.js.map b/priv/static/packs/locales/glitch/mr.js.map index 87356e45b..f6ee01063 100644 Binary files a/priv/static/packs/locales/glitch/mr.js.map and b/priv/static/packs/locales/glitch/mr.js.map differ diff --git a/priv/static/packs/locales/glitch/ms.js b/priv/static/packs/locales/glitch/ms.js index 6f4a4f059..549ef5cdc 100644 Binary files a/priv/static/packs/locales/glitch/ms.js and b/priv/static/packs/locales/glitch/ms.js differ diff --git a/priv/static/packs/locales/glitch/ms.js.map b/priv/static/packs/locales/glitch/ms.js.map index 7ab26e470..c3814701a 100644 Binary files a/priv/static/packs/locales/glitch/ms.js.map and b/priv/static/packs/locales/glitch/ms.js.map differ diff --git a/priv/static/packs/locales/glitch/nl.js b/priv/static/packs/locales/glitch/nl.js index 59c26947e..e38907733 100644 Binary files a/priv/static/packs/locales/glitch/nl.js and b/priv/static/packs/locales/glitch/nl.js differ diff --git a/priv/static/packs/locales/glitch/nl.js.map b/priv/static/packs/locales/glitch/nl.js.map index 501dd33b9..f975976d6 100644 Binary files a/priv/static/packs/locales/glitch/nl.js.map and b/priv/static/packs/locales/glitch/nl.js.map differ diff --git a/priv/static/packs/locales/glitch/nn.js b/priv/static/packs/locales/glitch/nn.js index b58e0e0e9..3aefbec9c 100644 Binary files a/priv/static/packs/locales/glitch/nn.js and b/priv/static/packs/locales/glitch/nn.js differ diff --git a/priv/static/packs/locales/glitch/nn.js.map b/priv/static/packs/locales/glitch/nn.js.map index ef2bd2759..fc106bc1a 100644 Binary files a/priv/static/packs/locales/glitch/nn.js.map and b/priv/static/packs/locales/glitch/nn.js.map differ diff --git a/priv/static/packs/locales/glitch/no.js b/priv/static/packs/locales/glitch/no.js index 6f372666b..d386209be 100644 Binary files a/priv/static/packs/locales/glitch/no.js and b/priv/static/packs/locales/glitch/no.js differ diff --git a/priv/static/packs/locales/glitch/no.js.map b/priv/static/packs/locales/glitch/no.js.map index 375fb7e7f..f1b9b20c9 100644 Binary files a/priv/static/packs/locales/glitch/no.js.map and b/priv/static/packs/locales/glitch/no.js.map differ diff --git a/priv/static/packs/locales/glitch/oc.js b/priv/static/packs/locales/glitch/oc.js index 56bed8938..820d2dc81 100644 Binary files a/priv/static/packs/locales/glitch/oc.js and b/priv/static/packs/locales/glitch/oc.js differ diff --git a/priv/static/packs/locales/glitch/oc.js.map b/priv/static/packs/locales/glitch/oc.js.map index 24974675c..29b6cbdb1 100644 Binary files a/priv/static/packs/locales/glitch/oc.js.map and b/priv/static/packs/locales/glitch/oc.js.map differ diff --git a/priv/static/packs/locales/glitch/pl.js b/priv/static/packs/locales/glitch/pl.js index d1684d3a2..7bdeb1088 100644 Binary files a/priv/static/packs/locales/glitch/pl.js and b/priv/static/packs/locales/glitch/pl.js differ diff --git a/priv/static/packs/locales/glitch/pl.js.map b/priv/static/packs/locales/glitch/pl.js.map index 7bfd358ee..470de5ebe 100644 Binary files a/priv/static/packs/locales/glitch/pl.js.map and b/priv/static/packs/locales/glitch/pl.js.map differ diff --git a/priv/static/packs/locales/glitch/pt-BR.js b/priv/static/packs/locales/glitch/pt-BR.js index dbd2e04c9..08950cd4d 100644 Binary files a/priv/static/packs/locales/glitch/pt-BR.js and b/priv/static/packs/locales/glitch/pt-BR.js differ diff --git a/priv/static/packs/locales/glitch/pt-BR.js.map b/priv/static/packs/locales/glitch/pt-BR.js.map index b27f848cc..584e0e410 100644 Binary files a/priv/static/packs/locales/glitch/pt-BR.js.map and b/priv/static/packs/locales/glitch/pt-BR.js.map differ diff --git a/priv/static/packs/locales/glitch/pt-PT.js b/priv/static/packs/locales/glitch/pt-PT.js index 09f100392..83d72b3fc 100644 Binary files a/priv/static/packs/locales/glitch/pt-PT.js and b/priv/static/packs/locales/glitch/pt-PT.js differ diff --git a/priv/static/packs/locales/glitch/pt-PT.js.map b/priv/static/packs/locales/glitch/pt-PT.js.map index f3f1d1200..51a2aa03f 100644 Binary files a/priv/static/packs/locales/glitch/pt-PT.js.map and b/priv/static/packs/locales/glitch/pt-PT.js.map differ diff --git a/priv/static/packs/locales/glitch/ro.js b/priv/static/packs/locales/glitch/ro.js index 2bec5b1d1..667af3651 100644 Binary files a/priv/static/packs/locales/glitch/ro.js and b/priv/static/packs/locales/glitch/ro.js differ diff --git a/priv/static/packs/locales/glitch/ro.js.map b/priv/static/packs/locales/glitch/ro.js.map index fccc7736d..3fd116429 100644 Binary files a/priv/static/packs/locales/glitch/ro.js.map and b/priv/static/packs/locales/glitch/ro.js.map differ diff --git a/priv/static/packs/locales/glitch/ru.js b/priv/static/packs/locales/glitch/ru.js index 6caa8d668..44c66bd7d 100644 Binary files a/priv/static/packs/locales/glitch/ru.js and b/priv/static/packs/locales/glitch/ru.js differ diff --git a/priv/static/packs/locales/glitch/ru.js.map b/priv/static/packs/locales/glitch/ru.js.map index 88dba64a9..f4bcd78fb 100644 Binary files a/priv/static/packs/locales/glitch/ru.js.map and b/priv/static/packs/locales/glitch/ru.js.map differ diff --git a/priv/static/packs/locales/glitch/sk.js b/priv/static/packs/locales/glitch/sk.js index 65e1e5e8a..9476a2f58 100644 Binary files a/priv/static/packs/locales/glitch/sk.js and b/priv/static/packs/locales/glitch/sk.js differ diff --git a/priv/static/packs/locales/glitch/sk.js.map b/priv/static/packs/locales/glitch/sk.js.map index a6f88af3c..747cb346d 100644 Binary files a/priv/static/packs/locales/glitch/sk.js.map and b/priv/static/packs/locales/glitch/sk.js.map differ diff --git a/priv/static/packs/locales/glitch/sl.js b/priv/static/packs/locales/glitch/sl.js index 6e1e09a12..4d99f2414 100644 Binary files a/priv/static/packs/locales/glitch/sl.js and b/priv/static/packs/locales/glitch/sl.js differ diff --git a/priv/static/packs/locales/glitch/sl.js.map b/priv/static/packs/locales/glitch/sl.js.map index ab1bc4134..805563fa1 100644 Binary files a/priv/static/packs/locales/glitch/sl.js.map and b/priv/static/packs/locales/glitch/sl.js.map differ diff --git a/priv/static/packs/locales/glitch/sq.js b/priv/static/packs/locales/glitch/sq.js index 3c01b8e28..3b0bcce3b 100644 Binary files a/priv/static/packs/locales/glitch/sq.js and b/priv/static/packs/locales/glitch/sq.js differ diff --git a/priv/static/packs/locales/glitch/sq.js.map b/priv/static/packs/locales/glitch/sq.js.map index d2f5392e3..17be693bf 100644 Binary files a/priv/static/packs/locales/glitch/sq.js.map and b/priv/static/packs/locales/glitch/sq.js.map differ diff --git a/priv/static/packs/locales/glitch/sr-Latn.js b/priv/static/packs/locales/glitch/sr-Latn.js index fb2be7e67..7613638cb 100644 Binary files a/priv/static/packs/locales/glitch/sr-Latn.js and b/priv/static/packs/locales/glitch/sr-Latn.js differ diff --git a/priv/static/packs/locales/glitch/sr-Latn.js.map b/priv/static/packs/locales/glitch/sr-Latn.js.map index 3557bb908..89ab44978 100644 Binary files a/priv/static/packs/locales/glitch/sr-Latn.js.map and b/priv/static/packs/locales/glitch/sr-Latn.js.map differ diff --git a/priv/static/packs/locales/glitch/sr.js b/priv/static/packs/locales/glitch/sr.js index 7d9c50942..e772dda4f 100644 Binary files a/priv/static/packs/locales/glitch/sr.js and b/priv/static/packs/locales/glitch/sr.js differ diff --git a/priv/static/packs/locales/glitch/sr.js.map b/priv/static/packs/locales/glitch/sr.js.map index 44f0dbb70..cf7001869 100644 Binary files a/priv/static/packs/locales/glitch/sr.js.map and b/priv/static/packs/locales/glitch/sr.js.map differ diff --git a/priv/static/packs/locales/glitch/sv.js b/priv/static/packs/locales/glitch/sv.js index feda6682c..549120a2a 100644 Binary files a/priv/static/packs/locales/glitch/sv.js and b/priv/static/packs/locales/glitch/sv.js differ diff --git a/priv/static/packs/locales/glitch/sv.js.map b/priv/static/packs/locales/glitch/sv.js.map index 367a2e89c..6db7b18b9 100644 Binary files a/priv/static/packs/locales/glitch/sv.js.map and b/priv/static/packs/locales/glitch/sv.js.map differ diff --git a/priv/static/packs/locales/glitch/ta.js b/priv/static/packs/locales/glitch/ta.js index 81fe1ff5e..ec8b2b111 100644 Binary files a/priv/static/packs/locales/glitch/ta.js and b/priv/static/packs/locales/glitch/ta.js differ diff --git a/priv/static/packs/locales/glitch/ta.js.map b/priv/static/packs/locales/glitch/ta.js.map index a67fb4e1f..b7e271261 100644 Binary files a/priv/static/packs/locales/glitch/ta.js.map and b/priv/static/packs/locales/glitch/ta.js.map differ diff --git a/priv/static/packs/locales/glitch/te.js b/priv/static/packs/locales/glitch/te.js index 9ff95c237..d7e85ee36 100644 Binary files a/priv/static/packs/locales/glitch/te.js and b/priv/static/packs/locales/glitch/te.js differ diff --git a/priv/static/packs/locales/glitch/te.js.map b/priv/static/packs/locales/glitch/te.js.map index a5f81b52a..a58e520cb 100644 Binary files a/priv/static/packs/locales/glitch/te.js.map and b/priv/static/packs/locales/glitch/te.js.map differ diff --git a/priv/static/packs/locales/glitch/th.js b/priv/static/packs/locales/glitch/th.js index d86f43e10..4cbdcbcf5 100644 Binary files a/priv/static/packs/locales/glitch/th.js and b/priv/static/packs/locales/glitch/th.js differ diff --git a/priv/static/packs/locales/glitch/th.js.map b/priv/static/packs/locales/glitch/th.js.map index 4e17eb892..65e5c656b 100644 Binary files a/priv/static/packs/locales/glitch/th.js.map and b/priv/static/packs/locales/glitch/th.js.map differ diff --git a/priv/static/packs/locales/glitch/tr.js b/priv/static/packs/locales/glitch/tr.js index 091ec391b..5c6386138 100644 Binary files a/priv/static/packs/locales/glitch/tr.js and b/priv/static/packs/locales/glitch/tr.js differ diff --git a/priv/static/packs/locales/glitch/tr.js.map b/priv/static/packs/locales/glitch/tr.js.map index b8ab1f599..0ed931331 100644 Binary files a/priv/static/packs/locales/glitch/tr.js.map and b/priv/static/packs/locales/glitch/tr.js.map differ diff --git a/priv/static/packs/locales/glitch/uk.js b/priv/static/packs/locales/glitch/uk.js index fc00bdc76..04388c934 100644 Binary files a/priv/static/packs/locales/glitch/uk.js and b/priv/static/packs/locales/glitch/uk.js differ diff --git a/priv/static/packs/locales/glitch/uk.js.map b/priv/static/packs/locales/glitch/uk.js.map index 7ec9350cf..803672f6f 100644 Binary files a/priv/static/packs/locales/glitch/uk.js.map and b/priv/static/packs/locales/glitch/uk.js.map differ diff --git a/priv/static/packs/locales/glitch/ur.js b/priv/static/packs/locales/glitch/ur.js index c365028a1..cb9988dd4 100644 Binary files a/priv/static/packs/locales/glitch/ur.js and b/priv/static/packs/locales/glitch/ur.js differ diff --git a/priv/static/packs/locales/glitch/ur.js.map b/priv/static/packs/locales/glitch/ur.js.map index 8df87f4e4..204bc365f 100644 Binary files a/priv/static/packs/locales/glitch/ur.js.map and b/priv/static/packs/locales/glitch/ur.js.map differ diff --git a/priv/static/packs/locales/glitch/vi.js b/priv/static/packs/locales/glitch/vi.js index c881b97ff..b45ffbc87 100644 Binary files a/priv/static/packs/locales/glitch/vi.js and b/priv/static/packs/locales/glitch/vi.js differ diff --git a/priv/static/packs/locales/glitch/vi.js.map b/priv/static/packs/locales/glitch/vi.js.map index 6b0faec01..11fc5629a 100644 Binary files a/priv/static/packs/locales/glitch/vi.js.map and b/priv/static/packs/locales/glitch/vi.js.map differ diff --git a/priv/static/packs/locales/glitch/zh-CN.js b/priv/static/packs/locales/glitch/zh-CN.js index c6ed12a3a..e3fbce7e1 100644 Binary files a/priv/static/packs/locales/glitch/zh-CN.js and b/priv/static/packs/locales/glitch/zh-CN.js differ diff --git a/priv/static/packs/locales/glitch/zh-CN.js.map b/priv/static/packs/locales/glitch/zh-CN.js.map index 024666d64..96a461367 100644 Binary files a/priv/static/packs/locales/glitch/zh-CN.js.map and b/priv/static/packs/locales/glitch/zh-CN.js.map differ diff --git a/priv/static/packs/locales/glitch/zh-HK.js b/priv/static/packs/locales/glitch/zh-HK.js index 79aafff13..0690558e6 100644 Binary files a/priv/static/packs/locales/glitch/zh-HK.js and b/priv/static/packs/locales/glitch/zh-HK.js differ diff --git a/priv/static/packs/locales/glitch/zh-HK.js.map b/priv/static/packs/locales/glitch/zh-HK.js.map index 18dd8ffad..1b64d826f 100644 Binary files a/priv/static/packs/locales/glitch/zh-HK.js.map and b/priv/static/packs/locales/glitch/zh-HK.js.map differ diff --git a/priv/static/packs/locales/glitch/zh-TW.js b/priv/static/packs/locales/glitch/zh-TW.js index 5ec359e10..4a9ec7186 100644 Binary files a/priv/static/packs/locales/glitch/zh-TW.js and b/priv/static/packs/locales/glitch/zh-TW.js differ diff --git a/priv/static/packs/locales/glitch/zh-TW.js.map b/priv/static/packs/locales/glitch/zh-TW.js.map index bb0634947..ef62a54ff 100644 Binary files a/priv/static/packs/locales/glitch/zh-TW.js.map and b/priv/static/packs/locales/glitch/zh-TW.js.map differ diff --git a/priv/static/packs/locales/vanilla/ar.js b/priv/static/packs/locales/vanilla/ar.js index 1dd13b40f..6b4181bd5 100644 Binary files a/priv/static/packs/locales/vanilla/ar.js and b/priv/static/packs/locales/vanilla/ar.js differ diff --git a/priv/static/packs/locales/vanilla/ar.js.map b/priv/static/packs/locales/vanilla/ar.js.map index 7e305f0cf..1802fd3f8 100644 Binary files a/priv/static/packs/locales/vanilla/ar.js.map and b/priv/static/packs/locales/vanilla/ar.js.map differ diff --git a/priv/static/packs/locales/vanilla/ast.js b/priv/static/packs/locales/vanilla/ast.js index 0b505c496..376afbe70 100644 Binary files a/priv/static/packs/locales/vanilla/ast.js and b/priv/static/packs/locales/vanilla/ast.js differ diff --git a/priv/static/packs/locales/vanilla/ast.js.map b/priv/static/packs/locales/vanilla/ast.js.map index dae5f5c19..ac9e083d8 100644 Binary files a/priv/static/packs/locales/vanilla/ast.js.map and b/priv/static/packs/locales/vanilla/ast.js.map differ diff --git a/priv/static/packs/locales/vanilla/bg.js b/priv/static/packs/locales/vanilla/bg.js index a2389e8f1..272033cc2 100644 Binary files a/priv/static/packs/locales/vanilla/bg.js and b/priv/static/packs/locales/vanilla/bg.js differ diff --git a/priv/static/packs/locales/vanilla/bg.js.map b/priv/static/packs/locales/vanilla/bg.js.map index 0df1dbb7e..ec253ea61 100644 Binary files a/priv/static/packs/locales/vanilla/bg.js.map and b/priv/static/packs/locales/vanilla/bg.js.map differ diff --git a/priv/static/packs/locales/vanilla/bn.js b/priv/static/packs/locales/vanilla/bn.js index 24f8b350f..ebdf68537 100644 Binary files a/priv/static/packs/locales/vanilla/bn.js and b/priv/static/packs/locales/vanilla/bn.js differ diff --git a/priv/static/packs/locales/vanilla/bn.js.map b/priv/static/packs/locales/vanilla/bn.js.map index deb68b0a5..45a9c014e 100644 Binary files a/priv/static/packs/locales/vanilla/bn.js.map and b/priv/static/packs/locales/vanilla/bn.js.map differ diff --git a/priv/static/packs/locales/vanilla/br.js b/priv/static/packs/locales/vanilla/br.js index 741262bf4..f6ab20245 100644 Binary files a/priv/static/packs/locales/vanilla/br.js and b/priv/static/packs/locales/vanilla/br.js differ diff --git a/priv/static/packs/locales/vanilla/br.js.map b/priv/static/packs/locales/vanilla/br.js.map index 994a36723..6d53acc51 100644 Binary files a/priv/static/packs/locales/vanilla/br.js.map and b/priv/static/packs/locales/vanilla/br.js.map differ diff --git a/priv/static/packs/locales/vanilla/ca.js b/priv/static/packs/locales/vanilla/ca.js index 2b5adff9d..e8453b5b9 100644 Binary files a/priv/static/packs/locales/vanilla/ca.js and b/priv/static/packs/locales/vanilla/ca.js differ diff --git a/priv/static/packs/locales/vanilla/ca.js.map b/priv/static/packs/locales/vanilla/ca.js.map index 35c2ed352..b0f1a6789 100644 Binary files a/priv/static/packs/locales/vanilla/ca.js.map and b/priv/static/packs/locales/vanilla/ca.js.map differ diff --git a/priv/static/packs/locales/vanilla/co.js b/priv/static/packs/locales/vanilla/co.js index c2bf0f920..9bbc5f461 100644 Binary files a/priv/static/packs/locales/vanilla/co.js and b/priv/static/packs/locales/vanilla/co.js differ diff --git a/priv/static/packs/locales/vanilla/co.js.map b/priv/static/packs/locales/vanilla/co.js.map index ec94caa16..303dbdf41 100644 Binary files a/priv/static/packs/locales/vanilla/co.js.map and b/priv/static/packs/locales/vanilla/co.js.map differ diff --git a/priv/static/packs/locales/vanilla/cs.js b/priv/static/packs/locales/vanilla/cs.js index 1272736fd..d5ea53c06 100644 Binary files a/priv/static/packs/locales/vanilla/cs.js and b/priv/static/packs/locales/vanilla/cs.js differ diff --git a/priv/static/packs/locales/vanilla/cs.js.map b/priv/static/packs/locales/vanilla/cs.js.map index 2fcac0fd2..253a990b7 100644 Binary files a/priv/static/packs/locales/vanilla/cs.js.map and b/priv/static/packs/locales/vanilla/cs.js.map differ diff --git a/priv/static/packs/locales/vanilla/cy.js b/priv/static/packs/locales/vanilla/cy.js index d6edb602c..9c41b6da4 100644 Binary files a/priv/static/packs/locales/vanilla/cy.js and b/priv/static/packs/locales/vanilla/cy.js differ diff --git a/priv/static/packs/locales/vanilla/cy.js.map b/priv/static/packs/locales/vanilla/cy.js.map index 83b72c10b..33ea782c7 100644 Binary files a/priv/static/packs/locales/vanilla/cy.js.map and b/priv/static/packs/locales/vanilla/cy.js.map differ diff --git a/priv/static/packs/locales/vanilla/da.js b/priv/static/packs/locales/vanilla/da.js index 139341108..6f12d46dc 100644 Binary files a/priv/static/packs/locales/vanilla/da.js and b/priv/static/packs/locales/vanilla/da.js differ diff --git a/priv/static/packs/locales/vanilla/da.js.map b/priv/static/packs/locales/vanilla/da.js.map index 0f8da806a..c3d938e88 100644 Binary files a/priv/static/packs/locales/vanilla/da.js.map and b/priv/static/packs/locales/vanilla/da.js.map differ diff --git a/priv/static/packs/locales/vanilla/de.js b/priv/static/packs/locales/vanilla/de.js index fe0e254e4..268accd2f 100644 Binary files a/priv/static/packs/locales/vanilla/de.js and b/priv/static/packs/locales/vanilla/de.js differ diff --git a/priv/static/packs/locales/vanilla/de.js.map b/priv/static/packs/locales/vanilla/de.js.map index a33f920c0..3b1203043 100644 Binary files a/priv/static/packs/locales/vanilla/de.js.map and b/priv/static/packs/locales/vanilla/de.js.map differ diff --git a/priv/static/packs/locales/vanilla/el.js b/priv/static/packs/locales/vanilla/el.js index 57efdd693..2dd5a9895 100644 Binary files a/priv/static/packs/locales/vanilla/el.js and b/priv/static/packs/locales/vanilla/el.js differ diff --git a/priv/static/packs/locales/vanilla/el.js.map b/priv/static/packs/locales/vanilla/el.js.map index a0b0f7f07..612980507 100644 Binary files a/priv/static/packs/locales/vanilla/el.js.map and b/priv/static/packs/locales/vanilla/el.js.map differ diff --git a/priv/static/packs/locales/vanilla/en.js b/priv/static/packs/locales/vanilla/en.js index ff8617a8f..226dde828 100644 Binary files a/priv/static/packs/locales/vanilla/en.js and b/priv/static/packs/locales/vanilla/en.js differ diff --git a/priv/static/packs/locales/vanilla/en.js.map b/priv/static/packs/locales/vanilla/en.js.map index 6c29e9803..2c5d92626 100644 Binary files a/priv/static/packs/locales/vanilla/en.js.map and b/priv/static/packs/locales/vanilla/en.js.map differ diff --git a/priv/static/packs/locales/vanilla/eo.js b/priv/static/packs/locales/vanilla/eo.js index c92427011..74d3bf896 100644 Binary files a/priv/static/packs/locales/vanilla/eo.js and b/priv/static/packs/locales/vanilla/eo.js differ diff --git a/priv/static/packs/locales/vanilla/eo.js.map b/priv/static/packs/locales/vanilla/eo.js.map index d271c3a2a..4aa69e848 100644 Binary files a/priv/static/packs/locales/vanilla/eo.js.map and b/priv/static/packs/locales/vanilla/eo.js.map differ diff --git a/priv/static/packs/locales/vanilla/es-AR.js b/priv/static/packs/locales/vanilla/es-AR.js index 6552d0ce3..1cd0935b2 100644 Binary files a/priv/static/packs/locales/vanilla/es-AR.js and b/priv/static/packs/locales/vanilla/es-AR.js differ diff --git a/priv/static/packs/locales/vanilla/es-AR.js.map b/priv/static/packs/locales/vanilla/es-AR.js.map index 297ef325e..9fdd3bde5 100644 Binary files a/priv/static/packs/locales/vanilla/es-AR.js.map and b/priv/static/packs/locales/vanilla/es-AR.js.map differ diff --git a/priv/static/packs/locales/vanilla/es.js b/priv/static/packs/locales/vanilla/es.js index ed37b0006..b2079bd66 100644 Binary files a/priv/static/packs/locales/vanilla/es.js and b/priv/static/packs/locales/vanilla/es.js differ diff --git a/priv/static/packs/locales/vanilla/es.js.map b/priv/static/packs/locales/vanilla/es.js.map index 38d258c9a..7f6210e0b 100644 Binary files a/priv/static/packs/locales/vanilla/es.js.map and b/priv/static/packs/locales/vanilla/es.js.map differ diff --git a/priv/static/packs/locales/vanilla/et.js b/priv/static/packs/locales/vanilla/et.js index b3df9d742..34a94d107 100644 Binary files a/priv/static/packs/locales/vanilla/et.js and b/priv/static/packs/locales/vanilla/et.js differ diff --git a/priv/static/packs/locales/vanilla/et.js.map b/priv/static/packs/locales/vanilla/et.js.map index a61d60808..394bb7428 100644 Binary files a/priv/static/packs/locales/vanilla/et.js.map and b/priv/static/packs/locales/vanilla/et.js.map differ diff --git a/priv/static/packs/locales/vanilla/eu.js b/priv/static/packs/locales/vanilla/eu.js index b448e0fc3..30cd4e43c 100644 Binary files a/priv/static/packs/locales/vanilla/eu.js and b/priv/static/packs/locales/vanilla/eu.js differ diff --git a/priv/static/packs/locales/vanilla/eu.js.map b/priv/static/packs/locales/vanilla/eu.js.map index 17dc81b17..e22dd44c5 100644 Binary files a/priv/static/packs/locales/vanilla/eu.js.map and b/priv/static/packs/locales/vanilla/eu.js.map differ diff --git a/priv/static/packs/locales/vanilla/fa.js b/priv/static/packs/locales/vanilla/fa.js index 8646e092d..b616d72a6 100644 Binary files a/priv/static/packs/locales/vanilla/fa.js and b/priv/static/packs/locales/vanilla/fa.js differ diff --git a/priv/static/packs/locales/vanilla/fa.js.map b/priv/static/packs/locales/vanilla/fa.js.map index b5cd146cb..3ec9c2377 100644 Binary files a/priv/static/packs/locales/vanilla/fa.js.map and b/priv/static/packs/locales/vanilla/fa.js.map differ diff --git a/priv/static/packs/locales/vanilla/fi.js b/priv/static/packs/locales/vanilla/fi.js index 43aa01ba3..4a1631a4d 100644 Binary files a/priv/static/packs/locales/vanilla/fi.js and b/priv/static/packs/locales/vanilla/fi.js differ diff --git a/priv/static/packs/locales/vanilla/fi.js.map b/priv/static/packs/locales/vanilla/fi.js.map index 752201541..debb697d9 100644 Binary files a/priv/static/packs/locales/vanilla/fi.js.map and b/priv/static/packs/locales/vanilla/fi.js.map differ diff --git a/priv/static/packs/locales/vanilla/fr.js b/priv/static/packs/locales/vanilla/fr.js index 96d12e8f5..431bd4ae7 100644 Binary files a/priv/static/packs/locales/vanilla/fr.js and b/priv/static/packs/locales/vanilla/fr.js differ diff --git a/priv/static/packs/locales/vanilla/fr.js.map b/priv/static/packs/locales/vanilla/fr.js.map index ea38b4b8c..b6324ca17 100644 Binary files a/priv/static/packs/locales/vanilla/fr.js.map and b/priv/static/packs/locales/vanilla/fr.js.map differ diff --git a/priv/static/packs/locales/vanilla/ga.js b/priv/static/packs/locales/vanilla/ga.js index 75596c19f..8b98545b3 100644 Binary files a/priv/static/packs/locales/vanilla/ga.js and b/priv/static/packs/locales/vanilla/ga.js differ diff --git a/priv/static/packs/locales/vanilla/ga.js.map b/priv/static/packs/locales/vanilla/ga.js.map index 04c200127..c0303f4f9 100644 Binary files a/priv/static/packs/locales/vanilla/ga.js.map and b/priv/static/packs/locales/vanilla/ga.js.map differ diff --git a/priv/static/packs/locales/vanilla/gl.js b/priv/static/packs/locales/vanilla/gl.js index 0cc410ca4..e0c3e6a38 100644 Binary files a/priv/static/packs/locales/vanilla/gl.js and b/priv/static/packs/locales/vanilla/gl.js differ diff --git a/priv/static/packs/locales/vanilla/gl.js.map b/priv/static/packs/locales/vanilla/gl.js.map index 100c44bb0..9f305218a 100644 Binary files a/priv/static/packs/locales/vanilla/gl.js.map and b/priv/static/packs/locales/vanilla/gl.js.map differ diff --git a/priv/static/packs/locales/vanilla/he.js b/priv/static/packs/locales/vanilla/he.js index 44cb712d9..73d91419a 100644 Binary files a/priv/static/packs/locales/vanilla/he.js and b/priv/static/packs/locales/vanilla/he.js differ diff --git a/priv/static/packs/locales/vanilla/he.js.map b/priv/static/packs/locales/vanilla/he.js.map index cd36595c2..c98587628 100644 Binary files a/priv/static/packs/locales/vanilla/he.js.map and b/priv/static/packs/locales/vanilla/he.js.map differ diff --git a/priv/static/packs/locales/vanilla/hi.js b/priv/static/packs/locales/vanilla/hi.js index 8ce3abc13..27a6eb946 100644 Binary files a/priv/static/packs/locales/vanilla/hi.js and b/priv/static/packs/locales/vanilla/hi.js differ diff --git a/priv/static/packs/locales/vanilla/hi.js.map b/priv/static/packs/locales/vanilla/hi.js.map index 088cac143..7a5858596 100644 Binary files a/priv/static/packs/locales/vanilla/hi.js.map and b/priv/static/packs/locales/vanilla/hi.js.map differ diff --git a/priv/static/packs/locales/vanilla/hr.js b/priv/static/packs/locales/vanilla/hr.js index 5e81ef38e..d8d788315 100644 Binary files a/priv/static/packs/locales/vanilla/hr.js and b/priv/static/packs/locales/vanilla/hr.js differ diff --git a/priv/static/packs/locales/vanilla/hr.js.map b/priv/static/packs/locales/vanilla/hr.js.map index 61803b58f..f9c432fb6 100644 Binary files a/priv/static/packs/locales/vanilla/hr.js.map and b/priv/static/packs/locales/vanilla/hr.js.map differ diff --git a/priv/static/packs/locales/vanilla/hu.js b/priv/static/packs/locales/vanilla/hu.js index df5aec299..f1e7682d6 100644 Binary files a/priv/static/packs/locales/vanilla/hu.js and b/priv/static/packs/locales/vanilla/hu.js differ diff --git a/priv/static/packs/locales/vanilla/hu.js.map b/priv/static/packs/locales/vanilla/hu.js.map index 37d4b7e91..220a6e92b 100644 Binary files a/priv/static/packs/locales/vanilla/hu.js.map and b/priv/static/packs/locales/vanilla/hu.js.map differ diff --git a/priv/static/packs/locales/vanilla/hy.js b/priv/static/packs/locales/vanilla/hy.js index 0ee6fe350..f21e393eb 100644 Binary files a/priv/static/packs/locales/vanilla/hy.js and b/priv/static/packs/locales/vanilla/hy.js differ diff --git a/priv/static/packs/locales/vanilla/hy.js.map b/priv/static/packs/locales/vanilla/hy.js.map index 1647a44dd..a395c87fe 100644 Binary files a/priv/static/packs/locales/vanilla/hy.js.map and b/priv/static/packs/locales/vanilla/hy.js.map differ diff --git a/priv/static/packs/locales/vanilla/id.js b/priv/static/packs/locales/vanilla/id.js index 0bf8d76cd..6591b818a 100644 Binary files a/priv/static/packs/locales/vanilla/id.js and b/priv/static/packs/locales/vanilla/id.js differ diff --git a/priv/static/packs/locales/vanilla/id.js.map b/priv/static/packs/locales/vanilla/id.js.map index 3bad1633a..d0f909013 100644 Binary files a/priv/static/packs/locales/vanilla/id.js.map and b/priv/static/packs/locales/vanilla/id.js.map differ diff --git a/priv/static/packs/locales/vanilla/io.js b/priv/static/packs/locales/vanilla/io.js index 204797e62..22f39d73a 100644 Binary files a/priv/static/packs/locales/vanilla/io.js and b/priv/static/packs/locales/vanilla/io.js differ diff --git a/priv/static/packs/locales/vanilla/io.js.map b/priv/static/packs/locales/vanilla/io.js.map index 358b74e3a..11d01d926 100644 Binary files a/priv/static/packs/locales/vanilla/io.js.map and b/priv/static/packs/locales/vanilla/io.js.map differ diff --git a/priv/static/packs/locales/vanilla/is.js b/priv/static/packs/locales/vanilla/is.js index a5002812b..05db56dfd 100644 Binary files a/priv/static/packs/locales/vanilla/is.js and b/priv/static/packs/locales/vanilla/is.js differ diff --git a/priv/static/packs/locales/vanilla/is.js.map b/priv/static/packs/locales/vanilla/is.js.map index 0da088d4d..e45da8547 100644 Binary files a/priv/static/packs/locales/vanilla/is.js.map and b/priv/static/packs/locales/vanilla/is.js.map differ diff --git a/priv/static/packs/locales/vanilla/it.js b/priv/static/packs/locales/vanilla/it.js index 4779ebdbb..f8877398f 100644 Binary files a/priv/static/packs/locales/vanilla/it.js and b/priv/static/packs/locales/vanilla/it.js differ diff --git a/priv/static/packs/locales/vanilla/it.js.map b/priv/static/packs/locales/vanilla/it.js.map index 1cc7b2ef4..79aed8726 100644 Binary files a/priv/static/packs/locales/vanilla/it.js.map and b/priv/static/packs/locales/vanilla/it.js.map differ diff --git a/priv/static/packs/locales/vanilla/ja.js b/priv/static/packs/locales/vanilla/ja.js index c45f7b0fe..99efb72a3 100644 Binary files a/priv/static/packs/locales/vanilla/ja.js and b/priv/static/packs/locales/vanilla/ja.js differ diff --git a/priv/static/packs/locales/vanilla/ja.js.map b/priv/static/packs/locales/vanilla/ja.js.map index df85f46fc..8a7d4edb0 100644 Binary files a/priv/static/packs/locales/vanilla/ja.js.map and b/priv/static/packs/locales/vanilla/ja.js.map differ diff --git a/priv/static/packs/locales/vanilla/ka.js b/priv/static/packs/locales/vanilla/ka.js index e0670762a..6091581a1 100644 Binary files a/priv/static/packs/locales/vanilla/ka.js and b/priv/static/packs/locales/vanilla/ka.js differ diff --git a/priv/static/packs/locales/vanilla/ka.js.map b/priv/static/packs/locales/vanilla/ka.js.map index 4e4743f35..aa254d9ab 100644 Binary files a/priv/static/packs/locales/vanilla/ka.js.map and b/priv/static/packs/locales/vanilla/ka.js.map differ diff --git a/priv/static/packs/locales/vanilla/kab.js b/priv/static/packs/locales/vanilla/kab.js index b6082022e..b9001a1cc 100644 Binary files a/priv/static/packs/locales/vanilla/kab.js and b/priv/static/packs/locales/vanilla/kab.js differ diff --git a/priv/static/packs/locales/vanilla/kab.js.map b/priv/static/packs/locales/vanilla/kab.js.map index 86bf00317..148c3351a 100644 Binary files a/priv/static/packs/locales/vanilla/kab.js.map and b/priv/static/packs/locales/vanilla/kab.js.map differ diff --git a/priv/static/packs/locales/vanilla/kk.js b/priv/static/packs/locales/vanilla/kk.js index bc99a54bb..f74b2cb3d 100644 Binary files a/priv/static/packs/locales/vanilla/kk.js and b/priv/static/packs/locales/vanilla/kk.js differ diff --git a/priv/static/packs/locales/vanilla/kk.js.map b/priv/static/packs/locales/vanilla/kk.js.map index 357325795..ced32c979 100644 Binary files a/priv/static/packs/locales/vanilla/kk.js.map and b/priv/static/packs/locales/vanilla/kk.js.map differ diff --git a/priv/static/packs/locales/vanilla/kn.js b/priv/static/packs/locales/vanilla/kn.js index 170fab533..a8be6ca7c 100644 Binary files a/priv/static/packs/locales/vanilla/kn.js and b/priv/static/packs/locales/vanilla/kn.js differ diff --git a/priv/static/packs/locales/vanilla/kn.js.map b/priv/static/packs/locales/vanilla/kn.js.map index a817395c8..c7f28697d 100644 Binary files a/priv/static/packs/locales/vanilla/kn.js.map and b/priv/static/packs/locales/vanilla/kn.js.map differ diff --git a/priv/static/packs/locales/vanilla/ko.js b/priv/static/packs/locales/vanilla/ko.js index 8ca8e8473..8940912cd 100644 Binary files a/priv/static/packs/locales/vanilla/ko.js and b/priv/static/packs/locales/vanilla/ko.js differ diff --git a/priv/static/packs/locales/vanilla/ko.js.map b/priv/static/packs/locales/vanilla/ko.js.map index aed5a710a..781e7af50 100644 Binary files a/priv/static/packs/locales/vanilla/ko.js.map and b/priv/static/packs/locales/vanilla/ko.js.map differ diff --git a/priv/static/packs/locales/vanilla/lt.js b/priv/static/packs/locales/vanilla/lt.js index 14c75f1b8..540f622a3 100644 Binary files a/priv/static/packs/locales/vanilla/lt.js and b/priv/static/packs/locales/vanilla/lt.js differ diff --git a/priv/static/packs/locales/vanilla/lt.js.map b/priv/static/packs/locales/vanilla/lt.js.map index b9e31d3fc..0ad385eaf 100644 Binary files a/priv/static/packs/locales/vanilla/lt.js.map and b/priv/static/packs/locales/vanilla/lt.js.map differ diff --git a/priv/static/packs/locales/vanilla/lv.js b/priv/static/packs/locales/vanilla/lv.js index 0960392ff..b58d988c1 100644 Binary files a/priv/static/packs/locales/vanilla/lv.js and b/priv/static/packs/locales/vanilla/lv.js differ diff --git a/priv/static/packs/locales/vanilla/lv.js.map b/priv/static/packs/locales/vanilla/lv.js.map index 9a81fb657..f448f8432 100644 Binary files a/priv/static/packs/locales/vanilla/lv.js.map and b/priv/static/packs/locales/vanilla/lv.js.map differ diff --git a/priv/static/packs/locales/vanilla/mk.js b/priv/static/packs/locales/vanilla/mk.js index 1788d66cf..205f06f17 100644 Binary files a/priv/static/packs/locales/vanilla/mk.js and b/priv/static/packs/locales/vanilla/mk.js differ diff --git a/priv/static/packs/locales/vanilla/mk.js.map b/priv/static/packs/locales/vanilla/mk.js.map index 70f8d8747..470d982ec 100644 Binary files a/priv/static/packs/locales/vanilla/mk.js.map and b/priv/static/packs/locales/vanilla/mk.js.map differ diff --git a/priv/static/packs/locales/vanilla/ml.js b/priv/static/packs/locales/vanilla/ml.js index e7beaf740..010562bb8 100644 Binary files a/priv/static/packs/locales/vanilla/ml.js and b/priv/static/packs/locales/vanilla/ml.js differ diff --git a/priv/static/packs/locales/vanilla/ml.js.map b/priv/static/packs/locales/vanilla/ml.js.map index 547658dcb..1be28164c 100644 Binary files a/priv/static/packs/locales/vanilla/ml.js.map and b/priv/static/packs/locales/vanilla/ml.js.map differ diff --git a/priv/static/packs/locales/vanilla/mr.js b/priv/static/packs/locales/vanilla/mr.js index 291b8054b..5fd186344 100644 Binary files a/priv/static/packs/locales/vanilla/mr.js and b/priv/static/packs/locales/vanilla/mr.js differ diff --git a/priv/static/packs/locales/vanilla/mr.js.map b/priv/static/packs/locales/vanilla/mr.js.map index cd29ccccf..cf404625f 100644 Binary files a/priv/static/packs/locales/vanilla/mr.js.map and b/priv/static/packs/locales/vanilla/mr.js.map differ diff --git a/priv/static/packs/locales/vanilla/ms.js b/priv/static/packs/locales/vanilla/ms.js index 7d40a3be9..aea0ef4c3 100644 Binary files a/priv/static/packs/locales/vanilla/ms.js and b/priv/static/packs/locales/vanilla/ms.js differ diff --git a/priv/static/packs/locales/vanilla/ms.js.map b/priv/static/packs/locales/vanilla/ms.js.map index 228f13cd7..897a1b7f8 100644 Binary files a/priv/static/packs/locales/vanilla/ms.js.map and b/priv/static/packs/locales/vanilla/ms.js.map differ diff --git a/priv/static/packs/locales/vanilla/nl.js b/priv/static/packs/locales/vanilla/nl.js index f24cd1d6b..84b479dd8 100644 Binary files a/priv/static/packs/locales/vanilla/nl.js and b/priv/static/packs/locales/vanilla/nl.js differ diff --git a/priv/static/packs/locales/vanilla/nl.js.map b/priv/static/packs/locales/vanilla/nl.js.map index 7f984bbee..677ff9cc5 100644 Binary files a/priv/static/packs/locales/vanilla/nl.js.map and b/priv/static/packs/locales/vanilla/nl.js.map differ diff --git a/priv/static/packs/locales/vanilla/nn.js b/priv/static/packs/locales/vanilla/nn.js index 7e4f9ff90..a015c275e 100644 Binary files a/priv/static/packs/locales/vanilla/nn.js and b/priv/static/packs/locales/vanilla/nn.js differ diff --git a/priv/static/packs/locales/vanilla/nn.js.map b/priv/static/packs/locales/vanilla/nn.js.map index 8ef48ff9f..01dfd6647 100644 Binary files a/priv/static/packs/locales/vanilla/nn.js.map and b/priv/static/packs/locales/vanilla/nn.js.map differ diff --git a/priv/static/packs/locales/vanilla/no.js b/priv/static/packs/locales/vanilla/no.js index d4a853588..19eeca907 100644 Binary files a/priv/static/packs/locales/vanilla/no.js and b/priv/static/packs/locales/vanilla/no.js differ diff --git a/priv/static/packs/locales/vanilla/no.js.map b/priv/static/packs/locales/vanilla/no.js.map index b469881f6..97d18247c 100644 Binary files a/priv/static/packs/locales/vanilla/no.js.map and b/priv/static/packs/locales/vanilla/no.js.map differ diff --git a/priv/static/packs/locales/vanilla/oc.js b/priv/static/packs/locales/vanilla/oc.js index e8580a16b..25d9e5663 100644 Binary files a/priv/static/packs/locales/vanilla/oc.js and b/priv/static/packs/locales/vanilla/oc.js differ diff --git a/priv/static/packs/locales/vanilla/oc.js.map b/priv/static/packs/locales/vanilla/oc.js.map index bc1dd8aa6..1890f6364 100644 Binary files a/priv/static/packs/locales/vanilla/oc.js.map and b/priv/static/packs/locales/vanilla/oc.js.map differ diff --git a/priv/static/packs/locales/vanilla/pl.js b/priv/static/packs/locales/vanilla/pl.js index 97f15bf38..f0b6e02ff 100644 Binary files a/priv/static/packs/locales/vanilla/pl.js and b/priv/static/packs/locales/vanilla/pl.js differ diff --git a/priv/static/packs/locales/vanilla/pl.js.map b/priv/static/packs/locales/vanilla/pl.js.map index 85145e5a6..5ce3d9490 100644 Binary files a/priv/static/packs/locales/vanilla/pl.js.map and b/priv/static/packs/locales/vanilla/pl.js.map differ diff --git a/priv/static/packs/locales/vanilla/pt-BR.js b/priv/static/packs/locales/vanilla/pt-BR.js index 7550603cc..bc1bf0707 100644 Binary files a/priv/static/packs/locales/vanilla/pt-BR.js and b/priv/static/packs/locales/vanilla/pt-BR.js differ diff --git a/priv/static/packs/locales/vanilla/pt-BR.js.map b/priv/static/packs/locales/vanilla/pt-BR.js.map index 267a7ef8b..99718f8e3 100644 Binary files a/priv/static/packs/locales/vanilla/pt-BR.js.map and b/priv/static/packs/locales/vanilla/pt-BR.js.map differ diff --git a/priv/static/packs/locales/vanilla/pt-PT.js b/priv/static/packs/locales/vanilla/pt-PT.js index 6895c5c7b..1400d34d0 100644 Binary files a/priv/static/packs/locales/vanilla/pt-PT.js and b/priv/static/packs/locales/vanilla/pt-PT.js differ diff --git a/priv/static/packs/locales/vanilla/pt-PT.js.map b/priv/static/packs/locales/vanilla/pt-PT.js.map index bab41c08a..65881dc14 100644 Binary files a/priv/static/packs/locales/vanilla/pt-PT.js.map and b/priv/static/packs/locales/vanilla/pt-PT.js.map differ diff --git a/priv/static/packs/locales/vanilla/ro.js b/priv/static/packs/locales/vanilla/ro.js index 481554f9f..6c786214d 100644 Binary files a/priv/static/packs/locales/vanilla/ro.js and b/priv/static/packs/locales/vanilla/ro.js differ diff --git a/priv/static/packs/locales/vanilla/ro.js.map b/priv/static/packs/locales/vanilla/ro.js.map index 3986b97e5..816cec77c 100644 Binary files a/priv/static/packs/locales/vanilla/ro.js.map and b/priv/static/packs/locales/vanilla/ro.js.map differ diff --git a/priv/static/packs/locales/vanilla/ru.js b/priv/static/packs/locales/vanilla/ru.js index 0bbdc37ab..9ae59768e 100644 Binary files a/priv/static/packs/locales/vanilla/ru.js and b/priv/static/packs/locales/vanilla/ru.js differ diff --git a/priv/static/packs/locales/vanilla/ru.js.map b/priv/static/packs/locales/vanilla/ru.js.map index c1f831ec3..1ce82af25 100644 Binary files a/priv/static/packs/locales/vanilla/ru.js.map and b/priv/static/packs/locales/vanilla/ru.js.map differ diff --git a/priv/static/packs/locales/vanilla/sk.js b/priv/static/packs/locales/vanilla/sk.js index 2ef30e303..2bfc244c5 100644 Binary files a/priv/static/packs/locales/vanilla/sk.js and b/priv/static/packs/locales/vanilla/sk.js differ diff --git a/priv/static/packs/locales/vanilla/sk.js.map b/priv/static/packs/locales/vanilla/sk.js.map index 083c50619..78be422ca 100644 Binary files a/priv/static/packs/locales/vanilla/sk.js.map and b/priv/static/packs/locales/vanilla/sk.js.map differ diff --git a/priv/static/packs/locales/vanilla/sl.js b/priv/static/packs/locales/vanilla/sl.js index 896523275..a91f08ff0 100644 Binary files a/priv/static/packs/locales/vanilla/sl.js and b/priv/static/packs/locales/vanilla/sl.js differ diff --git a/priv/static/packs/locales/vanilla/sl.js.map b/priv/static/packs/locales/vanilla/sl.js.map index e6d452df9..2a5f516d5 100644 Binary files a/priv/static/packs/locales/vanilla/sl.js.map and b/priv/static/packs/locales/vanilla/sl.js.map differ diff --git a/priv/static/packs/locales/vanilla/sq.js b/priv/static/packs/locales/vanilla/sq.js index 0d2c41318..2077d9e64 100644 Binary files a/priv/static/packs/locales/vanilla/sq.js and b/priv/static/packs/locales/vanilla/sq.js differ diff --git a/priv/static/packs/locales/vanilla/sq.js.map b/priv/static/packs/locales/vanilla/sq.js.map index 8eaf76d44..034c534ba 100644 Binary files a/priv/static/packs/locales/vanilla/sq.js.map and b/priv/static/packs/locales/vanilla/sq.js.map differ diff --git a/priv/static/packs/locales/vanilla/sr-Latn.js b/priv/static/packs/locales/vanilla/sr-Latn.js index d2ebf8e5e..c6ba642c9 100644 Binary files a/priv/static/packs/locales/vanilla/sr-Latn.js and b/priv/static/packs/locales/vanilla/sr-Latn.js differ diff --git a/priv/static/packs/locales/vanilla/sr-Latn.js.map b/priv/static/packs/locales/vanilla/sr-Latn.js.map index 86dd4c8f2..23a189d12 100644 Binary files a/priv/static/packs/locales/vanilla/sr-Latn.js.map and b/priv/static/packs/locales/vanilla/sr-Latn.js.map differ diff --git a/priv/static/packs/locales/vanilla/sr.js b/priv/static/packs/locales/vanilla/sr.js index 1a0aa8fc0..c70e85181 100644 Binary files a/priv/static/packs/locales/vanilla/sr.js and b/priv/static/packs/locales/vanilla/sr.js differ diff --git a/priv/static/packs/locales/vanilla/sr.js.map b/priv/static/packs/locales/vanilla/sr.js.map index ff378b7d5..34d0058c0 100644 Binary files a/priv/static/packs/locales/vanilla/sr.js.map and b/priv/static/packs/locales/vanilla/sr.js.map differ diff --git a/priv/static/packs/locales/vanilla/sv.js b/priv/static/packs/locales/vanilla/sv.js index 68b96c657..c9edf7b80 100644 Binary files a/priv/static/packs/locales/vanilla/sv.js and b/priv/static/packs/locales/vanilla/sv.js differ diff --git a/priv/static/packs/locales/vanilla/sv.js.map b/priv/static/packs/locales/vanilla/sv.js.map index b7c53c0f4..b0ca89829 100644 Binary files a/priv/static/packs/locales/vanilla/sv.js.map and b/priv/static/packs/locales/vanilla/sv.js.map differ diff --git a/priv/static/packs/locales/vanilla/ta.js b/priv/static/packs/locales/vanilla/ta.js index 88dabb897..2d274230d 100644 Binary files a/priv/static/packs/locales/vanilla/ta.js and b/priv/static/packs/locales/vanilla/ta.js differ diff --git a/priv/static/packs/locales/vanilla/ta.js.map b/priv/static/packs/locales/vanilla/ta.js.map index de245f786..ffebb252a 100644 Binary files a/priv/static/packs/locales/vanilla/ta.js.map and b/priv/static/packs/locales/vanilla/ta.js.map differ diff --git a/priv/static/packs/locales/vanilla/te.js b/priv/static/packs/locales/vanilla/te.js index eefd698ec..a9548c9f3 100644 Binary files a/priv/static/packs/locales/vanilla/te.js and b/priv/static/packs/locales/vanilla/te.js differ diff --git a/priv/static/packs/locales/vanilla/te.js.map b/priv/static/packs/locales/vanilla/te.js.map index 62232430e..d6f33a0b9 100644 Binary files a/priv/static/packs/locales/vanilla/te.js.map and b/priv/static/packs/locales/vanilla/te.js.map differ diff --git a/priv/static/packs/locales/vanilla/th.js b/priv/static/packs/locales/vanilla/th.js index 5230f7ed0..5fd67d669 100644 Binary files a/priv/static/packs/locales/vanilla/th.js and b/priv/static/packs/locales/vanilla/th.js differ diff --git a/priv/static/packs/locales/vanilla/th.js.map b/priv/static/packs/locales/vanilla/th.js.map index 20e4a78eb..25c2c4641 100644 Binary files a/priv/static/packs/locales/vanilla/th.js.map and b/priv/static/packs/locales/vanilla/th.js.map differ diff --git a/priv/static/packs/locales/vanilla/tr.js b/priv/static/packs/locales/vanilla/tr.js index 04ca75e3b..0e245a4ec 100644 Binary files a/priv/static/packs/locales/vanilla/tr.js and b/priv/static/packs/locales/vanilla/tr.js differ diff --git a/priv/static/packs/locales/vanilla/tr.js.map b/priv/static/packs/locales/vanilla/tr.js.map index 4eaf7717f..fb38b5cde 100644 Binary files a/priv/static/packs/locales/vanilla/tr.js.map and b/priv/static/packs/locales/vanilla/tr.js.map differ diff --git a/priv/static/packs/locales/vanilla/uk.js b/priv/static/packs/locales/vanilla/uk.js index 8b22d2c84..ea579e1a9 100644 Binary files a/priv/static/packs/locales/vanilla/uk.js and b/priv/static/packs/locales/vanilla/uk.js differ diff --git a/priv/static/packs/locales/vanilla/uk.js.map b/priv/static/packs/locales/vanilla/uk.js.map index aed57a251..e11d638c8 100644 Binary files a/priv/static/packs/locales/vanilla/uk.js.map and b/priv/static/packs/locales/vanilla/uk.js.map differ diff --git a/priv/static/packs/locales/vanilla/ur.js b/priv/static/packs/locales/vanilla/ur.js index 507ca10a0..fd92d1ab0 100644 Binary files a/priv/static/packs/locales/vanilla/ur.js and b/priv/static/packs/locales/vanilla/ur.js differ diff --git a/priv/static/packs/locales/vanilla/ur.js.map b/priv/static/packs/locales/vanilla/ur.js.map index 1e1f29498..7aa1dc75c 100644 Binary files a/priv/static/packs/locales/vanilla/ur.js.map and b/priv/static/packs/locales/vanilla/ur.js.map differ diff --git a/priv/static/packs/locales/vanilla/vi.js b/priv/static/packs/locales/vanilla/vi.js index f7964f810..74b75f64e 100644 Binary files a/priv/static/packs/locales/vanilla/vi.js and b/priv/static/packs/locales/vanilla/vi.js differ diff --git a/priv/static/packs/locales/vanilla/vi.js.map b/priv/static/packs/locales/vanilla/vi.js.map index e3a49bb69..ea34ab185 100644 Binary files a/priv/static/packs/locales/vanilla/vi.js.map and b/priv/static/packs/locales/vanilla/vi.js.map differ diff --git a/priv/static/packs/locales/vanilla/zh-CN.js b/priv/static/packs/locales/vanilla/zh-CN.js index 1c6668c14..35514a2ee 100644 Binary files a/priv/static/packs/locales/vanilla/zh-CN.js and b/priv/static/packs/locales/vanilla/zh-CN.js differ diff --git a/priv/static/packs/locales/vanilla/zh-CN.js.map b/priv/static/packs/locales/vanilla/zh-CN.js.map index c8a4f1f4a..e619f8655 100644 Binary files a/priv/static/packs/locales/vanilla/zh-CN.js.map and b/priv/static/packs/locales/vanilla/zh-CN.js.map differ diff --git a/priv/static/packs/locales/vanilla/zh-HK.js b/priv/static/packs/locales/vanilla/zh-HK.js index 4e6a49912..a0eb66b6e 100644 Binary files a/priv/static/packs/locales/vanilla/zh-HK.js and b/priv/static/packs/locales/vanilla/zh-HK.js differ diff --git a/priv/static/packs/locales/vanilla/zh-HK.js.map b/priv/static/packs/locales/vanilla/zh-HK.js.map index 5ccfee79b..aac2821fa 100644 Binary files a/priv/static/packs/locales/vanilla/zh-HK.js.map and b/priv/static/packs/locales/vanilla/zh-HK.js.map differ diff --git a/priv/static/packs/locales/vanilla/zh-TW.js b/priv/static/packs/locales/vanilla/zh-TW.js index 03165b6da..585a10947 100644 Binary files a/priv/static/packs/locales/vanilla/zh-TW.js and b/priv/static/packs/locales/vanilla/zh-TW.js differ diff --git a/priv/static/packs/locales/vanilla/zh-TW.js.map b/priv/static/packs/locales/vanilla/zh-TW.js.map index 5a5dc377b..9ae897a48 100644 Binary files a/priv/static/packs/locales/vanilla/zh-TW.js.map and b/priv/static/packs/locales/vanilla/zh-TW.js.map differ diff --git a/priv/static/packs/modals/block_modal.js b/priv/static/packs/modals/block_modal.js index 90c88d163..b74a7a3d0 100644 Binary files a/priv/static/packs/modals/block_modal.js and b/priv/static/packs/modals/block_modal.js differ diff --git a/priv/static/packs/modals/block_modal.js.map b/priv/static/packs/modals/block_modal.js.map index 406846735..2796f6af6 100644 Binary files a/priv/static/packs/modals/block_modal.js.map and b/priv/static/packs/modals/block_modal.js.map differ diff --git a/priv/static/packs/modals/embed_modal.js b/priv/static/packs/modals/embed_modal.js index 21ab12b50..9092bee72 100644 Binary files a/priv/static/packs/modals/embed_modal.js and b/priv/static/packs/modals/embed_modal.js differ diff --git a/priv/static/packs/modals/embed_modal.js.map b/priv/static/packs/modals/embed_modal.js.map index c2c70ba99..0fd5ad06d 100644 Binary files a/priv/static/packs/modals/embed_modal.js.map and b/priv/static/packs/modals/embed_modal.js.map differ diff --git a/priv/static/packs/modals/mute_modal.js b/priv/static/packs/modals/mute_modal.js index df9cdcb60..d239d0dab 100644 Binary files a/priv/static/packs/modals/mute_modal.js and b/priv/static/packs/modals/mute_modal.js differ diff --git a/priv/static/packs/modals/mute_modal.js.map b/priv/static/packs/modals/mute_modal.js.map index ac6f90cad..8a2885173 100644 Binary files a/priv/static/packs/modals/mute_modal.js.map and b/priv/static/packs/modals/mute_modal.js.map differ diff --git a/priv/static/packs/modals/report_modal.js b/priv/static/packs/modals/report_modal.js index 004baf326..cc6d7904f 100644 Binary files a/priv/static/packs/modals/report_modal.js and b/priv/static/packs/modals/report_modal.js differ diff --git a/priv/static/packs/modals/report_modal.js.map b/priv/static/packs/modals/report_modal.js.map index 079fd6be6..d695d2a81 100644 Binary files a/priv/static/packs/modals/report_modal.js.map and b/priv/static/packs/modals/report_modal.js.map differ diff --git a/priv/static/packs/skins/glitch/contrast/common.css b/priv/static/packs/skins/glitch/contrast/common.css index 79ea1f515..748cdc91f 100644 Binary files a/priv/static/packs/skins/glitch/contrast/common.css and b/priv/static/packs/skins/glitch/contrast/common.css differ diff --git a/priv/static/packs/skins/glitch/contrast/common.css.map b/priv/static/packs/skins/glitch/contrast/common.css.map index 0fe13838c..2869e4adc 100644 --- a/priv/static/packs/skins/glitch/contrast/common.css.map +++ b/priv/static/packs/skins/glitch/contrast/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/flavours/glitch/styles/reset.scss","webpack:///./app/javascript/flavours/glitch/styles/contrast/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/basics.scss","webpack:///./app/javascript/flavours/glitch/styles/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/containers.scss","webpack:///./app/javascript/flavours/glitch/styles/_mixins.scss","webpack:///./app/javascript/flavours/glitch/styles/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/footer.scss","webpack:///./app/javascript/flavours/glitch/styles/compact_header.scss","webpack:///./app/javascript/flavours/glitch/styles/widgets.scss","webpack:///./app/javascript/flavours/glitch/styles/forms.scss","webpack:///./app/javascript/flavours/glitch/styles/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/statuses.scss","webpack:///./app/javascript/flavours/glitch/styles/components/index.scss","webpack:///./app/javascript/flavours/glitch/styles/components/boost.scss","webpack:///./app/javascript/flavours/glitch/styles/components/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/components/domains.scss","webpack:///./app/javascript/flavours/glitch/styles/components/status.scss","webpack:///./app/javascript/flavours/glitch/styles/components/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/components/composer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/columns.scss","webpack:///./app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss","webpack:///./app/javascript/flavours/glitch/styles/components/directory.scss","webpack:///./app/javascript/flavours/glitch/styles/components/search.scss","webpack:///","webpack:///./app/javascript/flavours/glitch/styles/components/emoji.scss","webpack:///./app/javascript/flavours/glitch/styles/components/doodle.scss","webpack:///./app/javascript/flavours/glitch/styles/components/drawer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/media.scss","webpack:///./app/javascript/flavours/glitch/styles/components/sensitive.scss","webpack:///./app/javascript/flavours/glitch/styles/components/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/components/emoji_picker.scss","webpack:///./app/javascript/flavours/glitch/styles/components/local_settings.scss","webpack:///./app/javascript/flavours/glitch/styles/components/error_boundary.scss","webpack:///./app/javascript/flavours/glitch/styles/components/single_column.scss","webpack:///./app/javascript/flavours/glitch/styles/polls.scss","webpack:///./app/javascript/flavours/glitch/styles/about.scss","webpack:///./app/javascript/flavours/glitch/styles/tables.scss","webpack:///./app/javascript/flavours/glitch/styles/admin.scss","webpack:///./app/javascript/flavours/glitch/styles/accessibility.scss","webpack:///./app/javascript/flavours/glitch/styles/rtl.scss","webpack:///./app/javascript/flavours/glitch/styles/dashboard.scss","webpack:///./app/javascript/flavours/glitch/styles/contrast/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CC9EmB,iEDqFrB,kBCrFqB,4BDyFrB,sBACE,MEtFF,sBACE,mBACA,eACA,iBACA,gBACA,WCVM,kCDYN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,sIAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDpDmB,kBCwDnB,iBACE,kBACA,0BAEA,iBACE,YAIJ,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDzEgB,mBAZC,WCwFjB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,wBAEA,aACE,gBACA,WACA,YACA,kBACA,uBAGF,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,6BAKF,YAEE,WACA,mBACA,uBACA,oBACA,yEAKF,gBAEE,+EAKF,WAEE,gBErJJ,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SDrBI,YCuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WD9BE,qBCgCF,UACA,kBACA,iBACA,uBACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAKA,UACqB,sCC3EvB,iBD4EE,6BAEA,UACE,YACA,cACA,SACA,kBACA,iBD5BkB,wBE9DtB,4BACA,uBD8FA,aACE,cHjFmB,wBGmFnB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UD1UA,qCC6UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cHlVc,mBGoVd,kBACA,uHAEA,yBAGE,WDvWA,qCC2WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBH1aiB,8CG+anB,yBACE,gBACA,aACA,kBACA,mBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,CAEA,WACqB,yCCzgB3B,kBD0gBM,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,iBD7dc,wBE9DtB,4BACA,qCD+hBI,2CAvCF,YAwCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAKA,UACqB,sCCtiB7B,CDuiBQ,sBACA,wDAEA,QACE,kBACA,iBDrfY,wBE9DtB,4BACA,2DDsjBQ,mDAbF,YAcI,sCAKN,2CApEF,eAqEI,sCAGF,2CAxEF,cAyEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WDhlBF,gBCklBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WD1lBJ,gBC4lBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aHrmBQ,oDG4mBd,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cHjoBU,aGmoBV,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BHjqBW,wEGuqBX,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WDzsBJ,uBC2sBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cHluBY,uDGquBZ,oBACE,cHtuBU,qBGwuBV,aACA,gBACA,8DAEA,eACE,WD1vBJ,qCCgwBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aD3yBU,8DCizBV,mBACA,WDnzBE,qFCuzBJ,YAEE,eACA,cH7yBc,2CGizBhB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBHz3Ba,+IG43BX,kBAGE,WEl4BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,eChBJ,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,yBCrBF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cPnFc,6BOsFd,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cRYgB,gBQVhB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cRJiB,wBQQnB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBPPI,uBOUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBTlBmB,aSoBjB,0BACA,eACA,cTVgB,iBSYhB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aTxCmB,qBS0CjB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cTjEgB,+BSqElB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aT9FkB,aSmGpB,YACE,kBACA,mBTjHmB,mCSmHnB,qBAGF,YACE,kBACA,0BACA,kBACA,cT9GkB,mBSgHlB,iBAGF,eACE,eACA,cTrHkB,iBSuHlB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cThIgB,0BSoIlB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cT7IgB,qBS+IhB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBT3KmB,mCS6KnB,cT/JqB,gBSiKrB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cT5Mc,8DSkNhB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,ePlPM,COoPN,cACA,cTvOkB,mBSyOlB,+BANA,iBACA,CPlPM,kCOgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UPjQM,eOmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cT7PgB,qCSiQlB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBTvRqB,kBSyRnB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBTpSe,kBSsSf,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBT3SiB,eS6Sf,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WPnUE,mBOqUF,gBACA,uBACA,wBAEA,aT5Tc,0BSgUd,aACE,gBACA,eACA,eACA,cTpUY,yFS0Ud,UPvVE,+BO8VJ,aACE,YACA,uDAGF,oBTxViB,eS8VrB,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cTtYgB,gBSwYhB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WP3aI,8BO8aJ,aACE,cTlac,gBSoad,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aTngBkB,iCSkgBpB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cT1hBiB,4JS6hBjB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WP9jBI,gCOgkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MC/kBJ,+BACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WRhDA,cQkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aVjEoB,0BUmElB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aVvFkB,sBU0FhB,aVnGsB,yBUuGtB,iBACE,kBACA,mBACA,wBAIJ,aACE,eACA,eACA,qBAGF,kBACE,cV5GgB,iCU+GhB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WRzJA,gBQ2JA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WR/KE,cQiLF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WRrME,cQuMF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WRnRI,cQqRJ,WACA,2CAKE,mBACE,eACA,WR7RA,qBQ+RA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WR7TI,cQ+TJ,WACA,UACA,oBACA,gBACA,mBACA,yBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBRpVY,oLQwVZ,iBACE,4WAGF,oBVzVsB,mBU4VpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBVnYiB,WEXb,eQiZJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBV1aoB,gGU8apB,kBR9aQ,kHQibN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WRjcI,cQmcJ,WACA,UACA,oBACA,gBACA,wXACA,yBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cVjdY,oBUmdZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,iEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UR/gBF,aQyhBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cVphBkB,kBUshBlB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cRziBY,sBQ6iBd,mCACE,+BACA,cR9iBQ,kBQkjBV,oBACE,cVxiBgB,qBU0iBhB,wBAEA,URzjBI,0BQ2jBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gCACA,mBVzkBiB,WEDb,eQ6kBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aVrmBkB,qBUumBhB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aVloBwB,yBUooBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cVzoBkB,oCU4oBlB,cACE,mBACA,kBACA,4CAGF,aVhpBqB,gBUkpBnB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBRtrBM,YQwrBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cVrrBqB,WUurBrB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WRluBI,qCQouBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UR1uBI,0BQ4uBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cV5wBkB,0BU+wBlB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WRtyBI,kBQwyBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aRhzBc,0SQ0zBZ,+BACE,aAIJ,kBACE,yBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gCACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBRp2Bc,gBQs2BZ,2BAEA,kBRx2BY,gBQ02BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC36BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,mBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,CAEA,UACqB,sCPpDzB,gBOqDI,wBAEA,UACE,YACA,cACA,SACA,kBACA,iBTLgB,wBE9DtB,4BACA,mBOoEM,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WT/EA,gBSiFA,gBACA,uBACA,+BAGF,aACE,eACA,cX3EY,gBW6EZ,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WT7GI,gBS+GJ,qBACA,iBACA,qBACA,sBAGF,eTrHM,oBSuHJ,WXxHI,eW0HJ,cACA,kBAGF,cACE,uCAGF,wBAEE,cXpHmB,oBWwHrB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBX7KqB,mCW+KnB,cX3JiB,eW6JjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cX5MwB,sCW8MxB,sCACA,6DAEA,aTnNc,sCSqNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cX/OmB,6BWiPnB,6BAGF,aACE,cXvPgB,4BW2PlB,aXpQwB,qBWsQtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aTtRY,gBSwRV,0CAGF,aT3RY,wCSgSd,eACE,wCAIJ,UACE,0BAIA,aX9RkB,4BWiShB,aX3SsB,qBW6SpB,qGAEA,yBAGE,iCAIJ,UTzTI,gBS2TF,wBAIJ,eACE,kBClUJ,kCACE,kBACA,gBACA,mBACA,qCAEA,iBANF,eAOI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBZxBmB,6GY2BjB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBZhEmB,WEXb,oBU8EN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UVxFI,gFU4FN,kBAGE,qNAKA,kBZlGoB,4IY0GpB,kBV1GQ,qCUiHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAKA,cACA,iBACA,mBACA,mFAGF,iBACE,eACA,WACA,WACA,WACA,qMAGF,eAGE,mEASF,cACE,gBACA,qFAGF,aZhKc,YYkKZ,eACA,WACA,eACA,gBACA,+GAGF,aACE,eACA,CACA,sBACA,eACA,yJAEA,cACE,uEAIJ,WACE,kBACA,WACA,eACA,iDAQF,iBACE,mBACA,yHAEA,iBACE,gBACA,+FAGF,UACE,WC3NR,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cbKmB,SaHnB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,abhBsB,eakBpB,SAIJ,wBACE,YACA,kBACA,sBACA,WX5BM,eW8BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,qCACA,mBACA,WACA,4CAEA,wBAGE,4BACA,qCACA,sBAGF,eACE,mFAEA,wBX3DQ,gBW+DN,kBAIJ,wBbnEsB,eaqEpB,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,UbtFM,mBAGgB,qGauFpB,wBAGE,8BAIJ,kBXlEsB,2GWqEpB,wBAGE,0BAIJ,cACE,iBACA,YACA,cbhGgB,oBakGhB,uBACA,iBACA,kBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,cAIJ,oBACE,UACA,cbxHoB,Sa0HpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,uCACA,4BACA,2CACA,oBAGF,qCACE,uBAGF,aACE,6BACA,eACA,qBAGF,abjKwB,gCaqKxB,QACE,uEAGF,mBAGE,uBAGF,ab/JmB,sFakKjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,ablMsB,uCaqMpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,abxMqB,Sa0MnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,aACE,6BACA,eACA,0BAGF,abzPwB,qCa6PxB,QACE,sFAGF,mBAGE,gBAIJ,iBACE,uBACA,YAGF,WACE,cACA,qBACA,QACA,SACA,kBACA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,uCAIJ,MACE,kBACA,CXvSU,sEW8SZ,aX9SY,uBWkTZ,aXnTc,4DWyTV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UX7UM,0BW+UJ,eAIJ,aACE,eACA,gBACA,uBACA,mBACA,iBAEA,aACE,wBACA,sBAIA,cACA,gBAKA,yCAPF,WACE,CAEA,gBACA,uBACA,gBACA,mBAWA,CAVA,mBAGF,aACE,CACA,cAKA,8BAIA,yBACE,sBAIJ,SACE,YACA,eACA,iBACA,uBACA,mBACA,gBACA,CAME,sDAGF,cACE,YACA,kBACA,oBACA,qBAKN,eACE,wBAGF,cACE,eAGF,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cXhX4B,eAEC,0DWiX3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cXxY4B,eAEC,WWyY3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,WAIJ,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBbhd0B,cakdxB,kBACA,uCACA,mBAEA,eACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BbpesB,2BawexB,WACE,iBACA,uBACA,yBb3esB,8Ba+exB,QACE,iBACA,uBACA,4BblfsB,6BasfxB,SACE,gBACA,2BACA,2BbzfsB,wBa+fxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBbrgBsB,WAJlB,gBa4gBJ,uBACA,mBACA,yFAEA,kBbpgBiB,cAIE,UaqgBjB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBb/hBsB,caiiBtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBbxjBsB,WAJlB,gBa+jBJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBb3jBiB,cAIE,iBa8jBvB,qBACE,iBAIA,sBACA,cbrkBgB,oBawkBhB,cACE,gBACA,mBACA,kBACA,mBAGF,cACE,mBACA,iBAIJ,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WXnpBM,qBWqpBN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCThoBA,6GADF,kBSwoBI,4BACA,kHTpoBJ,kBSmoBI,4BACA,wBAIJ,+BACE,cbxqBsB,sBa4qBxB,eACE,aACA,2BAGF,aACE,eACA,kBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBbtrBiB,yBawrBjB,gBACA,kBACA,eACA,gBACA,iBACA,WXxsBI,mDW6sBR,oBACE,aAGF,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,gBAIJ,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBbjxBmB,qCamxBnB,sEAGF,wBACE,4CAGF,wBbhxBqB,+EaoxBrB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBb50BmB,yDag1BrB,kBb11BqB,2Bag2BrB,iBACE,gBACA,cAGF,aACE,kBAGF,kBbz2BqB,ca22BnB,oBAEA,ab/1BqB,oBam2BrB,abp2BgB,yBaw2BhB,0BACE,CADF,uBACE,CADF,kBACE,kDAKA,sBACA,cACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,ab13Bc,ea43BZ,0DAEA,ab93BY,0Bag4BV,sDAIJ,oBACE,cbt4Bc,sMay4Bd,yBAGE,0BAKN,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cbz5Bc,aa25Bd,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,abp7Bc,qBa27BpB,oBACE,kBACA,eACA,iBACA,gBACA,mBb58BmB,gBa88BnB,iBACA,qBAGF,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,ab39BoB,uBa69BlB,CACA,WACA,CADA,+BACA,sBACA,cACA,oBACA,mBACA,cACA,WACA,0CAEA,UXp/BM,4BFWa,qCIYjB,yDADF,cSq+BE,sBAGF,Ub//BM,gCaigCJ,sDAEA,UbngCI,4BAYa,mDa+/BrB,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAIJ,uBACE,2BACA,gDAGF,ab3gCsB,6Ba6gCpB,uDAGF,ab7hC0B,yDaiiC1B,aACE,YAGF,aACE,cb5hCgB,6Ba8hChB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,oBAGF,gBACE,qEAGF,4BACE,gCAGF,eACE,kBACA,MACA,QACA,YACA,kBACA,YAEA,mBACA,yBACA,eACA,aAEA,wCAEA,UXvhCsB,mBWyhCpB,aACA,sBACA,mBACA,uBACA,mBACA,8BACA,wBACA,gCACA,uCAGF,wBACE,kBACA,WACA,YACA,eACA,cb7lCgB,yBa+lChB,aACA,uBACA,mBACA,sCAGF,mBACE,6CAEA,8BACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,oBAGF,abxnCkB,ea0nChB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,6BAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,YACE,SACA,QACA,WACA,YACA,mBACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WXnyCE,gBWqyCF,eACA,+LAMA,yBACE,mEAKF,yBACE,iBAMR,aACE,iBACA,mEAGF,abjzCoB,qBaqzClB,mBACA,gBACA,sBACA,gBAGF,aACE,iBACA,uBAGF,eACE,8BAGF,abp0CoB,eas0ClB,cACA,gBACA,gBACA,uBAGF,qBACE,sBAGF,WACE,8BAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA3BF,qBAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,iBAIJ,0DACE,CADF,kDACE,cAGF,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBbn6CmB,kCaq6CnB,uBAGF,MACE,aACA,mBACA,uBACA,cb95CqB,eag6CrB,gBACA,0BACA,kBACA,qCAGF,SACE,oBACA,CADA,WACA,cAGF,wBb/6CqB,Wai7CnB,kBACA,MACA,OACA,aACA,qBAGF,iBACE,aAGF,iBACE,cACA,aACA,WACA,yBbh8CmB,kBak8CnB,cACA,UACA,WACA,eAGF,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBbl+CsB,kBao+CtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cb/+Cc,kBai/Cd,+BAGF,abp/CgB,eas/Cd,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UX3gDE,qBW6gDA,oHAEA,yBAGE,yCAKN,QACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UXviDI,oBWgjDN,kBACA,cACA,2BAGF,eACE,UAGF,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cbrjDmB,gBaujDnB,gBAEA,abpkDsB,0BaskDpB,sBAEA,oBACE,gBAIJ,qBACE,4BAKN,GACE,cACA,eACA,WARI,mBAKN,GACE,cACA,eACA,2CC5lDF,u+KACE,uCAEA,u+KACE,CAOA,8MAMF,okBACE,UClBJ,YACE,gCACA,cACA,qBACA,iCAEA,aACE,cACA,cfOgB,gBeLhB,qBACA,eACA,gBAGF,WACE,UACA,yCAEA,8CAEA,WACE,iBACA,mBAKN,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,gBb0BwB,wBE9DtB,4BACA,kBWqCA,eACA,yBAEA,oBACE,sBACA,iBACA,4BX3CF,eWgDE,CACA,cACA,2DAJF,gBbesB,wBE9DtB,4BACA,CWgDE,iBAQE,CANF,+BXlDF,UWsDI,CACA,qBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WbjEE,6BamEF,gBACA,eACA,0BAKN,iBACE,WACqB,sCXrErB,+BANA,UW+EuB,sCXzEvB,gEWuEA,gBbhBsB,wBE9DtB,4BW0FE,CXnFF,iCANA,UWoFuB,sCX9EvB,kBWgFE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,2BAGF,aACE,mBACA,sBAGF,YACE,cf5FgB,6Be+FhB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,4BAGF,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,8BACA,eACA,oCACA,uCAEA,aACE,kCAGF,+BACE,gCAGF,aACE,yBACA,eACA,cf1JgB,kCe8JlB,aACE,eACA,gBACA,Wb9KI,CamLA,2NADF,eACE,gCAKN,afpLwB,oBeyL1B,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,wBAGF,gBACE,qBACA,eACA,cflMkB,eeoMlB,kBACA,4BAEA,afhNwB,6BeoNxB,aACE,gBACA,uBACA,iBAIJ,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aftOoB,eewOlB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SX7MF,sBACA,WACA,YACA,gBACA,oBACA,mBJxDmB,cAYD,eI+ClB,SACA,+EWuMI,aACE,CXxMN,qEWuMI,aACE,CXxMN,yEWuMI,aACE,CXxMN,0EWuMI,aACE,CXxMN,gEWuMI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,af7Qc,iBe+QZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,afvSgB,0He4ShB,cAEE,gBACA,cf9SY,kZeiTZ,aAGE,gEAIJ,wBACE,iDAGF,eb1UI,kBEkEN,CAEA,eACA,cJhDiB,uCIkDjB,UWqQI,mBfzUoB,oDIsExB,wBACE,cJrDe,eIuDf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WJ3FI,sDeiVJ,WACE,mDAGF,UfrVI,kBeuVF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UbvWQ,kBayWN,cACA,mBACA,sBb5WM,yBa8WN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,6BAIJ,YACE,eACA,gBACA,wBAGF,WACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cfrZgB,eeuZhB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,af5ZmB,qWe+ZjB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,sBAQR,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cfldc,Ceqdd,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,2BAIJ,af/eoB,eeiflB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WbhnBA,gBaknBA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cf5mBU,gBe8mBV,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,Wb7oBE,gDaipBJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,ab5pBU,yBakqBd,cACE,gCAEA,cACE,cf1pBc,ee4pBd,kCAEA,oBACE,cf/pBY,qBeiqBZ,iBACA,gBACA,yCAEA,eACE,WbnrBF,ScFR,YACE,gCACA,8BAEA,aACE,cACA,WdJI,qBcMJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,mCCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,EDGF,0BCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,qCAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,EAtBA,2BAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,gCAIJ,cACE,kBAGF,iBACE,cACA,eACA,iBACA,qBACA,gBACA,iBACA,gBACA,wBAEA,SACE,4BAGF,UACE,YACA,gBACA,sBAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,qEAGF,kBACE,qBACA,sGAEA,eACE,qEAIJ,eAEE,qJAEA,kBAEE,mXAGF,eACE,mBACA,qJAGF,eACE,gBACA,2EAGF,eACE,+NAGF,eACE,2FAGF,iBACE,8BACA,cjBjGc,mBiBmGd,qHAEA,eACE,2JAIJ,eACE,mJAGF,iBACE,6EAGF,iBACE,eACA,6EAGF,iBACE,qBACA,qJAGF,eACE,6JAEA,QACE,2EAIJ,oBACE,2EAGF,uBACE,oBAIJ,af9Ic,qBegJZ,0BAEA,yBACE,8BAEA,aACE,kCAKF,oBACE,uCAEA,yBACE,wBAKN,ajBlKc,4CiBuKhB,YACE,8EAEA,aACE,mCAIJ,aACE,oDAEA,af5LQ,ee8LN,iDAIJ,kBACE,uDAEA,kBACE,qBACA,gCAKN,oBACE,kBACA,mBACA,YACA,WjBrNM,gBiBuNN,eACA,cACA,yBACA,oBACA,eACA,sBACA,sCAEA,kBACE,qBACA,+DAGF,oBACE,iBACA,sBACA,kBACA,eACA,oBACA,2GAKF,oBAGE,4BAIJ,ajBvOkB,SiByOhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,mDAGF,iBAEE,gCAGA,qEAEA,eACE,kBAKF,SACE,mBACA,kDAEA,kBACE,wDAEA,sBACE,iFAIJ,kBAEE,SAKN,iBACE,kBACA,YACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QAPF,kBAUI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,ajBpTiB,CArBb,uEiBkVF,UjBlVE,kCiBsVF,ajBjUe,gCiBsUjB,UjB3VI,kCiB8VF,ajBzVoB,gEiB6VpB,UfjWE,mBFEgB,sEiBmWhB,kBACE,mBAMR,uBACE,sBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,yCAEA,aACE,kBACA,OACA,QACA,MACA,SACA,6FACA,oBACA,WACA,2DAGF,oBACE,oCAGF,WACE,gBACA,uBACA,cACA,0CAEA,UACE,kBACA,MACA,gBACA,6DACA,oBACA,4CAGF,oBACE,gDAGJ,oDACE,mEAEF,oDACE,0CAGF,eACE,6DAGF,kBACE,gCAIJ,mBACE,+CAKF,sBACE,qEAEA,aACE,wBAKN,oBACE,YACA,CjBvagB,ciByahB,iBACA,mBACA,CACA,sBACA,8CANA,ajBvagB,CiB2ahB,eAOA,8CAGF,aACE,eACA,eAGF,YACE,8BACA,eACA,oBAEA,sBACE,gBACA,2CAGF,oBACE,sBAIJ,YACE,mBACA,WACA,cjBzcoB,iIiB4cpB,gBAGE,kBACA,0EAGF,yBACE,yEAMA,0CACE,CADF,kCACE,2EAKF,2CACE,CADF,mCACE,wBAKN,YACE,mBACA,2BACA,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,mBACA,iBACA,cjBvhBgB,CiByhBhB,iBACA,eACA,kBACA,+CAEA,ajB9hBgB,uBiBkiBhB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cjBxjBgB,4BiB8jBtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cjBpnBgB,eiBsnBhB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,6JAGF,oBAME,4DAKA,UfzqBM,kBe+qBN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,ajB7rBuB,ciB+rBrB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,Wf3tBI,kCeguBR,UACE,kBACA,iBAGF,SACE,kBACA,YACA,WACA,CjB3tBgB,8IiBsuBhB,ajBtuBgB,wBiB0uBhB,UACE,wCAGF,kBf9tBsB,WF/BhB,8CiBiwBJ,kBACE,qBACA,+DAOJ,yBACE,cAIJ,YACE,eACA,yBACA,kBACA,cjBpwBgB,gBiBswBhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cjB/xBiB,uBiBiyBjB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UfvzBE,yBe8zBJ,cACE,kBACA,YACA,+DAGF,aACE,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACE,YACA,SACA,2BAIF,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cjBx1BkB,gBiB01BlB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,ajBt2BoB,oBiB02BpB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,kBAGF,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cjB97Bc,iBiBg8Bd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cjB59BY,gBiB89BZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,ajB/+Bc,oCiBq/BlB,cACE,cACA,SACA,uBACA,UACA,kBACA,oBACA,oFAEA,yBAEE,6BChhCJ,kBACE,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,6CAGF,kBlBrC0B,WAJlB,kBkB8CN,gBACA,aACA,sBACA,0BAGF,WACE,WACA,gBACA,iBACA,8DAEA,UACE,YACA,sBACA,aACA,sBACA,mBACA,uBACA,aACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAIJ,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,qCAGF,kBACE,UACE,YACA,gBACA,0BAGF,UACE,YACA,eACA,gBACA,cACA,oDAIJ,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,alB9GmB,SkBiHjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,UlBvJI,qwDkB2JF,aAGE,sBAMR,sBACE,yBAGF,aACE,aACA,mBACA,uBACA,wBAGF,UACE,YACA,mBACA,mBACA,aACA,eACA,8BAEA,kBACE,+BAGF,cACE,mBACA,kCAIJ,mBACE,CACA,mBACA,0EAEA,mBACE,yBAIJ,cACE,iBACA,4BAEA,cACE,gBACA,WlBjNI,mBkBmNJ,2BAGF,alBjNwB,kGkBoNtB,aAGE,2CAIJ,aACE,2BAGF,cACE,clBhNiB,gBkBkNjB,mBACA,sCAEA,eACE,kCAGF,eACE,mBlB7Oe,cAcE,kBkBkOjB,eACA,gBACA,CAII,2NADF,eACE,oCAOV,WACE,UACA,mCAME,mBACA,mBACA,sCAEA,cACE,iBACA,kBACA,qCAGF,eACE,oCAIJ,kBACE,mBACA,kBACA,eAIJ,iBACE,eACA,mBACA,sBAEA,eACE,WlBnSI,kBkBqSJ,yBACA,eACA,qBAGF,kBlBxSmB,cAcE,gBkB6RnB,aACA,kBACA,6HAQF,eACE,qJAGF,kBACE,clB5SmB,mBkB8SnB,kBACA,aACA,kBACA,eACA,sCACA,yPAEA,iBACE,mBACA,qNAGF,mBACE,gBACA,4CAMJ,YACE,mBACA,gDAEA,UACE,cACA,4DAEA,aACE,2DAGF,cACE,kDAGF,iBACE,uDAIJ,eACE,sDAIJ,UhB3WM,2DgBgXR,0BACE,cACE,iBACA,qJAGF,cAIE,mBACA,4CAGF,kBACE,sDAGF,WACE,eACA,mBAIJ,oBACE,eACA,gBACA,iBACA,uHAGF,kBAOE,WlBvZM,kBkByZN,gBACA,eACA,YACA,kBACA,sBACA,+SAEA,alBjZgB,YkBmZd,eACA,WACA,eACA,gBACA,uSAGF,YACE,uPAGF,WACE,WACA,+WAGF,UACE,wBAKF,ehBvbM,CFGkB,gBkBubtB,oBACA,iEhB3bI,2BFGkB,qDkBgc1B,iBAEE,aACA,qEAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,kKAIJ,YAKE,8BACA,mBlBjdwB,akBmdxB,iBACA,0LAEA,aACE,iBACA,clBvciB,mBkBycjB,kNAGF,aACE,6DAIJ,cAEE,yDAGF,WAEE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,alBvhBwB,qCkB2hBxB,oDAZF,eAaI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UlB1jBI,gBECA,agB4jBJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,ehB5kBI,yBgB8kBF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UlB7lBE,oBkB+lBA,eACA,gBhB/lBA,+CgBomBJ,YACE,8BACA,mBACA,4CAIJ,aACE,WlB7mBI,ekB+mBJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UlBxnBI,ekB0nBF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eAWE,eACA,wBAXA,eACE,iBACA,uBAGF,aACE,gBACA,2CAMF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UlBzqBE,akB2qBA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBlB9qBW,WEXb,iJgBgsBA,iBAGE,oMAUR,aACE,iIAIJ,4BAIE,clBlsBmB,ekBosBnB,gBACA,6cAEA,aAGE,6BACA,uCAIJ,iBACE,mBACA,oBACA,eAEA,yFAEA,qBACE,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UlBnxBI,CkBqxBF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gDACA,mBlB5zBe,WEDb,egBg0BF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,cAKN,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBhB12BM,yDgB62BN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBhBr3BI,uBgBy3BN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UhBt5BI,egBw5BF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,wBAKN,gBACE,sCAEA,eACE,gCAGF,eACE,qDAGF,UlB57BM,iDkBg8BN,YACE,0DAEA,YACE,0BAIJ,YACE,iBACA,uBACA,kDAGF,alB57BoB,qBkB87BlB,wDAEA,yBACE,WCp9BN,YACE,oBAGF,cACE,uBACA,eACA,gBACA,cnBcmB,4CmBXnB,ajBNY,sCiBWd,2CACE,oBAGF,QACE,wBACA,UACA,+CAEA,WACE,mBACA,UACA,0BAGF,aACE,sBACA,SACA,YACA,kBACA,aACA,WACA,UACA,WnBtCI,gBECA,eiBwCJ,oBACA,gBACA,qDAEA,anB9Bc,CmB4Bd,2CAEA,anB9Bc,CmB4Bd,+CAEA,anB9Bc,CmB4Bd,gDAEA,anB9Bc,CmB4Bd,sCAEA,anB9Bc,gCmBkCd,8CfpCA,uCADF,cesC4D,0CfjC5D,ceiC4D,oBAI9D,UnBtDQ,mBmBwDN,mBnBrDsB,oCmBuDtB,iBACA,kBACA,eACA,gBACA,sBAEA,anB3CmB,gBmB6CjB,0BACA,mFAEA,oBAEU,iCAKZ,mBACA,eAEA,gBACA,wCAEA,anB7EwB,sDmBiFxB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,gBACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBnBnGsB,qCmB0G1B,eACE,kBACA,aACA,mBnB/GsB,gBmBiHtB,gBACA,cACA,yBAEA,iBACE,gBACA,wCAEA,UnB5HI,iCmB8HJ,WACE,iBACA,2BAIJ,iBACE,cACA,CACA,cACA,iBACA,WnBzII,qBmB2IJ,gBACA,iBACA,qBACA,mBACA,gBACA,gGAEA,kBACE,qBACA,iIAEA,eACE,kJAIJ,eACE,mBACA,2DAGF,eACE,eACA,8BAGF,cACE,wFAGF,eACE,sCAGF,iBACE,2BACA,WnB/KE,mBmBiLF,mDAEA,eACE,8DAIJ,eACE,0DAGF,iBACE,+BAGF,iBACE,eACA,2DAGF,eACE,+DAEA,QACE,8BAIJ,oBACE,8BAGF,uBACE,6BAGF,anBhMiB,qBmBkMf,mCAEA,oEAGE,oBACE,gDAEA,qDAMR,UACE,YACA,gBACA,wBAIJ,iBACE,UACA,QACA,gHAEA,+BAEE,uDAIJ,iBAEE,WACA,mIAGE,aACE,sBACA,SACA,YACA,0BACA,yBACA,WACA,iBACA,UACA,WnBtQE,gBECA,eiBwQF,oBACA,YACA,qBACA,yLAEA,anB/PY,CmB6PZ,sKAEA,anB/PY,CmB6PZ,8KAEA,anB/PY,CmB6PZ,gLAEA,anB/PY,CmB6PZ,4JAEA,anB/PY,yKmBmQZ,SACE,qJAGF,kBnBlRoB,+ImBmRpB,8Cf1QF,8JADF,ce4Q8D,kKfvQ9D,ceuQ8D,qCfhQ5D,8TADF,sBeoQM,gBACA,6BAMR,aACE,kBACA,SACA,UACA,WACA,gBACA,2CAEA,aACE,mBACA,WACA,YACA,cnBzRiB,emB2RjB,iBACA,kBACA,WACA,4CAIJ,iBACE,SACA,oCAGF,aACE,kBACA,sBACA,SACA,0BACA,YACA,WACA,WnBnUM,mBAIkB,sCmBkUxB,eACA,WACA,aACA,6CAGF,aACE,0CAGF,YACE,eACA,kBACA,iMAEA,kBAGa,iKAEb,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,+DAGF,6BACE,qEAEA,aACE,gBACA,uBACA,mBACA,sEAGF,eACE,qEAGF,aACE,iBACA,gBACA,uBACA,mBACA,4EAMA,anBzWe,wBmB8WrB,eACE,iCAEA,YACE,mBACA,eACA,oBACA,YACA,gBACA,8BAIJ,UACE,WACA,cACA,kCAEA,iBACE,kBACA,aACA,WACA,sBjBzZI,wBiB2ZJ,sBACA,4BACA,gBACA,2CAEA,aACE,kBACA,sBACA,SACA,OACA,SACA,SACA,aACA,WACA,cnBzZiB,gFmB2ZjB,eACA,oBACA,gBACA,UACA,UACA,4BACA,iDAEA,UjBlbE,sEiBobF,WACE,cnBtae,CEff,4DiBobF,WACE,cnBtae,CEff,gEiBobF,WACE,cnBtae,CEff,iEiBobF,WACE,cnBtae,CEff,uDiBobF,WACE,cnBtae,yCmB2anB,2EAKE,0CAKN,iFACE,aACA,uBACA,8BACA,UACA,4BACA,8CAEA,aACE,cnB1csB,emB4ctB,gBACA,aACA,oBACA,2JAEA,aAGE,wCAIJ,SACE,kCAIJ,YACE,aACA,cnBrdkB,gBmBudlB,sCAEA,cACE,kBACA,2CAGF,aACE,gDAEA,aACE,eACA,gBACA,yBACA,qDAGF,iBACE,eACA,kBACA,WACA,WACA,mBjB5dkB,8DiB+dlB,iBACE,MACA,OACA,WACA,kBACA,mBnBvfa,0BmB8frB,UnB1gBQ,oBmB4gBN,eACA,gBjB5gBM,4BiBghBR,YACE,mBACA,0BACA,YACA,aACA,8BACA,cACA,oBAGF,YACE,cACA,sBAEA,oBACE,uBACA,cACA,YACA,iBACA,sBACA,uBAGF,oBACE,aACA,CAEA,oBACA,CADA,6BACA,UACA,QACA,YACA,uBACA,2BAIJ,iBACE,iBACA,0CAKE,yBACE,qCACA,WjB7jBE,mBFWa,gBmBqjBf,8CAGA,yBACE,oCACA,uCAMR,iBACE,kBACA,uCACA,gBjB9kBM,gBiBglBN,uBACA,6CAGF,YACE,mBACA,aACA,WnBxlBM,emB0lBN,sDAEA,aACE,cnBxkBiB,wEmB2kBjB,6EAEA,aACE,WnBnmBE,gBmBqmBF,sGAIJ,kBnB7lBmB,WEXb,6PiBgnBF,UjBhnBE,0DiBonBN,wCAGF,gBACE,iBACA,mBACA,gBACA,yBACA,cACA,+BAEA,oBACE,SACA,eACA,kBACA,gCAGF,oBACE,aACA,UACA,WACA,kBACA,kCAIA,ajB5oBU,CkBFZ,+BAHF,YACE,cACA,kBAUA,CATA,cAKA,kBACA,2BACA,gBAEA,uBAEA,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,wHAMJ,WAGE,mCAIJ,YACE,mBACA,uBACA,YACA,CpBrFmB,IoBoGrB,aACE,aACA,sBACA,WACA,YACA,CAIA,oBAGF,qBACE,WACA,mBACA,cpBhHwB,eoBkHxB,cACA,eACA,SACA,iBACA,aACA,SACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cpBnIwB,eoBqIxB,cACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,cAGF,kBACE,WlB5KM,ckB8KN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cpB3LsB,kGoB8LtB,sBAGE,WlBpME,kCkBwMJ,apB7LiB,oBoBmMrB,oBACE,iBACA,oBAGF,kBpBlNqB,cAaH,iBoBwMhB,eACA,gBACA,yBACA,eACA,yBAGF,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,gFACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,uBAEA,QACE,YACA,aACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,apBxQwB,uBoB4QxB,qCACE,4CAEA,apB/QsB,wCoBiRpB,4CAIJ,SAEE,SAIJ,WACE,kBACA,sBACA,aACA,sBACA,gBACA,wDAEA,SACE,gBACA,gBACA,qBAGF,kBpB5SmB,yBoBiTrB,WACE,aACA,cACA,uBAGF,kBACE,iCAGF,iBACE,sEAGF,kBACE,SACA,cpBrTkB,eoBuTlB,eACA,eACA,kFAEA,aACE,CAKA,kLAEA,UlBhVI,mBkBkVF,kFAKJ,2BACE,wCAIJ,YACE,oBACA,6BACA,+CAEA,sBAEE,kBACA,eACA,qBACA,0CAGF,eACE,gDAKJ,SACE,6BAGF,eACE,gBACA,gBACA,cpBzWkB,0DoB2WlB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,iCAIF,eACE,2CACA,YACE,WACA,mCAKN,kBACE,aACA,mCAIA,apB/YkB,0BoBiZhB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,uBAKN,oBACE,uBACA,gBACA,mBACA,OACA,sBAGF,oBACE,iBACA,uCAGF,apB7akB,mBAbG,kBoB8bnB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBACA,sCAbF,cAcI,kDAGF,eACE,2CAGF,apB5cwB,qBoB8ctB,uDAEA,yBACE,eAKN,qBACE,uCAKA,sBACE,6BACA,qCASF,qCAXA,sBACE,6BACA,sCAgBF,mJAFF,qBAGI,sBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,kBACA,uCAEA,SACE,kCAKN,aACE,aACA,yBChhBJ,iBACE,eACA,gBACA,crBagB,mBAbG,eqBGnB,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,crBjBY,qCqBqBd,cACE,gBACA,kBCtCJ,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,mBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WpB1EF,gBoB4EE,gBACA,uBACA,0CAGF,aACE,eACA,ctBtEU,gBsBwEV,gBACA,uBACA,yBAKN,kBtB3FiB,asB6Ff,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,cAOV,kBtBjIqB,sBsBoInB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,CC/KJ,eAGF,SnBkDE,sBACA,WACA,YACA,gBACA,oBACA,mBJxDmB,cAYD,eI+ClB,SACA,cmBxDA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,4CACA,eACA,WACA,YACA,cvBxCmB,euB0CnB,oBACA,0BAEA,mBACE,WACA,0BAIJ,sBACE,iCAEA,mBACE,WACA,gCAIJ,QACE,uBACA,cvB5DkB,euB8DlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,avB7EkB,mBuB+EhB,gCACA,kBACA,eACA,gBACA,uBAGF,YACE,cvBxFkB,kBuB0FlB,iBAIA,avB7FgB,mBuB+Fd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cvBvHY,gBuByHZ,uBACA,mBACA,4BAEA,eACE,uBAGF,avBlIc,qBuBoIZ,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cvB3JiB,0BuB+JnB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,WC1LR,yCCCE,qBACA,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,8BAIJ,evBXQ,kBuBaN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBvBvCM,kBuByCN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,eAIJ,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,0EAMA,SACE,oBACA,CADA,WACA,eCpGN,WAEE,0BAGF,kBANW,kBAQT,cACA,iCACA,wBACE,mCAOF,WACE,SACA,UACA,2CAGF,aACE,aAEA,sBACA,YACA,6BACA,6DAGE,oBACE,WACA,iBACA,iBACA,iJAGF,UACE,gEAEF,oBACE,gBACA,WACA,2CAKN,yBACE,sBACA,kBACA,YACA,gBACA,kDAEA,uBACE,CADF,oBACE,CADF,eACE,WACA,YACA,SACA,4BACA,WACA,yBACA,eACA,4CACA,sBACA,oBACA,6DAEA,uBACE,6DAGF,sBACE,wEAGF,sBACE,kBACA,SCjFR,WACE,sBACA,aACA,sBACA,kBACA,iBACA,UACA,qBAEA,iBACE,oBAGF,kBACE,2DvBDF,SuBI0D,yBvBC1D,SuBD0D,qCvBQxD,qLuBLA,yBAGF,eACE,gBACA,eACA,qCvBZA,4BuBgBA,SACE,WACA,YACA,eACA,UACA,+BALF,SACE,WACA,YACA,eACA,UACA,yCAIJ,4BAGF,YACE,mBACA,mBACA,UACA,mBACA,eACA,mBAEA,aACE,sBACA,oCACA,sBACA,YACA,cACA,c3BzCgB,kB2B2ChB,qBACA,eACA,mBAGF,iCACE,iDAEA,YAEE,mBACA,mCACA,SAKN,iBACE,mBACA,UACA,qCvBrDE,6CADF,euBwDkF,sCvBlEhF,sBADF,cuBoE0D,yBvB/D1D,cuB+D0D,gBAG5D,ezBlFQ,kBEkEN,CACA,sBACA,gBACA,cJhDiB,uCIkDjB,mBAEA,wBACE,cJrDe,eIuDf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WJ3FI,kB2BuFR,YACE,c3B1EkB,a2B4ElB,mBACA,oBAEA,aACE,qBACA,wBAGF,aACE,c3BnFmB,gB2BqFnB,mBACA,gBACA,uBACA,0BAIJ,aACE,gBACA,gBACA,kBAGF,kB3BhHqB,kB2BkHnB,gBACA,yBAEA,a3BxGgB,mB2B0Gd,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,c3BhIY,iC2BmIZ,oBACE,iBACA,8FAIJ,eAEE,mCAGF,aACE,aACA,c3B/IiB,qB2BiJjB,0HAEA,aAGE,0BACA,gBAQN,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAgBA,CAfA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,uBAEA,kB3BlMmB,0B2BuMrB,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oCAGF,aACE,WACA,YACA,YACA,eACA,sCAGF,yBAzBF,aA0BI,iBAIJ,kBACE,eACA,gBACA,mBAGF,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,oBCrPF,kBACE,gB1BAM,WACA,e0BEN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,e1BdQ,cFcY,S4BGlB,WACA,YACA,iEAEA,aAGE,iCAGF,eACE,2BxBcF,iBACE,mBACA,cACA,eACA,aACA,gBACA,yBwBfJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,6BAGF,aACE,kBACA,W1B7CM,0B0B+CN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,WACE,aACA,sBACA,4BAEA,iBACE,c5BpEgB,a4BsEhB,YACA,mBACA,CAGE,yDAIJ,UACE,gBAIJ,qBACE,eACA,gBACA,kBACA,kBACA,WACA,aACA,2BxB/DA,iBACE,mBACA,cACA,eACA,aACA,gBACA,sBwB8DJ,WACE,sBACA,cACA,WACA,kBACA,kBACA,gBACA,kCAEA,eACE,qEAIA,cACE,MACA,gCAIJ,e1BlIM,gC0BuIR,cACE,cACA,qBACA,c5B1HqB,kB4B4HrB,UACA,mEAEA,WAEE,WACA,sBACA,CADA,gCACA,CADA,kBACA,CAIE,0HAFF,WACE,oBACA,CADA,8BACA,CADA,gB1BtJE,C0BuJF,wBAKN,UACE,CAEA,iBACA,MACA,OACA,UACA,gB1BnKM,iC0BsKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,aACA,uBACA,qCAGF,cACE,YACA,WACA,kBACA,UACA,sBACA,CADA,gCACA,CADA,kBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,qDAEA,WACE,oBACA,CADA,8BACA,CADA,gBACA,sCAIJ,0BACE,2BACA,gBACA,kBACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,c1B3K0B,eAEC,C0BqL7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,W1BvQM,e0ByQN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,c5BlTsB,mF4BqTtB,yBAGE,wBAKN,oBACE,sBAGF,qB1BpUQ,Y0BsUN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wB5BpUqB,qB4BwUrB,iBACE,UACA,QACA,YACA,qKAKA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gB1BzZM,e0B2ZN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,gCAGF,UACE,YACA,0BxBjYF,iBACE,mBACA,cACA,eACA,aACA,gBACA,qBwBgYF,eACE,gBACA,UACA,kBACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gB1B9eI,cFcY,gB4BmehB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,CAGE,gUAEA,aAIE,wBAKN,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,U1BpjBE,+E0B4jBN,cAGE,gBACA,6BAGF,U1BnkBM,iB0BqkBJ,yBAGF,oBACE,aACA,mDAGF,U1B7kBM,uB0BklBN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,W1BloBE,sF0BqoBF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,iBC5sBR,YACE,mBACA,mBACA,kBACA,QACA,SACA,YACA,mBAGF,YACE,kBACA,gBACA,yBACA,0BACA,eACA,iBACA,yBACA,WACA,4BACA,wCAEA,uBCtBF,kB9BGqB,sB8BDnB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kB9BnDqB,sB8BqDnB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,aC3FJ,cAOE,qBACA,W/BPM,gD+BEJ,iBACA,+BAOF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mB/BjBsB,4B+BqBxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,c/BfmB,c+BiBnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,a/BlD0B,mC+BqDxB,aACE,oDAGF,QACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBACA,uBAIA,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gB7B5FM,sB6B8FN,sGAEA,+BAEE,oBAKF,2BACA,gB7BxGM,0B6B2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,W/BnHI,yB+BqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gB7BpKI,mB6ByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,c/BlKiB,mD+BqKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,wBC1MF,iBACE,aACA,mBACA,mBhCEwB,WAJlB,kBgCKN,YACA,WACA,gBACA,iBACA,gBACA,4DAEA,aACE,eACA,mFAGF,iBACE,kBACA,gBACA,+FAEA,iBACE,OACA,MACA,kCAIJ,aACE,chCTiB,2BgCanB,cACE,gBACA,iBACA,mBACA,2BAGF,cACE,gBACA,iBACA,gBACA,mBACA,0CAIJ,aACE,kBACA,cACA,mBACA,gCACA,eACA,qBACA,aACA,0BACA,4DAEA,aACE,iBACA,gDAGF,kBhC9DwB,iDgCkExB,kBhC1DmB,WEXb,qG8B0EN,kB9BxEU,WAFJ,oC8BgFR,kBACE,YACA,eACA,iBACA,gBACA,8BAGF,aACE,UACA,kBACA,YACA,gBACA,oCAGF,iBACE,4FAGF,eAEE,mBACA,qCAGF,mCACE,UACE,cACA,0CAGF,YACE,4DAEA,YACE,kBCtHN,U/BEQ,gC+BCN,oBAEA,cACE,iBACA,gBACA,kBACA,mBAGF,U/BVM,0B+BYJ,oBAGF,eACE,cACA,iBACA,mDAGF,UACE,YACA,gBACA,gCACA,gBC3BJ,WACE,gBACA,aACA,sBACA,yBACA,kBACA,+BAEA,gBACE,eACA,CACA,2BACA,kCAGF,QACE,iCAGF,aACE,6BAGF,sBACE,0BAGF,MACE,kBACA,aACA,sBACA,iBACA,mDAGF,eACE,sBhClCI,0BgCoCJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,YACE,gBACA,gLAEA,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,CAIA,yFAGF,eACE,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WhCjNM,kBgCmNN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,mCAGF,kBAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,QC1QJ,eACE,eACA,8BAEA,QAEE,gBACA,UAGF,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBnCXiB,amCgBnB,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAIA,qBACA,WACA,eACA,WnCjDE,cmCmDF,UACA,oBACA,gBjCpDE,yBiCsDF,kBACA,iBACA,oCAEA,oBnCtDoB,wBmC2DtB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBjC7FY,8EiCkGZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,cnC1Hc,amC8HhB,cACE,uBACA,UACA,SACA,SACA,cnCnIc,0BmCqId,kBACA,mBAEA,oBACE,sCAGF,qCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,UAEE,aACA,+GAEA,oBnCtLoB,sDmC4LxB,cACE,gBACA,iBACA,YACA,oBACA,cnCpLkB,sCmCuLlB,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WnC1NI,qBmC4NJ,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,anC7NkB,qBmCgOhB,+BACE,6BAEA,6BACE,YC/ON,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,sBACE,eACA,gBACA,gBACA,qBACA,cpCPkB,oBoCUlB,apCnBwB,0BoCqBtB,6EAEA,oBAGE,wCAIJ,apCrBkB,oBoC0BlB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cpClCmB,qBoCsCrB,iBACE,cpCvCmB,uBoC2CrB,eACE,mBACA,kBACA,kBACA,yHAGF,sBAME,mBACA,oBACA,gBACA,cpC3DmB,qBoC+DrB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cpCxIc,iCoC4IhB,uBACE,gBACA,gBACA,cpC9IY,qDoCkJd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,uBACA,eACA,iBACA,WlCpNI,iBkCsNJ,kBACA,qEAEA,aAEE,6CAIA,apChNiB,oCoCqNnB,sBACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,sBACE,eACA,iBACA,gBACA,cpClPc,mBoCoPd,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAIE,UACqB,sChCnRzB,CgCoRI,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iBlCpOgB,wBE9DtB,4BACA,iCgCsSE,cACE,mCAEA,aACE,WlC3SA,qBkC6SA,uDAGE,yBACE,2CAKN,aACE,cpC1SY,kCoCkTlB,sBAEE,CACA,eACA,eACA,iBACA,mBACA,cpCzTgB,sCoC4ThB,apCrUsB,0BoCuUpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,sBACE,eACA,iBACA,gBACA,mBACA,cpCjVmB,wBoCoVnB,sBACE,cACA,eACA,gBACA,cACA,kBAKF,cACA,iBpC/VmB,mCoC6VrB,sBACE,CAEA,eACA,mBACA,cpClWmB,kBoCuWnB,cACA,iBpCxWmB,kBoCgXnB,cpChXmB,mCoC+WrB,sBACE,CACA,gBACA,gBACA,mBACA,cpCpXmB,kBoCyXnB,cpCzXmB,kBoCiYrB,sBACE,eACA,iBACA,gBACA,mBACA,cpCtYmB,mCoC0YrB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,4CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBpCpcmB,kBoCscjB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAMA,UACqB,sChC9hB3B,mDgCiiBI,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA9DF,iBA+DI,mFAIJ,qBAGE,mBpC9jBiB,kBoCgkBjB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,apChlBmB,qCoColBnB,eACE,WlCpmBE,gBkCsmBF,2CAEA,apC3lBc,gDoC8lBZ,apC5lBe,+CoCkmBnB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SlC1rBI,YkC4rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,cpCvsBc,6BoC2sBhB,eACE,iBACA,+BAGF,kBpC5tBiB,aoC8tBf,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,sBACE,eACA,gBACA,cACA,qCAGF,cACE,cpCtvBY,uFoC4vBlB,eACE,cASA,CpCtwBgB,2CoCmwBhB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cpC92BsB,qBoCg3BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cpC12Bc,SqChBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBrCxBmB,UqC6BnB,arC1BwB,0BqC4BtB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBrCjEiB,6BqCmEf,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+BACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,crC/FkB,gBqCiGlB,0DAEA,UnChHM,wDmCoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBrC/JiB,sBqCiKjB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBrC9KiB,gCqCiLjB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBrCtMiB,uCqCyMf,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,crClOY,gBqCoOZ,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,sCAEA,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBCzRN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBtCfe,YsCiBf,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SpCxCA,YoC0CE,kBACA,YACA,uCAIJ,aACE,ctCpCY,qBsCsCZ,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,ctC9EY,qBsCgFZ,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UpCzGA,yBoC2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UpCjIE,yBFWa,gBsCyHb,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,atCrMmB,esCuMjB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,atChNmB,esCkNjB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,ctC7Nc,mBsC+Nd,kBACA,gCACA,4BAGF,cACE,ctCnOiB,iBsCqOjB,gBACA,0CAGF,UpCxPI,gBoC0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WpCxQE,oBoC0QF,iBACA,gBACA,mBACA,2BAGF,cACE,iBACA,ctCnQiB,mBsCqQjB,kCAEA,UpCtRE,gBoCwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,4CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BtCxUe,YsC+UrB,UACE,SACA,cACA,WACA,sDAKA,atCtVkB,0DsCyVhB,atClWsB,4DsCuWxB,apC1Wc,gBoC4WZ,4DAGF,apC9WU,gBoCgXR,0DAGF,atCvWgB,gBsCyWd,0DAGF,apCtXU,gBoCwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,2BAKN,oBACE,ctCtZc,qBsCwZd,yBACA,eACA,gBACA,gCACA,iCAEA,UpC3aE,gCoC6aA,oCAGF,atC5aoB,gCsC8alB,iBAMR,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,ctC1cmB,CsC+cf,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,atCpiBwB,qBsCsiBtB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBtC7jBiB,cAYD,0BsCojBhB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,atC5kBgB,oBsCglBhB,kBACE,0BACA,aACA,ctCplBgB,gCsCslBhB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,ctC7lBc,2BsCimBhB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBpCtnBY,oCoC0nBZ,kBACE,mCAGF,kBtCpnBiB,sDsCynBnB,atCrnBqB,qBsCynBnB,gBACA,sBAGF,aACE,0BAGF,atCjoBqB,sBsCqoBrB,apCnpBc,yDoCwpBhB,oBAIE,ctC9oBqB,iGsCipBrB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBpCxsBc,yBoC4sBd,yBACE,wBAGF,yBpC7sBU,wBoCktBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,atCjtBgB,uBsCutBhB,wBACA,qBAGF,atC1tBgB,csC+tBlB,kBtC5uBqB,kBsC8uBnB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,ctCtvBc,yBsCwvBd,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,apCjxBM,6BoCwxBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,ctC3xBY,mLsC8xBZ,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,atCzyBU,iBsC2yBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,ctCtzBY,WsC6zBpB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,apCv3BY,8CoC43Bd,qBACE,aACA,WpC/3BI,coCo4BR,iBACE,kkECr4BF,kIACE,CADF,sIACE,uIAYA,aAEE,yIAGF,aAEE,qIAGF,aAEE,6IAGF,aAEE,UChCJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,+BAGF,eACE,2EAGF,UAEE,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,+BAGF,UACE,0BAGF,gBACE,eACA,UAGA,WACA,yCAGF,iBACE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,gBACA,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,kEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,sBChbJ,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WvCrCI,uBuCuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,czCpCgB,kByCsChB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,azCnEwB,gByCqEtB,qBACA,2GCrEM,SACE,CDoER,iGCrEM,SACE,CDoER,qGCrEM,SACE,CDoER,sGCrEM,SACE,CDoER,4FCrEM,SACE,mJAQZ,aAME,0BACA,mMAEA,oBACE,iOAGF,yBACE,CAKE,0zCAIJ,oBAGE,uUAGF,a1C3BqB,qB0C6BnB,oCAIJ,yBACE,6HAEA,oBAGE,4BAIJ,yBACE,qGAEA,oBAGE,eAIJ,a1CvDoB,yE0C2DpB,+BACE,0D","file":"skins/glitch/contrast/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#313543 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#313543;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#353a49}::-webkit-scrollbar-thumb:active{background:#313543}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#282c37}::-webkit-scrollbar-track:active{background:#282c37}::-webkit-scrollbar-corner{background:transparent}body{font-family:sans-serif,sans-serif;background:#191b22;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",sans-serif,sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#282c37}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.embed{background:#313543;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#1f232b;padding:0}body.error{position:absolute;text-align:center;color:#dde3ec;background:#282c37;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:sans-serif,sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;width:40px;height:40px;background-size:40px 40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}.account-header .name{flex:1 1 auto;color:#ecf0f4;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#42485a}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#dde3ec;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#4a5266;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#535b72}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#ecf0f4}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#0e1014}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#313543;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;width:120px;height:120px;background-size:120px 120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #313543;background:#17191f;border-radius:8%;background-position:50%;background-clip:padding-box}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#313543;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#dde3ec}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#dde3ec;padding:10px;border-right:1px solid #313543;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#ecf0f4}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:sans-serif,sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #42485a}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#dde3ec}.public-layout .public-account-header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#4e79df}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#dde3ec}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #393f4f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #393f4f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#282c37}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#313543}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.modal-layout{background:#282c37 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#737d99}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#737d99}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#dde3ec}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#737d99}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#737d99}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#7f88a2}.compact-header h1{font-size:24px;line-height:28px;color:#dde3ec;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#ecf0f4}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#282c37;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.hero-widget__text a{color:#ecf0f4;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#dde3ec}.box-widget{padding:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #c2cede;text-align:center;color:#dde3ec;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#dde3ec;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#ecf0f4;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#dde3ec}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#dde3ec;margin-bottom:10px}.page-header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#dde3ec}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#313543}.page-header h1{font-size:24px}}.directory{background:#282c37;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#282c37;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#393f4f}.directory__tag.active>a{background:#2b5fd9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#dde3ec}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#dde3ec}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b5fd9}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#17191f;border:2px solid #282c37}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#dde3ec;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #393f4f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#dde3ec;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #4a5266}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#dde3ec}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b5fd9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#1f232b;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #313543}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #313543}code{font-family:monospace,monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#dde3ec}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#0e1014}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#dde3ec}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #c2cede;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419;border:1px solid #0a0b0e;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#eaeef3}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#17191f}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b5fd9;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#416fdd}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#2454c7}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #0a0b0e;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#c2cede;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(19, 20, 25, 0), #131419)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(40,44,55,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#393f4f;color:#dde3ec;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#dde3ec;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:monospace,monospace;background:#282c37;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#313543}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#dde3ec;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#4ea2df}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#dde3ec}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#ecf0f4;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#ecf0f4;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#dde3ec}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:monospace,monospace}.input-copy{background:#131419;border:1px solid #0a0b0e;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:monospace,monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#1f232b;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#1f232b;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#393f4f}.card__img{height:130px;position:relative;background:#0e1014;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#313543;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box;background:#17191f;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#ecf0f4}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#1a1a1a}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#364861;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #42485a;border-bottom:1px solid #42485a;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #42485a}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#ecf0f4;background:rgba(23,25,31,.5)}.account__header__fields dd{flex:1 1 auto;color:#dde3ec}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#dde3ec}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#282c37}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#393f4f}.button.logo-button{flex:0 auto;font-size:14px;background:#2b5fd9;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#5680e1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{padding:8px 0;padding-bottom:2px;margin:initial;margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{position:absolute;margin:initial;float:initial;width:auto;left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}.embed .status .status__info,.public-layout .status .status__info{font-size:15px;display:initial}.embed .status .status__relative-time,.public-layout .status .status__relative-time{color:#c2cede;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.embed .status .status__info .status__display-name,.public-layout .status .status__info .status__display-name{display:block;max-width:100%;padding:6px 0;padding-right:25px;margin:initial}.embed .status .status__info .status__display-name .display-name strong,.public-layout .status .status__info .status__display-name .display-name strong{display:inline}.embed .status .status__avatar,.public-layout .status .status__avatar{height:48px;position:absolute;width:48px;margin:initial}.rtl .embed .status,.rtl .public-layout .status{padding-left:10px;padding-right:68px}.rtl .embed .status .status__info .status__display-name,.rtl .public-layout .status .status__info .status__display-name{padding-left:25px;padding-right:0}.rtl .embed .status .status__relative-time,.rtl .public-layout .status .status__relative-time{float:left}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#2b5fd9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#2558d0;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;transition-property:background-color;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#4976de;transition:all 200ms ease-out;transition-property:background-color}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled{background-color:#9baec8;cursor:default}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#606984}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#687390}.button.button-secondary{font-size:16px;line-height:36px;height:auto;color:#dde3ec;text-transform:none;background:transparent;padding:3px 15px;border-radius:4px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#eaeef3}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.icon-button{display:inline-block;padding:0;color:#8d9ac2;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#a4afce;background-color:rgba(141,154,194,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(141,154,194,.3)}.icon-button.disabled{color:#6274ab;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#1b1e25}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#0c0d11;background-color:rgba(27,30,37,.15)}.icon-button.inverted:focus{background-color:rgba(27,30,37,.3)}.icon-button.inverted.disabled{color:#2a2e3a;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#63ade3}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#1b1e25;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#0c0d11;background-color:rgba(27,30,37,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(27,30,37,.3)}.text-icon-button.disabled{color:#464d60;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute;transform-origin:50% 0}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.notification__favourite-icon-wrapper{left:0;position:absolute}.notification__favourite-icon-wrapper .fa.star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name a{color:inherit;text-decoration:inherit}.display-name strong{height:18px;font-size:16px;font-weight:500;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name span{display:block;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name>a:hover strong{text-decoration:underline}.display-name.inline{padding:0;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.display-name.inline strong{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name.inline span{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.dropdown-menu ul{list-style:none}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b5fd9;color:#ecf0f4;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b5fd9;color:#ecf0f4}.dropdown__icon{vertical-align:middle}.static-content{padding:10px;padding-top:20px;color:#c2cede}.static-content h1{font-size:16px;font-weight:500;margin-bottom:40px;text-align:center}.static-content p{font-size:13px;margin-bottom:20px}.column,.drawer{flex:1 1 100%;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#393f4f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #393f4f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.auto-columns .tabs-bar__link:hover,.auto-columns .tabs-bar__link:focus,.auto-columns .tabs-bar__link:active{background:#464d60;border-bottom-color:#464d60}}.multi-columns .tabs-bar__link:hover,.multi-columns .tabs-bar__link:focus,.multi-columns .tabs-bar__link:active{background:#464d60;border-bottom-color:#464d60}.tabs-bar__link.active{border-bottom:2px solid #2b5fd9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}.tabs-bar__link span.icon{margin-left:0;display:inline}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b5fd9;border:2px solid #393f4f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#17191f}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#282c37;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#131419}.react-toggle--checked .react-toggle-track{background-color:#2b5fd9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#5680e1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #282c37;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b5fd9}.getting-started__wrapper,.getting_started,.flex-spacer{background:#282c37}.getting-started__wrapper{position:relative;overflow-y:auto}.flex-spacer{flex:1 1 auto}.getting-started{background:#282c37;flex:1 0 auto}.getting-started p{color:#ecf0f4}.getting-started a{color:#c2cede}.getting-started__panel{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex:0 1 auto}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{color:#c2cede;font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#c2cede;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#dde3ec}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#dde3ec;padding:10px;font-weight:500;border-bottom:1px solid #393f4f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#dde3ec}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#282c37;padding:4px 8px;margin:-6px 10px}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#393f4f;border:1px solid #1f232b}.setting-text{color:#dde3ec;background:transparent;border:none;border-bottom:2px solid #9baec8;box-sizing:border-box;display:block;font-family:inherit;margin-bottom:10px;padding:7px 0;width:100%}.setting-text:focus,.setting-text:active{color:#fff;border-bottom-color:#2b5fd9}@media screen and (max-width: 600px){.auto-columns .setting-text,.single-column .setting-text{font-size:16px}}.setting-text.light{color:#000;border-bottom:2px solid #626c87}.setting-text.light:focus,.setting-text.light:active{color:#000;border-bottom-color:#2b5fd9}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#8d9ac2;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.reduce-motion button.icon-button.disabled i.fa-retweet{color:#6274ab}.load-more{display:block;color:#c2cede;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#2c313d}.load-gap{border-bottom:1px solid #393f4f}.missing-indicator{padding-top:68px}.scrollable>div>:first-child .notification__dismiss-overlay>.wrappy{border-top:1px solid #282c37}.notification__dismiss-overlay{overflow:hidden;position:absolute;top:0;right:0;bottom:-1px;padding-left:15px;z-index:999;align-items:center;justify-content:flex-end;cursor:pointer;display:flex}.notification__dismiss-overlay .wrappy{width:4rem;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#393f4f;border-left:1px solid #535b72;box-shadow:0 0 5px #000;border-bottom:1px solid #282c37}.notification__dismiss-overlay .ckbox{border:2px solid #9baec8;border-radius:2px;width:30px;height:30px;font-size:20px;color:#dde3ec;text-shadow:0 0 5px #000;display:flex;justify-content:center;align-items:center}.notification__dismiss-overlay:focus{outline:0 !important}.notification__dismiss-overlay:focus .ckbox{box-shadow:0 0 1px 1px #2b5fd9}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.loading-indicator{color:#c2cede;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #606984;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#606984}29%{background-color:#606984}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:flex;left:4px;top:4px;width:auto;height:auto;align-items:center}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.setting-toggle{display:block;line-height:24px}.setting-toggle__label,.setting-radio__label,.setting-meta__label{color:#dde3ec;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.setting-radio{display:block;line-height:18px}.setting-radio__label{margin-bottom:0}.column-settings__row legend{color:#dde3ec;cursor:default;display:block;font-weight:500;margin-top:10px}.setting-radio__input{vertical-align:middle}.setting-meta__label{float:right}@keyframes heartbeat{from{transform:scale(1);transform-origin:center center;animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.pulse-loading{animation:heartbeat 1.5s ease-in-out infinite both}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#282c37;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#ecf0f4;font-size:18px;font-weight:500;border:2px dashed #606984;border-radius:4px}.dropdown--active .emoji-button img{opacity:1;filter:none}.loading-bar{background-color:#2b5fd9;height:3px;position:absolute;top:0;left:0;z-index:9999}.icon-badge-wrapper{position:relative}.icon-badge{position:absolute;display:block;right:-0.25em;top:-0.25em;background-color:#2b5fd9;border-radius:50%;font-size:75%;width:1em;height:1em}.conversation{display:flex;border-bottom:1px solid #393f4f;padding:5px;padding-bottom:0}.conversation:focus{background:#2c313d;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#dde3ec;padding-left:15px}.conversation__content__names{color:#dde3ec;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content .status__content{margin:0}.conversation--unread{background:#2c313d}.conversation--unread:focus{background:#313543}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.ui .flash-message{margin-top:10px;margin-left:auto;margin-right:auto;margin-bottom:0;min-width:75%}::-webkit-scrollbar-thumb{border-radius:0}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#ecf0f4;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}noscript div a{word-break:break-word}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet,button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.status-direct button.icon-button.disabled i.fa-retweet,.status-direct button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.account{padding:10px;border-bottom:1px solid #393f4f;color:inherit;text-decoration:none}.account .account__display-name{flex:1 1 auto;display:block;color:#dde3ec;overflow:hidden;text-decoration:none;font-size:14px}.account.small{border:none;padding:0}.account.small>.account__avatar-wrapper{margin:0 8px 0 0}.account.small>.display-name{height:24px;line-height:24px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:8%;background-position:50%;background-clip:padding-box;position:relative;cursor:pointer}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:8%;background-position:50%;background-clip:padding-box;overflow:hidden;position:relative;cursor:default}.account__avatar-composite div{border-radius:8%;background-position:50%;background-clip:padding-box;float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}.account__avatar-overlay{position:relative;width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:8%;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:8%;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__header__wrapper{flex:0 0 auto;background:#313543}.account__disclaimer{padding:10px;color:#c2cede}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-left:1px solid #393f4f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab:first-child{border-left:0}.account__action-bar__tab.active{border-bottom:4px solid #2b5fd9}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#dde3ec}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account__action-bar__tab abbr{color:#2b90d9}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.notification__message{margin-left:42px;padding:8px 0 0 26px;cursor:default;color:#dde3ec;font-size:15px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account--panel{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#393f4f;padding:15px}.column-settings__section{color:#dde3ec;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#eaeef3}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#313543}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#c2cede;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#393f4f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#42485a;color:#eaeef3}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#dde3ec}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#c2cede}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#d0d9e5}.column-settings__hashtags .column-select__indicator-separator{background-color:#393f4f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#364861;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:none;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#1f232b;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#dde3ec;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#ecf0f4}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #393f4f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #282c37}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#242731;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #191b22}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f}.account__moved-note__message{position:relative;margin-left:58px;color:#c2cede;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.account__header__content{color:#dde3ec;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#1f232b}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#313543;padding:5px;border-bottom:1px solid #42485a}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#17191f;border:2px solid #313543}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #42485a;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #42485a}.account__header__bio .account__header__fields a{color:#4e79df}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#dde3ec;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.domain{padding:10px;border-bottom:1px solid #393f4f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}@keyframes spring-flip-in{0%{transform:rotate(0deg)}30%{transform:rotate(-242.4deg)}60%{transform:rotate(-158.35deg)}90%{transform:rotate(-187.5deg)}100%{transform:rotate(-180deg)}}@keyframes spring-flip-out{0%{transform:rotate(-180deg)}30%{transform:rotate(62.4deg)}60%{transform:rotate(-21.635deg)}90%{transform:rotate(7.5deg)}100%{transform:rotate(0deg)}}.status__content--with-action{cursor:pointer}.status__content{position:relative;margin:10px 0;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:visible;padding-top:5px}.status__content:focus{outline:0}.status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.status__content pre,.status__content blockquote{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.status__content pre:last-child,.status__content blockquote:last-child{margin-bottom:0}.status__content .status__content__text,.status__content .e-content{overflow:hidden}.status__content .status__content__text>ul,.status__content .status__content__text>ol,.status__content .e-content>ul,.status__content .e-content>ol{margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h1,.status__content .e-content h2,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{margin-top:20px;margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .e-content h1,.status__content .e-content h2{font-weight:700;font-size:1.2em}.status__content .status__content__text h2,.status__content .e-content h2{font-size:1.1em}.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{font-weight:500}.status__content .status__content__text blockquote,.status__content .e-content blockquote{padding-left:10px;border-left:3px solid #dde3ec;color:#dde3ec;white-space:normal}.status__content .status__content__text blockquote p:last-child,.status__content .e-content blockquote p:last-child{margin-bottom:0}.status__content .status__content__text b,.status__content .status__content__text strong,.status__content .e-content b,.status__content .e-content strong{font-weight:700}.status__content .status__content__text em,.status__content .status__content__text i,.status__content .e-content em,.status__content .e-content i{font-style:italic}.status__content .status__content__text sub,.status__content .e-content sub{font-size:smaller;text-align:sub}.status__content .status__content__text sup,.status__content .e-content sup{font-size:smaller;vertical-align:super}.status__content .status__content__text ul,.status__content .status__content__text ol,.status__content .e-content ul,.status__content .e-content ol{margin-left:1em}.status__content .status__content__text ul p,.status__content .status__content__text ol p,.status__content .e-content ul p,.status__content .e-content ol p{margin:0}.status__content .status__content__text ul,.status__content .e-content ul{list-style-type:disc}.status__content .status__content__text ol,.status__content .e-content ol{list-style-type:decimal}.status__content a{color:#d8a070;text-decoration:none}.status__content a:hover{text-decoration:underline}.status__content a:hover .fa{color:#dae1ea}.status__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span{text-decoration:underline}.status__content a .fa{color:#c2cede}.status__content .status__content__spoiler{display:none}.status__content .status__content__spoiler.status__content__spoiler--visible{display:block}.status__content a.unhandled-link{color:#4e79df}.status__content a.unhandled-link .link-origin-tag{color:#ca8f04;font-size:.8em}.status__content .status__content__spoiler-link{background:#687390}.status__content .status__content__spoiler-link:hover{background:#707b97;text-decoration:none}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:#687390;border:none;color:#000;font-weight:500;font-size:11px;padding:0 5px;text-transform:uppercase;line-height:inherit;cursor:pointer;vertical-align:bottom}.status__content__spoiler-link:hover{background:#707b97;text-decoration:none}.status__content__spoiler-link .status__content__spoiler-icon{display:inline-block;margin:0 0 0 5px;border-left:1px solid currentColor;padding:0 0 0 4px;font-size:16px;vertical-align:-2px}.notif-cleaning .status,.notif-cleaning .notification-follow,.notif-cleaning .notification-follow-request{padding-right:4.5rem}.status__wrapper--filtered{color:#c2cede;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #393f4f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.notification-follow,.notification-follow-request{position:relative;border-bottom:1px solid #393f4f}.notification-follow .account,.notification-follow-request .account{border-bottom:0 none}.focusable:focus{outline:0;background:#313543}.focusable:focus.status.status-direct:not(.read){background:#42485a}.focusable:focus.status.status-direct:not(.read).muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#393f4f}.status{padding:10px 14px;position:relative;height:auto;border-bottom:1px solid #393f4f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:28px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#393f4f;border-bottom-color:#42485a}.status.light .status__relative-time{color:#1b1e25}.status.light .status__display-name{color:#000}.status.light .display-name strong{color:#000}.status.light .display-name span{color:#1b1e25}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.status.collapsed{background-position:center;background-size:cover;user-select:none}.status.collapsed.has-background::before{display:block;position:absolute;left:0;right:0;top:0;bottom:0;background-image:linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.65) 24px, rgba(0, 0, 0, 0.8));pointer-events:none;content:\"\"}.status.collapsed .display-name:hover .display-name__html{text-decoration:none}.status.collapsed .status__content{height:20px;overflow:hidden;text-overflow:ellipsis;padding-top:0}.status.collapsed .status__content:after{content:\"\";position:absolute;top:0;bottom:0;left:0;right:0;background:linear-gradient(rgba(40, 44, 55, 0), #282c37);pointer-events:none}.status.collapsed .status__content a:hover{text-decoration:none}.status.collapsed:focus>.status__content:after{background:linear-gradient(rgba(49, 53, 67, 0), #313543)}.status.collapsed.status-direct:not(.read)>.status__content:after{background:linear-gradient(rgba(57, 63, 79, 0), #393f4f)}.status.collapsed .notification__message{margin-bottom:0}.status.collapsed .status__info .notification__message>span{white-space:nowrap}.status .notification__message{margin:-10px 0px 10px 0}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#b8c0d9}.status__relative-time{display:inline-block;flex-grow:1;color:#c2cede;font-size:14px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status__display-name{color:#c2cede;overflow:hidden}.status__info__account .status__display-name{display:block;max-width:100%}.status__info{display:flex;justify-content:space-between;font-size:15px}.status__info>span{text-overflow:ellipsis;overflow:hidden}.status__info .notification__message>span{word-wrap:break-word}.status__info__icons{display:flex;align-items:center;height:1em;color:#8d9ac2}.status__info__icons .status__media-icon,.status__info__icons .status__visibility-icon,.status__info__icons .status__reply-icon{padding-left:2px;padding-right:2px}.status__info__icons .status__collapse-button.active>.fa-angle-double-up{transform:rotate(-180deg)}.no-reduce-motion .status__collapse-button.activate>.fa-angle-double-up{animation:spring-flip-in 1s linear}.no-reduce-motion .status__collapse-button.deactivate>.fa-angle-double-up{animation:spring-flip-out 1s linear}.status__info__account{display:flex;align-items:center;justify-content:flex-start}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-top:-10px;margin-bottom:10px;margin-left:58px;color:#c2cede;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#c2cede}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#8d9ac2}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#313543;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#c2cede;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}a.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#ecf0f4;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{flex:none;margin:0 10px 0 0;height:48px;width:48px}.muted .status__content,.muted .status__content p,.muted .status__content a,.muted .status__content__text{color:#c2cede}.muted .status__display-name strong{color:#c2cede}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#606984;color:#000}.muted a.status__content__spoiler-link:hover{background:#66718d;text-decoration:none}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.status-card{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;color:#c2cede;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#ecf0f4;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}.status-card__actions a .fa,.status-card__actions a:hover .fa{color:inherit}a.status-card{cursor:pointer}a.status-card:hover{background:#393f4f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#dde3ec;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#dde3ec}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#393f4f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#313543}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#313543}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.attachment-list{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#c2cede;padding:8px 18px;cursor:default;border-right:1px solid #393f4f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#c2cede;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#c2cede}.status__wrapper--filtered__button{display:inline;color:#4e79df;border:0;background:transparent;padding:0;font-size:inherit;line-height:inherit}.status__wrapper--filtered__button:hover,.status__wrapper--filtered__button:active{text-decoration:underline}.modal-container--preloader{background:#393f4f}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.onboarding-modal__pager{height:80vh;width:80vw;max-width:520px;max-height:470px}.onboarding-modal__pager .react-swipeable-view-container>div{width:100%;height:100%;box-sizing:border-box;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;user-select:text}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}@media screen and (max-width: 550px){.onboarding-modal{width:100%;height:100%;border-radius:0}.onboarding-modal__pager{width:100%;height:auto;max-width:none;max-height:none;flex:1 1 auto}}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#1b1e25;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#131419;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#0a0a0a}.error-modal__footer{justify-content:center}.onboarding-modal__dots{flex:1 1 auto;display:flex;align-items:center;justify-content:center}.onboarding-modal__dot{width:14px;height:14px;border-radius:14px;background:#a6b9c9;margin:0 3px;cursor:pointer}.onboarding-modal__dot:hover{background:#a0b4c5}.onboarding-modal__dot.active{cursor:default;background:#8da5ba}.onboarding-modal__page__wrapper{pointer-events:none;padding:25px;padding-bottom:0}.onboarding-modal__page__wrapper.onboarding-modal__page__wrapper--active{pointer-events:auto}.onboarding-modal__page{cursor:default;line-height:21px}.onboarding-modal__page h1{font-size:18px;font-weight:500;color:#000;margin-bottom:20px}.onboarding-modal__page a{color:#2b90d9}.onboarding-modal__page a:hover,.onboarding-modal__page a:focus,.onboarding-modal__page a:active{color:#3c99dc}.onboarding-modal__page .navigation-bar a{color:inherit}.onboarding-modal__page p{font-size:16px;color:#1b1e25;margin-top:10px;margin-bottom:10px}.onboarding-modal__page p:last-child{margin-bottom:0}.onboarding-modal__page p strong{font-weight:500;background:#282c37;color:#ecf0f4;border-radius:4px;font-size:14px;padding:3px 6px}.onboarding-modal__page p strong:lang(ja){font-weight:700}.onboarding-modal__page p strong:lang(ko){font-weight:700}.onboarding-modal__page p strong:lang(zh-CN){font-weight:700}.onboarding-modal__page p strong:lang(zh-HK){font-weight:700}.onboarding-modal__page p strong:lang(zh-TW){font-weight:700}.onboarding-modal__page__wrapper-0{height:100%;padding:0}.onboarding-modal__page-one__lead{padding:65px;padding-top:45px;padding-bottom:0;margin-bottom:10px}.onboarding-modal__page-one__lead h1{font-size:26px;line-height:36px;margin-bottom:8px}.onboarding-modal__page-one__lead p{margin-bottom:0}.onboarding-modal__page-one__extra{padding-right:65px;padding-left:185px;text-align:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#282c37;color:#ecf0f4;font-weight:500;padding:10px;border-radius:4px}.onboarding-modal__page-two p,.onboarding-modal__page-three p,.onboarding-modal__page-four p,.onboarding-modal__page-five p{text-align:left}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{background:#17191f;color:#ecf0f4;margin-bottom:20px;border-radius:4px;padding:10px;text-align:center;font-size:14px;box-shadow:1px 2px 6px rgba(0,0,0,.3)}.onboarding-modal__page-two .figure .onboarding-modal__image,.onboarding-modal__page-three .figure .onboarding-modal__image,.onboarding-modal__page-four .figure .onboarding-modal__image,.onboarding-modal__page-five .figure .onboarding-modal__image{border-radius:4px;margin-bottom:10px}.onboarding-modal__page-two .figure.non-interactive,.onboarding-modal__page-three .figure.non-interactive,.onboarding-modal__page-four .figure.non-interactive,.onboarding-modal__page-five .figure.non-interactive{pointer-events:none;text-align:left}.onboarding-modal__page-four__columns .row{display:flex;margin-bottom:20px}.onboarding-modal__page-four__columns .row>div{flex:1 1 0;margin:0 10px}.onboarding-modal__page-four__columns .row>div:first-child{margin-left:0}.onboarding-modal__page-four__columns .row>div:last-child{margin-right:0}.onboarding-modal__page-four__columns .row>div p{text-align:center}.onboarding-modal__page-four__columns .row:last-child{margin-bottom:0}.onboarding-modal__page-four__columns .column-header{color:#fff}@media screen and (max-width: 320px)and (max-height: 600px){.onboarding-modal__page p{font-size:14px;line-height:20px}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{font-size:12px;margin-bottom:10px}.onboarding-modal__page-four__columns .row{margin-bottom:10px}.onboarding-modal__page-four__columns .column-header{padding:5px;font-size:12px}}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.doodle-modal,.favourite-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__relative-time,.doodle-modal .status__relative-time,.favourite-modal .status__relative-time,.confirmation-modal .status__relative-time,.report-modal .status__relative-time,.actions-modal .status__relative-time,.mute-modal .status__relative-time,.block-modal .status__relative-time{color:#c2cede;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.boost-modal .status__display-name,.doodle-modal .status__display-name,.favourite-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:flex}.boost-modal .status__avatar,.doodle-modal .status__avatar,.favourite-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:48px;width:48px}.boost-modal .status__content__spoiler-link,.doodle-modal .status__content__spoiler-link,.favourite-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#fff}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container,.favourite-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status,.favourite-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.doodle-modal__action-bar,.favourite-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.doodle-modal__action-bar>div,.favourite-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#1b1e25;padding-right:10px}.boost-modal__action-bar .button,.doodle-modal__action-bar .button,.favourite-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header,.favourite-modal__status-header{font-size:15px}.boost-modal__status-time,.favourite-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal strong{display:block;font-weight:500}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b5fd9;color:#fff}.actions-modal ul li:not(:empty) a>.react-toggle,.actions-modal ul li:not(:empty) a>.icon,.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#1b1e25;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#131419;background-color:transparent}.confirmation-modal__do_not_ask_again{padding-left:20px;padding-right:20px;padding-bottom:10px;font-size:14px}.confirmation-modal__do_not_ask_again label,.confirmation-modal__do_not_ask_again input{vertical-align:middle}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#282c37;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#313543}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.filtered-status-info{text-align:start}.filtered-status-info .spoiler__text{margin-top:20px}.filtered-status-info .account{border-bottom:0}.filtered-status-info .account__display-name strong{color:#000}.filtered-status-info .status__content__spoiler{display:none}.filtered-status-info .status__content__spoiler--visible{display:flex}.filtered-status-info ul{padding:10px;margin-left:12px;list-style:disc inside}.filtered-status-info .filtered-status-edit-link{color:#8d9ac2;text-decoration:none}.filtered-status-info .filtered-status-edit-link:hover{text-decoration:underline}.composer{padding:10px}.character-counter{cursor:default;font-family:sans-serif,sans-serif;font-size:14px;font-weight:600;color:#1b1e25}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .composer--spoiler{transition:height .4s ease,opacity .4s ease}.composer--spoiler{height:0;transform-origin:bottom;opacity:0}.composer--spoiler.composer--spoiler--visible{height:36px;margin-bottom:11px;opacity:1}.composer--spoiler input{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px;padding:10px;width:100%;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:vertical}.composer--spoiler input::placeholder{color:#c2cede}.composer--spoiler input:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .composer--spoiler input{font-size:16px}}.single-column .composer--spoiler input{font-size:16px}.composer--warning{color:#000;margin-bottom:15px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.composer--warning a{color:#1b1e25;font-weight:500;text-decoration:underline}.composer--warning a:active,.composer--warning a:focus,.composer--warning a:hover{text-decoration:none}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-left:5px;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.composer--reply{margin:0 0 10px;border-radius:4px;padding:10px;background:#9baec8;min-height:23px;overflow-y:auto;flex:0 2 auto}.composer--reply>header{margin-bottom:5px;overflow:hidden}.composer--reply>header>.account.small{color:#000}.composer--reply>header>.cancel{float:right;line-height:24px}.composer--reply>.content{position:relative;margin:10px 0;padding:0 12px;font-size:14px;line-height:20px;color:#000;word-wrap:break-word;font-weight:400;overflow:visible;white-space:pre-wrap;padding-top:5px;overflow:hidden}.composer--reply>.content p,.composer--reply>.content pre,.composer--reply>.content blockquote{margin-bottom:20px;white-space:pre-wrap}.composer--reply>.content p:last-child,.composer--reply>.content pre:last-child,.composer--reply>.content blockquote:last-child{margin-bottom:0}.composer--reply>.content h1,.composer--reply>.content h2,.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{margin-top:20px;margin-bottom:20px}.composer--reply>.content h1,.composer--reply>.content h2{font-weight:700;font-size:18px}.composer--reply>.content h2{font-size:16px}.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{font-weight:500}.composer--reply>.content blockquote{padding-left:10px;border-left:3px solid #000;color:#000;white-space:normal}.composer--reply>.content blockquote p:last-child{margin-bottom:0}.composer--reply>.content b,.composer--reply>.content strong{font-weight:700}.composer--reply>.content em,.composer--reply>.content i{font-style:italic}.composer--reply>.content sub{font-size:smaller;text-align:sub}.composer--reply>.content ul,.composer--reply>.content ol{margin-left:1em}.composer--reply>.content ul p,.composer--reply>.content ol p{margin:0}.composer--reply>.content ul{list-style-type:disc}.composer--reply>.content ol{list-style-type:decimal}.composer--reply>.content a{color:#1b1e25;text-decoration:none}.composer--reply>.content a:hover{text-decoration:underline}.composer--reply>.content a.mention:hover{text-decoration:none}.composer--reply>.content a.mention:hover span{text-decoration:underline}.composer--reply .emojione{width:20px;height:20px;margin:-5px 0 0}.emoji-picker-dropdown{position:absolute;right:5px;top:5px}.emoji-picker-dropdown ::-webkit-scrollbar-track:hover,.emoji-picker-dropdown ::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.compose-form__autosuggest-wrapper,.autosuggest-input{position:relative;width:100%}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.autosuggest-input label .autosuggest-textarea__textarea{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px 4px 0 0;padding:10px 32px 0 10px;width:100%;min-height:100px;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:none;scrollbar-color:initial}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::placeholder,.autosuggest-input label .autosuggest-textarea__textarea::placeholder{color:#c2cede}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::-webkit-scrollbar,.autosuggest-input label .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:disabled,.autosuggest-input label .autosuggest-textarea__textarea:disabled{background:#d9e1e8}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:focus,.autosuggest-input label .autosuggest-textarea__textarea:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}}.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}@media screen and (max-width: 600px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.composer--textarea--icons{display:block;position:absolute;top:29px;right:5px;bottom:5px;overflow:hidden}.composer--textarea--icons>.textarea_icon{display:block;margin:2px 0 0 2px;width:24px;height:24px;color:#1b1e25;font-size:18px;line-height:24px;text-align:center;opacity:.8}.autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.autosuggest-textarea__suggestions{display:block;position:absolute;box-sizing:border-box;top:100%;border-radius:0 0 4px 4px;padding:6px;width:100%;color:#000;background:#d9e1e8;box-shadow:4px 4px 6px rgba(0,0,0,.4);font-size:14px;z-index:99;display:none}.autosuggest-textarea__suggestions--visible{display:block}.autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.autosuggest-textarea__suggestions__item>.account,.autosuggest-textarea__suggestions__item>.emoji,.autosuggest-textarea__suggestions__item>.autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.autosuggest-textarea__suggestions__item .autosuggest-hashtag{justify-content:space-between}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item .autosuggest-hashtag strong{font-weight:500}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item>.account.small .display-name>span{color:#1b1e25}.composer--upload_form{overflow:hidden}.composer--upload_form>.content{display:flex;flex-direction:row;flex-wrap:wrap;font-family:inherit;padding:5px;overflow:hidden}.composer--upload_form--item{flex:1 1 0;margin:5px;min-width:40%}.composer--upload_form--item>div{position:relative;border-radius:4px;height:140px;width:100%;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;overflow:hidden}.composer--upload_form--item>div textarea{display:block;position:absolute;box-sizing:border-box;bottom:0;left:0;margin:0;border:0;padding:10px;width:100%;color:#ecf0f4;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);font-size:14px;font-family:inherit;font-weight:500;opacity:0;z-index:2;transition:opacity .1s ease}.composer--upload_form--item>div textarea:focus{color:#fff}.composer--upload_form--item>div textarea::placeholder{opacity:.54;color:#ecf0f4}.composer--upload_form--item>div>.close{mix-blend-mode:difference}.composer--upload_form--item.active>div textarea{opacity:1}.composer--upload_form--actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.composer--upload_form--actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.composer--upload_form--actions .icon-button:hover,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:active{color:#e6ebf0}.composer--upload_form--actions.active{opacity:1}.composer--upload_form--progress{display:flex;padding:10px;color:#dde3ec;overflow:hidden}.composer--upload_form--progress>.fa{font-size:34px;margin-right:10px}.composer--upload_form--progress>.message{flex:1 1 auto}.composer--upload_form--progress>.message>span{display:block;font-size:12px;font-weight:500;text-transform:uppercase}.composer--upload_form--progress>.message>.backdrop{position:relative;margin-top:5px;border-radius:6px;width:100%;height:6px;background:#606984}.composer--upload_form--progress>.message>.backdrop>.tracker{position:absolute;top:0;left:0;height:6px;border-radius:6px;background:#2b5fd9}.compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.composer--options-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;height:27px;display:flex;justify-content:space-between;flex:0 0 auto}.composer--options{display:flex;flex:0 0 auto}.composer--options>*{display:inline-block;box-sizing:content-box;padding:0 3px;height:27px;line-height:27px;vertical-align:bottom}.composer--options>hr{display:inline-block;margin:0 3px;border-width:0 0 0 1px;border-style:none none none solid;border-color:transparent transparent transparent #c2c2c2;padding:0;width:0;height:27px;background:transparent}.compose--counter-wrapper{align-self:center;margin-right:4px}.composer--options--dropdown.open>.value{border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1);color:#fff;background:#2b5fd9;transition:none}.composer--options--dropdown.open.top>.value{border-radius:0 0 4px 4px;box-shadow:0 4px 4px rgba(0,0,0,.1)}.composer--options--dropdown--content{position:absolute;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);background:#fff;overflow:hidden;transform-origin:50% 0}.composer--options--dropdown--content--item{display:flex;align-items:center;padding:10px;color:#000;cursor:pointer}.composer--options--dropdown--content--item>.content{flex:1 1 auto;color:#1b1e25}.composer--options--dropdown--content--item>.content:not(:first-child){margin-left:10px}.composer--options--dropdown--content--item>.content strong{display:block;color:#000;font-weight:500}.composer--options--dropdown--content--item:hover,.composer--options--dropdown--content--item.active{background:#2b5fd9;color:#fff}.composer--options--dropdown--content--item:hover>.content,.composer--options--dropdown--content--item.active>.content{color:#fff}.composer--options--dropdown--content--item:hover>.content strong,.composer--options--dropdown--content--item.active>.content strong{color:#fff}.composer--options--dropdown--content--item.active:hover{background:#3c6cdc}.composer--publisher{padding-top:10px;text-align:right;white-space:nowrap;overflow:hidden;justify-content:flex-end;flex:0 0 auto}.composer--publisher>.primary{display:inline-block;margin:0;padding:0 10px;text-align:center}.composer--publisher>.side_arm{display:inline-block;margin:0 2px;padding:0;width:36px;text-align:center}.composer--publisher.over>.count{color:#ff5050}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#17191f;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#282c37}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.column{overflow:hidden}.column-back-button{box-sizing:border-box;width:100%;background:#313543;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;border:0;text-align:unset;padding:15px;margin:0;z-index:3}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#313543;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.column-link{background:#393f4f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#404657}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#2b5fd9}.column-link__icon{display:inline-block;margin-right:5px}.column-subheading{background:#282c37;color:#c2cede;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 95, 217, 0.23) 0%, rgba(43, 95, 217, 0) 60%)}.column-header{display:flex;font-size:16px;background:#313543;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden}.column-header>button{margin:0;border:none;padding:15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active{box-shadow:0 1px 0 rgba(43,95,217,.3)}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,95,217,.4)}.column-header:focus,.column-header:active{outline:0}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden}.wide .columns-area:not(.columns-area--mobile) .column{flex:auto;min-width:330px;max-width:400px}.column>.scrollable{background:#282c37}.column-header__buttons{height:48px;display:flex;margin-left:0}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button,.column-header__notif-cleaning-buttons button{background:#313543;border:0;color:#dde3ec;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover,.column-header__notif-cleaning-buttons button:hover{color:#f4f6f9}.column-header__button.active,.column-header__notif-cleaning-buttons button.active{color:#fff;background:#393f4f}.column-header__button.active:hover,.column-header__notif-cleaning-buttons button.active:hover{color:#fff;background:#393f4f}.column-header__button:focus,.column-header__notif-cleaning-buttons button:focus{text-shadow:0 0 4px #2454c7}.column-header__notif-cleaning-buttons{display:flex;align-items:stretch;justify-content:space-around}.column-header__notif-cleaning-buttons button{background:transparent;text-align:center;padding:10px 0;white-space:pre-wrap}.column-header__notif-cleaning-buttons b{font-weight:bold}.column-header__collapsible-inner.nopad-drawer{padding:0}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#dde3ec;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #42485a;margin:10px 0}.column-header__collapsible.ncd{transition:none}.column-header__collapsible.ncd.collapsed{max-height:0;opacity:.7}.column-header__collapsible-inner{background:#393f4f;padding:15px}.column-header__setting-btn:hover{color:#dde3ec;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.column-header__title{display:inline-block;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header__icon{display:inline-block;margin-right:5px}.empty-column-indicator,.error-column{color:#c2cede;background:#282c37;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}.single-column.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}@media screen and (max-width: 415px){.auto-columns.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}}@media screen and (max-width: 415px){.auto-columns.navbar-under .react-swipeable-view-container .columns-area,.single-column.navbar-under .react-swipeable-view-container .columns-area{height:100% !important}}.column-inline-form{padding:7px 15px;padding-right:5px;display:flex;justify-content:flex-start;align-items:center;background:#313543}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%;margin-bottom:6px}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 5px}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#c2cede;background:#282c37;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#c2cede}.regeneration-indicator__label span{font-size:15px;font-weight:400}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#0e1014;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#313543;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#17191f;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#282c37;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #393f4f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.filter-form{background:#282c37}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#4e79df;background:#4e79df}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#eaeef3}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#313543}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:color,transform,opacity;font-size:18px;width:18px;height:18px;color:#ecf0f4;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(0deg)}.search__icon .fa-search.active{pointer-events:auto;opacity:.3}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#8d9ac2;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#a4afce}.search-results__header{color:#c2cede;background:#2c313d;border-bottom:1px solid #1f232b;padding:15px 10px;font-size:14px;font-weight:500}.search-results__info{padding:20px;color:#dde3ec;text-align:center}.trends__header{color:#c2cede;background:#2c313d;border-bottom:1px solid #1f232b;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #393f4f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#c2cede;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#dde3ec;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#ecf0f4}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#459ede !important}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.doodle-modal{width:unset}.doodle-modal__container{background:#d9e1e8;text-align:center;line-height:0}.doodle-modal__container canvas{border:5px solid #d9e1e8}.doodle-modal__action-bar .filler{flex-grow:1;margin:0;padding:0}.doodle-modal__action-bar .doodle-toolbar{line-height:1;display:flex;flex-direction:column;flex-grow:0;justify-content:space-around}.doodle-modal__action-bar .doodle-toolbar.with-inputs label{display:inline-block;width:70px;text-align:right;margin-right:2px}.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=number],.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=text]{width:40px}.doodle-modal__action-bar .doodle-toolbar.with-inputs span.val{display:inline-block;text-align:left;width:50px}.doodle-modal__action-bar .doodle-palette{padding-right:0 !important;border:1px solid #000;line-height:.2rem;flex-grow:0;background:#fff}.doodle-modal__action-bar .doodle-palette button{appearance:none;width:1rem;height:1rem;margin:0;padding:0;text-align:center;color:#000;text-shadow:0 0 1px #fff;cursor:pointer;box-shadow:inset 0 0 1px rgba(255,255,255,.5);border:1px solid #000;outline-offset:-1px}.doodle-modal__action-bar .doodle-palette button.foreground{outline:1px dashed #fff}.doodle-modal__action-bar .doodle-palette button.background{outline:1px dashed red}.doodle-modal__action-bar .doodle-palette button.foreground.background{outline:1px dashed red;border-color:#fff}.drawer{width:300px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden;padding:10px 5px;flex:none}.drawer:first-child{padding-left:10px}.drawer:last-child{padding-right:10px}@media screen and (max-width: 630px){.auto-columns .drawer{flex:auto}}.single-column .drawer{flex:auto}@media screen and (max-width: 630px){.auto-columns .drawer,.auto-columns .drawer:first-child,.auto-columns .drawer:last-child,.single-column .drawer,.single-column .drawer:first-child,.single-column .drawer:last-child{padding:0}}.wide .drawer{min-width:300px;max-width:400px;flex:1 1 200px}@media screen and (max-width: 630px){:root .auto-columns .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}}:root .single-column .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}.react-swipeable-view-container .drawer{height:100%}.drawer--header{display:flex;flex-direction:row;margin-bottom:10px;flex:none;background:#393f4f;font-size:16px}.drawer--header>*{display:block;box-sizing:border-box;border-bottom:2px solid transparent;padding:15px 5px 13px;height:48px;flex:1 1 auto;color:#dde3ec;text-align:center;text-decoration:none;cursor:pointer}.drawer--header a{transition:background 100ms ease-in}.drawer--header a:focus,.drawer--header a:hover{outline:none;background:#2e3340;transition:background 200ms ease-out}.search{position:relative;margin-bottom:10px;flex:none}@media screen and (max-width: 415px){.auto-columns .search,.single-column .search{margin-bottom:0}}@media screen and (max-width: 630px){.auto-columns .search{font-size:16px}}.single-column .search{font-size:16px}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#364861;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}.drawer--account{padding:10px;color:#dde3ec;display:flex;align-items:center}.drawer--account a{color:inherit;text-decoration:none}.drawer--account .acct{display:block;color:#ecf0f4;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;overflow:hidden}.drawer--results{background:#282c37;overflow-x:hidden;overflow-y:auto}.drawer--results>header{color:#c2cede;background:#2c313d;padding:15px;font-weight:500;font-size:16px;cursor:default}.drawer--results>header .fa{display:inline-block;margin-right:5px}.drawer--results>section{margin-bottom:5px}.drawer--results>section h5{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#c2cede}.drawer--results>section h5 .fa{display:inline-block;margin-right:5px}.drawer--results>section .account:last-child,.drawer--results>section>div:last-child .status{border-bottom:0}.drawer--results>section>.hashtag{display:block;padding:10px;color:#ecf0f4;text-decoration:none}.drawer--results>section>.hashtag:hover,.drawer--results>section>.hashtag:active,.drawer--results>section>.hashtag:focus{color:#f9fafb;text-decoration:underline}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#444b5d;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%}.drawer__inner.darker{background:#282c37}.drawer__inner__mastodon{background:#444b5d url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}.drawer__inner__mastodon>.mastodon{display:block;width:100%;height:100%;border:none;cursor:inherit}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#444b5d;font-size:13px;text-align:left}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#dde3ec;border:0;width:100%;height:100%}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{color:#f7f9fb}.status__content>.media-spoiler{margin-top:15px}.media-spoiler.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:500}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{height:100%;display:flex;flex-direction:column}.media-gallery__audio span{text-align:center;color:#dde3ec;display:flex;height:100%;align-items:center}.media-gallery__audio span p{width:100%}.media-gallery__audio audio{width:100%}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%;height:110px}.media-gallery.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-gallery__item{border:none;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.full-width .media-gallery__item{border-radius:0}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item.letterbox{background:#000}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#ecf0f4;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%;object-fit:contain}.media-gallery__item-thumbnail:not(.letterbox),.media-gallery__item-thumbnail img:not(.letterbox){height:100%;object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%;display:flex;justify-content:center}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;width:100%;position:relative;z-index:1;object-fit:contain;user-select:none}.media-gallery__item-gifv-thumbnail:not(.letterbox){height:100%;object-fit:cover}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b5fd9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#17191f;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #313543;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(78,121,223,.5)}.audio-player__wave-placeholder{background-color:#4a5266}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#17191f;border-top:1px solid #313543;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.detailed-status .video-player{width:100%;height:100%}.video-player.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.video-player video{max-width:100vw;max-height:80vh;z-index:1;position:relative}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#dde3ec;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#f4f6f9}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons-bar .video-player__download__icon .fa,.video-player__buttons-bar .video-player__download__icon:active .fa,.video-player__buttons-bar .video-player__download__icon:hover .fa,.video-player__buttons-bar .video-player__download__icon:focus .fa{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#4e79df}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#4e79df}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.sensitive-info{display:flex;flex-direction:row;align-items:center;position:absolute;top:4px;left:4px;z-index:100}.sensitive-marker{margin:0 3px;border-radius:2px;padding:2px 6px;color:rgba(255,255,255,.8);background:rgba(0,0,0,.5);font-size:12px;line-height:18px;text-transform:uppercase;opacity:.9;transition:opacity .1s ease}.media-gallery:hover .sensitive-marker{opacity:1}.list-editor{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#444b5d;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#444b5d}.list-adder__lists{background:#444b5d;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #393f4f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#1b1e25;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#131419}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#2485cb}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#2558d0}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#000;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#364861}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.glitch.local-settings{position:relative;display:flex;flex-direction:row;background:#d9e1e8;color:#000;border-radius:8px;height:80vh;width:80vw;max-width:740px;max-height:450px;overflow:hidden}.glitch.local-settings label,.glitch.local-settings legend{display:block;font-size:14px}.glitch.local-settings .boolean label,.glitch.local-settings .radio_buttons label{position:relative;padding-left:28px;padding-top:3px}.glitch.local-settings .boolean label input,.glitch.local-settings .radio_buttons label input{position:absolute;left:0;top:0}.glitch.local-settings span.hint{display:block;color:#1b1e25}.glitch.local-settings h1{font-size:18px;font-weight:500;line-height:24px;margin-bottom:20px}.glitch.local-settings h2{font-size:15px;font-weight:500;line-height:20px;margin-top:20px;margin-bottom:10px}.glitch.local-settings__navigation__item{display:block;padding:15px 20px;color:inherit;background:#f2f5f7;border-bottom:1px #d9e1e8 solid;cursor:pointer;text-decoration:none;outline:none;transition:background .3s}.glitch.local-settings__navigation__item .text-icon-button{color:inherit;transition:unset}.glitch.local-settings__navigation__item:hover{background:#d9e1e8}.glitch.local-settings__navigation__item.active{background:#2b5fd9;color:#fff}.glitch.local-settings__navigation__item.close,.glitch.local-settings__navigation__item.close:hover{background:#df405a;color:#fff}.glitch.local-settings__navigation{background:#f2f5f7;width:212px;font-size:15px;line-height:20px;overflow-y:auto}.glitch.local-settings__page{display:block;flex:auto;padding:15px 20px 15px 20px;width:360px;overflow-y:auto}.glitch.local-settings__page__item{margin-bottom:2px}.glitch.local-settings__page__item.string,.glitch.local-settings__page__item.radio_buttons{margin-top:10px;margin-bottom:10px}@media screen and (max-width: 630px){.glitch.local-settings__navigation{width:40px;flex-shrink:0}.glitch.local-settings__navigation__item{padding:10px}.glitch.local-settings__navigation__item span:last-of-type{display:none}}.error-boundary{color:#fff;font-size:15px;line-height:20px}.error-boundary h1{font-size:26px;line-height:36px;font-weight:400;margin-bottom:8px}.error-boundary a{color:#fff;text-decoration:underline}.error-boundary ul{list-style:disc;margin-left:0;padding-left:1em}.error-boundary textarea.web_app_crash-stacktrace{width:100%;resize:none;white-space:pre;font-family:monospace,monospace}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.compose-panel .search__icon .fa{top:15px}.compose-panel .drawer--account{flex:0 1 48px}.compose-panel .flex-spacer{background:transparent}.compose-panel .composer{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #313543;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px;min-height:50px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{padding-top:15px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#2558d0;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#4976de}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.search{margin-bottom:10px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.poll{margin-top:16px;font-size:14px}.poll ul,.e-content .poll ul{margin:0;list-style:none}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#6d89af}.poll__chart.leading{background:#2b5fd9}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;display:block;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#2b90d9}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#c2cede}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#c2cede;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(194,206,222,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{width:100%;flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#8d9ac2;border-color:#8d9ac2;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#c2cede}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(43,95,217,.2)}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:sans-serif,sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#dde3ec}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#dde3ec}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#ecf0f4}.rich-formatting em{font-style:italic;color:#ecf0f4}.rich-formatting code{font-size:.85em;background:#17191f;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:sans-serif,sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#ecf0f4}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #313543;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #313543;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#dde3ec}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#c2cede}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#1f232b;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:sans-serif,sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#ecf0f4}.information-board__section strong{font-family:sans-serif,sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#17191f;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#dde3ec;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #313543;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#bcc9da}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;width:80px;height:80px;background-size:80px 80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px;border-radius:8%;background-position:50%;background-clip:padding-box}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#dde3ec}.landing-page p,.landing-page li{font-family:sans-serif,sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#dde3ec}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.landing-page h1{font-family:sans-serif,sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h1 small{font-family:sans-serif,sans-serif;display:block;font-size:18px;font-weight:400;color:#fefefe}.landing-page h2{font-family:sans-serif,sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h3{font-family:sans-serif,sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h4{font-family:sans-serif,sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h5{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h6{font-family:sans-serif,sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#282c37;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#fefefe}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px;width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#282c37;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#ecf0f4}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#dde3ec}.landing-page__short-description h1 small span{color:#ecf0f4}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#282c37;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:sans-serif,sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#dde3ec}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#dde3ec}.landing .simple_form p.lead{color:#dde3ec;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #393f4f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#c2cede}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #282c37;text-align:left;background:#1f232b}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #282c37;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#282c37}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#282c37;border-top:1px solid #17191f;border-bottom:1px solid #17191f}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #17191f}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #17191f}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:monospace,monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#dde3ec;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #17191f;background:#282c37;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #17191f;border-top:0;background:#282c37}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #17191f;border-top:0;background:#1f232b}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #17191f}}.batch-table__row:hover{background:#242731}.batch-table__row:nth-child(even){background:#282c37}.batch-table__row:nth-child(even):hover{background:#2c313d}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#dde3ec;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #17191f;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #17191f}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#282c37;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#393f4f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#dde3ec;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#42485a}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#dde3ec;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#1d2028;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#242731;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#1f232b;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#2b5fd9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#416fdd}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #393f4f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#ecf0f4;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#ecf0f4;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#dde3ec;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #393f4f}.admin-wrapper .content h6{font-size:16px;color:#ecf0f4;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:none}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#ecf0f4;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #313543;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b5fd9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#dde3ec}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#c2cede;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#dde3ec;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #282c37}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #333846}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b5fd9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#ecf0f4}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#282c37;color:#dde3ec;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#c2cede}.log-entry__extras{background:#353a49;border-radius:0 0 4px 4px;padding:10px;color:#dde3ec;font-family:monospace,monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#c2cede}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#79bd9a}.log-entry__icon__overlay.negative{background:#e87487}.log-entry__icon__overlay.neutral{background:#2b5fd9}.log-entry a,.log-entry .username,.log-entry .target{color:#ecf0f4;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#e87487}.log-entry .diff-neutral{color:#ecf0f4}.log-entry .diff-new{color:#79bd9a}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#ecf0f4}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b5fd9}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#dde3ec}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#c2cede}.report-card{background:#282c37;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#dde3ec;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#f7f9fb}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #1f232b}.report-card__summary__item:hover{background:#2c313d}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#dde3ec}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#c2cede;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#dde3ec}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#393f4f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#4e79df}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff)}.hicolor-privacy-icons .status__visibility-icon.fa-globe,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-globe{color:#1976d2}.hicolor-privacy-icons .status__visibility-icon.fa-unlock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-unlock{color:#388e3c}.hicolor-privacy-icons .status__visibility-icon.fa-lock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-lock{color:#ffa000}.hicolor-privacy-icons .status__visibility-icon.fa-envelope,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-envelope{color:#d32f2f}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .composer--publisher{text-align:left}body.rtl .boost-modal__status-time,body.rtl .favourite-modal__status-time{float:left}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .setting-meta__label{float:left}body.rtl .status__avatar{margin-left:10px;margin-right:0;left:auto;right:10px}body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:58px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left;text-align:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(19, 20, 25, 0), #131419)}body.rtl .simple_form select{background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#313543;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#393f4f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:sans-serif,sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#dde3ec;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}.compose-form .compose-form__modifiers .compose-form__upload-description input::placeholder{opacity:1}.rich-formatting a,.rich-formatting p a,.rich-formatting li a,.landing-page__short-description p a,.status__content a,.reply-indicator__content a{color:#5f86e2;text-decoration:underline}.rich-formatting a.mention,.rich-formatting p a.mention,.rich-formatting li a.mention,.landing-page__short-description p a.mention,.status__content a.mention,.reply-indicator__content a.mention{text-decoration:none}.rich-formatting a.mention span,.rich-formatting p a.mention span,.rich-formatting li a.mention span,.landing-page__short-description p a.mention span,.status__content a.mention span,.reply-indicator__content a.mention span{text-decoration:underline}.rich-formatting a.mention span:hover,.rich-formatting a.mention span:focus,.rich-formatting a.mention span:active,.rich-formatting p a.mention span:hover,.rich-formatting p a.mention span:focus,.rich-formatting p a.mention span:active,.rich-formatting li a.mention span:hover,.rich-formatting li a.mention span:focus,.rich-formatting li a.mention span:active,.landing-page__short-description p a.mention span:hover,.landing-page__short-description p a.mention span:focus,.landing-page__short-description p a.mention span:active,.status__content a.mention span:hover,.status__content a.mention span:focus,.status__content a.mention span:active,.reply-indicator__content a.mention span:hover,.reply-indicator__content a.mention span:focus,.reply-indicator__content a.mention span:active{text-decoration:none}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active,.rich-formatting p a:hover,.rich-formatting p a:focus,.rich-formatting p a:active,.rich-formatting li a:hover,.rich-formatting li a:focus,.rich-formatting li a:active,.landing-page__short-description p a:hover,.landing-page__short-description p a:focus,.landing-page__short-description p a:active,.status__content a:hover,.status__content a:focus,.status__content a:active,.reply-indicator__content a:hover,.reply-indicator__content a:focus,.reply-indicator__content a:active{text-decoration:none}.rich-formatting a.status__content__spoiler-link,.rich-formatting p a.status__content__spoiler-link,.rich-formatting li a.status__content__spoiler-link,.landing-page__short-description p a.status__content__spoiler-link,.status__content a.status__content__spoiler-link,.reply-indicator__content a.status__content__spoiler-link{color:#ecf0f4;text-decoration:none}.status__content__read-more-button{text-decoration:underline}.status__content__read-more-button:hover,.status__content__read-more-button:focus,.status__content__read-more-button:active{text-decoration:none}.getting-started__footer a{text-decoration:underline}.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:none}.nothing-here{color:#dde3ec}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b5fd9}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n$ui-base-color: $classic-base-color !default;\n$ui-primary-color: $classic-primary-color !default;\n$ui-secondary-color: $classic-secondary-color !default;\n\n// Differences\n$ui-highlight-color: #2b5fd9;\n\n$darker-text-color: lighten($ui-primary-color, 20%) !default;\n$dark-text-color: lighten($ui-primary-color, 12%) !default;\n$secondary-text-color: lighten($ui-secondary-color, 6%) !default;\n$highlight-text-color: $classic-highlight-color !default;\n$action-button-color: #8d9ac2;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: darken($ui-base-color,6%) !default;\n$light-text-color: darken($ui-primary-color, 40%) !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n @return '%23' + unquote($color)\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a; // Padua\n$error-red: #df405a; // Cerise\n$warning-red: #ff5050; // Sunset Orange\n$gold-star: #ca8f04; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: sans-serif !default;\n$font-display: sans-serif !default;\n$font-monospace: monospace !default;\n\n// Avatar border size (8% default, 100% for rounded avatars)\n$ui-avatar-border-size: 8%;\n\n// More variables\n$dismiss-overlay-width: 4rem;\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n @include avatar-size(40px);\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1/3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1/3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n @include avatar-size(120px);\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n @include avatar-radius();\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n","@mixin avatar-radius() {\n border-radius: $ui-avatar-border-size;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size:48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin single-column($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .single-column #{$parent} {\n @content;\n }\n}\n\n@mixin limited-single-column($media, $parent: '&') {\n .auto-columns #{$parent}, .single-column #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n}\n\n@mixin multi-columns($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .multi-columns #{$parent} {\n @content;\n }\n}\n\n@mixin fullwidth-gallery {\n &.full-width {\n margin-left: -14px;\n margin-right: -14px;\n width: inherit;\n max-width: none;\n height: 250px;\n border-radius: 0px;\n }\n}\n\n@mixin search-input() {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: none;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout() {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n padding: 8px 0;\n padding-bottom: 2px;\n margin: initial;\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n position: absolute;\n margin: initial;\n float: initial;\n width: auto;\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n\n// Styling from upstream's WebUI, as public pages use the same layout\n.embed,\n.public-layout {\n .status {\n .status__info {\n font-size: 15px;\n display: initial;\n }\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding: 6px 0;\n padding-right: 25px;\n margin: initial;\n\n .display-name strong {\n display: inline;\n }\n }\n\n .status__avatar {\n height: 48px;\n position: absolute;\n width: 48px;\n margin: initial;\n }\n }\n}\n\n.rtl {\n .embed,\n .public-layout {\n .status {\n padding-left: 10px;\n padding-right: 68px;\n\n .status__info .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .status__relative-time {\n float: left;\n }\n }\n }\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: darken($ui-highlight-color, 3%);\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n transition-property: background-color;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 7%);\n transition: all 200ms ease-out;\n transition-property: background-color;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n color: $darker-text-color;\n text-transform: none;\n background: transparent;\n padding: 3px 15px;\n border-radius: 4px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n transform-origin: 50% 0;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: 0;\n position: absolute;\n\n .fa.star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n a {\n color: inherit;\n text-decoration: inherit;\n }\n\n strong {\n height: 18px;\n font-size: 16px;\n font-weight: 500;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n span {\n display: block;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n > a:hover {\n strong {\n text-decoration: underline;\n }\n }\n\n &.inline {\n padding: 0;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n strong {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n\n span {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n }\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n ul {\n list-style: none;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.static-content {\n padding: 10px;\n padding-top: 20px;\n color: $dark-text-color;\n\n h1 {\n font-size: 16px;\n font-weight: 500;\n margin-bottom: 40px;\n text-align: center;\n }\n\n p {\n font-size: 13px;\n margin-bottom: 20px;\n }\n}\n\n.column,\n.drawer {\n flex: 1 1 100%;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @include multi-columns('screen and (min-width: 631px)') {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $ui-highlight-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n\n span.icon {\n margin-left: 0;\n display: inline;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.getting-started__wrapper,\n.getting_started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.getting-started__wrapper {\n position: relative;\n overflow-y: auto;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n background: $ui-base-color;\n flex: 1 0 auto;\n\n p {\n color: $secondary-text-color;\n }\n\n a {\n color: $dark-text-color;\n }\n\n &__panel {\n height: min-content;\n }\n\n &__panel,\n &__footer {\n padding: 10px;\n padding-top: 20px;\n flex: 0 1 auto;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n color: $darker-text-color;\n background: transparent;\n border: none;\n border-bottom: 2px solid $ui-primary-color;\n box-sizing: border-box;\n display: block;\n font-family: inherit;\n margin-bottom: 10px;\n padding: 7px 0;\n width: 100%;\n\n &:focus,\n &:active {\n color: $primary-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n font-size: 16px;\n }\n\n &.light {\n color: $inverted-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 27%);\n\n &:focus,\n &:active {\n color: $inverted-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.reduce-motion button.icon-button.disabled i.fa-retweet {\n color: darken($action-button-color, 13%);\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.missing-indicator {\n padding-top: 20px + 48px;\n}\n\n.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {\n border-top: 1px solid $ui-base-color;\n}\n\n.notification__dismiss-overlay {\n overflow: hidden;\n position: absolute;\n top: 0;\n right: 0;\n bottom: -1px;\n padding-left: 15px; // space for the box shadow to be visible\n\n z-index: 999;\n align-items: center;\n justify-content: flex-end;\n cursor: pointer;\n\n display: flex;\n\n .wrappy {\n width: $dismiss-overlay-width;\n align-self: stretch;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: lighten($ui-base-color, 8%);\n border-left: 1px solid lighten($ui-base-color, 20%);\n box-shadow: 0 0 5px black;\n border-bottom: 1px solid $ui-base-color;\n }\n\n .ckbox {\n border: 2px solid $ui-primary-color;\n border-radius: 2px;\n width: 30px;\n height: 30px;\n font-size: 20px;\n color: $darker-text-color;\n text-shadow: 0 0 5px black;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n &:focus {\n outline: 0 !important;\n\n .ckbox {\n box-shadow: 0 0 1px 1px $ui-highlight-color;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: flex;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n align-items: center;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label,\n.setting-radio__label,\n.setting-meta__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.setting-radio {\n display: block;\n line-height: 18px;\n}\n\n.setting-radio__label {\n margin-bottom: 0;\n}\n\n.column-settings__row legend {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-top: 10px;\n}\n\n.setting-radio__input {\n vertical-align: middle;\n}\n\n.setting-meta__label {\n float: right;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n transform-origin: center center;\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.pulse-loading {\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.loading-bar {\n background-color: $ui-highlight-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.icon-badge-wrapper {\n position: relative;\n}\n\n.icon-badge {\n position: absolute;\n display: block;\n right: -.25em;\n top: -.25em;\n background-color: $ui-highlight-color;\n border-radius: 50%;\n font-size: 75%;\n width: 1em;\n height: 1em;\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n .status__content {\n margin: 0;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.ui .flash-message {\n margin-top: 10px;\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 0;\n min-width: 75%;\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@import 'boost';\n@import 'accounts';\n@import 'domains';\n@import 'status';\n@import 'modal';\n@import 'composer';\n@import 'columns';\n@import 'regeneration_indicator';\n@import 'directory';\n@import 'search';\n@import 'emoji';\n@import 'doodle';\n@import 'drawer';\n@import 'media';\n@import 'sensitive';\n@import 'lists';\n@import 'emoji_picker';\n@import 'local_settings';\n@import 'error_boundary';\n@import 'single_column';\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant\nbutton.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant for use with DMs\n.status-direct button.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n",".account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n color: inherit;\n text-decoration: none;\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n\n &.small {\n border: none;\n padding: 0;\n\n & > .account__avatar-wrapper { margin: 0 8px 0 0 }\n\n & > .display-name {\n height: 24px;\n line-height: 24px;\n }\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius();\n position: relative;\n cursor: pointer;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & div {\n @include avatar-radius;\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\n.account__avatar-overlay {\n position: relative;\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius();\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius();\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__header__wrapper {\n flex: 0 0 auto;\n background: lighten($ui-base-color, 4%);\n}\n\n.account__disclaimer {\n padding: 10px;\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-left: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &:first-child {\n border-left: 0;\n }\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n abbr {\n color: $highlight-text-color;\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.notification__message {\n margin-left: 42px;\n padding: 8px 0 0 26px;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input();\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout();\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n",".domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n","@keyframes spring-flip-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-242.4deg);\n }\n\n 60% {\n transform: rotate(-158.35deg);\n }\n\n 90% {\n transform: rotate(-187.5deg);\n }\n\n 100% {\n transform: rotate(-180deg);\n }\n}\n\n@keyframes spring-flip-out {\n 0% {\n transform: rotate(-180deg);\n }\n\n 30% {\n transform: rotate(62.4deg);\n }\n\n 60% {\n transform: rotate(-21.635deg);\n }\n\n 90% {\n transform: rotate(7.5deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content {\n position: relative;\n margin: 10px 0;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n padding-top: 5px;\n\n &:focus {\n outline: 0;\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .status__content__text,\n .e-content {\n overflow: hidden;\n\n & > ul,\n & > ol {\n margin-bottom: 20px;\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 1.2em;\n }\n\n h2 {\n font-size: 1.1em;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $darker-text-color;\n color: $darker-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n sup {\n font-size: smaller;\n vertical-align: super;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n .status__content__spoiler {\n display: none;\n\n &.status__content__spoiler--visible {\n display: block;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n\n .link-origin-tag {\n color: $gold-star;\n font-size: 0.8em;\n }\n }\n\n .status__content__spoiler-link {\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: lighten($ui-base-color, 30%);\n border: none;\n color: $inverted-text-color;\n font-weight: 500;\n font-size: 11px;\n padding: 0 5px;\n text-transform: uppercase;\n line-height: inherit;\n cursor: pointer;\n vertical-align: bottom;\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n\n .status__content__spoiler-icon {\n display: inline-block;\n margin: 0 0 0 5px;\n border-left: 1px solid currentColor;\n padding: 0 0 0 4px;\n font-size: 16px;\n vertical-align: -2px;\n }\n}\n\n.notif-cleaning {\n .status,\n .notification-follow,\n .notification-follow-request {\n padding-right: ($dismiss-overlay-width + 0.5rem);\n }\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.notification-follow,\n.notification-follow-request {\n position: relative;\n\n // same like Status\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .account {\n border-bottom: 0 none;\n }\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n &.status.status-direct:not(.read) {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 10px 14px;\n position: relative;\n height: auto;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 28px; // 12px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $lighter-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $lighter-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n\n &.collapsed {\n background-position: center;\n background-size: cover;\n user-select: none;\n\n &.has-background::before {\n display: block;\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));\n pointer-events: none;\n content: \"\";\n }\n\n .display-name:hover .display-name__html {\n text-decoration: none;\n }\n\n .status__content {\n height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 0;\n\n &:after {\n content: \"\";\n position: absolute;\n top: 0; bottom: 0;\n left: 0; right: 0;\n background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));\n pointer-events: none;\n }\n \n a:hover {\n text-decoration: none;\n }\n }\n &:focus > .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));\n }\n &.status-direct:not(.read)> .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));\n }\n\n .notification__message {\n margin-bottom: 0;\n }\n\n .status__info .notification__message > span {\n white-space: nowrap;\n }\n }\n\n .notification__message {\n margin: -10px 0px 10px 0;\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time {\n display: inline-block;\n flex-grow: 1;\n color: $dark-text-color;\n font-size: 14px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.status__display-name {\n color: $dark-text-color;\n overflow: hidden;\n}\n\n.status__info__account .status__display-name {\n display: block;\n max-width: 100%;\n}\n\n.status__info {\n display: flex;\n justify-content: space-between;\n font-size: 15px;\n\n > span {\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .notification__message > span {\n word-wrap: break-word;\n }\n}\n\n.status__info__icons {\n display: flex;\n align-items: center;\n height: 1em;\n color: $action-button-color;\n\n .status__media-icon,\n .status__visibility-icon,\n .status__reply-icon {\n padding-left: 2px;\n padding-right: 2px;\n }\n\n .status__collapse-button.active > .fa-angle-double-up {\n transform: rotate(-180deg);\n }\n}\n\n.no-reduce-motion .status__collapse-button {\n &.activate {\n & > .fa-angle-double-up {\n animation: spring-flip-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-angle-double-up {\n animation: spring-flip-out 1s linear;\n }\n }\n}\n\n.status__info__account {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-top: -10px;\n margin-bottom: 10px;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\na.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\n.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n flex: none;\n margin: 0 10px 0 0;\n height: 48px;\n width: 48px;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a,\n .status__content__text {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-color, 29%);\n text-decoration: none;\n }\n }\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n\n a .fa, a:hover .fa {\n color: inherit;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n.status__wrapper--filtered__button {\n display: inline;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n font-size: inherit;\n line-height: inherit;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.onboarding-modal__pager {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 470px;\n\n .react-swipeable-view-container > div {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n user-select: text;\n }\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n@media screen and (max-width: 550px) {\n .onboarding-modal {\n width: 100%;\n height: 100%;\n border-radius: 0;\n }\n\n .onboarding-modal__pager {\n width: 100%;\n height: auto;\n max-width: none;\n max-height: none;\n flex: 1 1 auto;\n }\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.onboarding-modal__dots {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.onboarding-modal__dot {\n width: 14px;\n height: 14px;\n border-radius: 14px;\n background: darken($ui-secondary-color, 16%);\n margin: 0 3px;\n cursor: pointer;\n\n &:hover {\n background: darken($ui-secondary-color, 18%);\n }\n\n &.active {\n cursor: default;\n background: darken($ui-secondary-color, 24%);\n }\n}\n\n.onboarding-modal__page__wrapper {\n pointer-events: none;\n padding: 25px;\n padding-bottom: 0;\n\n &.onboarding-modal__page__wrapper--active {\n pointer-events: auto;\n }\n}\n\n.onboarding-modal__page {\n cursor: default;\n line-height: 21px;\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 20px;\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 4%);\n }\n }\n\n .navigation-bar a {\n color: inherit;\n }\n\n p {\n font-size: 16px;\n color: $lighter-text-color;\n margin-top: 10px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n background: $ui-base-color;\n color: $secondary-text-color;\n border-radius: 4px;\n font-size: 14px;\n padding: 3px 6px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.onboarding-modal__page__wrapper-0 {\n height: 100%;\n padding: 0;\n}\n\n.onboarding-modal__page-one {\n &__lead {\n padding: 65px;\n padding-top: 45px;\n padding-bottom: 0;\n margin-bottom: 10px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n margin-bottom: 8px;\n }\n\n p {\n margin-bottom: 0;\n }\n }\n\n &__extra {\n padding-right: 65px;\n padding-left: 185px;\n text-align: center;\n }\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboarding-modal__page-two,\n.onboarding-modal__page-three,\n.onboarding-modal__page-four,\n.onboarding-modal__page-five {\n p {\n text-align: left;\n }\n\n .figure {\n background: darken($ui-base-color, 8%);\n color: $secondary-text-color;\n margin-bottom: 20px;\n border-radius: 4px;\n padding: 10px;\n text-align: center;\n font-size: 14px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);\n\n .onboarding-modal__image {\n border-radius: 4px;\n margin-bottom: 10px;\n }\n\n &.non-interactive {\n pointer-events: none;\n text-align: left;\n }\n }\n}\n\n.onboarding-modal__page-four__columns {\n .row {\n display: flex;\n margin-bottom: 20px;\n\n & > div {\n flex: 1 1 0;\n margin: 0 10px;\n\n &:first-child {\n margin-left: 0;\n }\n\n &:last-child {\n margin-right: 0;\n }\n\n p {\n text-align: center;\n }\n }\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .column-header {\n color: $primary-text-color;\n }\n}\n\n@media screen and (max-width: 320px) and (max-height: 600px) {\n .onboarding-modal__page p {\n font-size: 14px;\n line-height: 20px;\n }\n\n .onboarding-modal__page-two .figure,\n .onboarding-modal__page-three .figure,\n .onboarding-modal__page-four .figure,\n .onboarding-modal__page-five .figure {\n font-size: 12px;\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .row {\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .column-header {\n padding: 5px;\n font-size: 12px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.favourite-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__display-name {\n display: flex;\n }\n\n .status__avatar {\n height: 48px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container,\n.favourite-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.favourite-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header,\n.favourite-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time,\n.favourite-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n strong {\n display: block;\n font-weight: 500;\n }\n\n max-height: 80vh;\n max-width: 80vw;\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n & > .react-toggle,\n & > .icon,\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__do_not_ask_again {\n padding-left: 20px;\n padding-right: 20px;\n padding-bottom: 10px;\n\n font-size: 14px;\n\n label, input {\n vertical-align: middle;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: 'mastodon-font-monospace', monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.filtered-status-info {\n text-align: start;\n\n .spoiler__text {\n margin-top: 20px;\n }\n\n .account {\n border-bottom: 0;\n }\n\n .account__display-name strong {\n color: $inverted-text-color;\n }\n\n .status__content__spoiler {\n display: none;\n\n &--visible {\n display: flex;\n }\n }\n\n ul {\n padding: 10px;\n margin-left: 12px;\n list-style: disc inside;\n }\n\n .filtered-status-edit-link {\n color: $action-button-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline\n }\n }\n}\n",".composer {\n padding: 10px;\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .composer--spoiler {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.composer--spoiler {\n height: 0;\n transform-origin: bottom;\n opacity: 0.0;\n\n &.composer--spoiler--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1.0;\n }\n\n input {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px;\n padding: 10px;\n width: 100%;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: vertical;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n }\n}\n\n.composer--warning {\n color: $inverted-text-color;\n margin-bottom: 15px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:active,\n &:focus,\n &:hover { text-decoration: none }\n }\n}\n\n.compose-form__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-left: 5px;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n}\n\n.composer--reply {\n margin: 0 0 10px;\n border-radius: 4px;\n padding: 10px;\n background: $ui-primary-color;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n\n & > header {\n margin-bottom: 5px;\n overflow: hidden;\n\n & > .account.small { color: $inverted-text-color; }\n\n & > .cancel {\n float: right;\n line-height: 24px;\n }\n }\n\n & > .content {\n position: relative;\n margin: 10px 0;\n padding: 0 12px;\n font-size: 14px;\n line-height: 20px;\n color: $inverted-text-color;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n white-space: pre-wrap;\n padding-top: 5px;\n overflow: hidden;\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 18px;\n }\n\n h2 {\n font-size: 16px;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $inverted-text-color;\n color: $inverted-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n\n a {\n color: $lighter-text-color;\n text-decoration: none;\n\n &:hover { text-decoration: underline }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span { text-decoration: underline }\n }\n }\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -5px 0 0;\n }\n}\n\n.emoji-picker-dropdown {\n position: absolute;\n right: 5px;\n top: 5px;\n\n ::-webkit-scrollbar-track:hover,\n ::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.autosuggest-input {\n position: relative;\n width: 100%;\n\n label {\n .autosuggest-textarea__textarea {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px 4px 0 0;\n padding: 10px 32px 0 10px;\n width: 100%;\n min-height: 100px;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: none;\n scrollbar-color: initial;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n &:disabled { background: $ui-secondary-color }\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n }\n}\n\n.composer--textarea--icons {\n display: block;\n position: absolute;\n top: 29px;\n right: 5px;\n bottom: 5px;\n overflow: hidden;\n\n & > .textarea_icon {\n display: block;\n margin: 2px 0 0 2px;\n width: 24px;\n height: 24px;\n color: $lighter-text-color;\n font-size: 18px;\n line-height: 24px;\n text-align: center;\n opacity: .8;\n }\n}\n\n.autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n}\n\n.autosuggest-textarea__suggestions {\n display: block;\n position: absolute;\n box-sizing: border-box;\n top: 100%;\n border-radius: 0 0 4px 4px;\n padding: 6px;\n width: 100%;\n color: $inverted-text-color;\n background: $ui-secondary-color;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n font-size: 14px;\n z-index: 99;\n display: none;\n}\n\n.autosuggest-textarea__suggestions--visible {\n display: block;\n}\n\n.autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected { background: darken($ui-secondary-color, 10%) }\n\n > .account,\n > .emoji,\n > .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n & > .account.small {\n .display-name {\n & > span { color: $lighter-text-color }\n }\n }\n}\n\n.composer--upload_form {\n overflow: hidden;\n\n & > .content {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n font-family: inherit;\n padding: 5px;\n overflow: hidden;\n }\n}\n\n.composer--upload_form--item {\n flex: 1 1 0;\n margin: 5px;\n min-width: 40%;\n\n & > div {\n position: relative;\n border-radius: 4px;\n height: 140px;\n width: 100%;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n overflow: hidden;\n\n textarea {\n display: block;\n position: absolute;\n box-sizing: border-box;\n bottom: 0;\n left: 0;\n margin: 0;\n border: 0;\n padding: 10px;\n width: 100%;\n color: $secondary-text-color;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n font-size: 14px;\n font-family: inherit;\n font-weight: 500;\n opacity: 0;\n z-index: 2;\n transition: opacity .1s ease;\n\n &:focus { color: $white }\n\n &::placeholder {\n opacity: 0.54;\n color: $secondary-text-color;\n }\n }\n\n & > .close { mix-blend-mode: difference }\n }\n\n &.active {\n & > div {\n textarea { opacity: 1 }\n }\n }\n}\n\n.composer--upload_form--actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $ui-secondary-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($ui-secondary-color, 4%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n}\n\n.composer--upload_form--progress {\n display: flex;\n padding: 10px;\n color: $darker-text-color;\n overflow: hidden;\n\n & > .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n & > .message {\n flex: 1 1 auto;\n\n & > span {\n display: block;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n & > .backdrop {\n position: relative;\n margin-top: 5px;\n border-radius: 6px;\n width: 100%;\n height: 6px;\n background: $ui-base-lighter-color;\n\n & > .tracker {\n position: absolute;\n top: 0;\n left: 0;\n height: 6px;\n border-radius: 6px;\n background: $ui-highlight-color;\n }\n }\n }\n}\n\n.compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n}\n\n.composer--options-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n height: 27px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n}\n\n.composer--options {\n display: flex;\n flex: 0 0 auto;\n\n & > * {\n display: inline-block;\n box-sizing: content-box;\n padding: 0 3px;\n height: 27px;\n line-height: 27px;\n vertical-align: bottom;\n }\n\n & > hr {\n display: inline-block;\n margin: 0 3px;\n border-width: 0 0 0 1px;\n border-style: none none none solid;\n border-color: transparent transparent transparent darken($simple-background-color, 24%);\n padding: 0;\n width: 0;\n height: 27px;\n background: transparent;\n }\n}\n\n.compose--counter-wrapper {\n align-self: center;\n margin-right: 4px;\n}\n\n.composer--options--dropdown {\n &.open {\n & > .value {\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n color: $primary-text-color;\n background: $ui-highlight-color;\n transition: none;\n }\n &.top {\n & > .value {\n border-radius: 0 0 4px 4px;\n box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);\n }\n }\n }\n}\n\n.composer--options--dropdown--content {\n position: absolute;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n background: $simple-background-color;\n overflow: hidden;\n transform-origin: 50% 0;\n}\n\n.composer--options--dropdown--content--item {\n display: flex;\n align-items: center;\n padding: 10px;\n color: $inverted-text-color;\n cursor: pointer;\n\n & > .content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n &:not(:first-child) { margin-left: 10px }\n\n strong {\n display: block;\n color: $inverted-text-color;\n font-weight: 500;\n }\n }\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n\n & > .content {\n color: $primary-text-color;\n\n strong { color: $primary-text-color }\n }\n }\n\n &.active:hover { background: lighten($ui-highlight-color, 4%) }\n}\n\n.composer--publisher {\n padding-top: 10px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n justify-content: flex-end;\n flex: 0 0 auto;\n\n & > .primary {\n display: inline-block;\n margin: 0;\n padding: 0 10px;\n text-align: center;\n }\n\n & > .side_arm {\n display: inline-block;\n margin: 0 2px;\n padding: 0;\n width: 36px;\n text-align: center;\n }\n\n &.over {\n & > .count { color: $warning-red }\n }\n}\n",".column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.column {\n overflow: hidden;\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n\n & > button {\n margin: 0;\n border: none;\n padding: 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n\n .wide .columns-area:not(.columns-area--mobile) & {\n flex: auto;\n min-width: 330px;\n max-width: 400px;\n }\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n margin-left: 0;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n\n // glitch - added focus ring for keyboard navigation\n &:focus {\n text-shadow: 0 0 4px darken($ui-highlight-color, 5%);\n }\n}\n\n.column-header__notif-cleaning-buttons {\n display: flex;\n align-items: stretch;\n justify-content: space-around;\n\n button {\n @extend .column-header__button;\n background: transparent;\n text-align: center;\n padding: 10px 0;\n white-space: pre-wrap;\n }\n\n b {\n font-weight: bold;\n }\n}\n\n// The notifs drawer with no padding to have more space for the buttons\n.column-header__collapsible-inner.nopad-drawer {\n padding: 0;\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n\n // notif cleaning drawer\n &.ncd {\n transition: none;\n &.collapsed {\n max-height: 0;\n opacity: 0.7;\n }\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.column-header__title {\n display: inline-block;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n// more fixes for the navbar-under mode\n@mixin fix-margins-for-navbar-under {\n .tabs-bar {\n margin-top: 0 !important;\n margin-bottom: -6px !important;\n }\n}\n\n.single-column.navbar-under {\n @include fix-margins-for-navbar-under;\n}\n\n.auto-columns.navbar-under {\n @media screen and (max-width: $no-gap-breakpoint) {\n @include fix-margins-for-navbar-under;\n }\n}\n\n.auto-columns.navbar-under .react-swipeable-view-container .columns-area,\n.single-column.navbar-under .react-swipeable-view-container .columns-area {\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 100% !important;\n }\n}\n\n.column-inline-form {\n padding: 7px 15px;\n padding-right: 5px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n margin-bottom: 6px;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 5px;\n }\n}\n",".regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n",".directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n",".search {\n position: relative;\n}\n\n.search__input {\n @include search-input();\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: color, transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(0deg);\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n padding: 15px 10px;\n font-size: 14px;\n font-weight: 500;\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n",null,".emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n","$doodleBg: #d9e1e8;\n.doodle-modal {\n @extend .boost-modal;\n width: unset;\n}\n\n.doodle-modal__container {\n background: $doodleBg;\n text-align: center;\n line-height: 0; // remove weird gap under canvas\n canvas {\n border: 5px solid $doodleBg;\n }\n}\n\n.doodle-modal__action-bar {\n @extend .boost-modal__action-bar;\n\n .filler {\n flex-grow: 1;\n margin: 0;\n padding: 0;\n }\n\n .doodle-toolbar {\n line-height: 1;\n\n display: flex;\n flex-direction: column;\n flex-grow: 0;\n justify-content: space-around;\n\n &.with-inputs {\n label {\n display: inline-block;\n width: 70px;\n text-align: right;\n margin-right: 2px;\n }\n\n input[type=\"number\"],input[type=\"text\"] {\n width: 40px;\n }\n span.val {\n display: inline-block;\n text-align: left;\n width: 50px;\n }\n }\n }\n\n .doodle-palette {\n padding-right: 0 !important;\n border: 1px solid black;\n line-height: .2rem;\n flex-grow: 0;\n background: white;\n\n button {\n appearance: none;\n width: 1rem;\n height: 1rem;\n margin: 0; padding: 0;\n text-align: center;\n color: black;\n text-shadow: 0 0 1px white;\n cursor: pointer;\n box-shadow: inset 0 0 1px rgba(white, .5);\n border: 1px solid black;\n outline-offset:-1px;\n\n &.foreground {\n outline: 1px dashed white;\n }\n\n &.background {\n outline: 1px dashed red;\n }\n\n &.foreground.background {\n outline: 1px dashed red;\n border-color: white;\n }\n }\n }\n}\n",".drawer {\n width: 300px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n padding: 10px 5px;\n flex: none;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n\n @include single-column('screen and (max-width: 630px)') { flex: auto }\n\n @include limited-single-column('screen and (max-width: 630px)') {\n &, &:first-child, &:last-child { padding: 0 }\n }\n\n .wide & {\n min-width: 300px;\n max-width: 400px;\n flex: 1 1 200px;\n }\n\n @include single-column('screen and (max-width: 630px)') {\n :root & { // Overrides `.wide` for single-column view\n flex: auto;\n width: 100%;\n min-width: 0;\n max-width: none;\n padding: 0;\n }\n }\n\n .react-swipeable-view-container & { height: 100% }\n}\n\n.drawer--header {\n display: flex;\n flex-direction: row;\n margin-bottom: 10px;\n flex: none;\n background: lighten($ui-base-color, 8%);\n font-size: 16px;\n\n & > * {\n display: block;\n box-sizing: border-box;\n border-bottom: 2px solid transparent;\n padding: 15px 5px 13px;\n height: 48px;\n flex: 1 1 auto;\n color: $darker-text-color;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n }\n\n a {\n transition: background 100ms ease-in;\n\n &:focus,\n &:hover {\n outline: none;\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.search {\n position: relative;\n margin-bottom: 10px;\n flex: none;\n\n @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n}\n\n.search-popout {\n @include search-popout();\n}\n\n.drawer--account {\n padding: 10px;\n color: $darker-text-color;\n display: flex;\n align-items: center;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n .acct {\n display: block;\n color: $secondary-text-color;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n overflow: hidden;\n}\n\n.drawer--results {\n background: $ui-base-color;\n overflow-x: hidden;\n overflow-y: auto;\n\n & > header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n & > section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n\n & > .hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n }\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n > .mastodon {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n cursor: inherit;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n",".video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n width: 100%;\n height: 100%;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 8%);\n }\n\n .status__content > & {\n margin-top: 15px; // Add margin when used bare for NSFW video player\n }\n @include fullwidth-gallery;\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 500;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n height: 100%;\n display: flex;\n flex-direction: column;\n\n span {\n text-align: center;\n color: $darker-text-color;\n display: flex;\n height: 100%;\n align-items: center;\n\n p {\n width: 100%;\n }\n }\n\n audio {\n width: 100%;\n }\n}\n\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n height: 110px;\n\n @include fullwidth-gallery;\n}\n\n.media-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n .full-width & {\n border-radius: 0;\n }\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n\n &.letterbox {\n background: $base-shadow-color;\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n object-fit: contain;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n display: flex;\n justify-content: center;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n width: 100%;\n position: relative;\n z-index: 1;\n object-fit: contain;\n user-select: none;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $white;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $ui-highlight-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n .detailed-status & {\n width: 100%;\n height: 100%;\n }\n\n @include fullwidth-gallery;\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n position: relative;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-shadow-color;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n\n .fa,\n &:active .fa,\n &:hover .fa,\n &:focus .fa {\n color: inherit;\n }\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n",".sensitive-info {\n display: flex;\n flex-direction: row;\n align-items: center;\n position: absolute;\n top: 4px;\n left: 4px;\n z-index: 100;\n}\n\n.sensitive-marker {\n margin: 0 3px;\n border-radius: 2px;\n padding: 2px 6px;\n color: rgba($primary-text-color, 0.8);\n background: rgba($base-overlay-background, 0.5);\n font-size: 12px;\n line-height: 18px;\n text-transform: uppercase;\n opacity: .9;\n transition: opacity .1s ease;\n\n .media-gallery:hover & { opacity: 1 }\n}\n",".list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n",".emoji-mart {\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: 0;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -3px;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: darken($ui-highlight-color, 3%);\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n",".glitch.local-settings {\n position: relative;\n display: flex;\n flex-direction: row;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n height: 80vh;\n width: 80vw;\n max-width: 740px;\n max-height: 450px;\n overflow: hidden;\n\n label, legend {\n display: block;\n font-size: 14px;\n }\n\n .boolean label, .radio_buttons label {\n position: relative;\n padding-left: 28px;\n padding-top: 3px;\n\n input {\n position: absolute;\n left: 0;\n top: 0;\n }\n }\n\n span.hint {\n display: block;\n color: $lighter-text-color;\n }\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n line-height: 24px;\n margin-bottom: 20px;\n }\n\n h2 {\n font-size: 15px;\n font-weight: 500;\n line-height: 20px;\n margin-top: 20px;\n margin-bottom: 10px;\n }\n}\n\n.glitch.local-settings__navigation__item {\n display: block;\n padding: 15px 20px;\n color: inherit;\n background: lighten($ui-secondary-color, 8%);\n border-bottom: 1px $ui-secondary-color solid;\n cursor: pointer;\n text-decoration: none;\n outline: none;\n transition: background .3s;\n\n .text-icon-button {\n color: inherit;\n transition: unset;\n }\n\n &:hover {\n background: $ui-secondary-color;\n }\n\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n\n &.close, &.close:hover {\n background: $error-value-color;\n color: $primary-text-color;\n }\n}\n\n.glitch.local-settings__navigation {\n background: lighten($ui-secondary-color, 8%);\n width: 212px;\n font-size: 15px;\n line-height: 20px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page {\n display: block;\n flex: auto;\n padding: 15px 20px 15px 20px;\n width: 360px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page__item {\n margin-bottom: 2px;\n}\n\n.glitch.local-settings__page__item.string,\n.glitch.local-settings__page__item.radio_buttons {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n@media screen and (max-width: 630px) {\n .glitch.local-settings__navigation {\n width: 40px;\n flex-shrink: 0;\n }\n\n .glitch.local-settings__navigation__item {\n padding: 10px;\n\n span:last-of-type {\n display: none;\n }\n }\n}\n",".error-boundary {\n color: $primary-text-color;\n font-size: 15px;\n line-height: 20px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n font-weight: 400;\n margin-bottom: 8px;\n }\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n }\n\n ul {\n list-style: disc;\n margin-left: 0;\n padding-left: 1em;\n }\n\n textarea.web_app_crash-stacktrace {\n width: 100%;\n resize: none;\n white-space: pre;\n font-family: $font-monospace, monospace;\n }\n}\n",".compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .drawer--account {\n flex: 0 1 48px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .composer {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px;\n min-height: 48px + 2px;\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n padding-top: 15px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .search {\n margin-bottom: 10px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n ul,\n .e-content & ul {\n margin: 0;\n list-style: none;\n }\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n display: block;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n width: 100%;\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n @include avatar-size(80px);\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n @include avatar-radius();\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n @include avatar-size(44px);\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: none;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n","$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;\n\n%emoji-outline {\n filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);\n}\n\n.emojione {\n @each $emoji in $emojis-requiring-outlines {\n &[title=':#{$emoji}:'] {\n @extend %emoji-outline;\n }\n }\n}\n\n.hicolor-privacy-icons {\n .status__visibility-icon.fa-globe,\n .composer--options--dropdown--content--item .fa-globe {\n color: #1976D2;\n }\n\n .status__visibility-icon.fa-unlock,\n .composer--options--dropdown--content--item .fa-unlock {\n color: #388E3C;\n }\n\n .status__visibility-icon.fa-lock,\n .composer--options--dropdown--content--item .fa-lock {\n color: #FFA000;\n }\n\n .status__visibility-icon.fa-envelope,\n .composer--options--dropdown--content--item .fa-envelope {\n color: #D32F2F;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .composer--publisher {\n text-align: left;\n }\n\n .boost-modal__status-time,\n .favourite-modal__status-time {\n float: left;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .setting-meta__label {\n float: left;\n }\n\n .status__avatar {\n margin-left: 10px;\n margin-right: 0;\n\n // Those are used for public pages\n left: auto;\n right: 10px;\n }\n\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 58px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n text-align: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","// components.scss\n.compose-form {\n .compose-form__modifiers {\n .compose-form__upload {\n &-description {\n input {\n &::placeholder {\n opacity: 1.0;\n }\n }\n }\n }\n }\n}\n\n.rich-formatting a,\n.rich-formatting p a,\n.rich-formatting li a,\n.landing-page__short-description p a,\n.status__content a,\n.reply-indicator__content a {\n color: lighten($ui-highlight-color, 12%);\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n }\n\n &.mention span {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.status__content__spoiler-link {\n color: $secondary-text-color;\n text-decoration: none;\n }\n}\n\n.status__content__read-more-button {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.getting-started__footer a {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.nothing-here {\n color: $darker-text-color;\n}\n\n.public-layout .public-account-header__tabs__tabs .counter.active::after {\n border-bottom: 4px solid $ui-highlight-color;\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/flavours/glitch/styles/reset.scss","webpack:///./app/javascript/flavours/glitch/styles/contrast/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/basics.scss","webpack:///./app/javascript/flavours/glitch/styles/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/containers.scss","webpack:///./app/javascript/flavours/glitch/styles/_mixins.scss","webpack:///./app/javascript/flavours/glitch/styles/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/footer.scss","webpack:///./app/javascript/flavours/glitch/styles/compact_header.scss","webpack:///./app/javascript/flavours/glitch/styles/widgets.scss","webpack:///./app/javascript/flavours/glitch/styles/forms.scss","webpack:///./app/javascript/flavours/glitch/styles/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/statuses.scss","webpack:///./app/javascript/flavours/glitch/styles/components/index.scss","webpack:///./app/javascript/flavours/glitch/styles/components/boost.scss","webpack:///./app/javascript/flavours/glitch/styles/components/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/components/domains.scss","webpack:///./app/javascript/flavours/glitch/styles/components/status.scss","webpack:///./app/javascript/flavours/glitch/styles/components/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/components/composer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/columns.scss","webpack:///./app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss","webpack:///./app/javascript/flavours/glitch/styles/components/directory.scss","webpack:///./app/javascript/flavours/glitch/styles/components/search.scss","webpack:///","webpack:///./app/javascript/flavours/glitch/styles/components/emoji.scss","webpack:///./app/javascript/flavours/glitch/styles/components/doodle.scss","webpack:///./app/javascript/flavours/glitch/styles/components/drawer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/media.scss","webpack:///./app/javascript/flavours/glitch/styles/components/sensitive.scss","webpack:///./app/javascript/flavours/glitch/styles/components/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/components/emoji_picker.scss","webpack:///./app/javascript/flavours/glitch/styles/components/local_settings.scss","webpack:///./app/javascript/flavours/glitch/styles/components/error_boundary.scss","webpack:///./app/javascript/flavours/glitch/styles/components/single_column.scss","webpack:///./app/javascript/flavours/glitch/styles/components/announcements.scss","webpack:///./app/javascript/flavours/glitch/styles/polls.scss","webpack:///./app/javascript/flavours/glitch/styles/about.scss","webpack:///./app/javascript/flavours/glitch/styles/tables.scss","webpack:///./app/javascript/flavours/glitch/styles/admin.scss","webpack:///./app/javascript/flavours/glitch/styles/accessibility.scss","webpack:///./app/javascript/flavours/glitch/styles/rtl.scss","webpack:///./app/javascript/flavours/glitch/styles/dashboard.scss","webpack:///./app/javascript/flavours/glitch/styles/contrast/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CC9EmB,iEDqFrB,kBCrFqB,4BDyFrB,sBACE,MEtFF,sBACE,mBACA,eACA,iBACA,gBACA,WCVM,kCDYN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,sIAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDpDmB,kBCwDnB,iBACE,kBACA,0BAEA,iBACE,YAIJ,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDzEgB,mBAZC,WCwFjB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,wBAEA,aACE,gBACA,WACA,YACA,kBACA,uBAGF,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,6BAKF,YAEE,WACA,mBACA,uBACA,oBACA,yEAKF,gBAEE,+EAKF,WAEE,gBErJJ,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SDrBI,YCuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WD9BE,qBCgCF,UACA,kBACA,iBACA,uBACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAKA,UACqB,sCC3EvB,iBD4EE,6BAEA,UACE,YACA,cACA,SACA,kBACA,iBD5BkB,wBE9DtB,4BACA,uBD8FA,aACE,cHjFmB,wBGmFnB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UD1UA,qCC6UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cHlVc,mBGoVd,kBACA,uHAEA,yBAGE,WDvWA,qCC2WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBH1aiB,8CG+anB,yBACE,gBACA,aACA,kBACA,mBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,CAEA,WACqB,yCCzgB3B,kBD0gBM,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,iBD7dc,wBE9DtB,4BACA,qCD+hBI,2CAvCF,YAwCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAKA,UACqB,sCCtiB7B,CDuiBQ,sBACA,wDAEA,QACE,kBACA,iBDrfY,wBE9DtB,4BACA,2DDsjBQ,mDAbF,YAcI,sCAKN,2CApEF,eAqEI,sCAGF,2CAxEF,cAyEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WDhlBF,gBCklBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WD1lBJ,gBC4lBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aHrmBQ,oDG4mBd,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cHjoBU,aGmoBV,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BHjqBW,wEGuqBX,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WDzsBJ,uBC2sBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cHluBY,uDGquBZ,oBACE,cHtuBU,qBGwuBV,aACA,gBACA,8DAEA,eACE,WD1vBJ,qCCgwBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aD3yBU,8DCizBV,mBACA,WDnzBE,qFCuzBJ,YAEE,eACA,cH7yBc,2CGizBhB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBHz3Ba,+IG43BX,kBAGE,WEl4BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,eChBJ,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,yBCrBF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cPnFc,6BOsFd,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cRYgB,gBQVhB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cRJiB,wBQQnB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBPPI,uBOUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBTlBmB,aSoBjB,0BACA,eACA,cTVgB,iBSYhB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aTxCmB,qBS0CjB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cTjEgB,+BSqElB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aT9FkB,aSmGpB,YACE,kBACA,mBTjHmB,mCSmHnB,qBAGF,YACE,kBACA,0BACA,kBACA,cT9GkB,mBSgHlB,iBAGF,eACE,eACA,cTrHkB,iBSuHlB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cThIgB,0BSoIlB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cT7IgB,qBS+IhB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBT3KmB,mCS6KnB,cT/JqB,gBSiKrB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cT5Mc,8DSkNhB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,ePlPM,COoPN,cACA,cTvOkB,mBSyOlB,+BANA,iBACA,CPlPM,kCOgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UPjQM,eOmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cT7PgB,qCSiQlB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBTvRqB,kBSyRnB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBTpSe,kBSsSf,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBT3SiB,eS6Sf,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WPnUE,mBOqUF,gBACA,uBACA,wBAEA,aT5Tc,0BSgUd,aACE,gBACA,eACA,eACA,cTpUY,yFS0Ud,UPvVE,+BO8VJ,aACE,YACA,uDAGF,oBTxViB,eS8VrB,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cTtYgB,gBSwYhB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WP3aI,8BO8aJ,aACE,cTlac,gBSoad,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aTngBkB,iCSkgBpB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cT1hBiB,4JS6hBjB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WP9jBI,gCOgkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MC/kBJ,+BACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WRhDA,cQkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aVjEoB,0BUmElB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aVvFkB,sBU0FhB,aVnGsB,yBUuGtB,iBACE,kBACA,mBACA,wBAIJ,aACE,eACA,eACA,qBAGF,kBACE,cV5GgB,iCU+GhB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WRzJA,gBQ2JA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WR/KE,cQiLF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WRrME,cQuMF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WRzRI,cQ2RJ,WACA,2CAKE,mBACE,eACA,WRnSA,qBQqSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WRnUI,cQqUJ,WACA,UACA,oBACA,gBACA,mBACA,yBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBR1VY,oLQ8VZ,iBACE,4WAGF,oBV/VsB,mBUkWpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBVzYiB,WEXb,eQuZJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBVhboB,gGUobpB,kBRpbQ,kHQubN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WRvcI,cQycJ,WACA,UACA,oBACA,gBACA,wXACA,yBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cVvdY,oBUydZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,iEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,URrhBF,aQ+hBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cV1hBkB,kBU4hBlB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cR/iBY,sBQmjBd,mCACE,+BACA,cRpjBQ,kBQwjBV,oBACE,cV9iBgB,qBUgjBhB,wBAEA,UR/jBI,0BQikBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gCACA,mBV/kBiB,WEDb,eQmlBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aV3mBkB,qBU6mBhB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aVxoBwB,yBU0oBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cV/oBkB,oCUkpBlB,cACE,mBACA,kBACA,4CAGF,aVtpBqB,gBUwpBnB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBR5rBM,YQ8rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cV3rBqB,WU6rBrB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WRxuBI,qCQ0uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,URhvBI,0BQkvBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cVlxBkB,0BUqxBlB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WR5yBI,kBQ8yBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aRtzBc,0SQg0BZ,+BACE,aAIJ,kBACE,yBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gCACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBR12Bc,gBQ42BZ,2BAEA,kBR92BY,gBQg3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCj7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,mBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,CAEA,UACqB,sCPpDzB,gBOqDI,wBAEA,UACE,YACA,cACA,SACA,kBACA,iBTLgB,wBE9DtB,4BACA,mBOoEM,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WT/EA,gBSiFA,gBACA,uBACA,+BAGF,aACE,eACA,cX3EY,gBW6EZ,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WT7GI,gBS+GJ,qBACA,iBACA,qBACA,sBAGF,eTrHM,oBSuHJ,WXxHI,eW0HJ,cACA,kBAGF,cACE,uCAGF,wBAEE,cXpHmB,oBWwHrB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBX7KqB,mCW+KnB,cX3JiB,eW6JjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cX5MwB,sCW8MxB,sCACA,6DAEA,aTnNc,sCSqNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cX/OmB,6BWiPnB,6BAGF,aACE,cXvPgB,4BW2PlB,aXpQwB,qBWsQtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aTtRY,gBSwRV,0CAGF,aT3RY,wCSgSd,eACE,wCAIJ,UACE,0BAIA,aX9RkB,4BWiShB,aX3SsB,qBW6SpB,qGAEA,yBAGE,iCAIJ,UTzTI,gBS2TF,wBAIJ,eACE,kBClUJ,kCACE,kBACA,gBACA,mBACA,qCAEA,iBANF,eAOI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBZxBmB,6GY2BjB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBZhEmB,WEXb,oBU8EN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UVxFI,gFU4FN,kBAGE,qNAKA,kBZlGoB,4IY0GpB,kBV1GQ,qCUiHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAKA,cACA,iBACA,mBACA,mFAGF,iBACE,eACA,WACA,WACA,WACA,qMAGF,eAGE,mEASF,cACE,gBACA,qFAGF,aZhKc,YYkKZ,eACA,WACA,eACA,gBACA,+GAGF,aACE,eACA,CACA,sBACA,eACA,yJAEA,cACE,uEAIJ,WACE,kBACA,WACA,eACA,iDAQF,iBACE,mBACA,yHAEA,iBACE,gBACA,+FAGF,UACE,oCAMR,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,WC9OJ,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cbHmB,SaKnB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,abxBsB,ea0BpB,SAIJ,wBACE,YACA,kBACA,sBACA,WXpCM,eWsCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,qCACA,mBACA,WACA,4CAEA,wBAGE,4BACA,qCACA,sBAGF,eACE,mFAEA,wBXnEQ,gBWuEN,kBAIJ,wBb3EsB,ea6EpB,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,Ub9FM,mBAGgB,qGa+FpB,wBAGE,8BAIJ,kBX1EsB,2GW6EpB,wBAGE,0BAIJ,cACE,iBACA,YACA,cbxGgB,oBa0GhB,uBACA,iBACA,kBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,cAIJ,oBACE,UACA,cbhIoB,SakIpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,uCACA,4BACA,2CACA,oBAGF,qCACE,uBAGF,aACE,6BACA,eACA,qBAGF,abzKwB,gCa6KxB,QACE,uEAGF,mBAGE,uBAGF,abvKmB,sFa0KjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,ab1MsB,uCa6MpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,abhNqB,SakNnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,aACE,6BACA,eACA,0BAGF,abjQwB,qCaqQxB,QACE,sFAGF,mBAGE,gBAIJ,iBACE,uBACA,YAGF,WACE,cACA,qBACA,QACA,SACA,kBACA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,uCAIJ,MACE,kBACA,CX/SU,sEWsTZ,aXtTY,uBW0TZ,aX3Tc,4DWiUV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UXrVM,0BWuVJ,eAIJ,aACE,eACA,gBACA,uBACA,mBACA,iBAEA,aACE,wBACA,sBAIA,cACA,gBAKA,yCAPF,WACE,CAEA,gBACA,uBACA,gBACA,mBAWA,CAVA,mBAGF,aACE,CACA,cAKA,8BAIA,yBACE,sBAIJ,SACE,YACA,eACA,iBACA,uBACA,mBACA,gBACA,CAME,sDAGF,cACE,YACA,kBACA,oBACA,qBAKN,eACE,wBAGF,cACE,eAGF,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cXxX4B,eAEC,0DWyX3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cXhZ4B,eAEC,WWiZ3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,WAIJ,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBbxd0B,ca0dxB,kBACA,uCACA,mBAEA,eACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0Bb5esB,2BagfxB,WACE,iBACA,uBACA,yBbnfsB,8BaufxB,QACE,iBACA,uBACA,4Bb1fsB,6Ba8fxB,SACE,gBACA,2BACA,2BbjgBsB,wBaugBxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBb7gBsB,WAJlB,gBaohBJ,uBACA,mBACA,yFAEA,kBb5gBiB,cAIE,Ua6gBjB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBbviBsB,cayiBtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBbhkBsB,WAJlB,gBaukBJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBbnkBiB,cAIE,iBaskBvB,qBACE,iBAIA,sBACA,cb7kBgB,oBaglBhB,cACE,gBACA,mBACA,kBACA,mBAGF,cACE,mBACA,iBAIJ,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WX3pBM,qBW6pBN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCTxoBA,6GADF,kBSgpBI,4BACA,kHT5oBJ,kBS2oBI,4BACA,wBAIJ,+BACE,cbhrBsB,sBaorBxB,eACE,aACA,2BAGF,aACE,eACA,kBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBb9rBiB,yBagsBjB,gBACA,kBACA,eACA,gBACA,iBACA,WXhtBI,mDWqtBR,oBACE,aAGF,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,gBAIJ,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBbzxBmB,qCa2xBnB,sEAGF,wBACE,4CAGF,wBbxxBqB,+Ea4xBrB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBbp1BmB,yDaw1BrB,kBbl2BqB,2Baw2BrB,iBACE,gBACA,cAGF,aACE,kBAGF,kBbj3BqB,cam3BnB,oBAEA,abv2BqB,oBa22BrB,ab52BgB,yBag3BhB,0BACE,CADF,uBACE,CADF,kBACE,kDAKA,sBACA,cACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,abl4Bc,eao4BZ,0DAEA,abt4BY,0Baw4BV,sDAIJ,oBACE,cb94Bc,sMai5Bd,yBAGE,0BAKN,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cbj6Bc,aam6Bd,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,ab57Bc,qBam8BpB,oBACE,kBACA,eACA,iBACA,gBACA,mBbp9BmB,gBas9BnB,iBACA,qBAGF,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,abn+BoB,uBaq+BlB,CACA,WACA,CADA,+BACA,sBACA,cACA,oBACA,mBACA,cACA,WACA,0CAEA,UX5/BM,4BFWa,qCIYjB,yDADF,cS6+BE,sBAGF,UbvgCM,gCaygCJ,sDAEA,Ub3gCI,4BAYa,mDaugCrB,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAIJ,uBACE,2BACA,gDAGF,abnhCsB,6BaqhCpB,uDAGF,abriC0B,yDayiC1B,aACE,YAGF,aACE,cbpiCgB,6BasiChB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,oBAGF,gBACE,qEAGF,4BACE,gCAGF,eACE,kBACA,MACA,QACA,YACA,kBACA,YAEA,mBACA,yBACA,eACA,aAEA,wCAEA,UX/hCsB,mBWiiCpB,aACA,sBACA,mBACA,uBACA,mBACA,8BACA,wBACA,gCACA,uCAGF,wBACE,kBACA,WACA,YACA,eACA,cbrmCgB,yBaumChB,aACA,uBACA,mBACA,sCAGF,mBACE,6CAEA,8BACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,oBAGF,abhoCkB,eakoChB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,6BAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,YACE,SACA,QACA,WACA,YACA,mBACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WX3yCE,gBW6yCF,eACA,+LAMA,yBACE,mEAKF,yBACE,iBAMR,aACE,iBACA,mEAGF,abzzCoB,qBa6zClB,mBACA,gBACA,sBACA,gBAGF,aACE,iBACA,uBAGF,eACE,8BAGF,ab50CoB,ea80ClB,cACA,gBACA,gBACA,uBAGF,qBACE,sBAGF,WACE,8BAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA3BF,qBAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,iBAIJ,0DACE,CADF,kDACE,cAGF,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBb36CmB,kCa66CnB,uBAGF,MACE,aACA,mBACA,uBACA,cbt6CqB,eaw6CrB,gBACA,0BACA,kBACA,qCAGF,SACE,oBACA,CADA,WACA,cAGF,wBbv7CqB,Way7CnB,kBACA,MACA,OACA,aACA,qBAGF,iBACE,aAGF,iBACE,cACA,aACA,WACA,yBbx8CmB,kBa08CnB,cACA,UACA,WACA,eAGF,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBb3+CsB,kBa6+CtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cbx/Cc,kBa0/Cd,+BAGF,ab7/CgB,ea+/Cd,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UXphDE,qBWshDA,oHAEA,yBAGE,yCAKN,QACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UXhjDI,oBWyjDN,kBACA,cACA,2BAGF,eACE,UAGF,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cb9jDmB,gBagkDnB,gBAEA,ab7kDsB,0Ba+kDpB,sBAEA,oBACE,gBAIJ,qBACE,4BAKN,GACE,cACA,eACA,WARI,mBAKN,GACE,cACA,eACA,2CCrmDF,u+KACE,uCAEA,u+KACE,CAOA,8MAMF,okBACE,UClBJ,YACE,gCACA,cACA,qBACA,iCAEA,aACE,cACA,cfOgB,gBeLhB,qBACA,eACA,gBAGF,WACE,UACA,yCAEA,8CAEA,WACE,iBACA,mBAKN,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,gBb0BwB,wBE9DtB,4BACA,kBWqCA,eACA,yBAEA,oBACE,sBACA,iBACA,4BX3CF,eWiDE,2DAHF,gBbesB,wBE9DtB,4BACA,CWgDE,iBAOE,CANF,+BXjDF,UWqDI,CACA,qBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WbhEE,6BakEF,gBACA,eACA,0BAKN,iBACE,WACqB,sCXpErB,+BANA,UW8EuB,sCXxEvB,gEWsEA,gBbfsB,wBE9DtB,4BWyFE,CXlFF,iCANA,UWmFuB,sCX7EvB,kBW+EE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,2BAGF,aACE,mBACA,sBAGF,YACE,cf3FgB,6Be8FhB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,4BAGF,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,8BACA,eACA,oCACA,uCAEA,aACE,kCAGF,+BACE,gCAGF,aACE,yBACA,eACA,cfzJgB,kCe6JlB,aACE,eACA,gBACA,Wb7KI,CakLA,2NADF,eACE,gCAKN,afnLwB,oBewL1B,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,wBAGF,gBACE,qBACA,eACA,cfjMkB,eemMlB,kBACA,4BAEA,af/MwB,6BemNxB,aACE,gBACA,uBACA,iBAIJ,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,afrOoB,eeuOlB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SX5MF,sBACA,WACA,YACA,gBACA,oBACA,mBJxDmB,cAYD,eI+ClB,SACA,+EWsMI,aACE,CXvMN,qEWsMI,aACE,CXvMN,yEWsMI,aACE,CXvMN,gEWsMI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,af5Qc,iBe8QZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aftSgB,0He2ShB,cAEE,gBACA,cf7SY,kZegTZ,aAGE,gEAIJ,wBACE,iDAGF,ebzUI,kBEkEN,CAEA,eACA,cJhDiB,uCIkDjB,UWoQI,mBfxUoB,oDIsExB,wBACE,cJrDe,eIuDf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WJ3FI,sDegVJ,WACE,mDAGF,UfpVI,kBesVF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UbtWQ,kBawWN,cACA,mBACA,sBb3WM,yBa6WN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,6BAIJ,YACE,eACA,gBACA,wBAGF,WACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cfpZgB,eesZhB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,af3ZmB,qWe8ZjB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,sBAQR,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cfjdc,Ceodd,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,2BAIJ,af9eoB,eegflB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,Wb/mBA,gBainBA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cf3mBU,gBe6mBV,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,Wb5oBE,gDagpBJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,ab3pBU,yBaiqBd,cACE,gCAEA,cACE,cfzpBc,ee2pBd,kCAEA,oBACE,cf9pBY,qBegqBZ,iBACA,gBACA,yCAEA,eACE,WblrBF,ScFR,YACE,gCACA,8BAEA,aACE,cACA,WdJI,qBcMJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,mCCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,EDGF,0BCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,qCAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,EAtBA,2BAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,gCAIJ,cACE,kBAGF,iBACE,cACA,eACA,iBACA,qBACA,gBACA,iBACA,gBACA,wBAEA,SACE,4BAGF,UACE,YACA,gBACA,sBAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,qEAGF,kBACE,qBACA,sGAEA,eACE,qEAIJ,eAEE,qJAEA,kBAEE,mXAGF,eACE,mBACA,qJAGF,eACE,gBACA,2EAGF,eACE,+NAGF,eACE,2FAGF,iBACE,8BACA,cjBjGc,mBiBmGd,qHAEA,eACE,2JAIJ,eACE,mJAGF,iBACE,6EAGF,iBACE,eACA,6EAGF,iBACE,qBACA,qJAGF,eACE,6JAEA,QACE,2EAIJ,oBACE,2EAGF,uBACE,oBAIJ,af9Ic,qBegJZ,0BAEA,yBACE,8BAEA,aACE,kCAKF,oBACE,uCAEA,yBACE,wBAKN,ajBlKc,4CiBuKhB,YACE,8EAEA,aACE,mCAIJ,aACE,oDAEA,af5LQ,ee8LN,iDAIJ,kBACE,uDAEA,kBACE,qBACA,gCAKN,oBACE,kBACA,mBACA,YACA,WjBrNM,gBiBuNN,eACA,cACA,yBACA,oBACA,eACA,sBACA,sCAEA,kBACE,qBACA,+DAGF,oBACE,iBACA,sBACA,kBACA,eACA,oBACA,2GAKF,oBAGE,4BAIJ,ajBvOkB,SiByOhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,mDAGF,iBAEE,gCAGA,qEAEA,eACE,kBAKF,SACE,mBACA,kDAEA,kBACE,wDAEA,sBACE,iFAIJ,kBAEE,SAKN,iBACE,kBACA,YACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QAPF,kBAUI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,ajBpTiB,qCiBwTjB,UjB7UI,6BiBiVJ,ajB3Te,CAtBX,kEiByVJ,UjBzVI,kCiB4VF,ajBvVoB,gEiB2VpB,Uf/VE,mBFEgB,sEiBiWhB,kBACE,mBAMR,uBACE,sBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,yCAEA,aACE,kBACA,OACA,QACA,MACA,SACA,6FACA,oBACA,WACA,2DAGF,oBACE,oCAGF,WACE,gBACA,uBACA,cACA,0CAEA,UACE,kBACA,MACA,gBACA,6DACA,oBACA,4CAGF,oBACE,gDAGJ,oDACE,mEAEF,oDACE,0CAGF,eACE,6DAGF,kBACE,gCAIJ,mBACE,+CAKF,sBACE,qEAEA,aACE,wBAKN,oBACE,YACA,CjBragB,ciBuahB,iBACA,mBACA,CACA,sBACA,8CANA,ajBragB,CiByahB,eAOA,8CAGF,aACE,eACA,eAGF,YACE,8BACA,eACA,oBAEA,sBACE,gBACA,2CAGF,oBACE,sBAIJ,YACE,mBACA,WACA,cjBvcoB,iIiB0cpB,gBAGE,kBACA,0EAGF,yBACE,yEAMA,0CACE,CADF,kCACE,2EAKF,2CACE,CADF,mCACE,wBAKN,YACE,mBACA,2BACA,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,mBACA,iBACA,cjBthBgB,CiBwhBhB,iBACA,eACA,kBACA,+CAEA,ajB7hBgB,uBiBiiBhB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cjBvjBgB,4BiB6jBtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cjBnnBgB,eiBqnBhB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,6JAGF,oBAME,4DAKA,UfxqBM,kBe8qBN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,ajB5rBuB,ciB8rBrB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,Wf1tBI,kCe+tBR,UACE,kBACA,iBAGF,SACE,kBACA,YACA,WACA,CjB1tBgB,8IiBquBhB,ajBruBgB,wBiByuBhB,UACE,wCAGF,kBf7tBsB,WF/BhB,8CiBgwBJ,kBACE,qBACA,+DAOJ,yBACE,cAIJ,YACE,eACA,yBACA,kBACA,cjBnwBgB,gBiBqwBhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cjB9xBiB,uBiBgyBjB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UftzBE,yBe6zBJ,cACE,kBACA,YACA,+DAGF,aACE,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACE,YACA,SACA,2BAIF,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cjBv1BkB,gBiBy1BlB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,ajBr2BoB,oBiBy2BpB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,kBAGF,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cjB77Bc,iBiB+7Bd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cjB39BY,gBiB69BZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,ajB9+Bc,oCiBo/BlB,cACE,cACA,SACA,uBACA,UACA,kBACA,oBACA,oFAEA,yBAEE,6BC/gCJ,kBACE,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,6CAGF,kBlBrC0B,WAJlB,kBkB8CN,gBACA,aACA,sBACA,0BAGF,WACE,WACA,gBACA,iBACA,8DAEA,UACE,YACA,sBACA,aACA,sBACA,mBACA,uBACA,aACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAIJ,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,qCAGF,kBACE,UACE,YACA,gBACA,0BAGF,UACE,YACA,eACA,gBACA,cACA,oDAIJ,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,alB9GmB,SkBiHjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,UlBvJI,qwDkB2JF,aAGE,sBAMR,sBACE,yBAGF,aACE,aACA,mBACA,uBACA,wBAGF,UACE,YACA,mBACA,mBACA,aACA,eACA,8BAEA,kBACE,+BAGF,cACE,mBACA,kCAIJ,mBACE,CACA,mBACA,0EAEA,mBACE,yBAIJ,cACE,iBACA,4BAEA,cACE,gBACA,WlBjNI,mBkBmNJ,2BAGF,alBjNwB,kGkBoNtB,aAGE,2CAIJ,aACE,2BAGF,cACE,clBhNiB,gBkBkNjB,mBACA,sCAEA,eACE,kCAGF,eACE,mBlB7Oe,cAcE,kBkBkOjB,eACA,gBACA,CAII,2NADF,eACE,oCAOV,WACE,UACA,mCAME,mBACA,mBACA,sCAEA,cACE,iBACA,kBACA,qCAGF,eACE,oCAIJ,kBACE,mBACA,kBACA,eAIJ,iBACE,eACA,mBACA,sBAEA,eACE,WlBnSI,kBkBqSJ,yBACA,eACA,qBAGF,kBlBxSmB,cAcE,gBkB6RnB,aACA,kBACA,6HAQF,eACE,qJAGF,kBACE,clB5SmB,mBkB8SnB,kBACA,aACA,kBACA,eACA,sCACA,yPAEA,iBACE,mBACA,qNAGF,mBACE,gBACA,4CAMJ,YACE,mBACA,gDAEA,UACE,cACA,4DAEA,aACE,2DAGF,cACE,kDAGF,iBACE,uDAIJ,eACE,sDAIJ,UhB3WM,2DgBgXR,0BACE,cACE,iBACA,qJAGF,cAIE,mBACA,4CAGF,kBACE,sDAGF,WACE,eACA,mBAIJ,oBACE,eACA,gBACA,iBACA,uHAGF,kBAOE,WlBvZM,kBkByZN,gBACA,eACA,YACA,kBACA,sBACA,+SAEA,alBjZgB,YkBmZd,eACA,WACA,eACA,gBACA,uSAGF,YACE,uPAGF,WACE,WACA,+WAGF,UACE,wBAKF,ehBvbM,CFGkB,gBkBubtB,oBACA,iEhB3bI,2BFGkB,qDkBgc1B,iBAEE,aACA,qEAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,kKAIJ,YAKE,8BACA,mBlBjdwB,akBmdxB,iBACA,0LAEA,aACE,iBACA,clBvciB,mBkBycjB,kNAGF,aACE,6DAIJ,cAEE,yDAGF,WAEE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,alBvhBwB,qCkB2hBxB,oDAZF,eAaI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UlB1jBI,gBECA,agB4jBJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,ehB5kBI,yBgB8kBF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UlB7lBE,oBkB+lBA,eACA,gBhB/lBA,+CgBomBJ,YACE,8BACA,mBACA,4CAIJ,aACE,WlB7mBI,ekB+mBJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UlBxnBI,ekB0nBF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eAWE,eACA,wBAXA,eACE,iBACA,uBAGF,aACE,gBACA,2CAMF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UlBzqBE,akB2qBA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBlB9qBW,WEXb,iJgBgsBA,iBAGE,oMAUR,aACE,iIAIJ,4BAIE,clBlsBmB,ekBosBnB,gBACA,6cAEA,aAGE,6BACA,uCAIJ,iBACE,mBACA,oBACA,eAEA,yFAEA,qBACE,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UlBnxBI,CkBqxBF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gDACA,mBlB5zBe,WEDb,egBg0BF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,cAKN,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBhB12BM,yDgB62BN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBhBr3BI,uBgBy3BN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UhBt5BI,egBw5BF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,wBAKN,gBACE,sCAEA,eACE,gCAGF,eACE,qDAGF,UlB57BM,iDkBg8BN,YACE,0DAEA,YACE,0BAIJ,YACE,iBACA,uBACA,kDAGF,alB57BoB,qBkB87BlB,wDAEA,yBACE,WCp9BN,YACE,kCAEA,iBACE,MACA,QACA,oIAEA,+BAEE,oBAKN,cACE,uBACA,eACA,gBACA,cnBGmB,yDEjBP,sCiBsBd,2CACE,oBAGF,QACE,wBACA,UACA,+CAEA,WACE,mBACA,UACA,0BAGF,aACE,sBACA,SACA,YACA,kBACA,aACA,WACA,UACA,WnBjDI,gBECA,eiBmDJ,oBACA,gBACA,qDAEA,anBzCc,CmBuCd,2CAEA,anBzCc,CmBuCd,+CAEA,anBzCc,CmBuCd,sCAEA,anBzCc,gCmB6Cd,8Cf/CA,uCADF,ceiD4D,0Cf5C5D,ce4C4D,oBAI9D,UnBjEQ,mBmBmEN,mBnBhEsB,oCmBkEtB,iBACA,kBACA,eACA,gBACA,sBAEA,anBtDmB,gBmBwDjB,0BACA,mFAEA,oBAEU,iCAKZ,mBACA,eAEA,gBACA,wCAEA,anBxFwB,sDmB4FxB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,gBACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBnB9GsB,qCmBqH1B,eACE,kBACA,aACA,mBnB1HsB,gBmB4HtB,gBACA,cACA,yBAEA,iBACE,gBACA,wCAEA,UnBvII,iCmByIJ,WACE,iBACA,2BAIJ,iBACE,cACA,CACA,cACA,iBACA,WnBpJI,qBmBsJJ,gBACA,iBACA,qBACA,mBACA,gBACA,gGAEA,kBACE,qBACA,iIAEA,eACE,kJAIJ,eACE,mBACA,2DAGF,eACE,eACA,8BAGF,cACE,wFAGF,eACE,sCAGF,iBACE,2BACA,WnB1LE,mBmB4LF,mDAEA,eACE,8DAIJ,eACE,0DAGF,iBACE,+BAGF,iBACE,eACA,2DAGF,eACE,+DAEA,QACE,8BAIJ,oBACE,8BAGF,uBACE,6BAGF,anB3MiB,qBmB6Mf,mCAEA,oEAGE,oBACE,gDAEA,qDAMR,UACE,YACA,gBACA,uDAIJ,iBAEE,WACA,mIAGE,aACE,sBACA,SACA,YACA,0BACA,yBACA,WACA,iBACA,UACA,WnBtQE,gBECA,eiBwQF,oBACA,YACA,qBACA,yLAEA,anB/PY,CmB6PZ,sKAEA,anB/PY,CmB6PZ,8KAEA,anB/PY,CmB6PZ,4JAEA,anB/PY,yKmBmQZ,SACE,qJAGF,kBnBlRoB,+ImBmRpB,8Cf1QF,8JADF,ce4Q8D,kKfvQ9D,ceuQ8D,qCfhQ5D,8TADF,sBeoQM,gBACA,6BAMR,aACE,kBACA,SACA,UACA,WACA,gBACA,2CAEA,aACE,mBACA,WACA,YACA,cnBzRiB,emB2RjB,iBACA,kBACA,WACA,4CAIJ,iBACE,SACA,oCAGF,aACE,kBACA,sBACA,SACA,0BACA,YACA,WACA,WnBnUM,mBAIkB,sCmBkUxB,eACA,WACA,aACA,6CAGF,aACE,0CAGF,YACE,eACA,kBACA,iMAEA,kBAGa,iKAEb,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,+DAGF,6BACE,qEAEA,aACE,gBACA,uBACA,mBACA,sEAGF,eACE,qEAGF,aACE,iBACA,gBACA,uBACA,mBACA,4EAMA,anBzWe,wBmB8WrB,eACE,iCAEA,YACE,mBACA,eACA,oBACA,YACA,gBACA,8BAIJ,UACE,WACA,cACA,kCAEA,iBACE,kBACA,aACA,WACA,sBjBzZI,wBiB2ZJ,sBACA,4BACA,gBACA,2CAEA,aACE,kBACA,sBACA,SACA,OACA,SACA,SACA,aACA,WACA,cnBzZiB,gFmB2ZjB,eACA,oBACA,gBACA,UACA,UACA,4BACA,iDAEA,UjBlbE,sEiBobF,WACE,cnBtae,CEff,4DiBobF,WACE,cnBtae,CEff,gEiBobF,WACE,cnBtae,CEff,uDiBobF,WACE,cnBtae,yCmB2anB,2EAKE,0CAKN,iFACE,aACA,uBACA,8BACA,UACA,4BACA,8CAEA,aACE,cnB1csB,emB4ctB,gBACA,aACA,oBACA,2JAEA,aAGE,wCAIJ,SACE,kCAIJ,YACE,aACA,cnBrdkB,gBmBudlB,sCAEA,cACE,kBACA,2CAGF,aACE,gDAEA,aACE,eACA,gBACA,yBACA,qDAGF,iBACE,eACA,kBACA,WACA,WACA,mBjB5dkB,8DiB+dlB,iBACE,MACA,OACA,WACA,kBACA,mBnBvfa,0BmB8frB,UnB1gBQ,oBmB4gBN,eACA,gBjB5gBM,4BiBghBR,YACE,mBACA,0BACA,YACA,aACA,8BACA,cACA,oBAGF,YACE,cACA,sBAEA,oBACE,uBACA,cACA,YACA,iBACA,sBACA,uBAGF,oBACE,aACA,CAEA,oBACA,CADA,6BACA,UACA,QACA,YACA,uBACA,2BAIJ,iBACE,iBACA,0CAKE,yBACE,qCACA,WjB7jBE,mBFWa,gBmBqjBf,8CAGA,yBACE,oCACA,uCAMR,iBACE,kBACA,uCACA,gBjB9kBM,gBiBglBN,uBACA,6CAGF,YACE,mBACA,aACA,WnBxlBM,emB0lBN,sDAEA,aACE,cnBxkBiB,wEmB2kBjB,6EAEA,aACE,WnBnmBE,gBmBqmBF,sGAIJ,kBnB7lBmB,WEXb,6PiBgnBF,UjBhnBE,0DiBonBN,wCAGF,gBACE,iBACA,mBACA,gBACA,yBACA,cACA,+BAEA,oBACE,SACA,eACA,kBACA,gCAGF,oBACE,aACA,UACA,WACA,kBACA,kCAIA,ajB5oBU,CkBFZ,+BAHF,YACE,cACA,kBAUA,CATA,cAKA,kBACA,2BACA,gBAEA,uBAEA,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,wHAMJ,WAGE,mCAIJ,YACE,mBACA,uBACA,YACA,CpBrFmB,IoBoGrB,aACE,aACA,sBACA,WACA,YACA,CAIA,oBAGF,qBACE,WACA,mBACA,cpBhHwB,eoBkHxB,cACA,eACA,SACA,iBACA,aACA,SACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cpBnIwB,eoBqIxB,cACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,cAGF,kBACE,WlB5KM,ckB8KN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cpB3LsB,kGoB8LtB,sBAGE,WlBpME,kCkBwMJ,apB7LiB,oBoBmMrB,oBACE,iBACA,oBAGF,kBpBlNqB,cAaH,iBoBwMhB,eACA,gBACA,yBACA,eACA,yBAGF,iBACE,cACA,UACA,gCAEA,sCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,gFACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,uBAEA,QACE,YACA,aACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,apBhRwB,4CoBqRtB,apBrRsB,wCoBuRpB,4CAIJ,SAEE,SAIJ,WACE,kBACA,sBACA,aACA,sBACA,gBACA,wDAEA,SACE,gBACA,gBACA,qBAGF,kBpBlTmB,yBoBuTrB,WACE,aACA,cACA,uBAGF,kBACE,iCAGF,iBACE,sEAGF,kBACE,SACA,cpB3TkB,eoB6TlB,eACA,eACA,kFAEA,aACE,CAKA,kLAEA,UlBtVI,mBkBwVF,kFAKJ,2BACE,wCAIJ,YACE,oBACA,6BACA,+CAEA,sBAEE,kBACA,eACA,qBACA,0CAGF,eACE,gDAKJ,SACE,6BAGF,eACE,gBACA,gBACA,cpB/WkB,0DoBiXlB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,iCAIF,eACE,2CACA,YACE,WACA,mCAKN,kBACE,aACA,mCAIA,apBvZkB,0BoByZhB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,uBAKN,oBACE,uBACA,gBACA,mBACA,OACA,sBAGF,oBACE,iBACA,6EAGF,apBrbkB,mBAbG,kBoBucnB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBACA,4EAdF,cAeI,6FAGF,eACE,mFAGF,apBrdwB,qBoBudtB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,uCAKA,sBACE,6BACA,qCASF,qCAXA,sBACE,6BACA,sCAgBF,mJAFF,qBAGI,sBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,kBACA,uCAEA,SACE,kCAKN,aACE,aACA,yBC9hBJ,iBACE,eACA,gBACA,crBagB,mBAbG,eqBGnB,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,crBjBY,qCqBqBd,cACE,gBACA,kBCtCJ,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,mBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WpB1EF,gBoB4EE,gBACA,uBACA,0CAGF,aACE,eACA,ctBtEU,gBsBwEV,gBACA,uBACA,yBAKN,kBtB3FiB,asB6Ff,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,cAOV,kBtBjIqB,sBsBoInB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,CC/KJ,eAGF,SnBkDE,sBACA,WACA,YACA,gBACA,oBACA,mBJxDmB,cAYD,eI+ClB,SACA,cmBxDA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,4CACA,eACA,WACA,YACA,cvBxCmB,euB0CnB,oBACA,0BAEA,mBACE,WACA,0BAIJ,sBACE,iCAEA,mBACE,WACA,gCAIJ,QACE,uBACA,cvB5DkB,euB8DlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,avB7EkB,mBuB+EhB,gCACA,kBACA,eACA,gBACA,uBAGF,YACE,cvBxFkB,kBuB0FlB,iBAIA,avB7FgB,mBuB+Fd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cvBvHY,gBuByHZ,uBACA,mBACA,4BAEA,eACE,uBAGF,avBlIc,qBuBoIZ,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cvB3JiB,0BuB+JnB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,WC1LR,yCCCE,qBACA,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,8BAIJ,evBXQ,kBuBaN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBvBvCM,kBuByCN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,eAIJ,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,0EAMA,SACE,oBACA,CADA,WACA,eChGN,WAEE,0BAGF,kBANW,kBAQT,cACA,iCACA,wBACE,mCAOF,WACE,SACA,UACA,2CAGF,aACE,aAEA,sBACA,YACA,6BACA,6DAGE,oBACE,WACA,iBACA,iBACA,iJAGF,UACE,gEAEF,oBACE,gBACA,WACA,2CAKN,yBACE,sBACA,kBACA,YACA,gBACA,kDAEA,uBACE,CADF,oBACE,CADF,eACE,WACA,YACA,SACA,4BACA,WACA,yBACA,eACA,4CACA,sBACA,oBACA,6DAEA,uBACE,6DAGF,sBACE,wEAGF,sBACE,kBACA,SCjFR,WACE,sBACA,aACA,sBACA,kBACA,iBACA,UACA,qBAEA,iBACE,oBAGF,kBACE,2DvBDF,SuBI0D,yBvBC1D,SuBD0D,qCvBQxD,qLuBLA,yBAGF,eACE,gBACA,eACA,qCvBZA,4BuBgBA,SACE,WACA,YACA,eACA,UACA,+BALF,SACE,WACA,YACA,eACA,UACA,yCAIJ,4BAGF,YACE,mBACA,mBACA,UACA,mBACA,eACA,mBAEA,aACE,sBACA,oCACA,sBACA,YACA,cACA,c3BzCgB,kB2B2ChB,qBACA,eACA,mBAGF,iCACE,iDAEA,YAEE,mBACA,mCACA,SAKN,iBACE,mBACA,UACA,qCvBrDE,6CADF,euBwDkF,sCvBlEhF,sBADF,cuBoE0D,yBvB/D1D,cuB+D0D,gBAG5D,ezBlFQ,kBEkEN,CACA,sBACA,gBACA,cJhDiB,uCIkDjB,mBAEA,wBACE,cJrDe,eIuDf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WJ3FI,kB2BuFR,YACE,c3B1EkB,a2B4ElB,mBACA,oBAEA,aACE,qBACA,wBAGF,aACE,c3BnFmB,gB2BqFnB,mBACA,gBACA,uBACA,0BAIJ,aACE,gBACA,gBACA,kBAGF,kB3BhHqB,kB2BkHnB,gBACA,yBAEA,a3BxGgB,mB2B0Gd,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,c3BhIY,iC2BmIZ,oBACE,iBACA,8FAIJ,eAEE,mCAGF,aACE,aACA,c3B/IiB,qB2BiJjB,0HAEA,aAGE,0BACA,gBAQN,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAgBA,CAfA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,uBAEA,kB3BlMmB,0B2BuMrB,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oCAGF,aACE,WACA,YACA,YACA,eACA,sCAGF,yBAzBF,aA0BI,iBAIJ,kBACE,eACA,gBACA,mBAGF,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,oBCrPF,kBACE,gB1BAM,WACA,e0BEN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,e1BdQ,cFcY,S4BGlB,WACA,YACA,iEAEA,aAGE,iCAGF,eACE,2BxBcF,iBACE,mBACA,cACA,eACA,aACA,gBACA,yBwBfJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,6BAGF,aACE,kBACA,W1B7CM,0B0B+CN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,WACE,aACA,sBACA,4BAEA,iBACE,c5B9DgB,a4BgEhB,YACA,mBACA,CAGE,yDAIJ,UACE,gBAIJ,qBACE,eACA,gBACA,kBACA,kBACA,WACA,aACA,2BxBzDA,iBACE,mBACA,cACA,eACA,aACA,gBACA,sBwBwDJ,WACE,sBACA,cACA,WACA,kBACA,kBACA,gBACA,kCAEA,eACE,qEAIA,cACE,MACA,gCAIJ,e1B5HM,gC0BiIR,cACE,cACA,qBACA,c5BpHqB,kB4BsHrB,UACA,mEAEA,WAEE,WACA,sBACA,CADA,gCACA,CADA,kBACA,CAIE,0HAFF,WACE,oBACA,CADA,8BACA,CADA,gB1BhJE,C0BiJF,wBAKN,UACE,CAEA,iBACA,MACA,OACA,UACA,gB1B7JM,iC0BgKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,aACA,uBACA,qCAGF,cACE,YACA,WACA,kBACA,UACA,sBACA,CADA,gCACA,CADA,kBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,qDAEA,WACE,oBACA,CADA,8BACA,CADA,gBACA,sCAIJ,0BACE,2BACA,gBACA,kBACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,c1BrK0B,eAEC,C0B+K7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,W1BjQM,e0BmQN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,c5B5SsB,mF4B+StB,yBAGE,wBAKN,oBACE,sBAGF,qB1B9TQ,Y0BgUN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wB5B9TqB,qB4BkUrB,iBACE,UACA,QACA,YACA,qKAKA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gB1BnZM,e0BqZN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,gCAGF,UACE,YACA,0BxB3XF,iBACE,mBACA,cACA,eACA,aACA,gBACA,qBwB0XF,eACE,gBACA,UACA,kBACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gB1BxeI,cFcY,gB4B6dhB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,CAGE,gUAEA,aAIE,wBAKN,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,U1B9iBE,+E0BsjBN,cAGE,gBACA,6BAGF,U1B7jBM,iB0B+jBJ,yBAGF,oBACE,aACA,mDAGF,U1BvkBM,uB0B4kBN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,W1B5nBE,sF0B+nBF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,iBCtsBR,YACE,mBACA,mBACA,kBACA,QACA,SACA,YACA,mBAGF,YACE,kBACA,gBACA,yBACA,0BACA,eACA,iBACA,yBACA,WACA,4BACA,wCAEA,uBCtBF,kB9BGqB,sB8BDnB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kB9BnDqB,sB8BqDnB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,aC3FJ,cAOE,qBACA,W/BPM,gD+BEJ,iBACA,+BAOF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mB/BjBsB,4B+BqBxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,c/BfmB,c+BiBnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,a/BlD0B,mC+BqDxB,aACE,oDAGF,QACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBACA,uBAIA,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gB7B5FM,sB6B8FN,sGAEA,+BAEE,oBAKF,2BACA,gB7BxGM,0B6B2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,W/BnHI,yB+BqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gB7BpKI,mB6ByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,c/BlKiB,mD+BqKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,wBC1MF,iBACE,aACA,mBACA,mBhCEwB,WAJlB,kBgCKN,YACA,WACA,gBACA,iBACA,gBACA,4DAEA,aACE,eACA,mFAGF,iBACE,kBACA,gBACA,+FAEA,iBACE,OACA,MACA,kCAIJ,aACE,chCTiB,2BgCanB,cACE,gBACA,iBACA,mBACA,2BAGF,cACE,gBACA,iBACA,gBACA,mBACA,0CAIJ,aACE,kBACA,cACA,mBACA,gCACA,eACA,qBACA,aACA,0BACA,4DAEA,aACE,iBACA,gDAGF,kBhC9DwB,iDgCkExB,kBhC1DmB,WEXb,qG8B0EN,kB9BxEU,WAFJ,oC8BgFR,kBACE,YACA,eACA,iBACA,gBACA,8BAGF,aACE,UACA,kBACA,YACA,gBACA,oCAGF,iBACE,4FAGF,eAEE,mBACA,qCAGF,mCACE,UACE,cACA,0CAGF,YACE,4DAEA,YACE,kBCtHN,U/BEQ,gC+BCN,oBAEA,cACE,iBACA,gBACA,kBACA,mBAGF,U/BVM,0B+BYJ,oBAGF,eACE,cACA,iBACA,mDAGF,UACE,YACA,gBACA,gCACA,gBC3BJ,WACE,gBACA,aACA,sBACA,yBACA,kBACA,+BAEA,gBACE,eACA,CACA,2BACA,kCAGF,QACE,iCAGF,aACE,6BAGF,sBACE,0BAGF,MACE,kBACA,aACA,sBACA,iBACA,mDAGF,eACE,sBhClCI,0BgCoCJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,YACE,gBACA,gLAEA,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,CAIA,yFAGF,eACE,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WhCjNM,kBgCmNN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,mCAGF,kBAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,gCC1QJ,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,anCFqB,qBmCInB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,gBAKN,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBnCvFoB,kBmCyFpB,cACA,eACA,4BAIJ,YACE,cnCvFgB,kBmCyFhB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,cnCrJc,mFmCyJhB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,anCxLkB,SmC0LhB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OCjON,eACE,eACA,8BAEA,QAEE,gBACA,UAGF,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBpCTiB,eoCcnB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAIA,qBACA,WACA,eACA,WpCtDE,coCwDF,UACA,oBACA,gBlCzDE,yBkC2DF,kBACA,iBACA,sCAEA,oBpC3DoB,0BoCgEtB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBlClGY,8EkCuGZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,cpC9Hc,aoCkIhB,cACE,uBACA,UACA,SACA,SACA,cpCvIc,0BoCyId,kBACA,mBAEA,oBACE,sCAGF,qCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,kBACA,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,UAEE,aACA,+GAEA,oBpC3LoB,sDoCiMxB,cACE,gBACA,iBACA,YACA,oBACA,cpCzLkB,sCoC4LlB,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WpC/NI,qBoCiOJ,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,apClOkB,qBoCqOhB,+BACE,6BAEA,6BACE,YCpPN,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,sBACE,eACA,gBACA,gBACA,qBACA,crCPkB,oBqCUlB,arCnBwB,0BqCqBtB,6EAEA,oBAGE,wCAIJ,arCrBkB,oBqC0BlB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,crClCmB,qBqCsCrB,iBACE,crCvCmB,uBqC2CrB,eACE,mBACA,kBACA,kBACA,yHAGF,sBAME,mBACA,oBACA,gBACA,crC3DmB,qBqC+DrB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,crCxIc,iCqC4IhB,uBACE,gBACA,gBACA,crC9IY,qDqCkJd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,uBACA,eACA,iBACA,WnCpNI,iBmCsNJ,kBACA,qEAEA,aAEE,6CAIA,arChNiB,oCqCqNnB,sBACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,sBACE,eACA,iBACA,gBACA,crClPc,mBqCoPd,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAIE,UACqB,sCjCnRzB,CiCoRI,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iBnCpOgB,wBE9DtB,4BACA,iCiCsSE,cACE,mCAEA,aACE,WnC3SA,qBmC6SA,uDAGE,yBACE,2CAKN,aACE,crC1SY,kCqCkTlB,sBAEE,CACA,eACA,eACA,iBACA,mBACA,crCzTgB,sCqC4ThB,arCrUsB,0BqCuUpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,sBACE,eACA,iBACA,gBACA,mBACA,crCjVmB,wBqCoVnB,sBACE,cACA,eACA,gBACA,cACA,kBAKF,cACA,iBrC/VmB,mCqC6VrB,sBACE,CAEA,eACA,mBACA,crClWmB,kBqCuWnB,cACA,iBrCxWmB,kBqCgXnB,crChXmB,mCqC+WrB,sBACE,CACA,gBACA,gBACA,mBACA,crCpXmB,kBqCyXnB,crCzXmB,kBqCiYrB,sBACE,eACA,iBACA,gBACA,mBACA,crCtYmB,mCqC0YrB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,4CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBrCpcmB,kBqCscjB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAMA,UACqB,sCjC9hB3B,mDiCiiBI,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA9DF,iBA+DI,mFAIJ,qBAGE,mBrC9jBiB,kBqCgkBjB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,arChlBmB,qCqColBnB,eACE,WnCpmBE,gBmCsmBF,2CAEA,arC3lBc,gDqC8lBZ,arC5lBe,+CqCkmBnB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SnC1rBI,YmC4rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,crCvsBc,6BqC2sBhB,eACE,iBACA,+BAGF,kBrC5tBiB,aqC8tBf,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,sBACE,eACA,gBACA,cACA,qCAGF,cACE,crCtvBY,uFqC4vBlB,eACE,cASA,CrCtwBgB,2CqCmwBhB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,crC92BsB,qBqCg3BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,crC12Bc,SsChBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBtCxBmB,UsC6BnB,atC1BwB,0BsC4BtB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBtCjEiB,6BsCmEf,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+BACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,ctC/FkB,gBsCiGlB,0DAEA,UpChHM,wDoCoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBtC/JiB,sBsCiKjB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBtC9KiB,gCsCiLjB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBtCtMiB,uCsCyMf,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,ctClOY,gBsCoOZ,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,sCAEA,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBCzRN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBvCfe,YuCiBf,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SrCxCA,YqC0CE,kBACA,YACA,uCAIJ,aACE,cvCpCY,qBuCsCZ,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cvC9EY,qBuCgFZ,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UrCzGA,yBqC2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UrCjIE,yBFWa,gBuCyHb,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,avCrMmB,euCuMjB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,avChNmB,euCkNjB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,cvC7Nc,mBuC+Nd,kBACA,gCACA,4BAGF,cACE,cvCnOiB,iBuCqOjB,gBACA,0CAGF,UrCxPI,gBqC0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WrCxQE,oBqC0QF,iBACA,gBACA,mBACA,2BAGF,cACE,iBACA,cvCnQiB,mBuCqQjB,kCAEA,UrCtRE,gBqCwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,4CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BvCxUe,YuC+UrB,UACE,SACA,cACA,WACA,sDAKA,avCtVkB,0DuCyVhB,avClWsB,4DuCuWxB,arC1Wc,gBqC4WZ,4DAGF,arC9WU,gBqCgXR,0DAGF,avCvWgB,gBuCyWd,0DAGF,arCtXU,gBqCwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,cvC3Zc,qBuC6Zd,yBACA,eACA,gBACA,gCACA,iCAEA,UrChbE,gCqCkbA,oCAGF,avCjboB,gCuCmblB,iBAMR,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cvC/cmB,CuCodf,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,avCziBwB,qBuC2iBtB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBvC5jBmB,gCuC8jBnB,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,cvC3jBgB,euC6jBhB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,avCplBgB,sDuCwlBhB,avCvlBqB,qBuC2lBnB,gBACA,yDAIJ,oBAIE,cvCpmBqB,iGuCumBrB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBrC9pBc,yBqCkqBd,yBACE,wBAGF,yBrCnqBU,wBqCwqBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,avCvqBgB,uBuC6qBhB,wBACA,qBAGF,avChrBgB,cuCqrBlB,kBvClsBqB,kBuCosBnB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cvC5sBc,yBuC8sBd,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,arCvuBM,6BqC8uBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cvCjvBY,mLuCovBZ,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,avC/vBU,iBuCiwBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cvC5wBY,WuCmxBpB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,arC70BY,8CqCk1Bd,qBACE,aACA,WrCr1BI,cqC01BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBvCl2BiB,gCuCo2BjB,kCAEA,cACE,cACA,gBACA,eACA,gBACA,cvC71BiB,qBuC+1BjB,mBACA,uHAEA,UrCj3BE,iCqCw3BJ,cACE,cvC32BY,uCuC+2Bd,YACE,8BACA,mBACA,sCAGF,eACE,kkECp4BN,kIACE,CADF,sIACE,uIAYA,aAEE,yIAGF,aAEE,qIAGF,aAEE,6IAGF,aAEE,UChCJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,+BAGF,eACE,2EAGF,UAEE,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,+BAGF,UACE,0BAGF,gBACE,eACA,UAGA,WACA,yCAGF,iBACE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,gBACA,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,kEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,sBChbJ,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WxCrCI,uBwCuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,c1CpCgB,kB0CsChB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,a1CnEwB,gB0CqEtB,qBACA,2GCrEM,SACE,CDoER,iGCrEM,SACE,CDoER,qGCrEM,SACE,CDoER,4FCrEM,SACE,mJAQZ,aAME,0BACA,mMAEA,oBACE,iOAGF,yBACE,CAKE,0zCAIJ,oBAGE,uUAGF,a3C3BqB,qB2C6BnB,oCAIJ,yBACE,6HAEA,oBAGE,4BAIJ,yBACE,qGAEA,oBAGE,eAIJ,a3CvDoB,yE2C2DpB,+BACE,0D","file":"skins/glitch/contrast/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#313543 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#313543;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#353a49}::-webkit-scrollbar-thumb:active{background:#313543}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#282c37}::-webkit-scrollbar-track:active{background:#282c37}::-webkit-scrollbar-corner{background:transparent}body{font-family:sans-serif,sans-serif;background:#191b22;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",sans-serif,sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#282c37}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.embed{background:#313543;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#1f232b;padding:0}body.error{position:absolute;text-align:center;color:#dde3ec;background:#282c37;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:sans-serif,sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;width:40px;height:40px;background-size:40px 40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}.account-header .name{flex:1 1 auto;color:#ecf0f4;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#42485a}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#dde3ec;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#4a5266;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#535b72}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#ecf0f4}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#0e1014}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#313543;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;width:120px;height:120px;background-size:120px 120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #313543;background:#17191f;border-radius:8%;background-position:50%;background-clip:padding-box}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#313543;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#dde3ec}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#dde3ec;padding:10px;border-right:1px solid #313543;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#ecf0f4}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:sans-serif,sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #42485a}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#dde3ec}.public-layout .public-account-header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#4e79df}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#dde3ec}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #393f4f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #393f4f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#282c37}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#313543}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.modal-layout{background:#282c37 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#737d99}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#737d99}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#dde3ec}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#737d99}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#737d99}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#7f88a2}.compact-header h1{font-size:24px;line-height:28px;color:#dde3ec;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#ecf0f4}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#282c37;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.hero-widget__text a{color:#ecf0f4;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#dde3ec}.box-widget{padding:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #c2cede;text-align:center;color:#dde3ec;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#dde3ec;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#ecf0f4;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#dde3ec}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#dde3ec;margin-bottom:10px}.page-header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#dde3ec}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#313543}.page-header h1{font-size:24px}}.directory{background:#282c37;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#282c37;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#393f4f}.directory__tag.active>a{background:#2b5fd9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#dde3ec}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#dde3ec}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b5fd9}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#17191f;border:2px solid #282c37}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#dde3ec;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #393f4f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#dde3ec;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #4a5266}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#dde3ec}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b5fd9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#1f232b;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #313543}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #313543}code{font-family:monospace,monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#dde3ec}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#0e1014}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#dde3ec}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #c2cede;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419;border:1px solid #0a0b0e;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#eaeef3}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#17191f}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b5fd9;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#416fdd}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#2454c7}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #0a0b0e;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#c2cede;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(19, 20, 25, 0), #131419)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(40,44,55,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#393f4f;color:#dde3ec;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#dde3ec;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:monospace,monospace;background:#282c37;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#313543}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#dde3ec;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#4ea2df}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#dde3ec}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#ecf0f4;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#ecf0f4;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#dde3ec}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:monospace,monospace}.input-copy{background:#131419;border:1px solid #0a0b0e;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:monospace,monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#1f232b;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#1f232b;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#393f4f}.card__img{height:130px;position:relative;background:#0e1014;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#313543;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box;background:#17191f;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#ecf0f4}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#1a1a1a}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#364861;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #42485a;border-bottom:1px solid #42485a;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #42485a}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#ecf0f4;background:rgba(23,25,31,.5)}.account__header__fields dd{flex:1 1 auto;color:#dde3ec}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#dde3ec}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#282c37}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#393f4f}.button.logo-button{flex:0 auto;font-size:14px;background:#2b5fd9;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#5680e1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{padding:8px 0;padding-bottom:2px;margin:initial;margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{position:absolute;margin:initial;float:initial;width:auto;left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}.embed .status .status__info,.public-layout .status .status__info{font-size:15px;display:initial}.embed .status .status__relative-time,.public-layout .status .status__relative-time{color:#c2cede;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.embed .status .status__info .status__display-name,.public-layout .status .status__info .status__display-name{display:block;max-width:100%;padding:6px 0;padding-right:25px;margin:initial}.embed .status .status__info .status__display-name .display-name strong,.public-layout .status .status__info .status__display-name .display-name strong{display:inline}.embed .status .status__avatar,.public-layout .status .status__avatar{height:48px;position:absolute;width:48px;margin:initial}.rtl .embed .status,.rtl .public-layout .status{padding-left:10px;padding-right:68px}.rtl .embed .status .status__info .status__display-name,.rtl .public-layout .status .status__info .status__display-name{padding-left:25px;padding-right:0}.rtl .embed .status .status__relative-time,.rtl .public-layout .status .status__relative-time{float:left}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#4e79df;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#2b5fd9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#2558d0;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;transition-property:background-color;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#4976de;transition:all 200ms ease-out;transition-property:background-color}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled{background-color:#9baec8;cursor:default}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#606984}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#687390}.button.button-secondary{font-size:16px;line-height:36px;height:auto;color:#dde3ec;text-transform:none;background:transparent;padding:3px 15px;border-radius:4px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#eaeef3}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.icon-button{display:inline-block;padding:0;color:#8d9ac2;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#a4afce;background-color:rgba(141,154,194,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(141,154,194,.3)}.icon-button.disabled{color:#6274ab;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#1b1e25}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#0c0d11;background-color:rgba(27,30,37,.15)}.icon-button.inverted:focus{background-color:rgba(27,30,37,.3)}.icon-button.inverted.disabled{color:#2a2e3a;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#63ade3}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#1b1e25;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#0c0d11;background-color:rgba(27,30,37,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(27,30,37,.3)}.text-icon-button.disabled{color:#464d60;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute;transform-origin:50% 0}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.notification__favourite-icon-wrapper{left:0;position:absolute}.notification__favourite-icon-wrapper .fa.star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name a{color:inherit;text-decoration:inherit}.display-name strong{height:18px;font-size:16px;font-weight:500;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name span{display:block;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name>a:hover strong{text-decoration:underline}.display-name.inline{padding:0;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.display-name.inline strong{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name.inline span{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.dropdown-menu ul{list-style:none}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b5fd9;color:#ecf0f4;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b5fd9;color:#ecf0f4}.dropdown__icon{vertical-align:middle}.static-content{padding:10px;padding-top:20px;color:#c2cede}.static-content h1{font-size:16px;font-weight:500;margin-bottom:40px;text-align:center}.static-content p{font-size:13px;margin-bottom:20px}.column,.drawer{flex:1 1 100%;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#393f4f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #393f4f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.auto-columns .tabs-bar__link:hover,.auto-columns .tabs-bar__link:focus,.auto-columns .tabs-bar__link:active{background:#464d60;border-bottom-color:#464d60}}.multi-columns .tabs-bar__link:hover,.multi-columns .tabs-bar__link:focus,.multi-columns .tabs-bar__link:active{background:#464d60;border-bottom-color:#464d60}.tabs-bar__link.active{border-bottom:2px solid #2b5fd9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}.tabs-bar__link span.icon{margin-left:0;display:inline}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b5fd9;border:2px solid #393f4f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#17191f}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#282c37;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#131419}.react-toggle--checked .react-toggle-track{background-color:#2b5fd9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#5680e1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #282c37;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b5fd9}.getting-started__wrapper,.getting_started,.flex-spacer{background:#282c37}.getting-started__wrapper{position:relative;overflow-y:auto}.flex-spacer{flex:1 1 auto}.getting-started{background:#282c37;flex:1 0 auto}.getting-started p{color:#ecf0f4}.getting-started a{color:#c2cede}.getting-started__panel{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex:0 1 auto}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{color:#c2cede;font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#c2cede;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#dde3ec}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#dde3ec;padding:10px;font-weight:500;border-bottom:1px solid #393f4f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#dde3ec}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#282c37;padding:4px 8px;margin:-6px 10px}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#393f4f;border:1px solid #1f232b}.setting-text{color:#dde3ec;background:transparent;border:none;border-bottom:2px solid #9baec8;box-sizing:border-box;display:block;font-family:inherit;margin-bottom:10px;padding:7px 0;width:100%}.setting-text:focus,.setting-text:active{color:#fff;border-bottom-color:#2b5fd9}@media screen and (max-width: 600px){.auto-columns .setting-text,.single-column .setting-text{font-size:16px}}.setting-text.light{color:#000;border-bottom:2px solid #626c87}.setting-text.light:focus,.setting-text.light:active{color:#000;border-bottom-color:#2b5fd9}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#8d9ac2;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.reduce-motion button.icon-button.disabled i.fa-retweet{color:#6274ab}.load-more{display:block;color:#c2cede;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#2c313d}.load-gap{border-bottom:1px solid #393f4f}.missing-indicator{padding-top:68px}.scrollable>div>:first-child .notification__dismiss-overlay>.wrappy{border-top:1px solid #282c37}.notification__dismiss-overlay{overflow:hidden;position:absolute;top:0;right:0;bottom:-1px;padding-left:15px;z-index:999;align-items:center;justify-content:flex-end;cursor:pointer;display:flex}.notification__dismiss-overlay .wrappy{width:4rem;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#393f4f;border-left:1px solid #535b72;box-shadow:0 0 5px #000;border-bottom:1px solid #282c37}.notification__dismiss-overlay .ckbox{border:2px solid #9baec8;border-radius:2px;width:30px;height:30px;font-size:20px;color:#dde3ec;text-shadow:0 0 5px #000;display:flex;justify-content:center;align-items:center}.notification__dismiss-overlay:focus{outline:0 !important}.notification__dismiss-overlay:focus .ckbox{box-shadow:0 0 1px 1px #2b5fd9}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.loading-indicator{color:#c2cede;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #606984;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#606984}29%{background-color:#606984}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:flex;left:4px;top:4px;width:auto;height:auto;align-items:center}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.setting-toggle{display:block;line-height:24px}.setting-toggle__label,.setting-radio__label,.setting-meta__label{color:#dde3ec;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.setting-radio{display:block;line-height:18px}.setting-radio__label{margin-bottom:0}.column-settings__row legend{color:#dde3ec;cursor:default;display:block;font-weight:500;margin-top:10px}.setting-radio__input{vertical-align:middle}.setting-meta__label{float:right}@keyframes heartbeat{from{transform:scale(1);transform-origin:center center;animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.pulse-loading{animation:heartbeat 1.5s ease-in-out infinite both}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#282c37;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#ecf0f4;font-size:18px;font-weight:500;border:2px dashed #606984;border-radius:4px}.dropdown--active .emoji-button img{opacity:1;filter:none}.loading-bar{background-color:#2b5fd9;height:3px;position:absolute;top:0;left:0;z-index:9999}.icon-badge-wrapper{position:relative}.icon-badge{position:absolute;display:block;right:-0.25em;top:-0.25em;background-color:#2b5fd9;border-radius:50%;font-size:75%;width:1em;height:1em}.conversation{display:flex;border-bottom:1px solid #393f4f;padding:5px;padding-bottom:0}.conversation:focus{background:#2c313d;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#dde3ec;padding-left:15px}.conversation__content__names{color:#dde3ec;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content .status__content{margin:0}.conversation--unread{background:#2c313d}.conversation--unread:focus{background:#313543}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.ui .flash-message{margin-top:10px;margin-left:auto;margin-right:auto;margin-bottom:0;min-width:75%}::-webkit-scrollbar-thumb{border-radius:0}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#ecf0f4;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}noscript div a{word-break:break-word}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet,button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.status-direct button.icon-button.disabled i.fa-retweet,.status-direct button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.account{padding:10px;border-bottom:1px solid #393f4f;color:inherit;text-decoration:none}.account .account__display-name{flex:1 1 auto;display:block;color:#dde3ec;overflow:hidden;text-decoration:none;font-size:14px}.account.small{border:none;padding:0}.account.small>.account__avatar-wrapper{margin:0 8px 0 0}.account.small>.display-name{height:24px;line-height:24px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:8%;background-position:50%;background-clip:padding-box;position:relative;cursor:pointer}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:8%;background-position:50%;background-clip:padding-box;overflow:hidden;position:relative}.account__avatar-composite div{border-radius:8%;background-position:50%;background-clip:padding-box;float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}.account__avatar-overlay{position:relative;width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:8%;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:8%;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__header__wrapper{flex:0 0 auto;background:#313543}.account__disclaimer{padding:10px;color:#c2cede}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-left:1px solid #393f4f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab:first-child{border-left:0}.account__action-bar__tab.active{border-bottom:4px solid #2b5fd9}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#dde3ec}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account__action-bar__tab abbr{color:#2b90d9}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.notification__message{margin-left:42px;padding:8px 0 0 26px;cursor:default;color:#dde3ec;font-size:15px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account--panel{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#393f4f;padding:15px}.column-settings__section{color:#dde3ec;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#eaeef3}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#313543}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#c2cede;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#393f4f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#42485a;color:#eaeef3}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#dde3ec}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#c2cede}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#d0d9e5}.column-settings__hashtags .column-select__indicator-separator{background-color:#393f4f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#364861;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:none;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#1f232b;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#dde3ec;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#ecf0f4}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #393f4f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #282c37}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#242731;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #191b22}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f}.account__moved-note__message{position:relative;margin-left:58px;color:#c2cede;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.account__header__content{color:#dde3ec;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#1f232b}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#313543;padding:5px;border-bottom:1px solid #42485a}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#17191f;border:2px solid #313543}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #42485a;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #42485a}.account__header__bio .account__header__fields a{color:#4e79df}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#dde3ec;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.domain{padding:10px;border-bottom:1px solid #393f4f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}@keyframes spring-flip-in{0%{transform:rotate(0deg)}30%{transform:rotate(-242.4deg)}60%{transform:rotate(-158.35deg)}90%{transform:rotate(-187.5deg)}100%{transform:rotate(-180deg)}}@keyframes spring-flip-out{0%{transform:rotate(-180deg)}30%{transform:rotate(62.4deg)}60%{transform:rotate(-21.635deg)}90%{transform:rotate(7.5deg)}100%{transform:rotate(0deg)}}.status__content--with-action{cursor:pointer}.status__content{position:relative;margin:10px 0;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:visible;padding-top:5px}.status__content:focus{outline:0}.status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.status__content pre,.status__content blockquote{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.status__content pre:last-child,.status__content blockquote:last-child{margin-bottom:0}.status__content .status__content__text,.status__content .e-content{overflow:hidden}.status__content .status__content__text>ul,.status__content .status__content__text>ol,.status__content .e-content>ul,.status__content .e-content>ol{margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h1,.status__content .e-content h2,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{margin-top:20px;margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .e-content h1,.status__content .e-content h2{font-weight:700;font-size:1.2em}.status__content .status__content__text h2,.status__content .e-content h2{font-size:1.1em}.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{font-weight:500}.status__content .status__content__text blockquote,.status__content .e-content blockquote{padding-left:10px;border-left:3px solid #dde3ec;color:#dde3ec;white-space:normal}.status__content .status__content__text blockquote p:last-child,.status__content .e-content blockquote p:last-child{margin-bottom:0}.status__content .status__content__text b,.status__content .status__content__text strong,.status__content .e-content b,.status__content .e-content strong{font-weight:700}.status__content .status__content__text em,.status__content .status__content__text i,.status__content .e-content em,.status__content .e-content i{font-style:italic}.status__content .status__content__text sub,.status__content .e-content sub{font-size:smaller;text-align:sub}.status__content .status__content__text sup,.status__content .e-content sup{font-size:smaller;vertical-align:super}.status__content .status__content__text ul,.status__content .status__content__text ol,.status__content .e-content ul,.status__content .e-content ol{margin-left:1em}.status__content .status__content__text ul p,.status__content .status__content__text ol p,.status__content .e-content ul p,.status__content .e-content ol p{margin:0}.status__content .status__content__text ul,.status__content .e-content ul{list-style-type:disc}.status__content .status__content__text ol,.status__content .e-content ol{list-style-type:decimal}.status__content a{color:#d8a070;text-decoration:none}.status__content a:hover{text-decoration:underline}.status__content a:hover .fa{color:#dae1ea}.status__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span{text-decoration:underline}.status__content a .fa{color:#c2cede}.status__content .status__content__spoiler{display:none}.status__content .status__content__spoiler.status__content__spoiler--visible{display:block}.status__content a.unhandled-link{color:#4e79df}.status__content a.unhandled-link .link-origin-tag{color:#ca8f04;font-size:.8em}.status__content .status__content__spoiler-link{background:#687390}.status__content .status__content__spoiler-link:hover{background:#707b97;text-decoration:none}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:#687390;border:none;color:#000;font-weight:500;font-size:11px;padding:0 5px;text-transform:uppercase;line-height:inherit;cursor:pointer;vertical-align:bottom}.status__content__spoiler-link:hover{background:#707b97;text-decoration:none}.status__content__spoiler-link .status__content__spoiler-icon{display:inline-block;margin:0 0 0 5px;border-left:1px solid currentColor;padding:0 0 0 4px;font-size:16px;vertical-align:-2px}.notif-cleaning .status,.notif-cleaning .notification-follow,.notif-cleaning .notification-follow-request{padding-right:4.5rem}.status__wrapper--filtered{color:#c2cede;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #393f4f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.notification-follow,.notification-follow-request{position:relative;border-bottom:1px solid #393f4f}.notification-follow .account,.notification-follow-request .account{border-bottom:0 none}.focusable:focus{outline:0;background:#313543}.focusable:focus.status.status-direct:not(.read){background:#42485a}.focusable:focus.status.status-direct:not(.read).muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#393f4f}.status{padding:10px 14px;position:relative;height:auto;border-bottom:1px solid #393f4f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:28px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#393f4f;border-bottom-color:#42485a}.status.light .status__relative-time{color:#1b1e25}.status.light .status__display-name{color:#000}.status.light .display-name{color:#364861}.status.light .display-name strong{color:#000}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.status.collapsed{background-position:center;background-size:cover;user-select:none}.status.collapsed.has-background::before{display:block;position:absolute;left:0;right:0;top:0;bottom:0;background-image:linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.65) 24px, rgba(0, 0, 0, 0.8));pointer-events:none;content:\"\"}.status.collapsed .display-name:hover .display-name__html{text-decoration:none}.status.collapsed .status__content{height:20px;overflow:hidden;text-overflow:ellipsis;padding-top:0}.status.collapsed .status__content:after{content:\"\";position:absolute;top:0;bottom:0;left:0;right:0;background:linear-gradient(rgba(40, 44, 55, 0), #282c37);pointer-events:none}.status.collapsed .status__content a:hover{text-decoration:none}.status.collapsed:focus>.status__content:after{background:linear-gradient(rgba(49, 53, 67, 0), #313543)}.status.collapsed.status-direct:not(.read)>.status__content:after{background:linear-gradient(rgba(57, 63, 79, 0), #393f4f)}.status.collapsed .notification__message{margin-bottom:0}.status.collapsed .status__info .notification__message>span{white-space:nowrap}.status .notification__message{margin:-10px 0px 10px 0}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#b8c0d9}.status__relative-time{display:inline-block;flex-grow:1;color:#c2cede;font-size:14px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status__display-name{color:#c2cede;overflow:hidden}.status__info__account .status__display-name{display:block;max-width:100%}.status__info{display:flex;justify-content:space-between;font-size:15px}.status__info>span{text-overflow:ellipsis;overflow:hidden}.status__info .notification__message>span{word-wrap:break-word}.status__info__icons{display:flex;align-items:center;height:1em;color:#8d9ac2}.status__info__icons .status__media-icon,.status__info__icons .status__visibility-icon,.status__info__icons .status__reply-icon{padding-left:2px;padding-right:2px}.status__info__icons .status__collapse-button.active>.fa-angle-double-up{transform:rotate(-180deg)}.no-reduce-motion .status__collapse-button.activate>.fa-angle-double-up{animation:spring-flip-in 1s linear}.no-reduce-motion .status__collapse-button.deactivate>.fa-angle-double-up{animation:spring-flip-out 1s linear}.status__info__account{display:flex;align-items:center;justify-content:flex-start}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-top:-10px;margin-bottom:10px;margin-left:58px;color:#c2cede;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#c2cede}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#8d9ac2}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#313543;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#c2cede;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}a.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#ecf0f4;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{flex:none;margin:0 10px 0 0;height:48px;width:48px}.muted .status__content,.muted .status__content p,.muted .status__content a,.muted .status__content__text{color:#c2cede}.muted .status__display-name strong{color:#c2cede}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#606984;color:#000}.muted a.status__content__spoiler-link:hover{background:#66718d;text-decoration:none}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.status-card{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;color:#c2cede;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#ecf0f4;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}.status-card__actions a .fa,.status-card__actions a:hover .fa{color:inherit}a.status-card{cursor:pointer}a.status-card:hover{background:#393f4f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#dde3ec;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#dde3ec}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#393f4f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#313543}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#313543}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.attachment-list{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#c2cede;padding:8px 18px;cursor:default;border-right:1px solid #393f4f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#c2cede;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#c2cede}.status__wrapper--filtered__button{display:inline;color:#4e79df;border:0;background:transparent;padding:0;font-size:inherit;line-height:inherit}.status__wrapper--filtered__button:hover,.status__wrapper--filtered__button:active{text-decoration:underline}.modal-container--preloader{background:#393f4f}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.onboarding-modal__pager{height:80vh;width:80vw;max-width:520px;max-height:470px}.onboarding-modal__pager .react-swipeable-view-container>div{width:100%;height:100%;box-sizing:border-box;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;user-select:text}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}@media screen and (max-width: 550px){.onboarding-modal{width:100%;height:100%;border-radius:0}.onboarding-modal__pager{width:100%;height:auto;max-width:none;max-height:none;flex:1 1 auto}}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#1b1e25;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#131419;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#0a0a0a}.error-modal__footer{justify-content:center}.onboarding-modal__dots{flex:1 1 auto;display:flex;align-items:center;justify-content:center}.onboarding-modal__dot{width:14px;height:14px;border-radius:14px;background:#a6b9c9;margin:0 3px;cursor:pointer}.onboarding-modal__dot:hover{background:#a0b4c5}.onboarding-modal__dot.active{cursor:default;background:#8da5ba}.onboarding-modal__page__wrapper{pointer-events:none;padding:25px;padding-bottom:0}.onboarding-modal__page__wrapper.onboarding-modal__page__wrapper--active{pointer-events:auto}.onboarding-modal__page{cursor:default;line-height:21px}.onboarding-modal__page h1{font-size:18px;font-weight:500;color:#000;margin-bottom:20px}.onboarding-modal__page a{color:#2b90d9}.onboarding-modal__page a:hover,.onboarding-modal__page a:focus,.onboarding-modal__page a:active{color:#3c99dc}.onboarding-modal__page .navigation-bar a{color:inherit}.onboarding-modal__page p{font-size:16px;color:#1b1e25;margin-top:10px;margin-bottom:10px}.onboarding-modal__page p:last-child{margin-bottom:0}.onboarding-modal__page p strong{font-weight:500;background:#282c37;color:#ecf0f4;border-radius:4px;font-size:14px;padding:3px 6px}.onboarding-modal__page p strong:lang(ja){font-weight:700}.onboarding-modal__page p strong:lang(ko){font-weight:700}.onboarding-modal__page p strong:lang(zh-CN){font-weight:700}.onboarding-modal__page p strong:lang(zh-HK){font-weight:700}.onboarding-modal__page p strong:lang(zh-TW){font-weight:700}.onboarding-modal__page__wrapper-0{height:100%;padding:0}.onboarding-modal__page-one__lead{padding:65px;padding-top:45px;padding-bottom:0;margin-bottom:10px}.onboarding-modal__page-one__lead h1{font-size:26px;line-height:36px;margin-bottom:8px}.onboarding-modal__page-one__lead p{margin-bottom:0}.onboarding-modal__page-one__extra{padding-right:65px;padding-left:185px;text-align:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#282c37;color:#ecf0f4;font-weight:500;padding:10px;border-radius:4px}.onboarding-modal__page-two p,.onboarding-modal__page-three p,.onboarding-modal__page-four p,.onboarding-modal__page-five p{text-align:left}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{background:#17191f;color:#ecf0f4;margin-bottom:20px;border-radius:4px;padding:10px;text-align:center;font-size:14px;box-shadow:1px 2px 6px rgba(0,0,0,.3)}.onboarding-modal__page-two .figure .onboarding-modal__image,.onboarding-modal__page-three .figure .onboarding-modal__image,.onboarding-modal__page-four .figure .onboarding-modal__image,.onboarding-modal__page-five .figure .onboarding-modal__image{border-radius:4px;margin-bottom:10px}.onboarding-modal__page-two .figure.non-interactive,.onboarding-modal__page-three .figure.non-interactive,.onboarding-modal__page-four .figure.non-interactive,.onboarding-modal__page-five .figure.non-interactive{pointer-events:none;text-align:left}.onboarding-modal__page-four__columns .row{display:flex;margin-bottom:20px}.onboarding-modal__page-four__columns .row>div{flex:1 1 0;margin:0 10px}.onboarding-modal__page-four__columns .row>div:first-child{margin-left:0}.onboarding-modal__page-four__columns .row>div:last-child{margin-right:0}.onboarding-modal__page-four__columns .row>div p{text-align:center}.onboarding-modal__page-four__columns .row:last-child{margin-bottom:0}.onboarding-modal__page-four__columns .column-header{color:#fff}@media screen and (max-width: 320px)and (max-height: 600px){.onboarding-modal__page p{font-size:14px;line-height:20px}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{font-size:12px;margin-bottom:10px}.onboarding-modal__page-four__columns .row{margin-bottom:10px}.onboarding-modal__page-four__columns .column-header{padding:5px;font-size:12px}}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.doodle-modal,.favourite-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__relative-time,.doodle-modal .status__relative-time,.favourite-modal .status__relative-time,.confirmation-modal .status__relative-time,.report-modal .status__relative-time,.actions-modal .status__relative-time,.mute-modal .status__relative-time,.block-modal .status__relative-time{color:#c2cede;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.boost-modal .status__display-name,.doodle-modal .status__display-name,.favourite-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:flex}.boost-modal .status__avatar,.doodle-modal .status__avatar,.favourite-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:48px;width:48px}.boost-modal .status__content__spoiler-link,.doodle-modal .status__content__spoiler-link,.favourite-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#fff}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container,.favourite-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status,.favourite-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.doodle-modal__action-bar,.favourite-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.doodle-modal__action-bar>div,.favourite-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#1b1e25;padding-right:10px}.boost-modal__action-bar .button,.doodle-modal__action-bar .button,.favourite-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header,.favourite-modal__status-header{font-size:15px}.boost-modal__status-time,.favourite-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal strong{display:block;font-weight:500}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b5fd9;color:#fff}.actions-modal ul li:not(:empty) a>.react-toggle,.actions-modal ul li:not(:empty) a>.icon,.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#1b1e25;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#131419;background-color:transparent}.confirmation-modal__do_not_ask_again{padding-left:20px;padding-right:20px;padding-bottom:10px;font-size:14px}.confirmation-modal__do_not_ask_again label,.confirmation-modal__do_not_ask_again input{vertical-align:middle}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#282c37;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#313543}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.filtered-status-info{text-align:start}.filtered-status-info .spoiler__text{margin-top:20px}.filtered-status-info .account{border-bottom:0}.filtered-status-info .account__display-name strong{color:#000}.filtered-status-info .status__content__spoiler{display:none}.filtered-status-info .status__content__spoiler--visible{display:flex}.filtered-status-info ul{padding:10px;margin-left:12px;list-style:disc inside}.filtered-status-info .filtered-status-edit-link{color:#8d9ac2;text-decoration:none}.filtered-status-info .filtered-status-edit-link:hover{text-decoration:underline}.composer{padding:10px}.composer .emoji-picker-dropdown{position:absolute;top:0;right:0}.composer .emoji-picker-dropdown ::-webkit-scrollbar-track:hover,.composer .emoji-picker-dropdown ::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.character-counter{cursor:default;font-family:sans-serif,sans-serif;font-size:14px;font-weight:600;color:#1b1e25}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .composer--spoiler{transition:height .4s ease,opacity .4s ease}.composer--spoiler{height:0;transform-origin:bottom;opacity:0}.composer--spoiler.composer--spoiler--visible{height:36px;margin-bottom:11px;opacity:1}.composer--spoiler input{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px;padding:10px;width:100%;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:vertical}.composer--spoiler input::placeholder{color:#c2cede}.composer--spoiler input:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .composer--spoiler input{font-size:16px}}.single-column .composer--spoiler input{font-size:16px}.composer--warning{color:#000;margin-bottom:15px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.composer--warning a{color:#1b1e25;font-weight:500;text-decoration:underline}.composer--warning a:active,.composer--warning a:focus,.composer--warning a:hover{text-decoration:none}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-left:5px;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.composer--reply{margin:0 0 10px;border-radius:4px;padding:10px;background:#9baec8;min-height:23px;overflow-y:auto;flex:0 2 auto}.composer--reply>header{margin-bottom:5px;overflow:hidden}.composer--reply>header>.account.small{color:#000}.composer--reply>header>.cancel{float:right;line-height:24px}.composer--reply>.content{position:relative;margin:10px 0;padding:0 12px;font-size:14px;line-height:20px;color:#000;word-wrap:break-word;font-weight:400;overflow:visible;white-space:pre-wrap;padding-top:5px;overflow:hidden}.composer--reply>.content p,.composer--reply>.content pre,.composer--reply>.content blockquote{margin-bottom:20px;white-space:pre-wrap}.composer--reply>.content p:last-child,.composer--reply>.content pre:last-child,.composer--reply>.content blockquote:last-child{margin-bottom:0}.composer--reply>.content h1,.composer--reply>.content h2,.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{margin-top:20px;margin-bottom:20px}.composer--reply>.content h1,.composer--reply>.content h2{font-weight:700;font-size:18px}.composer--reply>.content h2{font-size:16px}.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{font-weight:500}.composer--reply>.content blockquote{padding-left:10px;border-left:3px solid #000;color:#000;white-space:normal}.composer--reply>.content blockquote p:last-child{margin-bottom:0}.composer--reply>.content b,.composer--reply>.content strong{font-weight:700}.composer--reply>.content em,.composer--reply>.content i{font-style:italic}.composer--reply>.content sub{font-size:smaller;text-align:sub}.composer--reply>.content ul,.composer--reply>.content ol{margin-left:1em}.composer--reply>.content ul p,.composer--reply>.content ol p{margin:0}.composer--reply>.content ul{list-style-type:disc}.composer--reply>.content ol{list-style-type:decimal}.composer--reply>.content a{color:#1b1e25;text-decoration:none}.composer--reply>.content a:hover{text-decoration:underline}.composer--reply>.content a.mention:hover{text-decoration:none}.composer--reply>.content a.mention:hover span{text-decoration:underline}.composer--reply .emojione{width:20px;height:20px;margin:-5px 0 0}.compose-form__autosuggest-wrapper,.autosuggest-input{position:relative;width:100%}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.autosuggest-input label .autosuggest-textarea__textarea{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px 4px 0 0;padding:10px 32px 0 10px;width:100%;min-height:100px;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:none;scrollbar-color:initial}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::placeholder,.autosuggest-input label .autosuggest-textarea__textarea::placeholder{color:#c2cede}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::-webkit-scrollbar,.autosuggest-input label .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:disabled,.autosuggest-input label .autosuggest-textarea__textarea:disabled{background:#d9e1e8}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:focus,.autosuggest-input label .autosuggest-textarea__textarea:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}}.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}@media screen and (max-width: 600px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.composer--textarea--icons{display:block;position:absolute;top:29px;right:5px;bottom:5px;overflow:hidden}.composer--textarea--icons>.textarea_icon{display:block;margin:2px 0 0 2px;width:24px;height:24px;color:#1b1e25;font-size:18px;line-height:24px;text-align:center;opacity:.8}.autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.autosuggest-textarea__suggestions{display:block;position:absolute;box-sizing:border-box;top:100%;border-radius:0 0 4px 4px;padding:6px;width:100%;color:#000;background:#d9e1e8;box-shadow:4px 4px 6px rgba(0,0,0,.4);font-size:14px;z-index:99;display:none}.autosuggest-textarea__suggestions--visible{display:block}.autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.autosuggest-textarea__suggestions__item>.account,.autosuggest-textarea__suggestions__item>.emoji,.autosuggest-textarea__suggestions__item>.autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.autosuggest-textarea__suggestions__item .autosuggest-hashtag{justify-content:space-between}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item .autosuggest-hashtag strong{font-weight:500}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item>.account.small .display-name>span{color:#1b1e25}.composer--upload_form{overflow:hidden}.composer--upload_form>.content{display:flex;flex-direction:row;flex-wrap:wrap;font-family:inherit;padding:5px;overflow:hidden}.composer--upload_form--item{flex:1 1 0;margin:5px;min-width:40%}.composer--upload_form--item>div{position:relative;border-radius:4px;height:140px;width:100%;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;overflow:hidden}.composer--upload_form--item>div textarea{display:block;position:absolute;box-sizing:border-box;bottom:0;left:0;margin:0;border:0;padding:10px;width:100%;color:#ecf0f4;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);font-size:14px;font-family:inherit;font-weight:500;opacity:0;z-index:2;transition:opacity .1s ease}.composer--upload_form--item>div textarea:focus{color:#fff}.composer--upload_form--item>div textarea::placeholder{opacity:.54;color:#ecf0f4}.composer--upload_form--item>div>.close{mix-blend-mode:difference}.composer--upload_form--item.active>div textarea{opacity:1}.composer--upload_form--actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.composer--upload_form--actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.composer--upload_form--actions .icon-button:hover,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:active{color:#e6ebf0}.composer--upload_form--actions.active{opacity:1}.composer--upload_form--progress{display:flex;padding:10px;color:#dde3ec;overflow:hidden}.composer--upload_form--progress>.fa{font-size:34px;margin-right:10px}.composer--upload_form--progress>.message{flex:1 1 auto}.composer--upload_form--progress>.message>span{display:block;font-size:12px;font-weight:500;text-transform:uppercase}.composer--upload_form--progress>.message>.backdrop{position:relative;margin-top:5px;border-radius:6px;width:100%;height:6px;background:#606984}.composer--upload_form--progress>.message>.backdrop>.tracker{position:absolute;top:0;left:0;height:6px;border-radius:6px;background:#2b5fd9}.compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.composer--options-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;height:27px;display:flex;justify-content:space-between;flex:0 0 auto}.composer--options{display:flex;flex:0 0 auto}.composer--options>*{display:inline-block;box-sizing:content-box;padding:0 3px;height:27px;line-height:27px;vertical-align:bottom}.composer--options>hr{display:inline-block;margin:0 3px;border-width:0 0 0 1px;border-style:none none none solid;border-color:transparent transparent transparent #c2c2c2;padding:0;width:0;height:27px;background:transparent}.compose--counter-wrapper{align-self:center;margin-right:4px}.composer--options--dropdown.open>.value{border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1);color:#fff;background:#2b5fd9;transition:none}.composer--options--dropdown.open.top>.value{border-radius:0 0 4px 4px;box-shadow:0 4px 4px rgba(0,0,0,.1)}.composer--options--dropdown--content{position:absolute;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);background:#fff;overflow:hidden;transform-origin:50% 0}.composer--options--dropdown--content--item{display:flex;align-items:center;padding:10px;color:#000;cursor:pointer}.composer--options--dropdown--content--item>.content{flex:1 1 auto;color:#1b1e25}.composer--options--dropdown--content--item>.content:not(:first-child){margin-left:10px}.composer--options--dropdown--content--item>.content strong{display:block;color:#000;font-weight:500}.composer--options--dropdown--content--item:hover,.composer--options--dropdown--content--item.active{background:#2b5fd9;color:#fff}.composer--options--dropdown--content--item:hover>.content,.composer--options--dropdown--content--item.active>.content{color:#fff}.composer--options--dropdown--content--item:hover>.content strong,.composer--options--dropdown--content--item.active>.content strong{color:#fff}.composer--options--dropdown--content--item.active:hover{background:#3c6cdc}.composer--publisher{padding-top:10px;text-align:right;white-space:nowrap;overflow:hidden;justify-content:flex-end;flex:0 0 auto}.composer--publisher>.primary{display:inline-block;margin:0;padding:0 10px;text-align:center}.composer--publisher>.side_arm{display:inline-block;margin:0 2px;padding:0;width:36px;text-align:center}.composer--publisher.over>.count{color:#ff5050}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#17191f;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#282c37}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.column{overflow:hidden}.column-back-button{box-sizing:border-box;width:100%;background:#313543;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;border:0;text-align:unset;padding:15px;margin:0;z-index:3}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#313543;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.column-link{background:#393f4f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#404657}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#2b5fd9}.column-link__icon{display:inline-block;margin-right:5px}.column-subheading{background:#282c37;color:#c2cede;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 95, 217, 0.23) 0%, rgba(43, 95, 217, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#313543;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden}.column-header>button{margin:0;border:none;padding:15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,95,217,.4)}.column-header:focus,.column-header:active{outline:0}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden}.wide .columns-area:not(.columns-area--mobile) .column{flex:auto;min-width:330px;max-width:400px}.column>.scrollable{background:#282c37}.column-header__buttons{height:48px;display:flex;margin-left:0}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button,.column-header__notif-cleaning-buttons button{background:#313543;border:0;color:#dde3ec;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover,.column-header__notif-cleaning-buttons button:hover{color:#f4f6f9}.column-header__button.active,.column-header__notif-cleaning-buttons button.active{color:#fff;background:#393f4f}.column-header__button.active:hover,.column-header__notif-cleaning-buttons button.active:hover{color:#fff;background:#393f4f}.column-header__button:focus,.column-header__notif-cleaning-buttons button:focus{text-shadow:0 0 4px #2454c7}.column-header__notif-cleaning-buttons{display:flex;align-items:stretch;justify-content:space-around}.column-header__notif-cleaning-buttons button{background:transparent;text-align:center;padding:10px 0;white-space:pre-wrap}.column-header__notif-cleaning-buttons b{font-weight:bold}.column-header__collapsible-inner.nopad-drawer{padding:0}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#dde3ec;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #42485a;margin:10px 0}.column-header__collapsible.ncd{transition:none}.column-header__collapsible.ncd.collapsed{max-height:0;opacity:.7}.column-header__collapsible-inner{background:#393f4f;padding:15px}.column-header__setting-btn:hover{color:#dde3ec;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.column-header__title{display:inline-block;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header__icon{display:inline-block;margin-right:5px}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#c2cede;background:#282c37;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#1f232b;contain:initial}.error-column{flex-direction:column}.single-column.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}@media screen and (max-width: 415px){.auto-columns.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}}@media screen and (max-width: 415px){.auto-columns.navbar-under .react-swipeable-view-container .columns-area,.single-column.navbar-under .react-swipeable-view-container .columns-area{height:100% !important}}.column-inline-form{padding:7px 15px;padding-right:5px;display:flex;justify-content:flex-start;align-items:center;background:#313543}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%;margin-bottom:6px}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 5px}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#c2cede;background:#282c37;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#c2cede}.regeneration-indicator__label span{font-size:15px;font-weight:400}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#0e1014;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#313543;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#17191f;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#282c37;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #393f4f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.filter-form{background:#282c37}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#4e79df;background:#4e79df}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#eaeef3}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#313543}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:color,transform,opacity;font-size:18px;width:18px;height:18px;color:#ecf0f4;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(0deg)}.search__icon .fa-search.active{pointer-events:auto;opacity:.3}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#8d9ac2;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#a4afce}.search-results__header{color:#c2cede;background:#2c313d;border-bottom:1px solid #1f232b;padding:15px 10px;font-size:14px;font-weight:500}.search-results__info{padding:20px;color:#dde3ec;text-align:center}.trends__header{color:#c2cede;background:#2c313d;border-bottom:1px solid #1f232b;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #393f4f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#c2cede;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#dde3ec;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#ecf0f4}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#459ede !important}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.doodle-modal{width:unset}.doodle-modal__container{background:#d9e1e8;text-align:center;line-height:0}.doodle-modal__container canvas{border:5px solid #d9e1e8}.doodle-modal__action-bar .filler{flex-grow:1;margin:0;padding:0}.doodle-modal__action-bar .doodle-toolbar{line-height:1;display:flex;flex-direction:column;flex-grow:0;justify-content:space-around}.doodle-modal__action-bar .doodle-toolbar.with-inputs label{display:inline-block;width:70px;text-align:right;margin-right:2px}.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=number],.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=text]{width:40px}.doodle-modal__action-bar .doodle-toolbar.with-inputs span.val{display:inline-block;text-align:left;width:50px}.doodle-modal__action-bar .doodle-palette{padding-right:0 !important;border:1px solid #000;line-height:.2rem;flex-grow:0;background:#fff}.doodle-modal__action-bar .doodle-palette button{appearance:none;width:1rem;height:1rem;margin:0;padding:0;text-align:center;color:#000;text-shadow:0 0 1px #fff;cursor:pointer;box-shadow:inset 0 0 1px rgba(255,255,255,.5);border:1px solid #000;outline-offset:-1px}.doodle-modal__action-bar .doodle-palette button.foreground{outline:1px dashed #fff}.doodle-modal__action-bar .doodle-palette button.background{outline:1px dashed red}.doodle-modal__action-bar .doodle-palette button.foreground.background{outline:1px dashed red;border-color:#fff}.drawer{width:300px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden;padding:10px 5px;flex:none}.drawer:first-child{padding-left:10px}.drawer:last-child{padding-right:10px}@media screen and (max-width: 630px){.auto-columns .drawer{flex:auto}}.single-column .drawer{flex:auto}@media screen and (max-width: 630px){.auto-columns .drawer,.auto-columns .drawer:first-child,.auto-columns .drawer:last-child,.single-column .drawer,.single-column .drawer:first-child,.single-column .drawer:last-child{padding:0}}.wide .drawer{min-width:300px;max-width:400px;flex:1 1 200px}@media screen and (max-width: 630px){:root .auto-columns .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}}:root .single-column .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}.react-swipeable-view-container .drawer{height:100%}.drawer--header{display:flex;flex-direction:row;margin-bottom:10px;flex:none;background:#393f4f;font-size:16px}.drawer--header>*{display:block;box-sizing:border-box;border-bottom:2px solid transparent;padding:15px 5px 13px;height:48px;flex:1 1 auto;color:#dde3ec;text-align:center;text-decoration:none;cursor:pointer}.drawer--header a{transition:background 100ms ease-in}.drawer--header a:focus,.drawer--header a:hover{outline:none;background:#2e3340;transition:background 200ms ease-out}.search{position:relative;margin-bottom:10px;flex:none}@media screen and (max-width: 415px){.auto-columns .search,.single-column .search{margin-bottom:0}}@media screen and (max-width: 630px){.auto-columns .search{font-size:16px}}.single-column .search{font-size:16px}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#364861;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}.drawer--account{padding:10px;color:#dde3ec;display:flex;align-items:center}.drawer--account a{color:inherit;text-decoration:none}.drawer--account .acct{display:block;color:#ecf0f4;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;overflow:hidden}.drawer--results{background:#282c37;overflow-x:hidden;overflow-y:auto}.drawer--results>header{color:#c2cede;background:#2c313d;padding:15px;font-weight:500;font-size:16px;cursor:default}.drawer--results>header .fa{display:inline-block;margin-right:5px}.drawer--results>section{margin-bottom:5px}.drawer--results>section h5{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#c2cede}.drawer--results>section h5 .fa{display:inline-block;margin-right:5px}.drawer--results>section .account:last-child,.drawer--results>section>div:last-child .status{border-bottom:0}.drawer--results>section>.hashtag{display:block;padding:10px;color:#ecf0f4;text-decoration:none}.drawer--results>section>.hashtag:hover,.drawer--results>section>.hashtag:active,.drawer--results>section>.hashtag:focus{color:#f9fafb;text-decoration:underline}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#444b5d;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%}.drawer__inner.darker{background:#282c37}.drawer__inner__mastodon{background:#444b5d url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}.drawer__inner__mastodon>.mastodon{display:block;width:100%;height:100%;border:none;cursor:inherit}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#444b5d;font-size:13px;text-align:left}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#dde3ec;border:0;width:100%;height:100%}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{color:#f7f9fb}.status__content>.media-spoiler{margin-top:15px}.media-spoiler.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:500}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{height:100%;display:flex;flex-direction:column}.media-gallery__audio span{text-align:center;color:#dde3ec;display:flex;height:100%;align-items:center}.media-gallery__audio span p{width:100%}.media-gallery__audio audio{width:100%}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%;height:110px}.media-gallery.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-gallery__item{border:none;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.full-width .media-gallery__item{border-radius:0}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item.letterbox{background:#000}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#ecf0f4;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%;object-fit:contain}.media-gallery__item-thumbnail:not(.letterbox),.media-gallery__item-thumbnail img:not(.letterbox){height:100%;object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%;display:flex;justify-content:center}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;width:100%;position:relative;z-index:1;object-fit:contain;user-select:none}.media-gallery__item-gifv-thumbnail:not(.letterbox){height:100%;object-fit:cover}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b5fd9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#17191f;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #313543;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(78,121,223,.5)}.audio-player__wave-placeholder{background-color:#4a5266}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#17191f;border-top:1px solid #313543;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.detailed-status .video-player{width:100%;height:100%}.video-player.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.video-player video{max-width:100vw;max-height:80vh;z-index:1;position:relative}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#dde3ec;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#f4f6f9}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons-bar .video-player__download__icon .fa,.video-player__buttons-bar .video-player__download__icon:active .fa,.video-player__buttons-bar .video-player__download__icon:hover .fa,.video-player__buttons-bar .video-player__download__icon:focus .fa{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#4e79df}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#4e79df}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.sensitive-info{display:flex;flex-direction:row;align-items:center;position:absolute;top:4px;left:4px;z-index:100}.sensitive-marker{margin:0 3px;border-radius:2px;padding:2px 6px;color:rgba(255,255,255,.8);background:rgba(0,0,0,.5);font-size:12px;line-height:18px;text-transform:uppercase;opacity:.9;transition:opacity .1s ease}.media-gallery:hover .sensitive-marker{opacity:1}.list-editor{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#444b5d;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#444b5d}.list-adder__lists{background:#444b5d;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #393f4f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#1b1e25;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#131419}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#2485cb}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#2558d0}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#000;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#364861}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.glitch.local-settings{position:relative;display:flex;flex-direction:row;background:#d9e1e8;color:#000;border-radius:8px;height:80vh;width:80vw;max-width:740px;max-height:450px;overflow:hidden}.glitch.local-settings label,.glitch.local-settings legend{display:block;font-size:14px}.glitch.local-settings .boolean label,.glitch.local-settings .radio_buttons label{position:relative;padding-left:28px;padding-top:3px}.glitch.local-settings .boolean label input,.glitch.local-settings .radio_buttons label input{position:absolute;left:0;top:0}.glitch.local-settings span.hint{display:block;color:#1b1e25}.glitch.local-settings h1{font-size:18px;font-weight:500;line-height:24px;margin-bottom:20px}.glitch.local-settings h2{font-size:15px;font-weight:500;line-height:20px;margin-top:20px;margin-bottom:10px}.glitch.local-settings__navigation__item{display:block;padding:15px 20px;color:inherit;background:#f2f5f7;border-bottom:1px #d9e1e8 solid;cursor:pointer;text-decoration:none;outline:none;transition:background .3s}.glitch.local-settings__navigation__item .text-icon-button{color:inherit;transition:unset}.glitch.local-settings__navigation__item:hover{background:#d9e1e8}.glitch.local-settings__navigation__item.active{background:#2b5fd9;color:#fff}.glitch.local-settings__navigation__item.close,.glitch.local-settings__navigation__item.close:hover{background:#df405a;color:#fff}.glitch.local-settings__navigation{background:#f2f5f7;width:212px;font-size:15px;line-height:20px;overflow-y:auto}.glitch.local-settings__page{display:block;flex:auto;padding:15px 20px 15px 20px;width:360px;overflow-y:auto}.glitch.local-settings__page__item{margin-bottom:2px}.glitch.local-settings__page__item.string,.glitch.local-settings__page__item.radio_buttons{margin-top:10px;margin-bottom:10px}@media screen and (max-width: 630px){.glitch.local-settings__navigation{width:40px;flex-shrink:0}.glitch.local-settings__navigation__item{padding:10px}.glitch.local-settings__navigation__item span:last-of-type{display:none}}.error-boundary{color:#fff;font-size:15px;line-height:20px}.error-boundary h1{font-size:26px;line-height:36px;font-weight:400;margin-bottom:8px}.error-boundary a{color:#fff;text-decoration:underline}.error-boundary ul{list-style:disc;margin-left:0;padding-left:1em}.error-boundary textarea.web_app_crash-stacktrace{width:100%;resize:none;white-space:pre;font-family:monospace,monospace}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.compose-panel .search__icon .fa{top:15px}.compose-panel .drawer--account{flex:0 1 48px}.compose-panel .flex-spacer{background:transparent}.compose-panel .composer{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #313543;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px;min-height:50px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{padding-top:15px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#2558d0;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#4976de}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.search{margin-bottom:10px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#ecf0f4;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#4e79df}.announcements{background:#393f4f;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#dde3ec;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#42485a;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#dde3ec}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#4a5266;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#eaeef3}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#3d4d73}.reactions-bar__item.active .reactions-bar__item__count{color:#4ea2df}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#dde3ec;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#eaeef3;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll ul,.e-content .poll ul{margin:0;list-style:none}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#8ba1bf;height:5px;min-width:1%}.poll__chart.leading{background:#2b5fd9}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;display:block;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#2b90d9}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#acd6c1;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#c2cede}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#c2cede;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(194,206,222,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb;overflow-x:hidden}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{width:100%;flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#8d9ac2;border-color:#8d9ac2;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#c2cede}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(43,95,217,.2)}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:sans-serif,sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#dde3ec}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#dde3ec}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#ecf0f4}.rich-formatting em{font-style:italic;color:#ecf0f4}.rich-formatting code{font-size:.85em;background:#17191f;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:sans-serif,sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#ecf0f4}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #313543;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #313543;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#dde3ec}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#c2cede}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#1f232b;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:sans-serif,sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#ecf0f4}.information-board__section strong{font-family:sans-serif,sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#17191f;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#dde3ec;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #313543;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#bcc9da}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;width:80px;height:80px;background-size:80px 80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px;border-radius:8%;background-position:50%;background-clip:padding-box}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#dde3ec}.landing-page p,.landing-page li{font-family:sans-serif,sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#dde3ec}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.landing-page h1{font-family:sans-serif,sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h1 small{font-family:sans-serif,sans-serif;display:block;font-size:18px;font-weight:400;color:#fefefe}.landing-page h2{font-family:sans-serif,sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h3{font-family:sans-serif,sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h4{font-family:sans-serif,sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h5{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h6{font-family:sans-serif,sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#282c37;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#fefefe}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px;width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#282c37;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#ecf0f4}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#dde3ec}.landing-page__short-description h1 small span{color:#ecf0f4}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#282c37;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:sans-serif,sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#dde3ec}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#dde3ec}.landing .simple_form p.lead{color:#dde3ec;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #393f4f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#c2cede}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #282c37;text-align:left;background:#1f232b}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #282c37;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#282c37}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#282c37;border-top:1px solid #17191f;border-bottom:1px solid #17191f}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #17191f}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #17191f}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:monospace,monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#dde3ec;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #17191f;background:#282c37;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #17191f;border-top:0;background:#282c37}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #17191f;border-top:0;background:#1f232b}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #17191f}}.batch-table__row:hover{background:#242731}.batch-table__row:nth-child(even){background:#282c37}.batch-table__row:nth-child(even):hover{background:#2c313d}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#dde3ec;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #17191f;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #17191f}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#282c37;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#393f4f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#dde3ec;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#42485a}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#dde3ec;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#1d2028;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#242731;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#1f232b;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#2b5fd9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#416fdd}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #393f4f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#ecf0f4;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#ecf0f4;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#dde3ec;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #393f4f}.admin-wrapper .content h6{font-size:16px;color:#ecf0f4;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:none}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#ecf0f4;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #313543;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b5fd9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#dde3ec}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#c2cede;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#dde3ec;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #282c37}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #333846}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b5fd9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#ecf0f4}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#282c37;border-bottom:1px solid #313543}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#dde3ec;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#c2cede}.log-entry a,.log-entry .username,.log-entry .target{color:#ecf0f4;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#ecf0f4}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b5fd9}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#dde3ec}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#c2cede}.report-card{background:#282c37;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#dde3ec;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#f7f9fb}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #1f232b}.report-card__summary__item:hover{background:#2c313d}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#dde3ec}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#c2cede;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#dde3ec}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#393f4f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#4e79df}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.announcements-list{border:1px solid #313543;border-radius:4px}.announcements-list__item{padding:15px 0;background:#282c37;border-bottom:1px solid #313543}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#ecf0f4;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#fff}.announcements-list__item__meta{padding:0 15px;color:#c2cede}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff)}.hicolor-privacy-icons .status__visibility-icon.fa-globe,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-globe{color:#1976d2}.hicolor-privacy-icons .status__visibility-icon.fa-unlock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-unlock{color:#388e3c}.hicolor-privacy-icons .status__visibility-icon.fa-lock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-lock{color:#ffa000}.hicolor-privacy-icons .status__visibility-icon.fa-envelope,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-envelope{color:#d32f2f}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .composer--publisher{text-align:left}body.rtl .boost-modal__status-time,body.rtl .favourite-modal__status-time{float:left}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .setting-meta__label{float:left}body.rtl .status__avatar{margin-left:10px;margin-right:0;left:auto;right:10px}body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:58px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left;text-align:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(19, 20, 25, 0), #131419)}body.rtl .simple_form select{background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#313543;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#393f4f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:sans-serif,sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#dde3ec;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}.compose-form .compose-form__modifiers .compose-form__upload-description input::placeholder{opacity:1}.rich-formatting a,.rich-formatting p a,.rich-formatting li a,.landing-page__short-description p a,.status__content a,.reply-indicator__content a{color:#5f86e2;text-decoration:underline}.rich-formatting a.mention,.rich-formatting p a.mention,.rich-formatting li a.mention,.landing-page__short-description p a.mention,.status__content a.mention,.reply-indicator__content a.mention{text-decoration:none}.rich-formatting a.mention span,.rich-formatting p a.mention span,.rich-formatting li a.mention span,.landing-page__short-description p a.mention span,.status__content a.mention span,.reply-indicator__content a.mention span{text-decoration:underline}.rich-formatting a.mention span:hover,.rich-formatting a.mention span:focus,.rich-formatting a.mention span:active,.rich-formatting p a.mention span:hover,.rich-formatting p a.mention span:focus,.rich-formatting p a.mention span:active,.rich-formatting li a.mention span:hover,.rich-formatting li a.mention span:focus,.rich-formatting li a.mention span:active,.landing-page__short-description p a.mention span:hover,.landing-page__short-description p a.mention span:focus,.landing-page__short-description p a.mention span:active,.status__content a.mention span:hover,.status__content a.mention span:focus,.status__content a.mention span:active,.reply-indicator__content a.mention span:hover,.reply-indicator__content a.mention span:focus,.reply-indicator__content a.mention span:active{text-decoration:none}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active,.rich-formatting p a:hover,.rich-formatting p a:focus,.rich-formatting p a:active,.rich-formatting li a:hover,.rich-formatting li a:focus,.rich-formatting li a:active,.landing-page__short-description p a:hover,.landing-page__short-description p a:focus,.landing-page__short-description p a:active,.status__content a:hover,.status__content a:focus,.status__content a:active,.reply-indicator__content a:hover,.reply-indicator__content a:focus,.reply-indicator__content a:active{text-decoration:none}.rich-formatting a.status__content__spoiler-link,.rich-formatting p a.status__content__spoiler-link,.rich-formatting li a.status__content__spoiler-link,.landing-page__short-description p a.status__content__spoiler-link,.status__content a.status__content__spoiler-link,.reply-indicator__content a.status__content__spoiler-link{color:#ecf0f4;text-decoration:none}.status__content__read-more-button{text-decoration:underline}.status__content__read-more-button:hover,.status__content__read-more-button:focus,.status__content__read-more-button:active{text-decoration:none}.getting-started__footer a{text-decoration:underline}.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:none}.nothing-here{color:#dde3ec}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b5fd9}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n$ui-base-color: $classic-base-color !default;\n$ui-primary-color: $classic-primary-color !default;\n$ui-secondary-color: $classic-secondary-color !default;\n\n// Differences\n$ui-highlight-color: #2b5fd9;\n\n$darker-text-color: lighten($ui-primary-color, 20%) !default;\n$dark-text-color: lighten($ui-primary-color, 12%) !default;\n$secondary-text-color: lighten($ui-secondary-color, 6%) !default;\n$highlight-text-color: $classic-highlight-color !default;\n$action-button-color: #8d9ac2;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: darken($ui-base-color,6%) !default;\n$light-text-color: darken($ui-primary-color, 40%) !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n @return '%23' + unquote($color)\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a; // Padua\n$error-red: #df405a; // Cerise\n$warning-red: #ff5050; // Sunset Orange\n$gold-star: #ca8f04; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: sans-serif !default;\n$font-display: sans-serif !default;\n$font-monospace: monospace !default;\n\n// Avatar border size (8% default, 100% for rounded avatars)\n$ui-avatar-border-size: 8%;\n\n// More variables\n$dismiss-overlay-width: 4rem;\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n @include avatar-size(40px);\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1/3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1/3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n @include avatar-size(120px);\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n @include avatar-radius();\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n","@mixin avatar-radius() {\n border-radius: $ui-avatar-border-size;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size:48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin single-column($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .single-column #{$parent} {\n @content;\n }\n}\n\n@mixin limited-single-column($media, $parent: '&') {\n .auto-columns #{$parent}, .single-column #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n}\n\n@mixin multi-columns($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .multi-columns #{$parent} {\n @content;\n }\n}\n\n@mixin fullwidth-gallery {\n &.full-width {\n margin-left: -14px;\n margin-right: -14px;\n width: inherit;\n max-width: none;\n height: 250px;\n border-radius: 0px;\n }\n}\n\n@mixin search-input() {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: none;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout() {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n padding: 8px 0;\n padding-bottom: 2px;\n margin: initial;\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n position: absolute;\n margin: initial;\n float: initial;\n width: auto;\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n\n// Styling from upstream's WebUI, as public pages use the same layout\n.embed,\n.public-layout {\n .status {\n .status__info {\n font-size: 15px;\n display: initial;\n }\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding: 6px 0;\n padding-right: 25px;\n margin: initial;\n\n .display-name strong {\n display: inline;\n }\n }\n\n .status__avatar {\n height: 48px;\n position: absolute;\n width: 48px;\n margin: initial;\n }\n }\n}\n\n.rtl {\n .embed,\n .public-layout {\n .status {\n padding-left: 10px;\n padding-right: 68px;\n\n .status__info .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .status__relative-time {\n float: left;\n }\n }\n }\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: darken($ui-highlight-color, 3%);\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n transition-property: background-color;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 7%);\n transition: all 200ms ease-out;\n transition-property: background-color;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n color: $darker-text-color;\n text-transform: none;\n background: transparent;\n padding: 3px 15px;\n border-radius: 4px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n transform-origin: 50% 0;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: 0;\n position: absolute;\n\n .fa.star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n a {\n color: inherit;\n text-decoration: inherit;\n }\n\n strong {\n height: 18px;\n font-size: 16px;\n font-weight: 500;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n span {\n display: block;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n > a:hover {\n strong {\n text-decoration: underline;\n }\n }\n\n &.inline {\n padding: 0;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n strong {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n\n span {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n }\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n ul {\n list-style: none;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.static-content {\n padding: 10px;\n padding-top: 20px;\n color: $dark-text-color;\n\n h1 {\n font-size: 16px;\n font-weight: 500;\n margin-bottom: 40px;\n text-align: center;\n }\n\n p {\n font-size: 13px;\n margin-bottom: 20px;\n }\n}\n\n.column,\n.drawer {\n flex: 1 1 100%;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @include multi-columns('screen and (min-width: 631px)') {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $ui-highlight-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n\n span.icon {\n margin-left: 0;\n display: inline;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.getting-started__wrapper,\n.getting_started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.getting-started__wrapper {\n position: relative;\n overflow-y: auto;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n background: $ui-base-color;\n flex: 1 0 auto;\n\n p {\n color: $secondary-text-color;\n }\n\n a {\n color: $dark-text-color;\n }\n\n &__panel {\n height: min-content;\n }\n\n &__panel,\n &__footer {\n padding: 10px;\n padding-top: 20px;\n flex: 0 1 auto;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n color: $darker-text-color;\n background: transparent;\n border: none;\n border-bottom: 2px solid $ui-primary-color;\n box-sizing: border-box;\n display: block;\n font-family: inherit;\n margin-bottom: 10px;\n padding: 7px 0;\n width: 100%;\n\n &:focus,\n &:active {\n color: $primary-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n font-size: 16px;\n }\n\n &.light {\n color: $inverted-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 27%);\n\n &:focus,\n &:active {\n color: $inverted-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.reduce-motion button.icon-button.disabled i.fa-retweet {\n color: darken($action-button-color, 13%);\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.missing-indicator {\n padding-top: 20px + 48px;\n}\n\n.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {\n border-top: 1px solid $ui-base-color;\n}\n\n.notification__dismiss-overlay {\n overflow: hidden;\n position: absolute;\n top: 0;\n right: 0;\n bottom: -1px;\n padding-left: 15px; // space for the box shadow to be visible\n\n z-index: 999;\n align-items: center;\n justify-content: flex-end;\n cursor: pointer;\n\n display: flex;\n\n .wrappy {\n width: $dismiss-overlay-width;\n align-self: stretch;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: lighten($ui-base-color, 8%);\n border-left: 1px solid lighten($ui-base-color, 20%);\n box-shadow: 0 0 5px black;\n border-bottom: 1px solid $ui-base-color;\n }\n\n .ckbox {\n border: 2px solid $ui-primary-color;\n border-radius: 2px;\n width: 30px;\n height: 30px;\n font-size: 20px;\n color: $darker-text-color;\n text-shadow: 0 0 5px black;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n &:focus {\n outline: 0 !important;\n\n .ckbox {\n box-shadow: 0 0 1px 1px $ui-highlight-color;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: flex;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n align-items: center;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label,\n.setting-radio__label,\n.setting-meta__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.setting-radio {\n display: block;\n line-height: 18px;\n}\n\n.setting-radio__label {\n margin-bottom: 0;\n}\n\n.column-settings__row legend {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-top: 10px;\n}\n\n.setting-radio__input {\n vertical-align: middle;\n}\n\n.setting-meta__label {\n float: right;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n transform-origin: center center;\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.pulse-loading {\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.loading-bar {\n background-color: $ui-highlight-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.icon-badge-wrapper {\n position: relative;\n}\n\n.icon-badge {\n position: absolute;\n display: block;\n right: -.25em;\n top: -.25em;\n background-color: $ui-highlight-color;\n border-radius: 50%;\n font-size: 75%;\n width: 1em;\n height: 1em;\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n .status__content {\n margin: 0;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.ui .flash-message {\n margin-top: 10px;\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 0;\n min-width: 75%;\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@import 'boost';\n@import 'accounts';\n@import 'domains';\n@import 'status';\n@import 'modal';\n@import 'composer';\n@import 'columns';\n@import 'regeneration_indicator';\n@import 'directory';\n@import 'search';\n@import 'emoji';\n@import 'doodle';\n@import 'drawer';\n@import 'media';\n@import 'sensitive';\n@import 'lists';\n@import 'emoji_picker';\n@import 'local_settings';\n@import 'error_boundary';\n@import 'single_column';\n@import 'announcements';\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant\nbutton.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant for use with DMs\n.status-direct button.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n",".account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n color: inherit;\n text-decoration: none;\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n\n &.small {\n border: none;\n padding: 0;\n\n & > .account__avatar-wrapper { margin: 0 8px 0 0 }\n\n & > .display-name {\n height: 24px;\n line-height: 24px;\n }\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius();\n position: relative;\n cursor: pointer;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n overflow: hidden;\n position: relative;\n\n & div {\n @include avatar-radius;\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\n.account__avatar-overlay {\n position: relative;\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius();\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius();\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__header__wrapper {\n flex: 0 0 auto;\n background: lighten($ui-base-color, 4%);\n}\n\n.account__disclaimer {\n padding: 10px;\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-left: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &:first-child {\n border-left: 0;\n }\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n abbr {\n color: $highlight-text-color;\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.notification__message {\n margin-left: 42px;\n padding: 8px 0 0 26px;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input();\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout();\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n",".domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n","@keyframes spring-flip-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-242.4deg);\n }\n\n 60% {\n transform: rotate(-158.35deg);\n }\n\n 90% {\n transform: rotate(-187.5deg);\n }\n\n 100% {\n transform: rotate(-180deg);\n }\n}\n\n@keyframes spring-flip-out {\n 0% {\n transform: rotate(-180deg);\n }\n\n 30% {\n transform: rotate(62.4deg);\n }\n\n 60% {\n transform: rotate(-21.635deg);\n }\n\n 90% {\n transform: rotate(7.5deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content {\n position: relative;\n margin: 10px 0;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n padding-top: 5px;\n\n &:focus {\n outline: 0;\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .status__content__text,\n .e-content {\n overflow: hidden;\n\n & > ul,\n & > ol {\n margin-bottom: 20px;\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 1.2em;\n }\n\n h2 {\n font-size: 1.1em;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $darker-text-color;\n color: $darker-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n sup {\n font-size: smaller;\n vertical-align: super;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n .status__content__spoiler {\n display: none;\n\n &.status__content__spoiler--visible {\n display: block;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n\n .link-origin-tag {\n color: $gold-star;\n font-size: 0.8em;\n }\n }\n\n .status__content__spoiler-link {\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: lighten($ui-base-color, 30%);\n border: none;\n color: $inverted-text-color;\n font-weight: 500;\n font-size: 11px;\n padding: 0 5px;\n text-transform: uppercase;\n line-height: inherit;\n cursor: pointer;\n vertical-align: bottom;\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n\n .status__content__spoiler-icon {\n display: inline-block;\n margin: 0 0 0 5px;\n border-left: 1px solid currentColor;\n padding: 0 0 0 4px;\n font-size: 16px;\n vertical-align: -2px;\n }\n}\n\n.notif-cleaning {\n .status,\n .notification-follow,\n .notification-follow-request {\n padding-right: ($dismiss-overlay-width + 0.5rem);\n }\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.notification-follow,\n.notification-follow-request {\n position: relative;\n\n // same like Status\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .account {\n border-bottom: 0 none;\n }\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n &.status.status-direct:not(.read) {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 10px 14px;\n position: relative;\n height: auto;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 28px; // 12px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $lighter-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n\n &.collapsed {\n background-position: center;\n background-size: cover;\n user-select: none;\n\n &.has-background::before {\n display: block;\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));\n pointer-events: none;\n content: \"\";\n }\n\n .display-name:hover .display-name__html {\n text-decoration: none;\n }\n\n .status__content {\n height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 0;\n\n &:after {\n content: \"\";\n position: absolute;\n top: 0; bottom: 0;\n left: 0; right: 0;\n background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));\n pointer-events: none;\n }\n \n a:hover {\n text-decoration: none;\n }\n }\n &:focus > .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));\n }\n &.status-direct:not(.read)> .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));\n }\n\n .notification__message {\n margin-bottom: 0;\n }\n\n .status__info .notification__message > span {\n white-space: nowrap;\n }\n }\n\n .notification__message {\n margin: -10px 0px 10px 0;\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time {\n display: inline-block;\n flex-grow: 1;\n color: $dark-text-color;\n font-size: 14px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.status__display-name {\n color: $dark-text-color;\n overflow: hidden;\n}\n\n.status__info__account .status__display-name {\n display: block;\n max-width: 100%;\n}\n\n.status__info {\n display: flex;\n justify-content: space-between;\n font-size: 15px;\n\n > span {\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .notification__message > span {\n word-wrap: break-word;\n }\n}\n\n.status__info__icons {\n display: flex;\n align-items: center;\n height: 1em;\n color: $action-button-color;\n\n .status__media-icon,\n .status__visibility-icon,\n .status__reply-icon {\n padding-left: 2px;\n padding-right: 2px;\n }\n\n .status__collapse-button.active > .fa-angle-double-up {\n transform: rotate(-180deg);\n }\n}\n\n.no-reduce-motion .status__collapse-button {\n &.activate {\n & > .fa-angle-double-up {\n animation: spring-flip-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-angle-double-up {\n animation: spring-flip-out 1s linear;\n }\n }\n}\n\n.status__info__account {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-top: -10px;\n margin-bottom: 10px;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\na.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\n.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n flex: none;\n margin: 0 10px 0 0;\n height: 48px;\n width: 48px;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a,\n .status__content__text {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-color, 29%);\n text-decoration: none;\n }\n }\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n\n a .fa, a:hover .fa {\n color: inherit;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n.status__wrapper--filtered__button {\n display: inline;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n font-size: inherit;\n line-height: inherit;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.onboarding-modal__pager {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 470px;\n\n .react-swipeable-view-container > div {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n user-select: text;\n }\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n@media screen and (max-width: 550px) {\n .onboarding-modal {\n width: 100%;\n height: 100%;\n border-radius: 0;\n }\n\n .onboarding-modal__pager {\n width: 100%;\n height: auto;\n max-width: none;\n max-height: none;\n flex: 1 1 auto;\n }\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.onboarding-modal__dots {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.onboarding-modal__dot {\n width: 14px;\n height: 14px;\n border-radius: 14px;\n background: darken($ui-secondary-color, 16%);\n margin: 0 3px;\n cursor: pointer;\n\n &:hover {\n background: darken($ui-secondary-color, 18%);\n }\n\n &.active {\n cursor: default;\n background: darken($ui-secondary-color, 24%);\n }\n}\n\n.onboarding-modal__page__wrapper {\n pointer-events: none;\n padding: 25px;\n padding-bottom: 0;\n\n &.onboarding-modal__page__wrapper--active {\n pointer-events: auto;\n }\n}\n\n.onboarding-modal__page {\n cursor: default;\n line-height: 21px;\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 20px;\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 4%);\n }\n }\n\n .navigation-bar a {\n color: inherit;\n }\n\n p {\n font-size: 16px;\n color: $lighter-text-color;\n margin-top: 10px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n background: $ui-base-color;\n color: $secondary-text-color;\n border-radius: 4px;\n font-size: 14px;\n padding: 3px 6px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.onboarding-modal__page__wrapper-0 {\n height: 100%;\n padding: 0;\n}\n\n.onboarding-modal__page-one {\n &__lead {\n padding: 65px;\n padding-top: 45px;\n padding-bottom: 0;\n margin-bottom: 10px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n margin-bottom: 8px;\n }\n\n p {\n margin-bottom: 0;\n }\n }\n\n &__extra {\n padding-right: 65px;\n padding-left: 185px;\n text-align: center;\n }\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboarding-modal__page-two,\n.onboarding-modal__page-three,\n.onboarding-modal__page-four,\n.onboarding-modal__page-five {\n p {\n text-align: left;\n }\n\n .figure {\n background: darken($ui-base-color, 8%);\n color: $secondary-text-color;\n margin-bottom: 20px;\n border-radius: 4px;\n padding: 10px;\n text-align: center;\n font-size: 14px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);\n\n .onboarding-modal__image {\n border-radius: 4px;\n margin-bottom: 10px;\n }\n\n &.non-interactive {\n pointer-events: none;\n text-align: left;\n }\n }\n}\n\n.onboarding-modal__page-four__columns {\n .row {\n display: flex;\n margin-bottom: 20px;\n\n & > div {\n flex: 1 1 0;\n margin: 0 10px;\n\n &:first-child {\n margin-left: 0;\n }\n\n &:last-child {\n margin-right: 0;\n }\n\n p {\n text-align: center;\n }\n }\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .column-header {\n color: $primary-text-color;\n }\n}\n\n@media screen and (max-width: 320px) and (max-height: 600px) {\n .onboarding-modal__page p {\n font-size: 14px;\n line-height: 20px;\n }\n\n .onboarding-modal__page-two .figure,\n .onboarding-modal__page-three .figure,\n .onboarding-modal__page-four .figure,\n .onboarding-modal__page-five .figure {\n font-size: 12px;\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .row {\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .column-header {\n padding: 5px;\n font-size: 12px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.favourite-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__display-name {\n display: flex;\n }\n\n .status__avatar {\n height: 48px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container,\n.favourite-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.favourite-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header,\n.favourite-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time,\n.favourite-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n strong {\n display: block;\n font-weight: 500;\n }\n\n max-height: 80vh;\n max-width: 80vw;\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n & > .react-toggle,\n & > .icon,\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__do_not_ask_again {\n padding-left: 20px;\n padding-right: 20px;\n padding-bottom: 10px;\n\n font-size: 14px;\n\n label, input {\n vertical-align: middle;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: 'mastodon-font-monospace', monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.filtered-status-info {\n text-align: start;\n\n .spoiler__text {\n margin-top: 20px;\n }\n\n .account {\n border-bottom: 0;\n }\n\n .account__display-name strong {\n color: $inverted-text-color;\n }\n\n .status__content__spoiler {\n display: none;\n\n &--visible {\n display: flex;\n }\n }\n\n ul {\n padding: 10px;\n margin-left: 12px;\n list-style: disc inside;\n }\n\n .filtered-status-edit-link {\n color: $action-button-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline\n }\n }\n}\n",".composer {\n padding: 10px;\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n\n ::-webkit-scrollbar-track:hover,\n ::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .composer--spoiler {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.composer--spoiler {\n height: 0;\n transform-origin: bottom;\n opacity: 0.0;\n\n &.composer--spoiler--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1.0;\n }\n\n input {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px;\n padding: 10px;\n width: 100%;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: vertical;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n }\n}\n\n.composer--warning {\n color: $inverted-text-color;\n margin-bottom: 15px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:active,\n &:focus,\n &:hover { text-decoration: none }\n }\n}\n\n.compose-form__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-left: 5px;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n}\n\n.composer--reply {\n margin: 0 0 10px;\n border-radius: 4px;\n padding: 10px;\n background: $ui-primary-color;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n\n & > header {\n margin-bottom: 5px;\n overflow: hidden;\n\n & > .account.small { color: $inverted-text-color; }\n\n & > .cancel {\n float: right;\n line-height: 24px;\n }\n }\n\n & > .content {\n position: relative;\n margin: 10px 0;\n padding: 0 12px;\n font-size: 14px;\n line-height: 20px;\n color: $inverted-text-color;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n white-space: pre-wrap;\n padding-top: 5px;\n overflow: hidden;\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 18px;\n }\n\n h2 {\n font-size: 16px;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $inverted-text-color;\n color: $inverted-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n\n a {\n color: $lighter-text-color;\n text-decoration: none;\n\n &:hover { text-decoration: underline }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span { text-decoration: underline }\n }\n }\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -5px 0 0;\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.autosuggest-input {\n position: relative;\n width: 100%;\n\n label {\n .autosuggest-textarea__textarea {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px 4px 0 0;\n padding: 10px 32px 0 10px;\n width: 100%;\n min-height: 100px;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: none;\n scrollbar-color: initial;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n &:disabled { background: $ui-secondary-color }\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n }\n}\n\n.composer--textarea--icons {\n display: block;\n position: absolute;\n top: 29px;\n right: 5px;\n bottom: 5px;\n overflow: hidden;\n\n & > .textarea_icon {\n display: block;\n margin: 2px 0 0 2px;\n width: 24px;\n height: 24px;\n color: $lighter-text-color;\n font-size: 18px;\n line-height: 24px;\n text-align: center;\n opacity: .8;\n }\n}\n\n.autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n}\n\n.autosuggest-textarea__suggestions {\n display: block;\n position: absolute;\n box-sizing: border-box;\n top: 100%;\n border-radius: 0 0 4px 4px;\n padding: 6px;\n width: 100%;\n color: $inverted-text-color;\n background: $ui-secondary-color;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n font-size: 14px;\n z-index: 99;\n display: none;\n}\n\n.autosuggest-textarea__suggestions--visible {\n display: block;\n}\n\n.autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected { background: darken($ui-secondary-color, 10%) }\n\n > .account,\n > .emoji,\n > .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n & > .account.small {\n .display-name {\n & > span { color: $lighter-text-color }\n }\n }\n}\n\n.composer--upload_form {\n overflow: hidden;\n\n & > .content {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n font-family: inherit;\n padding: 5px;\n overflow: hidden;\n }\n}\n\n.composer--upload_form--item {\n flex: 1 1 0;\n margin: 5px;\n min-width: 40%;\n\n & > div {\n position: relative;\n border-radius: 4px;\n height: 140px;\n width: 100%;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n overflow: hidden;\n\n textarea {\n display: block;\n position: absolute;\n box-sizing: border-box;\n bottom: 0;\n left: 0;\n margin: 0;\n border: 0;\n padding: 10px;\n width: 100%;\n color: $secondary-text-color;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n font-size: 14px;\n font-family: inherit;\n font-weight: 500;\n opacity: 0;\n z-index: 2;\n transition: opacity .1s ease;\n\n &:focus { color: $white }\n\n &::placeholder {\n opacity: 0.54;\n color: $secondary-text-color;\n }\n }\n\n & > .close { mix-blend-mode: difference }\n }\n\n &.active {\n & > div {\n textarea { opacity: 1 }\n }\n }\n}\n\n.composer--upload_form--actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $ui-secondary-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($ui-secondary-color, 4%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n}\n\n.composer--upload_form--progress {\n display: flex;\n padding: 10px;\n color: $darker-text-color;\n overflow: hidden;\n\n & > .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n & > .message {\n flex: 1 1 auto;\n\n & > span {\n display: block;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n & > .backdrop {\n position: relative;\n margin-top: 5px;\n border-radius: 6px;\n width: 100%;\n height: 6px;\n background: $ui-base-lighter-color;\n\n & > .tracker {\n position: absolute;\n top: 0;\n left: 0;\n height: 6px;\n border-radius: 6px;\n background: $ui-highlight-color;\n }\n }\n }\n}\n\n.compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n}\n\n.composer--options-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n height: 27px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n}\n\n.composer--options {\n display: flex;\n flex: 0 0 auto;\n\n & > * {\n display: inline-block;\n box-sizing: content-box;\n padding: 0 3px;\n height: 27px;\n line-height: 27px;\n vertical-align: bottom;\n }\n\n & > hr {\n display: inline-block;\n margin: 0 3px;\n border-width: 0 0 0 1px;\n border-style: none none none solid;\n border-color: transparent transparent transparent darken($simple-background-color, 24%);\n padding: 0;\n width: 0;\n height: 27px;\n background: transparent;\n }\n}\n\n.compose--counter-wrapper {\n align-self: center;\n margin-right: 4px;\n}\n\n.composer--options--dropdown {\n &.open {\n & > .value {\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n color: $primary-text-color;\n background: $ui-highlight-color;\n transition: none;\n }\n &.top {\n & > .value {\n border-radius: 0 0 4px 4px;\n box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);\n }\n }\n }\n}\n\n.composer--options--dropdown--content {\n position: absolute;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n background: $simple-background-color;\n overflow: hidden;\n transform-origin: 50% 0;\n}\n\n.composer--options--dropdown--content--item {\n display: flex;\n align-items: center;\n padding: 10px;\n color: $inverted-text-color;\n cursor: pointer;\n\n & > .content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n &:not(:first-child) { margin-left: 10px }\n\n strong {\n display: block;\n color: $inverted-text-color;\n font-weight: 500;\n }\n }\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n\n & > .content {\n color: $primary-text-color;\n\n strong { color: $primary-text-color }\n }\n }\n\n &.active:hover { background: lighten($ui-highlight-color, 4%) }\n}\n\n.composer--publisher {\n padding-top: 10px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n justify-content: flex-end;\n flex: 0 0 auto;\n\n & > .primary {\n display: inline-block;\n margin: 0;\n padding: 0 10px;\n text-align: center;\n }\n\n & > .side_arm {\n display: inline-block;\n margin: 0 2px;\n padding: 0;\n width: 36px;\n text-align: center;\n }\n\n &.over {\n & > .count { color: $warning-red }\n }\n}\n",".column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.column {\n overflow: hidden;\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n\n & > button {\n margin: 0;\n border: none;\n padding: 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n\n .wide .columns-area:not(.columns-area--mobile) & {\n flex: auto;\n min-width: 330px;\n max-width: 400px;\n }\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n margin-left: 0;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n\n // glitch - added focus ring for keyboard navigation\n &:focus {\n text-shadow: 0 0 4px darken($ui-highlight-color, 5%);\n }\n}\n\n.column-header__notif-cleaning-buttons {\n display: flex;\n align-items: stretch;\n justify-content: space-around;\n\n button {\n @extend .column-header__button;\n background: transparent;\n text-align: center;\n padding: 10px 0;\n white-space: pre-wrap;\n }\n\n b {\n font-weight: bold;\n }\n}\n\n// The notifs drawer with no padding to have more space for the buttons\n.column-header__collapsible-inner.nopad-drawer {\n padding: 0;\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n\n // notif cleaning drawer\n &.ncd {\n transition: none;\n &.collapsed {\n max-height: 0;\n opacity: 0.7;\n }\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.column-header__title {\n display: inline-block;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n// more fixes for the navbar-under mode\n@mixin fix-margins-for-navbar-under {\n .tabs-bar {\n margin-top: 0 !important;\n margin-bottom: -6px !important;\n }\n}\n\n.single-column.navbar-under {\n @include fix-margins-for-navbar-under;\n}\n\n.auto-columns.navbar-under {\n @media screen and (max-width: $no-gap-breakpoint) {\n @include fix-margins-for-navbar-under;\n }\n}\n\n.auto-columns.navbar-under .react-swipeable-view-container .columns-area,\n.single-column.navbar-under .react-swipeable-view-container .columns-area {\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 100% !important;\n }\n}\n\n.column-inline-form {\n padding: 7px 15px;\n padding-right: 5px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n margin-bottom: 6px;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 5px;\n }\n}\n",".regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n",".directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n",".search {\n position: relative;\n}\n\n.search__input {\n @include search-input();\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: color, transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(0deg);\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n padding: 15px 10px;\n font-size: 14px;\n font-weight: 500;\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n",null,".emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n","$doodleBg: #d9e1e8;\n.doodle-modal {\n @extend .boost-modal;\n width: unset;\n}\n\n.doodle-modal__container {\n background: $doodleBg;\n text-align: center;\n line-height: 0; // remove weird gap under canvas\n canvas {\n border: 5px solid $doodleBg;\n }\n}\n\n.doodle-modal__action-bar {\n @extend .boost-modal__action-bar;\n\n .filler {\n flex-grow: 1;\n margin: 0;\n padding: 0;\n }\n\n .doodle-toolbar {\n line-height: 1;\n\n display: flex;\n flex-direction: column;\n flex-grow: 0;\n justify-content: space-around;\n\n &.with-inputs {\n label {\n display: inline-block;\n width: 70px;\n text-align: right;\n margin-right: 2px;\n }\n\n input[type=\"number\"],input[type=\"text\"] {\n width: 40px;\n }\n span.val {\n display: inline-block;\n text-align: left;\n width: 50px;\n }\n }\n }\n\n .doodle-palette {\n padding-right: 0 !important;\n border: 1px solid black;\n line-height: .2rem;\n flex-grow: 0;\n background: white;\n\n button {\n appearance: none;\n width: 1rem;\n height: 1rem;\n margin: 0; padding: 0;\n text-align: center;\n color: black;\n text-shadow: 0 0 1px white;\n cursor: pointer;\n box-shadow: inset 0 0 1px rgba(white, .5);\n border: 1px solid black;\n outline-offset:-1px;\n\n &.foreground {\n outline: 1px dashed white;\n }\n\n &.background {\n outline: 1px dashed red;\n }\n\n &.foreground.background {\n outline: 1px dashed red;\n border-color: white;\n }\n }\n }\n}\n",".drawer {\n width: 300px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n padding: 10px 5px;\n flex: none;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n\n @include single-column('screen and (max-width: 630px)') { flex: auto }\n\n @include limited-single-column('screen and (max-width: 630px)') {\n &, &:first-child, &:last-child { padding: 0 }\n }\n\n .wide & {\n min-width: 300px;\n max-width: 400px;\n flex: 1 1 200px;\n }\n\n @include single-column('screen and (max-width: 630px)') {\n :root & { // Overrides `.wide` for single-column view\n flex: auto;\n width: 100%;\n min-width: 0;\n max-width: none;\n padding: 0;\n }\n }\n\n .react-swipeable-view-container & { height: 100% }\n}\n\n.drawer--header {\n display: flex;\n flex-direction: row;\n margin-bottom: 10px;\n flex: none;\n background: lighten($ui-base-color, 8%);\n font-size: 16px;\n\n & > * {\n display: block;\n box-sizing: border-box;\n border-bottom: 2px solid transparent;\n padding: 15px 5px 13px;\n height: 48px;\n flex: 1 1 auto;\n color: $darker-text-color;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n }\n\n a {\n transition: background 100ms ease-in;\n\n &:focus,\n &:hover {\n outline: none;\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.search {\n position: relative;\n margin-bottom: 10px;\n flex: none;\n\n @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n}\n\n.search-popout {\n @include search-popout();\n}\n\n.drawer--account {\n padding: 10px;\n color: $darker-text-color;\n display: flex;\n align-items: center;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n .acct {\n display: block;\n color: $secondary-text-color;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n overflow: hidden;\n}\n\n.drawer--results {\n background: $ui-base-color;\n overflow-x: hidden;\n overflow-y: auto;\n\n & > header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n & > section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n\n & > .hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n }\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n > .mastodon {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n cursor: inherit;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n",".video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n width: 100%;\n height: 100%;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 8%);\n }\n\n .status__content > & {\n margin-top: 15px; // Add margin when used bare for NSFW video player\n }\n @include fullwidth-gallery;\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 500;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n height: 100%;\n display: flex;\n flex-direction: column;\n\n span {\n text-align: center;\n color: $darker-text-color;\n display: flex;\n height: 100%;\n align-items: center;\n\n p {\n width: 100%;\n }\n }\n\n audio {\n width: 100%;\n }\n}\n\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n height: 110px;\n\n @include fullwidth-gallery;\n}\n\n.media-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n .full-width & {\n border-radius: 0;\n }\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n\n &.letterbox {\n background: $base-shadow-color;\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n object-fit: contain;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n display: flex;\n justify-content: center;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n width: 100%;\n position: relative;\n z-index: 1;\n object-fit: contain;\n user-select: none;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $white;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $ui-highlight-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n .detailed-status & {\n width: 100%;\n height: 100%;\n }\n\n @include fullwidth-gallery;\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n position: relative;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-shadow-color;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n\n .fa,\n &:active .fa,\n &:hover .fa,\n &:focus .fa {\n color: inherit;\n }\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n",".sensitive-info {\n display: flex;\n flex-direction: row;\n align-items: center;\n position: absolute;\n top: 4px;\n left: 4px;\n z-index: 100;\n}\n\n.sensitive-marker {\n margin: 0 3px;\n border-radius: 2px;\n padding: 2px 6px;\n color: rgba($primary-text-color, 0.8);\n background: rgba($base-overlay-background, 0.5);\n font-size: 12px;\n line-height: 18px;\n text-transform: uppercase;\n opacity: .9;\n transition: opacity .1s ease;\n\n .media-gallery:hover & { opacity: 1 }\n}\n",".list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n",".emoji-mart {\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: 0;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -3px;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: darken($ui-highlight-color, 3%);\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n",".glitch.local-settings {\n position: relative;\n display: flex;\n flex-direction: row;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n height: 80vh;\n width: 80vw;\n max-width: 740px;\n max-height: 450px;\n overflow: hidden;\n\n label, legend {\n display: block;\n font-size: 14px;\n }\n\n .boolean label, .radio_buttons label {\n position: relative;\n padding-left: 28px;\n padding-top: 3px;\n\n input {\n position: absolute;\n left: 0;\n top: 0;\n }\n }\n\n span.hint {\n display: block;\n color: $lighter-text-color;\n }\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n line-height: 24px;\n margin-bottom: 20px;\n }\n\n h2 {\n font-size: 15px;\n font-weight: 500;\n line-height: 20px;\n margin-top: 20px;\n margin-bottom: 10px;\n }\n}\n\n.glitch.local-settings__navigation__item {\n display: block;\n padding: 15px 20px;\n color: inherit;\n background: lighten($ui-secondary-color, 8%);\n border-bottom: 1px $ui-secondary-color solid;\n cursor: pointer;\n text-decoration: none;\n outline: none;\n transition: background .3s;\n\n .text-icon-button {\n color: inherit;\n transition: unset;\n }\n\n &:hover {\n background: $ui-secondary-color;\n }\n\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n\n &.close, &.close:hover {\n background: $error-value-color;\n color: $primary-text-color;\n }\n}\n\n.glitch.local-settings__navigation {\n background: lighten($ui-secondary-color, 8%);\n width: 212px;\n font-size: 15px;\n line-height: 20px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page {\n display: block;\n flex: auto;\n padding: 15px 20px 15px 20px;\n width: 360px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page__item {\n margin-bottom: 2px;\n}\n\n.glitch.local-settings__page__item.string,\n.glitch.local-settings__page__item.radio_buttons {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n@media screen and (max-width: 630px) {\n .glitch.local-settings__navigation {\n width: 40px;\n flex-shrink: 0;\n }\n\n .glitch.local-settings__navigation__item {\n padding: 10px;\n\n span:last-of-type {\n display: none;\n }\n }\n}\n",".error-boundary {\n color: $primary-text-color;\n font-size: 15px;\n line-height: 20px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n font-weight: 400;\n margin-bottom: 8px;\n }\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n }\n\n ul {\n list-style: disc;\n margin-left: 0;\n padding-left: 1em;\n }\n\n textarea.web_app_crash-stacktrace {\n width: 100%;\n resize: none;\n white-space: pre;\n font-family: $font-monospace, monospace;\n }\n}\n",".compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .drawer--account {\n flex: 0 1 48px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .composer {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px;\n min-height: 48px + 2px;\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n padding-top: 15px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .search {\n margin-bottom: 10px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n",".announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n ul,\n .e-content & ul {\n margin: 0;\n list-style: none;\n }\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n display: block;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n overflow-x: hidden;\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n width: 100%;\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n @include avatar-size(80px);\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n @include avatar-radius();\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n @include avatar-size(44px);\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: none;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n","$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;\n\n%emoji-outline {\n filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);\n}\n\n.emojione {\n @each $emoji in $emojis-requiring-outlines {\n &[title=':#{$emoji}:'] {\n @extend %emoji-outline;\n }\n }\n}\n\n.hicolor-privacy-icons {\n .status__visibility-icon.fa-globe,\n .composer--options--dropdown--content--item .fa-globe {\n color: #1976D2;\n }\n\n .status__visibility-icon.fa-unlock,\n .composer--options--dropdown--content--item .fa-unlock {\n color: #388E3C;\n }\n\n .status__visibility-icon.fa-lock,\n .composer--options--dropdown--content--item .fa-lock {\n color: #FFA000;\n }\n\n .status__visibility-icon.fa-envelope,\n .composer--options--dropdown--content--item .fa-envelope {\n color: #D32F2F;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .composer--publisher {\n text-align: left;\n }\n\n .boost-modal__status-time,\n .favourite-modal__status-time {\n float: left;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .setting-meta__label {\n float: left;\n }\n\n .status__avatar {\n margin-left: 10px;\n margin-right: 0;\n\n // Those are used for public pages\n left: auto;\n right: 10px;\n }\n\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 58px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n text-align: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","// components.scss\n.compose-form {\n .compose-form__modifiers {\n .compose-form__upload {\n &-description {\n input {\n &::placeholder {\n opacity: 1.0;\n }\n }\n }\n }\n }\n}\n\n.rich-formatting a,\n.rich-formatting p a,\n.rich-formatting li a,\n.landing-page__short-description p a,\n.status__content a,\n.reply-indicator__content a {\n color: lighten($ui-highlight-color, 12%);\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n }\n\n &.mention span {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.status__content__spoiler-link {\n color: $secondary-text-color;\n text-decoration: none;\n }\n}\n\n.status__content__read-more-button {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.getting-started__footer a {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.nothing-here {\n color: $darker-text-color;\n}\n\n.public-layout .public-account-header__tabs__tabs .counter.active::after {\n border-bottom: 4px solid $ui-highlight-color;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/skins/glitch/contrast/common.js b/priv/static/packs/skins/glitch/contrast/common.js index af82696b8..adca386cf 100644 Binary files a/priv/static/packs/skins/glitch/contrast/common.js and b/priv/static/packs/skins/glitch/contrast/common.js differ diff --git a/priv/static/packs/skins/glitch/mastodon-light/common.css b/priv/static/packs/skins/glitch/mastodon-light/common.css index 4f1274786..90aa2181c 100644 Binary files a/priv/static/packs/skins/glitch/mastodon-light/common.css and b/priv/static/packs/skins/glitch/mastodon-light/common.css differ diff --git a/priv/static/packs/skins/glitch/mastodon-light/common.css.map b/priv/static/packs/skins/glitch/mastodon-light/common.css.map index 049a2d972..88fa5f09b 100644 --- a/priv/static/packs/skins/glitch/mastodon-light/common.css.map +++ b/priv/static/packs/skins/glitch/mastodon-light/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/flavours/glitch/styles/reset.scss","webpack:///./app/javascript/flavours/glitch/styles/mastodon-light/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/basics.scss","webpack:///./app/javascript/flavours/glitch/styles/containers.scss","webpack:///./app/javascript/flavours/glitch/styles/_mixins.scss","webpack:///./app/javascript/flavours/glitch/styles/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/footer.scss","webpack:///./app/javascript/flavours/glitch/styles/compact_header.scss","webpack:///./app/javascript/flavours/glitch/styles/widgets.scss","webpack:///./app/javascript/flavours/glitch/styles/forms.scss","webpack:///./app/javascript/flavours/glitch/styles/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/statuses.scss","webpack:///./app/javascript/flavours/glitch/styles/components/index.scss","webpack:///./app/javascript/flavours/glitch/styles/components/boost.scss","webpack:///./app/javascript/flavours/glitch/styles/components/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/components/domains.scss","webpack:///./app/javascript/flavours/glitch/styles/components/status.scss","webpack:///./app/javascript/flavours/glitch/styles/components/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/components/composer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/columns.scss","webpack:///./app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss","webpack:///./app/javascript/flavours/glitch/styles/components/directory.scss","webpack:///./app/javascript/flavours/glitch/styles/components/search.scss","webpack:///","webpack:///./app/javascript/flavours/glitch/styles/components/emoji.scss","webpack:///./app/javascript/flavours/glitch/styles/components/doodle.scss","webpack:///./app/javascript/flavours/glitch/styles/components/drawer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/media.scss","webpack:///./app/javascript/flavours/glitch/styles/components/sensitive.scss","webpack:///./app/javascript/flavours/glitch/styles/components/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/components/emoji_picker.scss","webpack:///./app/javascript/flavours/glitch/styles/components/local_settings.scss","webpack:///./app/javascript/flavours/glitch/styles/components/error_boundary.scss","webpack:///./app/javascript/flavours/glitch/styles/components/single_column.scss","webpack:///./app/javascript/flavours/glitch/styles/polls.scss","webpack:///./app/javascript/flavours/glitch/styles/about.scss","webpack:///./app/javascript/flavours/glitch/styles/tables.scss","webpack:///./app/javascript/flavours/glitch/styles/admin.scss","webpack:///./app/javascript/flavours/glitch/styles/accessibility.scss","webpack:///./app/javascript/flavours/glitch/styles/rtl.scss","webpack:///./app/javascript/flavours/glitch/styles/dashboard.scss","webpack:///./app/javascript/flavours/glitch/styles/mastodon-light/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,0CACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,8BACA,CC3EwB,iEDkF1B,kBClF0B,4BDsF1B,sBACE,MEtFF,sBACE,mBACA,eACA,iBACA,gBACA,WDXM,kCCaN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,sIAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDjDwB,kBCqDxB,iBACE,kBACA,0BAEA,iBACE,YAIJ,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDpFiB,mBAEK,WCqFtB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,wBAEA,aACE,gBACA,WACA,YACA,kBACA,uBAGF,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,6BAKF,YAEE,WACA,mBACA,uBACA,oBACA,yEAKF,gBAEE,+EAKF,WAEE,gBCrJJ,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFtBI,YEwBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF/BE,qBEiCF,UACA,kBACA,iBACA,uBACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAKA,UACqB,sCC3EvB,iBD4EE,6BAEA,UACE,YACA,cACA,SACA,kBACA,iBE5BkB,wBD9DtB,4BACA,uBD8FA,aACE,cF9FiB,wBEgGjB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UF3UA,qCE8UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF7Ve,mBE+Vf,kBACA,uHAEA,yBAGE,WFxWA,qCE4WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFvbe,8CE4bjB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,CAEA,WACqB,yCCzgB3B,kBD0gBM,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,iBE7dc,wBD9DtB,4BACA,qCD+hBI,2CAvCF,YAwCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAKA,UACqB,sCCtiB7B,CDuiBQ,sBACA,wDAEA,QACE,kBACA,iBErfY,wBD9DtB,4BACA,2DDsjBQ,mDAbF,YAcI,sCAKN,2CApEF,eAqEI,sCAGF,2CAxEF,cAyEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WFjlBF,gBEmlBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WF3lBJ,gBE6lBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aFhnBS,oDEunBf,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cF5oBW,aE8oBX,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BF9qBS,wEEorBT,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WF1sBJ,uBE4sBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cF7uBa,uDEgvBb,oBACE,cFjvBW,qBEmvBX,aACA,gBACA,8DAEA,eACE,WF3vBJ,qCEiwBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aE3yBU,8DFizBV,mBACA,WFpzBE,qFEwzBJ,YAEE,eACA,cFxzBe,2CE4zBjB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBFt3BkB,+IEy3BhB,kBAGE,WGl4BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,eChBJ,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,yBCrBF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cP9Fe,6BOiGf,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cACA,gBACA,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cRjBe,wBQqBjB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBLPI,uBKUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBTfwB,aSiBtB,0BACA,eACA,cTrBiB,iBSuBjB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aTrDiB,qBSuDf,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cT5EiB,+BSgFnB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aTzGmB,aS8GrB,YACE,kBACA,mBT9GwB,mCSgHxB,qBAGF,YACE,kBACA,0BACA,kBACA,cTzHmB,mBS2HnB,iBAGF,eACE,eACA,cThImB,iBSkInB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cT3IiB,0BS+InB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cTxJiB,qBS0JjB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBTxKwB,mCS0KxB,cT5KmB,gBS8KnB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cTvNe,8DS6NjB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eLlPM,CKoPN,cACA,cTlPmB,mBSoPnB,+BANA,iBACA,CLlPM,kCKgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UTlQM,eSoQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cTxQiB,qCS4QnB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBTpR0B,kBSsRxB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBTjSoB,kBSmSpB,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBTjTsB,eSmTpB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WTpUE,mBSsUF,gBACA,uBACA,wBAEA,aTvUe,0BS2Uf,aACE,gBACA,eACA,eACA,cT/Ua,yFSqVf,UTxVE,+BS+VJ,aACE,YACA,uDAGF,oBT9VsB,eSoW1B,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cTjZiB,gBSmZjB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WT5aI,8BS+aJ,aACE,cT7ae,gBS+af,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aT9gBmB,iCS6gBrB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cThiBsB,4JSmiBtB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WT/jBI,gCSikBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MC/kBJ,+BACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WVjDA,cUmDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aVhEoB,0BUkElB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aVlGmB,sBUqGjB,aVlGsB,yBUsGtB,iBACE,kBACA,gBACA,wBAIJ,aACE,eACA,eACA,qBAGF,kBACE,cVvHiB,iCU0HjB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WV1JA,gBU4JA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WVhLE,cUkLF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WVtME,cUwMF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WVpRI,cUsRJ,WACA,2CAKE,mBACE,eACA,WV9RA,qBUgSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WV9TI,cUgUJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBNpVY,oLMwVZ,iBACE,4WAGF,oBVxVsB,mBU2VpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBVzYsB,WANlB,eUkZJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBVzaoB,gGU6apB,kBN9aQ,kHMibN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WVlcI,cUocJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cVhdY,oBUkdZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,oEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,iCACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UVhhBF,aU0hBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cV/hBmB,kBUiiBnB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cNziBY,sBM6iBd,mCACE,+BACA,cN9iBQ,kBMkjBV,oBACE,cVnjBiB,qBUqjBjB,wBAEA,UV1jBI,0BU4jBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gCACA,mBVtkBsB,WALlB,eU8kBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aVhnBmB,qBUknBjB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aVjoBwB,yBUmoBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cVppBmB,oCUupBnB,cACE,mBACA,kBACA,4CAGF,aV7pBmB,gBU+pBjB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBNtrBM,YMwrBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cVlsBmB,WUosBnB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WVnuBI,qCUquBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UV3uBI,0BU6uBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cVvxBmB,0BU0xBnB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WVvyBI,kBUyyBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aNhzBc,0SM0zBZ,+BACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gCACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBNp2Bc,gBMs2BZ,2BAEA,kBNx2BY,gBM02BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC36BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,CAEA,UACqB,sCRpDzB,gBQqDI,wBAEA,UACE,YACA,cACA,SACA,kBACA,iBPLgB,wBD9DtB,4BACA,mBQoEM,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WXhFA,gBWkFA,gBACA,uBACA,+BAGF,aACE,eACA,cXtFa,gBWwFb,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WX9GI,gBWgHJ,qBACA,iBACA,qBACA,sBAGF,ePrHM,oBOuHJ,WXxHI,eW0HJ,cACA,kBAGF,cACE,uCAGF,wBAEE,cXjIiB,oBWqInB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,WACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBX1K0B,mCW4KxB,cXxJiB,eW0JjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cX7MmB,mCW+MnB,mCACA,6DAEA,aPnNc,sCOqNZ,kCACA,qDAGF,aACE,oCACA,gCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cX5PiB,gCW8PjB,6BAGF,aACE,cXlQiB,4BWsQnB,aXnQwB,qBWqQtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aPtRY,gBOwRV,0CAGF,aP3RY,wCOgSd,eACE,wCAIJ,UACE,0BAIA,aXzSmB,4BW4SjB,aX5SiB,qBW8Sf,qGAEA,yBAGE,iCAIJ,UX1TI,gBW4TF,wBAIJ,eACE,kBClUJ,kCACE,kBACA,gBACA,mBACA,qCAEA,iBANF,eAOI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBZrBwB,6GYwBtB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBZtEwB,WANlB,oBY+EN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UZzFI,gFY6FN,kBAGE,qNAKA,kBZjGoB,4IYyGpB,kBR1GQ,qCQiHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAKA,cACA,iBACA,mBACA,mFAGF,iBACE,eACA,WACA,WACA,WACA,qMAGF,eAGE,mEASF,cACE,gBACA,qFAGF,aZ/Jc,YYiKZ,eACA,WACA,eACA,gBACA,+GAGF,aACE,eACA,CACA,sBACA,eACA,yJAEA,cACE,uEAIJ,WACE,kBACA,WACA,eACA,iDAQF,iBACE,mBACA,yHAEA,iBACE,gBACA,+FAGF,UACE,WC3NR,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cbDwB,SaGxB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,abfsB,eaiBpB,SAIJ,wBACE,YACA,kBACA,sBACA,Wb7BM,ea+BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,qCACA,mBACA,WACA,4CAEA,wBAGE,4BACA,qCACA,sBAGF,eACE,mFAEA,wBT3DQ,gBS+DN,kBAIJ,wBblEsB,eaoEpB,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,UbtFM,mBAIgB,qGasFpB,wBAGE,8BAIJ,kBbxFsB,2Ga2FpB,wBAGE,0BAIJ,cACE,iBACA,YACA,cb3GiB,oBa6GjB,uBACA,iBACA,kBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,cAIJ,oBACE,UACA,cbzHoB,Sa2HpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,sCACA,4BACA,2CACA,oBAGF,oCACE,uBAGF,aACE,6BACA,eACA,qBAGF,abhKwB,gCaoKxB,QACE,uEAGF,mBAGE,uBAGF,abjLmB,sFaoLjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,abjMsB,uCaoMpB,aACE,wBAKN,sBACE,8BACA,qBACA,kBACA,YACA,8BAEA,6BACE,mBAKN,ab1NqB,Sa4NnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,UACE,6BACA,eACA,0BAGF,abxPwB,qCa4PxB,QACE,sFAGF,mBAGE,gBAIJ,iBACE,uBACA,YAGF,WACE,cACA,qBACA,QACA,SACA,kBACA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,uCAIJ,MACE,kBACA,CTvSU,sES8SZ,aT9SY,uBSkTZ,aTnTc,4DSyTV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,Ub9UM,0BagVJ,eAIJ,aACE,eACA,gBACA,uBACA,mBACA,iBAEA,aACE,wBACA,sBAIA,cACA,gBAKA,yCAPF,WACE,CAEA,gBACA,uBACA,gBACA,mBAWA,CAVA,mBAGF,aACE,CACA,cAKA,8BAIA,yBACE,sBAIJ,SACE,YACA,eACA,iBACA,uBACA,mBACA,gBACA,CAME,sDAGF,cACE,YACA,kBACA,oBACA,qBAKN,eACE,wBAGF,cACE,eAGF,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cThX4B,eAEC,0DSiX3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cTxY4B,eAEC,WSyY3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,WAIJ,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBbjdqB,camdnB,kBACA,uCACA,mBAEA,eACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BbreiB,2BayenB,WACE,iBACA,uBACA,yBb5eiB,8BagfnB,QACE,iBACA,uBACA,4BbnfiB,6BaufnB,SACE,gBACA,2BACA,2Bb1fiB,wBaggBnB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBbtgBiB,WAHb,gBa4gBJ,uBACA,mBACA,yFAEA,kBb1gBsB,cAHL,UakhBf,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBbhiBiB,cakiBjB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBbzjBiB,WAHb,gBa+jBJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBbjkBsB,cAHL,iBa2kBrB,qBACE,iBAIA,sBACA,cbpkBgB,oBaukBhB,cACE,gBACA,mBACA,kBACA,mBAGF,cACE,mBACA,iBAIJ,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WbppBM,qBaspBN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCVhoBA,6GADF,kBUwoBI,4BACA,kHVpoBJ,kBUmoBI,4BACA,wBAIJ,+BACE,cbvqBsB,sBa2qBxB,eACE,aACA,2BAGF,aACE,eACA,kBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBb5rBsB,yBa8rBtB,gBACA,kBACA,eACA,gBACA,iBACA,WbzsBI,mDa8sBR,oBACE,aAGF,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,gBAIJ,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,gDACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBb9wBwB,qCagxBxB,sEAGF,wBACE,4CAGF,wBbtxB0B,+Ea0xB1B,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,sBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBbl1BwB,yDas1B1B,kBbv1B0B,2Ba61B1B,iBACE,gBACA,cAGF,aACE,kBAGF,kBbt2B0B,caw2BxB,oBAEA,ab52BmB,oBag3BnB,abn2BgB,yBau2BhB,0BACE,CADF,uBACE,CADF,kBACE,kDAKA,sBACA,cACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,abz3Bc,ea23BZ,0DAEA,ab73BY,0Ba+3BV,sDAIJ,oBACE,cbj5Be,sMao5Bf,yBAGE,0BAKN,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cbp6Be,aas6Bf,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,ab/7Be,qBas8BrB,oBACE,kBACA,eACA,iBACA,gBACA,mBbz8BwB,gBa28BxB,iBACA,qBAGF,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,abt+BqB,uBaw+BnB,CACA,WACA,CADA,+BACA,sBACA,cACA,oBACA,mBACA,cACA,WACA,0CAEA,Ubr/BM,4BAMkB,qCGkBtB,yDADF,cUq+BE,sBAGF,Ub//BM,gCaigCJ,sDAEA,UbngCI,4BAMkB,mDaqgC1B,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAIJ,uBACE,2BACA,gDAGF,ab5gCsB,6Ba8gCpB,uDAGF,ab5hC0B,yDagiC1B,aACE,YAGF,aACE,cb3hCgB,6Ba6hChB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,oBAGF,gBACE,qEAGF,4BACE,gCAGF,eACE,kBACA,MACA,QACA,YACA,kBACA,YAEA,mBACA,yBACA,eACA,aAEA,wCAEA,UTvhCsB,mBSyhCpB,aACA,sBACA,mBACA,uBACA,mBACA,8BACA,wBACA,gCACA,uCAGF,wBACE,kBACA,WACA,YACA,eACA,cbxmCiB,yBa0mCjB,aACA,uBACA,mBACA,sCAGF,mBACE,6CAEA,8BACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,oBAGF,abvnCkB,eaynChB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,6BAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,YACE,SACA,QACA,WACA,YACA,mBACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,8BACA,kBACA,iBACA,WbpyCE,gBasyCF,eACA,+LAMA,6BACE,mEAKF,6BACE,iBAMR,aACE,iBACA,mEAGF,ab5zCqB,qBag0CnB,mBACA,gBACA,sBACA,gBAGF,aACE,iBACA,uBAGF,eACE,8BAGF,ab/0CqB,eai1CnB,cACA,gBACA,gBACA,uBAGF,qBACE,sBAGF,WACE,8BAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA3BF,qBAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,iBAIJ,0DACE,CADF,kDACE,cAGF,kBACE,8BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBbh6CwB,kCak6CxB,uBAGF,MACE,aACA,mBACA,uBACA,cb36CmB,ea66CnB,gBACA,0BACA,kBACA,qCAGF,SACE,oBACA,CADA,WACA,cAGF,wBbr7C0B,Wau7CxB,kBACA,MACA,OACA,aACA,qBAGF,iBACE,aAGF,iBACE,cACA,aACA,WACA,yBbt8CwB,kBaw8CxB,cACA,UACA,WACA,eAGF,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBbj+CsB,kBam+CtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cb1/Ce,kBa4/Cf,+BAGF,ab//CiB,eaigDf,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,Ub5gDE,qBa8gDA,oHAEA,yBAGE,yCAKN,QACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UbxiDI,oBaijDN,kBACA,cACA,2BAGF,eACE,UAGF,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cblkDiB,gBaokDjB,gBAEA,abnkDsB,0BaqkDpB,sBAEA,oBACE,gBAIJ,qBACE,4BAKN,GACE,cACA,eACA,WARI,mBAKN,GACE,cACA,eACA,2CC5lDF,u+KACE,uCAEA,u+KACE,CAOA,8MAMF,okBACE,UClBJ,YACE,gCACA,cACA,qBACA,iCAEA,aACE,cACA,cfJiB,gBeMjB,qBACA,eACA,gBAGF,WACE,UACA,yCAEA,8CAEA,WACE,iBACA,mBAKN,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,gBX0BwB,wBD9DtB,4BACA,kBYqCA,eACA,yBAEA,oBACE,sBACA,iBACA,4BZ3CF,eYgDE,CACA,cACA,2DAJF,gBXesB,wBD9DtB,4BACA,CYgDE,iBAQE,CANF,+BZlDF,UYsDI,CACA,qBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WflEE,6BeoEF,gBACA,eACA,0BAKN,iBACE,WACqB,sCZrErB,+BANA,UY+EuB,sCZzEvB,gEYuEA,gBXhBsB,wBD9DtB,4BY0FE,CZnFF,iCANA,UYoFuB,sCZ9EvB,kBYgFE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,2BAGF,aACE,mBACA,sBAGF,YACE,cf3FgB,6Be8FhB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,4BAGF,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,8BACA,eACA,oCACA,uCAEA,aACE,kCAGF,+BACE,gCAGF,aACE,yBACA,eACA,cfrKiB,kCeyKnB,aACE,eACA,gBACA,Wf/KI,CeoLA,2NADF,eACE,gCAKN,afnLwB,oBewL1B,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,wBAGF,gBACE,qBACA,eACA,cf7MmB,ee+MnB,kBACA,4BAEA,af/MwB,6BemNxB,aACE,gBACA,uBACA,iBAIJ,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,afjPqB,eemPnB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SZ7MF,sBACA,WACA,YACA,gBACA,oBACA,mBHrDwB,cAFL,eG0DnB,SACA,+EYuMI,aACE,CZxMN,qEYuMI,aACE,CZxMN,yEYuMI,aACE,CZxMN,0EYuMI,aACE,CZxMN,gEYuMI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,af5Qc,iBe8QZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aflTiB,0HeuTjB,cAEE,gBACA,cf7SY,kZegTZ,aAGE,gEAIJ,wBACE,iDAGF,eX1UI,kBDkEN,CAEA,eACA,cH7CiB,uCG+CjB,UYqQI,mBf1Ue,oDGuEnB,wBACE,cHlDe,eGoDf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WH3FI,sDeiVJ,WACE,mDAGF,UfrVI,kBeuVF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UfxWQ,kBe0WN,cACA,mBACA,sBf3WM,yBe6WN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,6BAIJ,YACE,eACA,gBACA,wBAGF,WACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cfhaiB,eekajB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,afzaiB,qWe4af,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,sBAQR,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cfjdc,Ceodd,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,2BAIJ,af1fqB,ee4fnB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WfjnBA,gBemnBA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cfvnBW,gBeynBX,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,Wf9oBE,gDekpBJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aX5pBU,yBWkqBd,cACE,gCAEA,cACE,cfrqBe,eeuqBf,kCAEA,oBACE,cf1qBa,qBe4qBb,iBACA,gBACA,yCAEA,eACE,WfprBF,SgBDR,YACE,gCACA,8BAEA,aACE,cACA,WhBLI,qBgBOJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,mCCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,EDGF,0BCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,qCAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,EAtBA,2BAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,gCAIJ,cACE,kBAGF,iBACE,cACA,eACA,iBACA,qBACA,gBACA,iBACA,gBACA,wBAEA,SACE,4BAGF,UACE,YACA,gBACA,sBAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,qEAGF,kBACE,qBACA,sGAEA,eACE,qEAIJ,eAEE,qJAEA,kBAEE,mXAGF,eACE,mBACA,qJAGF,eACE,gBACA,2EAGF,eACE,+NAGF,eACE,2FAGF,iBACE,8BACA,cjB5Ge,mBiB8Gf,qHAEA,eACE,2JAIJ,eACE,mJAGF,iBACE,6EAGF,iBACE,eACA,6EAGF,iBACE,qBACA,qJAGF,eACE,6JAEA,QACE,2EAIJ,oBACE,2EAGF,uBACE,oBAIJ,ab9Ic,qBagJZ,0BAEA,yBACE,8BAEA,aACE,kCAKF,oBACE,uCAEA,yBACE,wBAKN,ajBjKc,4CiBsKhB,YACE,8EAEA,aACE,mCAIJ,aACE,oDAEA,ab5LQ,ea8LN,CAKF,sDAEA,kBAEE,gCAKN,oBACE,kBACA,mBACA,YACA,WjBrNM,gBiBuNN,eACA,cACA,yBACA,oBACA,eACA,sBACA,sCAEA,kBACE,qBACA,+DAGF,oBACE,iBACA,sBACA,kBACA,eACA,oBACA,2GAKF,oBAGE,4BAIJ,ajBtOkB,SiBwOhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,mDAGF,iBAEE,gCAGA,qEAEA,eACE,kBAKF,SACE,mBACA,kDAEA,kBACE,wDAEA,sBACE,iFAIJ,kBAEE,SAKN,iBACE,kBACA,YACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QAPF,kBAUI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,ajBtUiB,CAHb,uEiBkVF,UjBlVE,kCiBsVF,ajBnVe,gCiBwVjB,UjB3VI,kCiB8VF,ajBxVoB,gEiB4VpB,UjBlWE,mBAIgB,sEiBkWhB,kBACE,mBAMR,uBACE,sBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,yCAEA,aACE,kBACA,OACA,QACA,MACA,SACA,6FACA,oBACA,WACA,2DAGF,oBACE,oCAGF,WACE,gBACA,uBACA,cACA,0CAEA,UACE,kBACA,MACA,gBACA,gEACA,oBACA,4CAGF,oBACE,gDAGJ,uDACE,mEAEF,uDACE,0CAGF,eACE,6DAGF,kBACE,gCAIJ,mBACE,+CAKF,sBACE,qEAEA,aACE,wBAKN,oBACE,YACA,CjBtagB,ciBwahB,iBACA,mBACA,CACA,sBACA,8CANA,ajBtagB,CiB0ahB,eAOA,8CAGF,aACE,eACA,eAGF,YACE,8BACA,eACA,oBAEA,sBACE,gBACA,2CAGF,oBACE,sBAIJ,YACE,mBACA,WACA,cjB1coB,iIiB6cpB,gBAGE,kBACA,0EAGF,yBACE,yEAMA,0CACE,CADF,kCACE,2EAKF,2CACE,CADF,mCACE,wBAKN,YACE,mBACA,2BACA,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,mBACA,iBACA,cjBthBgB,CiBwhBhB,iBACA,eACA,kBACA,+CAEA,ajB7hBgB,uBiBiiBhB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cjBzjBgB,4BiB+jBtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cjBnnBgB,eiBqnBhB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,6JAGF,oBAME,4DAKA,UjB1qBM,kBiBgrBN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,ajB1sBqB,ciB4sBnB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WjB5tBI,kCiBiuBR,UACE,kBACA,iBAGF,SACE,kBACA,YACA,WACA,CjB1tBgB,8IiBquBhB,ajBruBgB,wBiByuBhB,UACE,wCAGF,kBjBpvBsB,WAThB,8CiBiwBJ,kBACE,qBACA,+DAOJ,yBACE,cAIJ,YACE,eACA,yBACA,kBACA,cjBnwBgB,gBiBqwBhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cjB5yBe,uBiB8yBf,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UjBxzBE,yBiB+zBJ,cACE,kBACA,YACA,+DAGF,aACE,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACE,YACA,SACA,2BAIF,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cjBn2BmB,gBiBq2BnB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,ajBj3BqB,oBiBq3BrB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,kBAGF,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cjB77Bc,iBiB+7Bd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cjB39BY,gBiB69BZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,ajB9+Bc,oCiBo/BlB,cACE,cACA,SACA,uBACA,UACA,kBACA,oBACA,oFAEA,yBAEE,6BChhCJ,kBACE,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,8BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,6CAGF,kBlBtCqB,WAHb,kBkB8CN,gBACA,aACA,sBACA,0BAGF,WACE,WACA,gBACA,iBACA,8DAEA,UACE,YACA,sBACA,aACA,sBACA,mBACA,uBACA,aACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAIJ,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,qCAGF,kBACE,UACE,YACA,gBACA,0BAGF,UACE,YACA,eACA,gBACA,cACA,oDAIJ,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,alBhImB,SkBmIjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,ClBpJE,wyEkB2JF,UAGE,sBAMR,sBACE,yBAGF,aACE,aACA,mBACA,uBACA,wBAGF,UACE,YACA,mBACA,mBACA,aACA,eACA,8BAEA,kBACE,+BAGF,cACE,mBACA,kCAIJ,mBACE,CACA,mBACA,0EAEA,mBACE,yBAIJ,cACE,iBACA,4BAEA,cACE,gBACA,WlBjNI,mBkBmNJ,2BAGF,alBhNwB,kGkBmNtB,aAGE,2CAIJ,aACE,2BAGF,cACE,clBlOiB,gBkBoOjB,mBACA,sCAEA,eACE,kCAGF,eACE,mBlB1OoB,cAFL,kBkB+Of,eACA,gBACA,CAII,2NADF,eACE,oCAOV,WACE,UACA,mCAME,mBACA,mBACA,sCAEA,cACE,iBACA,kBACA,qCAGF,eACE,oCAIJ,kBACE,mBACA,kBACA,eAIJ,iBACE,eACA,mBACA,sBAEA,eACE,WlBnSI,kBkBqSJ,yBACA,eACA,qBAGF,kBlBrSwB,cAFL,gBkB0SjB,aACA,kBACA,6HAQF,eACE,qJAGF,kBACE,clBzTiB,mBkB2TjB,kBACA,aACA,kBACA,eACA,sCACA,yPAEA,iBACE,mBACA,qNAGF,mBACE,gBACA,4CAMJ,YACE,mBACA,gDAEA,UACE,cACA,4DAEA,aACE,2DAGF,cACE,kDAGF,iBACE,uDAIJ,eACE,sDAIJ,UlB5WM,2DkBiXR,0BACE,cACE,iBACA,qJAGF,cAIE,mBACA,4CAGF,kBACE,sDAGF,WACE,eACA,mBAIJ,oBACE,eACA,gBACA,iBACA,uHAGF,kBAOE,WlBvZM,kBkByZN,gBACA,eACA,YACA,kBACA,sBACA,+SAEA,alBhZgB,YkBkZd,eACA,WACA,eACA,gBACA,uSAGF,YACE,uPAGF,WACE,WACA,+WAGF,aACE,wBAKF,edvbM,CJEa,gBkBwbjB,oBACA,iEd3bI,2BJEa,qDkBicrB,iBAEE,aACA,qEAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,kKAIJ,YAKE,8BACA,mBlBldmB,akBodnB,iBACA,0LAEA,aACE,iBACA,clBzdiB,mBkB2djB,kNAGF,aACE,6DAIJ,cAEE,yDAGF,WAEE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,alBthBwB,qCkB0hBxB,oDAZF,eAaI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UlB1jBI,gBICA,ac4jBJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,ed5kBI,yBc8kBF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UlB7lBE,oBkB+lBA,eACA,gBd/lBA,+CcomBJ,YACE,8BACA,mBACA,4CAIJ,aACE,WlB7mBI,ekB+mBJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UlBxnBI,ekB0nBF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eAWE,eACA,wBAXA,eACE,iBACA,uBAGF,aACE,gBACA,2CAMF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UlBzqBE,akB2qBA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBlBprBgB,WANlB,iJkBisBA,iBAGE,oMAUR,aACE,iIAIJ,4BAIE,clBptBmB,ekBstBnB,gBACA,6cAEA,aAGE,6BACA,uCAIJ,iBACE,mBACA,oBACA,eAEA,yFAEA,qBACE,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UlBnxBI,CkBqxBF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gDACA,mBlBzzBoB,WALlB,ekBi0BF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,cAKN,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBd12BM,yDc62BN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBdr3BI,uBcy3BN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UlBv5BI,ekBy5BF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,wBAKN,gBACE,sCAEA,eACE,gCAGF,eACE,qDAGF,UlB57BM,iDkBg8BN,YACE,0DAEA,YACE,0BAIJ,YACE,iBACA,uBACA,kDAGF,alB77BoB,qBkB+7BlB,wDAEA,yBACE,WCp9BN,YACE,oBAGF,cACE,uBACA,eACA,gBACA,cnBJmB,4CmBOnB,afNY,sCeWd,2CACE,oBAGF,QACE,wBACA,UACA,+CAEA,WACE,mBACA,UACA,0BAGF,aACE,sBACA,SACA,YACA,kBACA,aACA,WACA,UACA,WnBtCI,gBICA,eewCJ,oBACA,gBACA,qDAEA,anB7Bc,CmB2Bd,2CAEA,anB7Bc,CmB2Bd,+CAEA,anB7Bc,CmB2Bd,gDAEA,anB7Bc,CmB2Bd,sCAEA,anB7Bc,gCmBiCd,8ChBpCA,uCADF,cgBsC4D,0ChBjC5D,cgBiC4D,oBAI9D,UnBtDQ,mBmBwDN,mBnBpDsB,oCmBsDtB,iBACA,kBACA,eACA,gBACA,sBAEA,anB7DmB,gBmB+DjB,0BACA,mFAEA,oBAEU,iCAKZ,mBACA,eAEA,gBACA,wCAEA,anB5EwB,sDmBgFxB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,gBACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBnBlGsB,qCmByG1B,eACE,kBACA,aACA,mBnB9GsB,gBmBgHtB,gBACA,cACA,yBAEA,iBACE,gBACA,wCAEA,UnB5HI,iCmB8HJ,WACE,iBACA,2BAIJ,iBACE,cACA,CACA,cACA,iBACA,WnBzII,qBmB2IJ,gBACA,iBACA,qBACA,mBACA,gBACA,gGAEA,kBACE,qBACA,iIAEA,eACE,kJAIJ,eACE,mBACA,2DAGF,eACE,eACA,8BAGF,cACE,wFAGF,eACE,sCAGF,iBACE,2BACA,WnB/KE,mBmBiLF,mDAEA,eACE,8DAIJ,eACE,0DAGF,iBACE,+BAGF,iBACE,eACA,2DAGF,eACE,+DAEA,QACE,8BAIJ,oBACE,8BAGF,uBACE,6BAGF,anBlNiB,qBmBoNf,mCAEA,oEAGE,oBACE,gDAEA,qDAMR,UACE,YACA,gBACA,wBAIJ,iBACE,UACA,QACA,gHAEA,mCAEE,uDAIJ,iBAEE,WACA,mIAGE,aACE,sBACA,SACA,YACA,0BACA,yBACA,WACA,iBACA,UACA,WnBtQE,gBICA,eewQF,oBACA,YACA,qBACA,yLAEA,anB9PY,CmB4PZ,sKAEA,anB9PY,CmB4PZ,8KAEA,anB9PY,CmB4PZ,gLAEA,anB9PY,CmB4PZ,4JAEA,anB9PY,yKmBkQZ,SACE,qJAGF,kBnBnRe,+ImBoRf,8ChB1QF,8JADF,cgB4Q8D,kKhBvQ9D,cgBuQ8D,qChBhQ5D,8TADF,sBgBoQM,gBACA,6BAMR,aACE,kBACA,SACA,UACA,WACA,gBACA,2CAEA,aACE,mBACA,WACA,YACA,cnB3SiB,emB6SjB,iBACA,kBACA,WACA,4CAIJ,iBACE,SACA,oCAGF,aACE,kBACA,sBACA,SACA,0BACA,YACA,WACA,WnBnUM,mBAGa,sCmBmUnB,eACA,WACA,aACA,6CAGF,aACE,0CAGF,YACE,eACA,kBACA,iMAEA,kBAGa,iKAEb,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,+DAGF,6BACE,qEAEA,aACE,gBACA,uBACA,mBACA,sEAGF,eACE,qEAGF,aACE,iBACA,gBACA,uBACA,mBACA,4EAMA,anB3Xe,wBmBgYrB,eACE,iCAEA,YACE,mBACA,eACA,oBACA,YACA,gBACA,8BAIJ,UACE,WACA,cACA,kCAEA,iBACE,kBACA,aACA,WACA,sBfzZI,wBe2ZJ,sBACA,4BACA,gBACA,2CAEA,aACE,kBACA,sBACA,SACA,OACA,SACA,SACA,aACA,WACA,cnBtae,gFmBwaf,eACA,oBACA,gBACA,UACA,UACA,4BACA,iDAEA,UflbE,sEeobF,WACE,cnBnba,CIFb,4DeobF,WACE,cnBnba,CIFb,gEeobF,WACE,cnBnba,CIFb,iEeobF,WACE,cnBnba,CIFb,uDeobF,WACE,cnBnba,yCmBwbjB,2EAKE,0CAKN,iFACE,aACA,uBACA,8BACA,UACA,4BACA,8CAEA,aACE,cnB3ciB,emB6cjB,gBACA,aACA,oBACA,2JAEA,aAGE,wCAIJ,SACE,kCAIJ,YACE,aACA,cnBhemB,gBmBkenB,sCAEA,cACE,kBACA,2CAGF,aACE,gDAEA,aACE,eACA,gBACA,yBACA,qDAGF,iBACE,eACA,kBACA,WACA,WACA,mBnBlfkB,8DmBqflB,iBACE,MACA,OACA,WACA,kBACA,mBnB7fkB,0BmBogB1B,UnB1gBQ,oBmB4gBN,eACA,gBf5gBM,4BeghBR,YACE,gBACA,0BACA,YACA,aACA,8BACA,cACA,oBAGF,YACE,cACA,sBAEA,oBACE,uBACA,cACA,YACA,iBACA,sBACA,uBAGF,oBACE,aACA,CAEA,oBACA,CADA,0BACA,UACA,QACA,YACA,uBACA,2BAIJ,iBACE,iBACA,0CAKE,yBACE,qCACA,WnB9jBE,mBAMkB,gBmB2jBpB,8CAGA,yBACE,oCACA,uCAMR,iBACE,kBACA,uCACA,gBf9kBM,gBeglBN,uBACA,6CAGF,YACE,mBACA,aACA,WnBxlBM,emB0lBN,sDAEA,aACE,cnB1lBiB,wEmB6lBjB,6EAEA,aACE,WnBnmBE,gBmBqmBF,sGAIJ,kBnBnmBwB,WANlB,6PmBinBF,UnBjnBE,0DmBqnBN,wCAGF,gBACE,iBACA,mBACA,gBACA,yBACA,cACA,+BAEA,oBACE,SACA,eACA,kBACA,gCAGF,oBACE,aACA,UACA,WACA,kBACA,kCAIA,af5oBU,CgBFZ,+BAHF,YACE,cACA,kBAUA,CATA,cAKA,kBACA,2BACA,gBAEA,uBAEA,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,wHAMJ,WAGE,mCAIJ,YACE,mBACA,uBACA,YACA,CpBlFwB,IoBiG1B,aACE,aACA,sBACA,WACA,YACA,CAIA,oBAGF,qBACE,WACA,mBACA,cpB/GwB,eoBiHxB,cACA,eACA,SACA,iBACA,aACA,SACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cpBlIwB,eoBoIxB,cACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,cAGF,kBACE,WpB7KM,coB+KN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cpB5LiB,kGoB+LjB,sBAGE,WpBrME,kCoByMJ,apBnMsB,oBoByM1B,oBACE,iBACA,oBAGF,kBpB/M0B,cAWR,iBoBuMhB,eACA,gBACA,yBACA,eACA,yBAGF,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,kFACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,uBAEA,QACE,YACA,aACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,apBvQwB,uBoB2QxB,sCACE,4CAEA,apB9QsB,yCoBgRpB,4CAIJ,SAEE,SAIJ,WACE,kBACA,sBACA,aACA,sBACA,gBACA,wDAEA,SACE,gBACA,gBACA,qBAGF,kBpBzSwB,yBoB8S1B,WACE,aACA,cACA,uBAGF,kBACE,iCAGF,iBACE,sEAGF,kBACE,SACA,cpBhUmB,eoBkUnB,eACA,eACA,kFAEA,aACE,CAKA,kLAEA,UpBjVI,mBoBmVF,kFAKJ,2BACE,wCAIJ,YACE,oBACA,6BACA,+CAEA,sBAEE,kBACA,eACA,qBACA,0CAGF,eACE,gDAKJ,SACE,6BAGF,eACE,gBACA,gBACA,cpBpXmB,0DoBsXnB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,iCAIF,eACE,2CACA,YACE,WACA,mCAKN,kBACE,aACA,mCAIA,apB1ZmB,0BoB4ZjB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,uBAKN,oBACE,uBACA,gBACA,mBACA,OACA,sBAGF,oBACE,iBACA,uCAGF,apB5akB,mBAXQ,kBoB2bxB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBACA,sCAbF,cAcI,kDAGF,eACE,2CAGF,apB3cwB,qBoB6ctB,uDAEA,yBACE,eAKN,qBACE,uCAKA,sBACE,6BACA,qCASF,qCAXA,sBACE,6BACA,sCAgBF,mJAFF,qBAGI,sBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,kBACA,uCAEA,SACE,kCAKN,aACE,aACA,yBChhBJ,iBACE,eACA,gBACA,crBcgB,mBAXQ,4BqBCxB,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,crBhBY,qCqBoBd,cACE,gBACA,kBCtCJ,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WtB3EF,gBsB6EE,gBACA,uBACA,0CAGF,aACE,eACA,ctBjFW,gBsBmFX,gBACA,uBACA,yBAKN,kBtBxFsB,asB0FpB,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,cAOV,kBtB9H0B,sBsBiIxB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,CC/KJ,eAGF,SpBkDE,sBACA,WACA,YACA,gBACA,oBACA,mBHrDwB,cAFL,eG0DnB,SACA,coBxDA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,4CACA,eACA,WACA,YACA,cvBrDiB,euBuDjB,oBACA,0BAEA,mBACE,WACA,0BAIJ,sBACE,iCAEA,mBACE,WACA,gCAIJ,QACE,uBACA,cvB7DkB,euB+DlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,avB5EkB,mBuB8EhB,gCACA,kBACA,eACA,gBACA,uBAGF,YACE,cvBnGmB,kBuBqGnB,iBAIA,avB5FgB,mBuB8Fd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cvBtHY,gBuBwHZ,uBACA,mBACA,4BAEA,eACE,uBAGF,avB7Ie,qBuB+Ib,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cvBxKe,0BuB4KjB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,WC1LR,yCCCE,qBACA,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,8BAIJ,erBXQ,kBqBaN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBrBvCM,kBqByCN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,4BAGE,2DAIJ,WACE,wBAKF,2BACE,eAIJ,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,0EAMA,SACE,oBACA,CADA,WACA,eCpGN,WAEE,0BAGF,kBANW,kBAQT,cACA,iCACA,wBACE,mCAOF,WACE,SACA,UACA,2CAGF,aACE,aAEA,sBACA,YACA,6BACA,6DAGE,oBACE,WACA,iBACA,iBACA,iJAGF,UACE,gEAEF,oBACE,gBACA,WACA,2CAKN,yBACE,sBACA,kBACA,YACA,gBACA,kDAEA,uBACE,CADF,oBACE,CADF,eACE,WACA,YACA,SACA,4BACA,WACA,yBACA,eACA,4CACA,sBACA,oBACA,6DAEA,uBACE,6DAGF,sBACE,wEAGF,sBACE,kBACA,SCjFR,WACE,sBACA,aACA,sBACA,kBACA,iBACA,UACA,qBAEA,iBACE,oBAGF,kBACE,2DxBDF,SwBI0D,yBxBC1D,SwBD0D,qCxBQxD,qLwBLA,yBAGF,eACE,gBACA,eACA,qCxBZA,4BwBgBA,SACE,WACA,YACA,eACA,UACA,+BALF,SACE,WACA,YACA,eACA,UACA,yCAIJ,4BAGF,YACE,mBACA,mBACA,UACA,mBACA,eACA,mBAEA,aACE,sBACA,oCACA,sBACA,YACA,cACA,c3BpDiB,kB2BsDjB,qBACA,eACA,mBAGF,iCACE,iDAEA,YAEE,mBACA,mCACA,SAKN,iBACE,mBACA,UACA,qCxBrDE,6CADF,ewBwDkF,sCxBlEhF,sBADF,cwBoE0D,yBxB/D1D,cwB+D0D,gBAG5D,evBlFQ,kBDkEN,CACA,sBACA,gBACA,cH7CiB,uCG+CjB,mBAEA,wBACE,cHlDe,eGoDf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WH3FI,kB2BuFR,YACE,c3BrFmB,a2BuFnB,mBACA,oBAEA,aACE,qBACA,wBAGF,aACE,c3BhGiB,gB2BkGjB,mBACA,gBACA,uBACA,0BAIJ,aACE,gBACA,gBACA,kBAGF,kB3B7G0B,kB2B+GxB,gBACA,yBAEA,a3BvGgB,mB2ByGd,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,c3B/HY,iC2BkIZ,oBACE,iBACA,8FAIJ,eAEE,mCAGF,aACE,aACA,c3B5Je,qB2B8Jf,0HAEA,aAGE,0BACA,gBAQN,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAgBA,CAfA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,uBAEA,kB3B/LwB,0B2BoM1B,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oCAGF,aACE,WACA,YACA,YACA,eACA,sCAGF,yBAzBF,aA0BI,iBAIJ,kBACE,eACA,gBACA,mBAGF,cACE,kBACA,MACA,OACA,WACA,YACA,8BACA,oBCrPF,kBACE,gBACA,W5BDM,e4BGN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,e5BbQ,cAEa,S4BcnB,WACA,YACA,iEAEA,aAGE,iCAGF,eACE,2BzBcF,iBACE,mBACA,cACA,eACA,aACA,gBACA,yByBfJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,6BAGF,aACE,kBACA,W5B9CM,8B4BgDN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,WACE,aACA,sBACA,4BAEA,iBACE,c5B/EiB,a4BiFjB,YACA,mBACA,CAGE,yDAIJ,UACE,gBAIJ,qBACE,eACA,gBACA,kBACA,kBACA,WACA,aACA,2BzB/DA,iBACE,mBACA,cACA,eACA,aACA,gBACA,sByB8DJ,WACE,sBACA,cACA,WACA,kBACA,kBACA,gBACA,kCAEA,eACE,qEAIA,cACE,MACA,gCAIJ,exBlIM,gCwBuIR,cACE,cACA,qBACA,c5BvImB,kB4ByInB,UACA,mEAEA,WAEE,WACA,sBACA,CADA,gCACA,CADA,kBACA,CAIE,0HAFF,WACE,oBACA,CADA,8BACA,CADA,gB5BrJE,C4BsJF,wBAKN,UACE,CAEA,iBACA,MACA,OACA,UACA,gB5BlKM,iC4BqKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,aACA,uBACA,qCAGF,cACE,YACA,WACA,kBACA,UACA,sBACA,CADA,gCACA,CADA,kBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,qDAEA,WACE,oBACA,CADA,8BACA,CADA,gBACA,sCAIJ,0BACE,2BACA,gBACA,kBACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cxB3K0B,eAEC,CwBqL7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,6BACE,sBACA,SACA,W5BxQM,e4B0QN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,c5BnTiB,mF4BsTjB,yBAGE,wBAKN,oBACE,sBAGF,qBxBpUQ,YwBsUN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wB5B1U0B,qB4B8U1B,iBACE,UACA,QACA,YACA,qKAKA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBxBzZM,ewB2ZN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,gCAGF,UACE,YACA,0BzBjYF,iBACE,mBACA,cACA,eACA,aACA,gBACA,qByBgYF,eACE,gBACA,UACA,kBACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBxB9eI,cJGa,gB4B8ejB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,CAGE,gUAEA,aAIE,wBAKN,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UxBpjBE,+EwB4jBN,cAGE,gBACA,6BAGF,UxBnkBM,iBwBqkBJ,yBAGF,oBACE,aACA,mDAGF,UxB7kBM,uBwBklBN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WxBloBE,sFwBqoBF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,iBC5sBR,YACE,mBACA,mBACA,kBACA,QACA,SACA,YACA,mBAGF,YACE,kBACA,gBACA,qBACA,8BACA,eACA,iBACA,yBACA,WACA,4BACA,wCAEA,uBCtBF,kB9BM0B,sB8BJxB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kB9BhD0B,sB8BkDxB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,aC3FJ,cAOE,qBACA,W/BPM,gD+BEJ,iBACA,+BAOF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mB/BlBiB,4B+BsBnB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,c/BjCmB,c+BmCnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,a/BjD0B,mC+BoDxB,aACE,oDAGF,QACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBACA,uBAIA,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gB3B5FM,sB2B8FN,sGAEA,mCAEE,oBAKF,2BACA,gB3BxGM,0B2B2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,6BACA,W/BnHI,yB+BqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,mCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gB3BpKI,mB2ByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,c/B/JiB,mD+BkKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,wBC1MF,iBACE,aACA,mBACA,mBACA,WhCHM,kBgCKN,YACA,WACA,gBACA,iBACA,gBACA,4DAEA,aACE,eACA,mFAGF,iBACE,kBACA,gBACA,+FAEA,iBACE,OACA,MACA,kCAIJ,aACE,chC3BiB,2BgC+BnB,cACE,gBACA,iBACA,mBACA,2BAGF,cACE,gBACA,iBACA,gBACA,mBACA,0CAIJ,aACE,kBACA,cACA,mBACA,gCACA,eACA,qBACA,aACA,0BACA,4DAEA,aACE,iBACA,gDAGF,kBhC/DmB,iDgCmEnB,kBhChEwB,WANlB,qGgC2EN,kB5BxEU,WJHJ,oCgCiFR,kBACE,YACA,eACA,iBACA,gBACA,8BAGF,aACE,UACA,kBACA,YACA,gBACA,oCAGF,iBACE,4FAGF,eAEE,mBACA,qCAGF,mCACE,UACE,cACA,0CAGF,YACE,4DAEA,YACE,kBCtHN,UACE,eACA,iBACA,oBAEA,cACE,iBACA,gBACA,kBACA,mBAGF,UjCXM,0BiCaJ,oBAGF,eACE,cACA,iBACA,mDAGF,UACE,YACA,gBACA,gCACA,gBC3BJ,WACE,gBACA,aACA,sBACA,yBACA,kBACA,+BAEA,gBACE,eACA,CACA,2BACA,kCAGF,QACE,iCAGF,aACE,6BAGF,sBACE,0BAGF,MACE,kBACA,aACA,sBACA,iBACA,mDAGF,eACE,sB9BlCI,0B8BoCJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,YACE,gBACA,gLAEA,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,CAIA,yFAGF,eACE,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,W9BjNM,kB8BmNN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,mCAGF,kBAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,QC1QJ,eACE,eACA,8BAEA,QAEE,gBACA,UAGF,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBnCjBsB,amCsBxB,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAIA,qBACA,WACA,eACA,WnCjDE,cmCmDF,UACA,oBACA,gB/BpDE,sB+BsDF,kBACA,iBACA,oCAEA,oBnCrDoB,wBmC0DtB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oB/B7FY,8E+BkGZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,cnCzHc,amC6HhB,cACE,uBACA,UACA,SACA,SACA,cnClIc,0BmCoId,kBACA,mBAEA,oBACE,sCAGF,kCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,yBACE,gCAEA,YACE,2CAGF,yBACE,aACA,aACA,mBACA,mGAEA,UAEE,aACA,+GAEA,oBnCrLoB,sDmC2LxB,cACE,gBACA,iBACA,YACA,oBACA,cnCrLkB,sCmCwLlB,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WnC1NI,qBmC4NJ,WACA,UACA,oBACA,qXACA,sBACA,kBACA,CACA,yBACA,mDAGF,UACE,cAIJ,anC5NkB,qBmC+NhB,+BACE,6BAEA,8BACE,YC/ON,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,sBACE,eACA,gBACA,gBACA,qBACA,cpClBmB,oBoCqBnB,apClBwB,0BoCoBtB,6EAEA,oBAGE,wCAIJ,apChCmB,oBoCqCnB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cpC/CiB,qBoCmDnB,iBACE,cpCpDiB,uBoCwDnB,eACE,mBACA,kBACA,kBACA,yHAGF,sBAME,mBACA,oBACA,gBACA,cpCxEiB,qBoC4EnB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cpCnJe,iCoCuJjB,uBACE,gBACA,gBACA,cpC7IY,qDoCiJd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,uBACA,eACA,iBACA,WpCrNI,iBoCuNJ,kBACA,qEAEA,aAEE,6CAIA,apC7Ne,oCoCkOjB,sBACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,sBACE,eACA,iBACA,gBACA,cpC7Pe,mBoC+Pf,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAIE,UACqB,sCjCnRzB,CiCoRI,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iBhCpOgB,wBD9DtB,4BACA,iCiCsSE,cACE,mCAEA,aACE,WpC5SA,qBoC8SA,uDAGE,yBACE,2CAKN,aACE,cpCrTa,kCoC6TnB,sBAEE,CACA,eACA,eACA,iBACA,mBACA,cpCpUiB,sCoCuUjB,apCpUsB,0BoCsUpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,sBACE,eACA,iBACA,gBACA,mBACA,cpC9ViB,wBoCiWjB,sBACE,cACA,eACA,gBACA,cACA,kBAKF,cACA,iBpC5WiB,mCoC0WnB,sBACE,CAEA,eACA,mBACA,cpC/WiB,kBoCoXjB,cACA,iBpCrXiB,kBoC6XjB,cpC7XiB,mCoC4XnB,sBACE,CACA,gBACA,gBACA,mBACA,cpCjYiB,kBoCsYjB,cpCtYiB,kBoC8YnB,sBACE,eACA,iBACA,gBACA,mBACA,cpCnZiB,mCoCuZnB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,0CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBpCjcwB,kBoCmctB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAMA,UACqB,sCjC9hB3B,mDiCiiBI,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA9DF,iBA+DI,mFAIJ,qBAGE,mBpC3jBsB,kBoC6jBtB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,apC7lBiB,qCoCimBjB,eACE,WpCrmBE,gBoCumBF,CpCpmBe,yFoCymBb,apCzmBa,+CoC+mBjB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SpC3rBI,YoC6rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,cpCltBe,6BoCstBjB,eACE,iBACA,+BAGF,kBpCztBsB,aoC2tBpB,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,sBACE,eACA,gBACA,cACA,qCAGF,cACE,cpCjwBa,uFoCuwBnB,eACE,cASA,CpCjxBiB,2CoC8wBjB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cpC72BsB,qBoC+2BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cpCz2Bc,SqCjBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBrCrBwB,UqC0BxB,arCzBwB,0BqC2BtB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBrC9DsB,6BqCgEpB,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+BACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,crC1GmB,gBqC4GnB,0DAEA,UrCjHM,wDqCqHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBrC5JsB,sBqC8JtB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBrC3KsB,gCqC8KtB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBrCnMsB,uCqCsMpB,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,crC7Oa,gBqC+Ob,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,sCAEA,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBCzRN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBtCZoB,YsCcpB,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,StCzCA,YsC2CE,kBACA,YACA,uCAIJ,aACE,ctC/Ca,qBsCiDb,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,ctCzFa,qBsC2Fb,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UtC1GA,yBsC4GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UtClIE,yBAMkB,gBsC+HlB,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,atClNiB,esCoNf,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,atC7NiB,esC+Nf,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,ctCxOe,mBsC0Of,kBACA,gCACA,4BAGF,cACE,ctChPe,iBsCkPf,gBACA,0CAGF,UtCzPI,gBsC2PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WtCzQE,oBsC2QF,iBACA,gBACA,mBACA,2BAGF,cACE,iBACA,ctChRe,mBsCkRf,kCAEA,UtCvRE,gBsCyRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,0CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BtC9UoB,YsCqV1B,UACE,SACA,cACA,WACA,sDAKA,atCjWmB,0DsCoWjB,atCjWsB,4DsCsWxB,alC1Wc,gBkC4WZ,4DAGF,alC9WU,gBkCgXR,0DAGF,atCtWgB,gBsCwWd,0DAGF,alCtXU,gBkCwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,2BAKN,oBACE,ctCjae,qBsCmaf,yBACA,eACA,gBACA,gCACA,iCAEA,UtC5aE,gCsC8aA,oCAGF,atC3aoB,gCsC6alB,iBAMR,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,ctCvdiB,CsC4db,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,atCniBwB,qBsCqiBtB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBtC1jBsB,cAFL,0BsC+jBjB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,atC3kBgB,oBsC+kBhB,kBACE,0BACA,aACA,ctC/lBiB,gCsCimBjB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,ctC5lBc,2BsCgmBhB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBlCtnBY,oCkC0nBZ,kBACE,mCAGF,kBtC1nBsB,sDsC+nBxB,atCloBmB,qBsCsoBjB,gBACA,sBAGF,aACE,0BAGF,atC9oBmB,sBsCkpBnB,alCnpBc,yDkCwpBhB,oBAIE,ctC3pBmB,iGsC8pBnB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBlCxsBc,yBkC4sBd,yBACE,wBAGF,yBlC7sBU,wBkCktBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,atC5tBiB,uBsCkuBjB,wBACA,qBAGF,atCztBgB,csC8tBlB,kBtCzuB0B,kBsC2uBxB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,ctCjwBe,yBsCmwBf,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,alCjxBM,6BkCwxBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,ctCtyBa,mLsCyyBb,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,atCxyBU,iBsC0yBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,ctCj0Ba,WsCw0BrB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,alCv3BY,8CkC43Bd,qBACE,aACA,WtCh4BI,csCq4BR,iBACE,0pDCr4BF,kIACE,CADF,sIACE,uIAYA,aAEE,yIAGF,aAEE,qIAGF,aAEE,6IAGF,aAEE,UChCJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,+BAGF,eACE,2EAGF,UAEE,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,+BAGF,UACE,0BAGF,gBACE,eACA,UAGA,WACA,yCAGF,iBACE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,gBACA,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,qEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,sBChbJ,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WzCtCI,uByCwCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,czC/CiB,kByCiDjB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,azCpEmB,gByCsEjB,qBACA,wBCxEJ,kB1CG0B,C0CCtB,4EAGF,kBACE,gDAEA,kB1CPsB,wC0CcxB,gBACE,uCAGF,iBACE,kCAIJ,kBACE,yBACA,mEAEA,uDACE,kDAIJ,kBACE,mFAEA,uDACE,qBAMF,eACE,0CAIJ,kDACE,gBAGF,kB1CnD0B,0B0CuD1B,i2BACE,oCAEA,sDACE,CADF,8CACE,iDAOF,kBAEE,uDAEA,kBACE,qBACA,C1CxEoB,8E0CuF1B,kB1CvF0B,4B0C6FxB,yB1C7FwB,2B0CiGxB,wB1CjGwB,8B0CqGxB,2B1CrGwB,6B0CyGxB,0B1CzGwB,wB0CgHxB,kB1ChHwB,cAFL,0F0C2HnB,aACE,4GAEA,kKAEA,aACE,CAHF,6HAEA,aACE,CAHF,qIAEA,aACE,CAHF,uIAEA,aACE,CAHF,mHAEA,aACE,sCAIJ,kBACE,iCAGF,YACE,C1CzIoB,mH0C+IpB,a1C/IoB,8C0CuJxB,aACE,2JAEA,UtC7JM,wCsCoKR,aACE,mEAEA,aACE,CAHF,yDAEA,aACE,CAHF,6DAEA,aACE,CAHF,8DAEA,aACE,CAHF,oDAEA,aACE,2BAIJ,2BACE,gDAKA,a1C7KwB,iB0CkL1B,oBACE,6BAEA,kBACE,0BAIJ,+BACE,qB1C5LwB,oC0CgM1B,kBACE,iMAIA,kBAIE,qBAIJ,kB1C/MqB,sE0CmNrB,kBACE,4FAGF,kBACE,qOAIF,etC9NQ,yBsC0ON,wBAGF,0BACE,0BAGF,wBACE,uLAGF,kBAME,4qEAIE,qBAGE,uCAMN,aAEE,uBAIF,e1C9QQ,gC0CmRJ,a1ChRoB,yB0CyRtB,e1C5RM,CADA,oG0CwSF,U1CxSE,0D0CqTF,a1ClTe,2C0CwTf,U1C3TE,6C0CgUJ,a1C7TiB,6D0CiUjB,U1CpUI,qB0C2UR,UtC1UQ,yBsC6UN,StC7UM,iGsCmVN,eAGE,CAIA,oEAIA,kBACE,oDAEA,eACE,iHAMA,UtCxWA,2CsCiXR,yCACE,gMAGF,eAUE,sKAGF,U1CnYQ,0D","file":"skins/glitch/mastodon-light/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#ccd7e0 rgba(255,255,255,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#ccd7e0;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#c6d2dc}::-webkit-scrollbar-thumb:active{background:#ccd7e0}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(255,255,255,.1)}::-webkit-scrollbar-track:hover{background:#d9e1e8}::-webkit-scrollbar-track:active{background:#d9e1e8}::-webkit-scrollbar-corner{background:transparent}body{font-family:sans-serif,sans-serif;background:#eff3f5;font-size:13px;line-height:18px;font-weight:400;color:#000;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",sans-serif,sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#d9e1e8}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.embed{background:#ccd7e0;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#e6ebf0;padding:0}body.error{position:absolute;text-align:center;color:#282c37;background:#d9e1e8;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#000;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#000;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:sans-serif,sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;width:40px;height:40px;background-size:40px 40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}.account-header .name{flex:1 1 auto;color:#282c37;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#000}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#b3c3d1}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#282c37;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#000}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#a6b9c9;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#99afc2}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#282c37}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#fff}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#ccd7e0;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;width:120px;height:120px;background-size:120px 120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #ccd7e0;background:#f2f5f7;border-radius:8%;background-position:50%;background-clip:padding-box}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#ccd7e0;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#000;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#282c37}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#282c37;padding:10px;border-right:1px solid #ccd7e0;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#282c37}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#000;font-family:sans-serif,sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #b3c3d1}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#282c37}.public-layout .public-account-header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#000}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#217aba}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#000}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#282c37}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #c0cdd9}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #c0cdd9}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#d9e1e8}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#ccd7e0}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.modal-layout{background:#d9e1e8 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#6d8ca7}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#6d8ca7}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#282c37}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#6d8ca7}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#6d8ca7}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#60829f}.compact-header h1{font-size:24px;line-height:28px;color:#282c37;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#282c37}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#d9e1e8;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.hero-widget__text a{color:#282c37;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#282c37}.box-widget{padding:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #444b5d;text-align:center;color:#282c37;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#282c37;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#282c37;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#282c37}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#282c37;margin-bottom:10px}.page-header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#000;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#282c37}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#ccd7e0}.page-header h1{font-size:24px}}.directory{background:#d9e1e8;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#d9e1e8;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#c0cdd9}.directory__tag.active>a{background:#2b90d9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#000;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#282c37}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#282c37}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small{color:#000}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b90d9}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#f2f5f7;border:2px solid #d9e1e8}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#282c37;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #c0cdd9}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#000}.accounts-table__count small{display:block;color:#282c37;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #a6b9c9}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#282c37}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b90d9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#e6ebf0;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#000;border-bottom:1px solid #ccd7e0}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #ccd7e0}code{font-family:monospace,monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#000;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#282c37}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#fff}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#282c37}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#000;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#000;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#000;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#c1203b}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#000;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#000;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #444b5d;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb;border:1px solid #fff;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#1f232b}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#c1203b}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#fff}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#f2f5f7}.simple_form .input.field_with_errors label{color:#c1203b}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#c1203b}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#c1203b;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b90d9;color:#000;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#2482c7}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#419bdd}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#db2a47}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#e3566d}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#444b5d;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(249, 250, 251, 0), #f9fafb)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(217,225,232,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#000}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#c0cdd9;color:#282c37;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#282c37;text-decoration:none}.flash-message a:hover{color:#000;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:monospace,monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#ccd7e0}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#282c37;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#217aba}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#282c37}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#282c37;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#282c37;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#000;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#000;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#282c37}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#000;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:monospace,monospace}.input-copy{background:#f9fafb;border:1px solid #fff;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:monospace,monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#e6ebf0;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#e6ebf0;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#c0cdd9}.card__img{height:130px;position:relative;background:#fff;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box;background:#f2f5f7;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#000;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#282c37}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#000}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#444b5d;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#282c37;background-color:rgba(40,44,55,.1);border:1px solid rgba(40,44,55,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#c1203b;background-color:rgba(193,32,59,.1);border-color:rgba(193,32,59,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #b3c3d1;border-bottom:1px solid #b3c3d1;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #b3c3d1}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#282c37;background:rgba(242,245,247,.5)}.account__header__fields dd{flex:1 1 auto;color:#282c37}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#282c37}.pending-account__header a{color:#282c37;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#000;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#d9e1e8}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#c0cdd9}.button.logo-button{flex:0 auto;font-size:14px;background:#2b90d9;color:#000;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#000}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#2074b1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{padding:8px 0;padding-bottom:2px;margin:initial;margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{position:absolute;margin:initial;float:initial;width:auto;left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}.embed .status .status__info,.public-layout .status .status__info{font-size:15px;display:initial}.embed .status .status__relative-time,.public-layout .status .status__relative-time{color:#444b5d;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.embed .status .status__info .status__display-name,.public-layout .status .status__info .status__display-name{display:block;max-width:100%;padding:6px 0;padding-right:25px;margin:initial}.embed .status .status__info .status__display-name .display-name strong,.public-layout .status .status__info .status__display-name .display-name strong{display:inline}.embed .status .status__avatar,.public-layout .status .status__avatar{height:48px;position:absolute;width:48px;margin:initial}.rtl .embed .status,.rtl .public-layout .status{padding-left:10px;padding-right:68px}.rtl .embed .status .status__info .status__display-name,.rtl .public-layout .status .status__info .status__display-name{padding-left:25px;padding-right:0}.rtl .embed .status .status__relative-time,.rtl .public-layout .status .status__relative-time{float:left}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#2b90d9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#3897db;border:10px none;border-radius:4px;box-sizing:border-box;color:#000;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;transition-property:background-color;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#227dbe;transition:all 200ms ease-out;transition-property:background-color}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled{background-color:#9baec8;cursor:default}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#8ea3c1}.button.button-alternative-2{background:#3c5063}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#344656}.button.button-secondary{font-size:16px;line-height:36px;height:auto;color:#282c37;text-transform:none;background:transparent;padding:3px 15px;border-radius:4px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#8ea3c1;color:#1f232b}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.icon-button{display:inline-block;padding:0;color:#606984;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#51596f;background-color:rgba(96,105,132,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(96,105,132,.3)}.icon-button.disabled{color:#828ba4;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#282c37}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#373d4c;background-color:rgba(40,44,55,.15)}.icon-button.inverted:focus{background-color:rgba(40,44,55,.3)}.icon-button.inverted.disabled{color:#191b22;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#1d6ca4}.icon-button.overlayed{box-sizing:content-box;background:rgba(255,255,255,.6);color:rgba(0,0,0,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(255,255,255,.9)}.text-icon-button{color:#282c37;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#373d4c;background-color:rgba(40,44,55,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(40,44,55,.3)}.text-icon-button.disabled{color:#000;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute;transform-origin:50% 0}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.notification__favourite-icon-wrapper{left:0;position:absolute}.notification__favourite-icon-wrapper .fa.star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#000;text-decoration:underline}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name a{color:inherit;text-decoration:inherit}.display-name strong{height:18px;font-size:16px;font-weight:500;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name span{display:block;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name>a:hover strong{text-decoration:underline}.display-name.inline{padding:0;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.display-name.inline strong{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name.inline span{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #393f4f;margin:5px 7px 6px;height:0}.dropdown-menu{background:#282c37;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.dropdown-menu ul{list-style:none}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#282c37}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#282c37}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#282c37}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#282c37}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b90d9;color:#282c37;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#282c37;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b90d9;color:#282c37}.dropdown__icon{vertical-align:middle}.static-content{padding:10px;padding-top:20px;color:#444b5d}.static-content h1{font-size:16px;font-weight:500;margin-bottom:40px;text-align:center}.static-content p{font-size:13px;margin-bottom:20px}.column,.drawer{flex:1 1 100%;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#c0cdd9;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#000;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #c0cdd9;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.auto-columns .tabs-bar__link:hover,.auto-columns .tabs-bar__link:focus,.auto-columns .tabs-bar__link:active{background:#adbecd;border-bottom-color:#adbecd}}.multi-columns .tabs-bar__link:hover,.multi-columns .tabs-bar__link:focus,.multi-columns .tabs-bar__link:active{background:#adbecd;border-bottom-color:#adbecd}.tabs-bar__link.active{border-bottom:2px solid #2b90d9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}.tabs-bar__link span.icon{margin-left:0;display:inline}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b90d9;border:2px solid #c0cdd9;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#000}.column-link--transparent .icon-with-badge__badge{border-color:#f2f5f7}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(255,255,255,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#d9e1e8;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#f9fafb}.react-toggle--checked .react-toggle-track{background-color:#2b90d9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#2074b1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #d9e1e8;border-radius:50%;background-color:#fff;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b90d9}.getting-started__wrapper,.getting_started,.flex-spacer{background:#d9e1e8}.getting-started__wrapper{position:relative;overflow-y:auto}.flex-spacer{flex:1 1 auto}.getting-started{background:#d9e1e8;flex:1 0 auto}.getting-started p{color:#282c37}.getting-started a{color:#444b5d}.getting-started__panel{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex:0 1 auto}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{color:#444b5d;font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#444b5d;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#282c37}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#282c37;padding:10px;font-weight:500;border-bottom:1px solid #c0cdd9}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#282c37}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#d9e1e8;padding:4px 8px;margin:-6px 10px}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#c0cdd9;border:1px solid #e6ebf0}.setting-text{color:#282c37;background:transparent;border:none;border-bottom:2px solid #9baec8;box-sizing:border-box;display:block;font-family:inherit;margin-bottom:10px;padding:7px 0;width:100%}.setting-text:focus,.setting-text:active{color:#000;border-bottom-color:#2b90d9}@media screen and (max-width: 600px){.auto-columns .setting-text,.single-column .setting-text{font-size:16px}}.setting-text.light{color:#000;border-bottom:2px solid #839db4}.setting-text.light:focus,.setting-text.light:active{color:#000;border-bottom-color:#2b90d9}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#606984;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.reduce-motion button.icon-button.disabled i.fa-retweet{color:#828ba4}.load-more{display:block;color:#444b5d;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#d3dce4}.load-gap{border-bottom:1px solid #c0cdd9}.missing-indicator{padding-top:68px}.scrollable>div>:first-child .notification__dismiss-overlay>.wrappy{border-top:1px solid #d9e1e8}.notification__dismiss-overlay{overflow:hidden;position:absolute;top:0;right:0;bottom:-1px;padding-left:15px;z-index:999;align-items:center;justify-content:flex-end;cursor:pointer;display:flex}.notification__dismiss-overlay .wrappy{width:4rem;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#c0cdd9;border-left:1px solid #99afc2;box-shadow:0 0 5px #000;border-bottom:1px solid #d9e1e8}.notification__dismiss-overlay .ckbox{border:2px solid #9baec8;border-radius:2px;width:30px;height:30px;font-size:20px;color:#282c37;text-shadow:0 0 5px #000;display:flex;justify-content:center;align-items:center}.notification__dismiss-overlay:focus{outline:0 !important}.notification__dismiss-overlay:focus .ckbox{box-shadow:0 0 1px 1px #2b90d9}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.loading-indicator{color:#444b5d;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #86a0b6;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#86a0b6}29%{background-color:#86a0b6}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:flex;left:4px;top:4px;width:auto;height:auto;align-items:center}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(255,255,255,.5);border-radius:8px;padding:8px 12px;color:#000;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(255,255,255,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(255,255,255,.5)}.setting-toggle{display:block;line-height:24px}.setting-toggle__label,.setting-radio__label,.setting-meta__label{color:#282c37;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.setting-radio{display:block;line-height:18px}.setting-radio__label{margin-bottom:0}.column-settings__row legend{color:#282c37;cursor:default;display:block;font-weight:500;margin-top:10px}.setting-radio__input{vertical-align:middle}.setting-meta__label{float:right}@keyframes heartbeat{from{transform:scale(1);transform-origin:center center;animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.pulse-loading{animation:heartbeat 1.5s ease-in-out infinite both}.upload-area{align-items:center;background:rgba(255,255,255,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#d9e1e8;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#282c37;font-size:18px;font-weight:500;border:2px dashed #3c5063;border-radius:4px}.dropdown--active .emoji-button img{opacity:1;filter:none}.loading-bar{background-color:#2b90d9;height:3px;position:absolute;top:0;left:0;z-index:9999}.icon-badge-wrapper{position:relative}.icon-badge{position:absolute;display:block;right:-0.25em;top:-0.25em;background-color:#2b90d9;border-radius:50%;font-size:75%;width:1em;height:1em}.conversation{display:flex;border-bottom:1px solid #c0cdd9;padding:5px;padding-bottom:0}.conversation:focus{background:#d3dce4;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#282c37;padding-left:15px}.conversation__content__names{color:#282c37;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#000;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content .status__content{margin:0}.conversation--unread{background:#d3dce4}.conversation--unread:focus{background:#ccd7e0}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#000}.ui .flash-message{margin-top:10px;margin-left:auto;margin-right:auto;margin-bottom:0;min-width:75%}::-webkit-scrollbar-thumb{border-radius:0}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#282c37;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}noscript div a{word-break:break-word}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet,button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.status-direct button.icon-button.disabled i.fa-retweet,.status-direct button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.account{padding:10px;border-bottom:1px solid #c0cdd9;color:inherit;text-decoration:none}.account .account__display-name{flex:1 1 auto;display:block;color:#282c37;overflow:hidden;text-decoration:none;font-size:14px}.account.small{border:none;padding:0}.account.small>.account__avatar-wrapper{margin:0 8px 0 0}.account.small>.display-name{height:24px;line-height:24px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:8%;background-position:50%;background-clip:padding-box;position:relative;cursor:pointer}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:8%;background-position:50%;background-clip:padding-box;overflow:hidden;position:relative;cursor:default}.account__avatar-composite div{border-radius:8%;background-position:50%;background-clip:padding-box;float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#000;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}.account__avatar-overlay{position:relative;width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:8%;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:8%;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__header__wrapper{flex:0 0 auto;background:#ccd7e0}.account__disclaimer{padding:10px;color:#444b5d}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-left:1px solid #c0cdd9;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab:first-child{border-left:0}.account__action-bar__tab.active{border-bottom:4px solid #2b90d9}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#282c37}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#000}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account__action-bar__tab abbr{color:#2b90d9}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.notification__message{margin-left:42px;padding:8px 0 0 26px;cursor:default;color:#282c37;font-size:15px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account--panel{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#c0cdd9;padding:15px}.column-settings__section{color:#282c37;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#1f232b}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#ccd7e0}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#444b5d;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#c0cdd9}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#b3c3d1;color:#1f232b}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#282c37}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#444b5d}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#3b4151}.column-settings__hashtags .column-select__indicator-separator{background-color:#c0cdd9}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#282c37}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#444b5d;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#3d4455}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#000;margin-bottom:4px;display:block;vertical-align:top;background-color:#fff;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:none;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#e6ebf0;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#282c37;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#282c37}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #c0cdd9}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #d9e1e8}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#dfe6ec;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #eff3f5}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9}.account__moved-note__message{position:relative;margin-left:58px;color:#444b5d;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.account__header__content{color:#282c37;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#e6ebf0}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#ccd7e0;padding:5px;border-bottom:1px solid #b3c3d1}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#f2f5f7;border:2px solid #ccd7e0}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #b3c3d1;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#000}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #b3c3d1}.account__header__bio .account__header__fields a{color:#217aba}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#282c37;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#000}.domain{padding:10px;border-bottom:1px solid #c0cdd9}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#000;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}@keyframes spring-flip-in{0%{transform:rotate(0deg)}30%{transform:rotate(-242.4deg)}60%{transform:rotate(-158.35deg)}90%{transform:rotate(-187.5deg)}100%{transform:rotate(-180deg)}}@keyframes spring-flip-out{0%{transform:rotate(-180deg)}30%{transform:rotate(62.4deg)}60%{transform:rotate(-21.635deg)}90%{transform:rotate(7.5deg)}100%{transform:rotate(0deg)}}.status__content--with-action{cursor:pointer}.status__content{position:relative;margin:10px 0;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:visible;padding-top:5px}.status__content:focus{outline:0}.status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.status__content pre,.status__content blockquote{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.status__content pre:last-child,.status__content blockquote:last-child{margin-bottom:0}.status__content .status__content__text,.status__content .e-content{overflow:hidden}.status__content .status__content__text>ul,.status__content .status__content__text>ol,.status__content .e-content>ul,.status__content .e-content>ol{margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h1,.status__content .e-content h2,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{margin-top:20px;margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .e-content h1,.status__content .e-content h2{font-weight:700;font-size:1.2em}.status__content .status__content__text h2,.status__content .e-content h2{font-size:1.1em}.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{font-weight:500}.status__content .status__content__text blockquote,.status__content .e-content blockquote{padding-left:10px;border-left:3px solid #282c37;color:#282c37;white-space:normal}.status__content .status__content__text blockquote p:last-child,.status__content .e-content blockquote p:last-child{margin-bottom:0}.status__content .status__content__text b,.status__content .status__content__text strong,.status__content .e-content b,.status__content .e-content strong{font-weight:700}.status__content .status__content__text em,.status__content .status__content__text i,.status__content .e-content em,.status__content .e-content i{font-style:italic}.status__content .status__content__text sub,.status__content .e-content sub{font-size:smaller;text-align:sub}.status__content .status__content__text sup,.status__content .e-content sup{font-size:smaller;vertical-align:super}.status__content .status__content__text ul,.status__content .status__content__text ol,.status__content .e-content ul,.status__content .e-content ol{margin-left:1em}.status__content .status__content__text ul p,.status__content .status__content__text ol p,.status__content .e-content ul p,.status__content .e-content ol p{margin:0}.status__content .status__content__text ul,.status__content .e-content ul{list-style-type:disc}.status__content .status__content__text ol,.status__content .e-content ol{list-style-type:decimal}.status__content a{color:#d8a070;text-decoration:none}.status__content a:hover{text-decoration:underline}.status__content a:hover .fa{color:#353a48}.status__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span{text-decoration:underline}.status__content a .fa{color:#444b5d}.status__content .status__content__spoiler{display:none}.status__content .status__content__spoiler.status__content__spoiler--visible{display:block}.status__content a.unhandled-link{color:#217aba}.status__content a.unhandled-link .link-origin-tag{color:#ca8f04;font-size:.8em}.status__content .status__content__spoiler-link{background:#7a96ae}.status__content .status__content__spoiler-link:hover{background:#708ea9;text-decoration:none}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:#7a96ae;border:none;color:#000;font-weight:500;font-size:11px;padding:0 5px;text-transform:uppercase;line-height:inherit;cursor:pointer;vertical-align:bottom}.status__content__spoiler-link:hover{background:#708ea9;text-decoration:none}.status__content__spoiler-link .status__content__spoiler-icon{display:inline-block;margin:0 0 0 5px;border-left:1px solid currentColor;padding:0 0 0 4px;font-size:16px;vertical-align:-2px}.notif-cleaning .status,.notif-cleaning .notification-follow,.notif-cleaning .notification-follow-request{padding-right:4.5rem}.status__wrapper--filtered{color:#444b5d;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #c0cdd9}.status__prepend-icon-wrapper{left:-26px;position:absolute}.notification-follow,.notification-follow-request{position:relative;border-bottom:1px solid #c0cdd9}.notification-follow .account,.notification-follow-request .account{border-bottom:0 none}.focusable:focus{outline:0;background:#ccd7e0}.focusable:focus.status.status-direct:not(.read){background:#b3c3d1}.focusable:focus.status.status-direct:not(.read).muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#c0cdd9}.status{padding:10px 14px;position:relative;height:auto;border-bottom:1px solid #c0cdd9;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:28px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#c0cdd9;border-bottom-color:#b3c3d1}.status.light .status__relative-time{color:#282c37}.status.light .status__display-name{color:#000}.status.light .display-name strong{color:#000}.status.light .display-name span{color:#282c37}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#000;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#8199ba}.status.collapsed{background-position:center;background-size:cover;user-select:none}.status.collapsed.has-background::before{display:block;position:absolute;left:0;right:0;top:0;bottom:0;background-image:linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.65) 24px, rgba(0, 0, 0, 0.8));pointer-events:none;content:\"\"}.status.collapsed .display-name:hover .display-name__html{text-decoration:none}.status.collapsed .status__content{height:20px;overflow:hidden;text-overflow:ellipsis;padding-top:0}.status.collapsed .status__content:after{content:\"\";position:absolute;top:0;bottom:0;left:0;right:0;background:linear-gradient(rgba(217, 225, 232, 0), #d9e1e8);pointer-events:none}.status.collapsed .status__content a:hover{text-decoration:none}.status.collapsed:focus>.status__content:after{background:linear-gradient(rgba(204, 215, 224, 0), #ccd7e0)}.status.collapsed.status-direct:not(.read)>.status__content:after{background:linear-gradient(rgba(192, 205, 217, 0), #c0cdd9)}.status.collapsed .notification__message{margin-bottom:0}.status.collapsed .status__info .notification__message>span{white-space:nowrap}.status .notification__message{margin:-10px 0px 10px 0}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#444a5e}.status__relative-time{display:inline-block;flex-grow:1;color:#444b5d;font-size:14px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status__display-name{color:#444b5d;overflow:hidden}.status__info__account .status__display-name{display:block;max-width:100%}.status__info{display:flex;justify-content:space-between;font-size:15px}.status__info>span{text-overflow:ellipsis;overflow:hidden}.status__info .notification__message>span{word-wrap:break-word}.status__info__icons{display:flex;align-items:center;height:1em;color:#606984}.status__info__icons .status__media-icon,.status__info__icons .status__visibility-icon,.status__info__icons .status__reply-icon{padding-left:2px;padding-right:2px}.status__info__icons .status__collapse-button.active>.fa-angle-double-up{transform:rotate(-180deg)}.no-reduce-motion .status__collapse-button.activate>.fa-angle-double-up{animation:spring-flip-in 1s linear}.no-reduce-motion .status__collapse-button.deactivate>.fa-angle-double-up{animation:spring-flip-out 1s linear}.status__info__account{display:flex;align-items:center;justify-content:flex-start}.status-check-box{border-bottom:1px solid #282c37;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-top:-10px;margin-bottom:10px;margin-left:58px;color:#444b5d;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#444b5d}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#606984}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#ccd7e0;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#444b5d;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#000}.muted .emojione{opacity:.5}a.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#282c37;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#000}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{flex:none;margin:0 10px 0 0;height:48px;width:48px}.muted .status__content,.muted .status__content p,.muted .status__content a,.muted .status__content__text{color:#444b5d}.muted .status__display-name strong{color:#444b5d}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#3c5063;color:#000}.muted a.status__content__spoiler-link:hover{background:#7d98b0;text-decoration:none}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.status-card{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;color:#444b5d;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#282c37;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#000}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}.status-card__actions a .fa,.status-card__actions a:hover .fa{color:inherit}a.status-card{cursor:pointer}a.status-card:hover{background:#c0cdd9}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#282c37;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#282c37}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#c0cdd9;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#ccd7e0}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#ccd7e0}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.attachment-list{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#444b5d;padding:8px 18px;cursor:default;border-right:1px solid #c0cdd9;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#444b5d;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#444b5d}.status__wrapper--filtered__button{display:inline;color:#217aba;border:0;background:transparent;padding:0;font-size:inherit;line-height:inherit}.status__wrapper--filtered__button:hover,.status__wrapper--filtered__button:active{text-decoration:underline}.modal-container--preloader{background:#c0cdd9}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.onboarding-modal,.error-modal,.embed-modal{background:#282c37;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.onboarding-modal__pager{height:80vh;width:80vw;max-width:520px;max-height:470px}.onboarding-modal__pager .react-swipeable-view-container>div{width:100%;height:100%;box-sizing:border-box;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;user-select:text}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}@media screen and (max-width: 550px){.onboarding-modal{width:100%;height:100%;border-radius:0}.onboarding-modal__pager{width:100%;height:auto;max-width:none;max-height:none;flex:1 1 auto}}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#393f4f;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#282c37;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#313543;background-color:#4a5266}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#000}.error-modal__footer{justify-content:center}.onboarding-modal__dots{flex:1 1 auto;display:flex;align-items:center;justify-content:center}.onboarding-modal__dot{width:14px;height:14px;border-radius:14px;background:#4a5266;margin:0 3px;cursor:pointer}.onboarding-modal__dot:hover{background:#4f576c}.onboarding-modal__dot.active{cursor:default;background:#5c657e}.onboarding-modal__page__wrapper{pointer-events:none;padding:25px;padding-bottom:0}.onboarding-modal__page__wrapper.onboarding-modal__page__wrapper--active{pointer-events:auto}.onboarding-modal__page{cursor:default;line-height:21px}.onboarding-modal__page h1{font-size:18px;font-weight:500;color:#000;margin-bottom:20px}.onboarding-modal__page a{color:#2b90d9}.onboarding-modal__page a:hover,.onboarding-modal__page a:focus,.onboarding-modal__page a:active{color:#2485cb}.onboarding-modal__page .navigation-bar a{color:inherit}.onboarding-modal__page p{font-size:16px;color:#282c37;margin-top:10px;margin-bottom:10px}.onboarding-modal__page p:last-child{margin-bottom:0}.onboarding-modal__page p strong{font-weight:500;background:#d9e1e8;color:#282c37;border-radius:4px;font-size:14px;padding:3px 6px}.onboarding-modal__page p strong:lang(ja){font-weight:700}.onboarding-modal__page p strong:lang(ko){font-weight:700}.onboarding-modal__page p strong:lang(zh-CN){font-weight:700}.onboarding-modal__page p strong:lang(zh-HK){font-weight:700}.onboarding-modal__page p strong:lang(zh-TW){font-weight:700}.onboarding-modal__page__wrapper-0{height:100%;padding:0}.onboarding-modal__page-one__lead{padding:65px;padding-top:45px;padding-bottom:0;margin-bottom:10px}.onboarding-modal__page-one__lead h1{font-size:26px;line-height:36px;margin-bottom:8px}.onboarding-modal__page-one__lead p{margin-bottom:0}.onboarding-modal__page-one__extra{padding-right:65px;padding-left:185px;text-align:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#d9e1e8;color:#282c37;font-weight:500;padding:10px;border-radius:4px}.onboarding-modal__page-two p,.onboarding-modal__page-three p,.onboarding-modal__page-four p,.onboarding-modal__page-five p{text-align:left}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{background:#f2f5f7;color:#282c37;margin-bottom:20px;border-radius:4px;padding:10px;text-align:center;font-size:14px;box-shadow:1px 2px 6px rgba(0,0,0,.3)}.onboarding-modal__page-two .figure .onboarding-modal__image,.onboarding-modal__page-three .figure .onboarding-modal__image,.onboarding-modal__page-four .figure .onboarding-modal__image,.onboarding-modal__page-five .figure .onboarding-modal__image{border-radius:4px;margin-bottom:10px}.onboarding-modal__page-two .figure.non-interactive,.onboarding-modal__page-three .figure.non-interactive,.onboarding-modal__page-four .figure.non-interactive,.onboarding-modal__page-five .figure.non-interactive{pointer-events:none;text-align:left}.onboarding-modal__page-four__columns .row{display:flex;margin-bottom:20px}.onboarding-modal__page-four__columns .row>div{flex:1 1 0;margin:0 10px}.onboarding-modal__page-four__columns .row>div:first-child{margin-left:0}.onboarding-modal__page-four__columns .row>div:last-child{margin-right:0}.onboarding-modal__page-four__columns .row>div p{text-align:center}.onboarding-modal__page-four__columns .row:last-child{margin-bottom:0}.onboarding-modal__page-four__columns .column-header{color:#000}@media screen and (max-width: 320px)and (max-height: 600px){.onboarding-modal__page p{font-size:14px;line-height:20px}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{font-size:12px;margin-bottom:10px}.onboarding-modal__page-four__columns .row{margin-bottom:10px}.onboarding-modal__page-four__columns .column-header{padding:5px;font-size:12px}}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.doodle-modal,.favourite-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#17191f;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__relative-time,.doodle-modal .status__relative-time,.favourite-modal .status__relative-time,.confirmation-modal .status__relative-time,.report-modal .status__relative-time,.actions-modal .status__relative-time,.mute-modal .status__relative-time,.block-modal .status__relative-time{color:#444b5d;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.boost-modal .status__display-name,.doodle-modal .status__display-name,.favourite-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:flex}.boost-modal .status__avatar,.doodle-modal .status__avatar,.favourite-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:48px;width:48px}.boost-modal .status__content__spoiler-link,.doodle-modal .status__content__spoiler-link,.favourite-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#17191f}.actions-modal .status{background:#fff;border-bottom-color:#282c37;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#282c37}.boost-modal__container,.favourite-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status,.favourite-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.doodle-modal__action-bar,.favourite-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#282c37;padding:10px;line-height:36px}.boost-modal__action-bar>div,.doodle-modal__action-bar>div,.favourite-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#282c37;padding-right:10px}.boost-modal__action-bar .button,.doodle-modal__action-bar .button,.favourite-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header,.favourite-modal__status-header{font-size:15px}.boost-modal__status-time,.favourite-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #282c37}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #282c37;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #282c37;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #393f4f}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #282c37;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal strong{display:block;font-weight:500}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b90d9;color:#000}.actions-modal ul li:not(:empty) a>.react-toggle,.actions-modal ul li:not(:empty) a>.icon,.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#282c37;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#313543;background-color:transparent}.confirmation-modal__do_not_ask_again{padding-left:20px;padding-right:20px;padding-bottom:10px;font-size:14px}.confirmation-modal__do_not_ask_again label,.confirmation-modal__do_not_ask_again input{vertical-align:middle}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#ccd7e0}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#000;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.filtered-status-info{text-align:start}.filtered-status-info .spoiler__text{margin-top:20px}.filtered-status-info .account{border-bottom:0}.filtered-status-info .account__display-name strong{color:#000}.filtered-status-info .status__content__spoiler{display:none}.filtered-status-info .status__content__spoiler--visible{display:flex}.filtered-status-info ul{padding:10px;margin-left:12px;list-style:disc inside}.filtered-status-info .filtered-status-edit-link{color:#606984;text-decoration:none}.filtered-status-info .filtered-status-edit-link:hover{text-decoration:underline}.composer{padding:10px}.character-counter{cursor:default;font-family:sans-serif,sans-serif;font-size:14px;font-weight:600;color:#282c37}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .composer--spoiler{transition:height .4s ease,opacity .4s ease}.composer--spoiler{height:0;transform-origin:bottom;opacity:0}.composer--spoiler.composer--spoiler--visible{height:36px;margin-bottom:11px;opacity:1}.composer--spoiler input{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px;padding:10px;width:100%;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:vertical}.composer--spoiler input::placeholder{color:#444b5d}.composer--spoiler input:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .composer--spoiler input{font-size:16px}}.single-column .composer--spoiler input{font-size:16px}.composer--warning{color:#000;margin-bottom:15px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.composer--warning a{color:#282c37;font-weight:500;text-decoration:underline}.composer--warning a:active,.composer--warning a:focus,.composer--warning a:hover{text-decoration:none}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-left:5px;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.composer--reply{margin:0 0 10px;border-radius:4px;padding:10px;background:#9baec8;min-height:23px;overflow-y:auto;flex:0 2 auto}.composer--reply>header{margin-bottom:5px;overflow:hidden}.composer--reply>header>.account.small{color:#000}.composer--reply>header>.cancel{float:right;line-height:24px}.composer--reply>.content{position:relative;margin:10px 0;padding:0 12px;font-size:14px;line-height:20px;color:#000;word-wrap:break-word;font-weight:400;overflow:visible;white-space:pre-wrap;padding-top:5px;overflow:hidden}.composer--reply>.content p,.composer--reply>.content pre,.composer--reply>.content blockquote{margin-bottom:20px;white-space:pre-wrap}.composer--reply>.content p:last-child,.composer--reply>.content pre:last-child,.composer--reply>.content blockquote:last-child{margin-bottom:0}.composer--reply>.content h1,.composer--reply>.content h2,.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{margin-top:20px;margin-bottom:20px}.composer--reply>.content h1,.composer--reply>.content h2{font-weight:700;font-size:18px}.composer--reply>.content h2{font-size:16px}.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{font-weight:500}.composer--reply>.content blockquote{padding-left:10px;border-left:3px solid #000;color:#000;white-space:normal}.composer--reply>.content blockquote p:last-child{margin-bottom:0}.composer--reply>.content b,.composer--reply>.content strong{font-weight:700}.composer--reply>.content em,.composer--reply>.content i{font-style:italic}.composer--reply>.content sub{font-size:smaller;text-align:sub}.composer--reply>.content ul,.composer--reply>.content ol{margin-left:1em}.composer--reply>.content ul p,.composer--reply>.content ol p{margin:0}.composer--reply>.content ul{list-style-type:disc}.composer--reply>.content ol{list-style-type:decimal}.composer--reply>.content a{color:#282c37;text-decoration:none}.composer--reply>.content a:hover{text-decoration:underline}.composer--reply>.content a.mention:hover{text-decoration:none}.composer--reply>.content a.mention:hover span{text-decoration:underline}.composer--reply .emojione{width:20px;height:20px;margin:-5px 0 0}.emoji-picker-dropdown{position:absolute;right:5px;top:5px}.emoji-picker-dropdown ::-webkit-scrollbar-track:hover,.emoji-picker-dropdown ::-webkit-scrollbar-track:active{background-color:rgba(255,255,255,.3)}.compose-form__autosuggest-wrapper,.autosuggest-input{position:relative;width:100%}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.autosuggest-input label .autosuggest-textarea__textarea{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px 4px 0 0;padding:10px 32px 0 10px;width:100%;min-height:100px;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:none;scrollbar-color:initial}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::placeholder,.autosuggest-input label .autosuggest-textarea__textarea::placeholder{color:#444b5d}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::-webkit-scrollbar,.autosuggest-input label .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:disabled,.autosuggest-input label .autosuggest-textarea__textarea:disabled{background:#282c37}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:focus,.autosuggest-input label .autosuggest-textarea__textarea:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}}.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}@media screen and (max-width: 600px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.composer--textarea--icons{display:block;position:absolute;top:29px;right:5px;bottom:5px;overflow:hidden}.composer--textarea--icons>.textarea_icon{display:block;margin:2px 0 0 2px;width:24px;height:24px;color:#282c37;font-size:18px;line-height:24px;text-align:center;opacity:.8}.autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.autosuggest-textarea__suggestions{display:block;position:absolute;box-sizing:border-box;top:100%;border-radius:0 0 4px 4px;padding:6px;width:100%;color:#000;background:#282c37;box-shadow:4px 4px 6px rgba(0,0,0,.4);font-size:14px;z-index:99;display:none}.autosuggest-textarea__suggestions--visible{display:block}.autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#3d4455}.autosuggest-textarea__suggestions__item>.account,.autosuggest-textarea__suggestions__item>.emoji,.autosuggest-textarea__suggestions__item>.autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.autosuggest-textarea__suggestions__item .autosuggest-hashtag{justify-content:space-between}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item .autosuggest-hashtag strong{font-weight:500}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item>.account.small .display-name>span{color:#282c37}.composer--upload_form{overflow:hidden}.composer--upload_form>.content{display:flex;flex-direction:row;flex-wrap:wrap;font-family:inherit;padding:5px;overflow:hidden}.composer--upload_form--item{flex:1 1 0;margin:5px;min-width:40%}.composer--upload_form--item>div{position:relative;border-radius:4px;height:140px;width:100%;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;overflow:hidden}.composer--upload_form--item>div textarea{display:block;position:absolute;box-sizing:border-box;bottom:0;left:0;margin:0;border:0;padding:10px;width:100%;color:#282c37;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);font-size:14px;font-family:inherit;font-weight:500;opacity:0;z-index:2;transition:opacity .1s ease}.composer--upload_form--item>div textarea:focus{color:#fff}.composer--upload_form--item>div textarea::placeholder{opacity:.54;color:#282c37}.composer--upload_form--item>div>.close{mix-blend-mode:difference}.composer--upload_form--item.active>div textarea{opacity:1}.composer--upload_form--actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.composer--upload_form--actions .icon-button{flex:0 1 auto;color:#282c37;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.composer--upload_form--actions .icon-button:hover,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:active{color:#1f232b}.composer--upload_form--actions.active{opacity:1}.composer--upload_form--progress{display:flex;padding:10px;color:#282c37;overflow:hidden}.composer--upload_form--progress>.fa{font-size:34px;margin-right:10px}.composer--upload_form--progress>.message{flex:1 1 auto}.composer--upload_form--progress>.message>span{display:block;font-size:12px;font-weight:500;text-transform:uppercase}.composer--upload_form--progress>.message>.backdrop{position:relative;margin-top:5px;border-radius:6px;width:100%;height:6px;background:#3c5063}.composer--upload_form--progress>.message>.backdrop>.tracker{position:absolute;top:0;left:0;height:6px;border-radius:6px;background:#2b90d9}.compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.composer--options-wrapper{padding:10px;background:#fff;border-radius:0 0 4px 4px;height:27px;display:flex;justify-content:space-between;flex:0 0 auto}.composer--options{display:flex;flex:0 0 auto}.composer--options>*{display:inline-block;box-sizing:content-box;padding:0 3px;height:27px;line-height:27px;vertical-align:bottom}.composer--options>hr{display:inline-block;margin:0 3px;border-width:0 0 0 1px;border-style:none none none solid;border-color:transparent transparent transparent #fff;padding:0;width:0;height:27px;background:transparent}.compose--counter-wrapper{align-self:center;margin-right:4px}.composer--options--dropdown.open>.value{border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1);color:#000;background:#2b90d9;transition:none}.composer--options--dropdown.open.top>.value{border-radius:0 0 4px 4px;box-shadow:0 4px 4px rgba(0,0,0,.1)}.composer--options--dropdown--content{position:absolute;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);background:#fff;overflow:hidden;transform-origin:50% 0}.composer--options--dropdown--content--item{display:flex;align-items:center;padding:10px;color:#000;cursor:pointer}.composer--options--dropdown--content--item>.content{flex:1 1 auto;color:#282c37}.composer--options--dropdown--content--item>.content:not(:first-child){margin-left:10px}.composer--options--dropdown--content--item>.content strong{display:block;color:#000;font-weight:500}.composer--options--dropdown--content--item:hover,.composer--options--dropdown--content--item.active{background:#2b90d9;color:#000}.composer--options--dropdown--content--item:hover>.content,.composer--options--dropdown--content--item.active>.content{color:#000}.composer--options--dropdown--content--item:hover>.content strong,.composer--options--dropdown--content--item.active>.content strong{color:#000}.composer--options--dropdown--content--item.active:hover{background:#2485cb}.composer--publisher{padding-top:10px;text-align:right;white-space:nowrap;overflow:hidden;justify-content:flex-end;flex:0 0 auto}.composer--publisher>.primary{display:inline-block;margin:0;padding:0 10px;text-align:center}.composer--publisher>.side_arm{display:inline-block;margin:0 2px;padding:0;width:36px;text-align:center}.composer--publisher.over>.count{color:#ff5050}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#f2f5f7;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#d9e1e8}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.column{overflow:hidden}.column-back-button{box-sizing:border-box;width:100%;background:#ccd7e0;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;border:0;text-align:unset;padding:15px;margin:0;z-index:3}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#ccd7e0;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.column-link{background:#c0cdd9;color:#000;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#b6c5d3}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#282c37}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#000}.column-link--transparent.active{color:#2b90d9}.column-link__icon{display:inline-block;margin-right:5px}.column-subheading{background:#d9e1e8;color:#444b5d;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 144, 217, 0.23) 0%, rgba(43, 144, 217, 0) 60%)}.column-header{display:flex;font-size:16px;background:#ccd7e0;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden}.column-header>button{margin:0;border:none;padding:15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,144,217,.4)}.column-header:focus,.column-header:active{outline:0}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden}.wide .columns-area:not(.columns-area--mobile) .column{flex:auto;min-width:330px;max-width:400px}.column>.scrollable{background:#d9e1e8}.column-header__buttons{height:48px;display:flex;margin-left:0}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button,.column-header__notif-cleaning-buttons button{background:#ccd7e0;border:0;color:#282c37;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover,.column-header__notif-cleaning-buttons button:hover{color:#191b22}.column-header__button.active,.column-header__notif-cleaning-buttons button.active{color:#000;background:#c0cdd9}.column-header__button.active:hover,.column-header__notif-cleaning-buttons button.active:hover{color:#000;background:#c0cdd9}.column-header__button:focus,.column-header__notif-cleaning-buttons button:focus{text-shadow:0 0 4px #419bdd}.column-header__notif-cleaning-buttons{display:flex;align-items:stretch;justify-content:space-around}.column-header__notif-cleaning-buttons button{background:transparent;text-align:center;padding:10px 0;white-space:pre-wrap}.column-header__notif-cleaning-buttons b{font-weight:bold}.column-header__collapsible-inner.nopad-drawer{padding:0}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#282c37;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #b3c3d1;margin:10px 0}.column-header__collapsible.ncd{transition:none}.column-header__collapsible.ncd.collapsed{max-height:0;opacity:.7}.column-header__collapsible-inner{background:#c0cdd9;padding:15px}.column-header__setting-btn:hover{color:#282c37;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.column-header__title{display:inline-block;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header__icon{display:inline-block;margin-right:5px}.empty-column-indicator,.error-column{color:#444b5d;background:#d9e1e8;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}.single-column.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}@media screen and (max-width: 415px){.auto-columns.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}}@media screen and (max-width: 415px){.auto-columns.navbar-under .react-swipeable-view-container .columns-area,.single-column.navbar-under .react-swipeable-view-container .columns-area{height:100% !important}}.column-inline-form{padding:7px 15px;padding-right:5px;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%;margin-bottom:6px}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 5px}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#444b5d;background:#d9e1e8;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#444b5d}.regeneration-indicator__label span{font-size:15px;font-weight:400}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#fff;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#ccd7e0;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#f2f5f7;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#d9e1e8;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #c0cdd9;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.filter-form{background:#d9e1e8}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#217aba;background:#217aba}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#1f232b}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#ccd7e0}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:color,transform,opacity;font-size:18px;width:18px;height:18px;color:#282c37;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(0deg)}.search__icon .fa-search.active{pointer-events:auto;opacity:.3}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#606984;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#51596f}.search-results__header{color:#444b5d;background:#d3dce4;border-bottom:1px solid #e6ebf0;padding:15px 10px;font-size:14px;font-weight:500}.search-results__info{padding:20px;color:#282c37;text-align:center}.trends__header{color:#444b5d;background:#d3dce4;border-bottom:1px solid #e6ebf0;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #c0cdd9}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#444b5d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#282c37;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#282c37}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#2380c3 !important}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(40,44,55,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.doodle-modal{width:unset}.doodle-modal__container{background:#d9e1e8;text-align:center;line-height:0}.doodle-modal__container canvas{border:5px solid #d9e1e8}.doodle-modal__action-bar .filler{flex-grow:1;margin:0;padding:0}.doodle-modal__action-bar .doodle-toolbar{line-height:1;display:flex;flex-direction:column;flex-grow:0;justify-content:space-around}.doodle-modal__action-bar .doodle-toolbar.with-inputs label{display:inline-block;width:70px;text-align:right;margin-right:2px}.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=number],.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=text]{width:40px}.doodle-modal__action-bar .doodle-toolbar.with-inputs span.val{display:inline-block;text-align:left;width:50px}.doodle-modal__action-bar .doodle-palette{padding-right:0 !important;border:1px solid #000;line-height:.2rem;flex-grow:0;background:#fff}.doodle-modal__action-bar .doodle-palette button{appearance:none;width:1rem;height:1rem;margin:0;padding:0;text-align:center;color:#000;text-shadow:0 0 1px #fff;cursor:pointer;box-shadow:inset 0 0 1px rgba(255,255,255,.5);border:1px solid #000;outline-offset:-1px}.doodle-modal__action-bar .doodle-palette button.foreground{outline:1px dashed #fff}.doodle-modal__action-bar .doodle-palette button.background{outline:1px dashed red}.doodle-modal__action-bar .doodle-palette button.foreground.background{outline:1px dashed red;border-color:#fff}.drawer{width:300px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden;padding:10px 5px;flex:none}.drawer:first-child{padding-left:10px}.drawer:last-child{padding-right:10px}@media screen and (max-width: 630px){.auto-columns .drawer{flex:auto}}.single-column .drawer{flex:auto}@media screen and (max-width: 630px){.auto-columns .drawer,.auto-columns .drawer:first-child,.auto-columns .drawer:last-child,.single-column .drawer,.single-column .drawer:first-child,.single-column .drawer:last-child{padding:0}}.wide .drawer{min-width:300px;max-width:400px;flex:1 1 200px}@media screen and (max-width: 630px){:root .auto-columns .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}}:root .single-column .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}.react-swipeable-view-container .drawer{height:100%}.drawer--header{display:flex;flex-direction:row;margin-bottom:10px;flex:none;background:#c0cdd9;font-size:16px}.drawer--header>*{display:block;box-sizing:border-box;border-bottom:2px solid transparent;padding:15px 5px 13px;height:48px;flex:1 1 auto;color:#282c37;text-align:center;text-decoration:none;cursor:pointer}.drawer--header a{transition:background 100ms ease-in}.drawer--header a:focus,.drawer--header a:hover{outline:none;background:#cfd9e2;transition:background 200ms ease-out}.search{position:relative;margin-bottom:10px;flex:none}@media screen and (max-width: 415px){.auto-columns .search,.single-column .search{margin-bottom:0}}@media screen and (max-width: 630px){.auto-columns .search{font-size:16px}}.single-column .search{font-size:16px}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#444b5d;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}.drawer--account{padding:10px;color:#282c37;display:flex;align-items:center}.drawer--account a{color:inherit;text-decoration:none}.drawer--account .acct{display:block;color:#282c37;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;overflow:hidden}.drawer--results{background:#d9e1e8;overflow-x:hidden;overflow-y:auto}.drawer--results>header{color:#444b5d;background:#d3dce4;padding:15px;font-weight:500;font-size:16px;cursor:default}.drawer--results>header .fa{display:inline-block;margin-right:5px}.drawer--results>section{margin-bottom:5px}.drawer--results>section h5{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#444b5d}.drawer--results>section h5 .fa{display:inline-block;margin-right:5px}.drawer--results>section .account:last-child,.drawer--results>section>div:last-child .status{border-bottom:0}.drawer--results>section>.hashtag{display:block;padding:10px;color:#282c37;text-decoration:none}.drawer--results>section>.hashtag:hover,.drawer--results>section>.hashtag:active,.drawer--results>section>.hashtag:focus{color:#1f232b;text-decoration:underline}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#b0c0cf;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%}.drawer__inner.darker{background:#d9e1e8}.drawer__inner__mastodon{background:#b0c0cf url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}.drawer__inner__mastodon>.mastodon{display:block;width:100%;height:100%;border:none;cursor:inherit}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#b0c0cf;font-size:13px;text-align:left}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,.5)}.video-error-cover{align-items:center;background:#fff;color:#000;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#fff;color:#282c37;border:0;width:100%;height:100%}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{color:#17191f}.status__content>.media-spoiler{margin-top:15px}.media-spoiler.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:500}.media-gallery__gifv__label{display:block;position:absolute;color:#000;background:rgba(255,255,255,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{height:100%;display:flex;flex-direction:column}.media-gallery__audio span{text-align:center;color:#282c37;display:flex;height:100%;align-items:center}.media-gallery__audio span p{width:100%}.media-gallery__audio audio{width:100%}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%;height:110px}.media-gallery.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-gallery__item{border:none;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.full-width .media-gallery__item{border-radius:0}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item.letterbox{background:#000}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#282c37;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%;object-fit:contain}.media-gallery__item-thumbnail:not(.letterbox),.media-gallery__item-thumbnail img:not(.letterbox){height:100%;object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#fff}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%;display:flex;justify-content:center}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;width:100%;position:relative;z-index:1;object-fit:contain;user-select:none}.media-gallery__item-gifv-thumbnail:not(.letterbox){height:100%;object-fit:cover}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(255,255,255,.5);box-sizing:border-box;border:0;color:#000;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#282c37}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b90d9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#f2f5f7;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #ccd7e0;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(33,122,186,.5)}.audio-player__wave-placeholder{background-color:#a6b9c9}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#f2f5f7;border-top:1px solid #ccd7e0;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.detailed-status .video-player{width:100%;height:100%}.video-player.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.video-player video{max-width:100vw;max-height:80vh;z-index:1;position:relative}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#282c37;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#191b22}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons-bar .video-player__download__icon .fa,.video-player__buttons-bar .video-player__download__icon:active .fa,.video-player__buttons-bar .video-player__download__icon:hover .fa,.video-player__buttons-bar .video-player__download__icon:focus .fa{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#217aba}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#217aba}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.sensitive-info{display:flex;flex-direction:row;align-items:center;position:absolute;top:4px;left:4px;z-index:100}.sensitive-marker{margin:0 3px;border-radius:2px;padding:2px 6px;color:rgba(0,0,0,.8);background:rgba(255,255,255,.5);font-size:12px;line-height:18px;text-transform:uppercase;opacity:.9;transition:opacity .1s ease}.media-gallery:hover .sensitive-marker{opacity:1}.list-editor{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#b0c0cf;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#b0c0cf}.list-adder__lists{background:#b0c0cf;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #c0cdd9}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #393f4f}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#282c37}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#282c37;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#313543}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#3c99dc}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#3897db}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(255,255,255,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(40,44,55,.3);color:#000;border:1px solid #282c37;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(40,44,55,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#444b5d}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.glitch.local-settings{position:relative;display:flex;flex-direction:row;background:#282c37;color:#000;border-radius:8px;height:80vh;width:80vw;max-width:740px;max-height:450px;overflow:hidden}.glitch.local-settings label,.glitch.local-settings legend{display:block;font-size:14px}.glitch.local-settings .boolean label,.glitch.local-settings .radio_buttons label{position:relative;padding-left:28px;padding-top:3px}.glitch.local-settings .boolean label input,.glitch.local-settings .radio_buttons label input{position:absolute;left:0;top:0}.glitch.local-settings span.hint{display:block;color:#282c37}.glitch.local-settings h1{font-size:18px;font-weight:500;line-height:24px;margin-bottom:20px}.glitch.local-settings h2{font-size:15px;font-weight:500;line-height:20px;margin-top:20px;margin-bottom:10px}.glitch.local-settings__navigation__item{display:block;padding:15px 20px;color:inherit;background:#17191f;border-bottom:1px #282c37 solid;cursor:pointer;text-decoration:none;outline:none;transition:background .3s}.glitch.local-settings__navigation__item .text-icon-button{color:inherit;transition:unset}.glitch.local-settings__navigation__item:hover{background:#282c37}.glitch.local-settings__navigation__item.active{background:#2b90d9;color:#000}.glitch.local-settings__navigation__item.close,.glitch.local-settings__navigation__item.close:hover{background:#df405a;color:#000}.glitch.local-settings__navigation{background:#17191f;width:212px;font-size:15px;line-height:20px;overflow-y:auto}.glitch.local-settings__page{display:block;flex:auto;padding:15px 20px 15px 20px;width:360px;overflow-y:auto}.glitch.local-settings__page__item{margin-bottom:2px}.glitch.local-settings__page__item.string,.glitch.local-settings__page__item.radio_buttons{margin-top:10px;margin-bottom:10px}@media screen and (max-width: 630px){.glitch.local-settings__navigation{width:40px;flex-shrink:0}.glitch.local-settings__navigation__item{padding:10px}.glitch.local-settings__navigation__item span:last-of-type{display:none}}.error-boundary{color:#000;font-size:15px;line-height:20px}.error-boundary h1{font-size:26px;line-height:36px;font-weight:400;margin-bottom:8px}.error-boundary a{color:#000;text-decoration:underline}.error-boundary ul{list-style:disc;margin-left:0;padding-left:1em}.error-boundary textarea.web_app_crash-stacktrace{width:100%;resize:none;white-space:pre;font-family:monospace,monospace}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.compose-panel .search__icon .fa{top:15px}.compose-panel .drawer--account{flex:0 1 48px}.compose-panel .flex-spacer{background:transparent}.compose-panel .composer{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #ccd7e0;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px;min-height:50px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{padding-top:15px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#3897db;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#227dbe}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.search{margin-bottom:10px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.poll{margin-top:16px;font-size:14px}.poll ul,.e-content .poll ul{margin:0;list-style:none}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#c9d3e1}.poll__chart.leading{background:#2b90d9}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;display:block;outline:0;font-family:inherit;background:#fff;border:1px solid #fff;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#2b90d9}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#444b5d}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#444b5d;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(68,75,93,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #fff}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #fff;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{width:100%;flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#606984;border-color:#606984;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#fff}.muted .poll{color:#444b5d}.muted .poll__chart{background:rgba(201,211,225,.2)}.muted .poll__chart.leading{background:rgba(43,144,217,.2)}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:sans-serif,sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#282c37}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#282c37}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#282c37}.rich-formatting em{font-style:italic;color:#282c37}.rich-formatting code{font-size:.85em;background:#f2f5f7;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:sans-serif,sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#282c37}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #ccd7e0;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #ccd7e0;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#282c37}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#444b5d}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#e6ebf0;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:sans-serif,sans-serif;font-size:16px;line-height:28px;color:#000;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#282c37}.information-board__section strong{font-family:sans-serif,sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#f2f5f7;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#282c37;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #ccd7e0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#3d4455}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;width:80px;height:80px;background-size:80px 80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px;border-radius:8%;background-position:50%;background-clip:padding-box}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#000;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#282c37}.landing-page p,.landing-page li{font-family:sans-serif,sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#282c37}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.landing-page h1{font-family:sans-serif,sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h1 small{font-family:sans-serif,sans-serif;display:block;font-size:18px;font-weight:400;color:#131419}.landing-page h2{font-family:sans-serif,sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h3{font-family:sans-serif,sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h4{font-family:sans-serif,sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h5{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h6{font-family:sans-serif,sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(60,80,99,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#d9e1e8;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#131419}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px;width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#d9e1e8;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#282c37}.landing-page__short-description h1{font-weight:500;color:#000;margin-bottom:0}.landing-page__short-description h1 small{color:#282c37}.landing-page__short-description h1 small span{color:#282c37}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#000;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#d9e1e8;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:sans-serif,sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#282c37}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#282c37}.landing .simple_form p.lead{color:#282c37;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #c0cdd9}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#444b5d}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #d9e1e8;text-align:left;background:#e6ebf0}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #d9e1e8;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#d9e1e8}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#d9e1e8;border-top:1px solid #f2f5f7;border-bottom:1px solid #f2f5f7}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #f2f5f7}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #f2f5f7}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:monospace,monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#282c37;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#000}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #f2f5f7;background:#d9e1e8;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #f2f5f7;border-top:0;background:#d9e1e8}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #f2f5f7;border-top:0;background:#e6ebf0}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #f2f5f7}}.batch-table__row:hover{background:#dfe6ec}.batch-table__row:nth-child(even){background:#d9e1e8}.batch-table__row:nth-child(even):hover{background:#d3dce4}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#282c37;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #f2f5f7;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #f2f5f7}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#d9e1e8;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#c0cdd9;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#000;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#282c37;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#b3c3d1}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#282c37;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#000;background-color:#e9eef2;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#dfe6ec;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#e6ebf0;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#000;background-color:#2b90d9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#2482c7}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #c0cdd9;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#282c37;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#282c37;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#282c37;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #c0cdd9}.admin-wrapper .content h6{font-size:16px;color:#282c37;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#000;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#000;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:none}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#282c37;margin-bottom:20px}.admin-wrapper .content>p strong{color:#000;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(60,80,99,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #ccd7e0;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b90d9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#282c37}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#444b5d;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#282c37;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #d9e1e8}.filters .filter-subset a:hover{color:#000;border-bottom:2px solid #c9d4de}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b90d9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#282c37}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#d9e1e8;color:#282c37;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#444b5d}.log-entry__extras{background:#c6d2dc;border-radius:0 0 4px 4px;padding:10px;color:#282c37;font-family:monospace,monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#444b5d}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#79bd9a}.log-entry__icon__overlay.negative{background:#c1203b}.log-entry__icon__overlay.neutral{background:#2b90d9}.log-entry a,.log-entry .username,.log-entry .target{color:#282c37;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#c1203b}.log-entry .diff-neutral{color:#282c37}.log-entry .diff-new{color:#79bd9a}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#282c37}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#c1203b}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b90d9}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#c1203b}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#282c37}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#444b5d}.report-card{background:#d9e1e8;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#282c37;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#17191f}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #e6ebf0}.report-card__summary__item:hover{background:#d3dce4}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#282c37}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#444b5d;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#282c37}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#c0cdd9;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#217aba}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#000}.center-text{text-align:center}.emojione[title=\":wind_blowing_face:\"],.emojione[title=\":white_small_square:\"],.emojione[title=\":white_medium_square:\"],.emojione[title=\":white_medium_small_square:\"],.emojione[title=\":white_large_square:\"],.emojione[title=\":white_circle:\"],.emojione[title=\":waxing_crescent_moon:\"],.emojione[title=\":waving_white_flag:\"],.emojione[title=\":waning_gibbous_moon:\"],.emojione[title=\":waning_crescent_moon:\"],.emojione[title=\":volleyball:\"],.emojione[title=\":thought_balloon:\"],.emojione[title=\":speech_balloon:\"],.emojione[title=\":speaker:\"],.emojione[title=\":sound:\"],.emojione[title=\":snow_cloud:\"],.emojione[title=\":skull_and_crossbones:\"],.emojione[title=\":skull:\"],.emojione[title=\":sheep:\"],.emojione[title=\":rooster:\"],.emojione[title=\":rice_ball:\"],.emojione[title=\":rice:\"],.emojione[title=\":ram:\"],.emojione[title=\":rain_cloud:\"],.emojione[title=\":page_with_curl:\"],.emojione[title=\":mute:\"],.emojione[title=\":moon:\"],.emojione[title=\":loud_sound:\"],.emojione[title=\":lightning:\"],.emojione[title=\":last_quarter_moon_with_face:\"],.emojione[title=\":last_quarter_moon:\"],.emojione[title=\":ice_skate:\"],.emojione[title=\":grey_question:\"],.emojione[title=\":grey_exclamation:\"],.emojione[title=\":goat:\"],.emojione[title=\":ghost:\"],.emojione[title=\":full_moon_with_face:\"],.emojione[title=\":full_moon:\"],.emojione[title=\":fish_cake:\"],.emojione[title=\":first_quarter_moon_with_face:\"],.emojione[title=\":first_quarter_moon:\"],.emojione[title=\":eyes:\"],.emojione[title=\":dove_of_peace:\"],.emojione[title=\":dash:\"],.emojione[title=\":crescent_moon:\"],.emojione[title=\":cloud:\"],.emojione[title=\":chicken:\"],.emojione[title=\":chains:\"],.emojione[title=\":baseball:\"],.emojione[title=\":alien:\"]{filter:drop-shadow(1px 1px 0 #000000) drop-shadow(-1px 1px 0 #000000) drop-shadow(1px -1px 0 #000000) drop-shadow(-1px -1px 0 #000000)}.hicolor-privacy-icons .status__visibility-icon.fa-globe,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-globe{color:#1976d2}.hicolor-privacy-icons .status__visibility-icon.fa-unlock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-unlock{color:#388e3c}.hicolor-privacy-icons .status__visibility-icon.fa-lock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-lock{color:#ffa000}.hicolor-privacy-icons .status__visibility-icon.fa-envelope,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-envelope{color:#d32f2f}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .composer--publisher{text-align:left}body.rtl .boost-modal__status-time,body.rtl .favourite-modal__status-time{float:left}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .setting-meta__label{float:left}body.rtl .status__avatar{margin-left:10px;margin-right:0;left:auto;right:10px}body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:58px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left;text-align:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(249, 250, 251, 0), #f9fafb)}body.rtl .simple_form select{background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#ccd7e0;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#c0cdd9}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#000;font-family:sans-serif,sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#282c37;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#282c37;font-weight:500;text-decoration:none}.glitch.local-settings{background:#d9e1e8}.glitch.local-settings__navigation{background:#f2f5f7}.glitch.local-settings__navigation__item{background:#f2f5f7}.glitch.local-settings__navigation__item:hover{background:#d9e1e8}.notification__dismiss-overlay .wrappy{box-shadow:unset}.notification__dismiss-overlay .ckbox{text-shadow:unset}.status.status-direct:not(.read){background:#f2f5f7;border-bottom-color:#fff}.status.status-direct:not(.read).collapsed>.status__content:after{background:linear-gradient(rgba(242, 245, 247, 0), #f2f5f7)}.focusable:focus.status.status-direct:not(.read){background:#e6ebf0}.focusable:focus.status.status-direct:not(.read).collapsed>.status__content:after{background:linear-gradient(rgba(230, 235, 240, 0), #e6ebf0)}.column>.scrollable{background:#fff}.status.collapsed .status__content:after{background:linear-gradient(rgba(255, 255, 255, 0), white)}.drawer__inner{background:#d9e1e8}.drawer__inner__mastodon{background:#d9e1e8 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto !important}.drawer__inner__mastodon .mastodon{filter:contrast(75%) brightness(75%) !important}.status__content .status__content__spoiler-link{background:#7a96ae}.status__content .status__content__spoiler-link:hover{background:#6a89a5;text-decoration:none}.media-spoiler,.video-player__spoiler,.account-gallery__item a{background:#d9e1e8}.dropdown-menu{background:#d9e1e8}.dropdown-menu__arrow.left{border-left-color:#d9e1e8}.dropdown-menu__arrow.top{border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{border-right-color:#d9e1e8}.dropdown-menu__item a{background:#d9e1e8;color:#282c37}.composer .composer--spoiler input,.composer .compose-form__autosuggest-wrapper textarea{color:#0f151a}.composer .composer--spoiler input:disabled,.composer .compose-form__autosuggest-wrapper textarea:disabled{background:#e6e6e6}.composer .composer--spoiler input::placeholder,.composer .compose-form__autosuggest-wrapper textarea::placeholder{color:#232f39}.composer .composer--options-wrapper{background:#b9c8d5}.composer .composer--options>hr{display:none}.composer .composer--options--dropdown--content--item{color:#9baec8}.composer .composer--options--dropdown--content--item strong{color:#9baec8}.composer--upload_form--actions .icon-button{color:#ededed}.composer--upload_form--actions .icon-button:active,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:hover{color:#fff}.composer--upload_form--item>div input{color:#ededed}.composer--upload_form--item>div input::placeholder{color:#e6e6e6}.dropdown-menu__separator{border-bottom-color:#b3c3d1}.status__content a,.reply-indicator__content a{color:#2b90d9}.emoji-mart-bar{border-color:#e6ebf0}.emoji-mart-bar:first-child{background:#b9c8d5}.emoji-mart-search input{background:rgba(217,225,232,.3);border-color:#d9e1e8}.autosuggest-textarea__suggestions{background:#b9c8d5}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#e6ebf0}.react-toggle-track{background:#282c37}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background:#131419}.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background:#56a7e1}.actions-modal,.boost-modal,.doodle-modal,.confirmation-modal,.mute-modal,.block-modal,.report-modal,.embed-modal,.error-modal,.onboarding-modal,.report-modal__comment .setting-text__wrapper,.report-modal__comment .setting-text{background:#fff;border:1px solid #c0cdd9}.report-modal__comment{border-right-color:#c0cdd9}.report-modal__container{border-top-color:#c0cdd9}.boost-modal__action-bar,.doodle-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar,.onboarding-modal__paginator,.error-modal__footer{background:#ecf0f4}.boost-modal__action-bar .onboarding-modal__nav:hover,.doodle-modal__action-bar .onboarding-modal__nav:hover,.boost-modal__action-bar .onboarding-modal__nav:focus,.doodle-modal__action-bar .onboarding-modal__nav:focus,.boost-modal__action-bar .onboarding-modal__nav:active,.doodle-modal__action-bar .onboarding-modal__nav:active,.boost-modal__action-bar .error-modal__nav:hover,.doodle-modal__action-bar .error-modal__nav:hover,.boost-modal__action-bar .error-modal__nav:focus,.doodle-modal__action-bar .error-modal__nav:focus,.boost-modal__action-bar .error-modal__nav:active,.doodle-modal__action-bar .error-modal__nav:active,.confirmation-modal__action-bar .onboarding-modal__nav:hover,.confirmation-modal__action-bar .onboarding-modal__nav:focus,.confirmation-modal__action-bar .onboarding-modal__nav:active,.confirmation-modal__action-bar .error-modal__nav:hover,.confirmation-modal__action-bar .error-modal__nav:focus,.confirmation-modal__action-bar .error-modal__nav:active,.mute-modal__action-bar .onboarding-modal__nav:hover,.mute-modal__action-bar .onboarding-modal__nav:focus,.mute-modal__action-bar .onboarding-modal__nav:active,.mute-modal__action-bar .error-modal__nav:hover,.mute-modal__action-bar .error-modal__nav:focus,.mute-modal__action-bar .error-modal__nav:active,.block-modal__action-bar .onboarding-modal__nav:hover,.block-modal__action-bar .onboarding-modal__nav:focus,.block-modal__action-bar .onboarding-modal__nav:active,.block-modal__action-bar .error-modal__nav:hover,.block-modal__action-bar .error-modal__nav:focus,.block-modal__action-bar .error-modal__nav:active,.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{background-color:#fff}.empty-column-indicator,.error-column{color:#364959}.activity-stream-tabs{background:#fff}.activity-stream-tabs a.active{color:#9baec8}.activity-stream .entry{background:#fff}.activity-stream .status.light .status__content{color:#000}.activity-stream .status.light .display-name strong{color:#000}.accounts-grid .account-grid-card .controls .icon-button{color:#282c37}.accounts-grid .account-grid-card .name a{color:#000}.accounts-grid .account-grid-card .username{color:#282c37}.accounts-grid .account-grid-card .account__header__content{color:#000}.button.logo-button{color:#fff}.button.logo-button svg{fill:#fff}.public-layout .header,.public-layout .public-account-header,.public-layout .public-account-bio{box-shadow:none}.public-layout .header{background:#b3c3d1}.public-layout .public-account-header__image{background:#b3c3d1}.public-layout .public-account-header__image::after{box-shadow:none}.public-layout .public-account-header__tabs__name h1,.public-layout .public-account-header__tabs__name h1 small{color:#fff}.account__section-headline a.active::after{border-color:transparent transparent #fff}.hero-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.moved-account-widget,.memoriam-widget,.activity-stream,.nothing-here,.directory__tag>a,.directory__tag>div{box-shadow:none}.audio-player .video-player__controls button,.audio-player .video-player__time-sep,.audio-player .video-player__time-current,.audio-player .video-player__time-total{color:#000}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n$white: #ffffff;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n$ui-base-color: $classic-secondary-color !default;\n$ui-base-lighter-color: darken($ui-base-color, 57%);\n$ui-highlight-color: $classic-highlight-color !default;\n$ui-primary-color: $classic-primary-color !default;\n$ui-secondary-color: $classic-base-color !default;\n\n$primary-text-color: $black !default;\n$darker-text-color: $classic-base-color !default;\n$dark-text-color: #444b5d;\n$action-button-color: #606984;\n\n$success-green: lighten(#3c754d, 8%);\n\n$base-overlay-background: $white !default;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: $classic-base-color !default;\n$light-text-color: #444b5d;\n\n$account-background-color: $white !default;\n\n//Invert darkened and lightened colors\n@function darken($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) + $amount);\n}\n\n@function lighten($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) - $amount);\n}\n\n$emojis-requiring-outlines: 'alien' 'baseball' 'chains' 'chicken' 'cloud' 'crescent_moon' 'dash' 'dove_of_peace' 'eyes' 'first_quarter_moon' 'first_quarter_moon_with_face' 'fish_cake' 'full_moon' 'full_moon_with_face' 'ghost' 'goat' 'grey_exclamation' 'grey_question' 'ice_skate' 'last_quarter_moon' 'last_quarter_moon_with_face' 'lightning' 'loud_sound' 'moon' 'mute' 'page_with_curl' 'rain_cloud' 'ram' 'rice' 'rice_ball' 'rooster' 'sheep' 'skull' 'skull_and_crossbones' 'snow_cloud' 'sound' 'speaker' 'speech_balloon' 'thought_balloon' 'volleyball' 'waning_crescent_moon' 'waning_gibbous_moon' 'waving_white_flag' 'waxing_crescent_moon' 'white_circle' 'white_large_square' 'white_medium_small_square' 'white_medium_square' 'white_small_square' 'wind_blowing_face';\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n @return '%23' + unquote($color)\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n @include avatar-size(40px);\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1/3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1/3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n @include avatar-size(120px);\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n @include avatar-radius();\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n","@mixin avatar-radius() {\n border-radius: $ui-avatar-border-size;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size:48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin single-column($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .single-column #{$parent} {\n @content;\n }\n}\n\n@mixin limited-single-column($media, $parent: '&') {\n .auto-columns #{$parent}, .single-column #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n}\n\n@mixin multi-columns($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .multi-columns #{$parent} {\n @content;\n }\n}\n\n@mixin fullwidth-gallery {\n &.full-width {\n margin-left: -14px;\n margin-right: -14px;\n width: inherit;\n max-width: none;\n height: 250px;\n border-radius: 0px;\n }\n}\n\n@mixin search-input() {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: none;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout() {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a; // Padua\n$error-red: #df405a; // Cerise\n$warning-red: #ff5050; // Sunset Orange\n$gold-star: #ca8f04; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: sans-serif !default;\n$font-display: sans-serif !default;\n$font-monospace: monospace !default;\n\n// Avatar border size (8% default, 100% for rounded avatars)\n$ui-avatar-border-size: 8%;\n\n// More variables\n$dismiss-overlay-width: 4rem;\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n padding: 8px 0;\n padding-bottom: 2px;\n margin: initial;\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n position: absolute;\n margin: initial;\n float: initial;\n width: auto;\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n\n// Styling from upstream's WebUI, as public pages use the same layout\n.embed,\n.public-layout {\n .status {\n .status__info {\n font-size: 15px;\n display: initial;\n }\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding: 6px 0;\n padding-right: 25px;\n margin: initial;\n\n .display-name strong {\n display: inline;\n }\n }\n\n .status__avatar {\n height: 48px;\n position: absolute;\n width: 48px;\n margin: initial;\n }\n }\n}\n\n.rtl {\n .embed,\n .public-layout {\n .status {\n padding-left: 10px;\n padding-right: 68px;\n\n .status__info .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .status__relative-time {\n float: left;\n }\n }\n }\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: darken($ui-highlight-color, 3%);\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n transition-property: background-color;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 7%);\n transition: all 200ms ease-out;\n transition-property: background-color;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n color: $darker-text-color;\n text-transform: none;\n background: transparent;\n padding: 3px 15px;\n border-radius: 4px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n transform-origin: 50% 0;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: 0;\n position: absolute;\n\n .fa.star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n a {\n color: inherit;\n text-decoration: inherit;\n }\n\n strong {\n height: 18px;\n font-size: 16px;\n font-weight: 500;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n span {\n display: block;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n > a:hover {\n strong {\n text-decoration: underline;\n }\n }\n\n &.inline {\n padding: 0;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n strong {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n\n span {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n }\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n ul {\n list-style: none;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.static-content {\n padding: 10px;\n padding-top: 20px;\n color: $dark-text-color;\n\n h1 {\n font-size: 16px;\n font-weight: 500;\n margin-bottom: 40px;\n text-align: center;\n }\n\n p {\n font-size: 13px;\n margin-bottom: 20px;\n }\n}\n\n.column,\n.drawer {\n flex: 1 1 100%;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @include multi-columns('screen and (min-width: 631px)') {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $ui-highlight-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n\n span.icon {\n margin-left: 0;\n display: inline;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.getting-started__wrapper,\n.getting_started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.getting-started__wrapper {\n position: relative;\n overflow-y: auto;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n background: $ui-base-color;\n flex: 1 0 auto;\n\n p {\n color: $secondary-text-color;\n }\n\n a {\n color: $dark-text-color;\n }\n\n &__panel {\n height: min-content;\n }\n\n &__panel,\n &__footer {\n padding: 10px;\n padding-top: 20px;\n flex: 0 1 auto;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n color: $darker-text-color;\n background: transparent;\n border: none;\n border-bottom: 2px solid $ui-primary-color;\n box-sizing: border-box;\n display: block;\n font-family: inherit;\n margin-bottom: 10px;\n padding: 7px 0;\n width: 100%;\n\n &:focus,\n &:active {\n color: $primary-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n font-size: 16px;\n }\n\n &.light {\n color: $inverted-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 27%);\n\n &:focus,\n &:active {\n color: $inverted-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.reduce-motion button.icon-button.disabled i.fa-retweet {\n color: darken($action-button-color, 13%);\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.missing-indicator {\n padding-top: 20px + 48px;\n}\n\n.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {\n border-top: 1px solid $ui-base-color;\n}\n\n.notification__dismiss-overlay {\n overflow: hidden;\n position: absolute;\n top: 0;\n right: 0;\n bottom: -1px;\n padding-left: 15px; // space for the box shadow to be visible\n\n z-index: 999;\n align-items: center;\n justify-content: flex-end;\n cursor: pointer;\n\n display: flex;\n\n .wrappy {\n width: $dismiss-overlay-width;\n align-self: stretch;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: lighten($ui-base-color, 8%);\n border-left: 1px solid lighten($ui-base-color, 20%);\n box-shadow: 0 0 5px black;\n border-bottom: 1px solid $ui-base-color;\n }\n\n .ckbox {\n border: 2px solid $ui-primary-color;\n border-radius: 2px;\n width: 30px;\n height: 30px;\n font-size: 20px;\n color: $darker-text-color;\n text-shadow: 0 0 5px black;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n &:focus {\n outline: 0 !important;\n\n .ckbox {\n box-shadow: 0 0 1px 1px $ui-highlight-color;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: flex;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n align-items: center;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label,\n.setting-radio__label,\n.setting-meta__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.setting-radio {\n display: block;\n line-height: 18px;\n}\n\n.setting-radio__label {\n margin-bottom: 0;\n}\n\n.column-settings__row legend {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-top: 10px;\n}\n\n.setting-radio__input {\n vertical-align: middle;\n}\n\n.setting-meta__label {\n float: right;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n transform-origin: center center;\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.pulse-loading {\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.loading-bar {\n background-color: $ui-highlight-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.icon-badge-wrapper {\n position: relative;\n}\n\n.icon-badge {\n position: absolute;\n display: block;\n right: -.25em;\n top: -.25em;\n background-color: $ui-highlight-color;\n border-radius: 50%;\n font-size: 75%;\n width: 1em;\n height: 1em;\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n .status__content {\n margin: 0;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.ui .flash-message {\n margin-top: 10px;\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 0;\n min-width: 75%;\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@import 'boost';\n@import 'accounts';\n@import 'domains';\n@import 'status';\n@import 'modal';\n@import 'composer';\n@import 'columns';\n@import 'regeneration_indicator';\n@import 'directory';\n@import 'search';\n@import 'emoji';\n@import 'doodle';\n@import 'drawer';\n@import 'media';\n@import 'sensitive';\n@import 'lists';\n@import 'emoji_picker';\n@import 'local_settings';\n@import 'error_boundary';\n@import 'single_column';\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant\nbutton.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant for use with DMs\n.status-direct button.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n",".account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n color: inherit;\n text-decoration: none;\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n\n &.small {\n border: none;\n padding: 0;\n\n & > .account__avatar-wrapper { margin: 0 8px 0 0 }\n\n & > .display-name {\n height: 24px;\n line-height: 24px;\n }\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius();\n position: relative;\n cursor: pointer;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & div {\n @include avatar-radius;\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\n.account__avatar-overlay {\n position: relative;\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius();\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius();\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__header__wrapper {\n flex: 0 0 auto;\n background: lighten($ui-base-color, 4%);\n}\n\n.account__disclaimer {\n padding: 10px;\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-left: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &:first-child {\n border-left: 0;\n }\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n abbr {\n color: $highlight-text-color;\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.notification__message {\n margin-left: 42px;\n padding: 8px 0 0 26px;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input();\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout();\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n",".domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n","@keyframes spring-flip-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-242.4deg);\n }\n\n 60% {\n transform: rotate(-158.35deg);\n }\n\n 90% {\n transform: rotate(-187.5deg);\n }\n\n 100% {\n transform: rotate(-180deg);\n }\n}\n\n@keyframes spring-flip-out {\n 0% {\n transform: rotate(-180deg);\n }\n\n 30% {\n transform: rotate(62.4deg);\n }\n\n 60% {\n transform: rotate(-21.635deg);\n }\n\n 90% {\n transform: rotate(7.5deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content {\n position: relative;\n margin: 10px 0;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n padding-top: 5px;\n\n &:focus {\n outline: 0;\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .status__content__text,\n .e-content {\n overflow: hidden;\n\n & > ul,\n & > ol {\n margin-bottom: 20px;\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 1.2em;\n }\n\n h2 {\n font-size: 1.1em;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $darker-text-color;\n color: $darker-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n sup {\n font-size: smaller;\n vertical-align: super;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n .status__content__spoiler {\n display: none;\n\n &.status__content__spoiler--visible {\n display: block;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n\n .link-origin-tag {\n color: $gold-star;\n font-size: 0.8em;\n }\n }\n\n .status__content__spoiler-link {\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: lighten($ui-base-color, 30%);\n border: none;\n color: $inverted-text-color;\n font-weight: 500;\n font-size: 11px;\n padding: 0 5px;\n text-transform: uppercase;\n line-height: inherit;\n cursor: pointer;\n vertical-align: bottom;\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n\n .status__content__spoiler-icon {\n display: inline-block;\n margin: 0 0 0 5px;\n border-left: 1px solid currentColor;\n padding: 0 0 0 4px;\n font-size: 16px;\n vertical-align: -2px;\n }\n}\n\n.notif-cleaning {\n .status,\n .notification-follow,\n .notification-follow-request {\n padding-right: ($dismiss-overlay-width + 0.5rem);\n }\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.notification-follow,\n.notification-follow-request {\n position: relative;\n\n // same like Status\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .account {\n border-bottom: 0 none;\n }\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n &.status.status-direct:not(.read) {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 10px 14px;\n position: relative;\n height: auto;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 28px; // 12px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $lighter-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $lighter-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n\n &.collapsed {\n background-position: center;\n background-size: cover;\n user-select: none;\n\n &.has-background::before {\n display: block;\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));\n pointer-events: none;\n content: \"\";\n }\n\n .display-name:hover .display-name__html {\n text-decoration: none;\n }\n\n .status__content {\n height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 0;\n\n &:after {\n content: \"\";\n position: absolute;\n top: 0; bottom: 0;\n left: 0; right: 0;\n background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));\n pointer-events: none;\n }\n \n a:hover {\n text-decoration: none;\n }\n }\n &:focus > .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));\n }\n &.status-direct:not(.read)> .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));\n }\n\n .notification__message {\n margin-bottom: 0;\n }\n\n .status__info .notification__message > span {\n white-space: nowrap;\n }\n }\n\n .notification__message {\n margin: -10px 0px 10px 0;\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time {\n display: inline-block;\n flex-grow: 1;\n color: $dark-text-color;\n font-size: 14px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.status__display-name {\n color: $dark-text-color;\n overflow: hidden;\n}\n\n.status__info__account .status__display-name {\n display: block;\n max-width: 100%;\n}\n\n.status__info {\n display: flex;\n justify-content: space-between;\n font-size: 15px;\n\n > span {\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .notification__message > span {\n word-wrap: break-word;\n }\n}\n\n.status__info__icons {\n display: flex;\n align-items: center;\n height: 1em;\n color: $action-button-color;\n\n .status__media-icon,\n .status__visibility-icon,\n .status__reply-icon {\n padding-left: 2px;\n padding-right: 2px;\n }\n\n .status__collapse-button.active > .fa-angle-double-up {\n transform: rotate(-180deg);\n }\n}\n\n.no-reduce-motion .status__collapse-button {\n &.activate {\n & > .fa-angle-double-up {\n animation: spring-flip-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-angle-double-up {\n animation: spring-flip-out 1s linear;\n }\n }\n}\n\n.status__info__account {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-top: -10px;\n margin-bottom: 10px;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\na.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\n.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n flex: none;\n margin: 0 10px 0 0;\n height: 48px;\n width: 48px;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a,\n .status__content__text {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-color, 29%);\n text-decoration: none;\n }\n }\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n\n a .fa, a:hover .fa {\n color: inherit;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n.status__wrapper--filtered__button {\n display: inline;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n font-size: inherit;\n line-height: inherit;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.onboarding-modal__pager {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 470px;\n\n .react-swipeable-view-container > div {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n user-select: text;\n }\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n@media screen and (max-width: 550px) {\n .onboarding-modal {\n width: 100%;\n height: 100%;\n border-radius: 0;\n }\n\n .onboarding-modal__pager {\n width: 100%;\n height: auto;\n max-width: none;\n max-height: none;\n flex: 1 1 auto;\n }\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.onboarding-modal__dots {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.onboarding-modal__dot {\n width: 14px;\n height: 14px;\n border-radius: 14px;\n background: darken($ui-secondary-color, 16%);\n margin: 0 3px;\n cursor: pointer;\n\n &:hover {\n background: darken($ui-secondary-color, 18%);\n }\n\n &.active {\n cursor: default;\n background: darken($ui-secondary-color, 24%);\n }\n}\n\n.onboarding-modal__page__wrapper {\n pointer-events: none;\n padding: 25px;\n padding-bottom: 0;\n\n &.onboarding-modal__page__wrapper--active {\n pointer-events: auto;\n }\n}\n\n.onboarding-modal__page {\n cursor: default;\n line-height: 21px;\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 20px;\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 4%);\n }\n }\n\n .navigation-bar a {\n color: inherit;\n }\n\n p {\n font-size: 16px;\n color: $lighter-text-color;\n margin-top: 10px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n background: $ui-base-color;\n color: $secondary-text-color;\n border-radius: 4px;\n font-size: 14px;\n padding: 3px 6px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.onboarding-modal__page__wrapper-0 {\n height: 100%;\n padding: 0;\n}\n\n.onboarding-modal__page-one {\n &__lead {\n padding: 65px;\n padding-top: 45px;\n padding-bottom: 0;\n margin-bottom: 10px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n margin-bottom: 8px;\n }\n\n p {\n margin-bottom: 0;\n }\n }\n\n &__extra {\n padding-right: 65px;\n padding-left: 185px;\n text-align: center;\n }\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboarding-modal__page-two,\n.onboarding-modal__page-three,\n.onboarding-modal__page-four,\n.onboarding-modal__page-five {\n p {\n text-align: left;\n }\n\n .figure {\n background: darken($ui-base-color, 8%);\n color: $secondary-text-color;\n margin-bottom: 20px;\n border-radius: 4px;\n padding: 10px;\n text-align: center;\n font-size: 14px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);\n\n .onboarding-modal__image {\n border-radius: 4px;\n margin-bottom: 10px;\n }\n\n &.non-interactive {\n pointer-events: none;\n text-align: left;\n }\n }\n}\n\n.onboarding-modal__page-four__columns {\n .row {\n display: flex;\n margin-bottom: 20px;\n\n & > div {\n flex: 1 1 0;\n margin: 0 10px;\n\n &:first-child {\n margin-left: 0;\n }\n\n &:last-child {\n margin-right: 0;\n }\n\n p {\n text-align: center;\n }\n }\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .column-header {\n color: $primary-text-color;\n }\n}\n\n@media screen and (max-width: 320px) and (max-height: 600px) {\n .onboarding-modal__page p {\n font-size: 14px;\n line-height: 20px;\n }\n\n .onboarding-modal__page-two .figure,\n .onboarding-modal__page-three .figure,\n .onboarding-modal__page-four .figure,\n .onboarding-modal__page-five .figure {\n font-size: 12px;\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .row {\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .column-header {\n padding: 5px;\n font-size: 12px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.favourite-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__display-name {\n display: flex;\n }\n\n .status__avatar {\n height: 48px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container,\n.favourite-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.favourite-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header,\n.favourite-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time,\n.favourite-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n strong {\n display: block;\n font-weight: 500;\n }\n\n max-height: 80vh;\n max-width: 80vw;\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n & > .react-toggle,\n & > .icon,\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__do_not_ask_again {\n padding-left: 20px;\n padding-right: 20px;\n padding-bottom: 10px;\n\n font-size: 14px;\n\n label, input {\n vertical-align: middle;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: 'mastodon-font-monospace', monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.filtered-status-info {\n text-align: start;\n\n .spoiler__text {\n margin-top: 20px;\n }\n\n .account {\n border-bottom: 0;\n }\n\n .account__display-name strong {\n color: $inverted-text-color;\n }\n\n .status__content__spoiler {\n display: none;\n\n &--visible {\n display: flex;\n }\n }\n\n ul {\n padding: 10px;\n margin-left: 12px;\n list-style: disc inside;\n }\n\n .filtered-status-edit-link {\n color: $action-button-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline\n }\n }\n}\n",".composer {\n padding: 10px;\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .composer--spoiler {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.composer--spoiler {\n height: 0;\n transform-origin: bottom;\n opacity: 0.0;\n\n &.composer--spoiler--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1.0;\n }\n\n input {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px;\n padding: 10px;\n width: 100%;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: vertical;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n }\n}\n\n.composer--warning {\n color: $inverted-text-color;\n margin-bottom: 15px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:active,\n &:focus,\n &:hover { text-decoration: none }\n }\n}\n\n.compose-form__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-left: 5px;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n}\n\n.composer--reply {\n margin: 0 0 10px;\n border-radius: 4px;\n padding: 10px;\n background: $ui-primary-color;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n\n & > header {\n margin-bottom: 5px;\n overflow: hidden;\n\n & > .account.small { color: $inverted-text-color; }\n\n & > .cancel {\n float: right;\n line-height: 24px;\n }\n }\n\n & > .content {\n position: relative;\n margin: 10px 0;\n padding: 0 12px;\n font-size: 14px;\n line-height: 20px;\n color: $inverted-text-color;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n white-space: pre-wrap;\n padding-top: 5px;\n overflow: hidden;\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 18px;\n }\n\n h2 {\n font-size: 16px;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $inverted-text-color;\n color: $inverted-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n\n a {\n color: $lighter-text-color;\n text-decoration: none;\n\n &:hover { text-decoration: underline }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span { text-decoration: underline }\n }\n }\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -5px 0 0;\n }\n}\n\n.emoji-picker-dropdown {\n position: absolute;\n right: 5px;\n top: 5px;\n\n ::-webkit-scrollbar-track:hover,\n ::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.autosuggest-input {\n position: relative;\n width: 100%;\n\n label {\n .autosuggest-textarea__textarea {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px 4px 0 0;\n padding: 10px 32px 0 10px;\n width: 100%;\n min-height: 100px;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: none;\n scrollbar-color: initial;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n &:disabled { background: $ui-secondary-color }\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n }\n}\n\n.composer--textarea--icons {\n display: block;\n position: absolute;\n top: 29px;\n right: 5px;\n bottom: 5px;\n overflow: hidden;\n\n & > .textarea_icon {\n display: block;\n margin: 2px 0 0 2px;\n width: 24px;\n height: 24px;\n color: $lighter-text-color;\n font-size: 18px;\n line-height: 24px;\n text-align: center;\n opacity: .8;\n }\n}\n\n.autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n}\n\n.autosuggest-textarea__suggestions {\n display: block;\n position: absolute;\n box-sizing: border-box;\n top: 100%;\n border-radius: 0 0 4px 4px;\n padding: 6px;\n width: 100%;\n color: $inverted-text-color;\n background: $ui-secondary-color;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n font-size: 14px;\n z-index: 99;\n display: none;\n}\n\n.autosuggest-textarea__suggestions--visible {\n display: block;\n}\n\n.autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected { background: darken($ui-secondary-color, 10%) }\n\n > .account,\n > .emoji,\n > .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n & > .account.small {\n .display-name {\n & > span { color: $lighter-text-color }\n }\n }\n}\n\n.composer--upload_form {\n overflow: hidden;\n\n & > .content {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n font-family: inherit;\n padding: 5px;\n overflow: hidden;\n }\n}\n\n.composer--upload_form--item {\n flex: 1 1 0;\n margin: 5px;\n min-width: 40%;\n\n & > div {\n position: relative;\n border-radius: 4px;\n height: 140px;\n width: 100%;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n overflow: hidden;\n\n textarea {\n display: block;\n position: absolute;\n box-sizing: border-box;\n bottom: 0;\n left: 0;\n margin: 0;\n border: 0;\n padding: 10px;\n width: 100%;\n color: $secondary-text-color;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n font-size: 14px;\n font-family: inherit;\n font-weight: 500;\n opacity: 0;\n z-index: 2;\n transition: opacity .1s ease;\n\n &:focus { color: $white }\n\n &::placeholder {\n opacity: 0.54;\n color: $secondary-text-color;\n }\n }\n\n & > .close { mix-blend-mode: difference }\n }\n\n &.active {\n & > div {\n textarea { opacity: 1 }\n }\n }\n}\n\n.composer--upload_form--actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $ui-secondary-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($ui-secondary-color, 4%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n}\n\n.composer--upload_form--progress {\n display: flex;\n padding: 10px;\n color: $darker-text-color;\n overflow: hidden;\n\n & > .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n & > .message {\n flex: 1 1 auto;\n\n & > span {\n display: block;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n & > .backdrop {\n position: relative;\n margin-top: 5px;\n border-radius: 6px;\n width: 100%;\n height: 6px;\n background: $ui-base-lighter-color;\n\n & > .tracker {\n position: absolute;\n top: 0;\n left: 0;\n height: 6px;\n border-radius: 6px;\n background: $ui-highlight-color;\n }\n }\n }\n}\n\n.compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n}\n\n.composer--options-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n height: 27px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n}\n\n.composer--options {\n display: flex;\n flex: 0 0 auto;\n\n & > * {\n display: inline-block;\n box-sizing: content-box;\n padding: 0 3px;\n height: 27px;\n line-height: 27px;\n vertical-align: bottom;\n }\n\n & > hr {\n display: inline-block;\n margin: 0 3px;\n border-width: 0 0 0 1px;\n border-style: none none none solid;\n border-color: transparent transparent transparent darken($simple-background-color, 24%);\n padding: 0;\n width: 0;\n height: 27px;\n background: transparent;\n }\n}\n\n.compose--counter-wrapper {\n align-self: center;\n margin-right: 4px;\n}\n\n.composer--options--dropdown {\n &.open {\n & > .value {\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n color: $primary-text-color;\n background: $ui-highlight-color;\n transition: none;\n }\n &.top {\n & > .value {\n border-radius: 0 0 4px 4px;\n box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);\n }\n }\n }\n}\n\n.composer--options--dropdown--content {\n position: absolute;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n background: $simple-background-color;\n overflow: hidden;\n transform-origin: 50% 0;\n}\n\n.composer--options--dropdown--content--item {\n display: flex;\n align-items: center;\n padding: 10px;\n color: $inverted-text-color;\n cursor: pointer;\n\n & > .content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n &:not(:first-child) { margin-left: 10px }\n\n strong {\n display: block;\n color: $inverted-text-color;\n font-weight: 500;\n }\n }\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n\n & > .content {\n color: $primary-text-color;\n\n strong { color: $primary-text-color }\n }\n }\n\n &.active:hover { background: lighten($ui-highlight-color, 4%) }\n}\n\n.composer--publisher {\n padding-top: 10px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n justify-content: flex-end;\n flex: 0 0 auto;\n\n & > .primary {\n display: inline-block;\n margin: 0;\n padding: 0 10px;\n text-align: center;\n }\n\n & > .side_arm {\n display: inline-block;\n margin: 0 2px;\n padding: 0;\n width: 36px;\n text-align: center;\n }\n\n &.over {\n & > .count { color: $warning-red }\n }\n}\n",".column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.column {\n overflow: hidden;\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n\n & > button {\n margin: 0;\n border: none;\n padding: 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n\n .wide .columns-area:not(.columns-area--mobile) & {\n flex: auto;\n min-width: 330px;\n max-width: 400px;\n }\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n margin-left: 0;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n\n // glitch - added focus ring for keyboard navigation\n &:focus {\n text-shadow: 0 0 4px darken($ui-highlight-color, 5%);\n }\n}\n\n.column-header__notif-cleaning-buttons {\n display: flex;\n align-items: stretch;\n justify-content: space-around;\n\n button {\n @extend .column-header__button;\n background: transparent;\n text-align: center;\n padding: 10px 0;\n white-space: pre-wrap;\n }\n\n b {\n font-weight: bold;\n }\n}\n\n// The notifs drawer with no padding to have more space for the buttons\n.column-header__collapsible-inner.nopad-drawer {\n padding: 0;\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n\n // notif cleaning drawer\n &.ncd {\n transition: none;\n &.collapsed {\n max-height: 0;\n opacity: 0.7;\n }\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.column-header__title {\n display: inline-block;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n// more fixes for the navbar-under mode\n@mixin fix-margins-for-navbar-under {\n .tabs-bar {\n margin-top: 0 !important;\n margin-bottom: -6px !important;\n }\n}\n\n.single-column.navbar-under {\n @include fix-margins-for-navbar-under;\n}\n\n.auto-columns.navbar-under {\n @media screen and (max-width: $no-gap-breakpoint) {\n @include fix-margins-for-navbar-under;\n }\n}\n\n.auto-columns.navbar-under .react-swipeable-view-container .columns-area,\n.single-column.navbar-under .react-swipeable-view-container .columns-area {\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 100% !important;\n }\n}\n\n.column-inline-form {\n padding: 7px 15px;\n padding-right: 5px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n margin-bottom: 6px;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 5px;\n }\n}\n",".regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n",".directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n",".search {\n position: relative;\n}\n\n.search__input {\n @include search-input();\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: color, transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(0deg);\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n padding: 15px 10px;\n font-size: 14px;\n font-weight: 500;\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n",null,".emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n","$doodleBg: #d9e1e8;\n.doodle-modal {\n @extend .boost-modal;\n width: unset;\n}\n\n.doodle-modal__container {\n background: $doodleBg;\n text-align: center;\n line-height: 0; // remove weird gap under canvas\n canvas {\n border: 5px solid $doodleBg;\n }\n}\n\n.doodle-modal__action-bar {\n @extend .boost-modal__action-bar;\n\n .filler {\n flex-grow: 1;\n margin: 0;\n padding: 0;\n }\n\n .doodle-toolbar {\n line-height: 1;\n\n display: flex;\n flex-direction: column;\n flex-grow: 0;\n justify-content: space-around;\n\n &.with-inputs {\n label {\n display: inline-block;\n width: 70px;\n text-align: right;\n margin-right: 2px;\n }\n\n input[type=\"number\"],input[type=\"text\"] {\n width: 40px;\n }\n span.val {\n display: inline-block;\n text-align: left;\n width: 50px;\n }\n }\n }\n\n .doodle-palette {\n padding-right: 0 !important;\n border: 1px solid black;\n line-height: .2rem;\n flex-grow: 0;\n background: white;\n\n button {\n appearance: none;\n width: 1rem;\n height: 1rem;\n margin: 0; padding: 0;\n text-align: center;\n color: black;\n text-shadow: 0 0 1px white;\n cursor: pointer;\n box-shadow: inset 0 0 1px rgba(white, .5);\n border: 1px solid black;\n outline-offset:-1px;\n\n &.foreground {\n outline: 1px dashed white;\n }\n\n &.background {\n outline: 1px dashed red;\n }\n\n &.foreground.background {\n outline: 1px dashed red;\n border-color: white;\n }\n }\n }\n}\n",".drawer {\n width: 300px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n padding: 10px 5px;\n flex: none;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n\n @include single-column('screen and (max-width: 630px)') { flex: auto }\n\n @include limited-single-column('screen and (max-width: 630px)') {\n &, &:first-child, &:last-child { padding: 0 }\n }\n\n .wide & {\n min-width: 300px;\n max-width: 400px;\n flex: 1 1 200px;\n }\n\n @include single-column('screen and (max-width: 630px)') {\n :root & { // Overrides `.wide` for single-column view\n flex: auto;\n width: 100%;\n min-width: 0;\n max-width: none;\n padding: 0;\n }\n }\n\n .react-swipeable-view-container & { height: 100% }\n}\n\n.drawer--header {\n display: flex;\n flex-direction: row;\n margin-bottom: 10px;\n flex: none;\n background: lighten($ui-base-color, 8%);\n font-size: 16px;\n\n & > * {\n display: block;\n box-sizing: border-box;\n border-bottom: 2px solid transparent;\n padding: 15px 5px 13px;\n height: 48px;\n flex: 1 1 auto;\n color: $darker-text-color;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n }\n\n a {\n transition: background 100ms ease-in;\n\n &:focus,\n &:hover {\n outline: none;\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.search {\n position: relative;\n margin-bottom: 10px;\n flex: none;\n\n @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n}\n\n.search-popout {\n @include search-popout();\n}\n\n.drawer--account {\n padding: 10px;\n color: $darker-text-color;\n display: flex;\n align-items: center;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n .acct {\n display: block;\n color: $secondary-text-color;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n overflow: hidden;\n}\n\n.drawer--results {\n background: $ui-base-color;\n overflow-x: hidden;\n overflow-y: auto;\n\n & > header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n & > section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n\n & > .hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n }\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n > .mastodon {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n cursor: inherit;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n",".video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n width: 100%;\n height: 100%;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 8%);\n }\n\n .status__content > & {\n margin-top: 15px; // Add margin when used bare for NSFW video player\n }\n @include fullwidth-gallery;\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 500;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n height: 100%;\n display: flex;\n flex-direction: column;\n\n span {\n text-align: center;\n color: $darker-text-color;\n display: flex;\n height: 100%;\n align-items: center;\n\n p {\n width: 100%;\n }\n }\n\n audio {\n width: 100%;\n }\n}\n\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n height: 110px;\n\n @include fullwidth-gallery;\n}\n\n.media-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n .full-width & {\n border-radius: 0;\n }\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n\n &.letterbox {\n background: $base-shadow-color;\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n object-fit: contain;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n display: flex;\n justify-content: center;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n width: 100%;\n position: relative;\n z-index: 1;\n object-fit: contain;\n user-select: none;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $white;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $ui-highlight-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n .detailed-status & {\n width: 100%;\n height: 100%;\n }\n\n @include fullwidth-gallery;\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n position: relative;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-shadow-color;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n\n .fa,\n &:active .fa,\n &:hover .fa,\n &:focus .fa {\n color: inherit;\n }\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n",".sensitive-info {\n display: flex;\n flex-direction: row;\n align-items: center;\n position: absolute;\n top: 4px;\n left: 4px;\n z-index: 100;\n}\n\n.sensitive-marker {\n margin: 0 3px;\n border-radius: 2px;\n padding: 2px 6px;\n color: rgba($primary-text-color, 0.8);\n background: rgba($base-overlay-background, 0.5);\n font-size: 12px;\n line-height: 18px;\n text-transform: uppercase;\n opacity: .9;\n transition: opacity .1s ease;\n\n .media-gallery:hover & { opacity: 1 }\n}\n",".list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n",".emoji-mart {\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: 0;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -3px;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: darken($ui-highlight-color, 3%);\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n",".glitch.local-settings {\n position: relative;\n display: flex;\n flex-direction: row;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n height: 80vh;\n width: 80vw;\n max-width: 740px;\n max-height: 450px;\n overflow: hidden;\n\n label, legend {\n display: block;\n font-size: 14px;\n }\n\n .boolean label, .radio_buttons label {\n position: relative;\n padding-left: 28px;\n padding-top: 3px;\n\n input {\n position: absolute;\n left: 0;\n top: 0;\n }\n }\n\n span.hint {\n display: block;\n color: $lighter-text-color;\n }\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n line-height: 24px;\n margin-bottom: 20px;\n }\n\n h2 {\n font-size: 15px;\n font-weight: 500;\n line-height: 20px;\n margin-top: 20px;\n margin-bottom: 10px;\n }\n}\n\n.glitch.local-settings__navigation__item {\n display: block;\n padding: 15px 20px;\n color: inherit;\n background: lighten($ui-secondary-color, 8%);\n border-bottom: 1px $ui-secondary-color solid;\n cursor: pointer;\n text-decoration: none;\n outline: none;\n transition: background .3s;\n\n .text-icon-button {\n color: inherit;\n transition: unset;\n }\n\n &:hover {\n background: $ui-secondary-color;\n }\n\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n\n &.close, &.close:hover {\n background: $error-value-color;\n color: $primary-text-color;\n }\n}\n\n.glitch.local-settings__navigation {\n background: lighten($ui-secondary-color, 8%);\n width: 212px;\n font-size: 15px;\n line-height: 20px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page {\n display: block;\n flex: auto;\n padding: 15px 20px 15px 20px;\n width: 360px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page__item {\n margin-bottom: 2px;\n}\n\n.glitch.local-settings__page__item.string,\n.glitch.local-settings__page__item.radio_buttons {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n@media screen and (max-width: 630px) {\n .glitch.local-settings__navigation {\n width: 40px;\n flex-shrink: 0;\n }\n\n .glitch.local-settings__navigation__item {\n padding: 10px;\n\n span:last-of-type {\n display: none;\n }\n }\n}\n",".error-boundary {\n color: $primary-text-color;\n font-size: 15px;\n line-height: 20px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n font-weight: 400;\n margin-bottom: 8px;\n }\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n }\n\n ul {\n list-style: disc;\n margin-left: 0;\n padding-left: 1em;\n }\n\n textarea.web_app_crash-stacktrace {\n width: 100%;\n resize: none;\n white-space: pre;\n font-family: $font-monospace, monospace;\n }\n}\n",".compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .drawer--account {\n flex: 0 1 48px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .composer {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px;\n min-height: 48px + 2px;\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n padding-top: 15px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .search {\n margin-bottom: 10px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n ul,\n .e-content & ul {\n margin: 0;\n list-style: none;\n }\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n display: block;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n width: 100%;\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n @include avatar-size(80px);\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n @include avatar-radius();\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n @include avatar-size(44px);\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: none;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n","$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;\n\n%emoji-outline {\n filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);\n}\n\n.emojione {\n @each $emoji in $emojis-requiring-outlines {\n &[title=':#{$emoji}:'] {\n @extend %emoji-outline;\n }\n }\n}\n\n.hicolor-privacy-icons {\n .status__visibility-icon.fa-globe,\n .composer--options--dropdown--content--item .fa-globe {\n color: #1976D2;\n }\n\n .status__visibility-icon.fa-unlock,\n .composer--options--dropdown--content--item .fa-unlock {\n color: #388E3C;\n }\n\n .status__visibility-icon.fa-lock,\n .composer--options--dropdown--content--item .fa-lock {\n color: #FFA000;\n }\n\n .status__visibility-icon.fa-envelope,\n .composer--options--dropdown--content--item .fa-envelope {\n color: #D32F2F;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .composer--publisher {\n text-align: left;\n }\n\n .boost-modal__status-time,\n .favourite-modal__status-time {\n float: left;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .setting-meta__label {\n float: left;\n }\n\n .status__avatar {\n margin-left: 10px;\n margin-right: 0;\n\n // Those are used for public pages\n left: auto;\n right: 10px;\n }\n\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 58px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n text-align: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","// Notes!\n// Sass color functions, \"darken\" and \"lighten\" are automatically replaced.\n\n.glitch.local-settings {\n background: $ui-base-color;\n\n &__navigation {\n background: darken($ui-base-color, 8%);\n }\n\n &__navigation__item {\n background: darken($ui-base-color, 8%);\n\n &:hover {\n background: $ui-base-color;\n }\n }\n}\n\n.notification__dismiss-overlay {\n .wrappy {\n box-shadow: unset;\n }\n\n .ckbox {\n text-shadow: unset;\n }\n}\n\n.status.status-direct:not(.read) {\n background: darken($ui-base-color, 8%);\n border-bottom-color: darken($ui-base-color, 12%);\n\n &.collapsed> .status__content:after {\n background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1));\n }\n}\n\n.focusable:focus.status.status-direct:not(.read) {\n background: darken($ui-base-color, 4%);\n\n &.collapsed> .status__content:after {\n background: linear-gradient(rgba(darken($ui-base-color, 4%), 0), rgba(darken($ui-base-color, 4%), 1));\n }\n}\n\n// Change columns' default background colors\n.column {\n > .scrollable {\n background: darken($ui-base-color, 13%);\n }\n}\n\n.status.collapsed .status__content:after {\n background: linear-gradient(rgba(darken($ui-base-color, 13%), 0), rgba(darken($ui-base-color, 13%), 1));\n}\n\n.drawer__inner {\n background: $ui-base-color;\n}\n\n.drawer__inner__mastodon {\n background: $ui-base-color url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto !important;\n\n .mastodon {\n filter: contrast(75%) brightness(75%) !important;\n }\n}\n\n// Change the default appearance of the content warning button\n.status__content {\n\n .status__content__spoiler-link {\n\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 35%);\n text-decoration: none;\n }\n\n }\n\n}\n\n// Change the background colors of media and video spoilers\n.media-spoiler,\n.video-player__spoiler,\n.account-gallery__item a {\n background: $ui-base-color;\n}\n\n// Change the colors used in the dropdown menu\n.dropdown-menu {\n background: $ui-base-color;\n}\n\n.dropdown-menu__arrow {\n\n &.left {\n border-left-color: $ui-base-color;\n }\n\n &.top {\n border-top-color: $ui-base-color;\n }\n\n &.bottom {\n border-bottom-color: $ui-base-color;\n }\n\n &.right {\n border-right-color: $ui-base-color;\n }\n\n}\n\n.dropdown-menu__item {\n a {\n background: $ui-base-color;\n color: $ui-secondary-color;\n }\n}\n\n// Change the default color of several parts of the compose form\n.composer {\n\n .composer--spoiler input, .compose-form__autosuggest-wrapper textarea {\n color: lighten($ui-base-color, 80%);\n\n &:disabled { background: lighten($simple-background-color, 10%) }\n\n &::placeholder {\n color: lighten($ui-base-color, 70%);\n }\n }\n\n .composer--options-wrapper {\n background: lighten($ui-base-color, 10%);\n }\n\n .composer--options > hr {\n display: none;\n }\n\n .composer--options--dropdown--content--item {\n color: $ui-primary-color;\n \n strong {\n color: $ui-primary-color;\n }\n\n }\n\n}\n\n.composer--upload_form--actions .icon-button {\n color: lighten($white, 7%);\n\n &:active,\n &:focus,\n &:hover {\n color: $white;\n }\n}\n\n.composer--upload_form--item > div input {\n color: lighten($white, 7%);\n\n &::placeholder {\n color: lighten($white, 10%);\n }\n}\n\n.dropdown-menu__separator {\n border-bottom-color: lighten($ui-base-color, 12%);\n}\n\n.status__content,\n.reply-indicator__content {\n a {\n color: $highlight-text-color;\n }\n}\n\n.emoji-mart-bar {\n border-color: darken($ui-base-color, 4%);\n\n &:first-child {\n background: lighten($ui-base-color, 10%);\n }\n}\n\n.emoji-mart-search input {\n background: rgba($ui-base-color, 0.3);\n border-color: $ui-base-color;\n}\n\n.autosuggest-textarea__suggestions {\n background: lighten($ui-base-color, 10%)\n}\n\n.autosuggest-textarea__suggestions__item {\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-base-color, 4%);\n }\n}\n\n.react-toggle-track {\n background: $ui-secondary-color;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: lighten($ui-secondary-color, 10%);\n}\n\n.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: darken($ui-highlight-color, 10%);\n}\n\n// Change the background colors of modals\n.actions-modal,\n.boost-modal,\n.confirmation-modal,\n.mute-modal,\n.block-modal,\n.report-modal,\n.embed-modal,\n.error-modal,\n.onboarding-modal,\n.report-modal__comment .setting-text__wrapper,\n.report-modal__comment .setting-text {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.report-modal__comment {\n border-right-color: lighten($ui-base-color, 8%);\n}\n\n.report-modal__container {\n border-top-color: lighten($ui-base-color, 8%);\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar,\n.onboarding-modal__paginator,\n.error-modal__footer {\n background: darken($ui-base-color, 6%);\n\n .onboarding-modal__nav,\n .error-modal__nav {\n &:hover,\n &:focus,\n &:active {\n background-color: darken($ui-base-color, 12%);\n }\n }\n}\n\n// Change the default color used for the text in an empty column or on the error column\n.empty-column-indicator,\n.error-column {\n color: lighten($ui-base-color, 60%);\n}\n\n// Change the default colors used on some parts of the profile pages\n.activity-stream-tabs {\n\n background: $account-background-color;\n\n a {\n &.active {\n color: $ui-primary-color;\n }\n }\n\n}\n\n.activity-stream {\n\n .entry {\n background: $account-background-color;\n }\n\n .status.light {\n\n .status__content {\n color: $primary-text-color;\n }\n\n .display-name {\n strong {\n color: $primary-text-color;\n }\n }\n\n }\n\n}\n\n.accounts-grid {\n .account-grid-card {\n\n .controls {\n .icon-button {\n color: $ui-secondary-color;\n }\n }\n\n .name {\n a {\n color: $primary-text-color;\n }\n }\n\n .username {\n color: $ui-secondary-color;\n }\n\n .account__header__content {\n color: $primary-text-color;\n }\n\n }\n}\n\n.button.logo-button {\n color: $white;\n\n svg {\n fill: $white;\n }\n}\n\n.public-layout {\n .header,\n .public-account-header,\n .public-account-bio {\n box-shadow: none;\n }\n\n .header {\n background: lighten($ui-base-color, 12%);\n }\n\n .public-account-header {\n &__image {\n background: lighten($ui-base-color, 12%);\n\n &::after {\n box-shadow: none;\n }\n }\n\n &__tabs {\n &__name {\n h1,\n h1 small {\n color: $white;\n }\n }\n }\n }\n}\n\n.account__section-headline a.active::after {\n border-color: transparent transparent $white;\n}\n\n.hero-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.moved-account-widget,\n.memoriam-widget,\n.activity-stream,\n.nothing-here,\n.directory__tag > a,\n.directory__tag > div {\n box-shadow: none;\n}\n\n.audio-player .video-player__controls button,\n.audio-player .video-player__time-sep,\n.audio-player .video-player__time-current,\n.audio-player .video-player__time-total {\n color: $primary-text-color;\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/flavours/glitch/styles/reset.scss","webpack:///./app/javascript/flavours/glitch/styles/mastodon-light/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/basics.scss","webpack:///./app/javascript/flavours/glitch/styles/containers.scss","webpack:///./app/javascript/flavours/glitch/styles/_mixins.scss","webpack:///./app/javascript/flavours/glitch/styles/variables.scss","webpack:///./app/javascript/flavours/glitch/styles/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/footer.scss","webpack:///./app/javascript/flavours/glitch/styles/compact_header.scss","webpack:///./app/javascript/flavours/glitch/styles/widgets.scss","webpack:///./app/javascript/flavours/glitch/styles/forms.scss","webpack:///./app/javascript/flavours/glitch/styles/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/statuses.scss","webpack:///./app/javascript/flavours/glitch/styles/components/index.scss","webpack:///./app/javascript/flavours/glitch/styles/components/boost.scss","webpack:///./app/javascript/flavours/glitch/styles/components/accounts.scss","webpack:///./app/javascript/flavours/glitch/styles/components/domains.scss","webpack:///./app/javascript/flavours/glitch/styles/components/status.scss","webpack:///./app/javascript/flavours/glitch/styles/components/modal.scss","webpack:///./app/javascript/flavours/glitch/styles/components/composer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/columns.scss","webpack:///./app/javascript/flavours/glitch/styles/components/regeneration_indicator.scss","webpack:///./app/javascript/flavours/glitch/styles/components/directory.scss","webpack:///./app/javascript/flavours/glitch/styles/components/search.scss","webpack:///","webpack:///./app/javascript/flavours/glitch/styles/components/emoji.scss","webpack:///./app/javascript/flavours/glitch/styles/components/doodle.scss","webpack:///./app/javascript/flavours/glitch/styles/components/drawer.scss","webpack:///./app/javascript/flavours/glitch/styles/components/media.scss","webpack:///./app/javascript/flavours/glitch/styles/components/sensitive.scss","webpack:///./app/javascript/flavours/glitch/styles/components/lists.scss","webpack:///./app/javascript/flavours/glitch/styles/components/emoji_picker.scss","webpack:///./app/javascript/flavours/glitch/styles/components/local_settings.scss","webpack:///./app/javascript/flavours/glitch/styles/components/error_boundary.scss","webpack:///./app/javascript/flavours/glitch/styles/components/single_column.scss","webpack:///./app/javascript/flavours/glitch/styles/components/announcements.scss","webpack:///./app/javascript/flavours/glitch/styles/polls.scss","webpack:///./app/javascript/flavours/glitch/styles/about.scss","webpack:///./app/javascript/flavours/glitch/styles/tables.scss","webpack:///./app/javascript/flavours/glitch/styles/admin.scss","webpack:///./app/javascript/flavours/glitch/styles/accessibility.scss","webpack:///./app/javascript/flavours/glitch/styles/rtl.scss","webpack:///./app/javascript/flavours/glitch/styles/dashboard.scss","webpack:///./app/javascript/flavours/glitch/styles/mastodon-light/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,0CACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,8BACA,CC3EwB,iEDkF1B,kBClF0B,4BDsF1B,sBACE,MEtFF,sBACE,mBACA,eACA,iBACA,gBACA,WDXM,kCCaN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,sIAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDjDwB,kBCqDxB,iBACE,kBACA,0BAEA,iBACE,YAIJ,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDpFiB,mBAEK,WCqFtB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,wBAEA,aACE,gBACA,WACA,YACA,kBACA,uBAGF,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,6BAKF,YAEE,WACA,mBACA,uBACA,oBACA,yEAKF,gBAEE,+EAKF,WAEE,gBCrJJ,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFtBI,YEwBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF/BE,qBEiCF,UACA,kBACA,iBACA,uBACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAKA,UACqB,sCC3EvB,iBD4EE,6BAEA,UACE,YACA,cACA,SACA,kBACA,iBE5BkB,wBD9DtB,4BACA,uBD8FA,aACE,cF9FiB,wBEgGjB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UF3UA,qCE8UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF7Ve,mBE+Vf,kBACA,uHAEA,yBAGE,WFxWA,qCE4WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFvbe,8CE4bjB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,CAEA,WACqB,yCCzgB3B,kBD0gBM,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,iBE7dc,wBD9DtB,4BACA,qCD+hBI,2CAvCF,YAwCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAKA,UACqB,sCCtiB7B,CDuiBQ,sBACA,wDAEA,QACE,kBACA,iBErfY,wBD9DtB,4BACA,2DDsjBQ,mDAbF,YAcI,sCAKN,2CApEF,eAqEI,sCAGF,2CAxEF,cAyEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WFjlBF,gBEmlBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WF3lBJ,gBE6lBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aFhnBS,oDEunBf,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cF5oBW,aE8oBX,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BF9qBS,wEEorBT,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WF1sBJ,uBE4sBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cF7uBa,uDEgvBb,oBACE,cFjvBW,qBEmvBX,aACA,gBACA,8DAEA,eACE,WF3vBJ,qCEiwBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aE3yBU,8DFizBV,mBACA,WFpzBE,qFEwzBJ,YAEE,eACA,cFxzBe,2CE4zBjB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBFt3BkB,+IEy3BhB,kBAGE,WGl4BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,eChBJ,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,yBCrBF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cP9Fe,6BOiGf,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cACA,gBACA,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cRjBe,wBQqBjB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBLPI,uBKUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBTfwB,aSiBtB,0BACA,eACA,cTrBiB,iBSuBjB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aTrDiB,qBSuDf,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cT5EiB,+BSgFnB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aTzGmB,aS8GrB,YACE,kBACA,mBT9GwB,mCSgHxB,qBAGF,YACE,kBACA,0BACA,kBACA,cTzHmB,mBS2HnB,iBAGF,eACE,eACA,cThImB,iBSkInB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cT3IiB,0BS+InB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cTxJiB,qBS0JjB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBTxKwB,mCS0KxB,cT5KmB,gBS8KnB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cTvNe,8DS6NjB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eLlPM,CKoPN,cACA,cTlPmB,mBSoPnB,+BANA,iBACA,CLlPM,kCKgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UTlQM,eSoQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cTxQiB,qCS4QnB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBTpR0B,kBSsRxB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBTjSoB,kBSmSpB,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBTjTsB,eSmTpB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WTpUE,mBSsUF,gBACA,uBACA,wBAEA,aTvUe,0BS2Uf,aACE,gBACA,eACA,eACA,cT/Ua,yFSqVf,UTxVE,+BS+VJ,aACE,YACA,uDAGF,oBT9VsB,eSoW1B,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cTjZiB,gBSmZjB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WT5aI,8BS+aJ,aACE,cT7ae,gBS+af,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aT9gBmB,iCS6gBrB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cThiBsB,4JSmiBtB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WT/jBI,gCSikBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MC/kBJ,+BACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WVjDA,cUmDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aVhEoB,0BUkElB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aVlGmB,sBUqGjB,aVlGsB,yBUsGtB,iBACE,kBACA,gBACA,wBAIJ,aACE,eACA,eACA,qBAGF,kBACE,cVvHiB,iCU0HjB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WV1JA,gBU4JA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WVhLE,cUkLF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WVtME,cUwMF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WV1RI,cU4RJ,WACA,2CAKE,mBACE,eACA,WVpSA,qBUsSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WVpUI,cUsUJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBN1VY,oLM8VZ,iBACE,4WAGF,oBV9VsB,mBUiWpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBV/YsB,WANlB,eUwZJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBV/aoB,gGUmbpB,kBNpbQ,kHMubN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WVxcI,cU0cJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cVtdY,oBUwdZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,oEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,iCACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UVthBF,aUgiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cVriBmB,kBUuiBnB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cN/iBY,sBMmjBd,mCACE,+BACA,cNpjBQ,kBMwjBV,oBACE,cVzjBiB,qBU2jBjB,wBAEA,UVhkBI,0BUkkBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gCACA,mBV5kBsB,WALlB,eUolBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aVtnBmB,qBUwnBjB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aVvoBwB,yBUyoBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cV1pBmB,oCU6pBnB,cACE,mBACA,kBACA,4CAGF,aVnqBmB,gBUqqBjB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBN5rBM,YM8rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cVxsBmB,WU0sBnB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WVzuBI,qCU2uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UVjvBI,0BUmvBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cV7xBmB,0BUgyBnB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WV7yBI,kBU+yBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aNtzBc,0SMg0BZ,+BACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gCACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBN12Bc,gBM42BZ,2BAEA,kBN92BY,gBMg3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCj7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,CAEA,UACqB,sCRpDzB,gBQqDI,wBAEA,UACE,YACA,cACA,SACA,kBACA,iBPLgB,wBD9DtB,4BACA,mBQoEM,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WXhFA,gBWkFA,gBACA,uBACA,+BAGF,aACE,eACA,cXtFa,gBWwFb,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WX9GI,gBWgHJ,qBACA,iBACA,qBACA,sBAGF,ePrHM,oBOuHJ,WXxHI,eW0HJ,cACA,kBAGF,cACE,uCAGF,wBAEE,cXjIiB,oBWqInB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,WACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBX1K0B,mCW4KxB,cXxJiB,eW0JjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cX7MmB,mCW+MnB,mCACA,6DAEA,aPnNc,sCOqNZ,kCACA,qDAGF,aACE,oCACA,gCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cX5PiB,gCW8PjB,6BAGF,aACE,cXlQiB,4BWsQnB,aXnQwB,qBWqQtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aPtRY,gBOwRV,0CAGF,aP3RY,wCOgSd,eACE,wCAIJ,UACE,0BAIA,aXzSmB,4BW4SjB,aX5SiB,qBW8Sf,qGAEA,yBAGE,iCAIJ,UX1TI,gBW4TF,wBAIJ,eACE,kBClUJ,kCACE,kBACA,gBACA,mBACA,qCAEA,iBANF,eAOI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBZrBwB,6GYwBtB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBZtEwB,WANlB,oBY+EN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UZzFI,gFY6FN,kBAGE,qNAKA,kBZjGoB,4IYyGpB,kBR1GQ,qCQiHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAKA,cACA,iBACA,mBACA,mFAGF,iBACE,eACA,WACA,WACA,WACA,qMAGF,eAGE,mEASF,cACE,gBACA,qFAGF,aZ/Jc,YYiKZ,eACA,WACA,eACA,gBACA,+GAGF,aACE,eACA,CACA,sBACA,eACA,yJAEA,cACE,uEAIJ,WACE,kBACA,WACA,eACA,iDAQF,iBACE,mBACA,yHAEA,iBACE,gBACA,+FAGF,UACE,oCAMR,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,WC9OJ,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cbTwB,SaWxB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,abvBsB,eayBpB,SAIJ,wBACE,YACA,kBACA,sBACA,WbrCM,eauCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,qCACA,mBACA,WACA,4CAEA,wBAGE,4BACA,qCACA,sBAGF,eACE,mFAEA,wBTnEQ,gBSuEN,kBAIJ,wBb1EsB,ea4EpB,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,Ub9FM,mBAIgB,qGa8FpB,wBAGE,8BAIJ,kBbhGsB,2GamGpB,wBAGE,0BAIJ,cACE,iBACA,YACA,cbnHiB,oBaqHjB,uBACA,iBACA,kBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,cAIJ,oBACE,UACA,cbjIoB,SamIpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,sCACA,4BACA,2CACA,oBAGF,oCACE,uBAGF,aACE,6BACA,eACA,qBAGF,abxKwB,gCa4KxB,QACE,uEAGF,mBAGE,uBAGF,abzLmB,sFa4LjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,abzMsB,uCa4MpB,aACE,wBAKN,sBACE,8BACA,qBACA,kBACA,YACA,8BAEA,6BACE,mBAKN,ablOqB,SaoOnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,UACE,6BACA,eACA,0BAGF,abhQwB,qCaoQxB,QACE,sFAGF,mBAGE,gBAIJ,iBACE,uBACA,YAGF,WACE,cACA,qBACA,QACA,SACA,kBACA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,uCAIJ,MACE,kBACA,CT/SU,sESsTZ,aTtTY,uBS0TZ,aT3Tc,4DSiUV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UbtVM,0BawVJ,eAIJ,aACE,eACA,gBACA,uBACA,mBACA,iBAEA,aACE,wBACA,sBAIA,cACA,gBAKA,yCAPF,WACE,CAEA,gBACA,uBACA,gBACA,mBAWA,CAVA,mBAGF,aACE,CACA,cAKA,8BAIA,yBACE,sBAIJ,SACE,YACA,eACA,iBACA,uBACA,mBACA,gBACA,CAME,sDAGF,cACE,YACA,kBACA,oBACA,qBAKN,eACE,wBAGF,cACE,eAGF,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cTxX4B,eAEC,0DSyX3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cThZ4B,eAEC,WSiZ3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,WAIJ,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBbzdqB,ca2dnB,kBACA,uCACA,mBAEA,eACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0Bb7eiB,2BaifnB,WACE,iBACA,uBACA,yBbpfiB,8BawfnB,QACE,iBACA,uBACA,4Bb3fiB,6Ba+fnB,SACE,gBACA,2BACA,2BblgBiB,wBawgBnB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBb9gBiB,WAHb,gBaohBJ,uBACA,mBACA,yFAEA,kBblhBsB,cAHL,Ua0hBf,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBbxiBiB,ca0iBjB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBbjkBiB,WAHb,gBaukBJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBbzkBsB,cAHL,iBamlBrB,qBACE,iBAIA,sBACA,cb5kBgB,oBa+kBhB,cACE,gBACA,mBACA,kBACA,mBAGF,cACE,mBACA,iBAIJ,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,Wb5pBM,qBa8pBN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCVxoBA,6GADF,kBUgpBI,4BACA,kHV5oBJ,kBU2oBI,4BACA,wBAIJ,+BACE,cb/qBsB,sBamrBxB,eACE,aACA,2BAGF,aACE,eACA,kBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBbpsBsB,yBassBtB,gBACA,kBACA,eACA,gBACA,iBACA,WbjtBI,mDastBR,oBACE,aAGF,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,gBAIJ,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,gDACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBbtxBwB,qCawxBxB,sEAGF,wBACE,4CAGF,wBb9xB0B,+EakyB1B,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,sBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBb11BwB,yDa81B1B,kBb/1B0B,2Baq2B1B,iBACE,gBACA,cAGF,aACE,kBAGF,kBb92B0B,cag3BxB,oBAEA,abp3BmB,oBaw3BnB,ab32BgB,yBa+2BhB,0BACE,CADF,uBACE,CADF,kBACE,kDAKA,sBACA,cACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,abj4Bc,eam4BZ,0DAEA,abr4BY,0Bau4BV,sDAIJ,oBACE,cbz5Be,sMa45Bf,yBAGE,0BAKN,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cb56Be,aa86Bf,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,abv8Be,qBa88BrB,oBACE,kBACA,eACA,iBACA,gBACA,mBbj9BwB,gBam9BxB,iBACA,qBAGF,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,ab9+BqB,uBag/BnB,CACA,WACA,CADA,+BACA,sBACA,cACA,oBACA,mBACA,cACA,WACA,0CAEA,Ub7/BM,4BAMkB,qCGkBtB,yDADF,cU6+BE,sBAGF,UbvgCM,gCaygCJ,sDAEA,Ub3gCI,4BAMkB,mDa6gC1B,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAIJ,uBACE,2BACA,gDAGF,abphCsB,6BashCpB,uDAGF,abpiC0B,yDawiC1B,aACE,YAGF,aACE,cbniCgB,6BaqiChB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,oBAGF,gBACE,qEAGF,4BACE,gCAGF,eACE,kBACA,MACA,QACA,YACA,kBACA,YAEA,mBACA,yBACA,eACA,aAEA,wCAEA,UT/hCsB,mBSiiCpB,aACA,sBACA,mBACA,uBACA,mBACA,8BACA,wBACA,gCACA,uCAGF,wBACE,kBACA,WACA,YACA,eACA,cbhnCiB,yBaknCjB,aACA,uBACA,mBACA,sCAGF,mBACE,6CAEA,8BACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,oBAGF,ab/nCkB,eaioChB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,6BAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,YACE,SACA,QACA,WACA,YACA,mBACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,8BACA,kBACA,iBACA,Wb5yCE,gBa8yCF,eACA,+LAMA,6BACE,mEAKF,6BACE,iBAMR,aACE,iBACA,mEAGF,abp0CqB,qBaw0CnB,mBACA,gBACA,sBACA,gBAGF,aACE,iBACA,uBAGF,eACE,8BAGF,abv1CqB,eay1CnB,cACA,gBACA,gBACA,uBAGF,qBACE,sBAGF,WACE,8BAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA3BF,qBAGF,GACE,kBACE,+BACA,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,iBAIJ,0DACE,CADF,kDACE,cAGF,kBACE,8BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBbx6CwB,kCa06CxB,uBAGF,MACE,aACA,mBACA,uBACA,cbn7CmB,eaq7CnB,gBACA,0BACA,kBACA,qCAGF,SACE,oBACA,CADA,WACA,cAGF,wBb77C0B,Wa+7CxB,kBACA,MACA,OACA,aACA,qBAGF,iBACE,aAGF,iBACE,cACA,aACA,WACA,yBb98CwB,kBag9CxB,cACA,UACA,WACA,eAGF,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBb1+CsB,kBa4+CtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cbngDe,kBaqgDf,+BAGF,abxgDiB,ea0gDf,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UbrhDE,qBauhDA,oHAEA,yBAGE,yCAKN,QACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UbjjDI,oBa0jDN,kBACA,cACA,2BAGF,eACE,UAGF,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cb3kDiB,gBa6kDjB,gBAEA,ab5kDsB,0Ba8kDpB,sBAEA,oBACE,gBAIJ,qBACE,4BAKN,GACE,cACA,eACA,WARI,mBAKN,GACE,cACA,eACA,2CCrmDF,u+KACE,uCAEA,u+KACE,CAOA,8MAMF,okBACE,UClBJ,YACE,gCACA,cACA,qBACA,iCAEA,aACE,cACA,cfJiB,gBeMjB,qBACA,eACA,gBAGF,WACE,UACA,yCAEA,8CAEA,WACE,iBACA,mBAKN,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,gBX0BwB,wBD9DtB,4BACA,kBYqCA,eACA,yBAEA,oBACE,sBACA,iBACA,4BZ3CF,eYiDE,2DAHF,gBXesB,wBD9DtB,4BACA,CYgDE,iBAOE,CANF,+BZjDF,UYqDI,CACA,qBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WfjEE,6BemEF,gBACA,eACA,0BAKN,iBACE,WACqB,sCZpErB,+BANA,UY8EuB,sCZxEvB,gEYsEA,gBXfsB,wBD9DtB,4BYyFE,CZlFF,iCANA,UYmFuB,sCZ7EvB,kBY+EE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,2BAGF,aACE,mBACA,sBAGF,YACE,cf1FgB,6Be6FhB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,4BAGF,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,8BACA,eACA,oCACA,uCAEA,aACE,kCAGF,+BACE,gCAGF,aACE,yBACA,eACA,cfpKiB,kCewKnB,aACE,eACA,gBACA,Wf9KI,CemLA,2NADF,eACE,gCAKN,aflLwB,oBeuL1B,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,wBAGF,gBACE,qBACA,eACA,cf5MmB,ee8MnB,kBACA,4BAEA,af9MwB,6BekNxB,aACE,gBACA,uBACA,iBAIJ,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,afhPqB,eekPnB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SZ5MF,sBACA,WACA,YACA,gBACA,oBACA,mBHrDwB,cAFL,eG0DnB,SACA,+EYsMI,aACE,CZvMN,qEYsMI,aACE,CZvMN,yEYsMI,aACE,CZvMN,gEYsMI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,af3Qc,iBe6QZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,afjTiB,0HesTjB,cAEE,gBACA,cf5SY,kZe+SZ,aAGE,gEAIJ,wBACE,iDAGF,eXzUI,kBDkEN,CAEA,eACA,cH7CiB,uCG+CjB,UYoQI,mBfzUe,oDGuEnB,wBACE,cHlDe,eGoDf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WH3FI,sDegVJ,WACE,mDAGF,UfpVI,kBesVF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UfvWQ,kBeyWN,cACA,mBACA,sBf1WM,yBe4WN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,6BAIJ,YACE,eACA,gBACA,wBAGF,WACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cf/ZiB,eeiajB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,afxaiB,qWe2af,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,sBAQR,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cfhdc,Cemdd,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,2BAIJ,afzfqB,ee2fnB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WfhnBA,gBeknBA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cftnBW,gBewnBX,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,Wf7oBE,gDeipBJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aX3pBU,yBWiqBd,cACE,gCAEA,cACE,cfpqBe,eesqBf,kCAEA,oBACE,cfzqBa,qBe2qBb,iBACA,gBACA,yCAEA,eACE,WfnrBF,SgBDR,YACE,gCACA,8BAEA,aACE,cACA,WhBLI,qBgBOJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,mCCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,EDGF,0BCrBF,GACE,sBACE,KAGF,2BACE,KAGF,4BACE,KAGF,2BACE,IAGF,yBACE,qCAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,EAtBA,2BAIJ,GACE,yBACE,KAGF,yBACE,KAGF,4BACE,KAGF,wBACE,IAGF,sBACE,gCAIJ,cACE,kBAGF,iBACE,cACA,eACA,iBACA,qBACA,gBACA,iBACA,gBACA,wBAEA,SACE,4BAGF,UACE,YACA,gBACA,sBAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,qEAGF,kBACE,qBACA,sGAEA,eACE,qEAIJ,eAEE,qJAEA,kBAEE,mXAGF,eACE,mBACA,qJAGF,eACE,gBACA,2EAGF,eACE,+NAGF,eACE,2FAGF,iBACE,8BACA,cjB5Ge,mBiB8Gf,qHAEA,eACE,2JAIJ,eACE,mJAGF,iBACE,6EAGF,iBACE,eACA,6EAGF,iBACE,qBACA,qJAGF,eACE,6JAEA,QACE,2EAIJ,oBACE,2EAGF,uBACE,oBAIJ,ab9Ic,qBagJZ,0BAEA,yBACE,8BAEA,aACE,kCAKF,oBACE,uCAEA,yBACE,wBAKN,ajBjKc,4CiBsKhB,YACE,8EAEA,aACE,mCAIJ,aACE,oDAEA,ab5LQ,ea8LN,CAKF,sDAEA,kBAEE,gCAKN,oBACE,kBACA,mBACA,YACA,WjBrNM,gBiBuNN,eACA,cACA,yBACA,oBACA,eACA,sBACA,sCAEA,kBACE,qBACA,+DAGF,oBACE,iBACA,sBACA,kBACA,eACA,oBACA,2GAKF,oBAGE,4BAIJ,ajBtOkB,SiBwOhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,mDAGF,iBAEE,gCAGA,qEAEA,eACE,kBAKF,SACE,mBACA,kDAEA,kBACE,wDAEA,sBACE,iFAIJ,kBAEE,SAKN,iBACE,kBACA,YACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QAPF,kBAUI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,ajBtUiB,qCiB0UjB,UjB7UI,6BiBiVJ,ajBxTe,CAzBX,kEiByVJ,UjBzVI,kCiB4VF,ajBtVoB,gEiB0VpB,UjBhWE,mBAIgB,sEiBgWhB,kBACE,mBAMR,uBACE,sBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,yCAEA,aACE,kBACA,OACA,QACA,MACA,SACA,6FACA,oBACA,WACA,2DAGF,oBACE,oCAGF,WACE,gBACA,uBACA,cACA,0CAEA,UACE,kBACA,MACA,gBACA,gEACA,oBACA,4CAGF,oBACE,gDAGJ,uDACE,mEAEF,uDACE,0CAGF,eACE,6DAGF,kBACE,gCAIJ,mBACE,+CAKF,sBACE,qEAEA,aACE,wBAKN,oBACE,YACA,CjBpagB,ciBsahB,iBACA,mBACA,CACA,sBACA,8CANA,ajBpagB,CiBwahB,eAOA,8CAGF,aACE,eACA,eAGF,YACE,8BACA,eACA,oBAEA,sBACE,gBACA,2CAGF,oBACE,sBAIJ,YACE,mBACA,WACA,cjBxcoB,iIiB2cpB,gBAGE,kBACA,0EAGF,yBACE,yEAMA,0CACE,CADF,kCACE,2EAKF,2CACE,CADF,mCACE,wBAKN,YACE,mBACA,2BACA,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,mBACA,iBACA,cjBrhBgB,CiBuhBhB,iBACA,eACA,kBACA,+CAEA,ajB5hBgB,uBiBgiBhB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cjBxjBgB,4BiB8jBtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cjBlnBgB,eiBonBhB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,6JAGF,oBAME,4DAKA,UjBzqBM,kBiB+qBN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,ajBzsBqB,ciB2sBnB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WjB3tBI,kCiBguBR,UACE,kBACA,iBAGF,SACE,kBACA,YACA,WACA,CjBztBgB,8IiBouBhB,ajBpuBgB,wBiBwuBhB,UACE,wCAGF,kBjBnvBsB,WAThB,8CiBgwBJ,kBACE,qBACA,+DAOJ,yBACE,cAIJ,YACE,eACA,yBACA,kBACA,cjBlwBgB,gBiBowBhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cjB3yBe,uBiB6yBf,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UjBvzBE,yBiB8zBJ,cACE,kBACA,YACA,+DAGF,aACE,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACE,YACA,SACA,2BAIF,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cjBl2BmB,gBiBo2BnB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,ajBh3BqB,oBiBo3BrB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,kBAGF,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cjB57Bc,iBiB87Bd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cjB19BY,gBiB49BZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,ajB7+Bc,oCiBm/BlB,cACE,cACA,SACA,uBACA,UACA,kBACA,oBACA,oFAEA,yBAEE,6BC/gCJ,kBACE,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,8BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,6CAGF,kBlBtCqB,WAHb,kBkB8CN,gBACA,aACA,sBACA,0BAGF,WACE,WACA,gBACA,iBACA,8DAEA,UACE,YACA,sBACA,aACA,sBACA,mBACA,uBACA,aACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAIJ,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,qCAGF,kBACE,UACE,YACA,gBACA,0BAGF,UACE,YACA,eACA,gBACA,cACA,oDAIJ,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,alBhImB,SkBmIjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,ClBpJE,wyEkB2JF,UAGE,sBAMR,sBACE,yBAGF,aACE,aACA,mBACA,uBACA,wBAGF,UACE,YACA,mBACA,mBACA,aACA,eACA,8BAEA,kBACE,+BAGF,cACE,mBACA,kCAIJ,mBACE,CACA,mBACA,0EAEA,mBACE,yBAIJ,cACE,iBACA,4BAEA,cACE,gBACA,WlBjNI,mBkBmNJ,2BAGF,alBhNwB,kGkBmNtB,aAGE,2CAIJ,aACE,2BAGF,cACE,clBlOiB,gBkBoOjB,mBACA,sCAEA,eACE,kCAGF,eACE,mBlB1OoB,cAFL,kBkB+Of,eACA,gBACA,CAII,2NADF,eACE,oCAOV,WACE,UACA,mCAME,mBACA,mBACA,sCAEA,cACE,iBACA,kBACA,qCAGF,eACE,oCAIJ,kBACE,mBACA,kBACA,eAIJ,iBACE,eACA,mBACA,sBAEA,eACE,WlBnSI,kBkBqSJ,yBACA,eACA,qBAGF,kBlBrSwB,cAFL,gBkB0SjB,aACA,kBACA,6HAQF,eACE,qJAGF,kBACE,clBzTiB,mBkB2TjB,kBACA,aACA,kBACA,eACA,sCACA,yPAEA,iBACE,mBACA,qNAGF,mBACE,gBACA,4CAMJ,YACE,mBACA,gDAEA,UACE,cACA,4DAEA,aACE,2DAGF,cACE,kDAGF,iBACE,uDAIJ,eACE,sDAIJ,UlB5WM,2DkBiXR,0BACE,cACE,iBACA,qJAGF,cAIE,mBACA,4CAGF,kBACE,sDAGF,WACE,eACA,mBAIJ,oBACE,eACA,gBACA,iBACA,uHAGF,kBAOE,WlBvZM,kBkByZN,gBACA,eACA,YACA,kBACA,sBACA,+SAEA,alBhZgB,YkBkZd,eACA,WACA,eACA,gBACA,uSAGF,YACE,uPAGF,WACE,WACA,+WAGF,aACE,wBAKF,edvbM,CJEa,gBkBwbjB,oBACA,iEd3bI,2BJEa,qDkBicrB,iBAEE,aACA,qEAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,kKAIJ,YAKE,8BACA,mBlBldmB,akBodnB,iBACA,0LAEA,aACE,iBACA,clBzdiB,mBkB2djB,kNAGF,aACE,6DAIJ,cAEE,yDAGF,WAEE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,alBthBwB,qCkB0hBxB,oDAZF,eAaI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UlB1jBI,gBICA,ac4jBJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,ed5kBI,yBc8kBF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UlB7lBE,oBkB+lBA,eACA,gBd/lBA,+CcomBJ,YACE,8BACA,mBACA,4CAIJ,aACE,WlB7mBI,ekB+mBJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UlBxnBI,ekB0nBF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eAWE,eACA,wBAXA,eACE,iBACA,uBAGF,aACE,gBACA,2CAMF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UlBzqBE,akB2qBA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBlBprBgB,WANlB,iJkBisBA,iBAGE,oMAUR,aACE,iIAIJ,4BAIE,clBptBmB,ekBstBnB,gBACA,6cAEA,aAGE,6BACA,uCAIJ,iBACE,mBACA,oBACA,eAEA,yFAEA,qBACE,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UlBnxBI,CkBqxBF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,YACA,aACA,gDACA,mBlBzzBoB,WALlB,ekBi0BF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,cAKN,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBd12BM,yDc62BN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBdr3BI,uBcy3BN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UlBv5BI,ekBy5BF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,wBAKN,gBACE,sCAEA,eACE,gCAGF,eACE,qDAGF,UlB57BM,iDkBg8BN,YACE,0DAEA,YACE,0BAIJ,YACE,iBACA,uBACA,kDAGF,alB77BoB,qBkB+7BlB,wDAEA,yBACE,WCp9BN,YACE,kCAEA,iBACE,MACA,QACA,oIAEA,mCAEE,oBAKN,cACE,uBACA,eACA,gBACA,cnBfmB,4CmBkBnB,afjBY,sCesBd,2CACE,oBAGF,QACE,wBACA,UACA,+CAEA,WACE,mBACA,UACA,0BAGF,aACE,sBACA,SACA,YACA,kBACA,aACA,WACA,UACA,WnBjDI,gBICA,eemDJ,oBACA,gBACA,qDAEA,anBxCc,CmBsCd,2CAEA,anBxCc,CmBsCd,+CAEA,anBxCc,CmBsCd,sCAEA,anBxCc,gCmB4Cd,8ChB/CA,uCADF,cgBiD4D,0ChB5C5D,cgB4C4D,oBAI9D,UnBjEQ,mBmBmEN,mBnB/DsB,oCmBiEtB,iBACA,kBACA,eACA,gBACA,sBAEA,anBxEmB,gBmB0EjB,0BACA,mFAEA,oBAEU,iCAKZ,mBACA,eAEA,gBACA,wCAEA,anBvFwB,sDmB2FxB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,gBACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBnB7GsB,qCmBoH1B,eACE,kBACA,aACA,mBnBzHsB,gBmB2HtB,gBACA,cACA,yBAEA,iBACE,gBACA,wCAEA,UnBvII,iCmByIJ,WACE,iBACA,2BAIJ,iBACE,cACA,CACA,cACA,iBACA,WnBpJI,qBmBsJJ,gBACA,iBACA,qBACA,mBACA,gBACA,gGAEA,kBACE,qBACA,iIAEA,eACE,kJAIJ,eACE,mBACA,2DAGF,eACE,eACA,8BAGF,cACE,wFAGF,eACE,sCAGF,iBACE,2BACA,WnB1LE,mBmB4LF,mDAEA,eACE,8DAIJ,eACE,0DAGF,iBACE,+BAGF,iBACE,eACA,2DAGF,eACE,+DAEA,QACE,8BAIJ,oBACE,8BAGF,uBACE,6BAGF,anB7NiB,qBmB+Nf,mCAEA,oEAGE,oBACE,gDAEA,qDAMR,UACE,YACA,gBACA,uDAIJ,iBAEE,WACA,mIAGE,aACE,sBACA,SACA,YACA,0BACA,yBACA,WACA,iBACA,UACA,WnBtQE,gBICA,eewQF,oBACA,YACA,qBACA,yLAEA,anB9PY,CmB4PZ,sKAEA,anB9PY,CmB4PZ,8KAEA,anB9PY,CmB4PZ,4JAEA,anB9PY,yKmBkQZ,SACE,qJAGF,kBnBnRe,+ImBoRf,8ChB1QF,8JADF,cgB4Q8D,kKhBvQ9D,cgBuQ8D,qChBhQ5D,8TADF,sBgBoQM,gBACA,6BAMR,aACE,kBACA,SACA,UACA,WACA,gBACA,2CAEA,aACE,mBACA,WACA,YACA,cnB3SiB,emB6SjB,iBACA,kBACA,WACA,4CAIJ,iBACE,SACA,oCAGF,aACE,kBACA,sBACA,SACA,0BACA,YACA,WACA,WnBnUM,mBAGa,sCmBmUnB,eACA,WACA,aACA,6CAGF,aACE,0CAGF,YACE,eACA,kBACA,iMAEA,kBAGa,iKAEb,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,+DAGF,6BACE,qEAEA,aACE,gBACA,uBACA,mBACA,sEAGF,eACE,qEAGF,aACE,iBACA,gBACA,uBACA,mBACA,4EAMA,anB3Xe,wBmBgYrB,eACE,iCAEA,YACE,mBACA,eACA,oBACA,YACA,gBACA,8BAIJ,UACE,WACA,cACA,kCAEA,iBACE,kBACA,aACA,WACA,sBfzZI,wBe2ZJ,sBACA,4BACA,gBACA,2CAEA,aACE,kBACA,sBACA,SACA,OACA,SACA,SACA,aACA,WACA,cnBtae,gFmBwaf,eACA,oBACA,gBACA,UACA,UACA,4BACA,iDAEA,UflbE,sEeobF,WACE,cnBnba,CIFb,4DeobF,WACE,cnBnba,CIFb,gEeobF,WACE,cnBnba,CIFb,uDeobF,WACE,cnBnba,yCmBwbjB,2EAKE,0CAKN,iFACE,aACA,uBACA,8BACA,UACA,4BACA,8CAEA,aACE,cnB3ciB,emB6cjB,gBACA,aACA,oBACA,2JAEA,aAGE,wCAIJ,SACE,kCAIJ,YACE,aACA,cnBhemB,gBmBkenB,sCAEA,cACE,kBACA,2CAGF,aACE,gDAEA,aACE,eACA,gBACA,yBACA,qDAGF,iBACE,eACA,kBACA,WACA,WACA,mBnBlfkB,8DmBqflB,iBACE,MACA,OACA,WACA,kBACA,mBnB7fkB,0BmBogB1B,UnB1gBQ,oBmB4gBN,eACA,gBf5gBM,4BeghBR,YACE,gBACA,0BACA,YACA,aACA,8BACA,cACA,oBAGF,YACE,cACA,sBAEA,oBACE,uBACA,cACA,YACA,iBACA,sBACA,uBAGF,oBACE,aACA,CAEA,oBACA,CADA,0BACA,UACA,QACA,YACA,uBACA,2BAIJ,iBACE,iBACA,0CAKE,yBACE,qCACA,WnB9jBE,mBAMkB,gBmB2jBpB,8CAGA,yBACE,oCACA,uCAMR,iBACE,kBACA,uCACA,gBf9kBM,gBeglBN,uBACA,6CAGF,YACE,mBACA,aACA,WnBxlBM,emB0lBN,sDAEA,aACE,cnB1lBiB,wEmB6lBjB,6EAEA,aACE,WnBnmBE,gBmBqmBF,sGAIJ,kBnBnmBwB,WANlB,6PmBinBF,UnBjnBE,0DmBqnBN,wCAGF,gBACE,iBACA,mBACA,gBACA,yBACA,cACA,+BAEA,oBACE,SACA,eACA,kBACA,gCAGF,oBACE,aACA,UACA,WACA,kBACA,kCAIA,af5oBU,CgBFZ,+BAHF,YACE,cACA,kBAUA,CATA,cAKA,kBACA,2BACA,gBAEA,uBAEA,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,wHAMJ,WAGE,mCAIJ,YACE,mBACA,uBACA,YACA,CpBlFwB,IoBiG1B,aACE,aACA,sBACA,WACA,YACA,CAIA,oBAGF,qBACE,WACA,mBACA,cpB/GwB,eoBiHxB,cACA,eACA,SACA,iBACA,aACA,SACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cpBlIwB,eoBoIxB,cACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,cAGF,kBACE,WpB7KM,coB+KN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cpB5LiB,kGoB+LjB,sBAGE,WpBrME,kCoByMJ,apBnMsB,oBoByM1B,oBACE,iBACA,oBAGF,kBpB/M0B,cAWR,iBoBuMhB,eACA,gBACA,yBACA,eACA,yBAGF,iBACE,cACA,UACA,gCAEA,sCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,kFACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,uBAEA,QACE,YACA,aACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,apB/QwB,4CoBoRtB,apBpRsB,yCoBsRpB,4CAIJ,SAEE,SAIJ,WACE,kBACA,sBACA,aACA,sBACA,gBACA,wDAEA,SACE,gBACA,gBACA,qBAGF,kBpB/SwB,yBoBoT1B,WACE,aACA,cACA,uBAGF,kBACE,iCAGF,iBACE,sEAGF,kBACE,SACA,cpBtUmB,eoBwUnB,eACA,eACA,kFAEA,aACE,CAKA,kLAEA,UpBvVI,mBoByVF,kFAKJ,2BACE,wCAIJ,YACE,oBACA,6BACA,+CAEA,sBAEE,kBACA,eACA,qBACA,0CAGF,eACE,gDAKJ,SACE,6BAGF,eACE,gBACA,gBACA,cpB1XmB,0DoB4XnB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,iCAIF,eACE,2CACA,YACE,WACA,mCAKN,kBACE,aACA,mCAIA,apBlamB,0BoBoajB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,uBAKN,oBACE,uBACA,gBACA,mBACA,OACA,sBAGF,oBACE,iBACA,6EAGF,apBpbkB,mBAXQ,kBoBocxB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBACA,4EAdF,cAeI,6FAGF,eACE,mFAGF,apBpdwB,qBoBsdtB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,uCAKA,sBACE,6BACA,qCASF,qCAXA,sBACE,6BACA,sCAgBF,mJAFF,qBAGI,sBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,kBACA,uCAEA,SACE,kCAKN,aACE,aACA,yBC9hBJ,iBACE,eACA,gBACA,crBcgB,mBAXQ,4BqBCxB,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,crBhBY,qCqBoBd,cACE,gBACA,kBCtCJ,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WtB3EF,gBsB6EE,gBACA,uBACA,0CAGF,aACE,eACA,ctBjFW,gBsBmFX,gBACA,uBACA,yBAKN,kBtBxFsB,asB0FpB,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,cAOV,kBtB9H0B,sBsBiIxB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,CC/KJ,eAGF,SpBkDE,sBACA,WACA,YACA,gBACA,oBACA,mBHrDwB,cAFL,eG0DnB,SACA,coBxDA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,4CACA,eACA,WACA,YACA,cvBrDiB,euBuDjB,oBACA,0BAEA,mBACE,WACA,0BAIJ,sBACE,iCAEA,mBACE,WACA,gCAIJ,QACE,uBACA,cvB7DkB,euB+DlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,avB5EkB,mBuB8EhB,gCACA,kBACA,eACA,gBACA,uBAGF,YACE,cvBnGmB,kBuBqGnB,iBAIA,avB5FgB,mBuB8Fd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cvBtHY,gBuBwHZ,uBACA,mBACA,4BAEA,eACE,uBAGF,avB7Ie,qBuB+Ib,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cvBxKe,0BuB4KjB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,WC1LR,yCCCE,qBACA,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,8BAIJ,erBXQ,kBqBaN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBrBvCM,kBqByCN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,4BAGE,2DAIJ,WACE,wBAKF,2BACE,eAIJ,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,0EAMA,SACE,oBACA,CADA,WACA,eChGN,WAEE,0BAGF,kBANW,kBAQT,cACA,iCACA,wBACE,mCAOF,WACE,SACA,UACA,2CAGF,aACE,aAEA,sBACA,YACA,6BACA,6DAGE,oBACE,WACA,iBACA,iBACA,iJAGF,UACE,gEAEF,oBACE,gBACA,WACA,2CAKN,yBACE,sBACA,kBACA,YACA,gBACA,kDAEA,uBACE,CADF,oBACE,CADF,eACE,WACA,YACA,SACA,4BACA,WACA,yBACA,eACA,4CACA,sBACA,oBACA,6DAEA,uBACE,6DAGF,sBACE,wEAGF,sBACE,kBACA,SCjFR,WACE,sBACA,aACA,sBACA,kBACA,iBACA,UACA,qBAEA,iBACE,oBAGF,kBACE,2DxBDF,SwBI0D,yBxBC1D,SwBD0D,qCxBQxD,qLwBLA,yBAGF,eACE,gBACA,eACA,qCxBZA,4BwBgBA,SACE,WACA,YACA,eACA,UACA,+BALF,SACE,WACA,YACA,eACA,UACA,yCAIJ,4BAGF,YACE,mBACA,mBACA,UACA,mBACA,eACA,mBAEA,aACE,sBACA,oCACA,sBACA,YACA,cACA,c3BpDiB,kB2BsDjB,qBACA,eACA,mBAGF,iCACE,iDAEA,YAEE,mBACA,mCACA,SAKN,iBACE,mBACA,UACA,qCxBrDE,6CADF,ewBwDkF,sCxBlEhF,sBADF,cwBoE0D,yBxB/D1D,cwB+D0D,gBAG5D,evBlFQ,kBDkEN,CACA,sBACA,gBACA,cH7CiB,uCG+CjB,mBAEA,wBACE,cHlDe,eGoDf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WH3FI,kB2BuFR,YACE,c3BrFmB,a2BuFnB,mBACA,oBAEA,aACE,qBACA,wBAGF,aACE,c3BhGiB,gB2BkGjB,mBACA,gBACA,uBACA,0BAIJ,aACE,gBACA,gBACA,kBAGF,kB3B7G0B,kB2B+GxB,gBACA,yBAEA,a3BvGgB,mB2ByGd,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,c3B/HY,iC2BkIZ,oBACE,iBACA,8FAIJ,eAEE,mCAGF,aACE,aACA,c3B5Je,qB2B8Jf,0HAEA,aAGE,0BACA,gBAQN,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAgBA,CAfA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,uBAEA,kB3B/LwB,0B2BoM1B,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oCAGF,aACE,WACA,YACA,YACA,eACA,sCAGF,yBAzBF,aA0BI,iBAIJ,kBACE,eACA,gBACA,mBAGF,cACE,kBACA,MACA,OACA,WACA,YACA,8BACA,oBCrPF,kBACE,gBACA,W5BDM,e4BGN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,e5BbQ,cAEa,S4BcnB,WACA,YACA,iEAEA,aAGE,iCAGF,eACE,2BzBcF,iBACE,mBACA,cACA,eACA,aACA,gBACA,yByBfJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,6BAGF,aACE,kBACA,W5B9CM,8B4BgDN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,WACE,aACA,sBACA,4BAEA,iBACE,c5BzEiB,a4B2EjB,YACA,mBACA,CAGE,yDAIJ,UACE,gBAIJ,qBACE,eACA,gBACA,kBACA,kBACA,WACA,aACA,2BzBzDA,iBACE,mBACA,cACA,eACA,aACA,gBACA,sByBwDJ,WACE,sBACA,cACA,WACA,kBACA,kBACA,gBACA,kCAEA,eACE,qEAIA,cACE,MACA,gCAIJ,exB5HM,gCwBiIR,cACE,cACA,qBACA,c5BjImB,kB4BmInB,UACA,mEAEA,WAEE,WACA,sBACA,CADA,gCACA,CADA,kBACA,CAIE,0HAFF,WACE,oBACA,CADA,8BACA,CADA,gB5B/IE,C4BgJF,wBAKN,UACE,CAEA,iBACA,MACA,OACA,UACA,gB5B5JM,iC4B+JN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,aACA,uBACA,qCAGF,cACE,YACA,WACA,kBACA,UACA,sBACA,CADA,gCACA,CADA,kBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,qDAEA,WACE,oBACA,CADA,8BACA,CADA,gBACA,sCAIJ,0BACE,2BACA,gBACA,kBACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cxBrK0B,eAEC,CwB+K7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,6BACE,sBACA,SACA,W5BlQM,e4BoQN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,c5B7SiB,mF4BgTjB,yBAGE,wBAKN,oBACE,sBAGF,qBxB9TQ,YwBgUN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wB5BpU0B,qB4BwU1B,iBACE,UACA,QACA,YACA,qKAKA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBxBnZM,ewBqZN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,gCAGF,UACE,YACA,0BzB3XF,iBACE,mBACA,cACA,eACA,aACA,gBACA,qByB0XF,eACE,gBACA,UACA,kBACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBxBxeI,cJGa,gB4BwejB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,CAGE,gUAEA,aAIE,wBAKN,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UxB9iBE,+EwBsjBN,cAGE,gBACA,6BAGF,UxB7jBM,iBwB+jBJ,yBAGF,oBACE,aACA,mDAGF,UxBvkBM,uBwB4kBN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WxB5nBE,sFwB+nBF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,iBCtsBR,YACE,mBACA,mBACA,kBACA,QACA,SACA,YACA,mBAGF,YACE,kBACA,gBACA,qBACA,8BACA,eACA,iBACA,yBACA,WACA,4BACA,wCAEA,uBCtBF,kB9BM0B,sB8BJxB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kB9BhD0B,sB8BkDxB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,aC3FJ,cAOE,qBACA,W/BPM,gD+BEJ,iBACA,+BAOF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mB/BlBiB,4B+BsBnB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,c/BjCmB,c+BmCnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,a/BjD0B,mC+BoDxB,aACE,oDAGF,QACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBACA,uBAIA,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gB3B5FM,sB2B8FN,sGAEA,mCAEE,oBAKF,2BACA,gB3BxGM,0B2B2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,6BACA,W/BnHI,yB+BqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,mCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gB3BpKI,mB2ByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,c/B/JiB,mD+BkKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,wBC1MF,iBACE,aACA,mBACA,mBACA,WhCHM,kBgCKN,YACA,WACA,gBACA,iBACA,gBACA,4DAEA,aACE,eACA,mFAGF,iBACE,kBACA,gBACA,+FAEA,iBACE,OACA,MACA,kCAIJ,aACE,chC3BiB,2BgC+BnB,cACE,gBACA,iBACA,mBACA,2BAGF,cACE,gBACA,iBACA,gBACA,mBACA,0CAIJ,aACE,kBACA,cACA,mBACA,gCACA,eACA,qBACA,aACA,0BACA,4DAEA,aACE,iBACA,gDAGF,kBhC/DmB,iDgCmEnB,kBhChEwB,WANlB,qGgC2EN,kB5BxEU,WJHJ,oCgCiFR,kBACE,YACA,eACA,iBACA,gBACA,8BAGF,aACE,UACA,kBACA,YACA,gBACA,oCAGF,iBACE,4FAGF,eAEE,mBACA,qCAGF,mCACE,UACE,cACA,0CAGF,YACE,4DAEA,YACE,kBCtHN,UACE,eACA,iBACA,oBAEA,cACE,iBACA,gBACA,kBACA,mBAGF,UjCXM,0BiCaJ,oBAGF,eACE,cACA,iBACA,mDAGF,UACE,YACA,gBACA,gCACA,gBC3BJ,WACE,gBACA,aACA,sBACA,yBACA,kBACA,+BAEA,gBACE,eACA,CACA,2BACA,kCAGF,QACE,iCAGF,aACE,6BAGF,sBACE,0BAGF,MACE,kBACA,aACA,sBACA,iBACA,mDAGF,eACE,sB9BlCI,0B8BoCJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,YACE,gBACA,gLAEA,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,CAIA,yFAGF,eACE,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,W9BjNM,kB8BmNN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,mCAGF,kBAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,gCC1QJ,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,anCfmB,qBmCiBjB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,gBAKN,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBnCtFoB,kBmCwFpB,cACA,eACA,4BAIJ,YACE,cnClGiB,kBmCoGjB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,cnChKe,mFmCoKjB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,anCnMmB,SmCqMjB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OCjON,eACE,eACA,8BAEA,QAEE,gBACA,UAGF,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBpCfsB,eoCoBxB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAIA,qBACA,WACA,eACA,WpCtDE,coCwDF,UACA,oBACA,gBhCzDE,sBgC2DF,kBACA,iBACA,sCAEA,oBpC1DoB,0BoC+DtB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBhClGY,8EgCuGZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,cpC7Hc,aoCiIhB,cACE,uBACA,UACA,SACA,SACA,cpCtIc,0BoCwId,kBACA,mBAEA,oBACE,sCAGF,kCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,yBACE,kBACA,gCAEA,YACE,2CAGF,yBACE,aACA,aACA,mBACA,mGAEA,UAEE,aACA,+GAEA,oBpC1LoB,sDoCgMxB,cACE,gBACA,iBACA,YACA,oBACA,cpC1LkB,sCoC6LlB,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WpC/NI,qBoCiOJ,WACA,UACA,oBACA,qXACA,sBACA,kBACA,CACA,yBACA,mDAGF,UACE,cAIJ,apCjOkB,qBoCoOhB,+BACE,6BAEA,8BACE,YCpPN,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,sBACE,eACA,gBACA,gBACA,qBACA,crClBmB,oBqCqBnB,arClBwB,0BqCoBtB,6EAEA,oBAGE,wCAIJ,arChCmB,oBqCqCnB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,crC/CiB,qBqCmDnB,iBACE,crCpDiB,uBqCwDnB,eACE,mBACA,kBACA,kBACA,yHAGF,sBAME,mBACA,oBACA,gBACA,crCxEiB,qBqC4EnB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,crCnJe,iCqCuJjB,uBACE,gBACA,gBACA,crC7IY,qDqCiJd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,uBACA,eACA,iBACA,WrCrNI,iBqCuNJ,kBACA,qEAEA,aAEE,6CAIA,arC7Ne,oCqCkOjB,sBACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,sBACE,eACA,iBACA,gBACA,crC7Pe,mBqC+Pf,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAIE,UACqB,sClCnRzB,CkCoRI,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iBjCpOgB,wBD9DtB,4BACA,iCkCsSE,cACE,mCAEA,aACE,WrC5SA,qBqC8SA,uDAGE,yBACE,2CAKN,aACE,crCrTa,kCqC6TnB,sBAEE,CACA,eACA,eACA,iBACA,mBACA,crCpUiB,sCqCuUjB,arCpUsB,0BqCsUpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,sBACE,eACA,iBACA,gBACA,mBACA,crC9ViB,wBqCiWjB,sBACE,cACA,eACA,gBACA,cACA,kBAKF,cACA,iBrC5WiB,mCqC0WnB,sBACE,CAEA,eACA,mBACA,crC/WiB,kBqCoXjB,cACA,iBrCrXiB,kBqC6XjB,crC7XiB,mCqC4XnB,sBACE,CACA,gBACA,gBACA,mBACA,crCjYiB,kBqCsYjB,crCtYiB,kBqC8YnB,sBACE,eACA,iBACA,gBACA,mBACA,crCnZiB,mCqCuZnB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,0CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBrCjcwB,kBqCmctB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAMA,UACqB,sClC9hB3B,mDkCiiBI,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA9DF,iBA+DI,mFAIJ,qBAGE,mBrC3jBsB,kBqC6jBtB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,arC7lBiB,qCqCimBjB,eACE,WrCrmBE,gBqCumBF,CrCpmBe,yFqCymBb,arCzmBa,+CqC+mBjB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SrC3rBI,YqC6rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,crCltBe,6BqCstBjB,eACE,iBACA,+BAGF,kBrCztBsB,aqC2tBpB,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,sBACE,eACA,gBACA,cACA,qCAGF,cACE,crCjwBa,uFqCuwBnB,eACE,cASA,CrCjxBiB,2CqC8wBjB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,crC72BsB,qBqC+2BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,crCz2Bc,SsCjBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBtCrBwB,UsC0BxB,atCzBwB,0BsC2BtB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBtC9DsB,6BsCgEpB,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+BACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,ctC1GmB,gBsC4GnB,0DAEA,UtCjHM,wDsCqHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBtC5JsB,sBsC8JtB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBtC3KsB,gCsC8KtB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBtCnMsB,uCsCsMpB,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,ctC7Oa,gBsC+Ob,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,sCAEA,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBCzRN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBvCZoB,YuCcpB,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SvCzCA,YuC2CE,kBACA,YACA,uCAIJ,aACE,cvC/Ca,qBuCiDb,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cvCzFa,qBuC2Fb,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UvC1GA,yBuC4GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UvClIE,yBAMkB,gBuC+HlB,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,avClNiB,euCoNf,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,avC7NiB,euC+Nf,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,cvCxOe,mBuC0Of,kBACA,gCACA,4BAGF,cACE,cvChPe,iBuCkPf,gBACA,0CAGF,UvCzPI,gBuC2PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WvCzQE,oBuC2QF,iBACA,gBACA,mBACA,2BAGF,cACE,iBACA,cvChRe,mBuCkRf,kCAEA,UvCvRE,gBuCyRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,0CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BvC9UoB,YuCqV1B,UACE,SACA,cACA,WACA,sDAKA,avCjWmB,0DuCoWjB,avCjWsB,4DuCsWxB,anC1Wc,gBmC4WZ,4DAGF,anC9WU,gBmCgXR,0DAGF,avCtWgB,gBuCwWd,0DAGF,anCtXU,gBmCwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,cvCtae,qBuCwaf,yBACA,eACA,gBACA,gCACA,iCAEA,UvCjbE,gCuCmbA,oCAGF,avChboB,gCuCkblB,iBAMR,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cvC5diB,CuCieb,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,avCxiBwB,qBuC0iBtB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBvCzjBwB,gCuC2jBxB,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,cvCtkBiB,euCwkBjB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,avCnlBgB,sDuCulBhB,avCpmBmB,qBuCwmBjB,gBACA,yDAIJ,oBAIE,cvCjnBmB,iGuConBnB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBnC9pBc,yBmCkqBd,yBACE,wBAGF,yBnCnqBU,wBmCwqBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,avClrBiB,uBuCwrBjB,wBACA,qBAGF,avC/qBgB,cuCorBlB,kBvC/rB0B,kBuCisBxB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cvCvtBe,yBuCytBf,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,anCvuBM,6BmC8uBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cvC5vBa,mLuC+vBb,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,avC9vBU,iBuCgwBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cvCvxBa,WuC8xBrB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,anC70BY,8CmCk1Bd,qBACE,aACA,WvCt1BI,cuC21BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBvC/1BsB,gCuCi2BtB,kCAEA,cACE,cACA,gBACA,eACA,gBACA,cvC12Be,qBuC42Bf,mBACA,uHAEA,UvCl3BE,iCuCy3BJ,cACE,cvC12BY,uCuC82Bd,YACE,8BACA,mBACA,sCAGF,eACE,0pDCp4BN,kIACE,CADF,sIACE,uIAYA,aAEE,yIAGF,aAEE,qIAGF,aAEE,6IAGF,aAEE,UChCJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,+BAGF,eACE,2EAGF,UAEE,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,+BAGF,UACE,0BAGF,gBACE,eACA,UAGA,WACA,yCAGF,iBACE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,gBACA,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,qEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,sBChbJ,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,W1CtCI,uB0CwCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,c1C/CiB,kB0CiDjB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,a1CpEmB,gB0CsEjB,qBACA,wBCxEJ,kB3CG0B,C2CCtB,4EAGF,kBACE,gDAEA,kB3CPsB,wC2CcxB,gBACE,uCAGF,iBACE,kCAIJ,kBACE,yBACA,mEAEA,uDACE,kDAIJ,kBACE,mFAEA,uDACE,qBAMF,eACE,0CAIJ,kDACE,gBAGF,kB3CnD0B,0B2CuD1B,i2BACE,oCAEA,sDACE,CADF,8CACE,iDAOF,kBAEE,uDAEA,kBACE,qBACA,C3CxEoB,8E2CuF1B,kB3CvF0B,4B2C6FxB,yB3C7FwB,2B2CiGxB,wB3CjGwB,8B2CqGxB,2B3CrGwB,6B2CyGxB,0B3CzGwB,wB2CgHxB,kB3ChHwB,cAFL,0F2C2HnB,aACE,4GAEA,kKAEA,aACE,CAHF,6HAEA,aACE,CAHF,qIAEA,aACE,CAHF,mHAEA,aACE,sCAIJ,kBACE,iCAGF,YACE,C3CzIoB,mH2C+IpB,a3C/IoB,8C2CuJxB,aACE,2JAEA,UvC7JM,wCuCoKR,aACE,mEAEA,aACE,CAHF,yDAEA,aACE,CAHF,6DAEA,aACE,CAHF,oDAEA,aACE,2BAIJ,2BACE,gDAKA,a3C7KwB,iB2CkL1B,oBACE,6BAEA,kBACE,0BAIJ,+BACE,qB3C5LwB,oC2CgM1B,kBACE,iMAIA,kBAIE,qBAIJ,kB3C/MqB,sE2CmNrB,kBACE,4FAGF,kBACE,qOAIF,evC9NQ,yBuC0ON,wBAGF,0BACE,0BAGF,wBACE,uLAGF,kBAME,4qEAIE,qBAGE,uCAMN,aAEE,uBAIF,e3C9QQ,gC2CmRJ,a3ChRoB,yB2CyRtB,e3C5RM,CADA,oG2CwSF,U3CxSE,0D2CqTF,a3ClTe,2C2CwTf,U3C3TE,6C2CgUJ,a3C7TiB,6D2CiUjB,U3CpUI,qB2C2UR,UvC1UQ,yBuC6UN,SvC7UM,iGuCmVN,eAGE,CAIA,oEAIA,kBACE,oDAEA,eACE,iHAMA,UvCxWA,2CuCiXR,yCACE,gMAGF,eAUE,sKAGF,U3CnYQ,0D","file":"skins/glitch/mastodon-light/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#ccd7e0 rgba(255,255,255,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#ccd7e0;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#c6d2dc}::-webkit-scrollbar-thumb:active{background:#ccd7e0}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(255,255,255,.1)}::-webkit-scrollbar-track:hover{background:#d9e1e8}::-webkit-scrollbar-track:active{background:#d9e1e8}::-webkit-scrollbar-corner{background:transparent}body{font-family:sans-serif,sans-serif;background:#eff3f5;font-size:13px;line-height:18px;font-weight:400;color:#000;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",sans-serif,sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#d9e1e8}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.embed{background:#ccd7e0;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#e6ebf0;padding:0}body.error{position:absolute;text-align:center;color:#282c37;background:#d9e1e8;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#000;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#000;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:sans-serif,sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;width:40px;height:40px;background-size:40px 40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}.account-header .name{flex:1 1 auto;color:#282c37;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#000}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#b3c3d1}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#282c37;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#000}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#a6b9c9;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#99afc2}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#282c37}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#fff}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#ccd7e0;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;width:120px;height:120px;background-size:120px 120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #ccd7e0;background:#f2f5f7;border-radius:8%;background-position:50%;background-clip:padding-box}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#ccd7e0;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#000;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#282c37}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#282c37;padding:10px;border-right:1px solid #ccd7e0;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#282c37}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#000;font-family:sans-serif,sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #b3c3d1}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#282c37}.public-layout .public-account-header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#000}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#217aba}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#000}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#282c37}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #c0cdd9}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #c0cdd9}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#d9e1e8}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#ccd7e0}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.modal-layout{background:#d9e1e8 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#6d8ca7}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#6d8ca7}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#282c37}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#6d8ca7}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#6d8ca7}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#60829f}.compact-header h1{font-size:24px;line-height:28px;color:#282c37;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#282c37}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#d9e1e8;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.hero-widget__text a{color:#282c37;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#282c37}.box-widget{padding:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #444b5d;text-align:center;color:#282c37;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#282c37;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#282c37;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#282c37}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#282c37;margin-bottom:10px}.page-header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#000;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#282c37}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#ccd7e0}.page-header h1{font-size:24px}}.directory{background:#d9e1e8;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#d9e1e8;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#c0cdd9}.directory__tag.active>a{background:#2b90d9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#000;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#282c37}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#282c37}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small{color:#000}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b90d9}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#f2f5f7;border:2px solid #d9e1e8}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#282c37;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #c0cdd9}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#000}.accounts-table__count small{display:block;color:#282c37;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #a6b9c9}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#282c37}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b90d9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#e6ebf0;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#000;border-bottom:1px solid #ccd7e0}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #ccd7e0}code{font-family:monospace,monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#000;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#282c37}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#fff}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#282c37}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#000;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#000;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#000;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#c1203b}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#000;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#000;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #444b5d;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb;border:1px solid #fff;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#1f232b}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#c1203b}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#fff}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#f2f5f7}.simple_form .input.field_with_errors label{color:#c1203b}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#c1203b}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#c1203b;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b90d9;color:#000;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#2482c7}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#419bdd}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#db2a47}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#e3566d}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#444b5d;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(249, 250, 251, 0), #f9fafb)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(217,225,232,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#000}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#c0cdd9;color:#282c37;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#282c37;text-decoration:none}.flash-message a:hover{color:#000;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:monospace,monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#ccd7e0}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#282c37;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#217aba}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#282c37}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#282c37;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#282c37;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#000;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#000;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#282c37}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#000;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:monospace,monospace}.input-copy{background:#f9fafb;border:1px solid #fff;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:monospace,monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#e6ebf0;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#e6ebf0;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#c0cdd9}.card__img{height:130px;position:relative;background:#fff;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;width:48px;height:48px;background-size:48px 48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;border-radius:8%;background-position:50%;background-clip:padding-box;background:#f2f5f7;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#000;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#282c37}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#000}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#444b5d;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#282c37;background-color:rgba(40,44,55,.1);border:1px solid rgba(40,44,55,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#c1203b;background-color:rgba(193,32,59,.1);border-color:rgba(193,32,59,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #b3c3d1;border-bottom:1px solid #b3c3d1;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #b3c3d1}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#282c37;background:rgba(242,245,247,.5)}.account__header__fields dd{flex:1 1 auto;color:#282c37}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#282c37}.pending-account__header a{color:#282c37;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#000;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#d9e1e8}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#c0cdd9}.button.logo-button{flex:0 auto;font-size:14px;background:#2b90d9;color:#000;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#000}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#2074b1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{padding:8px 0;padding-bottom:2px;margin:initial;margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{position:absolute;margin:initial;float:initial;width:auto;left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}.embed .status .status__info,.public-layout .status .status__info{font-size:15px;display:initial}.embed .status .status__relative-time,.public-layout .status .status__relative-time{color:#444b5d;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.embed .status .status__info .status__display-name,.public-layout .status .status__info .status__display-name{display:block;max-width:100%;padding:6px 0;padding-right:25px;margin:initial}.embed .status .status__info .status__display-name .display-name strong,.public-layout .status .status__info .status__display-name .display-name strong{display:inline}.embed .status .status__avatar,.public-layout .status .status__avatar{height:48px;position:absolute;width:48px;margin:initial}.rtl .embed .status,.rtl .public-layout .status{padding-left:10px;padding-right:68px}.rtl .embed .status .status__info .status__display-name,.rtl .public-layout .status .status__info .status__display-name{padding-left:25px;padding-right:0}.rtl .embed .status .status__relative-time,.rtl .public-layout .status .status__relative-time{float:left}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#217aba;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#2b90d9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#3897db;border:10px none;border-radius:4px;box-sizing:border-box;color:#000;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;transition-property:background-color;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#227dbe;transition:all 200ms ease-out;transition-property:background-color}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled{background-color:#9baec8;cursor:default}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#8ea3c1}.button.button-alternative-2{background:#3c5063}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#344656}.button.button-secondary{font-size:16px;line-height:36px;height:auto;color:#282c37;text-transform:none;background:transparent;padding:3px 15px;border-radius:4px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#8ea3c1;color:#1f232b}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.icon-button{display:inline-block;padding:0;color:#606984;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#51596f;background-color:rgba(96,105,132,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(96,105,132,.3)}.icon-button.disabled{color:#828ba4;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#282c37}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#373d4c;background-color:rgba(40,44,55,.15)}.icon-button.inverted:focus{background-color:rgba(40,44,55,.3)}.icon-button.inverted.disabled{color:#191b22;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#1d6ca4}.icon-button.overlayed{box-sizing:content-box;background:rgba(255,255,255,.6);color:rgba(0,0,0,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(255,255,255,.9)}.text-icon-button{color:#282c37;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#373d4c;background-color:rgba(40,44,55,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(40,44,55,.3)}.text-icon-button.disabled{color:#000;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute;transform-origin:50% 0}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.notification__favourite-icon-wrapper{left:0;position:absolute}.notification__favourite-icon-wrapper .fa.star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#000;text-decoration:underline}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name a{color:inherit;text-decoration:inherit}.display-name strong{height:18px;font-size:16px;font-weight:500;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name span{display:block;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.display-name>a:hover strong{text-decoration:underline}.display-name.inline{padding:0;height:18px;font-size:15px;line-height:18px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.display-name.inline strong{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name.inline span{display:inline;height:auto;font-size:inherit;line-height:inherit}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #393f4f;margin:5px 7px 6px;height:0}.dropdown-menu{background:#282c37;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.dropdown-menu ul{list-style:none}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#282c37}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#282c37}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#282c37}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#282c37}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b90d9;color:#282c37;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#282c37;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b90d9;color:#282c37}.dropdown__icon{vertical-align:middle}.static-content{padding:10px;padding-top:20px;color:#444b5d}.static-content h1{font-size:16px;font-weight:500;margin-bottom:40px;text-align:center}.static-content p{font-size:13px;margin-bottom:20px}.column,.drawer{flex:1 1 100%;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#c0cdd9;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#000;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #c0cdd9;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.auto-columns .tabs-bar__link:hover,.auto-columns .tabs-bar__link:focus,.auto-columns .tabs-bar__link:active{background:#adbecd;border-bottom-color:#adbecd}}.multi-columns .tabs-bar__link:hover,.multi-columns .tabs-bar__link:focus,.multi-columns .tabs-bar__link:active{background:#adbecd;border-bottom-color:#adbecd}.tabs-bar__link.active{border-bottom:2px solid #2b90d9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}.tabs-bar__link span.icon{margin-left:0;display:inline}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b90d9;border:2px solid #c0cdd9;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#000}.column-link--transparent .icon-with-badge__badge{border-color:#f2f5f7}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(255,255,255,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#d9e1e8;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#f9fafb}.react-toggle--checked .react-toggle-track{background-color:#2b90d9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#2074b1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #d9e1e8;border-radius:50%;background-color:#fff;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b90d9}.getting-started__wrapper,.getting_started,.flex-spacer{background:#d9e1e8}.getting-started__wrapper{position:relative;overflow-y:auto}.flex-spacer{flex:1 1 auto}.getting-started{background:#d9e1e8;flex:1 0 auto}.getting-started p{color:#282c37}.getting-started a{color:#444b5d}.getting-started__panel{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex:0 1 auto}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{color:#444b5d;font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#444b5d;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#282c37}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#282c37;padding:10px;font-weight:500;border-bottom:1px solid #c0cdd9}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#282c37}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#d9e1e8;padding:4px 8px;margin:-6px 10px}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#c0cdd9;border:1px solid #e6ebf0}.setting-text{color:#282c37;background:transparent;border:none;border-bottom:2px solid #9baec8;box-sizing:border-box;display:block;font-family:inherit;margin-bottom:10px;padding:7px 0;width:100%}.setting-text:focus,.setting-text:active{color:#000;border-bottom-color:#2b90d9}@media screen and (max-width: 600px){.auto-columns .setting-text,.single-column .setting-text{font-size:16px}}.setting-text.light{color:#000;border-bottom:2px solid #839db4}.setting-text.light:focus,.setting-text.light:active{color:#000;border-bottom-color:#2b90d9}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#606984;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.reduce-motion button.icon-button.disabled i.fa-retweet{color:#828ba4}.load-more{display:block;color:#444b5d;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#d3dce4}.load-gap{border-bottom:1px solid #c0cdd9}.missing-indicator{padding-top:68px}.scrollable>div>:first-child .notification__dismiss-overlay>.wrappy{border-top:1px solid #d9e1e8}.notification__dismiss-overlay{overflow:hidden;position:absolute;top:0;right:0;bottom:-1px;padding-left:15px;z-index:999;align-items:center;justify-content:flex-end;cursor:pointer;display:flex}.notification__dismiss-overlay .wrappy{width:4rem;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#c0cdd9;border-left:1px solid #99afc2;box-shadow:0 0 5px #000;border-bottom:1px solid #d9e1e8}.notification__dismiss-overlay .ckbox{border:2px solid #9baec8;border-radius:2px;width:30px;height:30px;font-size:20px;color:#282c37;text-shadow:0 0 5px #000;display:flex;justify-content:center;align-items:center}.notification__dismiss-overlay:focus{outline:0 !important}.notification__dismiss-overlay:focus .ckbox{box-shadow:0 0 1px 1px #2b90d9}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.loading-indicator{color:#444b5d;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #86a0b6;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#86a0b6}29%{background-color:#86a0b6}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:flex;left:4px;top:4px;width:auto;height:auto;align-items:center}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(255,255,255,.5);border-radius:8px;padding:8px 12px;color:#000;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(255,255,255,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(255,255,255,.5)}.setting-toggle{display:block;line-height:24px}.setting-toggle__label,.setting-radio__label,.setting-meta__label{color:#282c37;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.setting-radio{display:block;line-height:18px}.setting-radio__label{margin-bottom:0}.column-settings__row legend{color:#282c37;cursor:default;display:block;font-weight:500;margin-top:10px}.setting-radio__input{vertical-align:middle}.setting-meta__label{float:right}@keyframes heartbeat{from{transform:scale(1);transform-origin:center center;animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.pulse-loading{animation:heartbeat 1.5s ease-in-out infinite both}.upload-area{align-items:center;background:rgba(255,255,255,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#d9e1e8;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#282c37;font-size:18px;font-weight:500;border:2px dashed #3c5063;border-radius:4px}.dropdown--active .emoji-button img{opacity:1;filter:none}.loading-bar{background-color:#2b90d9;height:3px;position:absolute;top:0;left:0;z-index:9999}.icon-badge-wrapper{position:relative}.icon-badge{position:absolute;display:block;right:-0.25em;top:-0.25em;background-color:#2b90d9;border-radius:50%;font-size:75%;width:1em;height:1em}.conversation{display:flex;border-bottom:1px solid #c0cdd9;padding:5px;padding-bottom:0}.conversation:focus{background:#d3dce4;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#282c37;padding-left:15px}.conversation__content__names{color:#282c37;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#000;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content .status__content{margin:0}.conversation--unread{background:#d3dce4}.conversation--unread:focus{background:#ccd7e0}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#000}.ui .flash-message{margin-top:10px;margin-left:auto;margin-right:auto;margin-bottom:0;min-width:75%}::-webkit-scrollbar-thumb{border-radius:0}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#282c37;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}noscript div a{word-break:break-word}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet,button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.status-direct button.icon-button.disabled i.fa-retweet,.status-direct button.icon-button.disabled i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}.account{padding:10px;border-bottom:1px solid #c0cdd9;color:inherit;text-decoration:none}.account .account__display-name{flex:1 1 auto;display:block;color:#282c37;overflow:hidden;text-decoration:none;font-size:14px}.account.small{border:none;padding:0}.account.small>.account__avatar-wrapper{margin:0 8px 0 0}.account.small>.display-name{height:24px;line-height:24px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:8%;background-position:50%;background-clip:padding-box;position:relative;cursor:pointer}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:8%;background-position:50%;background-clip:padding-box;overflow:hidden;position:relative}.account__avatar-composite div{border-radius:8%;background-position:50%;background-clip:padding-box;float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#000;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}.account__avatar-overlay{position:relative;width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:8%;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:8%;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__header__wrapper{flex:0 0 auto;background:#ccd7e0}.account__disclaimer{padding:10px;color:#444b5d}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-left:1px solid #c0cdd9;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab:first-child{border-left:0}.account__action-bar__tab.active{border-bottom:4px solid #2b90d9}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#282c37}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#000}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account__action-bar__tab abbr{color:#2b90d9}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.notification__message{margin-left:42px;padding:8px 0 0 26px;cursor:default;color:#282c37;font-size:15px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account--panel{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#c0cdd9;padding:15px}.column-settings__section{color:#282c37;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#1f232b}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#ccd7e0}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#444b5d;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#c0cdd9}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#b3c3d1;color:#1f232b}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#282c37}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#444b5d}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#3b4151}.column-settings__hashtags .column-select__indicator-separator{background-color:#c0cdd9}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#282c37}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#444b5d;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#3d4455}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#000;margin-bottom:4px;display:block;vertical-align:top;background-color:#fff;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:none;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#e6ebf0;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#282c37;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#282c37}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #c0cdd9}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #d9e1e8}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#dfe6ec;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #eff3f5}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9}.account__moved-note__message{position:relative;margin-left:58px;color:#444b5d;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.account__header__content{color:#282c37;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#e6ebf0}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#ccd7e0;padding:5px;border-bottom:1px solid #b3c3d1}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#f2f5f7;border:2px solid #ccd7e0}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #b3c3d1;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#000}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #b3c3d1}.account__header__bio .account__header__fields a{color:#217aba}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#282c37;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#000}.domain{padding:10px;border-bottom:1px solid #c0cdd9}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#000;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}@keyframes spring-flip-in{0%{transform:rotate(0deg)}30%{transform:rotate(-242.4deg)}60%{transform:rotate(-158.35deg)}90%{transform:rotate(-187.5deg)}100%{transform:rotate(-180deg)}}@keyframes spring-flip-out{0%{transform:rotate(-180deg)}30%{transform:rotate(62.4deg)}60%{transform:rotate(-21.635deg)}90%{transform:rotate(7.5deg)}100%{transform:rotate(0deg)}}.status__content--with-action{cursor:pointer}.status__content{position:relative;margin:10px 0;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:visible;padding-top:5px}.status__content:focus{outline:0}.status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.status__content pre,.status__content blockquote{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.status__content pre:last-child,.status__content blockquote:last-child{margin-bottom:0}.status__content .status__content__text,.status__content .e-content{overflow:hidden}.status__content .status__content__text>ul,.status__content .status__content__text>ol,.status__content .e-content>ul,.status__content .e-content>ol{margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h1,.status__content .e-content h2,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{margin-top:20px;margin-bottom:20px}.status__content .status__content__text h1,.status__content .status__content__text h2,.status__content .e-content h1,.status__content .e-content h2{font-weight:700;font-size:1.2em}.status__content .status__content__text h2,.status__content .e-content h2{font-size:1.1em}.status__content .status__content__text h3,.status__content .status__content__text h4,.status__content .status__content__text h5,.status__content .e-content h3,.status__content .e-content h4,.status__content .e-content h5{font-weight:500}.status__content .status__content__text blockquote,.status__content .e-content blockquote{padding-left:10px;border-left:3px solid #282c37;color:#282c37;white-space:normal}.status__content .status__content__text blockquote p:last-child,.status__content .e-content blockquote p:last-child{margin-bottom:0}.status__content .status__content__text b,.status__content .status__content__text strong,.status__content .e-content b,.status__content .e-content strong{font-weight:700}.status__content .status__content__text em,.status__content .status__content__text i,.status__content .e-content em,.status__content .e-content i{font-style:italic}.status__content .status__content__text sub,.status__content .e-content sub{font-size:smaller;text-align:sub}.status__content .status__content__text sup,.status__content .e-content sup{font-size:smaller;vertical-align:super}.status__content .status__content__text ul,.status__content .status__content__text ol,.status__content .e-content ul,.status__content .e-content ol{margin-left:1em}.status__content .status__content__text ul p,.status__content .status__content__text ol p,.status__content .e-content ul p,.status__content .e-content ol p{margin:0}.status__content .status__content__text ul,.status__content .e-content ul{list-style-type:disc}.status__content .status__content__text ol,.status__content .e-content ol{list-style-type:decimal}.status__content a{color:#d8a070;text-decoration:none}.status__content a:hover{text-decoration:underline}.status__content a:hover .fa{color:#353a48}.status__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span{text-decoration:underline}.status__content a .fa{color:#444b5d}.status__content .status__content__spoiler{display:none}.status__content .status__content__spoiler.status__content__spoiler--visible{display:block}.status__content a.unhandled-link{color:#217aba}.status__content a.unhandled-link .link-origin-tag{color:#ca8f04;font-size:.8em}.status__content .status__content__spoiler-link{background:#7a96ae}.status__content .status__content__spoiler-link:hover{background:#708ea9;text-decoration:none}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:#7a96ae;border:none;color:#000;font-weight:500;font-size:11px;padding:0 5px;text-transform:uppercase;line-height:inherit;cursor:pointer;vertical-align:bottom}.status__content__spoiler-link:hover{background:#708ea9;text-decoration:none}.status__content__spoiler-link .status__content__spoiler-icon{display:inline-block;margin:0 0 0 5px;border-left:1px solid currentColor;padding:0 0 0 4px;font-size:16px;vertical-align:-2px}.notif-cleaning .status,.notif-cleaning .notification-follow,.notif-cleaning .notification-follow-request{padding-right:4.5rem}.status__wrapper--filtered{color:#444b5d;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #c0cdd9}.status__prepend-icon-wrapper{left:-26px;position:absolute}.notification-follow,.notification-follow-request{position:relative;border-bottom:1px solid #c0cdd9}.notification-follow .account,.notification-follow-request .account{border-bottom:0 none}.focusable:focus{outline:0;background:#ccd7e0}.focusable:focus.status.status-direct:not(.read){background:#b3c3d1}.focusable:focus.status.status-direct:not(.read).muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#c0cdd9}.status{padding:10px 14px;position:relative;height:auto;border-bottom:1px solid #c0cdd9;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:28px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#c0cdd9;border-bottom-color:#b3c3d1}.status.light .status__relative-time{color:#282c37}.status.light .status__display-name{color:#000}.status.light .display-name{color:#444b5d}.status.light .display-name strong{color:#000}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#000;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#8199ba}.status.collapsed{background-position:center;background-size:cover;user-select:none}.status.collapsed.has-background::before{display:block;position:absolute;left:0;right:0;top:0;bottom:0;background-image:linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.65) 24px, rgba(0, 0, 0, 0.8));pointer-events:none;content:\"\"}.status.collapsed .display-name:hover .display-name__html{text-decoration:none}.status.collapsed .status__content{height:20px;overflow:hidden;text-overflow:ellipsis;padding-top:0}.status.collapsed .status__content:after{content:\"\";position:absolute;top:0;bottom:0;left:0;right:0;background:linear-gradient(rgba(217, 225, 232, 0), #d9e1e8);pointer-events:none}.status.collapsed .status__content a:hover{text-decoration:none}.status.collapsed:focus>.status__content:after{background:linear-gradient(rgba(204, 215, 224, 0), #ccd7e0)}.status.collapsed.status-direct:not(.read)>.status__content:after{background:linear-gradient(rgba(192, 205, 217, 0), #c0cdd9)}.status.collapsed .notification__message{margin-bottom:0}.status.collapsed .status__info .notification__message>span{white-space:nowrap}.status .notification__message{margin:-10px 0px 10px 0}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#444a5e}.status__relative-time{display:inline-block;flex-grow:1;color:#444b5d;font-size:14px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status__display-name{color:#444b5d;overflow:hidden}.status__info__account .status__display-name{display:block;max-width:100%}.status__info{display:flex;justify-content:space-between;font-size:15px}.status__info>span{text-overflow:ellipsis;overflow:hidden}.status__info .notification__message>span{word-wrap:break-word}.status__info__icons{display:flex;align-items:center;height:1em;color:#606984}.status__info__icons .status__media-icon,.status__info__icons .status__visibility-icon,.status__info__icons .status__reply-icon{padding-left:2px;padding-right:2px}.status__info__icons .status__collapse-button.active>.fa-angle-double-up{transform:rotate(-180deg)}.no-reduce-motion .status__collapse-button.activate>.fa-angle-double-up{animation:spring-flip-in 1s linear}.no-reduce-motion .status__collapse-button.deactivate>.fa-angle-double-up{animation:spring-flip-out 1s linear}.status__info__account{display:flex;align-items:center;justify-content:flex-start}.status-check-box{border-bottom:1px solid #282c37;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-top:-10px;margin-bottom:10px;margin-left:58px;color:#444b5d;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#444b5d}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#606984}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#ccd7e0;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#444b5d;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#000}.muted .emojione{opacity:.5}a.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#282c37;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#000}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{flex:none;margin:0 10px 0 0;height:48px;width:48px}.muted .status__content,.muted .status__content p,.muted .status__content a,.muted .status__content__text{color:#444b5d}.muted .status__display-name strong{color:#444b5d}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#3c5063;color:#000}.muted a.status__content__spoiler-link:hover{background:#7d98b0;text-decoration:none}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.status-card{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;color:#444b5d;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#282c37;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#000}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}.status-card__actions a .fa,.status-card__actions a:hover .fa{color:inherit}a.status-card{cursor:pointer}a.status-card:hover{background:#c0cdd9}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#282c37;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#282c37}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#c0cdd9;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#ccd7e0}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#ccd7e0}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.attachment-list{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#444b5d;padding:8px 18px;cursor:default;border-right:1px solid #c0cdd9;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#444b5d;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#444b5d}.status__wrapper--filtered__button{display:inline;color:#217aba;border:0;background:transparent;padding:0;font-size:inherit;line-height:inherit}.status__wrapper--filtered__button:hover,.status__wrapper--filtered__button:active{text-decoration:underline}.modal-container--preloader{background:#c0cdd9}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.onboarding-modal,.error-modal,.embed-modal{background:#282c37;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.onboarding-modal__pager{height:80vh;width:80vw;max-width:520px;max-height:470px}.onboarding-modal__pager .react-swipeable-view-container>div{width:100%;height:100%;box-sizing:border-box;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;user-select:text}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}@media screen and (max-width: 550px){.onboarding-modal{width:100%;height:100%;border-radius:0}.onboarding-modal__pager{width:100%;height:auto;max-width:none;max-height:none;flex:1 1 auto}}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#393f4f;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#282c37;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#313543;background-color:#4a5266}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#000}.error-modal__footer{justify-content:center}.onboarding-modal__dots{flex:1 1 auto;display:flex;align-items:center;justify-content:center}.onboarding-modal__dot{width:14px;height:14px;border-radius:14px;background:#4a5266;margin:0 3px;cursor:pointer}.onboarding-modal__dot:hover{background:#4f576c}.onboarding-modal__dot.active{cursor:default;background:#5c657e}.onboarding-modal__page__wrapper{pointer-events:none;padding:25px;padding-bottom:0}.onboarding-modal__page__wrapper.onboarding-modal__page__wrapper--active{pointer-events:auto}.onboarding-modal__page{cursor:default;line-height:21px}.onboarding-modal__page h1{font-size:18px;font-weight:500;color:#000;margin-bottom:20px}.onboarding-modal__page a{color:#2b90d9}.onboarding-modal__page a:hover,.onboarding-modal__page a:focus,.onboarding-modal__page a:active{color:#2485cb}.onboarding-modal__page .navigation-bar a{color:inherit}.onboarding-modal__page p{font-size:16px;color:#282c37;margin-top:10px;margin-bottom:10px}.onboarding-modal__page p:last-child{margin-bottom:0}.onboarding-modal__page p strong{font-weight:500;background:#d9e1e8;color:#282c37;border-radius:4px;font-size:14px;padding:3px 6px}.onboarding-modal__page p strong:lang(ja){font-weight:700}.onboarding-modal__page p strong:lang(ko){font-weight:700}.onboarding-modal__page p strong:lang(zh-CN){font-weight:700}.onboarding-modal__page p strong:lang(zh-HK){font-weight:700}.onboarding-modal__page p strong:lang(zh-TW){font-weight:700}.onboarding-modal__page__wrapper-0{height:100%;padding:0}.onboarding-modal__page-one__lead{padding:65px;padding-top:45px;padding-bottom:0;margin-bottom:10px}.onboarding-modal__page-one__lead h1{font-size:26px;line-height:36px;margin-bottom:8px}.onboarding-modal__page-one__lead p{margin-bottom:0}.onboarding-modal__page-one__extra{padding-right:65px;padding-left:185px;text-align:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#d9e1e8;color:#282c37;font-weight:500;padding:10px;border-radius:4px}.onboarding-modal__page-two p,.onboarding-modal__page-three p,.onboarding-modal__page-four p,.onboarding-modal__page-five p{text-align:left}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{background:#f2f5f7;color:#282c37;margin-bottom:20px;border-radius:4px;padding:10px;text-align:center;font-size:14px;box-shadow:1px 2px 6px rgba(0,0,0,.3)}.onboarding-modal__page-two .figure .onboarding-modal__image,.onboarding-modal__page-three .figure .onboarding-modal__image,.onboarding-modal__page-four .figure .onboarding-modal__image,.onboarding-modal__page-five .figure .onboarding-modal__image{border-radius:4px;margin-bottom:10px}.onboarding-modal__page-two .figure.non-interactive,.onboarding-modal__page-three .figure.non-interactive,.onboarding-modal__page-four .figure.non-interactive,.onboarding-modal__page-five .figure.non-interactive{pointer-events:none;text-align:left}.onboarding-modal__page-four__columns .row{display:flex;margin-bottom:20px}.onboarding-modal__page-four__columns .row>div{flex:1 1 0;margin:0 10px}.onboarding-modal__page-four__columns .row>div:first-child{margin-left:0}.onboarding-modal__page-four__columns .row>div:last-child{margin-right:0}.onboarding-modal__page-four__columns .row>div p{text-align:center}.onboarding-modal__page-four__columns .row:last-child{margin-bottom:0}.onboarding-modal__page-four__columns .column-header{color:#000}@media screen and (max-width: 320px)and (max-height: 600px){.onboarding-modal__page p{font-size:14px;line-height:20px}.onboarding-modal__page-two .figure,.onboarding-modal__page-three .figure,.onboarding-modal__page-four .figure,.onboarding-modal__page-five .figure{font-size:12px;margin-bottom:10px}.onboarding-modal__page-four__columns .row{margin-bottom:10px}.onboarding-modal__page-four__columns .column-header{padding:5px;font-size:12px}}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.doodle-modal,.favourite-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#17191f;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__relative-time,.doodle-modal .status__relative-time,.favourite-modal .status__relative-time,.confirmation-modal .status__relative-time,.report-modal .status__relative-time,.actions-modal .status__relative-time,.mute-modal .status__relative-time,.block-modal .status__relative-time{color:#444b5d;float:right;font-size:14px;width:auto;margin:initial;padding:initial}.boost-modal .status__display-name,.doodle-modal .status__display-name,.favourite-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:flex}.boost-modal .status__avatar,.doodle-modal .status__avatar,.favourite-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:48px;width:48px}.boost-modal .status__content__spoiler-link,.doodle-modal .status__content__spoiler-link,.favourite-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#17191f}.actions-modal .status{background:#fff;border-bottom-color:#282c37;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#282c37}.boost-modal__container,.favourite-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status,.favourite-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.doodle-modal__action-bar,.favourite-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#282c37;padding:10px;line-height:36px}.boost-modal__action-bar>div,.doodle-modal__action-bar>div,.favourite-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#282c37;padding-right:10px}.boost-modal__action-bar .button,.doodle-modal__action-bar .button,.favourite-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header,.favourite-modal__status-header{font-size:15px}.boost-modal__status-time,.favourite-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #282c37}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #282c37;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #282c37;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #393f4f}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #282c37;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal strong{display:block;font-weight:500}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b90d9;color:#000}.actions-modal ul li:not(:empty) a>.react-toggle,.actions-modal ul li:not(:empty) a>.icon,.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#282c37;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#313543;background-color:transparent}.confirmation-modal__do_not_ask_again{padding-left:20px;padding-right:20px;padding-bottom:10px;font-size:14px}.confirmation-modal__do_not_ask_again label,.confirmation-modal__do_not_ask_again input{vertical-align:middle}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:none;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#ccd7e0}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#000;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.filtered-status-info{text-align:start}.filtered-status-info .spoiler__text{margin-top:20px}.filtered-status-info .account{border-bottom:0}.filtered-status-info .account__display-name strong{color:#000}.filtered-status-info .status__content__spoiler{display:none}.filtered-status-info .status__content__spoiler--visible{display:flex}.filtered-status-info ul{padding:10px;margin-left:12px;list-style:disc inside}.filtered-status-info .filtered-status-edit-link{color:#606984;text-decoration:none}.filtered-status-info .filtered-status-edit-link:hover{text-decoration:underline}.composer{padding:10px}.composer .emoji-picker-dropdown{position:absolute;top:0;right:0}.composer .emoji-picker-dropdown ::-webkit-scrollbar-track:hover,.composer .emoji-picker-dropdown ::-webkit-scrollbar-track:active{background-color:rgba(255,255,255,.3)}.character-counter{cursor:default;font-family:sans-serif,sans-serif;font-size:14px;font-weight:600;color:#282c37}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .composer--spoiler{transition:height .4s ease,opacity .4s ease}.composer--spoiler{height:0;transform-origin:bottom;opacity:0}.composer--spoiler.composer--spoiler--visible{height:36px;margin-bottom:11px;opacity:1}.composer--spoiler input{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px;padding:10px;width:100%;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:vertical}.composer--spoiler input::placeholder{color:#444b5d}.composer--spoiler input:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .composer--spoiler input{font-size:16px}}.single-column .composer--spoiler input{font-size:16px}.composer--warning{color:#000;margin-bottom:15px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.composer--warning a{color:#282c37;font-weight:500;text-decoration:underline}.composer--warning a:active,.composer--warning a:focus,.composer--warning a:hover{text-decoration:none}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-left:5px;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.composer--reply{margin:0 0 10px;border-radius:4px;padding:10px;background:#9baec8;min-height:23px;overflow-y:auto;flex:0 2 auto}.composer--reply>header{margin-bottom:5px;overflow:hidden}.composer--reply>header>.account.small{color:#000}.composer--reply>header>.cancel{float:right;line-height:24px}.composer--reply>.content{position:relative;margin:10px 0;padding:0 12px;font-size:14px;line-height:20px;color:#000;word-wrap:break-word;font-weight:400;overflow:visible;white-space:pre-wrap;padding-top:5px;overflow:hidden}.composer--reply>.content p,.composer--reply>.content pre,.composer--reply>.content blockquote{margin-bottom:20px;white-space:pre-wrap}.composer--reply>.content p:last-child,.composer--reply>.content pre:last-child,.composer--reply>.content blockquote:last-child{margin-bottom:0}.composer--reply>.content h1,.composer--reply>.content h2,.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{margin-top:20px;margin-bottom:20px}.composer--reply>.content h1,.composer--reply>.content h2{font-weight:700;font-size:18px}.composer--reply>.content h2{font-size:16px}.composer--reply>.content h3,.composer--reply>.content h4,.composer--reply>.content h5{font-weight:500}.composer--reply>.content blockquote{padding-left:10px;border-left:3px solid #000;color:#000;white-space:normal}.composer--reply>.content blockquote p:last-child{margin-bottom:0}.composer--reply>.content b,.composer--reply>.content strong{font-weight:700}.composer--reply>.content em,.composer--reply>.content i{font-style:italic}.composer--reply>.content sub{font-size:smaller;text-align:sub}.composer--reply>.content ul,.composer--reply>.content ol{margin-left:1em}.composer--reply>.content ul p,.composer--reply>.content ol p{margin:0}.composer--reply>.content ul{list-style-type:disc}.composer--reply>.content ol{list-style-type:decimal}.composer--reply>.content a{color:#282c37;text-decoration:none}.composer--reply>.content a:hover{text-decoration:underline}.composer--reply>.content a.mention:hover{text-decoration:none}.composer--reply>.content a.mention:hover span{text-decoration:underline}.composer--reply .emojione{width:20px;height:20px;margin:-5px 0 0}.compose-form__autosuggest-wrapper,.autosuggest-input{position:relative;width:100%}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.autosuggest-input label .autosuggest-textarea__textarea{display:block;box-sizing:border-box;margin:0;border:none;border-radius:4px 4px 0 0;padding:10px 32px 0 10px;width:100%;min-height:100px;outline:0;color:#000;background:#fff;font-size:14px;font-family:inherit;resize:none;scrollbar-color:initial}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::placeholder,.autosuggest-input label .autosuggest-textarea__textarea::placeholder{color:#444b5d}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea::-webkit-scrollbar,.autosuggest-input label .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:disabled,.autosuggest-input label .autosuggest-textarea__textarea:disabled{background:#282c37}.compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea:focus,.autosuggest-input label .autosuggest-textarea__textarea:focus{outline:0}@media screen and (max-width: 630px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}}.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{font-size:16px}@media screen and (max-width: 600px){.auto-columns .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.single-column .compose-form__autosuggest-wrapper label .autosuggest-textarea__textarea,.auto-columns .autosuggest-input label .autosuggest-textarea__textarea,.single-column .autosuggest-input label .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.composer--textarea--icons{display:block;position:absolute;top:29px;right:5px;bottom:5px;overflow:hidden}.composer--textarea--icons>.textarea_icon{display:block;margin:2px 0 0 2px;width:24px;height:24px;color:#282c37;font-size:18px;line-height:24px;text-align:center;opacity:.8}.autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.autosuggest-textarea__suggestions{display:block;position:absolute;box-sizing:border-box;top:100%;border-radius:0 0 4px 4px;padding:6px;width:100%;color:#000;background:#282c37;box-shadow:4px 4px 6px rgba(0,0,0,.4);font-size:14px;z-index:99;display:none}.autosuggest-textarea__suggestions--visible{display:block}.autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#3d4455}.autosuggest-textarea__suggestions__item>.account,.autosuggest-textarea__suggestions__item>.emoji,.autosuggest-textarea__suggestions__item>.autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.autosuggest-textarea__suggestions__item .autosuggest-hashtag{justify-content:space-between}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item .autosuggest-hashtag strong{font-weight:500}.autosuggest-textarea__suggestions__item .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.autosuggest-textarea__suggestions__item>.account.small .display-name>span{color:#282c37}.composer--upload_form{overflow:hidden}.composer--upload_form>.content{display:flex;flex-direction:row;flex-wrap:wrap;font-family:inherit;padding:5px;overflow:hidden}.composer--upload_form--item{flex:1 1 0;margin:5px;min-width:40%}.composer--upload_form--item>div{position:relative;border-radius:4px;height:140px;width:100%;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;overflow:hidden}.composer--upload_form--item>div textarea{display:block;position:absolute;box-sizing:border-box;bottom:0;left:0;margin:0;border:0;padding:10px;width:100%;color:#282c37;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);font-size:14px;font-family:inherit;font-weight:500;opacity:0;z-index:2;transition:opacity .1s ease}.composer--upload_form--item>div textarea:focus{color:#fff}.composer--upload_form--item>div textarea::placeholder{opacity:.54;color:#282c37}.composer--upload_form--item>div>.close{mix-blend-mode:difference}.composer--upload_form--item.active>div textarea{opacity:1}.composer--upload_form--actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.composer--upload_form--actions .icon-button{flex:0 1 auto;color:#282c37;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.composer--upload_form--actions .icon-button:hover,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:active{color:#1f232b}.composer--upload_form--actions.active{opacity:1}.composer--upload_form--progress{display:flex;padding:10px;color:#282c37;overflow:hidden}.composer--upload_form--progress>.fa{font-size:34px;margin-right:10px}.composer--upload_form--progress>.message{flex:1 1 auto}.composer--upload_form--progress>.message>span{display:block;font-size:12px;font-weight:500;text-transform:uppercase}.composer--upload_form--progress>.message>.backdrop{position:relative;margin-top:5px;border-radius:6px;width:100%;height:6px;background:#3c5063}.composer--upload_form--progress>.message>.backdrop>.tracker{position:absolute;top:0;left:0;height:6px;border-radius:6px;background:#2b90d9}.compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.composer--options-wrapper{padding:10px;background:#fff;border-radius:0 0 4px 4px;height:27px;display:flex;justify-content:space-between;flex:0 0 auto}.composer--options{display:flex;flex:0 0 auto}.composer--options>*{display:inline-block;box-sizing:content-box;padding:0 3px;height:27px;line-height:27px;vertical-align:bottom}.composer--options>hr{display:inline-block;margin:0 3px;border-width:0 0 0 1px;border-style:none none none solid;border-color:transparent transparent transparent #fff;padding:0;width:0;height:27px;background:transparent}.compose--counter-wrapper{align-self:center;margin-right:4px}.composer--options--dropdown.open>.value{border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1);color:#000;background:#2b90d9;transition:none}.composer--options--dropdown.open.top>.value{border-radius:0 0 4px 4px;box-shadow:0 4px 4px rgba(0,0,0,.1)}.composer--options--dropdown--content{position:absolute;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);background:#fff;overflow:hidden;transform-origin:50% 0}.composer--options--dropdown--content--item{display:flex;align-items:center;padding:10px;color:#000;cursor:pointer}.composer--options--dropdown--content--item>.content{flex:1 1 auto;color:#282c37}.composer--options--dropdown--content--item>.content:not(:first-child){margin-left:10px}.composer--options--dropdown--content--item>.content strong{display:block;color:#000;font-weight:500}.composer--options--dropdown--content--item:hover,.composer--options--dropdown--content--item.active{background:#2b90d9;color:#000}.composer--options--dropdown--content--item:hover>.content,.composer--options--dropdown--content--item.active>.content{color:#000}.composer--options--dropdown--content--item:hover>.content strong,.composer--options--dropdown--content--item.active>.content strong{color:#000}.composer--options--dropdown--content--item.active:hover{background:#2485cb}.composer--publisher{padding-top:10px;text-align:right;white-space:nowrap;overflow:hidden;justify-content:flex-end;flex:0 0 auto}.composer--publisher>.primary{display:inline-block;margin:0;padding:0 10px;text-align:center}.composer--publisher>.side_arm{display:inline-block;margin:0 2px;padding:0;width:36px;text-align:center}.composer--publisher.over>.count{color:#ff5050}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#f2f5f7;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#d9e1e8}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.column{overflow:hidden}.column-back-button{box-sizing:border-box;width:100%;background:#ccd7e0;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;border:0;text-align:unset;padding:15px;margin:0;z-index:3}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#ccd7e0;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.column-link{background:#c0cdd9;color:#000;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#b6c5d3}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#282c37}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#000}.column-link--transparent.active{color:#2b90d9}.column-link__icon{display:inline-block;margin-right:5px}.column-subheading{background:#d9e1e8;color:#444b5d;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 144, 217, 0.23) 0%, rgba(43, 144, 217, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#ccd7e0;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden}.column-header>button{margin:0;border:none;padding:15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,144,217,.4)}.column-header:focus,.column-header:active{outline:0}.column{width:330px;position:relative;box-sizing:border-box;display:flex;flex-direction:column;overflow:hidden}.wide .columns-area:not(.columns-area--mobile) .column{flex:auto;min-width:330px;max-width:400px}.column>.scrollable{background:#d9e1e8}.column-header__buttons{height:48px;display:flex;margin-left:0}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button,.column-header__notif-cleaning-buttons button{background:#ccd7e0;border:0;color:#282c37;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover,.column-header__notif-cleaning-buttons button:hover{color:#191b22}.column-header__button.active,.column-header__notif-cleaning-buttons button.active{color:#000;background:#c0cdd9}.column-header__button.active:hover,.column-header__notif-cleaning-buttons button.active:hover{color:#000;background:#c0cdd9}.column-header__button:focus,.column-header__notif-cleaning-buttons button:focus{text-shadow:0 0 4px #419bdd}.column-header__notif-cleaning-buttons{display:flex;align-items:stretch;justify-content:space-around}.column-header__notif-cleaning-buttons button{background:transparent;text-align:center;padding:10px 0;white-space:pre-wrap}.column-header__notif-cleaning-buttons b{font-weight:bold}.column-header__collapsible-inner.nopad-drawer{padding:0}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#282c37;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #b3c3d1;margin:10px 0}.column-header__collapsible.ncd{transition:none}.column-header__collapsible.ncd.collapsed{max-height:0;opacity:.7}.column-header__collapsible-inner{background:#c0cdd9;padding:15px}.column-header__setting-btn:hover{color:#282c37;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.column-header__title{display:inline-block;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header__icon{display:inline-block;margin-right:5px}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#444b5d;background:#d9e1e8;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#e6ebf0;contain:initial}.error-column{flex-direction:column}.single-column.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}@media screen and (max-width: 415px){.auto-columns.navbar-under .tabs-bar{margin-top:0 !important;margin-bottom:-6px !important}}@media screen and (max-width: 415px){.auto-columns.navbar-under .react-swipeable-view-container .columns-area,.single-column.navbar-under .react-swipeable-view-container .columns-area{height:100% !important}}.column-inline-form{padding:7px 15px;padding-right:5px;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%;margin-bottom:6px}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 5px}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#444b5d;background:#d9e1e8;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#444b5d}.regeneration-indicator__label span{font-size:15px;font-weight:400}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#fff;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#ccd7e0;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#f2f5f7;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#d9e1e8;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #c0cdd9;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.filter-form{background:#d9e1e8}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#217aba;background:#217aba}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:none;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#1f232b}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#ccd7e0}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:color,transform,opacity;font-size:18px;width:18px;height:18px;color:#282c37;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(0deg)}.search__icon .fa-search.active{pointer-events:auto;opacity:.3}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#606984;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#51596f}.search-results__header{color:#444b5d;background:#d3dce4;border-bottom:1px solid #e6ebf0;padding:15px 10px;font-size:14px;font-weight:500}.search-results__info{padding:20px;color:#282c37;text-align:center}.trends__header{color:#444b5d;background:#d3dce4;border-bottom:1px solid #e6ebf0;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #c0cdd9}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#444b5d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#282c37;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#282c37}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#2380c3 !important}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(40,44,55,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.doodle-modal{width:unset}.doodle-modal__container{background:#d9e1e8;text-align:center;line-height:0}.doodle-modal__container canvas{border:5px solid #d9e1e8}.doodle-modal__action-bar .filler{flex-grow:1;margin:0;padding:0}.doodle-modal__action-bar .doodle-toolbar{line-height:1;display:flex;flex-direction:column;flex-grow:0;justify-content:space-around}.doodle-modal__action-bar .doodle-toolbar.with-inputs label{display:inline-block;width:70px;text-align:right;margin-right:2px}.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=number],.doodle-modal__action-bar .doodle-toolbar.with-inputs input[type=text]{width:40px}.doodle-modal__action-bar .doodle-toolbar.with-inputs span.val{display:inline-block;text-align:left;width:50px}.doodle-modal__action-bar .doodle-palette{padding-right:0 !important;border:1px solid #000;line-height:.2rem;flex-grow:0;background:#fff}.doodle-modal__action-bar .doodle-palette button{appearance:none;width:1rem;height:1rem;margin:0;padding:0;text-align:center;color:#000;text-shadow:0 0 1px #fff;cursor:pointer;box-shadow:inset 0 0 1px rgba(255,255,255,.5);border:1px solid #000;outline-offset:-1px}.doodle-modal__action-bar .doodle-palette button.foreground{outline:1px dashed #fff}.doodle-modal__action-bar .doodle-palette button.background{outline:1px dashed red}.doodle-modal__action-bar .doodle-palette button.foreground.background{outline:1px dashed red;border-color:#fff}.drawer{width:300px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden;padding:10px 5px;flex:none}.drawer:first-child{padding-left:10px}.drawer:last-child{padding-right:10px}@media screen and (max-width: 630px){.auto-columns .drawer{flex:auto}}.single-column .drawer{flex:auto}@media screen and (max-width: 630px){.auto-columns .drawer,.auto-columns .drawer:first-child,.auto-columns .drawer:last-child,.single-column .drawer,.single-column .drawer:first-child,.single-column .drawer:last-child{padding:0}}.wide .drawer{min-width:300px;max-width:400px;flex:1 1 200px}@media screen and (max-width: 630px){:root .auto-columns .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}}:root .single-column .drawer{flex:auto;width:100%;min-width:0;max-width:none;padding:0}.react-swipeable-view-container .drawer{height:100%}.drawer--header{display:flex;flex-direction:row;margin-bottom:10px;flex:none;background:#c0cdd9;font-size:16px}.drawer--header>*{display:block;box-sizing:border-box;border-bottom:2px solid transparent;padding:15px 5px 13px;height:48px;flex:1 1 auto;color:#282c37;text-align:center;text-decoration:none;cursor:pointer}.drawer--header a{transition:background 100ms ease-in}.drawer--header a:focus,.drawer--header a:hover{outline:none;background:#cfd9e2;transition:background 200ms ease-out}.search{position:relative;margin-bottom:10px;flex:none}@media screen and (max-width: 415px){.auto-columns .search,.single-column .search{margin-bottom:0}}@media screen and (max-width: 630px){.auto-columns .search{font-size:16px}}.single-column .search{font-size:16px}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#444b5d;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}.drawer--account{padding:10px;color:#282c37;display:flex;align-items:center}.drawer--account a{color:inherit;text-decoration:none}.drawer--account .acct{display:block;color:#282c37;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;overflow:hidden}.drawer--results{background:#d9e1e8;overflow-x:hidden;overflow-y:auto}.drawer--results>header{color:#444b5d;background:#d3dce4;padding:15px;font-weight:500;font-size:16px;cursor:default}.drawer--results>header .fa{display:inline-block;margin-right:5px}.drawer--results>section{margin-bottom:5px}.drawer--results>section h5{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#444b5d}.drawer--results>section h5 .fa{display:inline-block;margin-right:5px}.drawer--results>section .account:last-child,.drawer--results>section>div:last-child .status{border-bottom:0}.drawer--results>section>.hashtag{display:block;padding:10px;color:#282c37;text-decoration:none}.drawer--results>section>.hashtag:hover,.drawer--results>section>.hashtag:active,.drawer--results>section>.hashtag:focus{color:#1f232b;text-decoration:underline}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#b0c0cf;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%}.drawer__inner.darker{background:#d9e1e8}.drawer__inner__mastodon{background:#b0c0cf url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}.drawer__inner__mastodon>.mastodon{display:block;width:100%;height:100%;border:none;cursor:inherit}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#b0c0cf;font-size:13px;text-align:left}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,.5)}.video-error-cover{align-items:center;background:#fff;color:#000;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#fff;color:#282c37;border:0;width:100%;height:100%}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{color:#17191f}.status__content>.media-spoiler{margin-top:15px}.media-spoiler.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:500}.media-gallery__gifv__label{display:block;position:absolute;color:#000;background:rgba(255,255,255,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{height:100%;display:flex;flex-direction:column}.media-gallery__audio span{text-align:center;color:#282c37;display:flex;height:100%;align-items:center}.media-gallery__audio span p{width:100%}.media-gallery__audio audio{width:100%}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%;height:110px}.media-gallery.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.media-gallery__item{border:none;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.full-width .media-gallery__item{border-radius:0}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item.letterbox{background:#000}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#282c37;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%;object-fit:contain}.media-gallery__item-thumbnail:not(.letterbox),.media-gallery__item-thumbnail img:not(.letterbox){height:100%;object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#fff}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%;display:flex;justify-content:center}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;width:100%;position:relative;z-index:1;object-fit:contain;user-select:none}.media-gallery__item-gifv-thumbnail:not(.letterbox){height:100%;object-fit:cover}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(255,255,255,.5);box-sizing:border-box;border:0;color:#000;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#282c37}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b90d9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#f2f5f7;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #ccd7e0;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(33,122,186,.5)}.audio-player__wave-placeholder{background-color:#a6b9c9}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#f2f5f7;border-top:1px solid #ccd7e0;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.detailed-status .video-player{width:100%;height:100%}.video-player.full-width{margin-left:-14px;margin-right:-14px;width:inherit;max-width:none;height:250px;border-radius:0px}.video-player video{max-width:100vw;max-height:80vh;z-index:1;position:relative}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#282c37;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#191b22}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons-bar .video-player__download__icon .fa,.video-player__buttons-bar .video-player__download__icon:active .fa,.video-player__buttons-bar .video-player__download__icon:hover .fa,.video-player__buttons-bar .video-player__download__icon:focus .fa{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#217aba}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#217aba}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.sensitive-info{display:flex;flex-direction:row;align-items:center;position:absolute;top:4px;left:4px;z-index:100}.sensitive-marker{margin:0 3px;border-radius:2px;padding:2px 6px;color:rgba(0,0,0,.8);background:rgba(255,255,255,.5);font-size:12px;line-height:18px;text-transform:uppercase;opacity:.9;transition:opacity .1s ease}.media-gallery:hover .sensitive-marker{opacity:1}.list-editor{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#b0c0cf;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#b0c0cf}.list-adder__lists{background:#b0c0cf;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #c0cdd9}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #393f4f}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#282c37}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#282c37;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#313543}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#3c99dc}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#3897db}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(255,255,255,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(40,44,55,.3);color:#000;border:1px solid #282c37;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(40,44,55,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#444b5d}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.glitch.local-settings{position:relative;display:flex;flex-direction:row;background:#282c37;color:#000;border-radius:8px;height:80vh;width:80vw;max-width:740px;max-height:450px;overflow:hidden}.glitch.local-settings label,.glitch.local-settings legend{display:block;font-size:14px}.glitch.local-settings .boolean label,.glitch.local-settings .radio_buttons label{position:relative;padding-left:28px;padding-top:3px}.glitch.local-settings .boolean label input,.glitch.local-settings .radio_buttons label input{position:absolute;left:0;top:0}.glitch.local-settings span.hint{display:block;color:#282c37}.glitch.local-settings h1{font-size:18px;font-weight:500;line-height:24px;margin-bottom:20px}.glitch.local-settings h2{font-size:15px;font-weight:500;line-height:20px;margin-top:20px;margin-bottom:10px}.glitch.local-settings__navigation__item{display:block;padding:15px 20px;color:inherit;background:#17191f;border-bottom:1px #282c37 solid;cursor:pointer;text-decoration:none;outline:none;transition:background .3s}.glitch.local-settings__navigation__item .text-icon-button{color:inherit;transition:unset}.glitch.local-settings__navigation__item:hover{background:#282c37}.glitch.local-settings__navigation__item.active{background:#2b90d9;color:#000}.glitch.local-settings__navigation__item.close,.glitch.local-settings__navigation__item.close:hover{background:#df405a;color:#000}.glitch.local-settings__navigation{background:#17191f;width:212px;font-size:15px;line-height:20px;overflow-y:auto}.glitch.local-settings__page{display:block;flex:auto;padding:15px 20px 15px 20px;width:360px;overflow-y:auto}.glitch.local-settings__page__item{margin-bottom:2px}.glitch.local-settings__page__item.string,.glitch.local-settings__page__item.radio_buttons{margin-top:10px;margin-bottom:10px}@media screen and (max-width: 630px){.glitch.local-settings__navigation{width:40px;flex-shrink:0}.glitch.local-settings__navigation__item{padding:10px}.glitch.local-settings__navigation__item span:last-of-type{display:none}}.error-boundary{color:#000;font-size:15px;line-height:20px}.error-boundary h1{font-size:26px;line-height:36px;font-weight:400;margin-bottom:8px}.error-boundary a{color:#000;text-decoration:underline}.error-boundary ul{list-style:disc;margin-left:0;padding-left:1em}.error-boundary textarea.web_app_crash-stacktrace{width:100%;resize:none;white-space:pre;font-family:monospace,monospace}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.compose-panel .search__icon .fa{top:15px}.compose-panel .drawer--account{flex:0 1 48px}.compose-panel .flex-spacer{background:transparent}.compose-panel .composer{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #ccd7e0;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px;min-height:50px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{padding-top:15px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#3897db;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#227dbe}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.search{margin-bottom:10px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#282c37;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#217aba}.announcements{background:#c0cdd9;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#282c37;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#b3c3d1;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#282c37}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#a6b9c9;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#1f232b}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#98b9d3}.reactions-bar__item.active .reactions-bar__item__count{color:#217aba}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#282c37;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#1f232b;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll ul,.e-content .poll ul{margin:0;list-style:none}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#abbbd1;height:5px;min-width:1%}.poll__chart.leading{background:#2b90d9}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;display:block;outline:0;font-family:inherit;background:#fff;border:1px solid #fff;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#2b90d9}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#4d9c74;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#444b5d}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#444b5d;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(68,75,93,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #fff;overflow-x:hidden}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #fff;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{width:100%;flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#606984;border-color:#606984;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#fff}.muted .poll{color:#444b5d}.muted .poll__chart{background:rgba(201,211,225,.2)}.muted .poll__chart.leading{background:rgba(43,144,217,.2)}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:sans-serif,sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#282c37}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#282c37}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#282c37}.rich-formatting em{font-style:italic;color:#282c37}.rich-formatting code{font-size:.85em;background:#f2f5f7;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:sans-serif,sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#282c37}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #ccd7e0;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #ccd7e0;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#282c37}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#444b5d}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#e6ebf0;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:sans-serif,sans-serif;font-size:16px;line-height:28px;color:#000;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#282c37}.information-board__section strong{font-family:sans-serif,sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#f2f5f7;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#282c37;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #ccd7e0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#3d4455}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;width:80px;height:80px;background-size:80px 80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px;border-radius:8%;background-position:50%;background-clip:padding-box}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#000;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#282c37}.landing-page p,.landing-page li{font-family:sans-serif,sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#282c37}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.landing-page h1{font-family:sans-serif,sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h1 small{font-family:sans-serif,sans-serif;display:block;font-size:18px;font-weight:400;color:#131419}.landing-page h2{font-family:sans-serif,sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h3{font-family:sans-serif,sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h4{font-family:sans-serif,sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h5{font-family:sans-serif,sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h6{font-family:sans-serif,sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(60,80,99,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#d9e1e8;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#131419}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px;width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#d9e1e8;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#282c37}.landing-page__short-description h1{font-weight:500;color:#000;margin-bottom:0}.landing-page__short-description h1 small{color:#282c37}.landing-page__short-description h1 small span{color:#282c37}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#000;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#d9e1e8;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:sans-serif,sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#282c37}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#282c37}.landing .simple_form p.lead{color:#282c37;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #c0cdd9}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#444b5d}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #d9e1e8;text-align:left;background:#e6ebf0}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #d9e1e8;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#d9e1e8}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#d9e1e8;border-top:1px solid #f2f5f7;border-bottom:1px solid #f2f5f7}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #f2f5f7}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #f2f5f7}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:monospace,monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#282c37;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#000}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #f2f5f7;background:#d9e1e8;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #f2f5f7;border-top:0;background:#d9e1e8}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #f2f5f7;border-top:0;background:#e6ebf0}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #f2f5f7}}.batch-table__row:hover{background:#dfe6ec}.batch-table__row:nth-child(even){background:#d9e1e8}.batch-table__row:nth-child(even):hover{background:#d3dce4}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#282c37;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #f2f5f7;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #f2f5f7}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#d9e1e8;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#c0cdd9;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#000;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#282c37;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#b3c3d1}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#282c37;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#000;background-color:#e9eef2;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#dfe6ec;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#e6ebf0;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#000;background-color:#2b90d9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#2482c7}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #c0cdd9;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#282c37;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#282c37;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#282c37;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #c0cdd9}.admin-wrapper .content h6{font-size:16px;color:#282c37;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#000;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#000;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:none}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#282c37;margin-bottom:20px}.admin-wrapper .content>p strong{color:#000;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(60,80,99,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #ccd7e0;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b90d9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#282c37}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#444b5d;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#282c37;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #d9e1e8}.filters .filter-subset a:hover{color:#000;border-bottom:2px solid #c9d4de}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b90d9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#282c37}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#d9e1e8;border-bottom:1px solid #ccd7e0}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#282c37;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#444b5d}.log-entry a,.log-entry .username,.log-entry .target{color:#282c37;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#282c37}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#c1203b}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b90d9}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#c1203b}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#282c37}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#444b5d}.report-card{background:#d9e1e8;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#282c37;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#17191f}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #e6ebf0}.report-card__summary__item:hover{background:#d3dce4}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#282c37}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#444b5d;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#282c37}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#c0cdd9;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#217aba}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#000}.center-text{text-align:center}.announcements-list{border:1px solid #ccd7e0;border-radius:4px}.announcements-list__item{padding:15px 0;background:#d9e1e8;border-bottom:1px solid #ccd7e0}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#282c37;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#000}.announcements-list__item__meta{padding:0 15px;color:#444b5d}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.emojione[title=\":wind_blowing_face:\"],.emojione[title=\":white_small_square:\"],.emojione[title=\":white_medium_square:\"],.emojione[title=\":white_medium_small_square:\"],.emojione[title=\":white_large_square:\"],.emojione[title=\":white_circle:\"],.emojione[title=\":waxing_crescent_moon:\"],.emojione[title=\":waving_white_flag:\"],.emojione[title=\":waning_gibbous_moon:\"],.emojione[title=\":waning_crescent_moon:\"],.emojione[title=\":volleyball:\"],.emojione[title=\":thought_balloon:\"],.emojione[title=\":speech_balloon:\"],.emojione[title=\":speaker:\"],.emojione[title=\":sound:\"],.emojione[title=\":snow_cloud:\"],.emojione[title=\":skull_and_crossbones:\"],.emojione[title=\":skull:\"],.emojione[title=\":sheep:\"],.emojione[title=\":rooster:\"],.emojione[title=\":rice_ball:\"],.emojione[title=\":rice:\"],.emojione[title=\":ram:\"],.emojione[title=\":rain_cloud:\"],.emojione[title=\":page_with_curl:\"],.emojione[title=\":mute:\"],.emojione[title=\":moon:\"],.emojione[title=\":loud_sound:\"],.emojione[title=\":lightning:\"],.emojione[title=\":last_quarter_moon_with_face:\"],.emojione[title=\":last_quarter_moon:\"],.emojione[title=\":ice_skate:\"],.emojione[title=\":grey_question:\"],.emojione[title=\":grey_exclamation:\"],.emojione[title=\":goat:\"],.emojione[title=\":ghost:\"],.emojione[title=\":full_moon_with_face:\"],.emojione[title=\":full_moon:\"],.emojione[title=\":fish_cake:\"],.emojione[title=\":first_quarter_moon_with_face:\"],.emojione[title=\":first_quarter_moon:\"],.emojione[title=\":eyes:\"],.emojione[title=\":dove_of_peace:\"],.emojione[title=\":dash:\"],.emojione[title=\":crescent_moon:\"],.emojione[title=\":cloud:\"],.emojione[title=\":chicken:\"],.emojione[title=\":chains:\"],.emojione[title=\":baseball:\"],.emojione[title=\":alien:\"]{filter:drop-shadow(1px 1px 0 #000000) drop-shadow(-1px 1px 0 #000000) drop-shadow(1px -1px 0 #000000) drop-shadow(-1px -1px 0 #000000)}.hicolor-privacy-icons .status__visibility-icon.fa-globe,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-globe{color:#1976d2}.hicolor-privacy-icons .status__visibility-icon.fa-unlock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-unlock{color:#388e3c}.hicolor-privacy-icons .status__visibility-icon.fa-lock,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-lock{color:#ffa000}.hicolor-privacy-icons .status__visibility-icon.fa-envelope,.hicolor-privacy-icons .composer--options--dropdown--content--item .fa-envelope{color:#d32f2f}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .composer--publisher{text-align:left}body.rtl .boost-modal__status-time,body.rtl .favourite-modal__status-time{float:left}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .setting-meta__label{float:left}body.rtl .status__avatar{margin-left:10px;margin-right:0;left:auto;right:10px}body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:58px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left;text-align:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(249, 250, 251, 0), #f9fafb)}body.rtl .simple_form select{background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#ccd7e0;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#c0cdd9}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#000;font-family:sans-serif,sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#282c37;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#282c37;font-weight:500;text-decoration:none}.glitch.local-settings{background:#d9e1e8}.glitch.local-settings__navigation{background:#f2f5f7}.glitch.local-settings__navigation__item{background:#f2f5f7}.glitch.local-settings__navigation__item:hover{background:#d9e1e8}.notification__dismiss-overlay .wrappy{box-shadow:unset}.notification__dismiss-overlay .ckbox{text-shadow:unset}.status.status-direct:not(.read){background:#f2f5f7;border-bottom-color:#fff}.status.status-direct:not(.read).collapsed>.status__content:after{background:linear-gradient(rgba(242, 245, 247, 0), #f2f5f7)}.focusable:focus.status.status-direct:not(.read){background:#e6ebf0}.focusable:focus.status.status-direct:not(.read).collapsed>.status__content:after{background:linear-gradient(rgba(230, 235, 240, 0), #e6ebf0)}.column>.scrollable{background:#fff}.status.collapsed .status__content:after{background:linear-gradient(rgba(255, 255, 255, 0), white)}.drawer__inner{background:#d9e1e8}.drawer__inner__mastodon{background:#d9e1e8 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto !important}.drawer__inner__mastodon .mastodon{filter:contrast(75%) brightness(75%) !important}.status__content .status__content__spoiler-link{background:#7a96ae}.status__content .status__content__spoiler-link:hover{background:#6a89a5;text-decoration:none}.media-spoiler,.video-player__spoiler,.account-gallery__item a{background:#d9e1e8}.dropdown-menu{background:#d9e1e8}.dropdown-menu__arrow.left{border-left-color:#d9e1e8}.dropdown-menu__arrow.top{border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{border-right-color:#d9e1e8}.dropdown-menu__item a{background:#d9e1e8;color:#282c37}.composer .composer--spoiler input,.composer .compose-form__autosuggest-wrapper textarea{color:#0f151a}.composer .composer--spoiler input:disabled,.composer .compose-form__autosuggest-wrapper textarea:disabled{background:#e6e6e6}.composer .composer--spoiler input::placeholder,.composer .compose-form__autosuggest-wrapper textarea::placeholder{color:#232f39}.composer .composer--options-wrapper{background:#b9c8d5}.composer .composer--options>hr{display:none}.composer .composer--options--dropdown--content--item{color:#9baec8}.composer .composer--options--dropdown--content--item strong{color:#9baec8}.composer--upload_form--actions .icon-button{color:#ededed}.composer--upload_form--actions .icon-button:active,.composer--upload_form--actions .icon-button:focus,.composer--upload_form--actions .icon-button:hover{color:#fff}.composer--upload_form--item>div input{color:#ededed}.composer--upload_form--item>div input::placeholder{color:#e6e6e6}.dropdown-menu__separator{border-bottom-color:#b3c3d1}.status__content a,.reply-indicator__content a{color:#2b90d9}.emoji-mart-bar{border-color:#e6ebf0}.emoji-mart-bar:first-child{background:#b9c8d5}.emoji-mart-search input{background:rgba(217,225,232,.3);border-color:#d9e1e8}.autosuggest-textarea__suggestions{background:#b9c8d5}.autosuggest-textarea__suggestions__item:hover,.autosuggest-textarea__suggestions__item:focus,.autosuggest-textarea__suggestions__item:active,.autosuggest-textarea__suggestions__item.selected{background:#e6ebf0}.react-toggle-track{background:#282c37}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background:#131419}.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background:#56a7e1}.actions-modal,.boost-modal,.doodle-modal,.confirmation-modal,.mute-modal,.block-modal,.report-modal,.embed-modal,.error-modal,.onboarding-modal,.report-modal__comment .setting-text__wrapper,.report-modal__comment .setting-text{background:#fff;border:1px solid #c0cdd9}.report-modal__comment{border-right-color:#c0cdd9}.report-modal__container{border-top-color:#c0cdd9}.boost-modal__action-bar,.doodle-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar,.onboarding-modal__paginator,.error-modal__footer{background:#ecf0f4}.boost-modal__action-bar .onboarding-modal__nav:hover,.doodle-modal__action-bar .onboarding-modal__nav:hover,.boost-modal__action-bar .onboarding-modal__nav:focus,.doodle-modal__action-bar .onboarding-modal__nav:focus,.boost-modal__action-bar .onboarding-modal__nav:active,.doodle-modal__action-bar .onboarding-modal__nav:active,.boost-modal__action-bar .error-modal__nav:hover,.doodle-modal__action-bar .error-modal__nav:hover,.boost-modal__action-bar .error-modal__nav:focus,.doodle-modal__action-bar .error-modal__nav:focus,.boost-modal__action-bar .error-modal__nav:active,.doodle-modal__action-bar .error-modal__nav:active,.confirmation-modal__action-bar .onboarding-modal__nav:hover,.confirmation-modal__action-bar .onboarding-modal__nav:focus,.confirmation-modal__action-bar .onboarding-modal__nav:active,.confirmation-modal__action-bar .error-modal__nav:hover,.confirmation-modal__action-bar .error-modal__nav:focus,.confirmation-modal__action-bar .error-modal__nav:active,.mute-modal__action-bar .onboarding-modal__nav:hover,.mute-modal__action-bar .onboarding-modal__nav:focus,.mute-modal__action-bar .onboarding-modal__nav:active,.mute-modal__action-bar .error-modal__nav:hover,.mute-modal__action-bar .error-modal__nav:focus,.mute-modal__action-bar .error-modal__nav:active,.block-modal__action-bar .onboarding-modal__nav:hover,.block-modal__action-bar .onboarding-modal__nav:focus,.block-modal__action-bar .onboarding-modal__nav:active,.block-modal__action-bar .error-modal__nav:hover,.block-modal__action-bar .error-modal__nav:focus,.block-modal__action-bar .error-modal__nav:active,.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{background-color:#fff}.empty-column-indicator,.error-column{color:#364959}.activity-stream-tabs{background:#fff}.activity-stream-tabs a.active{color:#9baec8}.activity-stream .entry{background:#fff}.activity-stream .status.light .status__content{color:#000}.activity-stream .status.light .display-name strong{color:#000}.accounts-grid .account-grid-card .controls .icon-button{color:#282c37}.accounts-grid .account-grid-card .name a{color:#000}.accounts-grid .account-grid-card .username{color:#282c37}.accounts-grid .account-grid-card .account__header__content{color:#000}.button.logo-button{color:#fff}.button.logo-button svg{fill:#fff}.public-layout .header,.public-layout .public-account-header,.public-layout .public-account-bio{box-shadow:none}.public-layout .header{background:#b3c3d1}.public-layout .public-account-header__image{background:#b3c3d1}.public-layout .public-account-header__image::after{box-shadow:none}.public-layout .public-account-header__tabs__name h1,.public-layout .public-account-header__tabs__name h1 small{color:#fff}.account__section-headline a.active::after{border-color:transparent transparent #fff}.hero-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.moved-account-widget,.memoriam-widget,.activity-stream,.nothing-here,.directory__tag>a,.directory__tag>div{box-shadow:none}.audio-player .video-player__controls button,.audio-player .video-player__time-sep,.audio-player .video-player__time-current,.audio-player .video-player__time-total{color:#000}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n$white: #ffffff;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n$ui-base-color: $classic-secondary-color !default;\n$ui-base-lighter-color: darken($ui-base-color, 57%);\n$ui-highlight-color: $classic-highlight-color !default;\n$ui-primary-color: $classic-primary-color !default;\n$ui-secondary-color: $classic-base-color !default;\n\n$primary-text-color: $black !default;\n$darker-text-color: $classic-base-color !default;\n$dark-text-color: #444b5d;\n$action-button-color: #606984;\n\n$success-green: lighten(#3c754d, 8%);\n\n$base-overlay-background: $white !default;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: $classic-base-color !default;\n$light-text-color: #444b5d;\n\n$account-background-color: $white !default;\n\n//Invert darkened and lightened colors\n@function darken($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) + $amount);\n}\n\n@function lighten($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) - $amount);\n}\n\n$emojis-requiring-outlines: 'alien' 'baseball' 'chains' 'chicken' 'cloud' 'crescent_moon' 'dash' 'dove_of_peace' 'eyes' 'first_quarter_moon' 'first_quarter_moon_with_face' 'fish_cake' 'full_moon' 'full_moon_with_face' 'ghost' 'goat' 'grey_exclamation' 'grey_question' 'ice_skate' 'last_quarter_moon' 'last_quarter_moon_with_face' 'lightning' 'loud_sound' 'moon' 'mute' 'page_with_curl' 'rain_cloud' 'ram' 'rice' 'rice_ball' 'rooster' 'sheep' 'skull' 'skull_and_crossbones' 'snow_cloud' 'sound' 'speaker' 'speech_balloon' 'thought_balloon' 'volleyball' 'waning_crescent_moon' 'waning_gibbous_moon' 'waving_white_flag' 'waxing_crescent_moon' 'white_circle' 'white_large_square' 'white_medium_small_square' 'white_medium_square' 'white_small_square' 'wind_blowing_face';\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n @return '%23' + unquote($color)\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n @include avatar-size(40px);\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1/3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1/3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n @include avatar-size(120px);\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n @include avatar-radius();\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n @include avatar-radius();\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n","@mixin avatar-radius() {\n border-radius: $ui-avatar-border-size;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size:48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin single-column($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .single-column #{$parent} {\n @content;\n }\n}\n\n@mixin limited-single-column($media, $parent: '&') {\n .auto-columns #{$parent}, .single-column #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n}\n\n@mixin multi-columns($media, $parent: '&') {\n .auto-columns #{$parent} {\n @media #{$media} {\n @content;\n }\n }\n .multi-columns #{$parent} {\n @content;\n }\n}\n\n@mixin fullwidth-gallery {\n &.full-width {\n margin-left: -14px;\n margin-right: -14px;\n width: inherit;\n max-width: none;\n height: 250px;\n border-radius: 0px;\n }\n}\n\n@mixin search-input() {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: none;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout() {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a; // Padua\n$error-red: #df405a; // Cerise\n$warning-red: #ff5050; // Sunset Orange\n$gold-star: #ca8f04; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: sans-serif !default;\n$font-display: sans-serif !default;\n$font-monospace: monospace !default;\n\n// Avatar border size (8% default, 100% for rounded avatars)\n$ui-avatar-border-size: 8%;\n\n// More variables\n$dismiss-overlay-width: 4rem;\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n @include avatar-size(48px);\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n @include avatar-radius();\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n padding: 8px 0;\n padding-bottom: 2px;\n margin: initial;\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n position: absolute;\n margin: initial;\n float: initial;\n width: auto;\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n\n// Styling from upstream's WebUI, as public pages use the same layout\n.embed,\n.public-layout {\n .status {\n .status__info {\n font-size: 15px;\n display: initial;\n }\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding: 6px 0;\n padding-right: 25px;\n margin: initial;\n\n .display-name strong {\n display: inline;\n }\n }\n\n .status__avatar {\n height: 48px;\n position: absolute;\n width: 48px;\n margin: initial;\n }\n }\n}\n\n.rtl {\n .embed,\n .public-layout {\n .status {\n padding-left: 10px;\n padding-right: 68px;\n\n .status__info .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .status__relative-time {\n float: left;\n }\n }\n }\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: darken($ui-highlight-color, 3%);\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n transition-property: background-color;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 7%);\n transition: all 200ms ease-out;\n transition-property: background-color;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n color: $darker-text-color;\n text-transform: none;\n background: transparent;\n padding: 3px 15px;\n border-radius: 4px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n transform-origin: 50% 0;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: 0;\n position: absolute;\n\n .fa.star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n a {\n color: inherit;\n text-decoration: inherit;\n }\n\n strong {\n height: 18px;\n font-size: 16px;\n font-weight: 500;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n span {\n display: block;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n }\n\n > a:hover {\n strong {\n text-decoration: underline;\n }\n }\n\n &.inline {\n padding: 0;\n height: 18px;\n font-size: 15px;\n line-height: 18px;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n strong {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n\n span {\n display: inline;\n height: auto;\n font-size: inherit;\n line-height: inherit;\n }\n }\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n ul {\n list-style: none;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.static-content {\n padding: 10px;\n padding-top: 20px;\n color: $dark-text-color;\n\n h1 {\n font-size: 16px;\n font-weight: 500;\n margin-bottom: 40px;\n text-align: center;\n }\n\n p {\n font-size: 13px;\n margin-bottom: 20px;\n }\n}\n\n.column,\n.drawer {\n flex: 1 1 100%;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @include multi-columns('screen and (min-width: 631px)') {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $ui-highlight-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n\n span.icon {\n margin-left: 0;\n display: inline;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.getting-started__wrapper,\n.getting_started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.getting-started__wrapper {\n position: relative;\n overflow-y: auto;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n background: $ui-base-color;\n flex: 1 0 auto;\n\n p {\n color: $secondary-text-color;\n }\n\n a {\n color: $dark-text-color;\n }\n\n &__panel {\n height: min-content;\n }\n\n &__panel,\n &__footer {\n padding: 10px;\n padding-top: 20px;\n flex: 0 1 auto;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n color: $darker-text-color;\n background: transparent;\n border: none;\n border-bottom: 2px solid $ui-primary-color;\n box-sizing: border-box;\n display: block;\n font-family: inherit;\n margin-bottom: 10px;\n padding: 7px 0;\n width: 100%;\n\n &:focus,\n &:active {\n color: $primary-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n font-size: 16px;\n }\n\n &.light {\n color: $inverted-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 27%);\n\n &:focus,\n &:active {\n color: $inverted-text-color;\n border-bottom-color: $ui-highlight-color;\n }\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.reduce-motion button.icon-button.disabled i.fa-retweet {\n color: darken($action-button-color, 13%);\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.missing-indicator {\n padding-top: 20px + 48px;\n}\n\n.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {\n border-top: 1px solid $ui-base-color;\n}\n\n.notification__dismiss-overlay {\n overflow: hidden;\n position: absolute;\n top: 0;\n right: 0;\n bottom: -1px;\n padding-left: 15px; // space for the box shadow to be visible\n\n z-index: 999;\n align-items: center;\n justify-content: flex-end;\n cursor: pointer;\n\n display: flex;\n\n .wrappy {\n width: $dismiss-overlay-width;\n align-self: stretch;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n background: lighten($ui-base-color, 8%);\n border-left: 1px solid lighten($ui-base-color, 20%);\n box-shadow: 0 0 5px black;\n border-bottom: 1px solid $ui-base-color;\n }\n\n .ckbox {\n border: 2px solid $ui-primary-color;\n border-radius: 2px;\n width: 30px;\n height: 30px;\n font-size: 20px;\n color: $darker-text-color;\n text-shadow: 0 0 5px black;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n &:focus {\n outline: 0 !important;\n\n .ckbox {\n box-shadow: 0 0 1px 1px $ui-highlight-color;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.610, 0.355, 1.000);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: flex;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n align-items: center;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label,\n.setting-radio__label,\n.setting-meta__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.setting-radio {\n display: block;\n line-height: 18px;\n}\n\n.setting-radio__label {\n margin-bottom: 0;\n}\n\n.column-settings__row legend {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-top: 10px;\n}\n\n.setting-radio__input {\n vertical-align: middle;\n}\n\n.setting-meta__label {\n float: right;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n transform-origin: center center;\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.pulse-loading {\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.loading-bar {\n background-color: $ui-highlight-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.icon-badge-wrapper {\n position: relative;\n}\n\n.icon-badge {\n position: absolute;\n display: block;\n right: -.25em;\n top: -.25em;\n background-color: $ui-highlight-color;\n border-radius: 50%;\n font-size: 75%;\n width: 1em;\n height: 1em;\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n .status__content {\n margin: 0;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.ui .flash-message {\n margin-top: 10px;\n margin-left: auto;\n margin-right: auto;\n margin-bottom: 0;\n min-width: 75%;\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@import 'boost';\n@import 'accounts';\n@import 'domains';\n@import 'status';\n@import 'modal';\n@import 'composer';\n@import 'columns';\n@import 'regeneration_indicator';\n@import 'directory';\n@import 'search';\n@import 'emoji';\n@import 'doodle';\n@import 'drawer';\n@import 'media';\n@import 'sensitive';\n@import 'lists';\n@import 'emoji_picker';\n@import 'local_settings';\n@import 'error_boundary';\n@import 'single_column';\n@import 'announcements';\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant\nbutton.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\n// Disabled variant for use with DMs\n.status-direct button.icon-button.disabled i.fa-retweet {\n &, &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n",".account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n color: inherit;\n text-decoration: none;\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n\n &.small {\n border: none;\n padding: 0;\n\n & > .account__avatar-wrapper { margin: 0 8px 0 0 }\n\n & > .display-name {\n height: 24px;\n line-height: 24px;\n }\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius();\n position: relative;\n cursor: pointer;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n overflow: hidden;\n position: relative;\n\n & div {\n @include avatar-radius;\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\n.account__avatar-overlay {\n position: relative;\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius();\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius();\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__header__wrapper {\n flex: 0 0 auto;\n background: lighten($ui-base-color, 4%);\n}\n\n.account__disclaimer {\n padding: 10px;\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-left: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &:first-child {\n border-left: 0;\n }\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n abbr {\n color: $highlight-text-color;\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.notification__message {\n margin-left: 42px;\n padding: 8px 0 0 26px;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input();\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout();\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n",".domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n","@keyframes spring-flip-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-242.4deg);\n }\n\n 60% {\n transform: rotate(-158.35deg);\n }\n\n 90% {\n transform: rotate(-187.5deg);\n }\n\n 100% {\n transform: rotate(-180deg);\n }\n}\n\n@keyframes spring-flip-out {\n 0% {\n transform: rotate(-180deg);\n }\n\n 30% {\n transform: rotate(62.4deg);\n }\n\n 60% {\n transform: rotate(-21.635deg);\n }\n\n 90% {\n transform: rotate(7.5deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content {\n position: relative;\n margin: 10px 0;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n padding-top: 5px;\n\n &:focus {\n outline: 0;\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .status__content__text,\n .e-content {\n overflow: hidden;\n\n & > ul,\n & > ol {\n margin-bottom: 20px;\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 1.2em;\n }\n\n h2 {\n font-size: 1.1em;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $darker-text-color;\n color: $darker-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n sup {\n font-size: smaller;\n vertical-align: super;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n .status__content__spoiler {\n display: none;\n\n &.status__content__spoiler--visible {\n display: block;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n\n .link-origin-tag {\n color: $gold-star;\n font-size: 0.8em;\n }\n }\n\n .status__content__spoiler-link {\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: lighten($ui-base-color, 30%);\n border: none;\n color: $inverted-text-color;\n font-weight: 500;\n font-size: 11px;\n padding: 0 5px;\n text-transform: uppercase;\n line-height: inherit;\n cursor: pointer;\n vertical-align: bottom;\n\n &:hover {\n background: lighten($ui-base-color, 33%);\n text-decoration: none;\n }\n\n .status__content__spoiler-icon {\n display: inline-block;\n margin: 0 0 0 5px;\n border-left: 1px solid currentColor;\n padding: 0 0 0 4px;\n font-size: 16px;\n vertical-align: -2px;\n }\n}\n\n.notif-cleaning {\n .status,\n .notification-follow,\n .notification-follow-request {\n padding-right: ($dismiss-overlay-width + 0.5rem);\n }\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.notification-follow,\n.notification-follow-request {\n position: relative;\n\n // same like Status\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .account {\n border-bottom: 0 none;\n }\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n &.status.status-direct:not(.read) {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 10px 14px;\n position: relative;\n height: auto;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 28px; // 12px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $lighter-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n\n &.collapsed {\n background-position: center;\n background-size: cover;\n user-select: none;\n\n &.has-background::before {\n display: block;\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8));\n pointer-events: none;\n content: \"\";\n }\n\n .display-name:hover .display-name__html {\n text-decoration: none;\n }\n\n .status__content {\n height: 20px;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 0;\n\n &:after {\n content: \"\";\n position: absolute;\n top: 0; bottom: 0;\n left: 0; right: 0;\n background: linear-gradient(rgba($ui-base-color, 0), rgba($ui-base-color, 1));\n pointer-events: none;\n }\n \n a:hover {\n text-decoration: none;\n }\n }\n &:focus > .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));\n }\n &.status-direct:not(.read)> .status__content:after {\n background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));\n }\n\n .notification__message {\n margin-bottom: 0;\n }\n\n .status__info .notification__message > span {\n white-space: nowrap;\n }\n }\n\n .notification__message {\n margin: -10px 0px 10px 0;\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time {\n display: inline-block;\n flex-grow: 1;\n color: $dark-text-color;\n font-size: 14px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.status__display-name {\n color: $dark-text-color;\n overflow: hidden;\n}\n\n.status__info__account .status__display-name {\n display: block;\n max-width: 100%;\n}\n\n.status__info {\n display: flex;\n justify-content: space-between;\n font-size: 15px;\n\n > span {\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n .notification__message > span {\n word-wrap: break-word;\n }\n}\n\n.status__info__icons {\n display: flex;\n align-items: center;\n height: 1em;\n color: $action-button-color;\n\n .status__media-icon,\n .status__visibility-icon,\n .status__reply-icon {\n padding-left: 2px;\n padding-right: 2px;\n }\n\n .status__collapse-button.active > .fa-angle-double-up {\n transform: rotate(-180deg);\n }\n}\n\n.no-reduce-motion .status__collapse-button {\n &.activate {\n & > .fa-angle-double-up {\n animation: spring-flip-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-angle-double-up {\n animation: spring-flip-out 1s linear;\n }\n }\n}\n\n.status__info__account {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-top: -10px;\n margin-bottom: 10px;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\na.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\n.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n flex: none;\n margin: 0 10px 0 0;\n height: 48px;\n width: 48px;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a,\n .status__content__text {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-color, 29%);\n text-decoration: none;\n }\n }\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n\n a .fa, a:hover .fa {\n color: inherit;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n.status__wrapper--filtered__button {\n display: inline;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n font-size: inherit;\n line-height: inherit;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n",".modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.onboarding-modal__pager {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 470px;\n\n .react-swipeable-view-container > div {\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n user-select: text;\n }\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n@media screen and (max-width: 550px) {\n .onboarding-modal {\n width: 100%;\n height: 100%;\n border-radius: 0;\n }\n\n .onboarding-modal__pager {\n width: 100%;\n height: auto;\n max-width: none;\n max-height: none;\n flex: 1 1 auto;\n }\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.onboarding-modal__dots {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.onboarding-modal__dot {\n width: 14px;\n height: 14px;\n border-radius: 14px;\n background: darken($ui-secondary-color, 16%);\n margin: 0 3px;\n cursor: pointer;\n\n &:hover {\n background: darken($ui-secondary-color, 18%);\n }\n\n &.active {\n cursor: default;\n background: darken($ui-secondary-color, 24%);\n }\n}\n\n.onboarding-modal__page__wrapper {\n pointer-events: none;\n padding: 25px;\n padding-bottom: 0;\n\n &.onboarding-modal__page__wrapper--active {\n pointer-events: auto;\n }\n}\n\n.onboarding-modal__page {\n cursor: default;\n line-height: 21px;\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 20px;\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 4%);\n }\n }\n\n .navigation-bar a {\n color: inherit;\n }\n\n p {\n font-size: 16px;\n color: $lighter-text-color;\n margin-top: 10px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n background: $ui-base-color;\n color: $secondary-text-color;\n border-radius: 4px;\n font-size: 14px;\n padding: 3px 6px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.onboarding-modal__page__wrapper-0 {\n height: 100%;\n padding: 0;\n}\n\n.onboarding-modal__page-one {\n &__lead {\n padding: 65px;\n padding-top: 45px;\n padding-bottom: 0;\n margin-bottom: 10px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n margin-bottom: 8px;\n }\n\n p {\n margin-bottom: 0;\n }\n }\n\n &__extra {\n padding-right: 65px;\n padding-left: 185px;\n text-align: center;\n }\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboarding-modal__page-two,\n.onboarding-modal__page-three,\n.onboarding-modal__page-four,\n.onboarding-modal__page-five {\n p {\n text-align: left;\n }\n\n .figure {\n background: darken($ui-base-color, 8%);\n color: $secondary-text-color;\n margin-bottom: 20px;\n border-radius: 4px;\n padding: 10px;\n text-align: center;\n font-size: 14px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.3);\n\n .onboarding-modal__image {\n border-radius: 4px;\n margin-bottom: 10px;\n }\n\n &.non-interactive {\n pointer-events: none;\n text-align: left;\n }\n }\n}\n\n.onboarding-modal__page-four__columns {\n .row {\n display: flex;\n margin-bottom: 20px;\n\n & > div {\n flex: 1 1 0;\n margin: 0 10px;\n\n &:first-child {\n margin-left: 0;\n }\n\n &:last-child {\n margin-right: 0;\n }\n\n p {\n text-align: center;\n }\n }\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .column-header {\n color: $primary-text-color;\n }\n}\n\n@media screen and (max-width: 320px) and (max-height: 600px) {\n .onboarding-modal__page p {\n font-size: 14px;\n line-height: 20px;\n }\n\n .onboarding-modal__page-two .figure,\n .onboarding-modal__page-three .figure,\n .onboarding-modal__page-four .figure,\n .onboarding-modal__page-five .figure {\n font-size: 12px;\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .row {\n margin-bottom: 10px;\n }\n\n .onboarding-modal__page-four__columns .column-header {\n padding: 5px;\n font-size: 12px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.favourite-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__relative-time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n width: auto;\n margin: initial;\n padding: initial;\n }\n\n .status__display-name {\n display: flex;\n }\n\n .status__avatar {\n height: 48px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container,\n.favourite-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.favourite-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header,\n.favourite-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time,\n.favourite-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n strong {\n display: block;\n font-weight: 500;\n }\n\n max-height: 80vh;\n max-width: 80vw;\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n & > .react-toggle,\n & > .icon,\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__do_not_ask_again {\n padding-left: 20px;\n padding-right: 20px;\n padding-bottom: 10px;\n\n font-size: 14px;\n\n label, input {\n vertical-align: middle;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: none;\n padding: 10px;\n font-family: 'mastodon-font-monospace', monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.filtered-status-info {\n text-align: start;\n\n .spoiler__text {\n margin-top: 20px;\n }\n\n .account {\n border-bottom: 0;\n }\n\n .account__display-name strong {\n color: $inverted-text-color;\n }\n\n .status__content__spoiler {\n display: none;\n\n &--visible {\n display: flex;\n }\n }\n\n ul {\n padding: 10px;\n margin-left: 12px;\n list-style: disc inside;\n }\n\n .filtered-status-edit-link {\n color: $action-button-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline\n }\n }\n}\n",".composer {\n padding: 10px;\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n\n ::-webkit-scrollbar-track:hover,\n ::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .composer--spoiler {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.composer--spoiler {\n height: 0;\n transform-origin: bottom;\n opacity: 0.0;\n\n &.composer--spoiler--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1.0;\n }\n\n input {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px;\n padding: 10px;\n width: 100%;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: vertical;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n }\n}\n\n.composer--warning {\n color: $inverted-text-color;\n margin-bottom: 15px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:active,\n &:focus,\n &:hover { text-decoration: none }\n }\n}\n\n.compose-form__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-left: 5px;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n}\n\n.composer--reply {\n margin: 0 0 10px;\n border-radius: 4px;\n padding: 10px;\n background: $ui-primary-color;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n\n & > header {\n margin-bottom: 5px;\n overflow: hidden;\n\n & > .account.small { color: $inverted-text-color; }\n\n & > .cancel {\n float: right;\n line-height: 24px;\n }\n }\n\n & > .content {\n position: relative;\n margin: 10px 0;\n padding: 0 12px;\n font-size: 14px;\n line-height: 20px;\n color: $inverted-text-color;\n word-wrap: break-word;\n font-weight: 400;\n overflow: visible;\n white-space: pre-wrap;\n padding-top: 5px;\n overflow: hidden;\n\n p, pre, blockquote {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n h1, h2, h3, h4, h5 {\n margin-top: 20px;\n margin-bottom: 20px;\n }\n\n h1, h2 {\n font-weight: 700;\n font-size: 18px;\n }\n\n h2 {\n font-size: 16px;\n }\n\n h3, h4, h5 {\n font-weight: 500;\n }\n\n blockquote {\n padding-left: 10px;\n border-left: 3px solid $inverted-text-color;\n color: $inverted-text-color;\n white-space: normal;\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n b, strong {\n font-weight: 700;\n }\n\n em, i {\n font-style: italic;\n }\n\n sub {\n font-size: smaller;\n text-align: sub;\n }\n\n ul, ol {\n margin-left: 1em;\n\n p {\n margin: 0;\n }\n }\n\n ul {\n list-style-type: disc;\n }\n\n ol {\n list-style-type: decimal;\n }\n\n a {\n color: $lighter-text-color;\n text-decoration: none;\n\n &:hover { text-decoration: underline }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span { text-decoration: underline }\n }\n }\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -5px 0 0;\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.autosuggest-input {\n position: relative;\n width: 100%;\n\n label {\n .autosuggest-textarea__textarea {\n display: block;\n box-sizing: border-box;\n margin: 0;\n border: none;\n border-radius: 4px 4px 0 0;\n padding: 10px 32px 0 10px;\n width: 100%;\n min-height: 100px;\n outline: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n font-size: 14px;\n font-family: inherit;\n resize: none;\n scrollbar-color: initial;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n &:disabled { background: $ui-secondary-color }\n &:focus { outline: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n\n @include limited-single-column('screen and (max-width: 600px)') {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n }\n}\n\n.composer--textarea--icons {\n display: block;\n position: absolute;\n top: 29px;\n right: 5px;\n bottom: 5px;\n overflow: hidden;\n\n & > .textarea_icon {\n display: block;\n margin: 2px 0 0 2px;\n width: 24px;\n height: 24px;\n color: $lighter-text-color;\n font-size: 18px;\n line-height: 24px;\n text-align: center;\n opacity: .8;\n }\n}\n\n.autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n}\n\n.autosuggest-textarea__suggestions {\n display: block;\n position: absolute;\n box-sizing: border-box;\n top: 100%;\n border-radius: 0 0 4px 4px;\n padding: 6px;\n width: 100%;\n color: $inverted-text-color;\n background: $ui-secondary-color;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n font-size: 14px;\n z-index: 99;\n display: none;\n}\n\n.autosuggest-textarea__suggestions--visible {\n display: block;\n}\n\n.autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected { background: darken($ui-secondary-color, 10%) }\n\n > .account,\n > .emoji,\n > .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n & > .account.small {\n .display-name {\n & > span { color: $lighter-text-color }\n }\n }\n}\n\n.composer--upload_form {\n overflow: hidden;\n\n & > .content {\n display: flex;\n flex-direction: row;\n flex-wrap: wrap;\n font-family: inherit;\n padding: 5px;\n overflow: hidden;\n }\n}\n\n.composer--upload_form--item {\n flex: 1 1 0;\n margin: 5px;\n min-width: 40%;\n\n & > div {\n position: relative;\n border-radius: 4px;\n height: 140px;\n width: 100%;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n overflow: hidden;\n\n textarea {\n display: block;\n position: absolute;\n box-sizing: border-box;\n bottom: 0;\n left: 0;\n margin: 0;\n border: 0;\n padding: 10px;\n width: 100%;\n color: $secondary-text-color;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n font-size: 14px;\n font-family: inherit;\n font-weight: 500;\n opacity: 0;\n z-index: 2;\n transition: opacity .1s ease;\n\n &:focus { color: $white }\n\n &::placeholder {\n opacity: 0.54;\n color: $secondary-text-color;\n }\n }\n\n & > .close { mix-blend-mode: difference }\n }\n\n &.active {\n & > div {\n textarea { opacity: 1 }\n }\n }\n}\n\n.composer--upload_form--actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $ui-secondary-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($ui-secondary-color, 4%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n}\n\n.composer--upload_form--progress {\n display: flex;\n padding: 10px;\n color: $darker-text-color;\n overflow: hidden;\n\n & > .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n & > .message {\n flex: 1 1 auto;\n\n & > span {\n display: block;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n }\n\n & > .backdrop {\n position: relative;\n margin-top: 5px;\n border-radius: 6px;\n width: 100%;\n height: 6px;\n background: $ui-base-lighter-color;\n\n & > .tracker {\n position: absolute;\n top: 0;\n left: 0;\n height: 6px;\n border-radius: 6px;\n background: $ui-highlight-color;\n }\n }\n }\n}\n\n.compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n}\n\n.composer--options-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n height: 27px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n}\n\n.composer--options {\n display: flex;\n flex: 0 0 auto;\n\n & > * {\n display: inline-block;\n box-sizing: content-box;\n padding: 0 3px;\n height: 27px;\n line-height: 27px;\n vertical-align: bottom;\n }\n\n & > hr {\n display: inline-block;\n margin: 0 3px;\n border-width: 0 0 0 1px;\n border-style: none none none solid;\n border-color: transparent transparent transparent darken($simple-background-color, 24%);\n padding: 0;\n width: 0;\n height: 27px;\n background: transparent;\n }\n}\n\n.compose--counter-wrapper {\n align-self: center;\n margin-right: 4px;\n}\n\n.composer--options--dropdown {\n &.open {\n & > .value {\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n color: $primary-text-color;\n background: $ui-highlight-color;\n transition: none;\n }\n &.top {\n & > .value {\n border-radius: 0 0 4px 4px;\n box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);\n }\n }\n }\n}\n\n.composer--options--dropdown--content {\n position: absolute;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n background: $simple-background-color;\n overflow: hidden;\n transform-origin: 50% 0;\n}\n\n.composer--options--dropdown--content--item {\n display: flex;\n align-items: center;\n padding: 10px;\n color: $inverted-text-color;\n cursor: pointer;\n\n & > .content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n &:not(:first-child) { margin-left: 10px }\n\n strong {\n display: block;\n color: $inverted-text-color;\n font-weight: 500;\n }\n }\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n\n & > .content {\n color: $primary-text-color;\n\n strong { color: $primary-text-color }\n }\n }\n\n &.active:hover { background: lighten($ui-highlight-color, 4%) }\n}\n\n.composer--publisher {\n padding-top: 10px;\n text-align: right;\n white-space: nowrap;\n overflow: hidden;\n justify-content: flex-end;\n flex: 0 0 auto;\n\n & > .primary {\n display: inline-block;\n margin: 0;\n padding: 0 10px;\n text-align: center;\n }\n\n & > .side_arm {\n display: inline-block;\n margin: 0 2px;\n padding: 0;\n width: 36px;\n text-align: center;\n }\n\n &.over {\n & > .count { color: $warning-red }\n }\n}\n",".column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.column {\n overflow: hidden;\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n\n & > button {\n margin: 0;\n border: none;\n padding: 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column {\n width: 330px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n\n .wide .columns-area:not(.columns-area--mobile) & {\n flex: auto;\n min-width: 330px;\n max-width: 400px;\n }\n\n > .scrollable {\n background: $ui-base-color;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n margin-left: 0;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n\n // glitch - added focus ring for keyboard navigation\n &:focus {\n text-shadow: 0 0 4px darken($ui-highlight-color, 5%);\n }\n}\n\n.column-header__notif-cleaning-buttons {\n display: flex;\n align-items: stretch;\n justify-content: space-around;\n\n button {\n @extend .column-header__button;\n background: transparent;\n text-align: center;\n padding: 10px 0;\n white-space: pre-wrap;\n }\n\n b {\n font-weight: bold;\n }\n}\n\n// The notifs drawer with no padding to have more space for the buttons\n.column-header__collapsible-inner.nopad-drawer {\n padding: 0;\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n\n // notif cleaning drawer\n &.ncd {\n transition: none;\n &.collapsed {\n max-height: 0;\n opacity: 0.7;\n }\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.column-header__title {\n display: inline-block;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n// more fixes for the navbar-under mode\n@mixin fix-margins-for-navbar-under {\n .tabs-bar {\n margin-top: 0 !important;\n margin-bottom: -6px !important;\n }\n}\n\n.single-column.navbar-under {\n @include fix-margins-for-navbar-under;\n}\n\n.auto-columns.navbar-under {\n @media screen and (max-width: $no-gap-breakpoint) {\n @include fix-margins-for-navbar-under;\n }\n}\n\n.auto-columns.navbar-under .react-swipeable-view-container .columns-area,\n.single-column.navbar-under .react-swipeable-view-container .columns-area {\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 100% !important;\n }\n}\n\n.column-inline-form {\n padding: 7px 15px;\n padding-right: 5px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n margin-bottom: 6px;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 5px;\n }\n}\n",".regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n",".directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n",".search {\n position: relative;\n}\n\n.search__input {\n @include search-input();\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: color, transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(0deg);\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n padding: 15px 10px;\n font-size: 14px;\n font-weight: 500;\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n",null,".emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n","$doodleBg: #d9e1e8;\n.doodle-modal {\n @extend .boost-modal;\n width: unset;\n}\n\n.doodle-modal__container {\n background: $doodleBg;\n text-align: center;\n line-height: 0; // remove weird gap under canvas\n canvas {\n border: 5px solid $doodleBg;\n }\n}\n\n.doodle-modal__action-bar {\n @extend .boost-modal__action-bar;\n\n .filler {\n flex-grow: 1;\n margin: 0;\n padding: 0;\n }\n\n .doodle-toolbar {\n line-height: 1;\n\n display: flex;\n flex-direction: column;\n flex-grow: 0;\n justify-content: space-around;\n\n &.with-inputs {\n label {\n display: inline-block;\n width: 70px;\n text-align: right;\n margin-right: 2px;\n }\n\n input[type=\"number\"],input[type=\"text\"] {\n width: 40px;\n }\n span.val {\n display: inline-block;\n text-align: left;\n width: 50px;\n }\n }\n }\n\n .doodle-palette {\n padding-right: 0 !important;\n border: 1px solid black;\n line-height: .2rem;\n flex-grow: 0;\n background: white;\n\n button {\n appearance: none;\n width: 1rem;\n height: 1rem;\n margin: 0; padding: 0;\n text-align: center;\n color: black;\n text-shadow: 0 0 1px white;\n cursor: pointer;\n box-shadow: inset 0 0 1px rgba(white, .5);\n border: 1px solid black;\n outline-offset:-1px;\n\n &.foreground {\n outline: 1px dashed white;\n }\n\n &.background {\n outline: 1px dashed red;\n }\n\n &.foreground.background {\n outline: 1px dashed red;\n border-color: white;\n }\n }\n }\n}\n",".drawer {\n width: 300px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n padding: 10px 5px;\n flex: none;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n\n @include single-column('screen and (max-width: 630px)') { flex: auto }\n\n @include limited-single-column('screen and (max-width: 630px)') {\n &, &:first-child, &:last-child { padding: 0 }\n }\n\n .wide & {\n min-width: 300px;\n max-width: 400px;\n flex: 1 1 200px;\n }\n\n @include single-column('screen and (max-width: 630px)') {\n :root & { // Overrides `.wide` for single-column view\n flex: auto;\n width: 100%;\n min-width: 0;\n max-width: none;\n padding: 0;\n }\n }\n\n .react-swipeable-view-container & { height: 100% }\n}\n\n.drawer--header {\n display: flex;\n flex-direction: row;\n margin-bottom: 10px;\n flex: none;\n background: lighten($ui-base-color, 8%);\n font-size: 16px;\n\n & > * {\n display: block;\n box-sizing: border-box;\n border-bottom: 2px solid transparent;\n padding: 15px 5px 13px;\n height: 48px;\n flex: 1 1 auto;\n color: $darker-text-color;\n text-align: center;\n text-decoration: none;\n cursor: pointer;\n }\n\n a {\n transition: background 100ms ease-in;\n\n &:focus,\n &:hover {\n outline: none;\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.search {\n position: relative;\n margin-bottom: 10px;\n flex: none;\n\n @include limited-single-column('screen and (max-width: #{$no-gap-breakpoint})') { margin-bottom: 0 }\n @include single-column('screen and (max-width: 630px)') { font-size: 16px }\n}\n\n.search-popout {\n @include search-popout();\n}\n\n.drawer--account {\n padding: 10px;\n color: $darker-text-color;\n display: flex;\n align-items: center;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n .acct {\n display: block;\n color: $secondary-text-color;\n font-weight: 500;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n overflow: hidden;\n}\n\n.drawer--results {\n background: $ui-base-color;\n overflow-x: hidden;\n overflow-y: auto;\n\n & > header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n & > section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n\n & > .hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n }\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n > .mastodon {\n display: block;\n width: 100%;\n height: 100%;\n border: none;\n cursor: inherit;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n",".video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n width: 100%;\n height: 100%;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 8%);\n }\n\n .status__content > & {\n margin-top: 15px; // Add margin when used bare for NSFW video player\n }\n @include fullwidth-gallery;\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 500;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n height: 100%;\n display: flex;\n flex-direction: column;\n\n span {\n text-align: center;\n color: $darker-text-color;\n display: flex;\n height: 100%;\n align-items: center;\n\n p {\n width: 100%;\n }\n }\n\n audio {\n width: 100%;\n }\n}\n\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n height: 110px;\n\n @include fullwidth-gallery;\n}\n\n.media-gallery__item {\n border: none;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n .full-width & {\n border-radius: 0;\n }\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n\n &.letterbox {\n background: $base-shadow-color;\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n object-fit: contain;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n display: flex;\n justify-content: center;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n width: 100%;\n position: relative;\n z-index: 1;\n object-fit: contain;\n user-select: none;\n\n &:not(.letterbox) {\n height: 100%;\n object-fit: cover;\n }\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $white;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $ui-highlight-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n .detailed-status & {\n width: 100%;\n height: 100%;\n }\n\n @include fullwidth-gallery;\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n position: relative;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-shadow-color;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n\n .fa,\n &:active .fa,\n &:hover .fa,\n &:focus .fa {\n color: inherit;\n }\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n",".sensitive-info {\n display: flex;\n flex-direction: row;\n align-items: center;\n position: absolute;\n top: 4px;\n left: 4px;\n z-index: 100;\n}\n\n.sensitive-marker {\n margin: 0 3px;\n border-radius: 2px;\n padding: 2px 6px;\n color: rgba($primary-text-color, 0.8);\n background: rgba($base-overlay-background, 0.5);\n font-size: 12px;\n line-height: 18px;\n text-transform: uppercase;\n opacity: .9;\n transition: opacity .1s ease;\n\n .media-gallery:hover & { opacity: 1 }\n}\n",".list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n",".emoji-mart {\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: 0;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -3px;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: darken($ui-highlight-color, 3%);\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n",".glitch.local-settings {\n position: relative;\n display: flex;\n flex-direction: row;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n height: 80vh;\n width: 80vw;\n max-width: 740px;\n max-height: 450px;\n overflow: hidden;\n\n label, legend {\n display: block;\n font-size: 14px;\n }\n\n .boolean label, .radio_buttons label {\n position: relative;\n padding-left: 28px;\n padding-top: 3px;\n\n input {\n position: absolute;\n left: 0;\n top: 0;\n }\n }\n\n span.hint {\n display: block;\n color: $lighter-text-color;\n }\n\n h1 {\n font-size: 18px;\n font-weight: 500;\n line-height: 24px;\n margin-bottom: 20px;\n }\n\n h2 {\n font-size: 15px;\n font-weight: 500;\n line-height: 20px;\n margin-top: 20px;\n margin-bottom: 10px;\n }\n}\n\n.glitch.local-settings__navigation__item {\n display: block;\n padding: 15px 20px;\n color: inherit;\n background: lighten($ui-secondary-color, 8%);\n border-bottom: 1px $ui-secondary-color solid;\n cursor: pointer;\n text-decoration: none;\n outline: none;\n transition: background .3s;\n\n .text-icon-button {\n color: inherit;\n transition: unset;\n }\n\n &:hover {\n background: $ui-secondary-color;\n }\n\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n\n &.close, &.close:hover {\n background: $error-value-color;\n color: $primary-text-color;\n }\n}\n\n.glitch.local-settings__navigation {\n background: lighten($ui-secondary-color, 8%);\n width: 212px;\n font-size: 15px;\n line-height: 20px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page {\n display: block;\n flex: auto;\n padding: 15px 20px 15px 20px;\n width: 360px;\n overflow-y: auto;\n}\n\n.glitch.local-settings__page__item {\n margin-bottom: 2px;\n}\n\n.glitch.local-settings__page__item.string,\n.glitch.local-settings__page__item.radio_buttons {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n\n@media screen and (max-width: 630px) {\n .glitch.local-settings__navigation {\n width: 40px;\n flex-shrink: 0;\n }\n\n .glitch.local-settings__navigation__item {\n padding: 10px;\n\n span:last-of-type {\n display: none;\n }\n }\n}\n",".error-boundary {\n color: $primary-text-color;\n font-size: 15px;\n line-height: 20px;\n\n h1 {\n font-size: 26px;\n line-height: 36px;\n font-weight: 400;\n margin-bottom: 8px;\n }\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n }\n\n ul {\n list-style: disc;\n margin-left: 0;\n padding-left: 1em;\n }\n\n textarea.web_app_crash-stacktrace {\n width: 100%;\n resize: none;\n white-space: pre;\n font-family: $font-monospace, monospace;\n }\n}\n",".compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .drawer--account {\n flex: 0 1 48px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .composer {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px;\n min-height: 48px + 2px;\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n padding-top: 15px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .search {\n margin-bottom: 10px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n",".announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n ul,\n .e-content & ul {\n margin: 0;\n list-style: none;\n }\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n display: block;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n overflow-x: hidden;\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n width: 100%;\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n @include avatar-size(80px);\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n @include avatar-radius();\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n @include avatar-size(44px);\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: none;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n","$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;\n\n%emoji-outline {\n filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);\n}\n\n.emojione {\n @each $emoji in $emojis-requiring-outlines {\n &[title=':#{$emoji}:'] {\n @extend %emoji-outline;\n }\n }\n}\n\n.hicolor-privacy-icons {\n .status__visibility-icon.fa-globe,\n .composer--options--dropdown--content--item .fa-globe {\n color: #1976D2;\n }\n\n .status__visibility-icon.fa-unlock,\n .composer--options--dropdown--content--item .fa-unlock {\n color: #388E3C;\n }\n\n .status__visibility-icon.fa-lock,\n .composer--options--dropdown--content--item .fa-lock {\n color: #FFA000;\n }\n\n .status__visibility-icon.fa-envelope,\n .composer--options--dropdown--content--item .fa-envelope {\n color: #D32F2F;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .composer--publisher {\n text-align: left;\n }\n\n .boost-modal__status-time,\n .favourite-modal__status-time {\n float: left;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .setting-meta__label {\n float: left;\n }\n\n .status__avatar {\n margin-left: 10px;\n margin-right: 0;\n\n // Those are used for public pages\n left: auto;\n right: 10px;\n }\n\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 58px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n text-align: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","// Notes!\n// Sass color functions, \"darken\" and \"lighten\" are automatically replaced.\n\n.glitch.local-settings {\n background: $ui-base-color;\n\n &__navigation {\n background: darken($ui-base-color, 8%);\n }\n\n &__navigation__item {\n background: darken($ui-base-color, 8%);\n\n &:hover {\n background: $ui-base-color;\n }\n }\n}\n\n.notification__dismiss-overlay {\n .wrappy {\n box-shadow: unset;\n }\n\n .ckbox {\n text-shadow: unset;\n }\n}\n\n.status.status-direct:not(.read) {\n background: darken($ui-base-color, 8%);\n border-bottom-color: darken($ui-base-color, 12%);\n\n &.collapsed> .status__content:after {\n background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1));\n }\n}\n\n.focusable:focus.status.status-direct:not(.read) {\n background: darken($ui-base-color, 4%);\n\n &.collapsed> .status__content:after {\n background: linear-gradient(rgba(darken($ui-base-color, 4%), 0), rgba(darken($ui-base-color, 4%), 1));\n }\n}\n\n// Change columns' default background colors\n.column {\n > .scrollable {\n background: darken($ui-base-color, 13%);\n }\n}\n\n.status.collapsed .status__content:after {\n background: linear-gradient(rgba(darken($ui-base-color, 13%), 0), rgba(darken($ui-base-color, 13%), 1));\n}\n\n.drawer__inner {\n background: $ui-base-color;\n}\n\n.drawer__inner__mastodon {\n background: $ui-base-color url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto !important;\n\n .mastodon {\n filter: contrast(75%) brightness(75%) !important;\n }\n}\n\n// Change the default appearance of the content warning button\n.status__content {\n\n .status__content__spoiler-link {\n\n background: lighten($ui-base-color, 30%);\n\n &:hover {\n background: lighten($ui-base-color, 35%);\n text-decoration: none;\n }\n\n }\n\n}\n\n// Change the background colors of media and video spoilers\n.media-spoiler,\n.video-player__spoiler,\n.account-gallery__item a {\n background: $ui-base-color;\n}\n\n// Change the colors used in the dropdown menu\n.dropdown-menu {\n background: $ui-base-color;\n}\n\n.dropdown-menu__arrow {\n\n &.left {\n border-left-color: $ui-base-color;\n }\n\n &.top {\n border-top-color: $ui-base-color;\n }\n\n &.bottom {\n border-bottom-color: $ui-base-color;\n }\n\n &.right {\n border-right-color: $ui-base-color;\n }\n\n}\n\n.dropdown-menu__item {\n a {\n background: $ui-base-color;\n color: $ui-secondary-color;\n }\n}\n\n// Change the default color of several parts of the compose form\n.composer {\n\n .composer--spoiler input, .compose-form__autosuggest-wrapper textarea {\n color: lighten($ui-base-color, 80%);\n\n &:disabled { background: lighten($simple-background-color, 10%) }\n\n &::placeholder {\n color: lighten($ui-base-color, 70%);\n }\n }\n\n .composer--options-wrapper {\n background: lighten($ui-base-color, 10%);\n }\n\n .composer--options > hr {\n display: none;\n }\n\n .composer--options--dropdown--content--item {\n color: $ui-primary-color;\n \n strong {\n color: $ui-primary-color;\n }\n\n }\n\n}\n\n.composer--upload_form--actions .icon-button {\n color: lighten($white, 7%);\n\n &:active,\n &:focus,\n &:hover {\n color: $white;\n }\n}\n\n.composer--upload_form--item > div input {\n color: lighten($white, 7%);\n\n &::placeholder {\n color: lighten($white, 10%);\n }\n}\n\n.dropdown-menu__separator {\n border-bottom-color: lighten($ui-base-color, 12%);\n}\n\n.status__content,\n.reply-indicator__content {\n a {\n color: $highlight-text-color;\n }\n}\n\n.emoji-mart-bar {\n border-color: darken($ui-base-color, 4%);\n\n &:first-child {\n background: lighten($ui-base-color, 10%);\n }\n}\n\n.emoji-mart-search input {\n background: rgba($ui-base-color, 0.3);\n border-color: $ui-base-color;\n}\n\n.autosuggest-textarea__suggestions {\n background: lighten($ui-base-color, 10%)\n}\n\n.autosuggest-textarea__suggestions__item {\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-base-color, 4%);\n }\n}\n\n.react-toggle-track {\n background: $ui-secondary-color;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: lighten($ui-secondary-color, 10%);\n}\n\n.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: darken($ui-highlight-color, 10%);\n}\n\n// Change the background colors of modals\n.actions-modal,\n.boost-modal,\n.confirmation-modal,\n.mute-modal,\n.block-modal,\n.report-modal,\n.embed-modal,\n.error-modal,\n.onboarding-modal,\n.report-modal__comment .setting-text__wrapper,\n.report-modal__comment .setting-text {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.report-modal__comment {\n border-right-color: lighten($ui-base-color, 8%);\n}\n\n.report-modal__container {\n border-top-color: lighten($ui-base-color, 8%);\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar,\n.onboarding-modal__paginator,\n.error-modal__footer {\n background: darken($ui-base-color, 6%);\n\n .onboarding-modal__nav,\n .error-modal__nav {\n &:hover,\n &:focus,\n &:active {\n background-color: darken($ui-base-color, 12%);\n }\n }\n}\n\n// Change the default color used for the text in an empty column or on the error column\n.empty-column-indicator,\n.error-column {\n color: lighten($ui-base-color, 60%);\n}\n\n// Change the default colors used on some parts of the profile pages\n.activity-stream-tabs {\n\n background: $account-background-color;\n\n a {\n &.active {\n color: $ui-primary-color;\n }\n }\n\n}\n\n.activity-stream {\n\n .entry {\n background: $account-background-color;\n }\n\n .status.light {\n\n .status__content {\n color: $primary-text-color;\n }\n\n .display-name {\n strong {\n color: $primary-text-color;\n }\n }\n\n }\n\n}\n\n.accounts-grid {\n .account-grid-card {\n\n .controls {\n .icon-button {\n color: $ui-secondary-color;\n }\n }\n\n .name {\n a {\n color: $primary-text-color;\n }\n }\n\n .username {\n color: $ui-secondary-color;\n }\n\n .account__header__content {\n color: $primary-text-color;\n }\n\n }\n}\n\n.button.logo-button {\n color: $white;\n\n svg {\n fill: $white;\n }\n}\n\n.public-layout {\n .header,\n .public-account-header,\n .public-account-bio {\n box-shadow: none;\n }\n\n .header {\n background: lighten($ui-base-color, 12%);\n }\n\n .public-account-header {\n &__image {\n background: lighten($ui-base-color, 12%);\n\n &::after {\n box-shadow: none;\n }\n }\n\n &__tabs {\n &__name {\n h1,\n h1 small {\n color: $white;\n }\n }\n }\n }\n}\n\n.account__section-headline a.active::after {\n border-color: transparent transparent $white;\n}\n\n.hero-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.moved-account-widget,\n.memoriam-widget,\n.activity-stream,\n.nothing-here,\n.directory__tag > a,\n.directory__tag > div {\n box-shadow: none;\n}\n\n.audio-player .video-player__controls button,\n.audio-player .video-player__time-sep,\n.audio-player .video-player__time-current,\n.audio-player .video-player__time-total {\n color: $primary-text-color;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/skins/glitch/mastodon-light/common.js b/priv/static/packs/skins/glitch/mastodon-light/common.js index ba27beac9..0477e20b0 100644 Binary files a/priv/static/packs/skins/glitch/mastodon-light/common.js and b/priv/static/packs/skins/glitch/mastodon-light/common.js differ diff --git a/priv/static/packs/skins/vanilla/contrast/common.css b/priv/static/packs/skins/vanilla/contrast/common.css index fe14d9031..166981fe7 100644 Binary files a/priv/static/packs/skins/vanilla/contrast/common.css and b/priv/static/packs/skins/vanilla/contrast/common.css differ diff --git a/priv/static/packs/skins/vanilla/contrast/common.css.map b/priv/static/packs/skins/vanilla/contrast/common.css.map index 9db5f296a..55ba6a017 100644 --- a/priv/static/packs/skins/vanilla/contrast/common.css.map +++ b/priv/static/packs/skins/vanilla/contrast/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/contrast/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss","webpack:///./app/javascript/styles/contrast/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CC9EmB,iEDqFrB,kBCrFqB,4BDyFrB,sBACE,MErFF,iDACE,mBACA,eACA,iBACA,gBACA,WCXM,kCDaN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDrDmB,kBCyDnB,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cD9EgB,mBAZC,WC6FjB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aDpLwB,kKCuLtB,oBAGE,sDAIJ,aDpLgB,eCsLd,0DAEA,aDxLc,oDC6LhB,cACE,SACA,uBACA,cDhMc,aCkMd,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aC9NY,gBDgOV,gBEnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SDrBI,YCuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WD9BE,qBCgCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cH/EmB,wBGiFnB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UDxUA,qCC2UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cHhVc,mBGkVd,kBACA,uHAEA,yBAGE,WDrWA,qCCyWF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBHxaiB,8CG6anB,yBACE,gBACA,aACA,kBACA,mBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WD1kBF,gBC4kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WDplBJ,gBCslBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aH/lBQ,oDGsmBd,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cH3nBU,aG6nBV,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BH3pBW,wEGiqBX,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WDnsBJ,6CCqsBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cH5tBY,uDG+tBZ,oBACE,cHhuBU,qBGkuBV,aACA,gBACA,8DAEA,eACE,WDpvBJ,qCC0vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aDryBU,8DC2yBV,mBACA,WD7yBE,qFCizBJ,YAEE,eACA,cHvyBc,2CG2yBhB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBHn3Ba,+IGs3BX,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,eACE,kBACA,cLlFc,6BKqFd,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBCjIR,cACE,iBACA,cNYgB,gBMVhB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cNJiB,wBMQnB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBLPI,uBKUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBPlBmB,aOoBjB,0BACA,eACA,cPVgB,iBOYhB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aPxCmB,qBO0CjB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,gBACA,eACA,cPhEgB,+BOoElB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aP7FkB,aOkGpB,YACE,kBACA,mBPhHmB,mCOkHnB,qBAGF,YACE,kBACA,0BACA,kBACA,cP7GkB,mBO+GlB,iBAGF,eACE,eACA,cPpHkB,iBOsHlB,qBACA,gBACA,UACA,oBAEA,YACE,gBACA,eACA,cP9HgB,0BOkIlB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cP3IgB,qBO6IhB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBPzKmB,mCO2KnB,cP7JqB,gBO+JrB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cP1Mc,8DOgNhB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eLhPM,CKkPN,cACA,cPrOkB,mBOuOlB,+BANA,iBACA,CLhPM,kCK8PN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UL/PM,eKiQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cP3PgB,qCO+PlB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBPrRqB,kBOuRnB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBPlSe,kBOoSf,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBPzSiB,eO2Sf,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WLjUE,mBKmUF,gBACA,uBACA,wBAEA,aP1Tc,0BO8Td,aACE,gBACA,eACA,eACA,cPlUY,0IOwUd,ULrVE,+BK6VJ,aACE,YACA,uDAGF,oBPvViB,wCO2VjB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,cPxYgB,gBO0YhB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WL7aI,8BKgbJ,aACE,cPpac,gBOsad,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aPrgBkB,iCOogBpB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cP5hBiB,4JO+hBjB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WLhkBI,gCKkkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MCjlBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WNhDA,cMkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aRjEoB,0BQmElB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aRvFkB,sBQ0FhB,aRnGsB,yBQuGtB,iBACE,kBACA,mBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cRrHgB,iCQwHhB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WNlKA,gBMoKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WNxLE,cM0LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WN9ME,cMgNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WN5RI,cM8RJ,WACA,2CAKE,mBACE,eACA,WNtSA,qBMwSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WNtUI,cMwUJ,WACA,UACA,oBACA,gBACA,mBACA,yBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBN7VY,oLMiWZ,iBACE,4WAGF,oBRlWsB,mBQqWpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBR5YiB,WEXb,eM0ZJ,oBACA,YACA,aACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBRlboB,gGQsbpB,kBNtbQ,kHMybN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WNzcI,cM2cJ,WACA,UACA,oBACA,gBACA,wXACA,yBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cRzdY,oBQ2dZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,iEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UNvhBF,aMiiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cR5hBkB,kBQ8hBlB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cNjjBY,sBMqjBd,mCACE,+BACA,cNtjBQ,kBM0jBV,oBACE,cRhjBgB,qBQkjBhB,wBAEA,UNjkBI,0BMmkBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBRjlBiB,WEDb,eMqlBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aR7mBkB,qBQ+mBhB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aR1oBwB,qBQ4oBtB,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cRhpBkB,oCQmpBlB,cACE,mBACA,kBACA,4CAGF,aRvpBqB,gBQypBnB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBN7rBM,YM+rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cR5rBqB,WQ8rBrB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WNzuBI,qCM2uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UNjvBI,0BMmvBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cRnxBkB,0BQsxBlB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WN7yBI,kBM+yBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aNvzBc,0SMi0BZ,+CACE,aAIJ,kBACE,yBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBN32Bc,gBM62BZ,2BAEA,kBN/2BY,gBMi3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCl7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,mBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WP7EA,gBO+EA,gBACA,uBACA,+BAGF,aACE,eACA,cTzEY,gBS2EZ,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WP3GI,gBO6GJ,qBACA,iBACA,qBACA,sBAGF,ePnHM,oBOqHJ,WTtHI,eSwHJ,cACA,kBAGF,cACE,uCAGF,aThHqB,oBSqHrB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA7DF,iBA8DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBT1KqB,mCS4KnB,cTxJiB,eS0JjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cTzMwB,sCS2MxB,sCACA,6DAEA,aPhNc,sCOkNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cT5OmB,6BS8OnB,6BAGF,aACE,cTpPgB,4BSwPlB,aTjQwB,qBSmQtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aPnRY,gBOqRV,0CAGF,aPxRY,wCO6Rd,eACE,wCAIJ,UACE,0BAIA,aT3RkB,4BS8RhB,aTxSsB,qBS0SpB,qGAEA,yBAGE,iCAIJ,UPtTI,gBOwTF,wBAIJ,eACE,kBC/TJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBV5BmB,6GU+BjB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBVpEmB,WEXb,oBQkFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UR5FI,gFQgGN,kBAGE,qNAKA,kBVtGoB,4IU8GpB,kBR9GQ,qCQqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cZKmB,SYHnB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aZhBsB,eYkBpB,SAIJ,wBZbqB,YYenB,kBACA,sBACA,WV5BM,eU8BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBVxDQ,gBU4DN,mCAIJ,wBZhEsB,eYmEpB,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,UZ9FM,mBAGgB,qGY+FpB,wBAGE,8BAIJ,kBV1EsB,2GU6EpB,wBAGE,0BAIJ,aZrGkB,uBYuGhB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,UACA,cZjIoB,SYmIpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,uCACA,4BACA,2CACA,oBAGF,qCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aZ1KwB,gCY8KxB,QACE,uEAGF,mBAGE,uBAGF,aZxKmB,sFY2KjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,aZ3MsB,uCY8MpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,aZjNqB,SYmNnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,aACE,6BACA,eACA,0BAGF,aZlQwB,qCYsQxB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,aZvTsB,sDY2TtB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBZ5UoB,yDYmVxB,UZxVM,mBY0VJ,mBZvVoB,oCYyVpB,iBACA,kBACA,eACA,gBACA,6CAEA,UZlWI,gBYoWF,CAII,kRADF,eACE,wCAKN,aZxViB,gBY0Vf,0BACA,yIAEA,oBAGE,sCAKN,iBACE,QACA,UACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,WZ5ZI,gBECA,aU8ZJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,aZvZc,CYqZd,sHAEA,aZvZc,CYqZd,8HAEA,aZvZc,CYqZd,gIAEA,aZvZc,CYqZd,4GAEA,aZvZc,+FY2Zd,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBZjdsB,0BYmdtB,WZvdI,eYydJ,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,aZngBmB,wCYugBnB,UZ5hBM,oBY8hBJ,eACA,gBV9hBI,sEUiiBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cZ3iBa,eY6iBb,gBACA,aACA,oBACA,6QAEA,UAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cZ3kBa,SY6kBb,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UVpmBF,8GUwmBE,WACE,cZ1lBW,CEff,oGUwmBE,WACE,cZ1lBW,CEff,wGUwmBE,WACE,cZ1lBW,CEff,yGUwmBE,WACE,cZ1lBW,CEff,+FUwmBE,WACE,cZ1lBW,iFY+lBf,SACE,wEAKN,iBACE,sBVtnBE,wBUwnBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,mBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cZrqBmB,4CYwqBnB,aVzrBY,kCU8rBd,2CACE,WCpsBF,8DDysBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBZltBsB,aYotBtB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,UZvuBQ,cYyuBN,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WVlwBM,wDUqwBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aV1xBc,qBU4xBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,aZ9yBc,8EYmzBhB,aACE,0GAGF,kBZpzBoB,sHYuzBlB,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,WZ13BM,gBY43BN,eACA,cACA,iBACA,eACA,sBACA,4BAGF,aZr3BkB,SYu3BhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aZt7Be,CAtBX,uEYq9BF,UZr9BE,kCYy9BF,aZn8Ba,gCYw8Bf,UZ99BI,kCYi+BF,aZ59BoB,gEYg+BpB,UVp+BE,mBFEgB,sEYs+BhB,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,aZ5+BkB,YY++BhB,eACA,uBAGF,aZn/BkB,qCYu/BlB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cZviCgB,CYyiChB,iBACA,eACA,kBACA,+CAEA,aZ9iCgB,uBYkjChB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cZxkCgB,4BY8kCtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cZzoCgB,eY2oChB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,UZprCQ,eYsrCN,6BAEA,aZnqCmB,SYwqCrB,YACE,gCACA,8BAEA,aACE,cACA,WVlsCI,qBUosCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cZttCgB,gBYwtChB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBEtvCE,iCACA,wBACA,4BACA,kBFqvCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBEhwCA,iCACA,wBACA,4BACA,kBF+vCE,gBACA,kBACA,eACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WVjxCE,6BUmxCF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCEvxCrB,+BFyxCA,iBElyCA,iCACA,wBACA,4BACA,WFiyCuB,sCE3xCvB,kCF8xCA,iBEvyCA,iCACA,wBACA,4BACA,WFsyCuB,sCEhyCvB,kBFkyCE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cZ3yCgB,6BY8yChB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,eACA,cZ13CgB,kCY83ClB,aACE,eACA,gBACA,WV94CI,CUm5CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UVn7CM,kBUy7CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aZv8CuB,cYy8CrB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WVr+CI,kCU0+CR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,CZ/+CgB,gHYy/ChB,aZz/CgB,wBY6/ChB,UACE,wCAGF,kBVj/CsB,WF/BhB,8CYohDJ,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cZhhDkB,eYkhDlB,iBACA,kBACA,4BAEA,aZ/hDwB,6BYmiDxB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CV5iDU,mEUmjDZ,aVnjDY,uBUujDZ,aVxjDc,4DU8jDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UVllDM,0BUolDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cVzkD4B,eAEC,0DU0kD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cVjmD4B,eAEC,WUkmD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cZrpDkB,wBYwpDlB,aZtpDqB,mBY0pDrB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBZnuD0B,cYquDxB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BZxwDsB,2BY4wDxB,WACE,iBACA,uBACA,yBZ/wDsB,8BYmxDxB,QACE,iBACA,uBACA,4BZtxDsB,6BY0xDxB,SACE,gBACA,2BACA,2BZ7xDsB,wBYmyDxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZzyDsB,WAJlB,gBYgzDJ,uBACA,mBACA,yFAEA,kBZxyDiB,cAIE,UYyyDjB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBZn0DsB,cYq0DtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ51DsB,WAJlB,gBYm2DJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBZ/1DiB,cAIE,iBYk2DvB,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBZr9DmB,8BYu9DjB,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cZn+DkB,qBYq+DlB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WVxiEM,qBU0iEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cZ7jEsB,sBYikExB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WV/uEM,kBUivEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBZ/yEiB,yBYizEjB,gBACA,kBACA,eACA,gBACA,iBACA,WVj0EI,mDUs0ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBVx2EI,0BU02EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBZv6EmB,0BY46ErB,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cZ3/EwB,eY6/ExB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cZhhFwB,eYkhFxB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBZxlFmB,qCY0lFnB,sEAGF,wBACE,4CAGF,wBZvlFqB,+EY2lFrB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBZnpFmB,cYupFrB,kBACE,WVnqFM,cUqqFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cZlrFsB,kGYqrFtB,sBAGE,WV3rFE,kCU+rFJ,aZprFiB,oBY0rFrB,oBACE,iBACA,qBAGF,oBACE,kBACA,eACA,iBACA,gBACA,mBZ9sFmB,gBYgtFnB,iBACA,oBAGF,kBZptFqB,cAaH,iBY0sFhB,eACA,gBACA,eACA,yDAGF,kBZ7tFqB,cYmuFrB,aACE,kBAGF,aZ1tFkB,cY4tFhB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,aZxvFY,0BY0vFV,sDAIJ,oBACE,cZhwFc,sMYmwFd,yBAGE,oDAKN,aZ1wFgB,0BYgxFhB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,cZxxFc,aY0xFd,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA1BF,YA2BI,yCAGF,eACE,aACA,iDAEA,aZnzFc,qBY0zFpB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,WZj2FM,gBECA,aUm2FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,aZt3FsB,6BYw3FpB,uDAGF,aZx4F0B,cY44F1B,YACE,eACA,yBACA,kBACA,cZt4FgB,gBYw4FhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cZj6FiB,uBYm6FjB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UVz7FE,yBUg8FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cZt9FkB,gBYw9FlB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aZp+FoB,oBYw+FpB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cZpjGgB,6BYsjGhB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cZ9kGgB,mBAbG,eY8lGnB,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cZ5mGY,qCYgnGd,cACE,gBACA,yBAKN,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,gFACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,aZlrGwB,uBYsrGxB,sCACE,4CAEA,aZzrGsB,yCY2rGpB,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cZ3sGkB,eY6sGlB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UVtuGI,mBUwuGF,6BAKN,eACE,gBACA,gBACA,cZnuGkB,0DYquGlB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aZhwGkB,0BYkwGhB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,aZjyGkB,eYmyGhB,gBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBVz6GM,WACA,eU26GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eVv7GQ,cFcY,SY46GlB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WVt/GE,gBUw/GF,eACA,+LAMA,yBACE,mEAKF,yBACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aZvhHoB,eYyhHlB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtiHF,sBACA,WACA,SACA,gBACA,oBACA,mBdhBmB,cAYD,ecOlB,SACA,+EFgiHI,aACE,CEjiHN,qEFgiHI,aACE,CEjiHN,yEFgiHI,aACE,CEjiHN,0EFgiHI,aACE,CEjiHN,gEFgiHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,aZ9jHc,iBYgkHZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aZxlHgB,0HY6lHhB,cAEE,gBACA,cZ/lHY,kZYkmHZ,aAGE,gEAIJ,wBACE,iDAGF,eV3nHI,kBY0BN,CAEA,eACA,cdRiB,uCcUjB,UF8lHI,mBZ1nHoB,oDc8BxB,adZiB,eccf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WdlDI,sDYkoHJ,WACE,mDAGF,UZtoHI,kBYwoHF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UVxpHQ,kBU0pHN,cACA,mBACA,sBV7pHM,eU+pHN,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aZjqHoB,qBYmqHlB,mBACA,gBACA,sBACA,uCAGF,aZxqHkB,mBAbG,kBYyrHnB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,sCAdF,cAeI,kDAGF,eACE,2CAGF,aZxsHwB,qBY0sHtB,uDAEA,yBACE,eAKN,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eV/xHQ,kBUiyHN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBV3zHM,kBU6zHN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBZ53HmB,kCY83HnB,uBAGF,MACE,aACA,mBACA,uBACA,cZv3HqB,eYy3HrB,gBACA,0BACA,kBACA,kBAGF,YACE,cZ33HmB,gBY63HnB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBVz4HsB,kBU24HtB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBZt6HmB,kBYw6HnB,eAGF,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBV99HM,uCUg+HN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,UZ/+HQ,aYi/HN,eACA,aACA,kEAEA,kBZz+HmB,WEXb,UUw/HJ,CVx/HI,4RU6/HF,UV7/HE,wCUmgIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cZ5/HmB,2CY+/HnB,eACE,cACA,WZthII,CY2hIA,wQADF,eACE,mDAON,eVjiIM,0BUmiIJ,qCACA,gEAEA,eACE,0DAGF,kBZ/hIiB,uEYkiIf,UV7iIE,uDUmjIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SErjIE,sBACA,WACA,SACA,gBACA,oBACA,mBdhBmB,cAYD,ecOlB,SACA,cF+iIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cZvmImB,eYymInB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cZ3nIkB,eY6nIlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,aZ5oIkB,mBY8oIhB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cZpqIc,iCYuqId,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cZprIqB,qBYsrIrB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cZnsIkB,kBYqsIlB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cV7tI0B,eAEC,CUuuI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,WVzzIM,eU2zIN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cZp2IsB,mFYu2ItB,yBAGE,wBAKN,oBACE,sBAGF,qBVt3IQ,YUw3IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBZ73I0B,qBYi4I1B,iBACE,UACA,QACA,YACA,6CAGF,kBZz4I0B,WAJlB,kBYk5IN,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,aZ/6ImB,SYk7IjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,UZx9II,qwDY49IF,aAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,WZ/+II,kBYi/IJ,eACA,qBAGF,kBZn/ImB,cAcE,gBYw+InB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,WZ3gJM,kBY6gJN,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,UACE,wBAKF,eVviJM,CFGkB,gBYuiJtB,oBACA,iEV3iJI,2BFGkB,yBYgjJ1B,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBZ/jJwB,aYikJxB,iBACA,2HAEA,aACE,iBACA,cZrjJiB,mBYujJjB,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,aZnoJwB,iLYuoJxB,UZ5oJM,qCYipJN,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UZ3qJI,gBECA,aU6qJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eV7rJI,yBU+rJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UZ9sJE,oBYgtJA,eACA,gBVhtJA,+CUqtJJ,YACE,8BACA,mBACA,4CAIJ,aACE,WZ9tJI,eYguJJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UZzuJI,eY2uJF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UZrxJE,aYuxJA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBZ1xJW,WEXb,uDU4yJA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cZ5yJmB,eY8yJnB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UZj3JI,CYm3JF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBZ73J0B,WY+3JxB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WV54JM,0BU84JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cZ76Jc,iBY+6Jd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cZ38JY,gBY68JZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,aZ99Jc,gBYs+JhB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cZjgKqB,kBYmgKrB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBV5hKM,CU6hKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBVxiKM,iCU2iKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBVxoKM,eU0oKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBVrtKI,cFcY,gBY0sKhB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UVpxKE,+EU4xKN,cAGE,gBACA,6BAGF,UVnyKM,iBUqyKJ,yBAGF,oBACE,aACA,mDAGF,UV7yKM,uBUkzKN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WVl2KE,sFUq2KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,mBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WV5/KF,gBU8/KE,gBACA,uBACA,0CAGF,aACE,eACA,cZx/KU,gBY0/KV,gBACA,uBACA,yBAKN,kBZ7gLiB,aY+gLf,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cZhlLgB,eYklLhB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,aZvlLmB,qWY0lLjB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBZjpLqB,sBYopLnB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eVvsLQ,kBY0BN,CACA,sBACA,gBACA,cdRiB,uCcUjB,mBAEA,adZiB,eccf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WdlDI,UY4sLR,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cZvsLmB,gBYysLnB,gBAEA,aZttLsB,0BYwtLpB,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBZr1Le,WEDb,eUy1LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cZt3Lc,CYy3Ld,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,cAGF,kBZn8LqB,sBYq8LnB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBZz/LqB,sBY2/LnB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBV3iMM,yDU8iMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBVtjMI,uBU0jMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UVvlMI,eUylMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aZpmMoB,eYsmMlB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WVruMA,gBUuuMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cZjuMU,gBYmuMV,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WVlwME,gDUswMJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aVjxMU,yBUuxMd,cACE,gCAEA,cACE,cZ/wMc,eYixMd,kCAEA,oBACE,cZpxMY,qBYsxMZ,iBACA,gBACA,yCAEA,eACE,WVxyMF,iBUizMN,aZnyMgB,mBYqyMd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cZ7zMY,gBY+zMZ,uBACA,mBACA,4BAEA,eACE,uBAGF,aZx0Mc,qBY00MZ,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cZj2MiB,0BYq2MnB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBZn5MsB,kBYq5MtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cZh6Mc,kBYk6Md,+BAGF,aZr6MgB,eYu6Md,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UV57ME,qBU87MA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UVx9MI,OaFR,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBfLiB,aeUnB,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAGF,aACE,sBACA,WACA,eACA,Wf3CE,Ue6CF,oBACA,gBb7CE,yBa+CF,kBACA,iBACA,oCAEA,oBf/CoB,wBeoDtB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBbtFY,8Ea2FZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,cfnHc,aeuHhB,cACE,uBACA,UACA,SACA,SACA,cf5Hc,0Be8Hd,kBACA,mBAEA,oBACE,sCAGF,qCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBf9KoB,sDeoLxB,cACE,gBACA,iBACA,YACA,oBACA,cf5KkB,sCe+KlB,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WflNI,qBeoNJ,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,afrNkB,qBewNhB,+BACE,6BAEA,6BACE,eC5ON,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,WjBDM,2BiBIN,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBjBjBsB,4BiBqBxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,cjBfmB,ciBiBnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,ajBlD0B,mCiBqDxB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBjBnEwB,uBiBwExB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBf5FM,sBe8FN,sGAEA,+BAEE,oBAKF,2BACA,gBfxGM,0Be2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,WjBnHI,yBiBqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBfpKI,mBeyKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,cjBlKiB,mDiBqKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,clBPkB,oBkBUlB,alBnBwB,0BkBqBtB,6EAEA,oBAGE,wCAIJ,alBrBkB,oBkB0BlB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,clBlCmB,qBkBsCrB,iBACE,clBvCmB,uBkB2CrB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,clB3DmB,qBkB+DrB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,clBxIc,iCkB4IhB,uBACE,gBACA,gBACA,clB9IY,qDkBkJd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WhBpNI,iBgBsNJ,kBACA,qEAEA,aAEE,6CAIA,alBhNiB,oCkBqNnB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,clBlPc,mBkBoPd,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WhBzSA,qBgB2SA,uDAGE,yBACE,2CAKN,aACE,clBxSY,kCkBgTlB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,clBvTgB,sCkB0ThB,alBnUsB,0BkBqUpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,clB/UmB,wBkBkVnB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,clBhWmB,kBkBqWnB,clBrWmB,mCkBoWrB,4CACE,CACA,gBACA,gBACA,mBACA,clBzWmB,kBkB8WnB,clB9WmB,kBkBuXnB,clBvXmB,mCkBsXrB,4CACE,CACA,gBACA,gBACA,mBACA,clB3XmB,kBkBgYnB,clBhYmB,mCkBwYrB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,4CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBlBlcmB,kBkBocjB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBlB3jBiB,kBkB6jBjB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,alB7kBmB,qCkBilBnB,eACE,WhBjmBE,gBgBmmBF,2CAEA,alBxlBc,gDkB2lBZ,alBzlBe,+CkB+lBnB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,ShBvrBI,YgByrBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,gBACA,eACA,clBnsBc,6BkBusBhB,eACE,iBACA,+BAGF,kBlBxtBiB,akB0tBf,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,clBlvBY,uFkBwvBlB,eACE,cASA,ClBlwBgB,2CkB+vBhB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,clB12BsB,qBkB42BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,clBt2Bc,SmBhBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBnBxBmB,UmB6BnB,anB1BwB,0BmB4BtB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBnBjEiB,6BmBmEf,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,cnB/FkB,gBmBiGlB,0DAEA,UjBhHM,wDiBoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBnB/JiB,sBmBiKjB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBnB9KiB,gCmBiLjB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBnBtMiB,uCmByMf,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,cnBlOY,gBmBoOZ,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBpBfe,YoBiBf,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SlBxCA,YkB0CE,kBACA,YACA,uCAIJ,aACE,cpBpCY,qBoBsCZ,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cpB9EY,qBoBgFZ,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UlBzGA,yBkB2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UlBjIE,yBFWa,gBoByHb,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,apBrMmB,eoBuMjB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,apBhNmB,eoBkNjB,iBACA,gBACA,mBACA,4BAGF,cACE,gBACA,cpB5Nc,mBoB8Nd,kBACA,gCACA,4BAGF,cACE,cpBlOiB,iBoBoOjB,gBACA,0CAGF,UlBvPI,gBkByPF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WlBvQE,oBkByQF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cpBlQiB,mBoBoQjB,kCAEA,UlBrRE,gBkBuRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,4CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA7SF,aA8SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BpBvUe,YoB8UrB,UACE,SACA,cACA,WACA,sDAKA,apBrVkB,0DoBwVhB,apBjWsB,4DoBsWxB,alBzWc,gBkB2WZ,4DAGF,alB7WU,gBkB+WR,0DAGF,apBtWgB,gBoBwWd,0DAGF,alBrXU,gBkBuXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,eACA,CAII,iNADF,eACE,2BAKN,oBACE,cpBpZc,qBoBsZd,eACA,gBACA,gCACA,iCAEA,UlBxaE,gCkB0aA,oCAGF,apBzaoB,gCoB2alB,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cpBvdmB,CoB4df,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,apBjjBwB,qBoBmjBtB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBpB1kBiB,cAYD,0BoBikBhB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,apBzlBgB,oBoB6lBhB,kBACE,0BACA,aACA,cpBjmBgB,gDoBmmBhB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,cpB1mBc,2BoB8mBhB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBlBnoBY,oCkBuoBZ,kBACE,mCAGF,kBpBjoBiB,sDoBsoBnB,apBloBqB,qBoBsoBnB,gBACA,sBAGF,aACE,0BAGF,apB9oBqB,sBoBkpBrB,alBhqBc,yDkBqqBhB,oBAIE,cpB3pBqB,iGoB8pBrB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBlBrtBc,yBkBytBd,yBACE,wBAGF,yBlB1tBU,wBkB+tBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,apB9tBgB,uBoBouBhB,wBACA,qBAGF,apBvuBgB,coB4uBlB,kBpBzvBqB,kBoB2vBnB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cpBnwBc,iBoBqwBd,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,alB7xBM,6BkBoyBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cpBvyBY,mLoB0yBZ,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,apBrzBU,iBoBuzBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cpBl0BY,WoBy0BpB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,alBn4BY,8CkBw4Bd,qBACE,aACA,WlB34BI,ckBg5BR,iBACE,sBCn5BF,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WnBrCI,6CmBuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,crBpCgB,kBqBsChB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,arBnEwB,gBqBqEtB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,kEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,2GCEQ,SACE,CDHV,iGCEQ,SACE,CDHV,qGCEQ,SACE,CDHV,sGCEQ,SACE,CDHV,4FCEQ,SACE,mJAQZ,aAME,0BACA,mMAEA,oBACE,iOAGF,yBACE,CAKE,0zCAIJ,oBAGE,uUAGF,axB3BqB,qBwB6BnB,oCAIJ,yBACE,6HAEA,oBAGE,4BAIJ,yBACE,qGAEA,oBAGE,eAIJ,axBvDoB,yEwB2DpB,+BACE,0D","file":"skins/vanilla/contrast/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#313543 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#313543;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#353a49}::-webkit-scrollbar-thumb:active{background:#313543}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#282c37}::-webkit-scrollbar-track:active{background:#282c37}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#191b22;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#282c37}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#313543;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#1f232b;padding:0}body.error{position:absolute;text-align:center;color:#dde3ec;background:#282c37;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#e25169;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#2b90d9}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#c2cede;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#c2cede}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#c2cede;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#79bd9a;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#ecf0f4;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#42485a}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#dde3ec;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#4a5266;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#535b72}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#ecf0f4}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#0e1014}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#313543;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #313543;background:#17191f}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#313543;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#dde3ec}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#dde3ec;padding:10px;border-right:1px solid #313543;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#ecf0f4}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #42485a}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#dde3ec}.public-layout .public-account-header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#4e79df}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#dde3ec}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #393f4f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #393f4f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#282c37}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#313543}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#737d99}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#737d99}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{font-weight:700;margin-bottom:8px;color:#dde3ec}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#737d99}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#737d99}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#7f88a2}.compact-header h1{font-size:24px;line-height:28px;color:#dde3ec;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#ecf0f4}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#282c37;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.hero-widget__text a{color:#ecf0f4;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;font-weight:700;font-size:14px;color:#dde3ec}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#dde3ec}.box-widget{padding:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #c2cede;text-align:center;color:#dde3ec;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;font-weight:700;font-size:14px;color:#dde3ec}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#dde3ec;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#ecf0f4;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#dde3ec}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#dde3ec;margin-bottom:10px}.page-header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#dde3ec}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#313543}.page-header h1{font-size:24px}}.directory{background:#282c37;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#282c37;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#393f4f}.directory__tag.active>a{background:#2b5fd9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#dde3ec}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#dde3ec}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b5fd9}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#17191f;border:2px solid #282c37}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;color:#dde3ec;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #393f4f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#dde3ec;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #4a5266}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#dde3ec}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b5fd9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#1f232b;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #313543}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #313543}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#dde3ec}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#0e1014}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#dde3ec}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #c2cede;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419;border:1px solid #0a0b0e;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#eaeef3}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#17191f}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b5fd9;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#416fdd}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#2454c7}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #0a0b0e;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#c2cede;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(19, 20, 25, 0), #131419)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(40,44,55,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#393f4f;color:#dde3ec;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#dde3ec;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#282c37;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#313543}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#dde3ec;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#4ea2df}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#dde3ec}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#ecf0f4;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#ecf0f4;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#dde3ec}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#131419;border:1px solid #0a0b0e;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#1f232b;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#1f232b;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#393f4f}.card__img{height:130px;position:relative;background:#0e1014;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#313543;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#17191f;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{color:#ecf0f4}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#1a1a1a}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#364861;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #42485a;border-bottom:1px solid #42485a;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #42485a}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#ecf0f4;background:rgba(23,25,31,.5)}.account__header__fields dd{flex:1 1 auto;color:#dde3ec}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#dde3ec}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#282c37}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#393f4f}.button.logo-button{flex:0 auto;font-size:14px;background:#2b5fd9;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#5680e1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#2b5fd9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#2b5fd9;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:15px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#5680e1;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9baec8;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#606984}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#687390}.button.button-secondary{color:#dde3ec;background:transparent;padding:3px 15px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#eaeef3}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#8d9ac2;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#a4afce;background-color:rgba(141,154,194,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(141,154,194,.3)}.icon-button.disabled{color:#6274ab;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#1b1e25}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#0c0d11;background-color:rgba(27,30,37,.15)}.icon-button.inverted:focus{background-color:rgba(27,30,37,.3)}.icon-button.inverted.disabled{color:#2a2e3a;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#63ade3}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#1b1e25;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#0c0d11;background-color:rgba(27,30,37,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(27,30,37,.3)}.text-icon-button.disabled{color:#464d60;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.compose-form .compose-form__warning{color:#000;margin-bottom:10px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#000;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#1b1e25;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:5px;right:5px}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#c2cede}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#d9e1e8;border-radius:0 0 4px 4px;color:#000;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#1b1e25}.compose-form .compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#ecf0f4;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#ecf0f4;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#ecf0f4}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#1b1e25}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9baec8;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#000;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#fff}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#dae1ea}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#c2cede}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#4e79df}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#8d9ac2}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#a4afce;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#4e79df;border:0;background:transparent;padding:0;padding-top:8px}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#000;font-weight:700;font-size:12px;padding:0 6px;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#c2cede;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #393f4f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#313543}.focusable:focus .status.status-direct{background:#42485a}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#393f4f}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #393f4f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#393f4f;border-bottom-color:#42485a}.status.light .status__relative-time{color:#364861}.status.light .status__display-name{color:#000}.status.light .display-name strong{color:#000}.status.light .display-name span{color:#364861}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#b8c0d9}.status__relative-time,.notification__relative_time{color:#c2cede;float:right;font-size:14px}.status__display-name{color:#c2cede}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#c2cede;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#c2cede}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#8d9ac2}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#313543;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#c2cede;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#000;font-size:14px}.reply-indicator__content a{color:#1b1e25}.domain{padding:10px;border-bottom:1px solid #393f4f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #393f4f}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#dde3ec;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative;cursor:default}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #393f4f;color:#c2cede}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #393f4f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #2b5fd9}.account__action-bar__tab>span{display:block;font-size:12px;color:#dde3ec}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#ecf0f4;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#c2cede}.muted .status__display-name strong{color:#c2cede}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#606984;color:#000}.muted a.status__content__spoiler-link:hover{background:#707b97;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#dde3ec;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#dde3ec}.navigation-bar strong{color:#ecf0f4}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b5fd9;color:#ecf0f4;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b5fd9;color:#ecf0f4}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#17191f;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#282c37;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#dde3ec;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#393f4f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #393f4f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#464d60;border-bottom-color:#464d60}}.tabs-bar__link.active{border-bottom:2px solid #2b90d9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#2558d0;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#4976de}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b5fd9;border:2px solid #393f4f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#17191f}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #313543;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#444b5d;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#282c37}.drawer__inner__mastodon{background:#444b5d url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#444b5d;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#393f4f;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#2e3340;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#313543;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#313543;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#282c37;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#131419}.react-toggle--checked .react-toggle-track{background-color:#2b5fd9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#5680e1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #282c37;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b5fd9}.column-link{background:#393f4f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#404657}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#2b5fd9}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#282c37;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#282c37;color:#c2cede;padding:8px 20px;font-size:13px;font-weight:500;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#282c37}.flex-spacer{flex:1 1 auto}.getting-started{color:#c2cede;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#c2cede;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#dde3ec}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#c2cede}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:13px;color:#dde3ec;padding:10px;font-weight:500;border-bottom:1px solid #393f4f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#dde3ec}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#393f4f;border:1px solid #1f232b}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#8d9ac2;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.status-card{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;color:#c2cede;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#ecf0f4;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#393f4f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#dde3ec;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#dde3ec}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#393f4f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#313543}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#313543}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#c2cede;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#2c313d}.load-gap{border-bottom:1px solid #393f4f}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#c2cede;background:#282c37;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#c2cede}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 95, 217, 0.23) 0%, rgba(43, 95, 217, 0) 60%)}.column-header{display:flex;font-size:16px;background:#313543;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,144,217,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#313543;border:0;color:#dde3ec;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#f4f6f9}.column-header__button.active{color:#fff;background:#393f4f}.column-header__button.active:hover{color:#fff;background:#393f4f}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#dde3ec;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #42485a;margin:10px 0}.column-header__collapsible-inner{background:#393f4f;padding:15px}.column-header__setting-btn:hover{color:#dde3ec;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#c2cede;font-size:13px;font-weight:400;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #606984;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#606984}29%{background-color:#606984}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#dde3ec;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#f7f9fb}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.modal-container--preloader{background:#393f4f}.account--panel{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#393f4f;padding:15px}.column-settings__section{color:#dde3ec;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#eaeef3}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#313543}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#c2cede;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#393f4f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#42485a;color:#eaeef3}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#dde3ec}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#c2cede}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#d0d9e5}.column-settings__hashtags .column-select__indicator-separator{background-color:#393f4f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{color:#364861;font-size:14px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;font-size:12px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#dde3ec;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column{color:#c2cede;background:#282c37;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#282c37;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#ecf0f4;font-size:18px;font-weight:500;border:2px dashed #606984;border-radius:4px}.upload-progress{padding:10px;color:#1b1e25;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:13px;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#606984;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#2b5fd9;border-radius:6px}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#000;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#2b5fd9;color:#fff;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#fff}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#fff}.privacy-dropdown__option.active:hover{background:#3c6cdc}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#1b1e25}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#000}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#2b5fd9}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#eaeef3}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#313543}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#ecf0f4;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#8d9ac2;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#a4afce}.search-results__header{color:#c2cede;background:#2c313d;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#c2cede}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#ecf0f4;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#f9fafb;text-decoration:underline}.search-results__info{padding:20px;color:#dde3ec;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b90d9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#1b1e25;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#131419;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#0a0a0a}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;font-size:13px}.display-case__case{background:#282c37;color:#ecf0f4;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#fff}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#1b1e25;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#000}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b5fd9;color:#fff}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#1b1e25;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#131419;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#2b90d9;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#c2cede;padding:8px 18px;cursor:default;border-right:1px solid #393f4f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#c2cede;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#c2cede}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#ecf0f4;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#17191f;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #313543;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(78,121,223,.5)}.audio-player__wave-placeholder{background-color:#4a5266}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#17191f;border-top:1px solid #313543;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#dde3ec;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#f4f6f9}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#4e79df}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#4e79df}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#0e1014;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#313543;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#17191f;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#282c37;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #393f4f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#1f232b;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#dde3ec;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#ecf0f4}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #393f4f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #282c37}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#242731;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #191b22}.filter-form{background:#282c37}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#4e79df;background:#4e79df}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{color:#364861;font-size:14px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#ecf0f4;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#282c37;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#313543}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f}.account__moved-note__message{position:relative;margin-left:58px;color:#c2cede;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#313543}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.list-editor{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#444b5d;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#444b5d}.list-adder__lists{background:#444b5d;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #393f4f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#dde3ec;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#1f232b}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#313543;padding:5px;border-bottom:1px solid #42485a}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#17191f;border:2px solid #313543}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #42485a;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #42485a}.account__header__bio .account__header__fields a{color:#4e79df}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#dde3ec;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.trends__header{color:#c2cede;background:#2c313d;border-bottom:1px solid #1f232b;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #393f4f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#c2cede;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#dde3ec;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#ecf0f4}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#459ede !important}.conversation{display:flex;border-bottom:1px solid #393f4f;padding:5px;padding-bottom:0}.conversation:focus{background:#2c313d;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#dde3ec;padding-left:15px}.conversation__content__names{color:#dde3ec;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#2c313d}.conversation--unread:focus{background:#313543}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#6d89af}.poll__chart.leading{background:#2b5fd9}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#2b90d9}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#c2cede}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#c2cede;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(194,206,222,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#8d9ac2;border-color:#8d9ac2;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#c2cede}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(43,95,217,.2)}.modal-layout{background:#282c37 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#1b1e25;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#131419}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#2485cb}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#2b90d9}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#000;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#364861}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#dde3ec}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#dde3ec}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#ecf0f4}.rich-formatting em{font-style:italic;color:#ecf0f4}.rich-formatting code{font-size:.85em;background:#17191f;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#ecf0f4}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #313543;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #313543;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#dde3ec}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#c2cede}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#1f232b;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#ecf0f4}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#17191f;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#dde3ec;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #313543;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#bcc9da}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#dde3ec}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#dde3ec}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#fefefe}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#282c37;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#fefefe}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#282c37;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#ecf0f4}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#dde3ec}.landing-page__short-description h1 small span{color:#ecf0f4}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;font-weight:700;font-size:14px;color:#dde3ec}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#282c37;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#dde3ec}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#dde3ec}.landing .simple_form p.lead{color:#dde3ec;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #393f4f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#c2cede}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #282c37;text-align:left;background:#1f232b}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #282c37;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#282c37}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#282c37;border-top:1px solid #17191f;border-bottom:1px solid #17191f}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #17191f}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #17191f}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#dde3ec;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #17191f;background:#282c37;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #17191f;border-top:0;background:#282c37}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #17191f;border-top:0;background:#1f232b}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #17191f}}.batch-table__row:hover{background:#242731}.batch-table__row:nth-child(even){background:#282c37}.batch-table__row:nth-child(even):hover{background:#2c313d}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#dde3ec;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #17191f;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #17191f}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#282c37;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#393f4f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#dde3ec;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#42485a}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#dde3ec;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#1d2028;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#242731;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#1f232b;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#2b5fd9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#416fdd}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #393f4f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#ecf0f4;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#ecf0f4;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{font-size:14px;font-weight:700;color:#dde3ec;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #393f4f}.admin-wrapper .content h6{font-size:16px;color:#ecf0f4;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#ecf0f4;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #313543;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b5fd9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#dde3ec}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#c2cede;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;font-size:13px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#dde3ec;text-decoration:none;font-size:13px;font-weight:500;border-bottom:2px solid #282c37}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #333846}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b5fd9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#ecf0f4}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#282c37;color:#dde3ec;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#c2cede}.log-entry__extras{background:#353a49;border-radius:0 0 4px 4px;padding:10px;color:#dde3ec;font-family:\"mastodon-font-monospace\",monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#c2cede}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#79bd9a}.log-entry__icon__overlay.negative{background:#e87487}.log-entry__icon__overlay.neutral{background:#2b5fd9}.log-entry a,.log-entry .username,.log-entry .target{color:#ecf0f4;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#e87487}.log-entry .diff-neutral{color:#ecf0f4}.log-entry .diff-new{color:#79bd9a}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#ecf0f4}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b5fd9}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#dde3ec}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#c2cede}.report-card{background:#282c37;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#dde3ec;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#f7f9fb}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #1f232b}.report-card__summary__item:hover{background:#2c313d}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#dde3ec}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#c2cede;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#dde3ec}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#393f4f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#4e79df}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#313543;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#393f4f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#dde3ec;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(19, 20, 25, 0), #131419)}body.rtl .simple_form select{background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}.compose-form .compose-form__modifiers .compose-form__upload-description input::placeholder{opacity:1}.rich-formatting a,.rich-formatting p a,.rich-formatting li a,.landing-page__short-description p a,.status__content a,.reply-indicator__content a{color:#5f86e2;text-decoration:underline}.rich-formatting a.mention,.rich-formatting p a.mention,.rich-formatting li a.mention,.landing-page__short-description p a.mention,.status__content a.mention,.reply-indicator__content a.mention{text-decoration:none}.rich-formatting a.mention span,.rich-formatting p a.mention span,.rich-formatting li a.mention span,.landing-page__short-description p a.mention span,.status__content a.mention span,.reply-indicator__content a.mention span{text-decoration:underline}.rich-formatting a.mention span:hover,.rich-formatting a.mention span:focus,.rich-formatting a.mention span:active,.rich-formatting p a.mention span:hover,.rich-formatting p a.mention span:focus,.rich-formatting p a.mention span:active,.rich-formatting li a.mention span:hover,.rich-formatting li a.mention span:focus,.rich-formatting li a.mention span:active,.landing-page__short-description p a.mention span:hover,.landing-page__short-description p a.mention span:focus,.landing-page__short-description p a.mention span:active,.status__content a.mention span:hover,.status__content a.mention span:focus,.status__content a.mention span:active,.reply-indicator__content a.mention span:hover,.reply-indicator__content a.mention span:focus,.reply-indicator__content a.mention span:active{text-decoration:none}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active,.rich-formatting p a:hover,.rich-formatting p a:focus,.rich-formatting p a:active,.rich-formatting li a:hover,.rich-formatting li a:focus,.rich-formatting li a:active,.landing-page__short-description p a:hover,.landing-page__short-description p a:focus,.landing-page__short-description p a:active,.status__content a:hover,.status__content a:focus,.status__content a:active,.reply-indicator__content a:hover,.reply-indicator__content a:focus,.reply-indicator__content a:active{text-decoration:none}.rich-formatting a.status__content__spoiler-link,.rich-formatting p a.status__content__spoiler-link,.rich-formatting li a.status__content__spoiler-link,.landing-page__short-description p a.status__content__spoiler-link,.status__content a.status__content__spoiler-link,.reply-indicator__content a.status__content__spoiler-link{color:#ecf0f4;text-decoration:none}.status__content__read-more-button{text-decoration:underline}.status__content__read-more-button:hover,.status__content__read-more-button:focus,.status__content__read-more-button:active{text-decoration:none}.getting-started__footer a{text-decoration:underline}.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:none}.nothing-here{color:#dde3ec}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b5fd9}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n$ui-base-color: $classic-base-color !default;\n$ui-primary-color: $classic-primary-color !default;\n$ui-secondary-color: $classic-secondary-color !default;\n\n// Differences\n$ui-highlight-color: #2b5fd9;\n\n$darker-text-color: lighten($ui-primary-color, 20%) !default;\n$dark-text-color: lighten($ui-primary-color, 12%) !default;\n$secondary-text-color: lighten($ui-secondary-color, 6%) !default;\n$highlight-text-color: $classic-highlight-color !default;\n$action-button-color: #8d9ac2;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: darken($ui-base-color, 6%) !default;\n$light-text-color: darken($ui-primary-color, 40%) !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 15px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 5px;\n right: 5px;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 12px;\n padding: 0 6px;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $light-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n font-size: 12px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 13px;\n font-weight: 500;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 13px;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 13px;\n font-weight: 400;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n font-size: 12px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 13px;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n font-size: 13px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n font-size: 14px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n font-size: 13px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n font-size: 13px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n","// components.scss\n.compose-form {\n .compose-form__modifiers {\n .compose-form__upload {\n &-description {\n input {\n &::placeholder {\n opacity: 1;\n }\n }\n }\n }\n }\n}\n\n.rich-formatting a,\n.rich-formatting p a,\n.rich-formatting li a,\n.landing-page__short-description p a,\n.status__content a,\n.reply-indicator__content a {\n color: lighten($ui-highlight-color, 12%);\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n }\n\n &.mention span {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.status__content__spoiler-link {\n color: $secondary-text-color;\n text-decoration: none;\n }\n}\n\n.status__content__read-more-button {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.getting-started__footer a {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.nothing-here {\n color: $darker-text-color;\n}\n\n.public-layout .public-account-header__tabs__tabs .counter.active::after {\n border-bottom: 4px solid $ui-highlight-color;\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/contrast/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss","webpack:///./app/javascript/styles/contrast/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CC9EmB,iEDqFrB,kBCrFqB,4BDyFrB,sBACE,MErFF,iDACE,mBACA,eACA,iBACA,gBACA,WCXM,kCDaN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDrDmB,kBCyDnB,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cD9EgB,mBAZC,WC6FjB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aDpLwB,kKCuLtB,oBAGE,sDAIJ,aDpLgB,eCsLd,0DAEA,aDxLc,oDC6LhB,cACE,SACA,uBACA,cDhMc,aCkMd,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aC9NY,gBDgOV,gBEnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SDrBI,YCuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WD9BE,qBCgCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cH/EmB,wBGiFnB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UDxUA,qCC2UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cHhVc,mBGkVd,kBACA,uHAEA,yBAGE,WDrWA,qCCyWF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBHxaiB,8CG6anB,yBACE,gBACA,aACA,kBACA,mBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WD1kBF,gBC4kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WDplBJ,gBCslBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aH/lBQ,oDGsmBd,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cH3nBU,aG6nBV,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BH3pBW,wEGiqBX,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WDnsBJ,6CCqsBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cH5tBY,uDG+tBZ,oBACE,cHhuBU,qBGkuBV,aACA,gBACA,8DAEA,eACE,WDpvBJ,qCC0vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aDryBU,8DC2yBV,mBACA,WD7yBE,qFCizBJ,YAEE,eACA,cHvyBc,2CG2yBhB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBHn3Ba,+IGs3BX,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cLnFc,6BKsFd,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cNYgB,gBMVhB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cNJiB,wBMQnB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBLPI,uBKUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBPlBmB,aOoBjB,0BACA,eACA,cPVgB,iBOYhB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aPxCmB,qBO0CjB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cPjEgB,+BOqElB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aP9FkB,aOmGpB,YACE,kBACA,mBPjHmB,mCOmHnB,qBAGF,YACE,kBACA,0BACA,kBACA,cP9GkB,mBOgHlB,iBAGF,eACE,eACA,cPrHkB,iBOuHlB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cPhIgB,0BOoIlB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cP7IgB,qBO+IhB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBP3KmB,mCO6KnB,cP/JqB,gBOiKrB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cP5Mc,8DOkNhB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eLlPM,CKoPN,cACA,cPvOkB,mBOyOlB,+BANA,iBACA,CLlPM,kCKgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,ULjQM,eKmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cP7PgB,qCOiQlB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBPvRqB,kBOyRnB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBPpSe,kBOsSf,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBP3SiB,eO6Sf,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WLnUE,mBKqUF,gBACA,uBACA,wBAEA,aP5Tc,0BOgUd,aACE,gBACA,eACA,eACA,cPpUY,0IO0Ud,ULvVE,+BK+VJ,aACE,YACA,uDAGF,oBPzViB,wCO6VjB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cP3YgB,gBO6YhB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WLhbI,8BKmbJ,aACE,cPvac,gBOyad,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aPxgBkB,iCOugBpB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cP/hBiB,4JOkiBjB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WLnkBI,gCKqkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MCplBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WNhDA,cMkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aRjEoB,0BQmElB,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aRvFkB,sBQ0FhB,aRnGsB,yBQuGtB,iBACE,kBACA,mBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cRrHgB,iCQwHhB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WNlKA,gBMoKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WNxLE,cM0LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WN9ME,cMgNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WNlSI,cMoSJ,WACA,2CAKE,mBACE,eACA,WN5SA,qBM8SA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WN5UI,cM8UJ,WACA,UACA,oBACA,gBACA,mBACA,yBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBNnWY,oLMuWZ,iBACE,4WAGF,oBRxWsB,mBQ2WpB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBRlZiB,WEXb,eMgaJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBRzboB,gGQ6bpB,kBN7bQ,kHMgcN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WNhdI,cMkdJ,WACA,UACA,oBACA,gBACA,wXACA,yBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cRheY,oBQkeZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,iEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UN9hBF,aMwiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cRniBkB,kBQqiBlB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cNxjBY,sBM4jBd,mCACE,+BACA,cN7jBQ,kBMikBV,oBACE,cRvjBgB,qBQyjBhB,wBAEA,UNxkBI,0BM0kBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBRxlBiB,WEDb,eM4lBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aRpnBkB,qBQsnBhB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aRjpBwB,yBQmpBtB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cRxpBkB,oCQ2pBlB,cACE,mBACA,kBACA,4CAGF,aR/pBqB,gBQiqBnB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBNrsBM,YMusBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cRpsBqB,WQssBrB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WNjvBI,qCMmvBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UNzvBI,0BM2vBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cR3xBkB,0BQ8xBlB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WNrzBI,kBMuzBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aN/zBc,0SMy0BZ,+CACE,aAIJ,kBACE,yBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBNn3Bc,gBMq3BZ,2BAEA,kBNv3BY,gBMy3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC17BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,mBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WP7EA,gBO+EA,gBACA,uBACA,+BAGF,aACE,eACA,cTzEY,gBS2EZ,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WP3GI,gBO6GJ,qBACA,iBACA,qBACA,sBAGF,ePnHM,oBOqHJ,WTtHI,eSwHJ,cACA,kBAGF,cACE,uCAGF,wBAEE,cTlHmB,oBSsHrB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBT3KqB,mCS6KnB,cTzJiB,eS2JjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cT1MwB,sCS4MxB,sCACA,6DAEA,aPjNc,sCOmNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cT7OmB,6BS+OnB,6BAGF,aACE,cTrPgB,4BSyPlB,aTlQwB,qBSoQtB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aPpRY,gBOsRV,0CAGF,aPzRY,wCO8Rd,eACE,wCAIJ,UACE,0BAIA,aT5RkB,4BS+RhB,aTzSsB,qBS2SpB,qGAEA,yBAGE,iCAIJ,UPvTI,gBOyTF,wBAIJ,eACE,kBChUJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBV5BmB,6GU+BjB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBVpEmB,WEXb,oBQkFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UR5FI,gFQgGN,kBAGE,qNAKA,kBVtGoB,4IU8GpB,kBR9GQ,qCQqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cZHmB,SYKnB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aZxBsB,eY0BpB,SAIJ,wBZrBqB,YYuBnB,kBACA,sBACA,WVpCM,eUsCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBVjEQ,gBUqEN,mCAIJ,wBZzEsB,eY4EpB,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,UZvGM,mBAGgB,qGYwGpB,wBAGE,8BAIJ,kBVnFsB,2GUsFpB,wBAGE,0BAIJ,aZ9GkB,uBYgHhB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,UACA,cZ1IoB,SY4IpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,uCACA,4BACA,2CACA,oBAGF,qCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aZnLwB,gCYuLxB,QACE,uEAGF,mBAGE,uBAGF,aZjLmB,sFYoLjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,aZpNsB,uCYuNpB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBAKN,aZ1NqB,SY4NnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,aACE,6BACA,eACA,0BAGF,aZ3QwB,qCY+QxB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,aZhUsB,sDYoUtB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBZrVoB,yDY4VxB,UZjWM,mBYmWJ,mBZhWoB,oCYkWpB,iBACA,kBACA,eACA,gBACA,6CAEA,UZ3WI,gBY6WF,CAII,kRADF,eACE,wCAKN,aZjWiB,gBYmWf,0BACA,yIAEA,oBAGE,sCAKN,iBACE,MACA,QACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,WZraI,gBECA,aUuaJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,aZhac,CY8Zd,sHAEA,aZhac,CY8Zd,8HAEA,aZhac,CY8Zd,4GAEA,aZhac,+FYoad,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBZ1dsB,0BY4dtB,WZheI,eYkeJ,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,aZ5gBmB,wCYghBnB,UZriBM,oBYuiBJ,eACA,gBVviBI,sEU0iBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cZpjBa,eYsjBb,gBACA,aACA,oBACA,6QAEA,UAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cZplBa,SYslBb,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UV7mBF,8GUinBE,WACE,cZnmBW,CEff,oGUinBE,WACE,cZnmBW,CEff,wGUinBE,WACE,cZnmBW,CEff,+FUinBE,WACE,cZnmBW,iFYwmBf,SACE,wEAKN,iBACE,sBV/nBE,wBUioBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,mBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cZ9qBmB,4CYirBnB,aVlsBY,kCUusBd,2CACE,WC7sBF,8DDktBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBZ3tBsB,aY6tBtB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,UZhvBQ,cYkvBN,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WV3wBM,wDU8wBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aVnyBc,qBUqyBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,aZvzBc,8EY4zBhB,aACE,0GAGF,kBZ7zBoB,sHYg0BlB,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,+BAKN,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,aZ92BqB,qBYg3BnB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,WZ/6BM,gBYi7BN,eACA,cACA,yBACA,iBACA,eACA,sBACA,4BAGF,aZ36BkB,SY66BhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aZ5+Be,qCYg/Bf,UZtgCI,6BY0gCJ,aZp/Be,CAtBX,kEYkhCJ,UZlhCI,kCYqhCF,aZhhCoB,gEYohCpB,UVxhCE,mBFEgB,sEY0hChB,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,aZhiCkB,YYmiChB,eACA,uBAGF,aZviCkB,qCY2iClB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cZ5lCgB,CY8lChB,iBACA,eACA,kBACA,+CAEA,aZnmCgB,uBYumChB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cZ7nCgB,4BYmoCtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cZ9rCgB,eYgsChB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,UZzuCQ,eY2uCN,6BAEA,aZxtCmB,SY6tCrB,YACE,gCACA,8BAEA,aACE,cACA,WVvvCI,qBUyvCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cZ3wCgB,gBY6wChB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBE3yCE,iCACA,wBACA,4BACA,kBF0yCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBErzCA,iCACA,wBACA,4BACA,kBFozCE,gBACA,kBACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WVr0CE,6BUu0CF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCE30CrB,+BF60CA,iBEt1CA,iCACA,wBACA,4BACA,WFq1CuB,sCE/0CvB,kCFk1CA,iBE31CA,iCACA,wBACA,4BACA,WF01CuB,sCEp1CvB,kBFs1CE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cZ/1CgB,6BYk2ChB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,yBACA,eACA,cZ/6CgB,kCYm7ClB,aACE,eACA,gBACA,WVn8CI,CUw8CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UVx+CM,kBU8+CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aZ5/CuB,cY8/CrB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WV1hDI,kCU+hDR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,CZpiDgB,gHY8iDhB,aZ9iDgB,wBYkjDhB,UACE,wCAGF,kBVtiDsB,WF/BhB,8CYykDJ,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cZrkDkB,eYukDlB,iBACA,kBACA,4BAEA,aZplDwB,6BYwlDxB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CVjmDU,mEUwmDZ,aVxmDY,uBU4mDZ,aV7mDc,4DUmnDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UVvoDM,0BUyoDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cV9nD4B,eAEC,0DU+nD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cVtpD4B,eAEC,WUupD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cZ1sDkB,wBY6sDlB,aZ3sDqB,mBY+sDrB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBZxxD0B,cY0xDxB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BZ7zDsB,2BYi0DxB,WACE,iBACA,uBACA,yBZp0DsB,8BYw0DxB,QACE,iBACA,uBACA,4BZ30DsB,6BY+0DxB,SACE,gBACA,2BACA,2BZl1DsB,wBYw1DxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ91DsB,WAJlB,gBYq2DJ,uBACA,mBACA,yFAEA,kBZ71DiB,cAIE,UY81DjB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBZx3DsB,cY03DtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZj5DsB,WAJlB,gBYw5DJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBZp5DiB,cAIE,iBYu5DvB,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBZ1gEmB,8BY4gEjB,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cZxhEkB,qBY0hElB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WV7lEM,qBU+lEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cZlnEsB,sBYsnExB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WVpyEM,kBUsyEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBZp2EiB,yBYs2EjB,gBACA,kBACA,eACA,gBACA,iBACA,WVt3EI,mDU23ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBV75EI,0BU+5EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBZ59EmB,0BYi+ErB,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cZhjFwB,eYkjFxB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cZrkFwB,eYukFxB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBZ7oFmB,qCY+oFnB,sEAGF,wBACE,4CAGF,wBZ5oFqB,+EYgpFrB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBZxsFmB,cY4sFrB,kBACE,WVxtFM,cU0tFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cZvuFsB,kGY0uFtB,sBAGE,WVhvFE,kCUovFJ,aZzuFiB,oBY+uFrB,oBACE,iBACA,qBAGF,oBACE,kBACA,CACA,gBACA,CZlwFmB,eYqwFnB,iBACA,wCANA,cACA,CACA,eACA,mBAaA,CAVA,mBZtwFmB,aAaH,iBY+vFhB,CAEA,wBACA,eACA,yDAGF,kBZnxFqB,cYyxFrB,aACE,kBAGF,aZhxFkB,cYkxFhB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,aZ9yFY,0BYgzFV,sDAIJ,oBACE,cZtzFc,sMYyzFd,yBAGE,oDAKN,aZh0FgB,0BYs0FhB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cZ/0Fc,aYi1Fd,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,aZ12Fc,qBYi3FpB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,WZx5FM,gBECA,aU05FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,aZ76FsB,6BY+6FpB,uDAGF,aZ/7F0B,cYm8F1B,YACE,eACA,yBACA,kBACA,cZ77FgB,gBY+7FhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cZx9FiB,uBY09FjB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UVh/FE,yBUu/FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cZ7gGkB,gBY+gGlB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aZ3hGoB,oBY+hGpB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cZ3mGgB,6BY6mGhB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cZroGgB,mBAbG,eYqpGnB,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cZnqGY,qCYuqGd,cACE,gBACA,yBAKN,iBACE,cACA,UACA,gCAEA,sCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,gFACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,aZjvGwB,4CYsvGtB,aZtvGsB,yCYwvGpB,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cZxwGkB,eY0wGlB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UVnyGI,mBUqyGF,6BAKN,eACE,gBACA,gBACA,cZhyGkB,0DYkyGlB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aZ/zGkB,0BYi0GhB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,aZh2GkB,eYk2GhB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBVz+GM,WACA,eU2+GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eVv/GQ,cFcY,SY4+GlB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WVtjHE,gBUwjHF,eACA,+LAMA,yBACE,mEAKF,yBACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aZvlHoB,eYylHlB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtmHF,sBACA,WACA,SACA,gBACA,oBACA,mBdhBmB,cAYD,ecOlB,SACA,+EFgmHI,aACE,CEjmHN,qEFgmHI,aACE,CEjmHN,yEFgmHI,aACE,CEjmHN,gEFgmHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,aZ9nHc,iBYgoHZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aZxpHgB,0HY6pHhB,cAEE,gBACA,cZ/pHY,kZYkqHZ,aAGE,gEAIJ,wBACE,iDAGF,eV3rHI,kBY0BN,CAEA,eACA,cdRiB,uCcUjB,UF8pHI,mBZ1rHoB,oDc8BxB,wBACE,cdbe,ecef,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WdnDI,sDYksHJ,WACE,mDAGF,UZtsHI,kBYwsHF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UVxtHQ,kBU0tHN,cACA,mBACA,sBV7tHM,yBU+tHN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aZluHoB,qBYouHlB,mBACA,gBACA,sBACA,6EAGF,aZzuHkB,mBAbG,kBY2vHnB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,4EAfF,cAgBI,6FAGF,eACE,mFAGF,aZ1wHwB,qBY4wHtB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eVt2HQ,kBUw2HN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBVl4HM,kBUo4HN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBZn8HmB,kCYq8HnB,uBAGF,MACE,aACA,mBACA,uBACA,cZ97HqB,eYg8HrB,gBACA,0BACA,kBACA,kBAGF,YACE,cZl8HmB,gBYo8HnB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,yBACA,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBVj9HsB,kBUm9HtB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBZ9+HmB,kBYg/HnB,eAGF,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBVliIM,uCUoiIN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,UZnjIQ,aYqjIN,eACA,aACA,kEAEA,kBZ7iImB,WEXb,UU4jIJ,CV5jII,4RUikIF,UVjkIE,wCUukIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cZhkImB,2CYmkInB,eACE,cACA,WZ1lII,CY+lIA,wQADF,eACE,mDAON,eVrmIM,0BUumIJ,qCACA,gEAEA,eACE,0DAGF,kBZnmIiB,uEYsmIf,UVjnIE,uDUunIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SEznIE,sBACA,WACA,SACA,gBACA,oBACA,mBdhBmB,cAYD,ecOlB,SACA,cFmnIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cZ3qImB,eY6qInB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cZ/rIkB,eYisIlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,aZhtIkB,mBYktIhB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cZxuIc,iCY2uId,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cZxvIqB,qBY0vIrB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cZvwIkB,kBYywIlB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cVjyI0B,eAEC,CU2yI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,WV73IM,eU+3IN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cZx6IsB,mFY26ItB,yBAGE,wBAKN,oBACE,sBAGF,qBV17IQ,YU47IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBZj8I0B,qBYq8I1B,iBACE,UACA,QACA,YACA,6CAGF,kBZ78I0B,WAJlB,kBYs9IN,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,aZn/ImB,SYs/IjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,UZ5hJI,qwDYgiJF,aAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,WZnjJI,kBYqjJJ,yBACA,eACA,qBAGF,kBZxjJmB,cAcE,gBY6iJnB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,WZhlJM,kBYklJN,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,UACE,wBAKF,eV5mJM,CFGkB,gBY4mJtB,oBACA,iEVhnJI,2BFGkB,yBYqnJ1B,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBZpoJwB,aYsoJxB,iBACA,2HAEA,aACE,iBACA,cZ1nJiB,mBY4nJjB,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,aZxsJwB,iLY4sJxB,UZjtJM,qCYstJN,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UZhvJI,gBECA,aUkvJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eVlwJI,yBUowJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UZnxJE,oBYqxJA,eACA,gBVrxJA,+CU0xJJ,YACE,8BACA,mBACA,4CAIJ,aACE,WZnyJI,eYqyJJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UZ9yJI,eYgzJF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UZ11JE,aY41JA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBZ/1JW,WEXb,uDUi3JA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cZj3JmB,eYm3JnB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UZt7JI,CYw7JF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBZl8J0B,WYo8JxB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WVj9JM,0BUm9JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cZ5+Jc,iBY8+Jd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cZ1gKY,gBY4gKZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,aZ7hKc,gBYqiKhB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cZhkKqB,kBYkkKrB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBV3lKM,CU4lKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBVvmKM,iCU0mKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBVvsKM,eUysKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBVpxKI,cFcY,gBYywKhB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UVn1KE,+EU21KN,cAGE,gBACA,6BAGF,UVl2KM,iBUo2KJ,yBAGF,oBACE,aACA,mDAGF,UV52KM,uBUi3KN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WVj6KE,sFUo6KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,mBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WV3jLF,gBU6jLE,gBACA,uBACA,0CAGF,aACE,eACA,cZvjLU,gBYyjLV,gBACA,uBACA,yBAKN,kBZ5kLiB,aY8kLf,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cZ/oLgB,eYipLhB,eACA,gBACA,kBACA,qBACA,kBACA,WACA,mBACA,yJAEA,aZxpLmB,qWY2pLjB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBZltLqB,sBYqtLnB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eVxwLQ,kBY0BN,CACA,sBACA,gBACA,cdRiB,uCcUjB,mBAEA,wBACE,cdbe,ecef,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WdnDI,UY6wLR,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cZxwLmB,gBY0wLnB,gBAEA,aZvxLsB,0BYyxLpB,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBZt5Le,WEDb,eU05LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cZv7Lc,CY07Ld,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,cAGF,kBZpgMqB,sBYsgMnB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBZ1jMqB,sBY4jMnB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBV5mMM,yDU+mMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBVvnMI,uBU2nMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UVxpMI,eU0pMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aZrqMoB,eYuqMlB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WVtyMA,gBUwyMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cZlyMU,gBYoyMV,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WVn0ME,gDUu0MJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aVl1MU,yBUw1Md,cACE,gCAEA,cACE,cZh1Mc,eYk1Md,kCAEA,oBACE,cZr1MY,qBYu1MZ,iBACA,gBACA,yCAEA,eACE,WVz2MF,iBUk3MN,aZp2MgB,mBYs2Md,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cZ93MY,gBYg4MZ,uBACA,mBACA,4BAEA,eACE,uBAGF,aZz4Mc,qBY24MZ,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cZl6MiB,0BYs6MnB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBZr9MsB,kBYu9MtB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cZl+Mc,kBYo+Md,+BAGF,aZv+MgB,eYy+Md,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UV9/ME,qBUggNA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UV1hNI,gBUgiNR,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBZ9kNoB,kBYglNpB,cACA,eACA,4BAIJ,YACE,cZ9kNgB,kBYglNhB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,cZ5oNc,mFYgpNhB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,aZ/qNkB,SYirNhB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OGxtNN,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBfHiB,eeQnB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAGF,aACE,sBACA,WACA,eACA,WfhDE,UekDF,oBACA,gBblDE,yBaoDF,kBACA,iBACA,sCAEA,oBfpDoB,0BeyDtB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBb3FY,8EagGZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,cfvHc,ae2HhB,cACE,uBACA,UACA,SACA,SACA,cfhIc,0BekId,kBACA,mBAEA,oBACE,sCAGF,qCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBflLoB,sDewLxB,cACE,gBACA,iBACA,YACA,oBACA,cfhLkB,sCemLlB,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WftNI,qBewNJ,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,afzNkB,qBe4NhB,+BACE,6BAEA,6BACE,eChPN,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,WjBDM,2BiBIN,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBjBjBsB,4BiBqBxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,cjBfmB,ciBiBnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,ajBlD0B,mCiBqDxB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBjBnEwB,uBiBwExB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBf5FM,sBe8FN,sGAEA,+BAEE,oBAKF,2BACA,gBfxGM,0Be2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,WjBnHI,yBiBqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBfpKI,mBeyKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,cjBlKiB,mDiBqKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,clBPkB,oBkBUlB,alBnBwB,0BkBqBtB,6EAEA,oBAGE,wCAIJ,alBrBkB,oBkB0BlB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,clBlCmB,qBkBsCrB,iBACE,clBvCmB,uBkB2CrB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,clB3DmB,qBkB+DrB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,clBxIc,iCkB4IhB,uBACE,gBACA,gBACA,clB9IY,qDkBkJd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WhBpNI,iBgBsNJ,kBACA,qEAEA,aAEE,6CAIA,alBhNiB,oCkBqNnB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,clBlPc,mBkBoPd,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WhBzSA,qBgB2SA,uDAGE,yBACE,2CAKN,aACE,clBxSY,kCkBgTlB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,clBvTgB,sCkB0ThB,alBnUsB,0BkBqUpB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,clB/UmB,wBkBkVnB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,clBhWmB,kBkBqWnB,clBrWmB,mCkBoWrB,4CACE,CACA,gBACA,gBACA,mBACA,clBzWmB,kBkB8WnB,clB9WmB,kBkBuXnB,clBvXmB,mCkBsXrB,4CACE,CACA,gBACA,gBACA,mBACA,clB3XmB,kBkBgYnB,clBhYmB,mCkBwYrB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,4CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBlBlcmB,kBkBocjB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBlB3jBiB,kBkB6jBjB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,alB7kBmB,qCkBilBnB,eACE,WhBjmBE,gBgBmmBF,2CAEA,alBxlBc,gDkB2lBZ,alBzlBe,+CkB+lBnB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,ShBvrBI,YgByrBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,clBpsBc,6BkBwsBhB,eACE,iBACA,+BAGF,kBlBztBiB,akB2tBf,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,clBnvBY,uFkByvBlB,eACE,cASA,ClBnwBgB,2CkBgwBhB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,clB32BsB,qBkB62BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,clBv2Bc,SmBhBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBnBxBmB,UmB6BnB,anB1BwB,0BmB4BtB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBnBjEiB,6BmBmEf,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,cnB/FkB,gBmBiGlB,0DAEA,UjBhHM,wDiBoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBnB/JiB,sBmBiKjB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBnB9KiB,gCmBiLjB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBnBtMiB,uCmByMf,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,cnBlOY,gBmBoOZ,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBpBfe,YoBiBf,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SlBxCA,YkB0CE,kBACA,YACA,uCAIJ,aACE,cpBpCY,qBoBsCZ,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cpB9EY,qBoBgFZ,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UlBzGA,yBkB2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UlBjIE,yBFWa,gBoByHb,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,apBrMmB,eoBuMjB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,apBhNmB,eoBkNjB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,cpB7Nc,mBoB+Nd,kBACA,gCACA,4BAGF,cACE,cpBnOiB,iBoBqOjB,gBACA,0CAGF,UlBxPI,gBkB0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WlBxQE,oBkB0QF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cpBnQiB,mBoBqQjB,kCAEA,UlBtRE,gBkBwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,4CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BpBxUe,YoB+UrB,UACE,SACA,cACA,WACA,sDAKA,apBtVkB,0DoByVhB,apBlWsB,4DoBuWxB,alB1Wc,gBkB4WZ,4DAGF,alB9WU,gBkBgXR,0DAGF,apBvWgB,gBoByWd,0DAGF,alBtXU,gBkBwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,cpB3Zc,qBoB6Zd,yBACA,eACA,gBACA,gCACA,iCAEA,UlBhbE,gCkBkbA,oCAGF,apBjboB,gCoBmblB,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cpB/dmB,CoBoef,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,apBzjBwB,qBoB2jBtB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBpB5kBmB,gCoB8kBnB,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,cpB3kBgB,eoB6kBhB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,apBpmBgB,sDoBwmBhB,apBvmBqB,qBoB2mBnB,gBACA,yDAIJ,oBAIE,cpBpnBqB,iGoBunBrB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBlB9qBc,yBkBkrBd,yBACE,wBAGF,yBlBnrBU,wBkBwrBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,apBvrBgB,uBoB6rBhB,wBACA,qBAGF,apBhsBgB,coBqsBlB,kBpBltBqB,kBoBotBnB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cpB5tBc,yBoB8tBd,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,alBvvBM,6BkB8vBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cpBjwBY,mLoBowBZ,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,apB/wBU,iBoBixBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cpB5xBY,WoBmyBpB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,alB71BY,8CkBk2Bd,qBACE,aACA,WlBr2BI,ckB02BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBpBl3BiB,gCoBo3BjB,kCAEA,cACE,cACA,gBACA,eACA,gBACA,cpB72BiB,qBoB+2BjB,mBACA,uHAEA,UlBj4BE,iCkBw4BJ,cACE,cpB33BY,uCoB+3Bd,YACE,8BACA,mBACA,sCAGF,eACE,sBCt5BN,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WnBrCI,6CmBuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,crBpCgB,kBqBsChB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,arBnEwB,gBqBqEtB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,kEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,2GCEQ,SACE,CDHV,iGCEQ,SACE,CDHV,qGCEQ,SACE,CDHV,4FCEQ,SACE,mJAQZ,aAME,0BACA,mMAEA,oBACE,iOAGF,yBACE,CAKE,0zCAIJ,oBAGE,uUAGF,axB3BqB,qBwB6BnB,oCAIJ,yBACE,6HAEA,oBAGE,4BAIJ,yBACE,qGAEA,oBAGE,eAIJ,axBvDoB,yEwB2DpB,+BACE,0D","file":"skins/vanilla/contrast/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#313543 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#313543;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#353a49}::-webkit-scrollbar-thumb:active{background:#313543}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#282c37}::-webkit-scrollbar-track:active{background:#282c37}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#191b22;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#282c37}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#313543;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#1f232b;padding:0}body.error{position:absolute;text-align:center;color:#dde3ec;background:#282c37;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#e25169;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#2b90d9}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#c2cede;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#c2cede}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#c2cede;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#79bd9a;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#ecf0f4;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#42485a}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#dde3ec;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#4a5266;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#535b72}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#ecf0f4}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#0e1014}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#313543;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #313543;background:#17191f}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#313543;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#dde3ec}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#dde3ec;padding:10px;border-right:1px solid #313543;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#ecf0f4}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #42485a}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#dde3ec}.public-layout .public-account-header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#4e79df}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#dde3ec}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #393f4f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #393f4f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#282c37}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#313543}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#737d99}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#737d99}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#dde3ec}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#737d99}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#737d99}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#7f88a2}.compact-header h1{font-size:24px;line-height:28px;color:#dde3ec;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#ecf0f4}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#282c37;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.hero-widget__text a{color:#ecf0f4;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#dde3ec}.box-widget{padding:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #c2cede;text-align:center;color:#dde3ec;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#dde3ec;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#dde3ec;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#ecf0f4;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#dde3ec}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#dde3ec;margin-bottom:10px}.page-header{background:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#dde3ec}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#313543}.page-header h1{font-size:24px}}.directory{background:#282c37;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#282c37;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#393f4f}.directory__tag.active>a{background:#2b5fd9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#dde3ec}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#dde3ec}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b5fd9}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#17191f;border:2px solid #282c37}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#dde3ec;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #393f4f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#dde3ec;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #4a5266}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#dde3ec}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b5fd9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#1f232b;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #313543}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #313543}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#dde3ec}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#0e1014}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#dde3ec}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #c2cede;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419;border:1px solid #0a0b0e;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#eaeef3}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#17191f}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b5fd9;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#416fdd}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#2454c7}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #0a0b0e;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#c2cede;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(19, 20, 25, 0), #131419)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(40,44,55,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#393f4f;color:#dde3ec;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#dde3ec;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#282c37;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#313543}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#dde3ec;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#4ea2df}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#dde3ec}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#ecf0f4;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#ecf0f4;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#dde3ec}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#131419;border:1px solid #0a0b0e;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#1f232b;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#393f4f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#1f232b;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#393f4f}.card__img{height:130px;position:relative;background:#0e1014;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#313543;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#17191f;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#ecf0f4}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#1a1a1a}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#282c37;box-shadow:0 0 15px rgba(0,0,0,.2);color:#364861;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #42485a;border-bottom:1px solid #42485a;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #42485a}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#ecf0f4;background:rgba(23,25,31,.5)}.account__header__fields dd{flex:1 1 auto;color:#dde3ec}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#dde3ec}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#282c37}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#393f4f}.button.logo-button{flex:0 auto;font-size:14px;background:#2b5fd9;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#5680e1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#2b5fd9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#2b5fd9;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#5680e1;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9baec8;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#606984}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#687390}.button.button-secondary{color:#dde3ec;background:transparent;padding:3px 15px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#eaeef3}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#8d9ac2;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#a4afce;background-color:rgba(141,154,194,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(141,154,194,.3)}.icon-button.disabled{color:#6274ab;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#1b1e25}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#0c0d11;background-color:rgba(27,30,37,.15)}.icon-button.inverted:focus{background-color:rgba(27,30,37,.3)}.icon-button.inverted.disabled{color:#2a2e3a;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#63ade3}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#1b1e25;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#0c0d11;background-color:rgba(27,30,37,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(27,30,37,.3)}.text-icon-button.disabled{color:#464d60;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.compose-form .compose-form__warning{color:#000;margin-bottom:10px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#000;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#1b1e25;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:0;right:0}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#c2cede}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#d9e1e8;border-radius:0 0 4px 4px;color:#000;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#1b1e25}.compose-form .compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#ecf0f4;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#ecf0f4;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#ecf0f4}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#1b1e25}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9baec8;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#000;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#fff}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#dae1ea}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#c2cede}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#4e79df}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#8d9ac2}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#a4afce;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#ecf0f4;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#4e79df}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#4e79df;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#000;font-weight:700;font-size:11px;padding:0 6px;text-transform:uppercase;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#c2cede;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #393f4f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#313543}.focusable:focus .status.status-direct{background:#42485a}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#393f4f}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #393f4f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#393f4f;border-bottom-color:#42485a}.status.light .status__relative-time{color:#364861}.status.light .status__display-name{color:#000}.status.light .display-name{color:#364861}.status.light .display-name strong{color:#000}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#b8c0d9}.status__relative-time,.notification__relative_time{color:#c2cede;float:right;font-size:14px}.status__display-name{color:#c2cede}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#c2cede;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#c2cede}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#8d9ac2}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#313543;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#c2cede;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#000;font-size:14px}.reply-indicator__content a{color:#1b1e25}.domain{padding:10px;border-bottom:1px solid #393f4f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #393f4f}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#dde3ec;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #393f4f;color:#c2cede}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #393f4f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #2b5fd9}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#dde3ec}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#ecf0f4;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#c2cede}.muted .status__display-name strong{color:#c2cede}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#606984;color:#000}.muted a.status__content__spoiler-link:hover{background:#707b97;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#dde3ec;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#dde3ec}.navigation-bar strong{color:#ecf0f4}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b5fd9;color:#ecf0f4;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b5fd9;color:#ecf0f4}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#17191f;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#282c37;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#dde3ec;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#393f4f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #393f4f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#464d60;border-bottom-color:#464d60}}.tabs-bar__link.active{border-bottom:2px solid #2b90d9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#2558d0;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#4976de}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b5fd9;border:2px solid #393f4f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#17191f}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #313543;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#444b5d;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#282c37}.drawer__inner__mastodon{background:#444b5d url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#444b5d;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#393f4f;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#2e3340;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#313543;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#313543;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#282c37;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#131419}.react-toggle--checked .react-toggle-track{background-color:#2b5fd9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#5680e1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #282c37;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b5fd9}.column-link{background:#393f4f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#404657}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#2b5fd9}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#282c37;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#282c37;color:#c2cede;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#282c37}.flex-spacer{flex:1 1 auto}.getting-started{color:#c2cede;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#c2cede;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#dde3ec}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#c2cede}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#dde3ec;padding:10px;font-weight:500;border-bottom:1px solid #393f4f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#dde3ec}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#393f4f;border:1px solid #1f232b}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#8d9ac2;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.status-card{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;color:#c2cede;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#ecf0f4;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#393f4f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#dde3ec;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#dde3ec}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#393f4f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#313543}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#313543}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#c2cede;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#2c313d}.load-gap{border-bottom:1px solid #393f4f}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#c2cede;background:#282c37;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#c2cede}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 95, 217, 0.23) 0%, rgba(43, 95, 217, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#313543;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,144,217,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#313543;border:0;color:#dde3ec;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#f4f6f9}.column-header__button.active{color:#fff;background:#393f4f}.column-header__button.active:hover{color:#fff;background:#393f4f}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#dde3ec;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #42485a;margin:10px 0}.column-header__collapsible-inner{background:#393f4f;padding:15px}.column-header__setting-btn:hover{color:#dde3ec;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#c2cede;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #606984;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#606984}29%{background-color:#606984}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#dde3ec;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#f7f9fb}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.modal-container--preloader{background:#393f4f}.account--panel{background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#393f4f;padding:15px}.column-settings__section{color:#dde3ec;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#eaeef3}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#313543}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#c2cede;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#393f4f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#42485a;color:#eaeef3}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#dde3ec}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#c2cede}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#d0d9e5}.column-settings__hashtags .column-select__indicator-separator{background-color:#393f4f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#364861;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#dde3ec;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#c2cede;background:#282c37;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#1f232b;contain:initial}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#282c37;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#ecf0f4;font-size:18px;font-weight:500;border:2px dashed #606984;border-radius:4px}.upload-progress{padding:10px;color:#1b1e25;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:12px;text-transform:uppercase;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#606984;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#2b5fd9;border-radius:6px}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#000;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#2b5fd9;color:#fff;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#fff}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#fff}.privacy-dropdown__option.active:hover{background:#3c6cdc}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#1b1e25}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#000}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#2b5fd9}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#282c37;color:#dde3ec;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#eaeef3}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#313543}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#ecf0f4;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#8d9ac2;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#a4afce}.search-results__header{color:#c2cede;background:#2c313d;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#c2cede}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#ecf0f4;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#f9fafb;text-decoration:underline}.search-results__info{padding:20px;color:#dde3ec;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b90d9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#1b1e25;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#131419;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#0a0a0a}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#282c37;color:#ecf0f4;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#fff}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#1b1e25;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#000}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b5fd9;color:#fff}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#1b1e25;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#131419;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#2b90d9;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #393f4f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#c2cede;padding:8px 18px;cursor:default;border-right:1px solid #393f4f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#c2cede;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#c2cede}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#ecf0f4;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#17191f;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #313543;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(78,121,223,.5)}.audio-player__wave-placeholder{background-color:#4a5266}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#17191f;border-top:1px solid #313543;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#dde3ec;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#f4f6f9}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#4e79df}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#4e79df}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#4e79df;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#0e1014;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#313543;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#17191f;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#282c37;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #393f4f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#1f232b;border-bottom:1px solid #393f4f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#1f232b;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#dde3ec;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative;width:100%;white-space:nowrap}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#ecf0f4}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #393f4f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #282c37}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#242731;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #191b22}.filter-form{background:#282c37}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#4e79df;background:#4e79df}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#364861;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#364861;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#ecf0f4;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#282c37;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#313543}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#313543;border-top:1px solid #393f4f;border-bottom:1px solid #393f4f}.account__moved-note__message{position:relative;margin-left:58px;color:#c2cede;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#313543}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.list-editor{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#444b5d;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#282c37;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#444b5d}.list-adder__lists{background:#444b5d;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #393f4f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#dde3ec;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#1f232b}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#313543;padding:5px;border-bottom:1px solid #42485a}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#17191f;border:2px solid #313543}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #42485a;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#dde3ec;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #42485a}.account__header__bio .account__header__fields a{color:#4e79df}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#dde3ec;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#dde3ec;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.trends__header{color:#c2cede;background:#2c313d;border-bottom:1px solid #1f232b;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #393f4f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#c2cede;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#dde3ec;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#ecf0f4}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#459ede !important}.conversation{display:flex;border-bottom:1px solid #393f4f;padding:5px;padding-bottom:0}.conversation:focus{background:#2c313d;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#dde3ec;padding-left:15px}.conversation__content__names{color:#dde3ec;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#2c313d}.conversation--unread:focus{background:#313543}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.announcements{background:#393f4f;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#dde3ec;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#42485a;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#dde3ec}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#4a5266;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#eaeef3}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#3d4d73}.reactions-bar__item.active .reactions-bar__item__count{color:#4ea2df}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#dde3ec;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#eaeef3;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#8ba1bf;height:5px;min-width:1%}.poll__chart.leading{background:#2b5fd9}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#2b90d9}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#acd6c1;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#c2cede}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#c2cede;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(194,206,222,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#8d9ac2;border-color:#8d9ac2;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#c2cede}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(43,95,217,.2)}.modal-layout{background:#282c37 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#1b1e25;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#131419}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#2485cb}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#2b90d9}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#000;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#364861}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#dde3ec}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#dde3ec}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#ecf0f4}.rich-formatting em{font-style:italic;color:#ecf0f4}.rich-formatting code{font-size:.85em;background:#17191f;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#ecf0f4}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #313543;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #313543;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#dde3ec}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#c2cede}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#1f232b;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#ecf0f4}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#17191f;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#dde3ec;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #313543;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#bcc9da}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#dde3ec}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#dde3ec}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#fefefe}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#fefefe}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#ecf0f4}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#282c37;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#fefefe}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#282c37;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#ecf0f4}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#dde3ec}.landing-page__short-description h1 small span{color:#ecf0f4}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#dde3ec}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#282c37;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#dde3ec}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#dde3ec}.landing .simple_form p.lead{color:#dde3ec;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #393f4f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#c2cede}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #282c37;text-align:left;background:#1f232b}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #282c37;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#282c37}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#282c37;border-top:1px solid #17191f;border-bottom:1px solid #17191f}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #17191f}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #17191f}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#dde3ec;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #17191f;background:#282c37;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #17191f;border-top:0;background:#282c37}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #17191f;border-top:0;background:#1f232b}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #17191f}}.batch-table__row:hover{background:#242731}.batch-table__row:nth-child(even){background:#282c37}.batch-table__row:nth-child(even):hover{background:#2c313d}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#dde3ec;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #17191f;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #17191f}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#282c37;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#393f4f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#dde3ec;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#42485a}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#dde3ec;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#1d2028;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#242731;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#1f232b;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#2b5fd9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#416fdd}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #393f4f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#ecf0f4;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#ecf0f4;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#dde3ec;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #393f4f}.admin-wrapper .content h6{font-size:16px;color:#ecf0f4;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#ecf0f4;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(96,105,132,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #313543;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b5fd9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#dde3ec}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#c2cede;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#dde3ec;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #282c37}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #333846}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b5fd9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#ecf0f4}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#282c37;border-bottom:1px solid #313543}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#dde3ec;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#c2cede}.log-entry a,.log-entry .username,.log-entry .target{color:#ecf0f4;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#ecf0f4}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b5fd9}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#dde3ec}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#c2cede}.report-card{background:#282c37;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#dde3ec;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#f7f9fb}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #1f232b}.report-card__summary__item:hover{background:#2c313d}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#dde3ec}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#c2cede;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#dde3ec}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#393f4f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#4e79df}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.announcements-list{border:1px solid #313543;border-radius:4px}.announcements-list__item{padding:15px 0;background:#282c37;border-bottom:1px solid #313543}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#ecf0f4;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#fff}.announcements-list__item__meta{padding:0 15px;color:#c2cede}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#313543;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#393f4f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#dde3ec;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(19, 20, 25, 0), #131419)}body.rtl .simple_form select{background:#131419 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}.compose-form .compose-form__modifiers .compose-form__upload-description input::placeholder{opacity:1}.rich-formatting a,.rich-formatting p a,.rich-formatting li a,.landing-page__short-description p a,.status__content a,.reply-indicator__content a{color:#5f86e2;text-decoration:underline}.rich-formatting a.mention,.rich-formatting p a.mention,.rich-formatting li a.mention,.landing-page__short-description p a.mention,.status__content a.mention,.reply-indicator__content a.mention{text-decoration:none}.rich-formatting a.mention span,.rich-formatting p a.mention span,.rich-formatting li a.mention span,.landing-page__short-description p a.mention span,.status__content a.mention span,.reply-indicator__content a.mention span{text-decoration:underline}.rich-formatting a.mention span:hover,.rich-formatting a.mention span:focus,.rich-formatting a.mention span:active,.rich-formatting p a.mention span:hover,.rich-formatting p a.mention span:focus,.rich-formatting p a.mention span:active,.rich-formatting li a.mention span:hover,.rich-formatting li a.mention span:focus,.rich-formatting li a.mention span:active,.landing-page__short-description p a.mention span:hover,.landing-page__short-description p a.mention span:focus,.landing-page__short-description p a.mention span:active,.status__content a.mention span:hover,.status__content a.mention span:focus,.status__content a.mention span:active,.reply-indicator__content a.mention span:hover,.reply-indicator__content a.mention span:focus,.reply-indicator__content a.mention span:active{text-decoration:none}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active,.rich-formatting p a:hover,.rich-formatting p a:focus,.rich-formatting p a:active,.rich-formatting li a:hover,.rich-formatting li a:focus,.rich-formatting li a:active,.landing-page__short-description p a:hover,.landing-page__short-description p a:focus,.landing-page__short-description p a:active,.status__content a:hover,.status__content a:focus,.status__content a:active,.reply-indicator__content a:hover,.reply-indicator__content a:focus,.reply-indicator__content a:active{text-decoration:none}.rich-formatting a.status__content__spoiler-link,.rich-formatting p a.status__content__spoiler-link,.rich-formatting li a.status__content__spoiler-link,.landing-page__short-description p a.status__content__spoiler-link,.status__content a.status__content__spoiler-link,.reply-indicator__content a.status__content__spoiler-link{color:#ecf0f4;text-decoration:none}.status__content__read-more-button{text-decoration:underline}.status__content__read-more-button:hover,.status__content__read-more-button:focus,.status__content__read-more-button:active{text-decoration:none}.getting-started__footer a{text-decoration:underline}.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:none}.nothing-here{color:#dde3ec}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b5fd9}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n$ui-base-color: $classic-base-color !default;\n$ui-primary-color: $classic-primary-color !default;\n$ui-secondary-color: $classic-secondary-color !default;\n\n// Differences\n$ui-highlight-color: #2b5fd9;\n\n$darker-text-color: lighten($ui-primary-color, 20%) !default;\n$dark-text-color: lighten($ui-primary-color, 12%) !default;\n$secondary-text-color: lighten($ui-secondary-color, 6%) !default;\n$highlight-text-color: $classic-highlight-color !default;\n$action-button-color: #8d9ac2;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: darken($ui-base-color, 6%) !default;\n$light-text-color: darken($ui-primary-color, 40%) !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 11px;\n padding: 0 6px;\n text-transform: uppercase;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 12px;\n text-transform: uppercase;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n width: 100%;\n white-space: nowrap;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n","// components.scss\n.compose-form {\n .compose-form__modifiers {\n .compose-form__upload {\n &-description {\n input {\n &::placeholder {\n opacity: 1;\n }\n }\n }\n }\n }\n}\n\n.rich-formatting a,\n.rich-formatting p a,\n.rich-formatting li a,\n.landing-page__short-description p a,\n.status__content a,\n.reply-indicator__content a {\n color: lighten($ui-highlight-color, 12%);\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n }\n\n &.mention span {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.status__content__spoiler-link {\n color: $secondary-text-color;\n text-decoration: none;\n }\n}\n\n.status__content__read-more-button {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.getting-started__footer a {\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n}\n\n.nothing-here {\n color: $darker-text-color;\n}\n\n.public-layout .public-account-header__tabs__tabs .counter.active::after {\n border-bottom: 4px solid $ui-highlight-color;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/skins/vanilla/contrast/common.js b/priv/static/packs/skins/vanilla/contrast/common.js index e98309beb..ba45e450a 100644 Binary files a/priv/static/packs/skins/vanilla/contrast/common.js and b/priv/static/packs/skins/vanilla/contrast/common.js differ diff --git a/priv/static/packs/skins/vanilla/mastodon-light/common.css b/priv/static/packs/skins/vanilla/mastodon-light/common.css index 4c6bfe4e6..c2fdf30b8 100644 Binary files a/priv/static/packs/skins/vanilla/mastodon-light/common.css and b/priv/static/packs/skins/vanilla/mastodon-light/common.css differ diff --git a/priv/static/packs/skins/vanilla/mastodon-light/common.css.map b/priv/static/packs/skins/vanilla/mastodon-light/common.css.map index 64f2758de..f22d72ddd 100644 --- a/priv/static/packs/skins/vanilla/mastodon-light/common.css.map +++ b/priv/static/packs/skins/vanilla/mastodon-light/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/mastodon-light/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss","webpack:///./app/javascript/styles/mastodon-light/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,0CACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,8BACA,CC3EwB,iEDkF1B,kBClF0B,4BDsF1B,sBACE,MErFF,iDACE,mBACA,eACA,iBACA,gBACA,WDZM,kCCcN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDlDwB,kBCsDxB,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDzFiB,mBAEK,WC0FtB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aDvKmB,kKC0KjB,oBAGE,sDAIJ,aD7KgB,eC+Kd,0DAEA,aDjLc,oDCsLhB,cACE,SACA,uBACA,cDzLc,aC2Ld,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aDvNY,gBCyNV,gBCnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFtBI,YEwBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF/BE,qBEiCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cF5FiB,wBE8FjB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UFzUA,qCE4UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF3Ve,mBE6Vf,kBACA,uHAEA,yBAGE,WFtWA,qCE0WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFrbe,8CE0bjB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WF3kBF,gBE6kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WFrlBJ,gBEulBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aF1mBS,oDEinBf,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cFtoBW,aEwoBX,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BFxqBS,wEE8qBT,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFpsBJ,6CEssBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cFvuBa,uDE0uBb,oBACE,cF3uBW,qBE6uBX,aACA,gBACA,8DAEA,eACE,WFrvBJ,qCE2vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aF9xBU,8DEoyBV,mBACA,WF9yBE,qFEkzBJ,YAEE,eACA,cFlzBe,2CEszBjB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBFh3BkB,+IEm3BhB,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,eACE,kBACA,cJ7Fe,6BIgGf,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBCjIR,cACE,iBACA,cACA,gBACA,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cLjBe,wBKqBjB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBCPI,uBDUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBNfwB,aMiBtB,0BACA,eACA,cNrBiB,iBMuBjB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aNrDiB,qBMuDf,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,gBACA,eACA,cN3EiB,+BM+EnB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aNxGmB,aM6GrB,YACE,kBACA,mBN7GwB,mCM+GxB,qBAGF,YACE,kBACA,0BACA,kBACA,cNxHmB,mBM0HnB,iBAGF,eACE,eACA,cN/HmB,iBMiInB,qBACA,gBACA,UACA,oBAEA,YACE,gBACA,eACA,cNzIiB,0BM6InB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cNtJiB,qBMwJjB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBNtKwB,mCMwKxB,cN1KmB,gBM4KnB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cNrNe,8DM2NjB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eChPM,CDkPN,cACA,cNhPmB,mBMkPnB,+BANA,iBACA,CChPM,kCD8PN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UNhQM,eMkQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cNtQiB,qCM0QnB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBNlR0B,kBMoRxB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBN/RoB,kBMiSpB,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBNnSiB,eMqSf,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WNlUE,mBMoUF,gBACA,uBACA,wBAEA,aNrUe,0BMyUf,aACE,gBACA,eACA,eACA,cN7Ua,0IMmVf,UNtVE,+BM8VJ,aACE,YACA,uDAGF,oBNjViB,wCMqVjB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,cNnZiB,gBMqZjB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WN9aI,8BMibJ,aACE,cN/ae,gBMibf,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aNhhBmB,iCM+gBrB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cNthBiB,4JMyhBjB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WNjkBI,gCMmkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MEjlBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WRjDA,cQmDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aRpDe,0BQsDb,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aRlGmB,sBQqGjB,aRtFiB,yBQ0FjB,iBACE,kBACA,gBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cRhIiB,iCQmIjB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WRnKA,gBQqKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WRzLE,cQ2LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WR/ME,cQiNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WR7RI,cQ+RJ,WACA,2CAKE,mBACE,eACA,WRvSA,qBQySA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WRvUI,cQyUJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBRtVY,oLQ0VZ,iBACE,4WAGF,oBRrViB,mBQwVf,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBRtYiB,WAlBb,eQ2ZJ,oBACA,YACA,aACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBRrae,gGQyaf,kBDtbQ,kHCybN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WR1cI,cQ4cJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cRldY,oBQodZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,oEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,iCACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,URxhBF,aQkiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cRviBmB,kBQyiBnB,kBACA,mBACA,kBACA,uBAEA,mCACE,+BACA,cR1iBY,sBQ8iBd,mCACE,+BACA,cDtjBQ,kBC0jBV,oBACE,cR3jBiB,qBQ6jBjB,wBAEA,URlkBI,0BQokBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBR9kBsB,WALlB,eQslBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aRxnBmB,qBQ0nBjB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aR7nBmB,qBQ+nBjB,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cR3pBmB,oCQ8pBnB,cACE,mBACA,kBACA,4CAGF,aRpqBmB,gBQsqBjB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBD7rBM,YC+rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cRzsBmB,WQ2sBnB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,CACA,UR1uBI,qCQ4uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,URlvBI,0BQovBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cR9xBmB,0BQiyBnB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WR9yBI,kBQgzBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aDvzBc,0SCi0BZ,+CACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBRp2Bc,gBQs2BZ,2BAEA,kBRx2BY,gBQ02BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCl7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WT9EA,gBSgFA,gBACA,uBACA,+BAGF,aACE,eACA,cTpFa,gBSsFb,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WT5GI,gBS8GJ,qBACA,iBACA,qBACA,sBAGF,eFnHM,oBEqHJ,WTtHI,eSwHJ,cACA,kBAGF,cACE,uCAGF,aT7HmB,oBSkInB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,WACA,qCAGF,YA7DF,iBA8DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBTvK0B,mCSyKxB,cTnJiB,eSqJjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cT1MmB,mCS4MnB,mCACA,6DAEA,aTzMc,oCS2MZ,gCACA,qDAGF,aACE,oCACA,gCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cTzPiB,gCS2PjB,6BAGF,aACE,cT/PiB,4BSmQnB,aTpPmB,qBSsPjB,qGAEA,yBAGE,oCAIJ,mCACE,+BACA,sCAEA,aT5QY,gBS8QV,0CAGF,aTjRY,wCSsRd,eACE,wCAIJ,UACE,0BAIA,aTtSmB,4BSySjB,aTzSiB,qBS2Sf,qGAEA,yBAGE,iCAIJ,UTvTI,gBSyTF,wBAIJ,eACE,kBC/TJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBVzBwB,6GU4BtB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBV9DmB,WAlBb,oBUmFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UV7FI,gFUiGN,kBAGE,qNAKA,kBVzFe,4IUiGf,kBH9GQ,qCGqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cZWmB,SYTnB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aZHiB,eYKf,SAIJ,wBZPqB,YYSnB,kBACA,sBACA,WZ7BM,eY+BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBLxDQ,gBK4DN,mCAIJ,wBZnDiB,eYsDf,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,UZ9FM,mBAgBW,qGYkFf,wBAGE,8BAIJ,kBZ1FsB,2GY6FpB,wBAGE,0BAIJ,aZhHmB,uBYkHjB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,UACA,cZ5HoB,SY8HpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,sCACA,4BACA,2CACA,oBAGF,oCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aZ7JmB,gCYiKnB,QACE,uEAGF,mBAGE,uBAGF,aZ1LmB,sFY6LjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,aZ9LiB,uCYiMf,aACE,wBAKN,sBACE,8BACA,qBACA,kBACA,YACA,8BAEA,6BACE,mBAKN,aZnOqB,SYqOnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,UACE,6BACA,eACA,0BAGF,aZrPmB,qCYyPnB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,aZ1SiB,sDY8SjB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBZ/Te,yDYsUnB,UZxVM,mBY0VJ,mBZ1Ue,oCY4Uf,iBACA,kBACA,eACA,gBACA,6CAEA,UZlWI,gBYoWF,CAII,kRADF,eACE,wCAKN,aZ1WiB,gBY4Wf,0BACA,yIAEA,oBAGE,sCAKN,iBACE,QACA,UACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,WZ5ZI,gBOCA,aK8ZJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,aZhZc,CY8Yd,sHAEA,aZhZc,CY8Yd,8HAEA,aZhZc,CY8Yd,gIAEA,aZhZc,CY8Yd,4GAEA,aZhZc,+FYoZd,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBZldiB,0BYodjB,WZvdI,eYydJ,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,aZrhBmB,wCYyhBnB,UZ5hBM,oBY8hBJ,eACA,gBL9hBI,sEKiiBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cZxjBW,eY0jBX,gBACA,aACA,oBACA,6QAEA,aAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cZxlBW,SY0lBX,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,ULpmBF,8GKwmBE,WACE,cZvmBS,COFb,oGKwmBE,WACE,cZvmBS,COFb,wGKwmBE,WACE,cZvmBS,COFb,yGKwmBE,WACE,cZvmBS,COFb,+FKwmBE,WACE,cZvmBS,iFY4mBb,SACE,wEAKN,iBACE,sBLtnBE,wBKwnBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,gBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cZvrBmB,4CY0rBnB,aLzrBY,kCK8rBd,2CACE,WCpsBF,8DDysBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBZrsBiB,aYusBjB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,UZvuBQ,cYyuBN,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WZnwBM,wDYswBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aL1xBc,qBK4xBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,aZvyBc,8EY4yBhB,aACE,0GAGF,kBZ/yBoB,sHYkzBlB,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,WZ13BM,gBY43BN,eACA,cACA,iBACA,eACA,sBACA,4BAGF,aZ92BkB,SYg3BhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aZj7Be,CA3BX,uEYq9BF,UZr9BE,kCYy9BF,aZ97Ba,gCYm8Bf,UZ99BI,kCYi+BF,aZ/8Be,gEYm9Bf,UZr+BE,mBAgBW,sEYy9BX,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,aZr+BkB,YYw+BhB,eACA,uBAGF,aZ5+BkB,qCYg/BlB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cZhiCgB,CYkiChB,iBACA,eACA,kBACA,+CAEA,aZviCgB,uBY2iChB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cZnkCgB,4BYykCtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cZloCgB,eYooChB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,UZprCQ,eYsrCN,6BAEA,aZrrCmB,SY0rCrB,YACE,gCACA,8BAEA,aACE,cACA,WZnsCI,qBYqsCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cZjuCiB,gBYmuCjB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBEtvCE,iCACA,wBACA,4BACA,kBFqvCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBEhwCA,iCACA,wBACA,4BACA,kBF+vCE,gBACA,kBACA,eACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WZlxCE,6BYoxCF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCEvxCrB,+BFyxCA,iBElyCA,iCACA,wBACA,4BACA,WFiyCuB,sCE3xCvB,kCF8xCA,iBEvyCA,iCACA,wBACA,4BACA,WFsyCuB,sCEhyCvB,kBFkyCE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cZpyCgB,6BYuyChB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,eACA,cZr4CiB,kCYy4CnB,aACE,eACA,gBACA,WZ/4CI,CYo5CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UZp7CM,kBY07CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aZp9CqB,cYs9CnB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WZt+CI,kCY2+CR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,CZx+CgB,gHYk/ChB,aZl/CgB,wBYs/ChB,UACE,wCAGF,kBZjgDsB,WAfhB,8CYohDJ,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cZ3hDmB,eY6hDnB,iBACA,kBACA,4BAEA,aZlhDmB,6BYshDnB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CL5iDU,mEKmjDZ,aLnjDY,uBKujDZ,aLxjDc,4DK8jDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UZnlDM,0BYqlDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cLzkD4B,eAEC,0DK0kD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cLjmD4B,eAEC,WKkmD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cZhqDmB,wBYmqDnB,aZnqDmB,mBYuqDnB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBZpuDqB,cYsuDnB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BZzwDiB,2BY6wDnB,WACE,iBACA,uBACA,yBZhxDiB,8BYoxDnB,QACE,iBACA,uBACA,4BZvxDiB,6BY2xDnB,SACE,gBACA,2BACA,2BZ9xDiB,wBYoyDnB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ1yDiB,WAHb,gBYgzDJ,uBACA,mBACA,yFAEA,kBZlyDiB,cAfA,UYszDf,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBZp0DiB,cYs0DjB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ71DiB,WAHb,gBYm2DJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBZz1DiB,cAfA,iBY+2DrB,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBZl9DwB,8BYo9DtB,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cZ9+DmB,qBYg/DnB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WZziEM,qBY2iEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cZhjEiB,sBYojEnB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WL/uEM,kBKivEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBZzyEiB,yBY2yEjB,gBACA,kBACA,eACA,gBACA,iBACA,WZl0EI,mDYu0ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBLx2EI,0BK02EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBZp6EwB,0BYy6E1B,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cZ9+EmB,eYg/EnB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cZngFmB,eYqgFnB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,gDACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBZrlFwB,qCYulFxB,sEAGF,wBACE,4CAGF,wBZjlFqB,+EYqlFrB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,sBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBZ7oFmB,cYipFrB,kBACE,WZpqFM,cYsqFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cZnrFiB,kGYsrFjB,sBAGE,WZ5rFE,kCYgsFJ,aZ9qFiB,oBYorFrB,oBACE,iBACA,qBAGF,oBACE,kBACA,eACA,iBACA,gBACA,mBZ3sFwB,gBY6sFxB,iBACA,oBAGF,kBZjtF0B,cAiBR,iBYmsFhB,eACA,gBACA,eACA,yDAGF,kBZ1tF0B,cYguF1B,aACE,kBAGF,aZntFkB,cYqtFhB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,aZjvFY,0BYmvFV,sDAIJ,oBACE,cZ3wFe,sMY8wFf,yBAGE,oDAKN,aZnwFgB,0BYywFhB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,cZnyFe,aYqyFf,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA1BF,YA2BI,yCAGF,eACE,aACA,iDAEA,aZ9zFe,qBYq0FrB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,WZj2FM,gBOCA,aKm2FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,aZj3FsB,6BYm3FpB,uDAGF,aZ33FqB,cY+3FrB,YACE,eACA,yBACA,kBACA,cZ/3FgB,gBYi4FhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cZ96Fe,uBYg7Ff,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UZ17FE,yBYi8FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cZj+FmB,gBYm+FnB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aZ/+FqB,oBYm/FrB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cZ7iGgB,6BY+iGhB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cZvkGgB,mBAjBQ,eY2lGxB,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cZrmGY,qCYymGd,cACE,gBACA,yBAKN,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,kFACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,aZrqGmB,uBYyqGnB,sCACE,4CAEA,aZ5qGiB,yCY8qGf,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cZttGmB,eYwtGnB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UZvuGI,mBYyuGF,6BAKN,eACE,gBACA,gBACA,cZ9uGmB,0DYgvGnB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aZ3wGmB,0BY6wGjB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,aZ1xGkB,eY4xGhB,gBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBZx6GM,WADA,eY46GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eZt7GQ,cAEa,SYu7GnB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,8BACA,kBACA,iBACA,WZv/GE,gBYy/GF,eACA,+LAMA,6BACE,mEAKF,6BACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aZliHqB,eYoiHnB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtiHF,sBACA,WACA,SACA,gBACA,oBACA,mBdbwB,cAFL,eckBnB,SACA,+EFgiHI,aACE,CEjiHN,qEFgiHI,aACE,CEjiHN,yEFgiHI,aACE,CEjiHN,0EFgiHI,aACE,CEjiHN,gEFgiHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,aZvjHc,iBYyjHZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aZnmHiB,0HYwmHjB,cAEE,gBACA,cZxlHY,kZY2lHZ,aAGE,gEAIJ,wBACE,iDAGF,eL3nHI,kBO0BN,CAEA,eACA,cdHiB,uCcKjB,UF8lHI,mBZ3nHe,oDc+BnB,adPiB,ecSf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WdlDI,sDYkoHJ,WACE,mDAGF,UZtoHI,kBYwoHF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UZzpHQ,kBY2pHN,cACA,mBACA,sBZ5pHM,eY8pHN,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aZ5qHqB,qBY8qHnB,mBACA,gBACA,sBACA,uCAGF,aZjqHkB,mBAjBQ,kBYsrHxB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,sCAdF,cAeI,kDAGF,eACE,2CAGF,aZ3rHmB,qBY6rHjB,uDAEA,yBACE,eAKN,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eL/xHQ,kBKiyHN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBL3zHM,kBK6zHN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,4BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,8BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBZz3HwB,kCY23HxB,uBAGF,MACE,aACA,mBACA,uBACA,cZp4HmB,eYs4HnB,gBACA,0BACA,kBACA,kBAGF,YACE,cZ74HmB,gBY+4HnB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBZz5HsB,kBY25HtB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBZh6HmB,kBYk6HnB,eAGF,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBL99HM,uCKg+HN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,UZ/+HQ,aYi/HN,eACA,aACA,kEAEA,kBZn+HmB,WAlBb,UYy/HJ,CZz/HI,4RY8/HF,UZ9/HE,wCYogIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cZ9gImB,2CYihInB,eACE,cACA,WZthII,CY2hIA,wQADF,eACE,mDAON,eLjiIM,0BKmiIJ,qCACA,gEAEA,eACE,0DAGF,kBZzhIiB,uEY4hIf,UZ9iIE,uDYojIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SErjIE,sBACA,WACA,SACA,gBACA,oBACA,mBdbwB,cAFL,eckBnB,SACA,cF+iIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cZpnIiB,eYsnIjB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cZtnIkB,eYwnIlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,aZroIkB,mBYuoIhB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cZ7pIc,iCYgqId,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cZjsImB,qBYmsInB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cZ9sImB,kBYgtInB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,8BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cL7tI0B,eAEC,CKuuI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,6BACE,sBACA,SACA,WZ1zIM,eY4zIN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cZr2IiB,mFYw2IjB,yBAGE,wBAKN,oBACE,sBAGF,qBZv3IQ,YYy3IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBZh3IqB,qBYo3IrB,iBACE,UACA,QACA,YACA,6CAGF,kBZ14IqB,WAHb,kBYk5IN,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,aZj8ImB,SYo8IjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,CZr9IE,wyEY49IF,UAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,WZ/+II,kBYi/IJ,eACA,qBAGF,kBZh/IwB,cAFL,gBYq/IjB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,WZ3gJM,kBY6gJN,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,aACE,wBAKF,eLviJM,CPEa,gBYwiJjB,oBACA,iEL3iJI,2BPEa,yBYijJrB,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBZhkJmB,aYkkJnB,iBACA,2HAEA,aACE,iBACA,cZvkJiB,mBYykJjB,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,aZtnJmB,iLY0nJnB,UZ5oJM,qCYipJN,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UZ3qJI,gBOCA,aK6qJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eL7rJI,yBK+rJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UZ9sJE,oBYgtJA,eACA,gBLhtJA,+CKqtJJ,YACE,8BACA,mBACA,4CAIJ,aACE,WZ9tJI,eYguJJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UZzuJI,eY2uJF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UZrxJE,aYuxJA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBZpxJW,WAlBb,uDY6yJA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cZ9zJmB,eYg0JnB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UZj3JI,CYm3JF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBZh3JqB,WYk3JnB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WZ74JM,8BY+4JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cZt6Jc,iBYw6Jd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cZp8JY,gBYs8JZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,aZv9Jc,gBY+9JhB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cZ9gKmB,kBYghKnB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBZ3hKM,CY4hKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBZviKM,iCY0iKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBLxoKM,eK0oKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBZptKI,cAEa,gBYqtKjB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,ULpxKE,+EK4xKN,cAGE,gBACA,6BAGF,ULnyKM,iBKqyKJ,yBAGF,oBACE,aACA,mDAGF,UL7yKM,uBKkzKN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WLl2KE,sFKq2KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WZ7/KF,gBY+/KE,gBACA,uBACA,0CAGF,aACE,eACA,cZngLW,gBYqgLX,gBACA,uBACA,yBAKN,kBZ1gLsB,aY4gLpB,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cZ3lLiB,eY6lLjB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,aZpmLiB,qWYumLf,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBZ9oL0B,sBYipLxB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eLvsLQ,kBO0BN,CACA,sBACA,gBACA,cdHiB,uCcKjB,mBAEA,adPiB,ecSf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WdlDI,UY4sLR,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cZptLiB,gBYstLjB,gBAEA,aZzsLiB,0BY2sLf,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBZl1LoB,WALlB,eY01LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cZ/2Lc,CYk3Ld,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,8BACA,cAGF,kBZh8L0B,sBYk8LxB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBZt/L0B,sBYw/LxB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBL3iMM,yDK8iMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBLtjMI,uBK0jMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UZxlMI,eY0lMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aZ/mMqB,eYinMnB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WZtuMA,gBYwuMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cZ5uMW,gBY8uMX,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WZnwME,gDYuwMJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aZ1wMU,yBYgxMd,cACE,gCAEA,cACE,cZ1xMe,eY4xMf,kCAEA,oBACE,cZ/xMa,qBYiyMb,iBACA,gBACA,yCAEA,eACE,WZzyMF,iBYkzMN,aZ5xMgB,mBY8xMd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cZtzMY,gBYwzMZ,uBACA,mBACA,4BAEA,eACE,uBAGF,aZn1Me,qBYq1Mb,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cZ92Me,0BYk3MjB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBZt4MiB,kBYw4MjB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cZ36Me,kBY66Mf,+BAGF,aZh7MiB,eYk7Mf,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UZ77ME,qBY+7MA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UZz9MI,OeDR,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBACE,aAIJ,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAGF,aACE,sBACA,WACA,eACA,Wf3CE,Ue6CF,oBACA,gBR7CE,sBQ+CF,kBACA,iBACA,oCAEA,oBflCe,wBeuCjB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBf/EY,8EeoFZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,cf5Gc,aegHhB,cACE,uBACA,UACA,SACA,SACA,cfrHc,0BeuHd,kBACA,mBAEA,oBACE,sCAGF,kCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,yBACE,gCAEA,YACE,2CAGF,yBACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBfjKe,sDeuKnB,cACE,gBACA,iBACA,YACA,oBACA,cfvKkB,sCe0KlB,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WflNI,qBeoNJ,WACA,UACA,oBACA,qXACA,sBACA,kBACA,CACA,yBACA,mDAGF,UACE,cAIJ,af9MkB,qBeiNhB,+BACE,6BAEA,8BACE,eC5ON,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,WjBDM,2BiBIN,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBjBlBiB,4BiBsBnB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,cjBjCmB,ciBmCnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,ajBrCqB,mCiBwCnB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBjBtDmB,uBiB2DnB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBV5FM,sBU8FN,sGAEA,mCAEE,oBAKF,2BACA,gBVxGM,0BU2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,6BACA,WjBnHI,yBiBqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,mCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBVpKI,mBUyKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,cjB7JiB,mDiBgKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,clBlBmB,oBkBqBnB,alBNmB,0BkBQjB,6EAEA,oBAGE,wCAIJ,alBhCmB,oBkBqCnB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,clB/CiB,qBkBmDnB,iBACE,clBpDiB,uBkBwDnB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,clBxEiB,qBkB4EnB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,clBnJe,iCkBuJjB,uBACE,gBACA,gBACA,clBvIY,qDkB2Id,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WlBrNI,iBkBuNJ,kBACA,qEAEA,aAEE,6CAIA,alB7Ne,oCkBkOjB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,clB7Pe,mBkB+Pf,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WlB1SA,qBkB4SA,uDAGE,yBACE,2CAKN,aACE,clBnTa,kCkB2TnB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,clBlUiB,sCkBqUjB,alBtTiB,0BkBwTf,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,clB5ViB,wBkB+VjB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,clB7WiB,kBkBkXjB,clBlXiB,mCkBiXnB,4CACE,CACA,gBACA,gBACA,mBACA,clBtXiB,kBkB2XjB,clB3XiB,kBkBoYjB,clBpYiB,mCkBmYnB,4CACE,CACA,gBACA,gBACA,mBACA,clBxYiB,kBkB6YjB,clB7YiB,mCkBqZnB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,6CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBlB/bwB,kBkBictB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBlBxjBsB,kBkB0jBtB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,alB1lBiB,qCkB8lBjB,eACE,WlBlmBE,gBkBomBF,ClBjmBe,yFkBsmBb,alBtmBa,+CkB4mBjB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SlBxrBI,YkB0rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,gBACA,eACA,clB9sBe,6BkBktBjB,eACE,iBACA,+BAGF,kBlBrtBsB,akButBpB,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,clB7vBa,uFkBmwBnB,eACE,cASA,ClB7wBiB,2CkB0wBjB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,clB71BiB,qBkB+1BjB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,clB/1Bc,SmBvBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBnBrBwB,UmB0BxB,anBbmB,0BmBejB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBnB9DsB,6BmBgEpB,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,cnB1GmB,gBmB4GnB,0DAEA,UnBjHM,wDmBqHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBnB5JsB,sBmB8JtB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBnB3KsB,gCmB8KtB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBnBnMsB,uCmBsMpB,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,cnB7Oa,gBmB+Ob,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBpBZoB,YoBcpB,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SpBzCA,YoB2CE,kBACA,YACA,uCAIJ,aACE,cpB/Ca,qBoBiDb,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cpBzFa,qBoB2Fb,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UpB1GA,yBoB4GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UpBlIE,yBAkBa,gBoBmHb,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,apBlNiB,eoBoNf,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,apB7NiB,eoB+Nf,iBACA,gBACA,mBACA,4BAGF,cACE,gBACA,cpBvOe,mBoByOf,kBACA,gCACA,4BAGF,cACE,cpB/Oe,iBoBiPf,gBACA,0CAGF,UpBxPI,gBoB0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WpBxQE,oBoB0QF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cpB/Qe,mBoBiRf,kCAEA,UpBtRE,gBoBwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,6CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA7SF,aA8SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BpBjUe,YoBwUrB,UACE,SACA,cACA,WACA,sDAKA,apBhWmB,0DoBmWjB,apBpViB,4DoByVnB,apBlWc,gBoBoWZ,4DAGF,ab7WU,gBa+WR,0DAGF,apB/VgB,gBoBiWd,0DAGF,abrXU,gBauXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,eACA,CAII,iNADF,eACE,2BAKN,oBACE,cpB/Ze,qBoBiaf,eACA,gBACA,gCACA,iCAEA,UpBzaE,gCoB2aA,oCAGF,apB5Ze,gCoB8Zb,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cpBpeiB,CoByeb,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,apBpiBmB,qBoBsiBjB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBpBvkBsB,cAFL,0BoB4kBjB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,apBllBgB,oBoBslBhB,kBACE,0BACA,aACA,cpB5mBiB,gDoB8mBjB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,cpBnmBc,2BoBumBhB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBpB5nBY,oCoBgoBZ,kBACE,mCAGF,kBpB3nBiB,sDoBgoBnB,apB/oBmB,qBoBmpBjB,gBACA,sBAGF,aACE,0BAGF,apB3pBmB,sBoB+pBnB,apBzpBc,yDoB8pBhB,oBAIE,cpBxqBmB,iGoB2qBnB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBpB9sBc,yBoBktBd,yBACE,wBAGF,yBb1tBU,wBa+tBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,apBzuBiB,uBoB+uBjB,wBACA,qBAGF,apBhuBgB,coBquBlB,kBpBtvB0B,kBoBwvBxB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cpB9wBe,iBoBgxBf,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,ab7xBM,6BaoyBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cpBlzBa,mLoBqzBb,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,apB9yBU,iBoBgzBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cpB70Ba,WoBo1BrB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,apB53BY,8CoBi4Bd,qBACE,aACA,WpB54BI,coBi5BR,iBACE,sBCn5BF,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WrBtCI,6CqBwCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,crB/CiB,kBqBiDjB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,arBpEmB,gBqBsEjB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,qEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,MCDF,6CACE,CjBFM,qCiBSN,UjBTM,sDiBcR,wBAEE,sMAEA,UjBlBM,gGiB0BR,ejB1BQ,yBiBgCN,aACA,uBAGF,kBACE,oCAGF,ejBxCQ,gCiB2CN,8BAGF,sBACE,iBACA,kBACA,qCAGF,wBAEE,oCAGF,ejBzDQ,yBiB4DN,qCAEA,mCALF,YAMI,+DAGF,SACE,QACA,gIAIJ,ejBxEQ,+BiBgFR,axB/DqB,8GwBkEnB,axBlEmB,gBOjBb,gDiB2FR,iBjB3FQ,4BiB+FR,axB7FqB,0BwB+FnB,iIAGF,aAIE,6cAEA,UxB3GM,oBwBkHR,kBACE,gCACA,wDAKA,ejBxHM,gCiB0HJ,4MAEA,kBxBxHsB,kCwBgI1B,4BACE,gCACA,qCAEA,iCAJF,YAKI,qUAIJ,wBAYE,qCAIA,eADF,YAEI,gBACA,sCAIJ,YACE,gBACA,oCAGF,oXACE,uEAGF,wBAEE,2BAGF,wBACE,aACA,8CAGF,kBxBlL0B,yBwBoLxB,aACA,gCAGF,ejB5LQ,yBiB+LN,0BAGF,o1BACE,oFAME,aACE,6QAEA,UjB5ME,gFiBmNJ,aACE,2GAEA,aACE,CAHF,iGAEA,aACE,CAHF,qGAEA,aACE,CAHF,sGAEA,aACE,CAHF,4FAEA,aACE,CAMJ,8FAGF,kBACE,yPAIA,kBAIE,iBAKN,oBACE,6BAEA,kBACE,0BAIJ,+BACE,qBxBnPwB,kBwBwP1B,kBxBxP0B,uBwB4P1B,kBACE,wCAGF,kBACE,+CAGF,ejBxQQ,0GiB8QR,kBxB1Q0B,sHwB8QxB,kBACE,uCAKJ,kBxBpR0B,uEwByR1B,UjB7RQ,0BiBiSR,wBxB7R0B,gBwBkS1B,ejBtSQ,4BiB0SJ,sBjB1SI,2BiB8SJ,qBjB9SI,8BiBkTJ,wBjBlTI,6BiBsTJ,uBjBtTI,wBiB4TJ,ejB5TI,cPEa,85BwBkUrB,UjBpUQ,2BiB4VR,2BACE,uNAIF,ejBjWQ,yBiB6WN,wBAGF,0BACE,0BAGF,wBACE,mCAGF,kBACE,yBACA,aACA,8BAGF,UjB9XQ,6JiBkYR,kBAME,+2DAIE,qBAGE,qBAKN,ejBpZQ,yDiBwZR,ejBxZQ,yBiB0ZN,+DAEA,oBACE,gBjB7ZI,qBiBkaR,kBxBhaqB,sEwBoarB,kBACE,4FAGF,kBACE,uCAIF,UxBhbQ,gBOCA,WiBqbR,ejBrbQ,yBiBubN,gBACA,qCAEA,UALF,YAMI,kBAGF,mBACE,wBACA,4BACA,oEAEA,kBxB/bsB,yFwBscpB,sBAGE,4BxB5ba,uBwBocrB,exBrdQ,4BwBudN,kMAGF,ejB1dQ,yBiBoeN,qCAEA,iMAZF,aAaI,eACA,aACA,8BAIJ,YACE,gBACA,oLASE,oBACE,+BAKN,ejB9fQ,yBiBggBN,aACA,qCAEA,8BALF,QAMI,kBAIJ,axBtgBqB,0EwB2gBnB,kBxBzgBwB,qCwB+gBxB,kBAPF,QAQI,sDAIJ,oBxBvgBqB,mVwB2gBnB,UjB5hBM,mMiBoiBN,kBxBnhBmB,oEwB2hBnB,oBAGE,kBAIJ,wBACE,8BAEA,YACE,yBAGF,exB1jBM,0HwB6jBJ,2BAGE,CxBjkBE,oGwB2kBF,UxB3kBE,0DwBqlBF,axBllBe,2CwBwlBf,UxB3lBE,6CwBgmBJ,axB7lBiB,6DwBimBjB,UxBpmBI,4CwB4mBN,eACE,8BACA,iBACA,oDxB7lBiB,awBmmBjB,yFAHF,oBxBhmBmB,qCwBymBnB,CxBzmBmB,2HwBmnBnB,axBnnBmB,qBwBwnBrB,UjBzoBQ,yBiB4oBN,SjB5oBM,2CiBkpBN,wBACE,qCAEA,0CAHF,YAII,kGAIJ,eAGE,sEAGF,exBhqBM,yBwBmqBJ,wBAGF,kBxBlqBwB,yBwBoqBtB,qCAEA,uBAJF,QAKI,+GAIA,kBAGE,8CAMJ,kBACE,oDAEA,eACE,mDAKF,exBjsBE,yBwBmsBA,aACA,wDAGF,iBxBvsBE,qCwB2sBF,2CAXF,exBhsBI,yBwB6sBA,aACA,kHAMA,UjBptBA,qCiBwtBE,gHAJF,UxBrtBA,mEwBiuBF,QACE,2FAGF,oBACE,yFAMR,yCAEE,0PAGF,eAaE,sKAGF,UxBjwBQ,0D","file":"skins/vanilla/mastodon-light/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#ccd7e0 rgba(255,255,255,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#ccd7e0;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#c6d2dc}::-webkit-scrollbar-thumb:active{background:#ccd7e0}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(255,255,255,.1)}::-webkit-scrollbar-track:hover{background:#d9e1e8}::-webkit-scrollbar-track:active{background:#d9e1e8}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#eff3f5;font-size:13px;line-height:18px;font-weight:400;color:#000;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#d9e1e8}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#ccd7e0;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#e6ebf0;padding:0}body.error{position:absolute;text-align:center;color:#282c37;background:#d9e1e8;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#dc2f4b;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#2b90d9}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#444b5d;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#444b5d}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#444b5d;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#4a905f;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#000;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#000;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#282c37;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#000}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#b3c3d1}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#282c37;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#000}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#a6b9c9;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#99afc2}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#282c37}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#fff}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#ccd7e0;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #ccd7e0;background:#f2f5f7}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#ccd7e0;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#000;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#282c37}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#282c37;padding:10px;border-right:1px solid #ccd7e0;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9bcbed;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#282c37}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#000;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #b3c3d1}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#282c37}.public-layout .public-account-header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#000}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#217aba}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#4a905f}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#000}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#282c37}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #c0cdd9}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #c0cdd9}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#d9e1e8}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#ccd7e0}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#6d8ca7}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#6d8ca7}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{font-weight:700;margin-bottom:8px;color:#282c37}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#6d8ca7}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#6d8ca7}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#60829f}.compact-header h1{font-size:24px;line-height:28px;color:#282c37;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#282c37}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#d9e1e8;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.hero-widget__text a{color:#282c37;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;font-weight:700;font-size:14px;color:#282c37}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#282c37}.box-widget{padding:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #444b5d;text-align:center;color:#282c37;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;font-weight:700;font-size:14px;color:#282c37}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#282c37;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#282c37;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#282c37}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#282c37;margin-bottom:10px}.page-header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#000;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#282c37}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#ccd7e0}.page-header h1{font-size:24px}}.directory{background:#d9e1e8;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#d9e1e8;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#c0cdd9}.directory__tag.active>a{background:#2b90d9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#000;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#282c37}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#282c37}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#000}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b90d9}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#f2f5f7;border:2px solid #d9e1e8}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;color:#282c37;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #c0cdd9}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#000}.accounts-table__count small{display:block;color:#282c37;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #a6b9c9}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#282c37}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b90d9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#e6ebf0;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#000;border-bottom:1px solid #ccd7e0}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #ccd7e0}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#000;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#282c37}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#fff}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#282c37}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#000;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#000;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#000;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#c1203b}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#000;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#000;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #444b5d;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb;border:1px solid #fff;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#1f232b}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#c1203b}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#4a905f}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#fff}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#f2f5f7}.simple_form .input.field_with_errors label{color:#c1203b}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#c1203b}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#c1203b;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b90d9;color:#000;font-size:18px;line-height:inherit;height:auto;padding:10px;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#2482c7}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#419bdd}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9bcbed}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#db2a47}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#e3566d}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#444b5d;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(249, 250, 251, 0), #f9fafb)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(217,225,232,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#000}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#c0cdd9;color:#282c37;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(74,144,95,.5);background:rgba(74,144,95,.25);color:#4a905f}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#282c37;text-decoration:none}.flash-message a:hover{color:#000;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#ccd7e0}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#282c37;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#217aba}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#282c37}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#282c37;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#282c37;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#000;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#000;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#282c37}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#000;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#f9fafb;border:1px solid #fff;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#4a905f;transition:none}.input-copy.copied button{background:#4a905f;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#e6ebf0;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#e6ebf0;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#c0cdd9}.card__img{height:130px;position:relative;background:#fff;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#f2f5f7;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#000;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{color:#282c37}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#000}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#444b5d;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#282c37;background-color:rgba(40,44,55,.1);border:1px solid rgba(40,44,55,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#4a905f;background-color:rgba(74,144,95,.1);border-color:rgba(74,144,95,.5)}.account-role.admin,.simple_form .recommended.admin{color:#c1203b;background-color:rgba(193,32,59,.1);border-color:rgba(193,32,59,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #b3c3d1;border-bottom:1px solid #b3c3d1;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #b3c3d1}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#282c37;background:rgba(242,245,247,.5)}.account__header__fields dd{flex:1 1 auto;color:#282c37}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(74,144,95,.5);background:rgba(74,144,95,.25)}.account__header__fields .verified a{color:#4a905f;font-weight:500}.account__header__fields .verified__mark{color:#4a905f}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#282c37}.pending-account__header a{color:#282c37;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#000;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#d9e1e8}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#c0cdd9}.button.logo-button{flex:0 auto;font-size:14px;background:#2b90d9;color:#000;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#000}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#2074b1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9bcbed}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#2b90d9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9bcbed;cursor:default}.button{background-color:#2b90d9;border:10px none;border-radius:4px;box-sizing:border-box;color:#000;cursor:pointer;display:inline-block;font-family:inherit;font-size:15px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#2074b1;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9bcbed;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9bcbed}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#8ac2ea}.button.button-alternative-2{background:#b0c0cf}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#a3b6c7}.button.button-secondary{color:#282c37;background:transparent;padding:3px 15px;border:1px solid #9bcbed}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#8ac2ea;color:#1f232b}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#606984;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#51596f;background-color:rgba(96,105,132,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(96,105,132,.3)}.icon-button.disabled{color:#828ba4;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#282c37}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#373d4c;background-color:rgba(40,44,55,.15)}.icon-button.inverted:focus{background-color:rgba(40,44,55,.3)}.icon-button.inverted.disabled{color:#191b22;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#1d6ca4}.icon-button.overlayed{box-sizing:content-box;background:rgba(255,255,255,.6);color:rgba(0,0,0,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(255,255,255,.9)}.text-icon-button{color:#282c37;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#373d4c;background-color:rgba(40,44,55,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(40,44,55,.3)}.text-icon-button.disabled{color:#000;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9bcbed;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.compose-form .compose-form__warning{color:#000;margin-bottom:10px;background:#9bcbed;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#000;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#282c37;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:5px;right:5px}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#444b5d}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#282c37;border-radius:0 0 4px 4px;color:#000;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#3d4455}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#282c37}.compose-form .compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#282c37;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#191b22}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#282c37;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#282c37}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#fff;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#282c37}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9bcbed;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#000;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#000}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#353a48}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#444b5d}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#217aba}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#606984}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#51596f;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#217aba;border:0;background:transparent;padding:0;padding-top:8px}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#000;font-weight:700;font-size:12px;padding:0 6px;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#444b5d;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #c0cdd9}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#ccd7e0}.focusable:focus .status.status-direct{background:#b3c3d1}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#c0cdd9}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #c0cdd9;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#c0cdd9;border-bottom-color:#b3c3d1}.status.light .status__relative-time{color:#444b5d}.status.light .status__display-name{color:#000}.status.light .display-name strong{color:#000}.status.light .display-name span{color:#444b5d}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#000;background:#9bcbed}.status.light .status__content a.status__content__spoiler-link:hover{background:#78b9e7}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#444a5e}.status__relative-time,.notification__relative_time{color:#444b5d;float:right;font-size:14px}.status__display-name{color:#444b5d}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #282c37;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#444b5d;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#444b5d}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#606984}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#ccd7e0;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#444b5d;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#000;font-size:14px}.reply-indicator__content a{color:#282c37}.domain{padding:10px;border-bottom:1px solid #c0cdd9}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#000;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #c0cdd9}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#282c37;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative;cursor:default}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#000;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #c0cdd9;color:#444b5d}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #c0cdd9;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #2b90d9}.account__action-bar__tab>span{display:block;font-size:12px;color:#282c37}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#000}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#000}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#282c37;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#000}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#444b5d}.muted .status__display-name strong{color:#444b5d}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#b0c0cf;color:#000}.muted a.status__content__spoiler-link:hover{background:#9aaec2;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#282c37;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#000;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#282c37}.navigation-bar strong{color:#282c37}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #393f4f;margin:5px 7px 6px;height:0}.dropdown-menu{background:#282c37;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#282c37}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#282c37}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#282c37}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#282c37}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b90d9;color:#282c37;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#282c37;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b90d9;color:#282c37}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#f2f5f7;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#d9e1e8;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#282c37;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#c0cdd9;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#000;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #c0cdd9;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#adbecd;border-bottom-color:#adbecd}}.tabs-bar__link.active{border-bottom:2px solid #2b90d9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#3897db;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#227dbe}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b90d9;border:2px solid #c0cdd9;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#000}.column-link--transparent .icon-with-badge__badge{border-color:#f2f5f7}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #ccd7e0;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#b0c0cf;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#d9e1e8}.drawer__inner__mastodon{background:#b0c0cf url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#b0c0cf;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#c0cdd9;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#cfd9e2;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#ccd7e0;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#ccd7e0;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(255,255,255,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#d9e1e8;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#f9fafb}.react-toggle--checked .react-toggle-track{background-color:#2b90d9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#2074b1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #d9e1e8;border-radius:50%;background-color:#fff;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b90d9}.column-link{background:#c0cdd9;color:#000;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#b6c5d3}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#282c37}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#000}.column-link--transparent.active{color:#2b90d9}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#d9e1e8;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#d9e1e8;color:#444b5d;padding:8px 20px;font-size:13px;font-weight:500;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#d9e1e8}.flex-spacer{flex:1 1 auto}.getting-started{color:#444b5d;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#444b5d;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#282c37}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#444b5d}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:13px;color:#282c37;padding:10px;font-weight:500;border-bottom:1px solid #c0cdd9}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#282c37}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#c0cdd9;border:1px solid #e6ebf0}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#606984;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.status-card{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;color:#444b5d;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#282c37;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#000}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#c0cdd9}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#282c37;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#282c37}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#c0cdd9;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#ccd7e0}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#ccd7e0}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#444b5d;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#d3dce4}.load-gap{border-bottom:1px solid #c0cdd9}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#444b5d;background:#d9e1e8;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#444b5d}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 144, 217, 0.23) 0%, rgba(43, 144, 217, 0) 60%)}.column-header{display:flex;font-size:16px;background:#ccd7e0;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,144,217,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#ccd7e0;border:0;color:#282c37;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#191b22}.column-header__button.active{color:#000;background:#c0cdd9}.column-header__button.active:hover{color:#000;background:#c0cdd9}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#282c37;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #b3c3d1;margin:10px 0}.column-header__collapsible-inner{background:#c0cdd9;padding:15px}.column-header__setting-btn:hover{color:#282c37;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#444b5d;font-size:13px;font-weight:400;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #86a0b6;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#86a0b6}29%{background-color:#86a0b6}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#fff;color:#000;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#fff;color:#282c37;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#17191f}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(255,255,255,.5);border-radius:8px;padding:8px 12px;color:#000;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(255,255,255,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(255,255,255,.5)}.modal-container--preloader{background:#c0cdd9}.account--panel{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#c0cdd9;padding:15px}.column-settings__section{color:#282c37;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#1f232b}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#ccd7e0}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#444b5d;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#c0cdd9}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#b3c3d1;color:#1f232b}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#282c37}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#444b5d}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#3b4151}.column-settings__hashtags .column-select__indicator-separator{background-color:#c0cdd9}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#282c37}.column-settings__hashtags .column-select__menu h4{color:#444b5d;font-size:14px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#3d4455}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#000;margin-bottom:4px;display:block;vertical-align:top;background-color:#fff;font-size:12px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#282c37;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column{color:#444b5d;background:#d9e1e8;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(40,44,55,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(255,255,255,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#d9e1e8;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#282c37;font-size:18px;font-weight:500;border:2px dashed #b0c0cf;border-radius:4px}.upload-progress{padding:10px;color:#282c37;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:13px;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#b0c0cf;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#2b90d9;border-radius:6px}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#000;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#2b90d9;color:#000;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#000}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#000}.privacy-dropdown__option.active:hover{background:#2485cb}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#282c37}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#000}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#2b90d9}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#000}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#1f232b}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#ccd7e0}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#282c37;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#606984;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#51596f}.search-results__header{color:#444b5d;background:#d3dce4;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#444b5d}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#282c37;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#1f232b;text-decoration:underline}.search-results__info{padding:20px;color:#282c37;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(255,255,255,.5);box-sizing:border-box;border:0;color:#000;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#282c37}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#000;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b90d9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#282c37;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#393f4f;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#282c37;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#313543;background-color:#4a5266}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#000}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;font-size:13px}.display-case__case{background:#d9e1e8;color:#282c37;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#17191f;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#17191f}.actions-modal .status{background:#fff;border-bottom-color:#282c37;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#282c37}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#282c37;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#282c37;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #282c37}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#000}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #282c37;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #282c37;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #393f4f}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #282c37;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b90d9;color:#000}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#282c37;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#313543;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#2b90d9;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#000;background:rgba(255,255,255,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#444b5d;padding:8px 18px;cursor:default;border-right:1px solid #c0cdd9;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#444b5d;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#444b5d}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#282c37;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#fff}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#f2f5f7;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #ccd7e0;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(33,122,186,.5)}.audio-player__wave-placeholder{background-color:#a6b9c9}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#f2f5f7;border-top:1px solid #ccd7e0;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#fff;color:#282c37;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#191b22}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#217aba}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#217aba}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#fff;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#ccd7e0;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#f2f5f7;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#d9e1e8;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #c0cdd9;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#e6ebf0;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#282c37;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#282c37}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #c0cdd9}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #d9e1e8}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#dfe6ec;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #eff3f5}.filter-form{background:#d9e1e8}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9bcbed;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#217aba;background:#217aba}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{color:#444b5d;font-size:14px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#282c37;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#ccd7e0}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9}.account__moved-note__message{position:relative;margin-left:58px;color:#444b5d;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,.5)}.list-editor{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#b0c0cf;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#b0c0cf}.list-adder__lists{background:#b0c0cf;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #c0cdd9}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#000;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#282c37;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#e6ebf0}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#ccd7e0;padding:5px;border-bottom:1px solid #b3c3d1}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#f2f5f7;border:2px solid #ccd7e0}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #b3c3d1;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#000}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #b3c3d1}.account__header__bio .account__header__fields a{color:#217aba}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#4a905f}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#282c37;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#000}.trends__header{color:#444b5d;background:#d3dce4;border-bottom:1px solid #e6ebf0;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #c0cdd9}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#444b5d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#282c37;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#282c37}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#2380c3 !important}.conversation{display:flex;border-bottom:1px solid #c0cdd9;padding:5px;padding-bottom:0}.conversation:focus{background:#d3dce4;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#282c37;padding-left:15px}.conversation__content__names{color:#282c37;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#000;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#d3dce4}.conversation--unread:focus{background:#ccd7e0}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#000}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#d8eaf8}.poll__chart.leading{background:#2b90d9}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;outline:0;font-family:inherit;background:#fff;border:1px solid #fff;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#2b90d9}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9bcbed;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#4a905f;background:#4a905f}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#444b5d}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#444b5d;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(68,75,93,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #fff}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #fff;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#606984;border-color:#606984;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#fff}.muted .poll{color:#444b5d}.muted .poll__chart{background:rgba(216,234,248,.2)}.muted .poll__chart.leading{background:rgba(43,144,217,.2)}.modal-layout{background:#d9e1e8 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #393f4f}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#282c37}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#282c37;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#313543}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#3c99dc}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#2b90d9}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(255,255,255,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(40,44,55,.3);color:#000;border:1px solid #282c37;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(40,44,55,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#444b5d}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#282c37}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#282c37}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#282c37}.rich-formatting em{font-style:italic;color:#282c37}.rich-formatting code{font-size:.85em;background:#f2f5f7;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#282c37}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #ccd7e0;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #ccd7e0;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#282c37}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#444b5d}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#e6ebf0;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#000;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#282c37}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#f2f5f7;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#282c37;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #ccd7e0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#3d4455}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#000;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#282c37}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#282c37}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#131419}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(176,192,207,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#d9e1e8;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#131419}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#d9e1e8;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#282c37}.landing-page__short-description h1{font-weight:500;color:#000;margin-bottom:0}.landing-page__short-description h1 small{color:#282c37}.landing-page__short-description h1 small span{color:#282c37}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#000;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;font-weight:700;font-size:14px;color:#282c37}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#d9e1e8;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#282c37}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#282c37}.landing .simple_form p.lead{color:#282c37;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #c0cdd9}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9bcbed;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#444b5d}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #d9e1e8;text-align:left;background:#e6ebf0}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #d9e1e8;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#d9e1e8}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#d9e1e8;border-top:1px solid #f2f5f7;border-bottom:1px solid #f2f5f7}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #f2f5f7}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #f2f5f7}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#282c37;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#000}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #f2f5f7;background:#d9e1e8;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #f2f5f7;border-top:0;background:#d9e1e8}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #f2f5f7;border-top:0;background:#e6ebf0}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #f2f5f7}}.batch-table__row:hover{background:#dfe6ec}.batch-table__row:nth-child(even){background:#d9e1e8}.batch-table__row:nth-child(even):hover{background:#d3dce4}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#282c37;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #f2f5f7;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #f2f5f7}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#d9e1e8;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#c0cdd9;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#000;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#282c37;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#b3c3d1}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#282c37;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#000;background-color:#e9eef2;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#dfe6ec;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#e6ebf0;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#000;background-color:#2b90d9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#2482c7}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #c0cdd9;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#282c37;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#282c37;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{font-size:14px;font-weight:700;color:#282c37;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #c0cdd9}.admin-wrapper .content h6{font-size:16px;color:#282c37;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#000;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#000;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#282c37;margin-bottom:20px}.admin-wrapper .content>p strong{color:#000;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(176,192,207,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #ccd7e0;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b90d9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#282c37}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#4a905f;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#444b5d;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;font-size:13px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#282c37;text-decoration:none;font-size:13px;font-weight:500;border-bottom:2px solid #d9e1e8}.filters .filter-subset a:hover{color:#000;border-bottom:2px solid #c9d4de}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b90d9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#282c37}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#d9e1e8;color:#282c37;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#444b5d}.log-entry__extras{background:#c6d2dc;border-radius:0 0 4px 4px;padding:10px;color:#282c37;font-family:\"mastodon-font-monospace\",monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#444b5d}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#4a905f}.log-entry__icon__overlay.negative{background:#c1203b}.log-entry__icon__overlay.neutral{background:#2b90d9}.log-entry a,.log-entry .username,.log-entry .target{color:#282c37;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#c1203b}.log-entry .diff-neutral{color:#282c37}.log-entry .diff-new{color:#4a905f}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#282c37}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#c1203b}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b90d9}.speech-bubble.positive{border-left-color:#4a905f}.speech-bubble.negative{border-left-color:#c1203b}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#282c37}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#444b5d}.report-card{background:#d9e1e8;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#282c37;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#17191f}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #e6ebf0}.report-card__summary__item:hover{background:#d3dce4}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#282c37}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#444b5d;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#282c37}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#c0cdd9;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#217aba}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#4a905f}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#000}.center-text{text-align:center}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#ccd7e0;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#c0cdd9}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#000;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#282c37;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#282c37;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(249, 250, 251, 0), #f9fafb)}body.rtl .simple_form select{background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}html{scrollbar-color:#d9e1e8 rgba(217,225,232,.25)}.button{color:#fff}.button.button-alternative-2{color:#fff}.status-card__actions button,.status-card__actions a{color:rgba(255,255,255,.8)}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.column>.scrollable,.getting-started,.column-inline-form,.error-column,.regeneration-indicator{background:#fff;border:1px solid #c0cdd9;border-top:0}.directory__card__img{background:#b3c3d1}.filter-form,.directory__card__bar{background:#fff;border-bottom:1px solid #c0cdd9}.scrollable .directory__list{width:calc(100% + 2px);margin-left:-1px;margin-right:-1px}.directory__card,.table-of-contents{border:1px solid #c0cdd9}.column-back-button,.column-header{background:#fff;border:1px solid #c0cdd9}@media screen and (max-width: 415px){.column-back-button,.column-header{border-top:0}}.column-back-button--slim-button,.column-header--slim-button{top:-50px;right:0}.column-header__back-button,.column-header__button,.column-header__button.active,.account__header__bar,.directory__card__extra{background:#fff}.column-header__button.active{color:#2b90d9}.column-header__button.active:hover,.column-header__button.active:active,.column-header__button.active:focus{color:#2b90d9;background:#fff}.account__header__bar .avatar .account__avatar{border-color:#fff}.getting-started__footer a{color:#282c37;text-decoration:underline}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{color:#86a0b6}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#000}.column-subheading{background:#e6ebf0;border-bottom:1px solid #c0cdd9}.getting-started .column-link,.scrollable .column-link{background:#fff;border-bottom:1px solid #c0cdd9}.getting-started .column-link:hover,.getting-started .column-link:active,.getting-started .column-link:focus,.scrollable .column-link:hover,.scrollable .column-link:active,.scrollable .column-link:focus{background:#d9e1e8}.getting-started .navigation-bar{border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9}@media screen and (max-width: 415px){.getting-started .navigation-bar{border-top:0}}.compose-form__autosuggest-wrapper,.poll__text input[type=text],.compose-form .spoiler-input__input,.compose-form__poll-wrapper select,.search__input,.setting-text,.box-widget input[type=text],.box-widget input[type=email],.box-widget input[type=password],.box-widget textarea,.statuses-grid .detailed-status,.audio-player{border:1px solid #c0cdd9}@media screen and (max-width: 415px){.search__input{border-top:0;border-bottom:0}}.list-editor .search .search__input{border-top:0;border-bottom:0}.compose-form__poll-wrapper select{background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px}.compose-form__poll-wrapper,.compose-form__poll-wrapper .poll__footer{border-top-color:#c0cdd9}.notification__filter-bar{border:1px solid #c0cdd9;border-top:0}.compose-form .compose-form__buttons-wrapper{background:#d9e1e8;border:1px solid #c0cdd9;border-top:0}.drawer__header,.drawer__inner{background:#fff;border:1px solid #c0cdd9}.drawer__inner__mastodon{background:#fff url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{color:#ededed}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description input{color:#ededed}.compose-form .compose-form__modifiers .compose-form__upload-description input::placeholder{color:#ededed}.compose-form .compose-form__buttons-wrapper{background:#ecf0f4}.compose-form .autosuggest-textarea__suggestions{background:#ecf0f4}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#ccd7e0}.emoji-mart-bar{border-color:#ccd7e0}.emoji-mart-bar:first-child{background:#ecf0f4}.emoji-mart-search input{background:rgba(217,225,232,.3);border-color:#d9e1e8}.focusable:focus{background:#d9e1e8}.status.status-direct{background:#ccd7e0}.focusable:focus .status.status-direct{background:#c0cdd9}.detailed-status,.detailed-status__action-bar{background:#fff}.reply-indicator__content .status__content__spoiler-link,.status__content .status__content__spoiler-link{background:#d9e1e8}.reply-indicator__content .status__content__spoiler-link:hover,.status__content .status__content__spoiler-link:hover{background:#ccd7e0}.media-spoiler,.video-player__spoiler{background:#d9e1e8}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.account-gallery__item a{background-color:#d9e1e8}.dropdown-menu{background:#fff}.dropdown-menu__arrow.left{border-left-color:#fff}.dropdown-menu__arrow.top{border-top-color:#fff}.dropdown-menu__arrow.bottom{border-bottom-color:#fff}.dropdown-menu__arrow.right{border-right-color:#fff}.dropdown-menu__item a{background:#fff;color:#282c37}.privacy-dropdown__option.active,.privacy-dropdown__option:hover,.privacy-dropdown__option.active .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content strong,.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.dropdown-menu__item a:active,.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,.simple_form .block-button,.simple_form .button,.simple_form button{color:#fff}.dropdown-menu__separator{border-bottom-color:#ccd7e0}.actions-modal,.boost-modal,.confirmation-modal,.mute-modal,.block-modal,.report-modal,.embed-modal,.error-modal,.onboarding-modal,.report-modal__comment .setting-text__wrapper,.report-modal__comment .setting-text{background:#fff;border:1px solid #c0cdd9}.report-modal__comment{border-right-color:#c0cdd9}.report-modal__container{border-top-color:#c0cdd9}.column-header__collapsible-inner{background:#e6ebf0;border:1px solid #c0cdd9;border-top:0}.focal-point__preview strong{color:#fff}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar,.onboarding-modal__paginator,.error-modal__footer{background:#ecf0f4}.boost-modal__action-bar .onboarding-modal__nav:hover,.boost-modal__action-bar .onboarding-modal__nav:focus,.boost-modal__action-bar .onboarding-modal__nav:active,.boost-modal__action-bar .error-modal__nav:hover,.boost-modal__action-bar .error-modal__nav:focus,.boost-modal__action-bar .error-modal__nav:active,.confirmation-modal__action-bar .onboarding-modal__nav:hover,.confirmation-modal__action-bar .onboarding-modal__nav:focus,.confirmation-modal__action-bar .onboarding-modal__nav:active,.confirmation-modal__action-bar .error-modal__nav:hover,.confirmation-modal__action-bar .error-modal__nav:focus,.confirmation-modal__action-bar .error-modal__nav:active,.mute-modal__action-bar .onboarding-modal__nav:hover,.mute-modal__action-bar .onboarding-modal__nav:focus,.mute-modal__action-bar .onboarding-modal__nav:active,.mute-modal__action-bar .error-modal__nav:hover,.mute-modal__action-bar .error-modal__nav:focus,.mute-modal__action-bar .error-modal__nav:active,.block-modal__action-bar .onboarding-modal__nav:hover,.block-modal__action-bar .onboarding-modal__nav:focus,.block-modal__action-bar .onboarding-modal__nav:active,.block-modal__action-bar .error-modal__nav:hover,.block-modal__action-bar .error-modal__nav:focus,.block-modal__action-bar .error-modal__nav:active,.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{background-color:#fff}.display-case__case{background:#fff}.embed-modal .embed-modal__container .embed-modal__html{background:#fff;border:1px solid #c0cdd9}.embed-modal .embed-modal__container .embed-modal__html:focus{border-color:#b3c3d1;background:#fff}.react-toggle-track{background:#282c37}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background:#3d4455}.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background:#2074b1}.empty-column-indicator,.error-column{color:#000;background:#fff}.tabs-bar{background:#fff;border:1px solid #c0cdd9;border-bottom:0}@media screen and (max-width: 415px){.tabs-bar{border-top:0}}.tabs-bar__link{padding-bottom:14px;border-bottom-width:1px;border-bottom-color:#c0cdd9}.tabs-bar__link:hover,.tabs-bar__link:active,.tabs-bar__link:focus{background:#d9e1e8}.tabs-bar__link.active:hover,.tabs-bar__link.active:active,.tabs-bar__link.active:focus{background:transparent;border-bottom-color:#2b90d9}.activity-stream-tabs{background:#fff;border-bottom-color:#c0cdd9}.box-widget,.nothing-here,.page-header,.directory__tag>a,.directory__tag>div,.landing-page__call-to-action,.contact-widget,.landing .hero-widget__text,.landing-page__information.contact-widget{background:#fff;border:1px solid #c0cdd9}@media screen and (max-width: 415px){.box-widget,.nothing-here,.page-header,.directory__tag>a,.directory__tag>div,.landing-page__call-to-action,.contact-widget,.landing .hero-widget__text,.landing-page__information.contact-widget{border-left:0;border-right:0;border-top:0}}.landing .hero-widget__text{border-top:0;border-bottom:0}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#b3c3d1}.landing .hero-widget__footer{background:#fff;border:1px solid #c0cdd9;border-top:0}@media screen and (max-width: 415px){.landing .hero-widget__footer{border:0}}.brand__tagline{color:#282c37}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#d9e1e8}@media screen and (max-width: 415px){.directory__tag>a{border:0}}.directory__tag.active>a,.directory__tag.active>div{border-color:#2b90d9}.directory__tag.active>a,.directory__tag.active>a h4,.directory__tag.active>a h4 small,.directory__tag.active>a .fa,.directory__tag.active>a .trends__item__current,.directory__tag.active>div,.directory__tag.active>div h4,.directory__tag.active>div h4 small,.directory__tag.active>div .fa,.directory__tag.active>div .trends__item__current{color:#fff}.directory__tag.active>a:hover,.directory__tag.active>a:active,.directory__tag.active>a:focus,.directory__tag.active>div:hover,.directory__tag.active>div:active,.directory__tag.active>div:focus{background:#2b90d9}.batch-table__toolbar,.batch-table__row,.batch-table .nothing-here{border-color:#c0cdd9}.activity-stream{border:1px solid #c0cdd9}.activity-stream--under-tabs{border-top:0}.activity-stream .entry{background:#fff}.activity-stream .entry .detailed-status.light,.activity-stream .entry .more.light,.activity-stream .entry .status.light{border-bottom-color:#c0cdd9}.activity-stream .status.light .status__content{color:#000}.activity-stream .status.light .display-name strong{color:#000}.accounts-grid .account-grid-card .controls .icon-button{color:#282c37}.accounts-grid .account-grid-card .name a{color:#000}.accounts-grid .account-grid-card .username{color:#282c37}.accounts-grid .account-grid-card .account__header__content{color:#000}.simple_form .warning,.table-form .warning{box-shadow:none;background:rgba(223,64,90,.5);text-shadow:none}.simple_form .recommended,.table-form .recommended{border-color:#2b90d9;color:#2b90d9;background-color:rgba(43,144,217,.1)}.compose-form .compose-form__warning{border-color:#2b90d9;background-color:rgba(43,144,217,.1)}.compose-form .compose-form__warning,.compose-form .compose-form__warning a{color:#2b90d9}.status__content a,.reply-indicator__content a{color:#2b90d9}.button.logo-button{color:#fff}.button.logo-button svg{fill:#fff}.public-layout .account__section-headline{border:1px solid #c0cdd9}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-top:0}}.public-layout .header,.public-layout .public-account-header,.public-layout .public-account-bio{box-shadow:none}.public-layout .public-account-bio,.public-layout .hero-widget__text{background:#fff;border:1px solid #c0cdd9}.public-layout .header{background:#d9e1e8;border:1px solid #c0cdd9}@media screen and (max-width: 415px){.public-layout .header{border:0}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#ccd7e0}.public-layout .public-account-header__image{background:#b3c3d1}.public-layout .public-account-header__image::after{box-shadow:none}.public-layout .public-account-header__bar::before{background:#fff;border:1px solid #c0cdd9;border-top:0}.public-layout .public-account-header__bar .avatar img{border-color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{background:#fff;border:1px solid #c0cdd9;border-top:0}}.public-layout .public-account-header__tabs__name h1,.public-layout .public-account-header__tabs__name h1 small{color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__name h1,.public-layout .public-account-header__tabs__name h1 small{color:#000}}.public-layout .public-account-header__extra .public-account-bio{border:0}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-color:#c0cdd9}.notification__filter-bar button.active::after,.account__section-headline a.active::after{border-color:transparent transparent #fff}.hero-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.moved-account-widget,.memoriam-widget,.activity-stream,.nothing-here,.directory__tag>a,.directory__tag>div,.card>a,.page-header,.compose-form .compose-form__warning{box-shadow:none}.audio-player .video-player__controls button,.audio-player .video-player__time-sep,.audio-player .video-player__time-current,.audio-player .video-player__time-total{color:#000}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n$white: #ffffff;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n// Differences\n$success-green: lighten(#3c754d, 8%);\n\n$base-overlay-background: $white !default;\n$valid-value-color: $success-green !default;\n\n$ui-base-color: $classic-secondary-color !default;\n$ui-base-lighter-color: #b0c0cf;\n$ui-primary-color: #9bcbed;\n$ui-secondary-color: $classic-base-color !default;\n$ui-highlight-color: #2b90d9;\n\n$primary-text-color: $black !default;\n$darker-text-color: $classic-base-color !default;\n$dark-text-color: #444b5d;\n$action-button-color: #606984;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: $classic-base-color !default;\n$light-text-color: #444b5d;\n\n//Newly added colors\n$account-background-color: $white !default;\n\n//Invert darkened and lightened colors\n@function darken($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) + $amount);\n}\n\n@function lighten($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) - $amount);\n}\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 15px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 5px;\n right: 5px;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 12px;\n padding: 0 6px;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $light-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n font-size: 12px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 13px;\n font-weight: 500;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 13px;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 13px;\n font-weight: 400;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n font-size: 12px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 13px;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n font-size: 13px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n font-size: 14px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n font-size: 13px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n font-size: 13px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n","// Notes!\n// Sass color functions, \"darken\" and \"lighten\" are automatically replaced.\n\nhtml {\n scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25);\n}\n\n// Change the colors of button texts\n.button {\n color: $white;\n\n &.button-alternative-2 {\n color: $white;\n }\n}\n\n.status-card__actions button,\n.status-card__actions a {\n color: rgba($white, 0.8);\n\n &:hover,\n &:active,\n &:focus {\n color: $white;\n }\n}\n\n// Change default background colors of columns\n.column > .scrollable,\n.getting-started,\n.column-inline-form,\n.error-column,\n.regeneration-indicator {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.directory__card__img {\n background: lighten($ui-base-color, 12%);\n}\n\n.filter-form,\n.directory__card__bar {\n background: $white;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.scrollable .directory__list {\n width: calc(100% + 2px);\n margin-left: -1px;\n margin-right: -1px;\n}\n\n.directory__card,\n.table-of-contents {\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.column-back-button,\n.column-header {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n\n &--slim-button {\n top: -50px;\n right: 0;\n }\n}\n\n.column-header__back-button,\n.column-header__button,\n.column-header__button.active,\n.account__header__bar,\n.directory__card__extra {\n background: $white;\n}\n\n.column-header__button.active {\n color: $ui-highlight-color;\n\n &:hover,\n &:active,\n &:focus {\n color: $ui-highlight-color;\n background: $white;\n }\n}\n\n.account__header__bar .avatar .account__avatar {\n border-color: $white;\n}\n\n.getting-started__footer a {\n color: $ui-secondary-color;\n text-decoration: underline;\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n color: lighten($ui-base-color, 26%);\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n}\n\n.column-subheading {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.getting-started,\n.scrollable {\n .column-link {\n background: $white;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:hover,\n &:active,\n &:focus {\n background: $ui-base-color;\n }\n }\n}\n\n.getting-started .navigation-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.poll__text input[type=\"text\"],\n.compose-form .spoiler-input__input,\n.compose-form__poll-wrapper select,\n.search__input,\n.setting-text,\n.box-widget input[type=\"text\"],\n.box-widget input[type=\"email\"],\n.box-widget input[type=\"password\"],\n.box-widget textarea,\n.statuses-grid .detailed-status,\n.audio-player {\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.search__input {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n border-bottom: 0;\n }\n}\n\n.list-editor .search .search__input {\n border-top: 0;\n border-bottom: 0;\n}\n\n.compose-form__poll-wrapper select {\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n}\n\n.compose-form__poll-wrapper,\n.compose-form__poll-wrapper .poll__footer {\n border-top-color: lighten($ui-base-color, 8%);\n}\n\n.notification__filter-bar {\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.compose-form .compose-form__buttons-wrapper {\n background: $ui-base-color;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.drawer__header,\n.drawer__inner {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.drawer__inner__mastodon {\n background: $white url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n}\n\n// Change the colors used in compose-form\n.compose-form {\n .compose-form__modifiers {\n .compose-form__upload__actions .icon-button {\n color: lighten($white, 7%);\n\n &:active,\n &:focus,\n &:hover {\n color: $white;\n }\n }\n\n .compose-form__upload-description input {\n color: lighten($white, 7%);\n\n &::placeholder {\n color: lighten($white, 7%);\n }\n }\n }\n\n .compose-form__buttons-wrapper {\n background: darken($ui-base-color, 6%);\n }\n\n .autosuggest-textarea__suggestions {\n background: darken($ui-base-color, 6%);\n }\n\n .autosuggest-textarea__suggestions__item {\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: lighten($ui-base-color, 4%);\n }\n }\n}\n\n.emoji-mart-bar {\n border-color: lighten($ui-base-color, 4%);\n\n &:first-child {\n background: darken($ui-base-color, 6%);\n }\n}\n\n.emoji-mart-search input {\n background: rgba($ui-base-color, 0.3);\n border-color: $ui-base-color;\n}\n\n// Change the background colors of statuses\n.focusable:focus {\n background: $ui-base-color;\n}\n\n.status.status-direct {\n background: lighten($ui-base-color, 4%);\n}\n\n.focusable:focus .status.status-direct {\n background: lighten($ui-base-color, 8%);\n}\n\n.detailed-status,\n.detailed-status__action-bar {\n background: $white;\n}\n\n// Change the background colors of status__content__spoiler-link\n.reply-indicator__content .status__content__spoiler-link,\n.status__content .status__content__spoiler-link {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 4%);\n }\n}\n\n// Change the background colors of media and video spoilers\n.media-spoiler,\n.video-player__spoiler {\n background: $ui-base-color;\n}\n\n.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {\n color: $white;\n}\n\n.account-gallery__item a {\n background-color: $ui-base-color;\n}\n\n// Change the colors used in the dropdown menu\n.dropdown-menu {\n background: $white;\n\n &__arrow {\n &.left {\n border-left-color: $white;\n }\n\n &.top {\n border-top-color: $white;\n }\n\n &.bottom {\n border-bottom-color: $white;\n }\n\n &.right {\n border-right-color: $white;\n }\n }\n\n &__item {\n a {\n background: $white;\n color: $darker-text-color;\n }\n }\n}\n\n// Change the text colors on inverted background\n.privacy-dropdown__option.active,\n.privacy-dropdown__option:hover,\n.privacy-dropdown__option.active .privacy-dropdown__option__content,\n.privacy-dropdown__option.active .privacy-dropdown__option__content strong,\n.privacy-dropdown__option:hover .privacy-dropdown__option__content,\n.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,\n.dropdown-menu__item a:active,\n.dropdown-menu__item a:focus,\n.dropdown-menu__item a:hover,\n.actions-modal ul li:not(:empty) a.active,\n.actions-modal ul li:not(:empty) a.active button,\n.actions-modal ul li:not(:empty) a:active,\n.actions-modal ul li:not(:empty) a:active button,\n.actions-modal ul li:not(:empty) a:focus,\n.actions-modal ul li:not(:empty) a:focus button,\n.actions-modal ul li:not(:empty) a:hover,\n.actions-modal ul li:not(:empty) a:hover button,\n.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,\n.simple_form .block-button,\n.simple_form .button,\n.simple_form button {\n color: $white;\n}\n\n.dropdown-menu__separator {\n border-bottom-color: lighten($ui-base-color, 4%);\n}\n\n// Change the background colors of modals\n.actions-modal,\n.boost-modal,\n.confirmation-modal,\n.mute-modal,\n.block-modal,\n.report-modal,\n.embed-modal,\n.error-modal,\n.onboarding-modal,\n.report-modal__comment .setting-text__wrapper,\n.report-modal__comment .setting-text {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.report-modal__comment {\n border-right-color: lighten($ui-base-color, 8%);\n}\n\n.report-modal__container {\n border-top-color: lighten($ui-base-color, 8%);\n}\n\n.column-header__collapsible-inner {\n background: darken($ui-base-color, 4%);\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.focal-point__preview strong {\n color: $white;\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar,\n.onboarding-modal__paginator,\n.error-modal__footer {\n background: darken($ui-base-color, 6%);\n\n .onboarding-modal__nav,\n .error-modal__nav {\n &:hover,\n &:focus,\n &:active {\n background-color: darken($ui-base-color, 12%);\n }\n }\n}\n\n.display-case__case {\n background: $white;\n}\n\n.embed-modal .embed-modal__container .embed-modal__html {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n\n &:focus {\n border-color: lighten($ui-base-color, 12%);\n background: $white;\n }\n}\n\n.react-toggle-track {\n background: $ui-secondary-color;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: darken($ui-secondary-color, 10%);\n}\n\n.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: lighten($ui-highlight-color, 10%);\n}\n\n// Change the default color used for the text in an empty column or on the error column\n.empty-column-indicator,\n.error-column {\n color: $primary-text-color;\n background: $white;\n}\n\n.tabs-bar {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n\n &__link {\n padding-bottom: 14px;\n border-bottom-width: 1px;\n border-bottom-color: lighten($ui-base-color, 8%);\n\n &:hover,\n &:active,\n &:focus {\n background: $ui-base-color;\n }\n\n &.active {\n &:hover,\n &:active,\n &:focus {\n background: transparent;\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\n// Change the default colors used on some parts of the profile pages\n.activity-stream-tabs {\n background: $account-background-color;\n border-bottom-color: lighten($ui-base-color, 8%);\n}\n\n.box-widget,\n.nothing-here,\n.page-header,\n.directory__tag > a,\n.directory__tag > div,\n.landing-page__call-to-action,\n.contact-widget,\n.landing .hero-widget__text,\n.landing-page__information.contact-widget {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-left: 0;\n border-right: 0;\n border-top: 0;\n }\n}\n\n.landing .hero-widget__text {\n border-top: 0;\n border-bottom: 0;\n}\n\n.simple_form {\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n &:hover {\n border-color: lighten($ui-base-color, 12%);\n }\n }\n}\n\n.landing .hero-widget__footer {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border: 0;\n }\n}\n\n.brand__tagline {\n color: $ui-secondary-color;\n}\n\n.directory__tag > a {\n &:hover,\n &:active,\n &:focus {\n background: $ui-base-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border: 0;\n }\n}\n\n.directory__tag.active > a,\n.directory__tag.active > div {\n border-color: $ui-highlight-color;\n\n &,\n h4,\n h4 small,\n .fa,\n .trends__item__current {\n color: $white;\n }\n\n &:hover,\n &:active,\n &:focus {\n background: $ui-highlight-color;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row,\n .nothing-here {\n border-color: lighten($ui-base-color, 8%);\n }\n}\n\n.activity-stream {\n border: 1px solid lighten($ui-base-color, 8%);\n\n &--under-tabs {\n border-top: 0;\n }\n\n .entry {\n background: $account-background-color;\n\n .detailed-status.light,\n .more.light,\n .status.light {\n border-bottom-color: lighten($ui-base-color, 8%);\n }\n }\n\n .status.light {\n .status__content {\n color: $primary-text-color;\n }\n\n .display-name {\n strong {\n color: $primary-text-color;\n }\n }\n }\n}\n\n.accounts-grid {\n .account-grid-card {\n .controls {\n .icon-button {\n color: $darker-text-color;\n }\n }\n\n .name {\n a {\n color: $primary-text-color;\n }\n }\n\n .username {\n color: $darker-text-color;\n }\n\n .account__header__content {\n color: $primary-text-color;\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-shadow: none;\n background: rgba($error-red, 0.5);\n text-shadow: none;\n }\n\n .recommended {\n border-color: $ui-highlight-color;\n color: $ui-highlight-color;\n background-color: rgba($ui-highlight-color, 0.1);\n }\n}\n\n.compose-form .compose-form__warning {\n border-color: $ui-highlight-color;\n background-color: rgba($ui-highlight-color, 0.1);\n\n &,\n a {\n color: $ui-highlight-color;\n }\n}\n\n.status__content,\n.reply-indicator__content {\n a {\n color: $highlight-text-color;\n }\n}\n\n.button.logo-button {\n color: $white;\n\n svg {\n fill: $white;\n }\n}\n\n.public-layout {\n .account__section-headline {\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n }\n\n .header,\n .public-account-header,\n .public-account-bio {\n box-shadow: none;\n }\n\n .public-account-bio,\n .hero-widget__text {\n background: $account-background-color;\n border: 1px solid lighten($ui-base-color, 8%);\n }\n\n .header {\n background: $ui-base-color;\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border: 0;\n }\n\n .brand {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n\n .public-account-header {\n &__image {\n background: lighten($ui-base-color, 12%);\n\n &::after {\n box-shadow: none;\n }\n }\n\n &__bar {\n &::before {\n background: $account-background-color;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n }\n\n .avatar img {\n border-color: $account-background-color;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n background: $account-background-color;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n }\n }\n\n &__tabs {\n &__name {\n h1,\n h1 small {\n color: $white;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n color: $primary-text-color;\n }\n }\n }\n }\n\n &__extra {\n .public-account-bio {\n border: 0;\n }\n\n .public-account-bio .account__header__fields {\n border-color: lighten($ui-base-color, 8%);\n }\n }\n }\n}\n\n.notification__filter-bar button.active::after,\n.account__section-headline a.active::after {\n border-color: transparent transparent $white;\n}\n\n.hero-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.moved-account-widget,\n.memoriam-widget,\n.activity-stream,\n.nothing-here,\n.directory__tag > a,\n.directory__tag > div,\n.card > a,\n.page-header,\n.compose-form .compose-form__warning {\n box-shadow: none;\n}\n\n.audio-player .video-player__controls button,\n.audio-player .video-player__time-sep,\n.audio-player .video-player__time-current,\n.audio-player .video-player__time-total {\n color: $primary-text-color;\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/mastodon-light/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss","webpack:///./app/javascript/styles/mastodon-light/diff.scss"],"names":[],"mappings":"AAAA,2ZCKA,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,0CACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,8BACA,CC3EwB,iEDkF1B,kBClF0B,4BDsF1B,sBACE,MErFF,iDACE,mBACA,eACA,iBACA,gBACA,WDZM,kCCcN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBDlDwB,kBCsDxB,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cDzFiB,mBAEK,WC0FtB,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aDvKmB,kKC0KjB,oBAGE,sDAIJ,aD7KgB,eC+Kd,0DAEA,aDjLc,oDCsLhB,cACE,SACA,uBACA,cDzLc,aC2Ld,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aDvNY,gBCyNV,gBCnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFtBI,YEwBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF/BE,qBEiCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cF5FiB,wBE8FjB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UFzUA,qCE4UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF3Ve,mBE6Vf,kBACA,uHAEA,yBAGE,WFtWA,qCE0WF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFrbe,8CE0bjB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WF3kBF,gBE6kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WFrlBJ,gBEulBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aF1mBS,oDEinBf,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cFtoBW,aEwoBX,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BFxqBS,wEE8qBT,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFpsBJ,6CEssBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cFvuBa,uDE0uBb,oBACE,cF3uBW,qBE6uBX,aACA,gBACA,8DAEA,eACE,WFrvBJ,qCE2vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aF9xBU,8DEoyBV,mBACA,WF9yBE,qFEkzBJ,YAEE,eACA,cFlzBe,2CEszBjB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBFh3BkB,+IEm3BhB,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cJ9Fe,6BIiGf,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cACA,gBACA,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cLjBe,wBKqBjB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBCPI,uBDUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBNfwB,aMiBtB,0BACA,eACA,cNrBiB,iBMuBjB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aNrDiB,qBMuDf,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cN5EiB,+BMgFnB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aNzGmB,aM8GrB,YACE,kBACA,mBN9GwB,mCMgHxB,qBAGF,YACE,kBACA,0BACA,kBACA,cNzHmB,mBM2HnB,iBAGF,eACE,eACA,cNhImB,iBMkInB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cN3IiB,0BM+InB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cNxJiB,qBM0JjB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBNxKwB,mCM0KxB,cN5KmB,gBM8KnB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cNvNe,8DM6NjB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eClPM,CDoPN,cACA,cNlPmB,mBMoPnB,+BANA,iBACA,CClPM,kCDgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UNlQM,eMoQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cNxQiB,qCM4QnB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBNpR0B,kBMsRxB,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBNjSoB,kBMmSpB,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBNrSiB,eMuSf,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WNpUE,mBMsUF,gBACA,uBACA,wBAEA,aNvUe,0BM2Uf,aACE,gBACA,eACA,eACA,cN/Ua,0IMqVf,UNxVE,+BMgWJ,aACE,YACA,uDAGF,oBNnViB,wCMuVjB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cNtZiB,gBMwZjB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WNjbI,8BMobJ,aACE,cNlbe,gBMobf,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aNnhBmB,iCMkhBrB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cNzhBiB,4JM4hBjB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WNpkBI,gCMskBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MEplBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WRjDA,cQmDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aRpDe,0BQsDb,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aRlGmB,sBQqGjB,aRtFiB,yBQ0FjB,iBACE,kBACA,gBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cRhIiB,iCQmIjB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WRnKA,gBQqKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WRzLE,cQ2LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WR/ME,cQiNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WRnSI,cQqSJ,WACA,2CAKE,mBACE,eACA,WR7SA,qBQ+SA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WR7UI,cQ+UJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBR5VY,oLQgWZ,iBACE,4WAGF,oBR3ViB,mBQ8Vf,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBR5YiB,WAlBb,eQiaJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,wBACE,gLAGF,wBAEE,kHAGF,wBR5ae,gGQgbf,kBD7bQ,kHCgcN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WRjdI,cQmdJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cRzdY,oBQ2dZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,oEACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,iCACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UR/hBF,aQyiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cR9iBmB,kBQgjBnB,kBACA,mBACA,kBACA,uBAEA,mCACE,+BACA,cRjjBY,sBQqjBd,mCACE,+BACA,cD7jBQ,kBCikBV,oBACE,cRlkBiB,qBQokBjB,wBAEA,URzkBI,0BQ2kBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBRrlBsB,WALlB,eQ6lBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aR/nBmB,qBQioBjB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aRpoBmB,yBQsoBjB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cRnqBmB,oCQsqBnB,cACE,mBACA,kBACA,4CAGF,aR5qBmB,gBQ8qBjB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBDrsBM,YCusBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cRjtBmB,WQmtBnB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,CACA,URlvBI,qCQovBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UR1vBI,0BQ4vBF,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cRtyBmB,0BQyyBnB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WRtzBI,kBQwzBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aD/zBc,0SCy0BZ,+CACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBR52Bc,gBQ82BZ,2BAEA,kBRh3BY,gBQk3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC17BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WT9EA,gBSgFA,gBACA,uBACA,+BAGF,aACE,eACA,cTpFa,gBSsFb,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WT5GI,gBS8GJ,qBACA,iBACA,qBACA,sBAGF,eFnHM,oBEqHJ,WTtHI,eSwHJ,cACA,kBAGF,cACE,uCAGF,wBAEE,cT/HiB,oBSmInB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,WACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBTxK0B,mCS0KxB,cTpJiB,eSsJjB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cT3MmB,mCS6MnB,mCACA,6DAEA,aT1Mc,oCS4MZ,gCACA,qDAGF,aACE,oCACA,gCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cT1PiB,gCS4PjB,6BAGF,aACE,cThQiB,4BSoQnB,aTrPmB,qBSuPjB,qGAEA,yBAGE,oCAIJ,mCACE,+BACA,sCAEA,aT7QY,gBS+QV,0CAGF,aTlRY,wCSuRd,eACE,wCAIJ,UACE,0BAIA,aTvSmB,4BS0SjB,aT1SiB,qBS4Sf,qGAEA,yBAGE,iCAIJ,UTxTI,gBS0TF,wBAIJ,eACE,kBChUJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBVzBwB,6GU4BtB,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBV9DmB,WAlBb,oBUmFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UV7FI,gFUiGN,kBAGE,qNAKA,kBVzFe,4IUiGf,kBH9GQ,qCGqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cZGmB,SYDnB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aZXiB,eYaf,SAIJ,wBZfqB,YYiBnB,kBACA,sBACA,WZrCM,eYuCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBLjEQ,gBKqEN,mCAIJ,wBZ5DiB,eY+Df,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,UZvGM,mBAgBW,qGY2Ff,wBAGE,8BAIJ,kBZnGsB,2GYsGpB,wBAGE,0BAIJ,aZzHmB,uBY2HjB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,UACA,cZrIoB,SYuIpB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,sCACA,4BACA,2CACA,oBAGF,oCACE,uBAGF,aACE,6BACA,eACA,qBAGF,aZtKmB,gCY0KnB,QACE,uEAGF,mBAGE,uBAGF,aZnMmB,sFYsMjB,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,aZvMiB,uCY0Mf,aACE,wBAKN,sBACE,8BACA,qBACA,kBACA,YACA,8BAEA,6BACE,mBAKN,aZ5OqB,SY8OnB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,UACE,6BACA,eACA,0BAGF,aZ9PmB,qCYkQnB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,aZnTiB,sDYuTjB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBZxUe,yDY+UnB,UZjWM,mBYmWJ,mBZnVe,oCYqVf,iBACA,kBACA,eACA,gBACA,6CAEA,UZ3WI,gBY6WF,CAII,kRADF,eACE,wCAKN,aZnXiB,gBYqXf,0BACA,yIAEA,oBAGE,sCAKN,iBACE,MACA,QACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,WZraI,gBOCA,aKuaJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,aZzZc,CYuZd,sHAEA,aZzZc,CYuZd,8HAEA,aZzZc,CYuZd,4GAEA,aZzZc,+FY6Zd,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBZ3diB,0BY6djB,WZheI,eYkeJ,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,aZ9hBmB,wCYkiBnB,UZriBM,oBYuiBJ,eACA,gBLviBI,sEK0iBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cZjkBW,eYmkBX,gBACA,aACA,oBACA,6QAEA,aAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cZjmBW,SYmmBX,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UL7mBF,8GKinBE,WACE,cZhnBS,COFb,oGKinBE,WACE,cZhnBS,COFb,wGKinBE,WACE,cZhnBS,COFb,+FKinBE,WACE,cZhnBS,iFYqnBb,SACE,wEAKN,iBACE,sBL/nBE,wBKioBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,gBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cZhsBmB,4CYmsBnB,aLlsBY,kCKusBd,2CACE,WC7sBF,8DDktBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBZ9sBiB,aYgtBjB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,UZhvBQ,cYkvBN,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WZ5wBM,wDY+wBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aLnyBc,qBKqyBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,aZhzBc,8EYqzBhB,aACE,0GAGF,kBZxzBoB,sHY2zBlB,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,+BAKN,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,aZ33BmB,qBY63BjB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,WZ/6BM,gBYi7BN,eACA,cACA,yBACA,iBACA,eACA,sBACA,4BAGF,aZp6BkB,SYs6BhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aZv+Be,qCY2+Bf,UZtgCI,6BY0gCJ,aZ/+Be,CA3BX,kEYkhCJ,UZlhCI,kCYqhCF,aZngCe,gEYugCf,UZzhCE,mBAgBW,sEY6gCX,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,aZzhCkB,YY4hChB,eACA,uBAGF,aZhiCkB,qCYoiClB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cZrlCgB,CYulChB,iBACA,eACA,kBACA,+CAEA,aZ5lCgB,uBYgmChB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cZxnCgB,4BY8nCtB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cZvrCgB,eYyrChB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,UZzuCQ,eY2uCN,6BAEA,aZ1uCmB,SY+uCrB,YACE,gCACA,8BAEA,aACE,cACA,WZxvCI,qBY0vCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cZtxCiB,gBYwxCjB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBE3yCE,iCACA,wBACA,4BACA,kBF0yCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBErzCA,iCACA,wBACA,4BACA,kBFozCE,gBACA,kBACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WZt0CE,6BYw0CF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCE30CrB,+BF60CA,iBEt1CA,iCACA,wBACA,4BACA,WFq1CuB,sCE/0CvB,kCFk1CA,iBE31CA,iCACA,wBACA,4BACA,WF01CuB,sCEp1CvB,kBFs1CE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cZx1CgB,6BY21ChB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,yBACA,eACA,cZ17CiB,kCY87CnB,aACE,eACA,gBACA,WZp8CI,CYy8CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UZz+CM,kBY++CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aZzgDqB,cY2gDnB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WZ3hDI,kCYgiDR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,CZ7hDgB,gHYuiDhB,aZviDgB,wBY2iDhB,UACE,wCAGF,kBZtjDsB,WAfhB,8CYykDJ,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cZhlDmB,eYklDnB,iBACA,kBACA,4BAEA,aZvkDmB,6BY2kDnB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CLjmDU,mEKwmDZ,aLxmDY,uBK4mDZ,aL7mDc,4DKmnDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UZxoDM,0BY0oDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cL9nD4B,eAEC,0DK+nD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cLtpD4B,eAEC,WKupD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cZrtDmB,wBYwtDnB,aZxtDmB,mBY4tDnB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBZzxDqB,cY2xDnB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BZ9zDiB,2BYk0DnB,WACE,iBACA,uBACA,yBZr0DiB,8BYy0DnB,QACE,iBACA,uBACA,4BZ50DiB,6BYg1DnB,SACE,gBACA,2BACA,2BZn1DiB,wBYy1DnB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZ/1DiB,WAHb,gBYq2DJ,uBACA,mBACA,yFAEA,kBZv1DiB,cAfA,UY22Df,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBZz3DiB,cY23DjB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBZl5DiB,WAHb,gBYw5DJ,uBACA,mBACA,oDAEA,SACE,oDAGF,kBZ94DiB,cAfA,iBYo6DrB,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBZvgEwB,8BYygEtB,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cZniEmB,qBYqiEnB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WZ9lEM,qBYgmEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cZrmEiB,sBYymEnB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WLpyEM,kBKsyEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBZ91EiB,yBYg2EjB,gBACA,kBACA,eACA,gBACA,iBACA,WZv3EI,mDY43ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBL75EI,0BK+5EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBZz9EwB,0BY89E1B,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cZniFmB,eYqiFnB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cZxjFmB,eY0jFnB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,kBACA,QACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,gDACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBZ1oFwB,qCY4oFxB,sEAGF,wBACE,4CAGF,wBZtoFqB,+EY0oFrB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,sBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBZlsFmB,cYssFrB,kBACE,WZztFM,cY2tFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cZxuFiB,kGY2uFjB,sBAGE,WZjvFE,kCYqvFJ,aZnuFiB,oBYyuFrB,oBACE,iBACA,qBAGF,oBACE,kBACA,CACA,gBACA,CZ/vFwB,eYkwFxB,iBACA,wCANA,cACA,CACA,eACA,mBAaA,CAVA,mBZnwFwB,aAiBR,iBYwvFhB,CAEA,wBACA,eACA,yDAGF,kBZhxF0B,cYsxF1B,aACE,kBAGF,aZzwFkB,cY2wFhB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,aZvyFY,0BYyyFV,sDAIJ,oBACE,cZj0Fe,sMYo0Ff,yBAGE,oDAKN,aZzzFgB,0BY+zFhB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cZ11Fe,aY41Ff,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,aZr3Fe,qBY43FrB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,WZx5FM,gBOCA,aK05FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,aZx6FsB,6BY06FpB,uDAGF,aZl7FqB,cYs7FrB,YACE,eACA,yBACA,kBACA,cZt7FgB,gBYw7FhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cZr+Fe,uBYu+Ff,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UZj/FE,yBYw/FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cZxhGmB,gBY0hGnB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aZtiGqB,oBY0iGrB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cZpmGgB,6BYsmGhB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cZ9nGgB,mBAjBQ,eYkpGxB,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cZ5pGY,qCYgqGd,cACE,gBACA,yBAKN,iBACE,cACA,UACA,gCAEA,sCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,kFACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,aZpuGmB,4CYyuGjB,aZzuGiB,yCY2uGf,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cZnxGmB,eYqxGnB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UZpyGI,mBYsyGF,6BAKN,eACE,gBACA,gBACA,cZ3yGmB,0DY6yGnB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aZ10GmB,0BY40GjB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,aZz1GkB,eY21GhB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBZx+GM,WADA,eY4+GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eZt/GQ,cAEa,SYu/GnB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,8BACA,kBACA,iBACA,WZvjHE,gBYyjHF,eACA,+LAMA,6BACE,mEAKF,6BACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aZlmHqB,eYomHnB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtmHF,sBACA,WACA,SACA,gBACA,oBACA,mBdbwB,cAFL,eckBnB,SACA,+EFgmHI,aACE,CEjmHN,qEFgmHI,aACE,CEjmHN,yEFgmHI,aACE,CEjmHN,gEFgmHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,aZvnHc,iBYynHZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aZnqHiB,0HYwqHjB,cAEE,gBACA,cZxpHY,kZY2pHZ,aAGE,gEAIJ,wBACE,iDAGF,eL3rHI,kBO0BN,CAEA,eACA,cdHiB,uCcKjB,UF8pHI,mBZ3rHe,oDc+BnB,wBACE,cdRe,ecUf,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,WdnDI,sDYksHJ,WACE,mDAGF,UZtsHI,kBYwsHF,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UZztHQ,kBY2tHN,cACA,mBACA,sBZ5tHM,yBY8tHN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aZ7uHqB,qBY+uHnB,mBACA,gBACA,sBACA,6EAGF,aZluHkB,mBAjBQ,kBYwvHxB,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,4EAfF,cAgBI,6FAGF,eACE,mFAGF,aZ7vHmB,qBY+vHjB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eLt2HQ,kBKw2HN,sCACA,kBACA,eACA,UACA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBLl4HM,kBKo4HN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,4BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,8BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBZh8HwB,kCYk8HxB,uBAGF,MACE,aACA,mBACA,uBACA,cZ38HmB,eY68HnB,gBACA,0BACA,kBACA,kBAGF,YACE,cZp9HmB,gBYs9HnB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,yBACA,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBZj+HsB,kBYm+HtB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBZx+HmB,kBY0+HnB,eAGF,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBLliIM,uCKoiIN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,UZnjIQ,aYqjIN,eACA,aACA,kEAEA,kBZviImB,WAlBb,UY6jIJ,CZ7jII,4RYkkIF,UZlkIE,wCYwkIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cZllImB,2CYqlInB,eACE,cACA,WZ1lII,CY+lIA,wQADF,eACE,mDAON,eLrmIM,0BKumIJ,qCACA,gEAEA,eACE,0DAGF,kBZ7lIiB,uEYgmIf,UZlnIE,uDYwnIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SEznIE,sBACA,WACA,SACA,gBACA,oBACA,mBdbwB,cAFL,eckBnB,SACA,cFmnIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cZxrIiB,eY0rIjB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cZ1rIkB,eY4rIlB,uCAEA,uBACE,sCAGF,aACE,yBAKN,aZzsIkB,mBY2sIhB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cZjuIc,iCYouId,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cZrwImB,qBYuwInB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cZlxImB,kBYoxInB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,8BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cLjyI0B,eAEC,CK2yI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,6BACE,sBACA,SACA,WZ93IM,eYg4IN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cZz6IiB,mFY46IjB,yBAGE,wBAKN,oBACE,sBAGF,qBZ37IQ,YY67IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBZp7IqB,qBYw7IrB,iBACE,UACA,QACA,YACA,6CAGF,kBZ98IqB,WAHb,kBYs9IN,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,aZrgJmB,SYwgJjB,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,CZzhJE,wyEYgiJF,UAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,WZnjJI,kBYqjJJ,yBACA,eACA,qBAGF,kBZrjJwB,cAFL,gBY0jJjB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,WZhlJM,kBYklJN,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,aACE,wBAKF,eL5mJM,CPEa,gBY6mJjB,oBACA,iELhnJI,2BPEa,yBYsnJrB,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBZroJmB,aYuoJnB,iBACA,2HAEA,aACE,iBACA,cZ5oJiB,mBY8oJjB,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,aZ3rJmB,iLY+rJnB,UZjtJM,qCYstJN,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,UZhvJI,gBOCA,aKkvJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eLlwJI,yBKowJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,UZnxJE,oBYqxJA,eACA,gBLrxJA,+CK0xJJ,YACE,8BACA,mBACA,4CAIJ,aACE,WZnyJI,eYqyJJ,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,UZ9yJI,eYgzJF,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,UZ11JE,aY41JA,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBZz1JW,WAlBb,uDYk3JA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cZn4JmB,eYq4JnB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,UZt7JI,CYw7JF,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBZr7JqB,WYu7JnB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WZl9JM,8BYo9JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cZr+Jc,iBYu+Jd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cZngKY,gBYqgKZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,aZthKc,gBY8hKhB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cZ7kKmB,kBY+kKnB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBZ1lKM,CY2lKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBZtmKM,iCYymKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,oCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBLvsKM,eKysKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBZnxKI,cAEa,gBYoxKjB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,ULn1KE,+EK21KN,cAGE,gBACA,6BAGF,ULl2KM,iBKo2KJ,yBAGF,oBACE,aACA,mDAGF,UL52KM,uBKi3KN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WLj6KE,sFKo6KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WZ5jLF,gBY8jLE,gBACA,uBACA,0CAGF,aACE,eACA,cZlkLW,gBYokLX,gBACA,uBACA,yBAKN,kBZzkLsB,aY2kLpB,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cZ1pLiB,eY4pLjB,eACA,gBACA,kBACA,qBACA,kBACA,WACA,mBACA,yJAEA,aZrqLiB,qWYwqLf,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBZ/sL0B,sBYktLxB,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eLxwLQ,kBO0BN,CACA,sBACA,gBACA,cdHiB,uCcKjB,mBAEA,wBACE,cdRe,ecUf,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,WdnDI,UY6wLR,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cZrxLiB,gBYuxLjB,gBAEA,aZ1wLiB,0BY4wLf,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBZn5LoB,WALlB,eY25LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cZh7Lc,CYm7Ld,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,8BACA,cAGF,kBZjgM0B,sBYmgMxB,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBZvjM0B,sBYyjMxB,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBL5mMM,yDK+mMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBLvnMI,uBK2nMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UZzpMI,eY2pMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aZhrMqB,eYkrMnB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WZvyMA,gBYyyMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cZ7yMW,gBY+yMX,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WZp0ME,gDYw0MJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aZ30MU,yBYi1Md,cACE,gCAEA,cACE,cZ31Me,eY61Mf,kCAEA,oBACE,cZh2Ma,qBYk2Mb,iBACA,gBACA,yCAEA,eACE,WZ12MF,iBYm3MN,aZ71MgB,mBY+1Md,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cZv3MY,gBYy3MZ,uBACA,mBACA,4BAEA,eACE,uBAGF,aZp5Me,qBYs5Mb,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cZ/6Me,0BYm7MjB,aACE,WACA,2CAEA,mCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBZx8MiB,kBY08MjB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cZ7+Me,kBY++Mf,+BAGF,aZl/MiB,eYo/Mf,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UZ//ME,qBYigNA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UZ3hNI,gBYiiNR,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBZjkNe,kBYmkNf,cACA,eACA,4BAIJ,YACE,cZzlNiB,kBY2lNjB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,cZvpNe,mFY2pNjB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,aZ1rNmB,SY4rNjB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OGxtNN,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBfGiB,eeEnB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAGF,aACE,sBACA,WACA,eACA,WfhDE,UekDF,oBACA,gBRlDE,sBQoDF,kBACA,iBACA,sCAEA,oBfvCe,0Be4CjB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBfpFY,8EeyFZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,cfhHc,aeoHhB,cACE,uBACA,UACA,SACA,SACA,cfzHc,0Be2Hd,kBACA,mBAEA,oBACE,sCAGF,kCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,yBACE,gCAEA,YACE,2CAGF,yBACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBfrKe,sDe2KnB,cACE,gBACA,iBACA,YACA,oBACA,cf3KkB,sCe8KlB,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WftNI,qBewNJ,WACA,UACA,oBACA,qXACA,sBACA,kBACA,CACA,yBACA,mDAGF,UACE,cAIJ,aflNkB,qBeqNhB,+BACE,6BAEA,8BACE,eChPN,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,WjBDM,2BiBIN,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBjBlBiB,4BiBsBnB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,cjBjCmB,ciBmCnB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,ajBrCqB,mCiBwCnB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBjBtDmB,uBiB2DnB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBV5FM,sBU8FN,sGAEA,mCAEE,oBAKF,2BACA,gBVxGM,0BU2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,6BACA,WjBnHI,yBiBqHJ,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,mCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBVpKI,mBUyKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,cjB7JiB,mDiBgKjB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,clBlBmB,oBkBqBnB,alBNmB,0BkBQjB,6EAEA,oBAGE,wCAIJ,alBhCmB,oBkBqCnB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,clB/CiB,qBkBmDnB,iBACE,clBpDiB,uBkBwDnB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,clBxEiB,qBkB4EnB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,clBnJe,iCkBuJjB,uBACE,gBACA,gBACA,clBvIY,qDkB2Id,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WlBrNI,iBkBuNJ,kBACA,qEAEA,aAEE,6CAIA,alB7Ne,oCkBkOjB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,clB7Pe,mBkB+Pf,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WlB1SA,qBkB4SA,uDAGE,yBACE,2CAKN,aACE,clBnTa,kCkB2TnB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,clBlUiB,sCkBqUjB,alBtTiB,0BkBwTf,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,clB5ViB,wBkB+VjB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,clB7WiB,kBkBkXjB,clBlXiB,mCkBiXnB,4CACE,CACA,gBACA,gBACA,mBACA,clBtXiB,kBkB2XjB,clB3XiB,kBkBoYjB,clBpYiB,mCkBmYnB,4CACE,CACA,gBACA,gBACA,mBACA,clBxYiB,kBkB6YjB,clB7YiB,mCkBqZnB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,6CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBlB/bwB,kBkBictB,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBlBxjBsB,kBkB0jBtB,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,alB1lBiB,qCkB8lBjB,eACE,WlBlmBE,gBkBomBF,ClBjmBe,yFkBsmBb,alBtmBa,+CkB4mBjB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SlBxrBI,YkB0rBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,clB/sBe,6BkBmtBjB,eACE,iBACA,+BAGF,kBlBttBsB,akBwtBpB,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,clB9vBa,uFkBowBnB,eACE,cASA,ClB9wBiB,2CkB2wBjB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,clB91BiB,qBkBg2BjB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,clBh2Bc,SmBvBlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBnBrBwB,UmB0BxB,anBbmB,0BmBejB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBnB9DsB,6BmBgEpB,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,cnB1GmB,gBmB4GnB,0DAEA,UnBjHM,wDmBqHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBnB5JsB,sBmB8JtB,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBnB3KsB,gCmB8KtB,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBnBnMsB,uCmBsMpB,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,cnB7Oa,gBmB+Ob,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBpBZoB,YoBcpB,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SpBzCA,YoB2CE,kBACA,YACA,uCAIJ,aACE,cpB/Ca,qBoBiDb,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cpBzFa,qBoB2Fb,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UpB1GA,yBoB4GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UpBlIE,yBAkBa,gBoBmHb,gBACA,mEAEA,wBACE,6DAKN,yBACE,iCAIJ,qBACE,WACA,gBApJY,cAsJZ,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,apBlNiB,eoBoNf,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,apB7NiB,eoB+Nf,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,cpBxOe,mBoB0Of,kBACA,gCACA,4BAGF,cACE,cpBhPe,iBoBkPf,gBACA,0CAGF,UpBzPI,gBoB2PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WpBzQE,oBoB2QF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cpBhRe,mBoBkRf,kCAEA,UpBvRE,gBoByRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,6CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BpBlUe,YoByUrB,UACE,SACA,cACA,WACA,sDAKA,apBjWmB,0DoBoWjB,apBrViB,4DoB0VnB,apBnWc,gBoBqWZ,4DAGF,ab9WU,gBagXR,0DAGF,apBhWgB,gBoBkWd,0DAGF,abtXU,gBawXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,cpBtae,qBoBwaf,yBACA,eACA,gBACA,gCACA,iCAEA,UpBjbE,gCoBmbA,oCAGF,apBpae,gCoBsab,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cpB5eiB,CoBifb,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,apB5iBmB,qBoB8iBjB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBpBzkBwB,gCoB2kBxB,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,cpBtlBiB,eoBwlBjB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,apB7lBgB,sDoBimBhB,apBpnBmB,qBoBwnBjB,gBACA,yDAIJ,oBAIE,cpBjoBmB,iGoBooBnB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBpBvqBc,yBoB2qBd,yBACE,wBAGF,yBbnrBU,wBawrBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,apBlsBiB,uBoBwsBjB,wBACA,qBAGF,apBzrBgB,coB8rBlB,kBpB/sB0B,kBoBitBxB,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cpBvuBe,yBoByuBf,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,abvvBM,6Ba8vBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cpB5wBa,mLoB+wBb,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,apBxwBU,iBoB0wBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cpBvyBa,WoB8yBrB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,apBt1BY,8CoB21Bd,qBACE,aACA,WpBt2BI,coB22BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBpB/2BsB,gCoBi3BtB,kCAEA,cACE,cACA,gBACA,eACA,gBACA,cpB13Be,qBoB43Bf,mBACA,uHAEA,UpBl4BE,iCoBy4BJ,cACE,cpBp3BY,uCoBw3Bd,YACE,8BACA,mBACA,sCAGF,eACE,sBCt5BN,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WrBtCI,6CqBwCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,crB/CiB,kBqBiDjB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,arBpEmB,gBqBsEjB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,qEACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,MCDF,6CACE,CjBFM,qCiBSN,UjBTM,sDiBcR,wBAEE,sMAEA,UjBlBM,gGiB0BR,ejB1BQ,yBiBgCN,aACA,uBAGF,kBACE,oCAGF,ejBxCQ,gCiB2CN,8BAGF,sBACE,iBACA,kBACA,qCAGF,wBAEE,oCAGF,ejBzDQ,yBiB4DN,qCAEA,mCALF,YAMI,+DAGF,SACE,QACA,gIAIJ,ejBxEQ,+BiBgFR,axB/DqB,8GwBkEnB,axBlEmB,gBOjBb,gDiB2FR,iBjB3FQ,4BiB+FR,axB7FqB,0BwB+FnB,iIAGF,aAIE,6cAEA,UxB3GM,oBwBkHR,kBACE,gCACA,wDAKA,ejBxHM,gCiB0HJ,4MAEA,kBxBxHsB,kCwBgI1B,4BACE,gCACA,qCAEA,iCAJF,YAKI,uUAIJ,wBAYE,qCAIA,eADF,YAEI,gBACA,sCAIJ,YACE,gBACA,oCAGF,oXACE,uEAGF,wBAEE,2BAGF,wBACE,aACA,8CAGF,kBxBlL0B,yBwBoLxB,aACA,gCAGF,ejB5LQ,yBiB+LN,0BAGF,o1BACE,oFAME,aACE,6QAEA,UjB5ME,gFiBmNJ,aACE,2GAEA,aACE,CAHF,iGAEA,aACE,CAHF,qGAEA,aACE,CAHF,4FAEA,aACE,CAMJ,8FAGF,kBACE,yPAIA,kBAIE,iBAKN,oBACE,6BAEA,kBACE,0BAIJ,+BACE,qBxBnPwB,kBwBwP1B,kBxBxP0B,uBwB4P1B,kBACE,wCAGF,kBACE,+CAGF,ejBxQQ,0GiB8QR,kBxB1Q0B,sHwB8QxB,kBACE,uCAKJ,kBxBpR0B,uEwByR1B,UjB7RQ,0BiBiSR,wBxB7R0B,gBwBkS1B,ejBtSQ,4BiB0SJ,sBjB1SI,2BiB8SJ,qBjB9SI,8BiBkTJ,wBjBlTI,6BiBsTJ,uBjBtTI,wBiB4TJ,ejB5TI,cPEa,85BwBkUrB,UjBpUQ,2BiB4VR,2BACE,uNAIF,ejBjWQ,yBiB6WN,wBAGF,0BACE,0BAGF,wBACE,mCAGF,kBACE,yBACA,aACA,8BAGF,UjB9XQ,6JiBkYR,kBAME,+2DAIE,qBAGE,qBAKN,ejBpZQ,yDiBwZR,ejBxZQ,yBiB0ZN,+DAEA,oBACE,gBjB7ZI,qBiBkaR,kBxBhaqB,sEwBoarB,kBACE,4FAGF,kBACE,uCAIF,UxBhbQ,gBOCA,WiBqbR,ejBrbQ,yBiBubN,gBACA,qCAEA,UALF,YAMI,kBAGF,mBACE,wBACA,4BACA,oEAEA,kBxB/bsB,yFwBscpB,sBAGE,4BxB5ba,uBwBocrB,exBrdQ,4BwBudN,kMAGF,ejB1dQ,yBiBoeN,qCAEA,iMAZF,aAaI,eACA,aACA,8BAIJ,YACE,gBACA,oLASE,oBACE,+BAKN,ejB9fQ,yBiBggBN,aACA,qCAEA,8BALF,QAMI,kBAIJ,axBtgBqB,0EwB2gBnB,kBxBzgBwB,qCwB+gBxB,kBAPF,QAQI,sDAIJ,oBxBvgBqB,mVwB2gBnB,UjB5hBM,mMiBoiBN,kBxBnhBmB,oEwB2hBnB,oBAGE,kBAIJ,wBACE,8BAEA,YACE,yBAGF,exB1jBM,0HwB6jBJ,2BAGE,CxBjkBE,oGwB2kBF,UxB3kBE,0DwBqlBF,axBllBe,2CwBwlBf,UxB3lBE,6CwBgmBJ,axB7lBiB,6DwBimBjB,UxBpmBI,4CwB4mBN,eACE,8BACA,iBACA,oDxB7lBiB,awBmmBjB,yFAHF,oBxBhmBmB,qCwBymBnB,CxBzmBmB,2HwBmnBnB,axBnnBmB,qBwBwnBrB,UjBzoBQ,yBiB4oBN,SjB5oBM,2CiBkpBN,wBACE,qCAEA,0CAHF,YAII,kGAIJ,eAGE,sEAGF,exBhqBM,yBwBmqBJ,wBAGF,kBxBlqBwB,yBwBoqBtB,qCAEA,uBAJF,QAKI,+GAIA,kBAGE,8CAMJ,kBACE,oDAEA,eACE,mDAKF,exBjsBE,yBwBmsBA,aACA,wDAGF,iBxBvsBE,qCwB2sBF,2CAXF,exBhsBI,yBwB6sBA,aACA,kHAMA,UjBptBA,qCiBwtBE,gHAJF,UxBrtBA,mEwBiuBF,QACE,2FAGF,oBACE,yFAMR,yCAEE,0PAGF,eAaE,sKAGF,UxBjwBQ,0D","file":"skins/vanilla/mastodon-light/common.css","sourcesContent":["html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#ccd7e0 rgba(255,255,255,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#ccd7e0;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#c6d2dc}::-webkit-scrollbar-thumb:active{background:#ccd7e0}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(255,255,255,.1)}::-webkit-scrollbar-track:hover{background:#d9e1e8}::-webkit-scrollbar-track:active{background:#d9e1e8}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#eff3f5;font-size:13px;line-height:18px;font-weight:400;color:#000;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#d9e1e8}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#ccd7e0;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#e6ebf0;padding:0}body.error{position:absolute;text-align:center;color:#282c37;background:#d9e1e8;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#dc2f4b;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#2b90d9}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#444b5d;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#444b5d}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#444b5d;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#4a905f;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#000;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#000;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#282c37;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#000}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#b3c3d1}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#282c37;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#000}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#a6b9c9;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#99afc2}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#282c37}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#fff}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#ccd7e0;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #ccd7e0;background:#f2f5f7}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#ccd7e0;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#000;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#282c37}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#282c37;padding:10px;border-right:1px solid #ccd7e0;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9bcbed;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #2b90d9;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#282c37}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#000;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #b3c3d1}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#282c37}.public-layout .public-account-header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#000}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#217aba}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#4a905f}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#000}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#282c37}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #c0cdd9}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #c0cdd9}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#d9e1e8}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#ccd7e0}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#6d8ca7}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#6d8ca7}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#282c37}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#6d8ca7}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#6d8ca7}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#60829f}.compact-header h1{font-size:24px;line-height:28px;color:#282c37;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#282c37}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#d9e1e8;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.hero-widget__text a{color:#282c37;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#282c37}.box-widget{padding:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #444b5d;text-align:center;color:#282c37;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#282c37;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#282c37;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#282c37;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#282c37}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#282c37;margin-bottom:10px}.page-header{background:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#000;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#282c37}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#ccd7e0}.page-header h1{font-size:24px}}.directory{background:#d9e1e8;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#d9e1e8;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#c0cdd9}.directory__tag.active>a{background:#2b90d9;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#000;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#282c37}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#282c37}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#000}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#2b90d9}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#f2f5f7;border:2px solid #d9e1e8}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#282c37;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #c0cdd9}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#000}.accounts-table__count small{display:block;color:#282c37;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #a6b9c9}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#282c37}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#2b90d9}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#e6ebf0;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#000;border-bottom:1px solid #ccd7e0}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #ccd7e0}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#000;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#2b90d9;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#282c37}.simple_form .hint a{color:#2b90d9}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#fff}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#282c37}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#000;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#000;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#000;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#c1203b}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#000;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#000;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #444b5d;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb;border:1px solid #fff;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#1f232b}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#c1203b}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#4a905f}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#fff}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#2b90d9;background:#f2f5f7}.simple_form .input.field_with_errors label{color:#c1203b}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#c1203b}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#c1203b;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#2b90d9;color:#000;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#2482c7}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#419bdd}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9bcbed}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#db2a47}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#e3566d}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#000;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#444b5d;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(249, 250, 251, 0), #f9fafb)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(217,225,232,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#000}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#c0cdd9;color:#282c37;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(74,144,95,.5);background:rgba(74,144,95,.25);color:#4a905f}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#282c37;text-decoration:none}.flash-message a:hover{color:#000;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#ccd7e0}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#282c37;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#2b90d9;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#217aba}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#282c37}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#282c37;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#282c37;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#000;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#000;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#282c37}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#000;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#f9fafb;border:1px solid #fff;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#4a905f;transition:none}.input-copy.copied button{background:#4a905f;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#e6ebf0;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#c0cdd9;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#e6ebf0;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#c0cdd9}.card__img{height:130px;position:relative;background:#fff;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#f2f5f7;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#000;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#000;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#282c37}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#000}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#d9e1e8;box-shadow:0 0 15px rgba(0,0,0,.2);color:#444b5d;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#282c37;background-color:rgba(40,44,55,.1);border:1px solid rgba(40,44,55,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#4a905f;background-color:rgba(74,144,95,.1);border-color:rgba(74,144,95,.5)}.account-role.admin,.simple_form .recommended.admin{color:#c1203b;background-color:rgba(193,32,59,.1);border-color:rgba(193,32,59,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #b3c3d1;border-bottom:1px solid #b3c3d1;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #b3c3d1}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#282c37;background:rgba(242,245,247,.5)}.account__header__fields dd{flex:1 1 auto;color:#282c37}.account__header__fields a{color:#2b90d9;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(74,144,95,.5);background:rgba(74,144,95,.25)}.account__header__fields .verified a{color:#4a905f;font-weight:500}.account__header__fields .verified__mark{color:#4a905f}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#282c37}.pending-account__header a{color:#282c37;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#000;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#d9e1e8}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#c0cdd9}.button.logo-button{flex:0 auto;font-size:14px;background:#2b90d9;color:#000;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#000}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#2074b1}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9bcbed}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#2b90d9;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9bcbed;cursor:default}.button{background-color:#2b90d9;border:10px none;border-radius:4px;box-sizing:border-box;color:#000;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#2074b1;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9bcbed;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#000;background:#9bcbed}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#8ac2ea}.button.button-alternative-2{background:#b0c0cf}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#a3b6c7}.button.button-secondary{color:#282c37;background:transparent;padding:3px 15px;border:1px solid #9bcbed}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#8ac2ea;color:#1f232b}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#606984;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#51596f;background-color:rgba(96,105,132,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(96,105,132,.3)}.icon-button.disabled{color:#828ba4;background-color:transparent;cursor:default}.icon-button.active{color:#2b90d9}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#282c37}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#373d4c;background-color:rgba(40,44,55,.15)}.icon-button.inverted:focus{background-color:rgba(40,44,55,.3)}.icon-button.inverted.disabled{color:#191b22;background-color:transparent}.icon-button.inverted.active{color:#2b90d9}.icon-button.inverted.active.disabled{color:#1d6ca4}.icon-button.overlayed{box-sizing:content-box;background:rgba(255,255,255,.6);color:rgba(0,0,0,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(255,255,255,.9)}.text-icon-button{color:#282c37;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#373d4c;background-color:rgba(40,44,55,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(40,44,55,.3)}.text-icon-button.disabled{color:#000;background-color:transparent;cursor:default}.text-icon-button.active{color:#2b90d9}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#2b90d9}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9bcbed;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#2b90d9;background:#2b90d9}.compose-form .compose-form__warning{color:#000;margin-bottom:10px;background:#9bcbed;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#000;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#282c37;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:0;right:0}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#444b5d}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#282c37;border-radius:0 0 4px 4px;color:#000;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#3d4455}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#282c37}.compose-form .compose-form__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#282c37;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#191b22}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#282c37;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#282c37}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#fff;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#282c37}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9bcbed;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#000;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#000}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#353a48}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#444b5d}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#217aba}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#606984}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#51596f;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#282c37;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#217aba}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#217aba;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#000;font-weight:700;font-size:11px;padding:0 6px;text-transform:uppercase;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#444b5d;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #c0cdd9}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#ccd7e0}.focusable:focus .status.status-direct{background:#b3c3d1}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#c0cdd9}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #c0cdd9;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#c0cdd9;border-bottom-color:#b3c3d1}.status.light .status__relative-time{color:#444b5d}.status.light .status__display-name{color:#000}.status.light .display-name{color:#444b5d}.status.light .display-name strong{color:#000}.status.light .status__content{color:#000}.status.light .status__content a{color:#2b90d9}.status.light .status__content a.status__content__spoiler-link{color:#000;background:#9bcbed}.status.light .status__content a.status__content__spoiler-link:hover{background:#78b9e7}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#444a5e}.status__relative-time,.notification__relative_time{color:#444b5d;float:right;font-size:14px}.status__display-name{color:#444b5d}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #282c37;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#444b5d;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#444b5d}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#606984}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#ccd7e0;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#444b5d;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#000;font-size:14px}.reply-indicator__content a{color:#282c37}.domain{padding:10px;border-bottom:1px solid #c0cdd9}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#000;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #c0cdd9}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#282c37;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#000;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #c0cdd9;color:#444b5d}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #c0cdd9;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #2b90d9}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#282c37}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#000}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#000}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#282c37;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#000}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#444b5d}.muted .status__display-name strong{color:#444b5d}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#b0c0cf;color:#000}.muted a.status__content__spoiler-link:hover{background:#9aaec2;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#282c37;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#2b90d9}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#000;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#282c37}.navigation-bar strong{color:#282c37}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #393f4f;margin:5px 7px 6px;height:0}.dropdown-menu{background:#282c37;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#282c37}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#282c37}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#282c37}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#282c37}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#2b90d9;color:#282c37;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#282c37;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#282c37;color:#000;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#2b90d9;color:#282c37}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#f2f5f7;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#d9e1e8;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#282c37;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#c0cdd9;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#000;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #c0cdd9;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#adbecd;border-bottom-color:#adbecd}}.tabs-bar__link.active{border-bottom:2px solid #2b90d9;color:#2b90d9}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#3897db;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#227dbe}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#2b90d9;border:2px solid #c0cdd9;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#000}.column-link--transparent .icon-with-badge__badge{border-color:#f2f5f7}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #ccd7e0;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#b0c0cf;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#d9e1e8}.drawer__inner__mastodon{background:#b0c0cf url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#b0c0cf;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#c0cdd9;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#cfd9e2;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#ccd7e0;color:#2b90d9;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#ccd7e0;border:0;font-family:inherit;color:#2b90d9;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(255,255,255,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#d9e1e8;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#f9fafb}.react-toggle--checked .react-toggle-track{background-color:#2b90d9}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#2074b1}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #d9e1e8;border-radius:50%;background-color:#fff;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#2b90d9}.column-link{background:#c0cdd9;color:#000;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#b6c5d3}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#282c37}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#000}.column-link--transparent.active{color:#2b90d9}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#d9e1e8;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#d9e1e8;color:#444b5d;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#d9e1e8}.flex-spacer{flex:1 1 auto}.getting-started{color:#444b5d;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#444b5d;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#282c37}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#444b5d}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#282c37;padding:10px;font-weight:500;border-bottom:1px solid #c0cdd9}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#282c37}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#c0cdd9;border:1px solid #e6ebf0}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#606984;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#2b90d9}.status-card{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;color:#444b5d;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#282c37;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#000}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#c0cdd9}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#282c37;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#282c37}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#c0cdd9;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#ccd7e0}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#ccd7e0}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#444b5d;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#d3dce4}.load-gap{border-bottom:1px solid #c0cdd9}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#444b5d;background:#d9e1e8;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#444b5d}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(43,144,217,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(43, 144, 217, 0.23) 0%, rgba(43, 144, 217, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#ccd7e0;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#2b90d9}.column-header.active .column-header__icon{color:#2b90d9;text-shadow:0 0 10px rgba(43,144,217,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#ccd7e0;border:0;color:#282c37;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#191b22}.column-header__button.active{color:#000;background:#c0cdd9}.column-header__button.active:hover{color:#000;background:#c0cdd9}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#282c37;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #b3c3d1;margin:10px 0}.column-header__collapsible-inner{background:#c0cdd9;padding:15px}.column-header__setting-btn:hover{color:#282c37;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#444b5d;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #86a0b6;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#86a0b6}29%{background-color:#86a0b6}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#fff;color:#000;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#fff;color:#282c37;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#17191f}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(255,255,255,.5);border-radius:8px;padding:8px 12px;color:#000;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(255,255,255,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(255,255,255,.5)}.modal-container--preloader{background:#c0cdd9}.account--panel{background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#c0cdd9;padding:15px}.column-settings__section{color:#282c37;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#1f232b}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#ccd7e0}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#444b5d;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#c0cdd9}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#b3c3d1;color:#1f232b}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#282c37}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#444b5d}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#3b4151}.column-settings__hashtags .column-select__indicator-separator{background-color:#c0cdd9}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#282c37}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#444b5d;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#000}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#000;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#3d4455}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#000;margin-bottom:4px;display:block;vertical-align:top;background-color:#fff;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#282c37;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#444b5d;background:#d9e1e8;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#2b90d9;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#e6ebf0;contain:initial}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(40,44,55,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(255,255,255,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#d9e1e8;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#282c37;font-size:18px;font-weight:500;border:2px dashed #b0c0cf;border-radius:4px}.upload-progress{padding:10px;color:#282c37;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:12px;text-transform:uppercase;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#b0c0cf;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#2b90d9;border-radius:6px}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#000;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#2b90d9;color:#000;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#000}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#000}.privacy-dropdown__option.active:hover{background:#2485cb}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#282c37}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#000}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#2b90d9}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#000}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#d9e1e8;color:#282c37;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#1f232b}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#ccd7e0}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#282c37;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#606984;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#51596f}.search-results__header{color:#444b5d;background:#d3dce4;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#444b5d}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#282c37;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#1f232b;text-decoration:underline}.search-results__info{padding:20px;color:#282c37;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(255,255,255,.5);box-sizing:border-box;border:0;color:#000;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#282c37}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#000;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#2b90d9}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#282c37;color:#000;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#393f4f;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#282c37;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#313543;background-color:#4a5266}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#000}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#000}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#000;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#d9e1e8;color:#282c37;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#17191f;color:#000;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#17191f}.actions-modal .status{background:#fff;border-bottom-color:#282c37;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#282c37}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#282c37;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#282c37;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #282c37}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#2b90d9}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#000}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #282c37;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#000;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #282c37;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #393f4f}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #282c37;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#000;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#000;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#000;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#000;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#2b90d9;color:#000}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#282c37;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#313543;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#000;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#2b90d9;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#000;background:rgba(255,255,255,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #c0cdd9;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#444b5d;padding:8px 18px;cursor:default;border-right:1px solid #c0cdd9;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#444b5d;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#444b5d}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#282c37;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#fff}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#f2f5f7;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #ccd7e0;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(33,122,186,.5)}.audio-player__wave-placeholder{background-color:#a6b9c9}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#f2f5f7;border-top:1px solid #ccd7e0;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#fff;color:#282c37;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#191b22}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#217aba}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#217aba}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#217aba;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#fff;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#ccd7e0;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#f2f5f7;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#000;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#d9e1e8;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #c0cdd9;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#e6ebf0;border-bottom:1px solid #c0cdd9;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#e6ebf0;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#282c37;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative;width:100%;white-space:nowrap}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#282c37}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #c0cdd9}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #d9e1e8}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#dfe6ec;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #eff3f5}.filter-form{background:#d9e1e8}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9bcbed;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#217aba;background:#217aba}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#444b5d;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#444b5d;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#000}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#282c37;max-width:400px}noscript div a{color:#2b90d9;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#d9e1e8;color:#000;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#ccd7e0}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#ccd7e0;border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9}.account__moved-note__message{position:relative;margin-left:58px;color:#444b5d;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#ccd7e0}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,.5)}.list-editor{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#b0c0cf;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#d9e1e8;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#b0c0cf}.list-adder__lists{background:#b0c0cf;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #c0cdd9}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#000;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#282c37;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#e6ebf0}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#ccd7e0;padding:5px;border-bottom:1px solid #b3c3d1}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#f2f5f7;border:2px solid #ccd7e0}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #b3c3d1;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#000;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#282c37;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#000}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #b3c3d1}.account__header__bio .account__header__fields a{color:#217aba}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#4a905f}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#282c37;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#282c37;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#000}.trends__header{color:#444b5d;background:#d3dce4;border-bottom:1px solid #e6ebf0;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #c0cdd9}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#444b5d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#282c37;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#282c37}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(43,144,217,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#2380c3 !important}.conversation{display:flex;border-bottom:1px solid #c0cdd9;padding:5px;padding-bottom:0}.conversation:focus{background:#d3dce4;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#282c37;padding-left:15px}.conversation__content__names{color:#282c37;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#000;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#d3dce4}.conversation--unread:focus{background:#ccd7e0}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#000}.announcements{background:#c0cdd9;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#2b90d9;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#282c37;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#b3c3d1;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#282c37}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#a6b9c9;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#1f232b}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#98b9d3}.reactions-bar__item.active .reactions-bar__item__count{color:#217aba}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#282c37;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#1f232b;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#b1d6f1;height:5px;min-width:1%}.poll__chart.leading{background:#2b90d9}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#000;outline:0;font-family:inherit;background:#fff;border:1px solid #fff;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#2b90d9}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9bcbed;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#4a905f;background:#4a905f}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#305d3d;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#444b5d}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#444b5d;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(68,75,93,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #fff}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #fff;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#2b90d9}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#606984;border-color:#606984;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#000;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #fff;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#fff}.muted .poll{color:#444b5d}.muted .poll__chart{background:rgba(216,234,248,.2)}.muted .poll__chart.leading{background:rgba(43,144,217,.2)}.modal-layout{background:#d9e1e8 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#000}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #393f4f}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#282c37}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#282c37;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#313543}.emoji-mart-anchor-selected{color:#2b90d9}.emoji-mart-anchor-selected:hover{color:#3c99dc}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#2b90d9}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(255,255,255,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(40,44,55,.3);color:#000;border:1px solid #282c37;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(40,44,55,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#444b5d}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#282c37}.rich-formatting a{color:#2b90d9;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#282c37}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#282c37}.rich-formatting em{font-style:italic;color:#282c37}.rich-formatting code{font-size:.85em;background:#f2f5f7;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#282c37}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #ccd7e0;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #ccd7e0;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#282c37}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#444b5d}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#e6ebf0;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#000;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#282c37}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#f2f5f7;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#282c37;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #ccd7e0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#3d4455}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#000;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#282c37}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#282c37}.landing-page p a,.landing-page li a{color:#2b90d9;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#131419}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#131419}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#282c37}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(176,192,207,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#d9e1e8;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#131419}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#d9e1e8;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#282c37}.landing-page__short-description h1{font-weight:500;color:#000;margin-bottom:0}.landing-page__short-description h1 small{color:#282c37}.landing-page__short-description h1 small span{color:#282c37}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#000;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#282c37}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#d9e1e8;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#282c37}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#282c37}.landing .simple_form p.lead{color:#282c37;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #c0cdd9}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9bcbed;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#444b5d}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #d9e1e8;text-align:left;background:#e6ebf0}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #d9e1e8;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#d9e1e8}.table a{color:#2b90d9;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#d9e1e8;border-top:1px solid #f2f5f7;border-bottom:1px solid #f2f5f7}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #f2f5f7}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #f2f5f7}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#282c37;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#000}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #f2f5f7;background:#d9e1e8;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #f2f5f7;border-top:0;background:#d9e1e8}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #f2f5f7;border-top:0;background:#e6ebf0}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #f2f5f7}}.batch-table__row:hover{background:#dfe6ec}.batch-table__row:nth-child(even){background:#d9e1e8}.batch-table__row:nth-child(even):hover{background:#d3dce4}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#282c37;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #f2f5f7;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #f2f5f7}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#d9e1e8;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#c0cdd9;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#000;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#282c37;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#b3c3d1}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#282c37;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#000;background-color:#e9eef2;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#dfe6ec;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#e6ebf0;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#000;background-color:#2b90d9;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#2482c7}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #c0cdd9;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#282c37;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#282c37;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#282c37;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #c0cdd9}.admin-wrapper .content h6{font-size:16px;color:#282c37;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#000;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#000;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#282c37;margin-bottom:20px}.admin-wrapper .content>p strong{color:#000;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(176,192,207,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #ccd7e0;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#2b90d9}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#282c37}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#2b90d9}body .positive-hint,.admin-wrapper .content .positive-hint{color:#4a905f;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#444b5d;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#282c37;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #d9e1e8}.filters .filter-subset a:hover{color:#000;border-bottom:2px solid #c9d4de}.filters .filter-subset a.selected{color:#2b90d9;border-bottom:2px solid #2b90d9}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#282c37}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#2b90d9;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#d9e1e8;border-bottom:1px solid #ccd7e0}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#282c37;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#444b5d}.log-entry a,.log-entry .username,.log-entry .target{color:#282c37;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#282c37}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#c1203b}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #2b90d9}.speech-bubble.positive{border-left-color:#4a905f}.speech-bubble.negative{border-left-color:#c1203b}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#282c37}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#444b5d}.report-card{background:#d9e1e8;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#282c37;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#17191f}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #e6ebf0}.report-card__summary__item:hover{background:#d3dce4}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#282c37}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#444b5d;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#282c37}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#c0cdd9;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#217aba}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#4a905f}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#000}.center-text{text-align:center}.announcements-list{border:1px solid #ccd7e0;border-radius:4px}.announcements-list__item{padding:15px 0;background:#d9e1e8;border-bottom:1px solid #ccd7e0}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#282c37;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#000}.announcements-list__item__meta{padding:0 15px;color:#444b5d}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#ccd7e0;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#c0cdd9}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#000;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#282c37;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#282c37;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(249, 250, 251, 0), #f9fafb)}body.rtl .simple_form select{background:#f9fafb url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}html{scrollbar-color:#d9e1e8 rgba(217,225,232,.25)}.button{color:#fff}.button.button-alternative-2{color:#fff}.status-card__actions button,.status-card__actions a{color:rgba(255,255,255,.8)}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.column>.scrollable,.getting-started,.column-inline-form,.error-column,.regeneration-indicator{background:#fff;border:1px solid #c0cdd9;border-top:0}.directory__card__img{background:#b3c3d1}.filter-form,.directory__card__bar{background:#fff;border-bottom:1px solid #c0cdd9}.scrollable .directory__list{width:calc(100% + 2px);margin-left:-1px;margin-right:-1px}.directory__card,.table-of-contents{border:1px solid #c0cdd9}.column-back-button,.column-header{background:#fff;border:1px solid #c0cdd9}@media screen and (max-width: 415px){.column-back-button,.column-header{border-top:0}}.column-back-button--slim-button,.column-header--slim-button{top:-50px;right:0}.column-header__back-button,.column-header__button,.column-header__button.active,.account__header__bar,.directory__card__extra{background:#fff}.column-header__button.active{color:#2b90d9}.column-header__button.active:hover,.column-header__button.active:active,.column-header__button.active:focus{color:#2b90d9;background:#fff}.account__header__bar .avatar .account__avatar{border-color:#fff}.getting-started__footer a{color:#282c37;text-decoration:underline}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{color:#86a0b6}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#000}.column-subheading{background:#e6ebf0;border-bottom:1px solid #c0cdd9}.getting-started .column-link,.scrollable .column-link{background:#fff;border-bottom:1px solid #c0cdd9}.getting-started .column-link:hover,.getting-started .column-link:active,.getting-started .column-link:focus,.scrollable .column-link:hover,.scrollable .column-link:active,.scrollable .column-link:focus{background:#d9e1e8}.getting-started .navigation-bar{border-top:1px solid #c0cdd9;border-bottom:1px solid #c0cdd9}@media screen and (max-width: 415px){.getting-started .navigation-bar{border-top:0}}.compose-form__autosuggest-wrapper,.poll__option input[type=text],.compose-form .spoiler-input__input,.compose-form__poll-wrapper select,.search__input,.setting-text,.box-widget input[type=text],.box-widget input[type=email],.box-widget input[type=password],.box-widget textarea,.statuses-grid .detailed-status,.audio-player{border:1px solid #c0cdd9}@media screen and (max-width: 415px){.search__input{border-top:0;border-bottom:0}}.list-editor .search .search__input{border-top:0;border-bottom:0}.compose-form__poll-wrapper select{background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px}.compose-form__poll-wrapper,.compose-form__poll-wrapper .poll__footer{border-top-color:#c0cdd9}.notification__filter-bar{border:1px solid #c0cdd9;border-top:0}.compose-form .compose-form__buttons-wrapper{background:#d9e1e8;border:1px solid #c0cdd9;border-top:0}.drawer__header,.drawer__inner{background:#fff;border:1px solid #c0cdd9}.drawer__inner__mastodon{background:#fff url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{color:#ededed}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description input{color:#ededed}.compose-form .compose-form__modifiers .compose-form__upload-description input::placeholder{color:#ededed}.compose-form .compose-form__buttons-wrapper{background:#ecf0f4}.compose-form .autosuggest-textarea__suggestions{background:#ecf0f4}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#ccd7e0}.emoji-mart-bar{border-color:#ccd7e0}.emoji-mart-bar:first-child{background:#ecf0f4}.emoji-mart-search input{background:rgba(217,225,232,.3);border-color:#d9e1e8}.focusable:focus{background:#d9e1e8}.status.status-direct{background:#ccd7e0}.focusable:focus .status.status-direct{background:#c0cdd9}.detailed-status,.detailed-status__action-bar{background:#fff}.reply-indicator__content .status__content__spoiler-link,.status__content .status__content__spoiler-link{background:#d9e1e8}.reply-indicator__content .status__content__spoiler-link:hover,.status__content .status__content__spoiler-link:hover{background:#ccd7e0}.media-spoiler,.video-player__spoiler{background:#d9e1e8}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.account-gallery__item a{background-color:#d9e1e8}.dropdown-menu{background:#fff}.dropdown-menu__arrow.left{border-left-color:#fff}.dropdown-menu__arrow.top{border-top-color:#fff}.dropdown-menu__arrow.bottom{border-bottom-color:#fff}.dropdown-menu__arrow.right{border-right-color:#fff}.dropdown-menu__item a{background:#fff;color:#282c37}.privacy-dropdown__option.active,.privacy-dropdown__option:hover,.privacy-dropdown__option.active .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content strong,.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.dropdown-menu__item a:active,.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,.simple_form .block-button,.simple_form .button,.simple_form button{color:#fff}.dropdown-menu__separator{border-bottom-color:#ccd7e0}.actions-modal,.boost-modal,.confirmation-modal,.mute-modal,.block-modal,.report-modal,.embed-modal,.error-modal,.onboarding-modal,.report-modal__comment .setting-text__wrapper,.report-modal__comment .setting-text{background:#fff;border:1px solid #c0cdd9}.report-modal__comment{border-right-color:#c0cdd9}.report-modal__container{border-top-color:#c0cdd9}.column-header__collapsible-inner{background:#e6ebf0;border:1px solid #c0cdd9;border-top:0}.focal-point__preview strong{color:#fff}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar,.onboarding-modal__paginator,.error-modal__footer{background:#ecf0f4}.boost-modal__action-bar .onboarding-modal__nav:hover,.boost-modal__action-bar .onboarding-modal__nav:focus,.boost-modal__action-bar .onboarding-modal__nav:active,.boost-modal__action-bar .error-modal__nav:hover,.boost-modal__action-bar .error-modal__nav:focus,.boost-modal__action-bar .error-modal__nav:active,.confirmation-modal__action-bar .onboarding-modal__nav:hover,.confirmation-modal__action-bar .onboarding-modal__nav:focus,.confirmation-modal__action-bar .onboarding-modal__nav:active,.confirmation-modal__action-bar .error-modal__nav:hover,.confirmation-modal__action-bar .error-modal__nav:focus,.confirmation-modal__action-bar .error-modal__nav:active,.mute-modal__action-bar .onboarding-modal__nav:hover,.mute-modal__action-bar .onboarding-modal__nav:focus,.mute-modal__action-bar .onboarding-modal__nav:active,.mute-modal__action-bar .error-modal__nav:hover,.mute-modal__action-bar .error-modal__nav:focus,.mute-modal__action-bar .error-modal__nav:active,.block-modal__action-bar .onboarding-modal__nav:hover,.block-modal__action-bar .onboarding-modal__nav:focus,.block-modal__action-bar .onboarding-modal__nav:active,.block-modal__action-bar .error-modal__nav:hover,.block-modal__action-bar .error-modal__nav:focus,.block-modal__action-bar .error-modal__nav:active,.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{background-color:#fff}.display-case__case{background:#fff}.embed-modal .embed-modal__container .embed-modal__html{background:#fff;border:1px solid #c0cdd9}.embed-modal .embed-modal__container .embed-modal__html:focus{border-color:#b3c3d1;background:#fff}.react-toggle-track{background:#282c37}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background:#3d4455}.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background:#2074b1}.empty-column-indicator,.error-column{color:#000;background:#fff}.tabs-bar{background:#fff;border:1px solid #c0cdd9;border-bottom:0}@media screen and (max-width: 415px){.tabs-bar{border-top:0}}.tabs-bar__link{padding-bottom:14px;border-bottom-width:1px;border-bottom-color:#c0cdd9}.tabs-bar__link:hover,.tabs-bar__link:active,.tabs-bar__link:focus{background:#d9e1e8}.tabs-bar__link.active:hover,.tabs-bar__link.active:active,.tabs-bar__link.active:focus{background:transparent;border-bottom-color:#2b90d9}.activity-stream-tabs{background:#fff;border-bottom-color:#c0cdd9}.box-widget,.nothing-here,.page-header,.directory__tag>a,.directory__tag>div,.landing-page__call-to-action,.contact-widget,.landing .hero-widget__text,.landing-page__information.contact-widget{background:#fff;border:1px solid #c0cdd9}@media screen and (max-width: 415px){.box-widget,.nothing-here,.page-header,.directory__tag>a,.directory__tag>div,.landing-page__call-to-action,.contact-widget,.landing .hero-widget__text,.landing-page__information.contact-widget{border-left:0;border-right:0;border-top:0}}.landing .hero-widget__text{border-top:0;border-bottom:0}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#b3c3d1}.landing .hero-widget__footer{background:#fff;border:1px solid #c0cdd9;border-top:0}@media screen and (max-width: 415px){.landing .hero-widget__footer{border:0}}.brand__tagline{color:#282c37}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#d9e1e8}@media screen and (max-width: 415px){.directory__tag>a{border:0}}.directory__tag.active>a,.directory__tag.active>div{border-color:#2b90d9}.directory__tag.active>a,.directory__tag.active>a h4,.directory__tag.active>a h4 small,.directory__tag.active>a .fa,.directory__tag.active>a .trends__item__current,.directory__tag.active>div,.directory__tag.active>div h4,.directory__tag.active>div h4 small,.directory__tag.active>div .fa,.directory__tag.active>div .trends__item__current{color:#fff}.directory__tag.active>a:hover,.directory__tag.active>a:active,.directory__tag.active>a:focus,.directory__tag.active>div:hover,.directory__tag.active>div:active,.directory__tag.active>div:focus{background:#2b90d9}.batch-table__toolbar,.batch-table__row,.batch-table .nothing-here{border-color:#c0cdd9}.activity-stream{border:1px solid #c0cdd9}.activity-stream--under-tabs{border-top:0}.activity-stream .entry{background:#fff}.activity-stream .entry .detailed-status.light,.activity-stream .entry .more.light,.activity-stream .entry .status.light{border-bottom-color:#c0cdd9}.activity-stream .status.light .status__content{color:#000}.activity-stream .status.light .display-name strong{color:#000}.accounts-grid .account-grid-card .controls .icon-button{color:#282c37}.accounts-grid .account-grid-card .name a{color:#000}.accounts-grid .account-grid-card .username{color:#282c37}.accounts-grid .account-grid-card .account__header__content{color:#000}.simple_form .warning,.table-form .warning{box-shadow:none;background:rgba(223,64,90,.5);text-shadow:none}.simple_form .recommended,.table-form .recommended{border-color:#2b90d9;color:#2b90d9;background-color:rgba(43,144,217,.1)}.compose-form .compose-form__warning{border-color:#2b90d9;background-color:rgba(43,144,217,.1)}.compose-form .compose-form__warning,.compose-form .compose-form__warning a{color:#2b90d9}.status__content a,.reply-indicator__content a{color:#2b90d9}.button.logo-button{color:#fff}.button.logo-button svg{fill:#fff}.public-layout .account__section-headline{border:1px solid #c0cdd9}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-top:0}}.public-layout .header,.public-layout .public-account-header,.public-layout .public-account-bio{box-shadow:none}.public-layout .public-account-bio,.public-layout .hero-widget__text{background:#fff;border:1px solid #c0cdd9}.public-layout .header{background:#d9e1e8;border:1px solid #c0cdd9}@media screen and (max-width: 415px){.public-layout .header{border:0}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#ccd7e0}.public-layout .public-account-header__image{background:#b3c3d1}.public-layout .public-account-header__image::after{box-shadow:none}.public-layout .public-account-header__bar::before{background:#fff;border:1px solid #c0cdd9;border-top:0}.public-layout .public-account-header__bar .avatar img{border-color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{background:#fff;border:1px solid #c0cdd9;border-top:0}}.public-layout .public-account-header__tabs__name h1,.public-layout .public-account-header__tabs__name h1 small{color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__name h1,.public-layout .public-account-header__tabs__name h1 small{color:#000}}.public-layout .public-account-header__extra .public-account-bio{border:0}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-color:#c0cdd9}.notification__filter-bar button.active::after,.account__section-headline a.active::after{border-color:transparent transparent #fff}.hero-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.moved-account-widget,.memoriam-widget,.activity-stream,.nothing-here,.directory__tag>a,.directory__tag>div,.card>a,.page-header,.compose-form .compose-form__warning{box-shadow:none}.audio-player .video-player__controls button,.audio-player .video-player__time-sep,.audio-player .video-player__time-current,.audio-player .video-player__time-total{color:#000}","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Dependent colors\n$black: #000000;\n$white: #ffffff;\n\n$classic-base-color: #282c37;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #2b90d9;\n\n// Differences\n$success-green: lighten(#3c754d, 8%);\n\n$base-overlay-background: $white !default;\n$valid-value-color: $success-green !default;\n\n$ui-base-color: $classic-secondary-color !default;\n$ui-base-lighter-color: #b0c0cf;\n$ui-primary-color: #9bcbed;\n$ui-secondary-color: $classic-base-color !default;\n$ui-highlight-color: #2b90d9;\n\n$primary-text-color: $black !default;\n$darker-text-color: $classic-base-color !default;\n$dark-text-color: #444b5d;\n$action-button-color: #606984;\n\n$inverted-text-color: $black !default;\n$lighter-text-color: $classic-base-color !default;\n$light-text-color: #444b5d;\n\n//Newly added colors\n$account-background-color: $white !default;\n\n//Invert darkened and lightened colors\n@function darken($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) + $amount);\n}\n\n@function lighten($color, $amount) {\n @return hsl(hue($color), saturation($color), lightness($color) - $amount);\n}\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 11px;\n padding: 0 6px;\n text-transform: uppercase;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 12px;\n text-transform: uppercase;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n width: 100%;\n white-space: nowrap;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n","// Notes!\n// Sass color functions, \"darken\" and \"lighten\" are automatically replaced.\n\nhtml {\n scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25);\n}\n\n// Change the colors of button texts\n.button {\n color: $white;\n\n &.button-alternative-2 {\n color: $white;\n }\n}\n\n.status-card__actions button,\n.status-card__actions a {\n color: rgba($white, 0.8);\n\n &:hover,\n &:active,\n &:focus {\n color: $white;\n }\n}\n\n// Change default background colors of columns\n.column > .scrollable,\n.getting-started,\n.column-inline-form,\n.error-column,\n.regeneration-indicator {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.directory__card__img {\n background: lighten($ui-base-color, 12%);\n}\n\n.filter-form,\n.directory__card__bar {\n background: $white;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.scrollable .directory__list {\n width: calc(100% + 2px);\n margin-left: -1px;\n margin-right: -1px;\n}\n\n.directory__card,\n.table-of-contents {\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.column-back-button,\n.column-header {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n\n &--slim-button {\n top: -50px;\n right: 0;\n }\n}\n\n.column-header__back-button,\n.column-header__button,\n.column-header__button.active,\n.account__header__bar,\n.directory__card__extra {\n background: $white;\n}\n\n.column-header__button.active {\n color: $ui-highlight-color;\n\n &:hover,\n &:active,\n &:focus {\n color: $ui-highlight-color;\n background: $white;\n }\n}\n\n.account__header__bar .avatar .account__avatar {\n border-color: $white;\n}\n\n.getting-started__footer a {\n color: $ui-secondary-color;\n text-decoration: underline;\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n color: lighten($ui-base-color, 26%);\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n}\n\n.column-subheading {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.getting-started,\n.scrollable {\n .column-link {\n background: $white;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:hover,\n &:active,\n &:focus {\n background: $ui-base-color;\n }\n }\n}\n\n.getting-started .navigation-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n}\n\n.compose-form__autosuggest-wrapper,\n.poll__option input[type=\"text\"],\n.compose-form .spoiler-input__input,\n.compose-form__poll-wrapper select,\n.search__input,\n.setting-text,\n.box-widget input[type=\"text\"],\n.box-widget input[type=\"email\"],\n.box-widget input[type=\"password\"],\n.box-widget textarea,\n.statuses-grid .detailed-status,\n.audio-player {\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.search__input {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n border-bottom: 0;\n }\n}\n\n.list-editor .search .search__input {\n border-top: 0;\n border-bottom: 0;\n}\n\n.compose-form__poll-wrapper select {\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n}\n\n.compose-form__poll-wrapper,\n.compose-form__poll-wrapper .poll__footer {\n border-top-color: lighten($ui-base-color, 8%);\n}\n\n.notification__filter-bar {\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.compose-form .compose-form__buttons-wrapper {\n background: $ui-base-color;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.drawer__header,\n.drawer__inner {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.drawer__inner__mastodon {\n background: $white url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n}\n\n// Change the colors used in compose-form\n.compose-form {\n .compose-form__modifiers {\n .compose-form__upload__actions .icon-button {\n color: lighten($white, 7%);\n\n &:active,\n &:focus,\n &:hover {\n color: $white;\n }\n }\n\n .compose-form__upload-description input {\n color: lighten($white, 7%);\n\n &::placeholder {\n color: lighten($white, 7%);\n }\n }\n }\n\n .compose-form__buttons-wrapper {\n background: darken($ui-base-color, 6%);\n }\n\n .autosuggest-textarea__suggestions {\n background: darken($ui-base-color, 6%);\n }\n\n .autosuggest-textarea__suggestions__item {\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: lighten($ui-base-color, 4%);\n }\n }\n}\n\n.emoji-mart-bar {\n border-color: lighten($ui-base-color, 4%);\n\n &:first-child {\n background: darken($ui-base-color, 6%);\n }\n}\n\n.emoji-mart-search input {\n background: rgba($ui-base-color, 0.3);\n border-color: $ui-base-color;\n}\n\n// Change the background colors of statuses\n.focusable:focus {\n background: $ui-base-color;\n}\n\n.status.status-direct {\n background: lighten($ui-base-color, 4%);\n}\n\n.focusable:focus .status.status-direct {\n background: lighten($ui-base-color, 8%);\n}\n\n.detailed-status,\n.detailed-status__action-bar {\n background: $white;\n}\n\n// Change the background colors of status__content__spoiler-link\n.reply-indicator__content .status__content__spoiler-link,\n.status__content .status__content__spoiler-link {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 4%);\n }\n}\n\n// Change the background colors of media and video spoilers\n.media-spoiler,\n.video-player__spoiler {\n background: $ui-base-color;\n}\n\n.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {\n color: $white;\n}\n\n.account-gallery__item a {\n background-color: $ui-base-color;\n}\n\n// Change the colors used in the dropdown menu\n.dropdown-menu {\n background: $white;\n\n &__arrow {\n &.left {\n border-left-color: $white;\n }\n\n &.top {\n border-top-color: $white;\n }\n\n &.bottom {\n border-bottom-color: $white;\n }\n\n &.right {\n border-right-color: $white;\n }\n }\n\n &__item {\n a {\n background: $white;\n color: $darker-text-color;\n }\n }\n}\n\n// Change the text colors on inverted background\n.privacy-dropdown__option.active,\n.privacy-dropdown__option:hover,\n.privacy-dropdown__option.active .privacy-dropdown__option__content,\n.privacy-dropdown__option.active .privacy-dropdown__option__content strong,\n.privacy-dropdown__option:hover .privacy-dropdown__option__content,\n.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,\n.dropdown-menu__item a:active,\n.dropdown-menu__item a:focus,\n.dropdown-menu__item a:hover,\n.actions-modal ul li:not(:empty) a.active,\n.actions-modal ul li:not(:empty) a.active button,\n.actions-modal ul li:not(:empty) a:active,\n.actions-modal ul li:not(:empty) a:active button,\n.actions-modal ul li:not(:empty) a:focus,\n.actions-modal ul li:not(:empty) a:focus button,\n.actions-modal ul li:not(:empty) a:hover,\n.actions-modal ul li:not(:empty) a:hover button,\n.admin-wrapper .sidebar ul .simple-navigation-active-leaf a,\n.simple_form .block-button,\n.simple_form .button,\n.simple_form button {\n color: $white;\n}\n\n.dropdown-menu__separator {\n border-bottom-color: lighten($ui-base-color, 4%);\n}\n\n// Change the background colors of modals\n.actions-modal,\n.boost-modal,\n.confirmation-modal,\n.mute-modal,\n.block-modal,\n.report-modal,\n.embed-modal,\n.error-modal,\n.onboarding-modal,\n.report-modal__comment .setting-text__wrapper,\n.report-modal__comment .setting-text {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n}\n\n.report-modal__comment {\n border-right-color: lighten($ui-base-color, 8%);\n}\n\n.report-modal__container {\n border-top-color: lighten($ui-base-color, 8%);\n}\n\n.column-header__collapsible-inner {\n background: darken($ui-base-color, 4%);\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n}\n\n.focal-point__preview strong {\n color: $white;\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar,\n.onboarding-modal__paginator,\n.error-modal__footer {\n background: darken($ui-base-color, 6%);\n\n .onboarding-modal__nav,\n .error-modal__nav {\n &:hover,\n &:focus,\n &:active {\n background-color: darken($ui-base-color, 12%);\n }\n }\n}\n\n.display-case__case {\n background: $white;\n}\n\n.embed-modal .embed-modal__container .embed-modal__html {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n\n &:focus {\n border-color: lighten($ui-base-color, 12%);\n background: $white;\n }\n}\n\n.react-toggle-track {\n background: $ui-secondary-color;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: darken($ui-secondary-color, 10%);\n}\n\n.react-toggle.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background: lighten($ui-highlight-color, 10%);\n}\n\n// Change the default color used for the text in an empty column or on the error column\n.empty-column-indicator,\n.error-column {\n color: $primary-text-color;\n background: $white;\n}\n\n.tabs-bar {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n\n &__link {\n padding-bottom: 14px;\n border-bottom-width: 1px;\n border-bottom-color: lighten($ui-base-color, 8%);\n\n &:hover,\n &:active,\n &:focus {\n background: $ui-base-color;\n }\n\n &.active {\n &:hover,\n &:active,\n &:focus {\n background: transparent;\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\n// Change the default colors used on some parts of the profile pages\n.activity-stream-tabs {\n background: $account-background-color;\n border-bottom-color: lighten($ui-base-color, 8%);\n}\n\n.box-widget,\n.nothing-here,\n.page-header,\n.directory__tag > a,\n.directory__tag > div,\n.landing-page__call-to-action,\n.contact-widget,\n.landing .hero-widget__text,\n.landing-page__information.contact-widget {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-left: 0;\n border-right: 0;\n border-top: 0;\n }\n}\n\n.landing .hero-widget__text {\n border-top: 0;\n border-bottom: 0;\n}\n\n.simple_form {\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n &:hover {\n border-color: lighten($ui-base-color, 12%);\n }\n }\n}\n\n.landing .hero-widget__footer {\n background: $white;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border: 0;\n }\n}\n\n.brand__tagline {\n color: $ui-secondary-color;\n}\n\n.directory__tag > a {\n &:hover,\n &:active,\n &:focus {\n background: $ui-base-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border: 0;\n }\n}\n\n.directory__tag.active > a,\n.directory__tag.active > div {\n border-color: $ui-highlight-color;\n\n &,\n h4,\n h4 small,\n .fa,\n .trends__item__current {\n color: $white;\n }\n\n &:hover,\n &:active,\n &:focus {\n background: $ui-highlight-color;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row,\n .nothing-here {\n border-color: lighten($ui-base-color, 8%);\n }\n}\n\n.activity-stream {\n border: 1px solid lighten($ui-base-color, 8%);\n\n &--under-tabs {\n border-top: 0;\n }\n\n .entry {\n background: $account-background-color;\n\n .detailed-status.light,\n .more.light,\n .status.light {\n border-bottom-color: lighten($ui-base-color, 8%);\n }\n }\n\n .status.light {\n .status__content {\n color: $primary-text-color;\n }\n\n .display-name {\n strong {\n color: $primary-text-color;\n }\n }\n }\n}\n\n.accounts-grid {\n .account-grid-card {\n .controls {\n .icon-button {\n color: $darker-text-color;\n }\n }\n\n .name {\n a {\n color: $primary-text-color;\n }\n }\n\n .username {\n color: $darker-text-color;\n }\n\n .account__header__content {\n color: $primary-text-color;\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-shadow: none;\n background: rgba($error-red, 0.5);\n text-shadow: none;\n }\n\n .recommended {\n border-color: $ui-highlight-color;\n color: $ui-highlight-color;\n background-color: rgba($ui-highlight-color, 0.1);\n }\n}\n\n.compose-form .compose-form__warning {\n border-color: $ui-highlight-color;\n background-color: rgba($ui-highlight-color, 0.1);\n\n &,\n a {\n color: $ui-highlight-color;\n }\n}\n\n.status__content,\n.reply-indicator__content {\n a {\n color: $highlight-text-color;\n }\n}\n\n.button.logo-button {\n color: $white;\n\n svg {\n fill: $white;\n }\n}\n\n.public-layout {\n .account__section-headline {\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 0;\n }\n }\n\n .header,\n .public-account-header,\n .public-account-bio {\n box-shadow: none;\n }\n\n .public-account-bio,\n .hero-widget__text {\n background: $account-background-color;\n border: 1px solid lighten($ui-base-color, 8%);\n }\n\n .header {\n background: $ui-base-color;\n border: 1px solid lighten($ui-base-color, 8%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border: 0;\n }\n\n .brand {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n\n .public-account-header {\n &__image {\n background: lighten($ui-base-color, 12%);\n\n &::after {\n box-shadow: none;\n }\n }\n\n &__bar {\n &::before {\n background: $account-background-color;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n }\n\n .avatar img {\n border-color: $account-background-color;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n background: $account-background-color;\n border: 1px solid lighten($ui-base-color, 8%);\n border-top: 0;\n }\n }\n\n &__tabs {\n &__name {\n h1,\n h1 small {\n color: $white;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n color: $primary-text-color;\n }\n }\n }\n }\n\n &__extra {\n .public-account-bio {\n border: 0;\n }\n\n .public-account-bio .account__header__fields {\n border-color: lighten($ui-base-color, 8%);\n }\n }\n }\n}\n\n.notification__filter-bar button.active::after,\n.account__section-headline a.active::after {\n border-color: transparent transparent $white;\n}\n\n.hero-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.moved-account-widget,\n.memoriam-widget,\n.activity-stream,\n.nothing-here,\n.directory__tag > a,\n.directory__tag > div,\n.card > a,\n.page-header,\n.compose-form .compose-form__warning {\n box-shadow: none;\n}\n\n.audio-player .video-player__controls button,\n.audio-player .video-player__time-sep,\n.audio-player .video-player__time-current,\n.audio-player .video-player__time-total {\n color: $primary-text-color;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/skins/vanilla/mastodon-light/common.js b/priv/static/packs/skins/vanilla/mastodon-light/common.js index 6acf14a24..18bbb7f3b 100644 Binary files a/priv/static/packs/skins/vanilla/mastodon-light/common.js and b/priv/static/packs/skins/vanilla/mastodon-light/common.js differ diff --git a/priv/static/packs/skins/vanilla/win95/common.css b/priv/static/packs/skins/vanilla/win95/common.css index 061d665f0..9e1c6e9e0 100644 Binary files a/priv/static/packs/skins/vanilla/win95/common.css and b/priv/static/packs/skins/vanilla/win95/common.css differ diff --git a/priv/static/packs/skins/vanilla/win95/common.css.map b/priv/static/packs/skins/vanilla/win95/common.css.map index 7edf53558..74f240491 100644 --- a/priv/static/packs/skins/vanilla/win95/common.css.map +++ b/priv/static/packs/skins/vanilla/win95/common.css.map @@ -1 +1 @@ -{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/styles/win95.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss"],"names":[],"mappings":"AAAA,WCwEA,wBACE,+DACA,4ZCrEF,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CCtEW,iED6Eb,kBC7Ea,4BDiFb,sBACE,MErFF,iDACE,mBACA,CACA,gBACA,gBACA,WDXM,kCCaN,6BACA,8BACA,CADA,0BACA,CADA,yBACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBD7CW,kBCiDX,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cD3EoB,mBAPX,WCqFT,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aHlLoB,kKGqLlB,oBAGE,sDAIJ,aH9LgB,eGgMd,0DAEA,aHlMc,oDGuMhB,cACE,SACA,uBACA,cH1Mc,aG4Md,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aD9NY,gBCgOV,gBCnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFrBI,YEuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF9BE,qBEgCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cF7EsB,wBE+EtB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UFxUA,qCE2UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF7UkB,mBE+UlB,kBACA,uHAEA,yBAGE,WFrWA,qCEyWF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFtaoB,8CE2atB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WF1kBF,gBE4kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WFplBJ,gBEslBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aF5lBY,oDEmmBlB,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cFxnBc,aE0nBd,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BFzpBc,wEE+pBd,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFnsBJ,6CEqsBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cFztBgB,uDE4tBhB,oBACE,cF7tBc,qBE+tBd,aACA,gBACA,8DAEA,eACE,WFpvBJ,qCE0vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aFryBU,8DE2yBV,mBACA,WF7yBE,qFEizBJ,YAEE,eACA,cFpyBkB,2CEwyBpB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBF32BK,+IE82BH,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,eACE,kBACA,cJ/EkB,6BIkFlB,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBCjIR,cACE,iBACA,cLeoB,gBKbpB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cLFoB,wBKMtB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBNPI,uBMUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBNVW,aMYT,0BACA,eACA,cNPoB,iBMSpB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aNtCsB,qBMwCpB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,gBACA,eACA,cN7DoB,+BMiEtB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aN1FsB,aM+FxB,YACE,kBACA,mBNxGW,mCM0GX,qBAGF,YACE,kBACA,0BACA,kBACA,cN1GsB,mBM4GtB,iBAGF,eACE,eACA,cNjHsB,iBMmHtB,qBACA,gBACA,UACA,oBAEA,YACE,gBACA,eACA,cN3HoB,0BM+HtB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cNxIoB,qBM0IpB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBNjKW,mCMmKX,cN3JwB,gBM6JxB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cNvMkB,8DM6MpB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eNhPM,CMkPN,cACA,cNlOsB,mBMoOtB,+BANA,iBACA,CNhPM,kCM8PN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UN/PM,eMiQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cNxPoB,qCM4PtB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBN7Qa,kBM+QX,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBN1RO,kBM4RP,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBR9SkB,eQgThB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WNjUE,mBMmUF,gBACA,uBACA,wBAEA,aNvTkB,0BM2TlB,aACE,gBACA,eACA,eACA,cN/TgB,0IMqUlB,UNrVE,+BM6VJ,aACE,YACA,uDAGF,oBR5VkB,wCQgWlB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,cNrYoB,gBMuYpB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WN7aI,8BMgbJ,aACE,cNjakB,gBMmalB,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aNlgBsB,iCMigBxB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cRjiBkB,4JQoiBlB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WNhkBI,gCMkkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MCjlBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WPhDA,cOkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aT/DgB,0BSiEd,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aPpFsB,sBOuFpB,aTjGkB,yBSqGlB,iBACE,kBACA,gBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cPlHoB,iCOqHpB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WPlKA,gBOoKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WPxLE,cO0LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WP9ME,cOgNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,6BAIJ,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WP5RI,cO8RJ,WACA,2CAKE,mBACE,eACA,WPtSA,qBOwSA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WPtUI,cOwUJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,wQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBP7VY,oLOiWZ,iBACE,4WAGF,oBThWkB,mBSmWhB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBTjZkB,WENd,eO0ZJ,oBACA,YACA,aACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,qBACE,gLAGF,qBAEE,kHAGF,wBPpaoB,gGOwapB,kBPtbQ,kHOybN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WPzcI,cO2cJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cTneY,oBSqeZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,8DACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UPvhBF,aOiiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cPzhBsB,kBO2hBtB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cPjjBY,sBOqjBd,mCACE,+BACA,cPtjBQ,kBO0jBV,oBACE,cP7iBoB,qBO+iBpB,wBAEA,UPjkBI,0BOmkBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBPzkBS,WATL,eOqlBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aP1mBsB,qBO4mBpB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aTxoBoB,qBS0oBlB,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cP7oBsB,oCOgpBtB,cACE,mBACA,kBACA,4CAGF,aPrpBwB,gBOupBtB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBP7rBM,YO+rBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cP1rBwB,WO4rBxB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WPzuBI,qCO2uBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UAEE,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cPhxBsB,0BOmxBtB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WP7yBI,kBO+yBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aPvzBc,0SOi0BZ,+CACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBP32Bc,gBO62BZ,2BAEA,kBP/2BY,gBOi3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SCl7BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WR7EA,gBQ+EA,gBACA,uBACA,+BAGF,aACE,eACA,cRtEgB,gBQwEhB,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WR3GI,gBQ6GJ,qBACA,iBACA,qBACA,sBAGF,eRnHM,oBQqHJ,cR5GS,eQ8GT,cACA,kBAGF,cACE,uCAGF,aR9GwB,oBQmHxB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA7DF,iBA8DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBRlKa,mCQoKX,cR7JsB,eQ+JtB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cR3LwB,sCQ6LxB,sCACA,6DAEA,aRhNc,sCQkNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cR1OsB,0BQ4OtB,6BAGF,aACE,cRjPoB,4BQqPtB,aV/PoB,qBUiQlB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aRnRY,gBQqRV,0CAGF,aRxRY,wCQ6Rd,eACE,wCAIJ,UACE,0BAIA,aRxRsB,4BQ2RpB,aR1RsB,qBQ4RpB,qGAEA,yBAGE,iCAIJ,URtTI,gBQwTF,wBAIJ,eACE,kBC/TJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBTpBW,6GSuBT,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBXzEoB,WENd,oBSkFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UT5FI,gFSgGN,kBAGE,qNAKA,kBTxFoB,4ISgGpB,kBT9GQ,qCSqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,cAGF,aACE,eACA,iBACA,cbAoB,SaEpB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aXFsB,eWIpB,SAIJ,wBblBsB,YaoBpB,kBACA,sBACA,WX5BM,eW8BN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBXxDQ,gBW4DN,mCAIJ,wBXlDsB,eWqDpB,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,aXpFW,mBAOW,qGWiFpB,wBAGE,8BAIJ,kBbpGgB,2GauGd,wBAGE,0BAIJ,aXlGsB,uBWoGpB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,CACA,ab9IgB,SagJhB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,oCACA,4BACA,2CACA,oBAGF,kCACE,uBAGF,aACE,6BACA,eACA,qBAGF,abxKoB,gCa4KpB,QACE,uEAGF,mBAGE,uBAGF,abxLgB,sFa2Ld,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,abzMkB,uCa4MhB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBb5NY,QamOhB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,aACE,6BACA,eACA,0BAGF,abhQoB,qCaoQpB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,abrTkB,sDayTlB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBb1UgB,yDaiVpB,aX9UW,mBWgVT,mBXzUoB,oCW2UpB,iBACA,kBACA,eACA,gBACA,6CAEA,aXxVS,gBW0VP,CAII,kRADF,eACE,wCAKN,abxWc,gBa0WZ,0BACA,yIAEA,oBAGE,sCAKN,iBACE,QACA,UACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,cXlZS,gBATL,aW8ZJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,abjac,Ca+Zd,sHAEA,abjac,Ca+Zd,8HAEA,abjac,Ca+Zd,gIAEA,abjac,Ca+Zd,4GAEA,abjac,+Faqad,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBXncsB,0BWqctB,cX7cS,eW+cT,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,abnhBgB,wCauhBhB,aXlhBW,oBWohBT,eACA,gBX9hBI,sEWiiBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cXziBgB,eW2iBhB,gBACA,aACA,oBACA,6QAEA,aAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cXzkBgB,SW2kBhB,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UXpmBF,8GWwmBE,WACE,cXxlBc,CAjBlB,oGWwmBE,WACE,cXxlBc,CAjBlB,wGWwmBE,WACE,cXxlBc,CAjBlB,yGWwmBE,WACE,cXxlBc,CAjBlB,+FWwmBE,WACE,cXxlBc,iFW6lBlB,SACE,wEAKN,iBACE,sBXtnBE,wBWwnBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,mBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cbrrBgB,4CawrBhB,aXzrBY,kCW8rBd,2CACE,WCpsBF,8DDysBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBXpsBsB,aWssBtB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,aX7tBa,cW+tBX,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WXlwBM,wDWqwBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aX1xBc,qBW4xBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,abxzBc,8Ea6zBhB,aACE,0GAGF,kBbj0BgB,sHao0Bd,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,cXh3BW,gBWk3BX,eACA,cACA,iBACA,eACA,sBACA,4BAGF,ab/3BkB,Sai4BhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aX37BoB,CAPX,uEW28BP,aX38BO,kCW+8BP,aXx8BkB,gCW68BpB,aXp9BS,kCWu9BP,ab19BgB,gEa89BhB,UXp+BE,mBAgBgB,sEWw9BhB,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,abt/BkB,Yay/BhB,eACA,uBAGF,ab7/BkB,qCaigClB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cbjjCgB,CamjChB,iBACA,eACA,kBACA,+CAEA,abxjCgB,uBa4jChB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cbrlCY,4Ba2lClB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cbnpCgB,eaqpChB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,aX1qCa,eW4qCX,6BAEA,abnrCgB,SawrClB,YACE,gCACA,8BAEA,aACE,cACA,WXlsCI,qBWosCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cXntCoB,gBWqtCpB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBEtvCE,iCACA,wBACA,4BACA,kBFqvCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBEhwCA,iCACA,wBACA,4BACA,kBF+vCE,gBACA,kBACA,eACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WXjxCE,6BWmxCF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCEvxCrB,+BFyxCA,iBElyCA,iCACA,wBACA,4BACA,WFiyCuB,sCE3xCvB,kCF8xCA,iBEvyCA,iCACA,wBACA,4BACA,WFsyCuB,sCEhyCvB,kBFkyCE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cbrzCgB,6BawzChB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,eACA,cXv3CoB,kCW23CtB,aACE,eACA,gBACA,WX94CI,CWm5CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UXn7CM,kBWy7CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aXr8C0B,cWu8CxB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WXr+CI,kCW0+CR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,Cbz/CgB,gHamgDhB,abngDgB,wBaugDhB,UACE,wCAGF,kBb3gDgB,cEKL,8CW0gDT,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cX7gDsB,eW+gDtB,iBACA,kBACA,4BAEA,ab7hDoB,6BaiiDpB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CX5iDU,mEWmjDZ,aXnjDY,uBWujDZ,aXxjDc,4DW8jDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UXllDM,0BWolDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cXzkD4B,eAEC,0DW0kD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cXjmD4B,eAEC,WWkmD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cXlpDsB,wBWqpDtB,aXppDwB,mBWwpDxB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBXrtD0B,cWutDxB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BX1vDsB,2BW8vDxB,WACE,iBACA,uBACA,yBXjwDsB,8BWqwDxB,QACE,iBACA,uBACA,4BXxwDsB,6BW4wDxB,SACE,gBACA,2BACA,2BX/wDsB,wBWqxDxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBX3xDsB,cARb,gBWsyDT,uBACA,mBACA,yFAEA,kBb7yDkB,cEWI,UWuyDpB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBXrzDsB,cWuzDtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBX90DsB,cARb,gBWy1DT,uBACA,mBACA,oDAEA,SACE,oDAGF,kBbp2DkB,cEWI,iBWg2D1B,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBX78DW,8BW+8DT,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cXh+DsB,qBWk+DtB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WXxiEM,qBW0iEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cb3jEkB,sBa+jEpB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WX/uEM,kBWivEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBbpzEkB,yBaszElB,gBACA,kBACA,eACA,gBACA,iBACA,WXj0EI,mDWs0ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBXx2EI,0BW02EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBX/5EW,0BWo6Eb,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,WACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cbz/EoB,ea2/EpB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cb9gFoB,eaghFpB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,CACA,OACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBXhlFW,qCWklFX,sEAGF,wBACE,4CAGF,wBb5lFsB,+EagmFtB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBbxpFoB,ca4pFtB,kBACE,WXnqFM,cWqqFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cXpqFsB,kGWuqFtB,sBAGE,WX3rFE,kCW+rFJ,abzrFkB,oBa+rFtB,oBACE,iBACA,qBAGF,oBACE,kBACA,eACA,iBACA,gBACA,mBXtsFW,gBWwsFX,iBACA,oBAGF,kBX5sFa,cFLK,iBaotFhB,eACA,gBACA,eACA,yDAGF,kBXrtFa,cW2tFb,aACE,kBAGF,abpuFkB,casuFhB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,ablwFY,0BaowFV,sDAIJ,oBACE,cX7vFkB,sMWgwFlB,yBAGE,oDAKN,abpxFgB,0Ba0xFhB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,cXrxFkB,aWuxFlB,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA1BF,YA2BI,yCAGF,eACE,aACA,iDAEA,aXhzFkB,qBWuzFxB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,cXv1FW,gBATL,aWm2FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,abn4FkB,6Baq4FhB,uDAGF,abt4FsB,ca04FtB,YACE,eACA,yBACA,kBACA,cbh5FgB,gBak5FhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cX/5FoB,uBWi6FpB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UXz7FE,yBWg8FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cXn9FsB,gBWq9FtB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aXj+FwB,oBWq+FxB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cb9jGgB,6BagkGhB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cbxlGgB,mBEKL,eWslGX,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cbtnGY,qCa0nGd,cACE,gBACA,yBAKN,iBACE,cACA,uCAGE,aACE,WACA,kBACA,SACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,4EACA,gBAKN,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,abhrGoB,uBaorGpB,mCACE,4CAEA,abvrGkB,sCayrGhB,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cXxsGsB,eW0sGtB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UXtuGI,mBWwuGF,6BAKN,eACE,gBACA,gBACA,cXhuGsB,0DWkuGtB,UACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aX7vGsB,0BW+vGpB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,ab3yGkB,ea6yGhB,gBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBXz6GM,WACA,eW26GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eXv7GQ,cAiBgB,SWy6GtB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WXt/GE,gBWw/GF,eACA,+LAMA,yBACE,mEAKF,yBACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aXphHwB,eWshHtB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtiHF,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,+EFgiHI,aACE,CEjiHN,qEFgiHI,aACE,CEjiHN,yEFgiHI,aACE,CEjiHN,0EFgiHI,aACE,CEjiHN,gEFgiHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,abxkHc,iBa0kHZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aXrlHoB,0HW0lHpB,cAEE,gBACA,cbzmHY,kZa4mHZ,aAGE,gEAIJ,wBACE,iDAGF,eX3nHI,kBa0BN,CAEA,eACA,cbbsB,uCaetB,UF8lHI,mBX5mHoB,oDagBxB,abjBsB,eamBpB,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,cbxCS,sDWwnHT,WACE,mDAGF,aX5nHS,kBW8nHP,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UXxpHQ,kBW0pHN,cACA,mBACA,sBX7pHM,eW+pHN,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aX9pHwB,qBWgqHtB,mBACA,gBACA,sBACA,uCAGF,ablrHkB,mBEKL,kBWirHX,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,sCAdF,cAeI,kDAGF,eACE,2CAGF,abtsHoB,qBawsHlB,uDAEA,yBACE,eAKN,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eX/xHQ,kBWiyHN,sCACA,kBACA,eAEA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBX3zHM,kBW6zHN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBXp3HW,kCWs3HX,uBAGF,MACE,aACA,mBACA,uBACA,cXr3HwB,eWu3HxB,gBACA,0BACA,kBACA,kBAGF,YACE,cb34HgB,gBa64HhB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBbn6HgB,kBaq6HhB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBb36HoB,kBa66HpB,eAGF,aACE,eACA,iBACA,gBACA,WACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,CACA,UACA,YACA,eACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBX99HM,uCWg+HN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,aXr+Ha,aWu+HX,eACA,aACA,kEAEA,kBb9+HoB,WENd,UWw/HJ,CXx/HI,4RW6/HF,UX7/HE,wCWmgIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cb5gIgB,2Ca+gIhB,eACE,cACA,cX5gIS,CWihIL,wQADF,eACE,mDAON,eXjiIM,0BWmiIJ,qCACA,gEAEA,eACE,0DAGF,kBbpiIkB,uEauiIhB,UX7iIE,uDWmjIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SErjIE,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,cF+iIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,sCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cXrmIsB,eWumItB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cbxoIc,ea0oId,uCAEA,uBACE,sCAGF,aACE,yBAKN,abtpIkB,mBawpIhB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cb9qIc,iCairId,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cXlrIwB,qBWorIxB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cXhsIsB,kBWksItB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cX7tI0B,eAEC,CWuuI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,WXzzIM,eW2zIN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cXt1IsB,mFWy1ItB,yBAGE,wBAKN,oBACE,sBAGF,qBXt3IQ,YWw3IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBb33IsB,qBa+3ItB,iBACE,UACA,QACA,YACA,6CAGF,kBX33I0B,cARb,kBWw4IX,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,ab/7IgB,Sak8Id,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,aX98IS,qwDWk9IP,aAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,cXr+IS,kBWu+IT,eACA,qBAGF,kBX3+IW,cAQa,gBWs+ItB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,cXjgJW,kBWmgJX,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,aACE,wBAKF,eXviJM,CAiBkB,gBWyhJtB,oBACA,iEX3iJI,2BAiBkB,yBWkiJ1B,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBXjjJwB,aWmjJxB,iBACA,2HAEA,aACE,iBACA,cbrkJc,mBaukJd,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,abjoJoB,iLaqoJpB,aXloJW,qCWuoJX,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,aXjqJS,gBATL,aW6qJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eX7rJI,yBW+rJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,aXpsJO,oBWssJL,eACA,gBXhtJA,+CWqtJJ,YACE,8BACA,mBACA,4CAIJ,aACE,cXptJS,eWstJT,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,aX/tJS,eWiuJP,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,aX3wJO,aW6wJL,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBb/xJY,WENd,uDW4yJA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cb5zJgB,ea8zJhB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,aXv2JS,CWy2JP,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBb33JsB,Wa63JpB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WX54JM,0BW84JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,2DAKE,YACE,wDAKF,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cbv7Jc,iBay7Jd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cbr9JY,gBau9JZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,abx+Jc,gBag/JhB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cX//JwB,kBWigKxB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBX5hKM,CW6hKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBXxiKM,iCW2iKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,iCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBXxoKM,eW0oKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBXrtKI,cAiBgB,gBWusKpB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UXpxKE,+EW4xKN,cAGE,gBACA,6BAGF,UXnyKM,iBWqyKJ,yBAGF,oBACE,aACA,mDAGF,UX7yKM,uBWkzKN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WXl2KE,sFWq2KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WX5/KF,gBW8/KE,gBACA,uBACA,0CAGF,aACE,eACA,cXr/Kc,gBWu/Kd,gBACA,uBACA,yBAKN,kBXrgLS,aWugLP,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cX7kLoB,eW+kLpB,eACA,gBACA,kBACA,qBACA,kBACA,yJAEA,aXrlLsB,qWWwlLpB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBXzoLa,sBW4oLX,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eXvsLQ,kBa0BN,CACA,sBACA,gBACA,cbbsB,uCaetB,mBAEA,abjBsB,eamBpB,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,cbxCS,UWksLb,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cXrsLsB,gBWusLtB,gBAEA,abptLkB,0BastLhB,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBX70LO,WATL,eWy1LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cbh4Lc,Cam4Ld,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,cAGF,kBX37La,sBW67LX,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBXj/La,sBWm/LX,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBX3iMM,yDW8iMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBXtjMI,uBW0jMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UXvlMI,eWylMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aXjmMwB,eWmmMtB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WXruMA,gBWuuMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cX9tMc,gBWguMd,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WXlwME,gDWswMJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aXjxMU,yBWuxMd,cACE,gCAEA,cACE,cX5wMkB,eW8wMlB,kCAEA,oBACE,cXjxMgB,qBWmxMhB,iBACA,gBACA,yCAEA,eACE,WXxyMF,iBWizMN,ab7yMgB,mBa+yMd,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cbv0MY,gBay0MZ,uBACA,mBACA,4BAEA,eACE,uBAGF,aXr0MkB,qBWu0MhB,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cX/1MoB,0BWm2MtB,aACE,WACA,2CAEA,gCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,uBAGF,oBACE,mBbj5MkB,kBam5MlB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cX75MkB,kBW+5MlB,+BAGF,aXl6MoB,eWo6MlB,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UX57ME,qBW87MA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UXx9MI,OcFR,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,MACA,OACA,YACA,qBACA,kBACA,mBACA,sBAEA,kBhBVkB,agBepB,iBACE,aACA,cACA,iBACA,eACA,gBACA,gEAEA,YAEE,gCAGF,aACE,8BAGF,aACE,sBACA,WACA,eACA,cdjCO,UcmCP,oBACA,gBd7CE,yBc+CF,kBACA,iBACA,oCAEA,oBhB7CgB,wBgBkDlB,cACE,sBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBdtFY,8Ec2FZ,gBAGE,gBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,CACA,oBACA,iBACA,gBACA,mBACA,cACA,mBAGF,UACE,iBACA,eAGF,eACE,mBACA,chB7Hc,agBiIhB,cACE,uBACA,UACA,SACA,SACA,chBtIc,0BgBwId,kBACA,mBAEA,oBACE,sCAGF,kCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBhB5KgB,sDgBkLpB,cACE,gBACA,iBACA,YACA,oBACA,chBzLc,sCgB4Ld,gCAGF,YACE,mBACA,4CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,cdxMS,qBc0MT,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,ahB/NkB,qBgBkOhB,+BACE,6BAEA,2BACE,eC5ON,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,chBSW,2BgBNX,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBhBHsB,4BgBOxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,clB/BgB,ckBiChB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,alBhDsB,mCkBmDpB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBlBjEoB,uBkBsEpB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBhB5FM,sBgB8FN,sGAEA,+BAEE,oBAKF,2BACA,gBhBxGM,0BgB2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,chBzGS,yBgB2GT,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBhBpKI,mBgByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,chBvKsB,mDgB0KtB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,cjBJsB,oBiBOtB,anBjBoB,0BmBmBlB,6EAEA,oBAGE,wCAIJ,ajBlBsB,oBiBuBtB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cjBhCsB,qBiBoCxB,iBACE,cjBrCsB,uBiByCxB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,cjBzDsB,qBiB6DxB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cjBrIkB,iCiByIpB,uBACE,gBACA,gBACA,cnBxJY,qDmB4Jd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WjBpNI,iBiBsNJ,kBACA,qEAEA,aAEE,6CAIA,ajB9MoB,oCiBmNtB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,cjB/OkB,mBiBiPlB,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WjBzSA,qBiB2SA,uDAGE,yBACE,2CAKN,aACE,cjBrSgB,kCiB6StB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,cjBpToB,sCiBuTpB,anBjUkB,0BmBmUhB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,cjB7UsB,wBiBgVtB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,cjB9VsB,kBiBmWtB,cjBnWsB,mCiBkWxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBvWsB,kBiB4WtB,cjB5WsB,kBiBqXtB,cjBrXsB,mCiBoXxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBzXsB,kBiB8XtB,cjB9XsB,mCiBsYxB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,0CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBjB1bW,kBiB4bT,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBjBnjBS,kBiBqjBT,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,ajB3kBsB,qCiB+kBtB,eACE,WjBjmBE,gBiBmmBF,2CAEA,ajBrlBkB,gDiBwlBhB,ajBvlBkB,+CiB6lBtB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SjBvrBI,YiByrBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,gBACA,eACA,cjBhsBkB,6BiBosBpB,eACE,iBACA,+BAGF,kBjBhtBS,aiBktBP,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,cjB/uBgB,uFiBqvBtB,eACE,cASA,CjB/vBoB,2CiB4vBpB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cjB51BsB,qBiB81BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cnBh3Bc,SoBNlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBlBhBW,UkBqBX,apBxBoB,0BoB0BlB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBlBzDS,6BkB2DP,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,clB5FsB,gBkB8FtB,0DAEA,UlBhHM,wDkBoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBlBvJS,sBkByJT,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBlBtKS,gCkByKT,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBlB9LS,uCkBiMP,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,clB/NgB,gBkBiOhB,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBnBPO,YmBSP,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SnBxCA,YmB0CE,kBACA,YACA,uCAIJ,aACE,cnBjCgB,qBmBmChB,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cnB3EgB,qBmB6EhB,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UnBzGA,yBmB2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UnBjIE,yBFMc,gBqB8Hd,gBACA,mEAEA,qBACE,6DAKN,yBACE,iCAKF,UACA,gBAEA,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,anBnMsB,emBqMpB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,anB9MsB,emBgNpB,iBACA,gBACA,mBACA,4BAGF,cACE,gBACA,cnBzNkB,mBmB2NlB,kBACA,gCACA,4BAGF,cACE,cnBhOoB,iBmBkOpB,gBACA,0CAGF,UnBvPI,gBmByPF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WnBvQE,oBmByQF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cnBhQoB,mBmBkQpB,kCAEA,UnBrRE,gBmBuRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,0CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA7SF,aA8SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BrB5UgB,YqBmVtB,UACE,SACA,cACA,WACA,sDAKA,anBlVsB,0DmBqVpB,arB/VkB,4DqBoWpB,anBzWc,gBmB2WZ,4DAGF,anB7WU,gBmB+WR,0DAGF,arBhXgB,gBqBkXd,0DAGF,anBrXU,gBmBuXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,eACA,CAII,iNADF,eACE,2BAKN,oBACE,cnBjZkB,qBmBmZlB,eACA,gBACA,gCACA,iCAEA,UnBxaE,gCmB0aA,oCAGF,arBvagB,gCqByad,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cnBrdsB,CmB0dlB,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,arB/iBoB,qBqBijBlB,oBAEA,yBACE,SAKN,aACE,YAGF,kBACE,iBACA,oBAEA,YACE,2BACA,mBACA,aACA,mBnBlkBS,cAOW,0BmB8jBpB,eACA,kBACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,arBnmBgB,oBqBumBhB,kBACE,0BACA,aACA,cnB9lBoB,gDmBgmBpB,eACA,qBACA,gBACA,kBAGF,cACE,kBACA,crBpnBc,2BqBwnBhB,iBACE,SACA,WACA,WACA,YACA,kBACA,oCAEA,kBnBnoBY,oCmBuoBZ,kBACE,mCAGF,kBrBtoBkB,sDqB2oBpB,anBhoBwB,qBmBooBtB,gBACA,sBAGF,aACE,0BAGF,anB5oBwB,sBmBgpBxB,anBhqBc,yDmBqqBhB,oBAIE,cnBzpBwB,iGmB4pBxB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBnBrtBc,yBmBytBd,yBACE,wBAGF,yBnB1tBU,wBmB+tBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,anB3tBoB,uBmBiuBpB,wBACA,qBAGF,arBjvBgB,cqBsvBlB,kBnBjvBa,kBmBmvBX,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cnBhwBkB,iBmBkwBlB,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,anB7xBM,6BmBoyBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cnBpyBgB,mLmBuyBhB,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,arB/zBU,iBqBi0BR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cnB/zBgB,WmBs0BxB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,anBn4BY,8CmBw4Bd,qBACE,aACA,WnB34BI,cmBg5BR,iBACE,sBCn5BF,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WpBrCI,6CoBuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,cpBjCoB,kBoBmCpB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,apBrDwB,gBoBuDtB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,+DACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,sCxB6EF,QACE,qBACE,gBACA,SAGF,SACE,gBACA,gBACA,+BAIJ,eAEE,sBACA,kBACA,gBACA,kBACA,kCACA,4BACA,gEAGF,gBAEE,uBACA,2BACA,qBAGF,eACE,UACA,wDAGF,qBAEE,sBACA,MAKF,cACE,oDACA,WACA,kCAGF,eAGE,cAGF,UACE,sBACA,WAGF,kBAzIW,sGAmBT,gBAIA,YAqHA,iBAGF,UACE,CAEA,oBACA,CADA,mBACA,CADA,4BACA,WACA,YACA,wBAGF,qGA7GE,eAIA,gBACA,WA0GA,mCAGF,eACE,WACA,gBACA,eACA,UACA,cACA,kBACA,QACA,4BAGF,iBACE,0BACA,YACA,cA3KS,yDA8KT,4BACA,uBACA,4BACA,yBACA,wBAGF,gBACE,eACA,mBAvLS,eA2LX,aACI,YACA,kBAEA,YACA,UACA,YACA,aACA,iGACA,4BACA,iCACA,UACA,gBAGJ,eACE,UACA,6BAGF,SACE,SAGF,gBACE,qBAGF,kBAvNW,CAcT,eACA,6CA2MA,CA3MA,kBA2MA,CA3MA,sBA2MA,CAMA,uCAHF,UACE,gBACA,mBAYA,CAXA,eAGF,WACE,eACA,CAvNA,eACA,6CAyNA,CAzNA,kBAyNA,CAzNA,sBAyNA,CAEA,oBACA,gCAGF,kBA3OsB,uCA+OtB,YACE,uBAEF,gBACE,mBAnPoB,4CAuPtB,UACE,yBAGF,eACE,eACA,wBAGF,kBAnQW,WAqQT,cACA,eACA,gBACA,cACA,eACA,sGAvPA,gBAIA,8BAsPA,UACE,mEAIJ,qGAvOE,eAIA,gBACA,yBAoOA,6BAMA,eACA,eAIA,iDARF,kBAvRW,WAyRT,YACA,CAEA,qGAzQA,gBAIA,eAuQA,gBAUA,kCAGF,iBACE,UACA,UACA,gBACA,eACA,cACA,2BAGF,cACE,gBACA,6BAGF,8BACE,gCACA,mCAGF,kBA9TW,WAgUT,oCAGF,UACE,oDAGF,yBACE,kBACA,kBACA,YACA,qBAGF,wBA9UW,CAcT,eACA,CAkUA,4CACA,CADA,kBACA,CADA,kBACA,2BAGF,UACE,gBACA,eACA,kBACA,UACA,SACA,yBA3VS,qBA6VT,cACA,gBACA,6CAGF,UACE,gBACA,kCAGF,WACE,iCAEF,WACE,iBAGF,gBACE,mCAIA,qBACA,CAvVA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,WAwVA,YACA,sEAGF,qBACE,yCAGF,QACE,iBACA,kDAGF,SACE,qCAGF,YACE,mCAGF,eACE,aACA,WAGF,wBAjZW,sGAmBT,gBAIA,YA6XA,iBAGF,oBACE,WACA,CAzWA,+BA4WF,qGAjXE,eAIA,gBAsXA,CArXA,cAgXF,UACE,sBACA,CAlXA,cAoXA,YACA,+FAGF,UAEE,gCACA,CAIA,iIAGF,gBACE,oBAGF,wBAtbW,WAwbT,sGAraA,gBAIA,wBAqaF,0lBACE,wBAEA,uCAGF,kBAlcW,WAqcT,kBAGF,yBACE,WACA,SAraA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,sBAwaA,CACA,mBACA,mBACA,uBAGF,wBArdW,kBAydX,cACE,uEAGF,aAEE,qBAGF,qBACE,kBACA,YACA,UACA,mBAteS,uBAweT,CAEA,eACA,iCACA,8BACA,iBACA,sCAGF,qBACE,4BAGF,WACE,8BAGF,gBACE,kBACA,2CAEA,cACE,kCAGJ,UACE,CAlgBS,+DAugBT,kBAvgBS,4DA2gBT,eACE,wBACA,gCAIJ,iBACE,oDAGF,cACE,kBAGF,eACE,4BACA,WACA,0BACA,YACA,gCAGF,aACE,uCAGF,UACE,gBACA,0GAlgBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,uBAugBA,CAvgBA,cAugBA,6CACA,CADA,oCACA,8BAGF,wBAljBW,SAojBT,iCACA,kBACA,mBACA,iBACA,cAEF,kBA1jBW,CAaT,4CACA,CADA,kBACA,CADA,gBACA,gBACA,UA8iBA,iBAGA,0HAFA,aAIE,qBAriBF,4CACA,CADA,kBACA,CADA,gBACA,gBACA,kCA2iBF,kBACE,eACA,sDAGF,sBAEE,YACA,CEjlBU,2IFslBV,aEtlBU,0BF2lBZ,kBA5lBW,CAaT,4CACA,CADA,kBACA,CADA,gBACA,gBACA,mBAglBA,iCAlkBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,mBAukBF,aArmBkB,uCAymBlB,gBACE,sBACA,mBACA,0BAGF,aACE,uCAGF,gBACE,mBACA,cAGF,eACE,gBACA,sBACA,WACA,oBAGF,qBACE,qBAGF,UACE,0BACA,gBACA,YAGF,UACE,gBACA,CA5oBS,qGAmBT,gBAIA,CAwnBA,eACA,6BAJA,kBA5oBS,CAuBT,UA6nBE,2BAIJ,UACC,4DAGD,UACE,gBACA,iCAGF,UACE,UAGF,gCACE,0GAGF,kBAzqBW,sGAmBT,gBAIA,sHAupBF,kBA9qBW,wHAkrBX,qGAvoBE,eAIA,gBACA,gDAsoBF,UACE,eAGF,yBACE,WACA,wBAGF,UACE,eACA,6BAGF,eACE,iBAGF,kBAxsBW,CAaT,+BACA,gBACA,qBA4rBA,gBACA,mBACA,6CACA,CADA,+BACA,CADA,gBACA,cAGF,UACE,sGA/rBA,gBAIA,YA6rBA,WACA,cACA,iCAGF,eACE,WACA,gBACA,eACA,UACA,cACA,kBACA,QACA,0BAIF,iBACE,iBACA,WACA,YACA,cAzuBS,yDA4uBT,4BACA,uBACA,4BACA,yBACA,yBAGF,4BACE,qCAGF,8YACE,4BACA,uBACA,4BACA,yBACA,iBACA,SAOF,kBApwBW,CAswBT,WACA,iCACA,CACA,oBACA,CADA,iCACA,CADA,sBACA,gBACA,eAIA,UACA,CA3uBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,oCAuuBF,qBAOE,gBAGF,gBACE,WACA,gBACA,sBAvxBqB,sBAyxBrB,mBAEA,UACE,oBACA,gBACA,yBAIJ,wBAtyBW,WAwyBT,iCACA,0BAGF,UACE,sOAGF,wBA7yBsB,WAkzBpB,mBAGF,UACE,0BAEA,SACE,yBAGF,UACE,sCAIJ,wBAp0BW,CAu0BT,yBACA,CADA,2BACA,iBAGF,UACE,wBAGF,UACE,gBACA,mFA5yBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,+CAmzBF,eACE,gCAGF,eACE,gCACA,mBACA,+BAGF,6BACE,+BACA,8CAGF,wBAz2BW,0BA22BT,eACA,gBACA,wBAGF,wBAh3BW,gBAk3BT,iBACA,kCAGF,8BACE,4HAGF,kBA13BW,uEA+3BX,aA93BkB,mDAk4BlB,kBAn4BW,iBAs4BT,yGAGF,kBAt4BsB,4LA24BtB,gBAKE,WACA,sGAj4BA,gBAIA,mBAvBS,oCAy5BX,UACE,2CAGF,eACE,+BAGF,cACE,gBACA,cACA,kBACA,UACA,yBAt6BS,eAw6BT,cACA,wBAGF,iBACE,iBACA,0BACA,yBA/6BS,WAi7BT,0BAGF,UACE,+BAGF,UACE,0BACA,iDA75BA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,mBAg6BA,cACA,iCAGF,eACE,0BACA,yBAr8BS,YAu8BT,sBACA,8CAGF,cACE,gBACA,2BACA,qDAGF,WACE,eACA,gBACA,WACA,gDAGF,YACE,8BAGF,SACE,2BAGF,gBACE,gBACA,yBAl+BS,sBAo+BT,uBACA,6BAIF,UACE,sBACA,CACA,qGAj8BA,eAIA,gBACA,qCAg8BF,gCACE,qCAGF,UACE,gBACA,kBAGF,wBAz/BW,kBA2/BT,0BACA,SA5/BS,qGAmBT,CAIA,eA2+BA,WACA,gBACA,sDALF,wBA//BW,gBA0gCT,qGA/9BA,eAIA,gBACA,kBA89BA,UACE,8BACA,yBAEA,qGA//BF,gBAIA,kBAkgCF,wBAzhCW,sGA2CT,CAIA,eACA,eA4+BA,yBAGF,eACE,WACA,gBACA,eACA,UACA,kBACA,cACA,kBACA,UACA,kBAGF,iBACE,iBACA,WACA,YACA,cA/iCS,+YAkjCT,4BACA,uBACA,4BACA,yBACA,oBAGF,wBAzjCW,WA2jCT,iCACA,oBACA,eACA,cAGF,4BACE,WACA,oBACA,wBAjkCoB,WAmkClB,8CAKF,WACE,SACA,UACA,wCAMA,iBACA,qFAJF,yBACE,4BACA,6BAOE,0CAGF,WACE,WACA,CAMJ,4FACA,yDAGA,wGACA,yDAGA,wEACA,yDAGA,gFACA,yDAGA,sEACA,0DAGA,0FACA,0DAGA,gGACA,0DAGA,wEACA,0DAGA,sEACA,0DAGA,4FACA,0DAGA,wEACA,0DAGA,8EACA,mFAGF,YACE,kCAGF,qBACE,gBACA,eACA,WACA,iBACA,kBACA,mBACA,OAEA,aACA,cACA,kBACA,yBACA,WACA,YACA,CAIA,wBACA,0BACA,2BAjqCA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,yBAfS,yCAsrCX,YACE,yBAGF,wBA1rCW,kBA8rCX,wBACE,4CAGF,UACE,6BAGF,yBACE,WACA,YACA,iBAGF,wBA5sCW,SA8sCT,8BACA,8CAGF,UACE,YACA,gCAGF,UACE,gBACA,kCAGF,UACE,sBAGF,YACE,2BAGF,yBACE,kCAGF,qGA7rCE,eAIA,gBACA,wDA4rCF,eAxuCuB,gBA2uCrB,sBACA,iBACA,kBAGF,4BACE,yBAGF,YACE,gCAGF,UACE,sGAltCA,eAIA,gBACA,8CAitCF,sBACE,oDAGF,sBACE,WACA,0BACA,0CAGF,oBAGE,2FAGF,UACE,4GAGF,qBAII,qBACA,sBACA,yCAGJ,eACI,8BAGJ,iBACI,SACA,iDAGJ,iBACI,SACA,mCAGJ,kBACI,uBAGJ,iBACI,0BAGJ,iBACI,kBAGJ,iBACI,0BAGJ,iBACI,SACA,2GAGJ,qGA9yCE,gBAIA,mBAvBS,2FAu0CX,sBAIE,cACA,mBAz0CoB,WA20CpB,gBACA,iBACA,qBAGF,4BACE,0CAGF,eACE,gBACA,oFAGF,kBA51CW,iBA81CT,sDAGF,kBA91CsB,WAg2CpB,gBACA,YACA,eACA,gBACA,CAKE,+RAEA,UAGE,CAj0CJ,gMAo0CE,qGAz0CF,eAIA,gBACA,uHA00CF,eAEE,WA50CA,2CAi1CF,oQAEE,uBAGF,iBACE,MACA,wBACA,WACA,yBAv4CoB,eAy4CpB,gBACA,WACA,WACA,cACA,CACA,wBACA,sBACA,gBAGF,iBACE,mBAv5CS,sGAmBT,gBAIA,WAm4CA,YACA,iBACA,WACA,iBACA,sBACA,gBACA,sCAGF,eACE,UACE,YACA,kBACA,sCAIJ,eACE,WACE,YACA,0BACA,SACA,kCAIJ,eACE,YACA,cACA,WACA,iCAGF,aACE,wBACA,CAh7CA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,kBAg7CA,iBACA,kBACA,mBACA,sBACA,yBAGF,wBAt8CW,WAw8CT,eACA,gBACA,sBACA,kBACA,yBAGF,eACE,mBAh9CS,WAk9CT,WACA,YACA,oBACA,+BAGF,iBACE,QACA,SACA,WACA,YACA,SACA,4BAGF,kBAj+CW,CAm+CT,gBACA,WACA,+BAEA,oBACE,4EAEA,WAEE,2BACA,sCAt9CJ,UA69CI,wEAJF,iBACE,sGA99CJ,gBAIA,CA49CI,WASA,CARA,kCAGF,oBACE,CAEA,SAEA,iCAGF,oBACE,qIA58CJ,gBAMA,2BACA,4BACA,gBAs8CI,SACA,WACA,wBACA,0CAEA,kBAvgDK,WAygDH,gBACA,mBACA,uCAGF,kBA9gDK,WAghDH,kCAIJ,uBACE,uBACA,kBACA,UACA,SACA,UACA,qCAEA,kBA5hDK,qBA8hDH,wBACA,uCAEA,kBAjiDG,qIAoDT,gBAMA,2BACA,4BACA,WAw+CQ,gBACA,kBACA,UACA,gDAEA,kBAziDC,WA2iDC,mBACA,gBACA,kBACA,iBACA,kBACA,kBACA,UACA,4DAEA,aACE,sDAGF,sBACE,WACA,6CAIJ,kBA9jDC,WAgkDC,sCAQZ,iCACE,gBACE,yBAGF,mBACE,sCAIJ,iCACE,eACE,yBAKE,gBACA,WACA,YACA,iCAEF,aACE,WACA,0BACA,iBAKN,qBAlmDuB,WAomDrB,sBACA,gBACA,kBACA,MACA,OACA,WACA,sBAGF,qBACE,CA7kDA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,weA+kDF,UAeE,qEAGF,qBAEE,sJAGF,UAKE,sBACA,CA9mDA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,4WA+mDA,qBACE,qEAIJ,kBA3pDW,sGAmBT,gBAIA,WA0oDA,gBACA,uFAEA,kBApqDS,4CAyqDX,eArqDuB,WAwqDrB,iBACA,kBACA,sBACA,gDAEA,UACE,0BACA,gGAIJ,kBAvrDW,yBA8rDX,yBACE,YACA,kCAGF,UACE,sBACA,kBACA,CAIA,4CACA,CADA,kBACA,CADA,gBACA,WACA,YACA,qBACA,sBACA,iBACA,2CAGF,qBACE,gCACA,8FAGF,UAGE,kCACA,ooB","file":"skins/vanilla/win95/common.css","sourcesContent":["@font-face{font-family:\"premillenium\";src:url(\"~fonts/premillenium/MSSansSerif.ttf\") format(\"truetype\")}html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#192432 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#192432;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#1c2938}::-webkit-scrollbar-thumb:active{background:#192432}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#121a24}::-webkit-scrollbar-track:active{background:#121a24}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#06090c;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#121a24}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#192432;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#0b1016;padding:0}body.error{position:absolute;text-align:center;color:#9baec8;background:#121a24;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#e25169;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#00007f}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#404040;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#404040}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#404040;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#79bd9a;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#d9e1e8;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#26374d}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#9baec8;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#2d415a;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#344b68}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#d9e1e8}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#000}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#192432;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #192432;background:#040609}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#192432;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#9baec8}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#9baec8;padding:10px;border-right:1px solid #192432;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #00007f;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#d9e1e8}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #26374d}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#9baec8}.public-layout .public-account-header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#0000a8}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#9baec8}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #202e3f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #202e3f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#121a24}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#192432}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#4c6d98}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#4c6d98}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{font-weight:700;margin-bottom:8px;color:#9baec8}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#4c6d98}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#4c6d98}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#5377a5}.compact-header h1{font-size:24px;line-height:28px;color:#9baec8;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#d9e1e8}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#121a24;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.hero-widget__text a{color:#d9e1e8;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;font-weight:700;font-size:14px;color:#9baec8}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#9baec8}.box-widget{padding:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #404040;text-align:center;color:#9baec8;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;font-weight:700;font-size:14px;color:#9baec8}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#9baec8;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#d9e1e8;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#9baec8}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#9baec8;margin-bottom:10px}.page-header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#9baec8}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#192432}.page-header h1{font-size:24px}}.directory{background:#121a24;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#121a24;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#202e3f}.directory__tag.active>a{background:#00007f;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#9baec8}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#9baec8}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#00007f}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#040609;border:2px solid #121a24}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;color:#9baec8;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #202e3f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#9baec8;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #2d415a}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#9baec8}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#00007f}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#0b1016;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #192432}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #192432}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#00007f;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#9baec8}.simple_form .hint a{color:#00007f}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#000}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#9baec8}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #404040;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102;border:1px solid #000;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#a8b9cf}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#00007f;background:#040609}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#00007f;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#009}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#006}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #000;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#404040;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(1, 1, 2, 0), #010102)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(18,26,36,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#202e3f;color:#9baec8;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#9baec8;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#192432}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#9baec8;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#00007f;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#0000a8}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#9baec8}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#d9e1e8;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#d9e1e8;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#9baec8}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#010102;border:1px solid #000;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#0b1016;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#0b1016;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#202e3f}.card__img{height:130px;position:relative;background:#000;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#192432;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#121a24;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{color:#d9e1e8}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#233346}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#9baec8;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #26374d;border-bottom:1px solid #26374d;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #26374d}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#d9e1e8;background:rgba(4,6,9,.5)}.account__header__fields dd{flex:1 1 auto;color:#9baec8}.account__header__fields a{color:#00007f;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#9baec8}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#121a24}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#202e3f}.button.logo-button{flex:0 auto;font-size:14px;background:#00007f;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#0000b2}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.link-button{display:block;font-size:15px;line-height:20px;color:#00007f;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#00007f;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:15px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#0000b2;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9baec8;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#121a24;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#404040}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#4a4a4a}.button.button-secondary{color:#9baec8;background:transparent;padding:3px 15px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#a8b9cf}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#404040;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#525252;background-color:rgba(64,64,64,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(64,64,64,.3)}.icon-button.disabled{color:#1f1f1f;background-color:transparent;cursor:default}.icon-button.active{color:#00007f}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#404040}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#2e2e2e;background-color:rgba(64,64,64,.15)}.icon-button.inverted:focus{background-color:rgba(64,64,64,.3)}.icon-button.inverted.disabled{color:#525252;background-color:transparent}.icon-button.inverted.active{color:#00007f}.icon-button.inverted.active.disabled{color:#0000c1}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#404040;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#2e2e2e;background-color:rgba(64,64,64,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(64,64,64,.3)}.text-icon-button.disabled{color:#737373;background-color:transparent;cursor:default}.text-icon-button.active{color:#00007f}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#00007f}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#00007f;background:#00007f}.compose-form .compose-form__warning{color:#121a24;margin-bottom:10px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#121a24;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#404040;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:5px;right:5px}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#404040}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#d9e1e8;border-radius:0 0 4px 4px;color:#121a24;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#404040}.compose-form .compose-form__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#eff3f5}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#d9e1e8;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#d9e1e8}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#404040}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9baec8;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#121a24;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#fff}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#525252}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#404040}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#0000a8}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#404040}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#525252;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#0000a8;border:0;background:transparent;padding:0;padding-top:8px}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#121a24;font-weight:700;font-size:12px;padding:0 6px;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#404040;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #202e3f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#192432}.focusable:focus .status.status-direct{background:#26374d}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#202e3f}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #202e3f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#202e3f;border-bottom-color:#26374d}.status.light .status__relative-time{color:#9baec8}.status.light .status__display-name{color:#121a24}.status.light .display-name strong{color:#121a24}.status.light .display-name span{color:#9baec8}.status.light .status__content{color:#121a24}.status.light .status__content a{color:#00007f}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#616161}.status__relative-time,.notification__relative_time{color:#404040;float:right;font-size:14px}.status__display-name{color:#404040}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#404040;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#404040}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#404040}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#192432;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#404040;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#121a24;font-size:14px}.reply-indicator__content a{color:#404040}.domain{padding:10px;border-bottom:1px solid #202e3f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #202e3f}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#9baec8;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative;cursor:default}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #202e3f;color:#404040}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #202e3f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #00007f}.account__action-bar__tab>span{display:block;font-size:12px;color:#9baec8}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#d9e1e8;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#404040}.muted .status__display-name strong{color:#404040}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#404040;color:#121a24}.muted a.status__content__spoiler-link:hover{background:#525252;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#9baec8;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#00007f}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#9baec8}.navigation-bar strong{color:#d9e1e8}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#00007f;color:#d9e1e8;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#00007f;color:#d9e1e8}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#040609;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#121a24;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#9baec8;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#202e3f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #202e3f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}}.tabs-bar__link.active{border-bottom:2px solid #00007f;color:#00007f}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#000070;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#0000a3}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#00007f;border:2px solid #202e3f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#040609}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #192432;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#283a50;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#121a24}.drawer__inner__mastodon{background:#283a50 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:100%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#283a50;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#202e3f;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#17212e;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#192432;color:#00007f;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#192432;border:0;font-family:inherit;color:#00007f;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#121a24;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#010102}.react-toggle--checked .react-toggle-track{background-color:#00007f}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#0000b2}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #121a24;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#00007f}.column-link{background:#202e3f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#253549}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#00007f}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#121a24;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#121a24;color:#404040;padding:8px 20px;font-size:13px;font-weight:500;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#121a24}.flex-spacer{flex:1 1 auto}.getting-started{color:#404040;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#404040;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#9baec8}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#404040}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:13px;color:#9baec8;padding:10px;font-weight:500;border-bottom:1px solid #202e3f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#9baec8}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#202e3f;border:1px solid #0b1016}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#404040;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#00007f}.status-card{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;color:#404040;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#d9e1e8;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#202e3f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#9baec8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#9baec8}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#202e3f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#192432}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#192432}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#404040;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#151f2b}.load-gap{border-bottom:1px solid #202e3f}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#404040;background:#121a24;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#404040}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;top:35px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(0, 0, 127, 0.23) 0%, rgba(0, 0, 127, 0) 60%)}.column-header{display:flex;font-size:16px;background:#192432;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#00007f}.column-header.active{box-shadow:0 1px 0 rgba(0,0,127,.3)}.column-header.active .column-header__icon{color:#00007f;text-shadow:0 0 10px rgba(0,0,127,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#192432;border:0;color:#9baec8;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#b2c1d5}.column-header__button.active{color:#fff;background:#202e3f}.column-header__button.active:hover{color:#fff;background:#202e3f}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#9baec8;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #26374d;margin:10px 0}.column-header__collapsible-inner{background:#202e3f;padding:15px}.column-header__setting-btn:hover{color:#9baec8;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#404040;font-size:13px;font-weight:400;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #3e5a7c;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#3e5a7c}29%{background-color:#3e5a7c}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#9baec8;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#b5c3d6}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.modal-container--preloader{background:#202e3f}.account--panel{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#202e3f;padding:15px}.column-settings__section{color:#9baec8;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#a8b9cf}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#192432}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#404040;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#202e3f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#26374d;color:#a8b9cf}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#9baec8}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#404040}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#4a4a4a}.column-settings__hashtags .column-select__indicator-separator{background-color:#202e3f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{color:#9baec8;font-size:14px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#121a24}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#121a24;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;font-size:12px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#9baec8;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column{color:#404040;background:#121a24;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column{contain:strict}}.empty-column-indicator>span,.error-column>span{max-width:400px}.empty-column-indicator a,.error-column a{color:#00007f;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover{text-decoration:underline}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#121a24;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#d9e1e8;font-size:18px;font-weight:500;border:2px dashed #404040;border-radius:4px}.upload-progress{padding:10px;color:#404040;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:13px;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#404040;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#00007f;border-radius:6px}.emoji-button{display:block;font-size:24px;line-height:24px;margin-left:2px;width:24px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px;margin-top:2px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#121a24;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#00007f;color:#fff;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#fff}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#fff}.privacy-dropdown__option.active:hover{background:#000093}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#404040}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#121a24}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#00007f}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#a8b9cf}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#192432}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#d9e1e8;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#404040;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#525252}.search-results__header{color:#404040;background:#151f2b;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#404040}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#d9e1e8;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#e6ebf0;text-decoration:underline}.search-results__info{padding:20px;color:#9baec8;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#00007f}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#121a24;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#404040;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#363636;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#121a24}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#192432}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#121a24;margin-bottom:5px;font-size:13px}.display-case__case{background:#121a24;color:#d9e1e8;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#121a24;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#f2f5f7}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#404040;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#00007f}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#121a24}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#121a24;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#121a24;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#121a24;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#00007f;color:#fff}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#404040;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#363636;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#121a24;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#00007f;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv.autoplay .media-gallery__gifv__label{display:none}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#404040;padding:8px 18px;cursor:default;border-right:1px solid #202e3f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#404040;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#404040}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#d9e1e8;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#040609;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #192432;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(0,0,168,.5)}.audio-player__wave-placeholder{background-color:#2d415a}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#040609;border-top:1px solid #192432;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#9baec8;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#b2c1d5}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#0000a8}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#0000a8;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#0000a8}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#0000a8;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#000;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#192432;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#121a24;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #202e3f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#0b1016;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#9baec8;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#d9e1e8}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #202e3f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #121a24}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#0f151d;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #06090c}.filter-form{background:#121a24}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#0000a8;background:#0000a8}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{color:#9baec8;font-size:14px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#121a24}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#d9e1e8;max-width:400px}noscript div a{color:#00007f;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#192432}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f}.account__moved-note__message{position:relative;margin-left:58px;color:#404040;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#192432}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.list-editor{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#283a50;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#283a50}.list-adder__lists{background:#283a50;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #202e3f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#9baec8;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#0b1016}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#192432;padding:5px;border-bottom:1px solid #26374d}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#040609;border:2px solid #192432}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #26374d;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #26374d}.account__header__bio .account__header__fields a{color:#0000a8}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#9baec8;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.trends__header{color:#404040;background:#151f2b;border-bottom:1px solid #0b1016;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #202e3f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#404040;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#9baec8;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#d9e1e8}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(0,0,127,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#00009e !important}.conversation{display:flex;border-bottom:1px solid #202e3f;padding:5px;padding-bottom:0}.conversation:focus{background:#151f2b;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative}.conversation__unread{display:inline-block;background:#00007f;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#9baec8;padding-left:15px}.conversation__content__names{color:#9baec8;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#151f2b}.conversation--unread:focus{background:#192432}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{position:absolute;top:0;left:0;height:100%;display:inline-block;border-radius:4px;background:#6d89af}.poll__chart.leading{background:#00007f}.poll__text{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__text input[type=radio],.poll__text input[type=checkbox]{display:none}.poll__text .autossugest-input{flex:1 1 auto}.poll__text input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#121a24;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__text input[type=text]:focus{border-color:#00007f}.poll__text.selectable{cursor:pointer}.poll__text.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-width:4px;background:none}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:52px;font-weight:700;padding:0 10px;padding-left:8px;text-align:right;margin-top:auto;margin-bottom:auto;flex:0 0 52px}.poll__vote__mark{float:left;line-height:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#404040}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#404040;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(64,64,64,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#00007f}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#404040;border-color:#404040;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__text{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#121a24;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#404040}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(0,0,127,.2)}.modal-layout{background:#121a24 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#121a24}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#404040;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#363636}.emoji-mart-anchor-selected{color:#00007f}.emoji-mart-anchor-selected:hover{color:#00006b}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#00007f}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#121a24;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#9baec8}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#9baec8}.rich-formatting a{color:#00007f;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#9baec8}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#d9e1e8}.rich-formatting em{font-style:italic;color:#d9e1e8}.rich-formatting code{font-size:.85em;background:#040609;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#d9e1e8}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #192432;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #192432;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#9baec8}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#404040}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#0b1016;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#d9e1e8}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#040609;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#9baec8;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #192432;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#7a93b6}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#9baec8}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#9baec8}.landing-page p a,.landing-page li a{color:#00007f;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#bcc9da}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(64,64,64,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#121a24;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#bcc9da}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#121a24;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#d9e1e8}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#9baec8}.landing-page__short-description h1 small span{color:#d9e1e8}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;font-weight:700;font-size:14px;color:#9baec8}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#121a24;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#9baec8}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#9baec8}.landing .simple_form p.lead{color:#9baec8;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #202e3f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#404040}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #121a24;text-align:left;background:#0b1016}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #121a24;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#121a24}.table a{color:#00007f;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#121a24;border-top:1px solid #040609;border-bottom:1px solid #040609}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #040609}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #040609}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#9baec8;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #040609;background:#121a24;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #040609;border-top:0;background:#121a24}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #040609;border-top:0;background:#0b1016}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #040609}}.batch-table__row:hover{background:#0f151d}.batch-table__row:nth-child(even){background:#121a24}.batch-table__row:nth-child(even):hover{background:#151f2b}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#9baec8;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #040609;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #040609}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#121a24;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#202e3f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#9baec8;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#26374d}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#9baec8;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#0a0e13;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#0f151d;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#0b1016;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#00007f;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#009}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #202e3f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#d9e1e8;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#d9e1e8;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{font-size:14px;font-weight:700;color:#9baec8;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #202e3f}.admin-wrapper .content h6{font-size:16px;color:#d9e1e8;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#d9e1e8;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(64,64,64,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #192432;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#00007f}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#9baec8}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#00007f}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#404040;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;font-size:13px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset a{display:inline-block;color:#9baec8;text-decoration:none;font-size:13px;font-weight:500;border-bottom:2px solid #121a24}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #1b2635}.filters .filter-subset a.selected{color:#00007f;border-bottom:2px solid #00007f}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#d9e1e8}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#00007f;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{margin-bottom:20px;line-height:20px}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;padding:10px;background:#121a24;color:#9baec8;border-radius:4px 4px 0 0;font-size:14px;position:relative}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#404040}.log-entry__extras{background:#1c2938;border-radius:0 0 4px 4px;padding:10px;color:#9baec8;font-family:\"mastodon-font-monospace\",monospace;font-size:12px;word-wrap:break-word;min-height:20px}.log-entry__icon{font-size:28px;margin-right:10px;color:#404040}.log-entry__icon__overlay{position:absolute;top:10px;right:10px;width:10px;height:10px;border-radius:50%}.log-entry__icon__overlay.positive{background:#79bd9a}.log-entry__icon__overlay.negative{background:#e87487}.log-entry__icon__overlay.neutral{background:#00007f}.log-entry a,.log-entry .username,.log-entry .target{color:#d9e1e8;text-decoration:none;font-weight:500}.log-entry .diff-old{color:#e87487}.log-entry .diff-neutral{color:#d9e1e8}.log-entry .diff-new{color:#79bd9a}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#d9e1e8}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #00007f}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#9baec8}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#404040}.report-card{background:#121a24;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#9baec8;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#b5c3d6}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #0b1016}.report-card__summary__item:hover{background:#151f2b}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#9baec8}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#404040;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#9baec8}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#202e3f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#0000a8}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#192432;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#202e3f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#9baec8;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(1, 1, 2, 0), #010102)}body.rtl .simple_form select{background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}@media screen and (min-width: 1300px){.column{flex-grow:1 !important;max-width:400px}.drawer{width:17%;max-width:400px;min-width:330px}}.media-gallery,.video-player{max-height:30vh;height:30vh !important;position:relative;margin-top:20px;margin-left:-68px;width:calc(100% + 80px) !important;max-width:calc(100% + 80px)}.detailed-status .media-gallery,.detailed-status .video-player{margin-left:-5px;width:calc(100% + 9px);max-width:calc(100% + 9px)}.video-player video{transform:unset;top:unset}.detailed-status .media-spoiler,.status .media-spoiler{height:100% !important;vertical-align:middle}body{font-size:13px;font-family:\"MS Sans Serif\",\"premillenium\",sans-serif;color:#000}.ui,.ui .columns-area,body.admin{background:teal}.loading-bar{height:5px;background-color:navy}.tabs-bar{background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;height:30px}.tabs-bar__link{color:#000;border:2px outset #bfbfbf;border-top-width:1px;border-left-width:1px;margin:2px;padding:3px}.tabs-bar__link.active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;color:#000}.tabs-bar__link:last-child::before{content:\"Start\";color:#000;font-weight:bold;font-size:15px;width:80%;display:block;position:absolute;right:0px}.tabs-bar__link:last-child{position:relative;flex-basis:60px !important;font-size:0px;color:#bfbfbf;background-image:url(\"~images/start.png\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%}.drawer .drawer__inner{overflow:visible;height:inherit;background:#bfbfbf}.drawer:after{display:block;content:\" \";position:absolute;bottom:15px;left:15px;width:132px;height:117px;background-image:url(\"~images/clippy_wave.gif\"),url(\"~images/clippy_frame.png\");background-repeat:no-repeat;background-position:4px 20px,0px 0px;z-index:0}.drawer__pager{overflow-y:auto;z-index:1}.privacy-dropdown__dropdown{z-index:2}.column{max-height:100vh}.column>.scrollable{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;border-top-width:0px}.column-header__wrapper{color:#fff;font-weight:bold;background:#7f7f7f}.column-header{padding:2px;font-size:13px;background:#7f7f7f;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;border-bottom-width:0px;color:#fff;font-weight:bold;align-items:baseline}.column-header__wrapper.active{background:#00007f}.column-header__wrapper.active::before{display:none}.column-header.active{box-shadow:unset;background:#00007f}.column-header.active .column-header__icon{color:#fff}.column-header__buttons{max-height:20px;margin-right:0px}.column-header__button{background:#bfbfbf;color:#000;line-height:0px;font-size:14px;max-height:20px;padding:0px 2px;margin-top:2px;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}.column-header__button:hover{color:#000}.column-header__button.active,.column-header__button.active:hover{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;background-color:#7f7f7f}.column-header__back-button{background:#bfbfbf;color:#000;padding:2px;max-height:20px;margin-top:2px;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;font-size:13px;font-weight:bold}.column-back-button{background:#bfbfbf;color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;padding:2px;font-size:13px;font-weight:bold}.column-back-button--slim-button{position:absolute;top:-22px;right:4px;max-height:20px;max-width:60px;padding:0px 2px}.column-back-button__icon{font-size:11px;margin-top:-3px}.column-header__collapsible{border-left:2px outset #bfbfbf;border-right:2px outset #bfbfbf}.column-header__collapsible-inner{background:#bfbfbf;color:#000}.column-header__collapsible__extra{color:#000}.column-header__collapsible__extra div[role=group]{border:2px groove #bfbfbf;border-radius:4px;margin-bottom:8px;padding:4px}.column-inline-form{background-color:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;border-bottom-width:0px;border-top-width:0px}.column-settings__section{color:#000;font-weight:bold;font-size:11px;position:relative;top:-12px;left:4px;background-color:#bfbfbf;display:inline-block;padding:0px 4px;margin-bottom:0px}.setting-meta__label,.setting-toggle__label{color:#000;font-weight:normal}.setting-meta__label span:before{content:\"(\"}.setting-meta__label span:after{content:\")\"}.setting-toggle{line-height:13px}.react-toggle .react-toggle-track{border-radius:0px;background-color:#fff;border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px;width:12px;height:12px}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#fff}.react-toggle .react-toggle-track-check{left:2px;transition:unset}.react-toggle .react-toggle-track-check svg path{fill:#000}.react-toggle .react-toggle-track-x{display:none}.react-toggle .react-toggle-thumb{border-radius:0px;display:none}.text-btn{background-color:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;padding:4px}.text-btn:hover{text-decoration:none;color:#000}.text-btn:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.setting-text{color:#000;background-color:#fff;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;font-size:13px;padding:2px}.setting-text:active,.setting-text:focus,.setting-text.light:active,.setting-text.light:focus{color:#000;border-bottom:2px inset #bfbfbf}.column-header__setting-arrows .column-header__setting-btn{padding:3px 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding:3px 10px}.missing-indicator{background-color:#bfbfbf;color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}.missing-indicator>div{background:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRUaXRsZQAACJnLyy9Jyy/NSwEAD5IDblIFOhoAAAAXelRYdEF1dGhvcgAACJlLzijKz0vMAQALmgLoDsFj8gAAAQpJREFUOMuVlD0OwjAMhd2oQl04Axfo0IGBgYELcAY6cqQuSO0ZOEAZGBg6VKg74gwsEaoESRVHjusI8aQqzY8/PbtOEz1qkFSn2YevlaNOpLMJh2DwvixhuXtOa6/LCh51DUMEFkAsgAZD207Doin8mQ562JpRE5CHBAAhmIqD1L8AqzUUUJkxc6kr3AgAJ+NuvIWRdk7WcrKl0AUqcIBBHOiEbpS4m27mIL5Onfg3k0rgggeQuS2sDOGSahKR+glgqaGLgUJs951NN1q9D72cQqQWR9cr3sm9YcEssEuz6eEuZh2bu0aSOhQ1MBezu2O/+TVSvEFII3qLsZWrSA2AAUQIh1HpyP/kC++zjVSMj6ntAAAAAElFTkSuQmCC\") no-repeat;background-position:center center}.empty-column-indicator,.error-column{background:#bfbfbf;color:#000}.status__wrapper{border:2px groove #bfbfbf;margin:4px}.status{border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px;background-color:#fff;margin:4px;padding-bottom:40px;margin-bottom:8px}.status.status-direct{background-color:#bfbfbf}.status__content{font-size:13px}.status.light .status__relative-time,.status.light .display-name span{color:#7f7f7f}.status__action-bar{box-sizing:border-box;position:absolute;bottom:-1px;left:-1px;background:#bfbfbf;width:calc(100% + 2px);padding-left:10px;padding:4px 2px;padding-bottom:4px;border-bottom:2px groove #bfbfbf;border-top:1px outset #bfbfbf;text-align:right}.status__wrapper .status__action-bar{border-bottom-width:0px}.status__action-bar-button{float:right}.status__action-bar-dropdown{margin-left:auto;margin-right:10px}.status__action-bar-dropdown .icon-button{min-width:28px}.status.light .status__content a{color:blue}.focusable:focus{background:#bfbfbf}.focusable:focus .detailed-status__action-bar{background:#bfbfbf}.focusable:focus .status,.focusable:focus .detailed-status{background:#fff;outline:2px dotted gray}.dropdown__trigger.icon-button{padding-right:6px}.detailed-status__action-bar-dropdown .icon-button{min-width:28px}.detailed-status{background:#fff;background-clip:padding-box;margin:4px;border:2px groove #bfbfbf;padding:4px}.detailed-status__display-name{color:#7f7f7f}.detailed-status__display-name strong{color:#000;font-weight:bold}.account__avatar,.account__avatar-overlay-base,.account__header__avatar,.account__avatar-overlay-overlay{border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px;clip-path:none;filter:saturate(1.8) brightness(1.1)}.detailed-status__action-bar{background-color:#bfbfbf;border:0px;border-bottom:2px groove #bfbfbf;margin-bottom:8px;justify-items:left;padding-left:4px}.icon-button{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;padding:0px 0px 0px 0px;margin-right:4px;color:#3f3f3f}.icon-button.inverted,.icon-button:hover,.icon-button.inverted:hover,.icon-button:active,.icon-button:focus{color:#3f3f3f}.icon-button:active{border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px}.status__action-bar>.icon-button{padding:0px 15px 0px 0px;min-width:25px}.icon-button.star-icon,.icon-button.star-icon:active{background:transparent;border:none}.icon-button.star-icon.active{color:#ca8f04}.icon-button.star-icon.active:active,.icon-button.star-icon.active:hover,.icon-button.star-icon.active:focus{color:#ca8f04}.icon-button.star-icon>i{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;padding-bottom:3px}.icon-button.star-icon:active>i{border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px}.text-icon-button{color:#404040}.detailed-status__action-bar-dropdown{margin-left:auto;justify-content:right;padding-right:16px}.detailed-status__button{flex:0 0 auto}.detailed-status__button .icon-button{padding-left:2px;padding-right:25px}.status-card{border-radius:0px;background:#fff;border:1px solid #000;color:#000}.status-card:hover{background-color:#fff}.status-card__title{color:blue;text-decoration:underline;font-weight:bold}.load-more{width:auto;margin:5px auto;background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;color:#000;padding:2px 5px}.load-more:hover{background:#bfbfbf;color:#000}.status-card__description{color:#000}.account__display-name strong,.status__display-name strong{color:#000;font-weight:bold}.account .account__display-name{color:#000}.account{border-bottom:2px groove #bfbfbf}.reply-indicator__content .status__content__spoiler-link,.status__content .status__content__spoiler-link{background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}.reply-indicator__content .status__content__spoiler-link:hover,.status__content .status__content__spoiler-link:hover{background:#bfbfbf}.reply-indicator__content .status__content__spoiler-link:active,.status__content .status__content__spoiler-link:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.reply-indicator__content a,.status__content a{color:blue}.notification{border:2px groove #bfbfbf;margin:4px}.notification__message{color:#000;font-size:13px}.notification__display-name{font-weight:bold}.drawer__header{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;justify-content:left;margin-bottom:0px;padding-bottom:2px;border-bottom:2px groove #bfbfbf}.drawer__tab{color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;padding:5px;margin:2px;flex:0 0 auto}.drawer__tab:first-child::before{content:\"Start\";color:#000;font-weight:bold;font-size:15px;width:80%;display:block;position:absolute;right:0px}.drawer__tab:first-child{position:relative;padding:5px 15px;width:40px;font-size:0px;color:#bfbfbf;background-image:url(\"~images/start.png\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%}.drawer__header a:hover{background-color:transparent}.drawer__header a:first-child:hover{background-image:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%;transition:unset}.search{background:#bfbfbf;padding-top:2px;padding:2px;border:2px outset #bfbfbf;border-top-width:0px;border-bottom:2px groove #bfbfbf;margin-bottom:0px}.search input{background-color:#fff;color:#000;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.search__input:focus{background-color:#fff}.search-popout{box-shadow:unset;color:#000;border-radius:0px;background-color:#ffc;border:1px solid #000}.search-popout h4{color:#000;text-transform:none;font-weight:bold}.search-results__header{background-color:#bfbfbf;color:#000;border-bottom:2px groove #bfbfbf}.search-results__hashtag{color:blue}.search-results__section .account:hover,.search-results__section .account:hover .account__display-name,.search-results__section .account:hover .account__display-name strong,.search-results__section .search-results__hashtag:hover{background-color:#00007f;color:#fff}.search__icon .fa{color:gray}.search__icon .fa.active{opacity:1}.search__icon .fa:hover{color:gray}.drawer__inner,.drawer__inner.darker{background-color:#bfbfbf;border:2px outset #bfbfbf;border-top-width:0px}.navigation-bar{color:#000}.navigation-bar strong{color:#000;font-weight:bold}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{border-radius:0px;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.compose-form .autosuggest-textarea__textarea{border-bottom:0px}.compose-form__uploads-wrapper{border-radius:0px;border-bottom:1px inset #bfbfbf;border-top-width:0px}.compose-form__upload-wrapper{border-left:1px inset #bfbfbf;border-right:1px inset #bfbfbf}.compose-form .compose-form__buttons-wrapper{background-color:#bfbfbf;border:2px groove #bfbfbf;margin-top:4px;padding:4px 8px}.compose-form__buttons{background-color:#bfbfbf;border-radius:0px;box-shadow:unset}.compose-form__buttons-separator{border-left:2px groove #bfbfbf}.privacy-dropdown.active .privacy-dropdown__value.active,.advanced-options-dropdown.open .advanced-options-dropdown__value{background:#bfbfbf}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#404040}.privacy-dropdown.active .privacy-dropdown__value{background:#bfbfbf;box-shadow:unset}.privacy-dropdown__option.active,.privacy-dropdown__option:hover,.privacy-dropdown__option.active:hover{background:#00007f}.privacy-dropdown__dropdown,.privacy-dropdown.active .privacy-dropdown__dropdown,.advanced-options-dropdown__dropdown,.advanced-options-dropdown.open .advanced-options-dropdown__dropdown{box-shadow:unset;color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;background:#bfbfbf}.privacy-dropdown__option__content{color:#000}.privacy-dropdown__option__content strong{font-weight:bold}.compose-form__warning::before{content:\"Tip:\";font-weight:bold;display:block;position:absolute;top:-10px;background-color:#bfbfbf;font-size:11px;padding:0px 5px}.compose-form__warning{position:relative;box-shadow:unset;border:2px groove #bfbfbf;background-color:#bfbfbf;color:#000}.compose-form__warning a{color:blue}.compose-form__warning strong{color:#000;text-decoration:underline}.compose-form__buttons button.active:last-child{border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px;background:#dfdfdf;color:#7f7f7f}.compose-form__upload-thumbnail{border-radius:0px;border:2px groove #bfbfbf;background-color:#bfbfbf;padding:2px;box-sizing:border-box}.compose-form__upload-thumbnail .icon-button{max-width:20px;max-height:20px;line-height:10px !important}.compose-form__upload-thumbnail .icon-button::before{content:\"X\";font-size:13px;font-weight:bold;color:#000}.compose-form__upload-thumbnail .icon-button i{display:none}.emoji-picker-dropdown__menu{z-index:2}.emoji-dialog.with-search{box-shadow:unset;border-radius:0px;background-color:#bfbfbf;border:1px solid #000;box-sizing:content-box}.emoji-dialog .emoji-search{color:#000;background-color:#fff;border-radius:0px;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.emoji-dialog .emoji-search-wrapper{border-bottom:2px groove #bfbfbf}.emoji-dialog .emoji-category-title{color:#000;font-weight:bold}.reply-indicator{background-color:#bfbfbf;border-radius:3px;border:2px groove #bfbfbf}.button{background-color:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;border-radius:0px;color:#000;font-weight:bold}.button:hover,.button:focus,.button:disabled{background-color:#bfbfbf}.button:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.button:disabled{color:gray;text-shadow:1px 1px 0px #efefef}.button:disabled:active{box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}#Getting-started{background-color:#bfbfbf;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;border-bottom-width:0px}#Getting-started::before{content:\"Start\";color:#000;font-weight:bold;font-size:15px;width:80%;text-align:center;display:block;position:absolute;right:2px}#Getting-started{position:relative;padding:5px 15px;width:60px;font-size:0px;color:#bfbfbf;background-image:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%}.column-subheading{background-color:#bfbfbf;color:#000;border-bottom:2px groove #bfbfbf;text-transform:none;font-size:16px}.column-link{background-color:transparent;color:#000}.column-link:hover{background-color:#00007f;color:#fff}.getting-started__wrapper .column-subheading{font-size:0px;margin:0px;padding:0px}.getting-started__wrapper .column-link{background-size:32px 32px;background-repeat:no-repeat;background-position:36px 50%;padding-left:40px}.getting-started__wrapper .column-link:hover{background-size:32px 32px;background-repeat:no-repeat;background-position:36px 50%}.getting-started__wrapper .column-link i{font-size:0px;width:32px}.column-link[href=\"/web/timelines/public\"]{background-image:url(\"~images/icon_public.png\")}.column-link[href=\"/web/timelines/public\"]:hover{background-image:url(\"~images/icon_public.png\")}.column-link[href=\"/web/timelines/public/local\"]{background-image:url(\"~images/icon_local.png\")}.column-link[href=\"/web/timelines/public/local\"]:hover{background-image:url(\"~images/icon_local.png\")}.column-link[href=\"/web/pinned\"]{background-image:url(\"~images/icon_pin.png\")}.column-link[href=\"/web/pinned\"]:hover{background-image:url(\"~images/icon_pin.png\")}.column-link[href=\"/web/favourites\"]{background-image:url(\"~images/icon_likes.png\")}.column-link[href=\"/web/favourites\"]:hover{background-image:url(\"~images/icon_likes.png\")}.column-link[href=\"/web/lists\"]{background-image:url(\"~images/icon_lists.png\")}.column-link[href=\"/web/lists\"]:hover{background-image:url(\"~images/icon_lists.png\")}.column-link[href=\"/web/follow_requests\"]{background-image:url(\"~images/icon_follow_requests.png\")}.column-link[href=\"/web/follow_requests\"]:hover{background-image:url(\"~images/icon_follow_requests.png\")}.column-link[href=\"/web/keyboard-shortcuts\"]{background-image:url(\"~images/icon_keyboard_shortcuts.png\")}.column-link[href=\"/web/keyboard-shortcuts\"]:hover{background-image:url(\"~images/icon_keyboard_shortcuts.png\")}.column-link[href=\"/web/blocks\"]{background-image:url(\"~images/icon_blocks.png\")}.column-link[href=\"/web/blocks\"]:hover{background-image:url(\"~images/icon_blocks.png\")}.column-link[href=\"/web/mutes\"]{background-image:url(\"~images/icon_mutes.png\")}.column-link[href=\"/web/mutes\"]:hover{background-image:url(\"~images/icon_mutes.png\")}.column-link[href=\"/settings/preferences\"]{background-image:url(\"~images/icon_settings.png\")}.column-link[href=\"/settings/preferences\"]:hover{background-image:url(\"~images/icon_settings.png\")}.column-link[href=\"/about/more\"]{background-image:url(\"~images/icon_about.png\")}.column-link[href=\"/about/more\"]:hover{background-image:url(\"~images/icon_about.png\")}.column-link[href=\"/auth/sign_out\"]{background-image:url(\"~images/icon_logout.png\")}.column-link[href=\"/auth/sign_out\"]:hover{background-image:url(\"~images/icon_logout.png\")}.getting-started__footer{display:none}.getting-started__wrapper::before{content:\"Mastodon 95\";font-weight:bold;font-size:23px;color:#fff;line-height:30px;padding-left:20px;padding-right:40px;left:0px;bottom:-30px;display:block;position:absolute;background-color:#7f7f7f;width:200%;height:30px;-ms-transform:rotate(-90deg);-webkit-transform:rotate(-90deg);transform:rotate(-90deg);transform-origin:top left}.getting-started__wrapper{border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;background-color:#bfbfbf}.column .static-content.getting-started{display:none}.keyboard-shortcuts kbd{background-color:#bfbfbf}.account__header{background-color:#7f7f7f}.account__header .account__header__content{color:#fff}.account-authorize__wrapper{border:2px groove #bfbfbf;margin:2px;padding:2px}.account--panel{background-color:#bfbfbf;border:0px;border-top:2px groove #bfbfbf}.account-authorize .account__header__content{color:#000;margin:10px}.account__action-bar__tab>span{color:#000;font-weight:bold}.account__action-bar__tab strong{color:#000}.account__action-bar{border:unset}.account__action-bar__tab{border:1px outset #bfbfbf}.account__action-bar__tab:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.dropdown--active .dropdown__content>ul,.dropdown-menu{background:#ffc;border-radius:0px;border:1px solid #000;box-shadow:unset}.dropdown-menu a{background-color:transparent}.dropdown--active::after{display:none}.dropdown--active .icon-button{color:#000;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.dropdown--active .dropdown__content>ul>li>a{background:transparent}.dropdown--active .dropdown__content>ul>li>a:hover{background:transparent;color:#000;text-decoration:underline}.dropdown__sep,.dropdown-menu__separator{border-color:#7f7f7f}.detailed-status__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__left{left:unset}.dropdown>.icon-button,.detailed-status__button>.icon-button,.status__action-bar>.icon-button,.star-icon i{height:25px !important;width:28px !important;box-sizing:border-box}.status__action-bar-button .fa-floppy-o{padding-top:2px}.status__action-bar-dropdown{position:relative;top:-3px}.detailed-status__action-bar-dropdown .dropdown{position:relative;top:-4px}.notification .status__action-bar{border-bottom:none}.notification .status{margin-bottom:4px}.status__wrapper .status{margin-bottom:3px}.status__wrapper{margin-bottom:8px}.icon-button .fa-retweet{position:relative;top:-1px}.embed-modal,.error-modal,.onboarding-modal,.actions-modal,.boost-modal,.confirmation-modal,.report-modal{box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;background:#bfbfbf}.actions-modal::before,.boost-modal::before,.confirmation-modal::before,.report-modal::before{content:\"Confirmation\";display:block;background:#00007f;color:#fff;font-weight:bold;padding-left:2px}.boost-modal::before{content:\"Boost confirmation\"}.boost-modal__action-bar>div>span:before{content:\"Tip: \";font-weight:bold}.boost-modal__action-bar,.confirmation-modal__action-bar,.report-modal__action-bar{background:#bfbfbf;margin-top:-15px}.embed-modal h4,.error-modal h4,.onboarding-modal h4{background:#00007f;color:#fff;font-weight:bold;padding:2px;font-size:13px;text-align:left}.confirmation-modal__action-bar .confirmation-modal__cancel-button{color:#000}.confirmation-modal__action-bar .confirmation-modal__cancel-button:active,.confirmation-modal__action-bar .confirmation-modal__cancel-button:focus,.confirmation-modal__action-bar .confirmation-modal__cancel-button:hover{color:#000}.confirmation-modal__action-bar .confirmation-modal__cancel-button:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.embed-modal .embed-modal__container .embed-modal__html,.embed-modal .embed-modal__container .embed-modal__html:focus{background:#fff;color:#000;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.modal-root__overlay,.account__header>div{background:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFnpUWHRUaXRsZQAACJnLzU9JzElKBwALgwLXaCRlPwAAABd6VFh0QXV0aG9yAAAImUvOKMrPS8wBAAuaAugOwWPyAAAAEUlEQVQImWNgYGD4z4AE/gMADwMB/414xEUAAAAASUVORK5CYII=\")}.admin-wrapper::before{position:absolute;top:0px;content:\"Control Panel\";color:#fff;background-color:#00007f;font-size:13px;font-weight:bold;width:calc(100%);margin:2px;display:block;padding:2px;padding-left:22px;box-sizing:border-box}.admin-wrapper{position:relative;background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;width:70vw;height:80vh;margin:10vh auto;color:#000;padding-top:24px;flex-direction:column;overflow:hidden}@media screen and (max-width: 1120px){.admin-wrapper{width:90vw;height:95vh;margin:2.5vh auto}}@media screen and (max-width: 740px){.admin-wrapper{width:100vw;height:95vh;height:calc(100vh - 24px);margin:0px 0px 0px 0px}}.admin-wrapper .sidebar-wrapper{position:static;height:auto;flex:0 0 auto;margin:2px}.admin-wrapper .content-wrapper{flex:1 1 auto;width:calc(100% - 20px);border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;position:relative;margin-left:10px;margin-right:10px;margin-bottom:40px;box-sizing:border-box}.admin-wrapper .content{background-color:#bfbfbf;width:100%;max-width:100%;min-height:100%;box-sizing:border-box;position:relative}.admin-wrapper .sidebar{position:static;background:#bfbfbf;color:#000;width:100%;height:auto;padding-bottom:20px}.admin-wrapper .sidebar .logo{position:absolute;top:2px;left:4px;width:18px;height:18px;margin:0px}.admin-wrapper .sidebar>ul{background:#bfbfbf;margin:0px;margin-left:8px;color:#000}.admin-wrapper .sidebar>ul>li{display:inline-block}.admin-wrapper .sidebar>ul>li#settings,.admin-wrapper .sidebar>ul>li#admin{padding:2px;border:0px solid transparent}.admin-wrapper .sidebar>ul>li#logout{position:absolute;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;right:12px;bottom:10px}.admin-wrapper .sidebar>ul>li#web{display:inline-block;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;position:absolute;left:12px;bottom:10px}.admin-wrapper .sidebar>ul>li>a{display:inline-block;box-shadow:inset -1px 0px 0px #000,inset 1px 0px 0px #fff,inset 0px 1px 0px #fff,inset 0px 2px 0px #dfdfdf,inset -2px 0px 0px gray,inset 2px 0px 0px #dfdfdf;border-radius:0px;border-top-left-radius:1px;border-top-right-radius:1px;padding:2px 5px;margin:0px;color:#000;vertical-align:baseline}.admin-wrapper .sidebar>ul>li>a.selected{background:#bfbfbf;color:#000;padding-top:4px;padding-bottom:4px}.admin-wrapper .sidebar>ul>li>a:hover{background:#bfbfbf;color:#000}.admin-wrapper .sidebar>ul>li>ul{width:calc(100% - 20px);background:transparent;position:absolute;left:10px;top:54px;z-index:3}.admin-wrapper .sidebar>ul>li>ul>li{background:#bfbfbf;display:inline-block;vertical-align:baseline}.admin-wrapper .sidebar>ul>li>ul>li>a{background:#bfbfbf;box-shadow:inset -1px 0px 0px #000,inset 1px 0px 0px #fff,inset 0px 1px 0px #fff,inset 0px 2px 0px #dfdfdf,inset -2px 0px 0px gray,inset 2px 0px 0px #dfdfdf;border-radius:0px;border-top-left-radius:1px;border-top-right-radius:1px;color:#000;padding:2px 5px;position:relative;z-index:3}.admin-wrapper .sidebar>ul>li>ul>li>a.selected{background:#bfbfbf;color:#000;padding-bottom:4px;padding-top:4px;padding-right:7px;margin-left:-2px;margin-right:-2px;position:relative;z-index:4}.admin-wrapper .sidebar>ul>li>ul>li>a.selected:first-child{margin-left:0px}.admin-wrapper .sidebar>ul>li>ul>li>a.selected:hover{background:transparent;color:#000}.admin-wrapper .sidebar>ul>li>ul>li>a:hover{background:#bfbfbf;color:#000}@media screen and (max-width: 1520px){.admin-wrapper .sidebar>ul>li>ul{max-width:1000px}.admin-wrapper .sidebar{padding-bottom:45px}}@media screen and (max-width: 600px){.admin-wrapper .sidebar>ul>li>ul{max-width:500px}.admin-wrapper .sidebar{padding:0px;padding-bottom:70px;width:100%;height:auto}.admin-wrapper .content-wrapper{overflow:auto;height:80%;height:calc(100% - 150px)}}.flash-message{background-color:#ffc;color:#000;border:1px solid #000;border-radius:0px;position:absolute;top:0px;left:0px;width:100%}.admin-wrapper table{background-color:#fff;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.admin-wrapper .content h2,.simple_form .input.with_label .label_input>label,.admin-wrapper .content h6,.admin-wrapper .content>p,.admin-wrapper .content .muted-hint,.simple_form span.hint,.simple_form h4,.simple_form .check_boxes .checkbox label,.simple_form .input.with_label.boolean .label_input>label,.filters .filter-subset a,.simple_form .input.radio_buttons .radio label,a.table-action-link,a.table-action-link:hover,.simple_form .input.with_block_label>label,.simple_form p.hint{color:#000}.table>tbody>tr:nth-child(2n+1)>td,.table>tbody>tr:nth-child(2n+1)>th{background-color:#fff}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{color:#000;background-color:#fff;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{background-color:#fff}.simple_form button,.simple_form .button,.simple_form .block-button{background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;color:#000;font-weight:normal}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background:#bfbfbf}.simple_form .warning,.table-form .warning{background:#ffc;color:#000;box-shadow:unset;text-shadow:unset;border:1px solid #000}.simple_form .warning a,.table-form .warning a{color:blue;text-decoration:underline}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#bfbfbf}.filters .filter-subset{border:2px groove #bfbfbf;padding:2px}.filters .filter-subset a::before{content:\"\";background-color:#fff;border-radius:50%;border:2px solid #000;border-top-color:#7f7f7f;border-left-color:#7f7f7f;border-bottom-color:#f5f5f5;border-right-color:#f5f5f5;width:12px;height:12px;display:inline-block;vertical-align:middle;margin-right:2px}.filters .filter-subset a.selected::before{background-color:#000;box-shadow:inset 0 0 0 3px #fff}.filters .filter-subset a,.filters .filter-subset a:hover,.filters .filter-subset a.selected{color:#000;border-bottom:0px solid transparent}","// win95 theme from cybrespace.\n\n// Modified by kibi! to use webpack package syntax for urls (eg,\n// `url(~images/…)`) for easy importing into skins.\n\n$win95-bg: #bfbfbf;\n$win95-dark-grey: #404040;\n$win95-mid-grey: #808080;\n$win95-window-header: #00007f;\n$win95-tooltip-yellow: #ffffcc;\n$win95-blue: blue;\n\n$ui-base-lighter-color: $win95-dark-grey;\n$ui-highlight-color: $win95-window-header;\n\n@mixin win95-border-outset() {\n border-left: 2px solid #efefef;\n border-top: 2px solid #efefef;\n border-right: 2px solid #404040;\n border-bottom: 2px solid #404040;\n border-radius:0px;\n}\n\n@mixin win95-outset() {\n box-shadow: inset -1px -1px 0px #000000,\n inset 1px 1px 0px #ffffff,\n inset -2px -2px 0px #808080,\n inset 2px 2px 0px #dfdfdf;\n border-radius:0px;\n}\n\n@mixin win95-border-inset() {\n border-left: 2px solid #404040;\n border-top: 2px solid #404040;\n border-right: 2px solid #efefef;\n border-bottom: 2px solid #efefef;\n border-radius:0px;\n}\n\n@mixin win95-border-slight-inset() {\n border-left: 1px solid #404040;\n border-top: 1px solid #404040;\n border-right: 1px solid #efefef;\n border-bottom: 1px solid #efefef;\n border-radius:0px;\n}\n\n@mixin win95-inset() {\n box-shadow: inset 1px 1px 0px #000000,\n inset -1px -1px 0px #ffffff,\n inset 2px 2px 0px #808080,\n inset -2px -2px 0px #dfdfdf;\n border-width:0px;\n border-radius:0px;\n}\n\n@mixin win95-tab() {\n box-shadow: inset -1px 0px 0px #000000,\n inset 1px 0px 0px #ffffff,\n inset 0px 1px 0px #ffffff,\n inset 0px 2px 0px #dfdfdf,\n inset -2px 0px 0px #808080,\n inset 2px 0px 0px #dfdfdf;\n border-radius:0px;\n border-top-left-radius: 1px;\n border-top-right-radius: 1px;\n}\n\n@mixin win95-reset() {\n box-shadow: unset;\n}\n\n@font-face {\n font-family:\"premillenium\";\n src: url('~fonts/premillenium/MSSansSerif.ttf') format('truetype');\n}\n\n@import 'application';\n\n/* borrowed from cybrespace style: wider columns and full column width images */\n\n@media screen and (min-width: 1300px) {\n .column {\n flex-grow: 1 !important;\n max-width: 400px;\n }\n\n .drawer {\n width: 17%;\n max-width: 400px;\n min-width: 330px;\n }\n}\n\n.media-gallery,\n.video-player {\n max-height:30vh;\n height:30vh !important;\n position:relative;\n margin-top:20px;\n margin-left:-68px;\n width: calc(100% + 80px) !important;\n max-width: calc(100% + 80px);\n}\n\n.detailed-status .media-gallery,\n.detailed-status .video-player {\n margin-left:-5px;\n width: calc(100% + 9px);\n max-width: calc(100% + 9px);\n}\n\n.video-player video {\n transform: unset;\n top: unset;\n}\n\n.detailed-status .media-spoiler,\n.status .media-spoiler {\n height: 100%!important;\n vertical-align: middle;\n}\n\n/* main win95 style */\n\nbody {\n font-size:13px;\n font-family: \"MS Sans Serif\", \"premillenium\", sans-serif;\n color:black;\n}\n\n.ui,\n.ui .columns-area,\nbody.admin {\n background: #008080;\n}\n\n.loading-bar {\n height:5px;\n background-color: #000080;\n}\n\n.tabs-bar {\n background: $win95-bg;\n @include win95-outset();\n height: 30px;\n}\n\n.tabs-bar__link {\n color:black;\n border:2px outset $win95-bg;\n border-top-width: 1px;\n border-left-width: 1px;\n margin:2px;\n padding:3px;\n}\n\n.tabs-bar__link.active {\n @include win95-inset();\n color:black;\n}\n\n.tabs-bar__link:last-child::before {\n content:\"Start\";\n color:black;\n font-weight:bold;\n font-size:15px;\n width:80%;\n display:block;\n position:absolute;\n right:0px;\n}\n\n.tabs-bar__link:last-child {\n position:relative;\n flex-basis:60px !important;\n font-size:0px;\n color:$win95-bg;\n\n background-image: url(\"~images/start.png\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n}\n\n.drawer .drawer__inner {\n overflow: visible;\n height:inherit;\n background:$win95-bg;\n}\n\n.drawer:after {\n display:block;\n content: \" \";\n\n position:absolute;\n bottom:15px;\n left:15px;\n width:132px;\n height:117px;\n background-image:url(\"~images/clippy_wave.gif\"), url(\"~images/clippy_frame.png\");\n background-repeat:no-repeat;\n background-position: 4px 20px, 0px 0px;\n z-index:0;\n}\n\n.drawer__pager {\n overflow-y:auto;\n z-index:1;\n}\n\n.privacy-dropdown__dropdown {\n z-index:2;\n}\n\n.column {\n max-height:100vh;\n}\n\n.column > .scrollable {\n background: $win95-bg;\n @include win95-border-outset();\n border-top-width:0px;\n}\n\n.column-header__wrapper {\n color:white;\n font-weight:bold;\n background:#7f7f7f;\n}\n\n.column-header {\n padding:2px;\n font-size:13px;\n background:#7f7f7f;\n @include win95-border-outset();\n border-bottom-width:0px;\n color:white;\n font-weight:bold;\n align-items:baseline;\n}\n\n.column-header__wrapper.active {\n background:$win95-window-header;\n}\n\n.column-header__wrapper.active::before {\n display:none;\n}\n.column-header.active {\n box-shadow:unset;\n background:$win95-window-header;\n}\n\n.column-header.active .column-header__icon {\n color:white;\n}\n\n.column-header__buttons {\n max-height: 20px;\n margin-right:0px;\n}\n\n.column-header__button {\n background: $win95-bg;\n color: black;\n line-height:0px;\n font-size:14px;\n max-height:20px;\n padding:0px 2px;\n margin-top:2px;\n @include win95-outset();\n\n &:hover {\n color: black;\n }\n}\n\n.column-header__button.active, .column-header__button.active:hover {\n @include win95-inset();\n background-color:#7f7f7f;\n}\n\n.column-header__back-button {\n background: $win95-bg;\n color: black;\n padding:2px;\n max-height:20px;\n margin-top:2px;\n @include win95-outset();\n font-size:13px;\n font-weight:bold;\n}\n\n.column-back-button {\n background:$win95-bg;\n color:black;\n @include win95-outset();\n padding:2px;\n font-size:13px;\n font-weight:bold;\n}\n\n.column-back-button--slim-button {\n position:absolute;\n top:-22px;\n right:4px;\n max-height:20px;\n max-width:60px;\n padding:0px 2px;\n}\n\n.column-back-button__icon {\n font-size:11px;\n margin-top:-3px;\n}\n\n.column-header__collapsible {\n border-left:2px outset $win95-bg;\n border-right:2px outset $win95-bg;\n}\n\n.column-header__collapsible-inner {\n background:$win95-bg;\n color:black;\n}\n\n.column-header__collapsible__extra {\n color:black;\n}\n\n.column-header__collapsible__extra div[role=\"group\"] {\n border: 2px groove $win95-bg;\n border-radius:4px;\n margin-bottom:8px;\n padding:4px;\n}\n\n.column-inline-form {\n background-color: $win95-bg;\n @include win95-border-outset();\n border-bottom-width:0px;\n border-top-width:0px;\n}\n\n.column-settings__section {\n color:black;\n font-weight:bold;\n font-size:11px;\n position:relative;\n top: -12px;\n left:4px;\n background-color:$win95-bg;\n display:inline-block;\n padding:0px 4px;\n margin-bottom:0px;\n}\n\n.setting-meta__label, .setting-toggle__label {\n color:black;\n font-weight:normal;\n}\n\n.setting-meta__label span:before {\n content:\"(\";\n}\n.setting-meta__label span:after {\n content:\")\";\n}\n\n.setting-toggle {\n line-height:13px;\n}\n\n.react-toggle .react-toggle-track {\n border-radius:0px;\n background-color:white;\n @include win95-border-inset();\n\n width:12px;\n height:12px;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color:white;\n}\n\n.react-toggle .react-toggle-track-check {\n left:2px;\n transition:unset;\n}\n\n.react-toggle .react-toggle-track-check svg path {\n fill: black;\n}\n\n.react-toggle .react-toggle-track-x {\n display:none;\n}\n\n.react-toggle .react-toggle-thumb {\n border-radius:0px;\n display:none;\n}\n\n.text-btn {\n background-color:$win95-bg;\n @include win95-outset();\n padding:4px;\n}\n\n.text-btn:hover {\n text-decoration:none;\n color:black;\n}\n\n.text-btn:active {\n @include win95-inset();\n}\n\n.setting-text {\n color:black;\n background-color:white;\n @include win95-inset();\n font-size:13px;\n padding:2px;\n}\n\n.setting-text:active, .setting-text:focus,\n.setting-text.light:active, .setting-text.light:focus {\n color:black;\n border-bottom:2px inset $win95-bg;\n}\n\n.column-header__setting-arrows .column-header__setting-btn {\n padding:3px 10px;\n}\n\n.column-header__setting-arrows .column-header__setting-btn:last-child {\n padding:3px 10px;\n}\n\n.missing-indicator {\n background-color:$win95-bg;\n color:black;\n @include win95-outset();\n}\n\n.missing-indicator > div {\n background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRUaXRsZQAACJnLyy9Jyy/NSwEAD5IDblIFOhoAAAAXelRYdEF1dGhvcgAACJlLzijKz0vMAQALmgLoDsFj8gAAAQpJREFUOMuVlD0OwjAMhd2oQl04Axfo0IGBgYELcAY6cqQuSO0ZOEAZGBg6VKg74gwsEaoESRVHjusI8aQqzY8/PbtOEz1qkFSn2YevlaNOpLMJh2DwvixhuXtOa6/LCh51DUMEFkAsgAZD207Doin8mQ562JpRE5CHBAAhmIqD1L8AqzUUUJkxc6kr3AgAJ+NuvIWRdk7WcrKl0AUqcIBBHOiEbpS4m27mIL5Onfg3k0rgggeQuS2sDOGSahKR+glgqaGLgUJs951NN1q9D72cQqQWR9cr3sm9YcEssEuz6eEuZh2bu0aSOhQ1MBezu2O/+TVSvEFII3qLsZWrSA2AAUQIh1HpyP/kC++zjVSMj6ntAAAAAElFTkSuQmCC')\n no-repeat;\n background-position:center center;\n}\n\n.empty-column-indicator,\n.error-column {\n background: $win95-bg;\n color: black;\n}\n\n.status__wrapper {\n border: 2px groove $win95-bg;\n margin:4px;\n}\n\n.status {\n @include win95-border-slight-inset();\n background-color:white;\n margin:4px;\n padding-bottom:40px;\n margin-bottom:8px;\n}\n\n.status.status-direct {\n background-color:$win95-bg;\n}\n\n.status__content {\n font-size:13px;\n}\n\n.status.light .status__relative-time,\n.status.light .display-name span {\n color: #7f7f7f;\n}\n\n.status__action-bar {\n box-sizing:border-box;\n position:absolute;\n bottom:-1px;\n left:-1px;\n background:$win95-bg;\n width:calc(100% + 2px);\n padding-left:10px;\n padding: 4px 2px;\n padding-bottom:4px;\n border-bottom:2px groove $win95-bg;\n border-top:1px outset $win95-bg;\n text-align: right;\n}\n\n.status__wrapper .status__action-bar {\n border-bottom-width:0px;\n}\n\n.status__action-bar-button {\n float:right;\n}\n\n.status__action-bar-dropdown {\n margin-left:auto;\n margin-right:10px;\n\n .icon-button {\n min-width:28px;\n }\n}\n.status.light .status__content a {\n color:blue;\n}\n\n.focusable:focus {\n background: $win95-bg;\n .detailed-status__action-bar {\n background: $win95-bg;\n }\n\n .status, .detailed-status {\n background: white;\n outline:2px dotted $win95-mid-grey;\n }\n}\n\n.dropdown__trigger.icon-button {\n padding-right:6px;\n}\n\n.detailed-status__action-bar-dropdown .icon-button {\n min-width:28px;\n}\n\n.detailed-status {\n background:white;\n background-clip:padding-box;\n margin:4px;\n border: 2px groove $win95-bg;\n padding:4px;\n}\n\n.detailed-status__display-name {\n color:#7f7f7f;\n}\n\n.detailed-status__display-name strong {\n color:black;\n font-weight:bold;\n}\n.account__avatar,\n.account__avatar-overlay-base,\n.account__header__avatar,\n.account__avatar-overlay-overlay {\n @include win95-border-slight-inset();\n clip-path:none;\n filter: saturate(1.8) brightness(1.1);\n}\n\n.detailed-status__action-bar {\n background-color:$win95-bg;\n border:0px;\n border-bottom:2px groove $win95-bg;\n margin-bottom:8px;\n justify-items:left;\n padding-left:4px;\n}\n.icon-button {\n background:$win95-bg;\n @include win95-border-outset();\n padding:0px 0px 0px 0px;\n margin-right:4px;\n\n color:#3f3f3f;\n &.inverted, &:hover, &.inverted:hover, &:active, &:focus {\n color:#3f3f3f;\n }\n}\n\n.icon-button:active {\n @include win95-border-inset();\n}\n\n.status__action-bar > .icon-button {\n padding:0px 15px 0px 0px;\n min-width:25px;\n}\n\n.icon-button.star-icon,\n.icon-button.star-icon:active {\n background:transparent;\n border:none;\n}\n\n.icon-button.star-icon.active {\n color: $gold-star;\n &:active, &:hover, &:focus {\n color: $gold-star;\n }\n}\n\n.icon-button.star-icon > i {\n background:$win95-bg;\n @include win95-border-outset();\n padding-bottom:3px;\n}\n\n.icon-button.star-icon:active > i {\n @include win95-border-inset();\n}\n\n.text-icon-button {\n color:$win95-dark-grey;\n}\n\n.detailed-status__action-bar-dropdown {\n margin-left:auto;\n justify-content:right;\n padding-right:16px;\n}\n\n.detailed-status__button {\n flex:0 0 auto;\n}\n\n.detailed-status__button .icon-button {\n padding-left:2px;\n padding-right:25px;\n}\n\n.status-card {\n border-radius:0px;\n background:white;\n border: 1px solid black;\n color:black;\n}\n\n.status-card:hover {\n background-color:white;\n}\n\n.status-card__title {\n color:blue;\n text-decoration:underline;\n font-weight:bold;\n}\n\n.load-more {\n width:auto;\n margin:5px auto;\n background: $win95-bg;\n @include win95-outset();\n color:black;\n padding: 2px 5px;\n\n &:hover {\n background: $win95-bg;\n color:black;\n }\n}\n\n.status-card__description {\n color:black;\n}\n\n.account__display-name strong, .status__display-name strong {\n color:black;\n font-weight:bold;\n}\n\n.account .account__display-name {\n color:black;\n}\n\n.account {\n border-bottom: 2px groove $win95-bg;\n}\n\n.reply-indicator__content .status__content__spoiler-link, .status__content .status__content__spoiler-link {\n background:$win95-bg;\n @include win95-outset();\n}\n\n.reply-indicator__content .status__content__spoiler-link:hover, .status__content .status__content__spoiler-link:hover {\n background:$win95-bg;\n}\n\n.reply-indicator__content .status__content__spoiler-link:active, .status__content .status__content__spoiler-link:active {\n @include win95-inset();\n}\n\n.reply-indicator__content a, .status__content a {\n color:blue;\n}\n\n.notification {\n border: 2px groove $win95-bg;\n margin:4px;\n}\n\n.notification__message {\n color:black;\n font-size:13px;\n}\n\n.notification__display-name {\n font-weight:bold;\n}\n\n.drawer__header {\n background: $win95-bg;\n @include win95-border-outset();\n justify-content:left;\n margin-bottom:0px;\n padding-bottom:2px;\n border-bottom:2px groove $win95-bg;\n}\n\n.drawer__tab {\n color:black;\n @include win95-outset();\n padding:5px;\n margin:2px;\n flex: 0 0 auto;\n}\n\n.drawer__tab:first-child::before {\n content:\"Start\";\n color:black;\n font-weight:bold;\n font-size:15px;\n width:80%;\n display:block;\n position:absolute;\n right:0px;\n\n}\n\n.drawer__tab:first-child {\n position:relative;\n padding:5px 15px;\n width:40px;\n font-size:0px;\n color:$win95-bg;\n\n background-image: url(\"~images/start.png\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n}\n\n.drawer__header a:hover {\n background-color:transparent;\n}\n\n.drawer__header a:first-child:hover {\n background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n transition:unset;\n}\n\n.drawer__tab:first-child {\n\n}\n\n.search {\n background:$win95-bg;\n padding-top:2px;\n padding:2px;\n border:2px outset $win95-bg;\n border-top-width:0px;\n border-bottom: 2px groove $win95-bg;\n margin-bottom:0px;\n}\n\n.search input {\n background-color:white;\n color:black;\n @include win95-border-slight-inset();\n}\n\n.search__input:focus {\n background-color:white;\n}\n\n.search-popout {\n box-shadow: unset;\n color:black;\n border-radius:0px;\n background-color:$win95-tooltip-yellow;\n border:1px solid black;\n\n h4 {\n color:black;\n text-transform: none;\n font-weight:bold;\n }\n}\n\n.search-results__header {\n background-color: $win95-bg;\n color:black;\n border-bottom:2px groove $win95-bg;\n}\n\n.search-results__hashtag {\n color:blue;\n}\n\n.search-results__section .account:hover,\n.search-results__section .account:hover .account__display-name,\n.search-results__section .account:hover .account__display-name strong,\n.search-results__section .search-results__hashtag:hover {\n background-color:$win95-window-header;\n color:white;\n}\n\n.search__icon .fa {\n color:#808080;\n\n &.active {\n opacity:1.0;\n }\n\n &:hover {\n color: #808080;\n }\n}\n\n.drawer__inner,\n.drawer__inner.darker {\n background-color:$win95-bg;\n border: 2px outset $win95-bg;\n border-top-width:0px;\n}\n\n.navigation-bar {\n color:black;\n}\n\n.navigation-bar strong {\n color:black;\n font-weight:bold;\n}\n\n.compose-form .autosuggest-textarea__textarea,\n.compose-form .spoiler-input__input {\n border-radius:0px;\n @include win95-border-slight-inset();\n}\n\n.compose-form .autosuggest-textarea__textarea {\n border-bottom:0px;\n}\n\n.compose-form__uploads-wrapper {\n border-radius:0px;\n border-bottom:1px inset $win95-bg;\n border-top-width:0px;\n}\n\n.compose-form__upload-wrapper {\n border-left:1px inset $win95-bg;\n border-right:1px inset $win95-bg;\n}\n\n.compose-form .compose-form__buttons-wrapper {\n background-color: $win95-bg;\n border:2px groove $win95-bg;\n margin-top:4px;\n padding:4px 8px;\n}\n\n.compose-form__buttons {\n background-color:$win95-bg;\n border-radius:0px;\n box-shadow:unset;\n}\n\n.compose-form__buttons-separator {\n border-left: 2px groove $win95-bg;\n}\n\n.privacy-dropdown.active .privacy-dropdown__value.active,\n.advanced-options-dropdown.open .advanced-options-dropdown__value {\n background: $win95-bg;\n}\n\n.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {\n color: $win95-dark-grey;\n}\n\n.privacy-dropdown.active\n.privacy-dropdown__value {\n background: $win95-bg;\n box-shadow:unset;\n}\n\n.privacy-dropdown__option.active, .privacy-dropdown__option:hover,\n.privacy-dropdown__option.active:hover {\n background:$win95-window-header;\n}\n\n.privacy-dropdown__dropdown,\n.privacy-dropdown.active .privacy-dropdown__dropdown,\n.advanced-options-dropdown__dropdown,\n.advanced-options-dropdown.open .advanced-options-dropdown__dropdown\n{\n box-shadow:unset;\n color:black;\n @include win95-outset();\n background: $win95-bg;\n}\n\n.privacy-dropdown__option__content {\n color:black;\n}\n\n.privacy-dropdown__option__content strong {\n font-weight:bold;\n}\n\n.compose-form__warning::before {\n content:\"Tip:\";\n font-weight:bold;\n display:block;\n position:absolute;\n top:-10px;\n background-color:$win95-bg;\n font-size:11px;\n padding: 0px 5px;\n}\n\n.compose-form__warning {\n position:relative;\n box-shadow:unset;\n border:2px groove $win95-bg;\n background-color:$win95-bg;\n color:black;\n}\n\n.compose-form__warning a {\n color:blue;\n}\n\n.compose-form__warning strong {\n color:black;\n text-decoration:underline;\n}\n\n.compose-form__buttons button.active:last-child {\n @include win95-border-inset();\n background: #dfdfdf;\n color:#7f7f7f;\n}\n\n.compose-form__upload-thumbnail {\n border-radius:0px;\n border:2px groove $win95-bg;\n background-color:$win95-bg;\n padding:2px;\n box-sizing:border-box;\n}\n\n.compose-form__upload-thumbnail .icon-button {\n max-width:20px;\n max-height:20px;\n line-height:10px !important;\n}\n\n.compose-form__upload-thumbnail .icon-button::before {\n content:\"X\";\n font-size:13px;\n font-weight:bold;\n color:black;\n}\n\n.compose-form__upload-thumbnail .icon-button i {\n display:none;\n}\n\n.emoji-picker-dropdown__menu {\n z-index:2;\n}\n\n.emoji-dialog.with-search {\n box-shadow:unset;\n border-radius:0px;\n background-color:$win95-bg;\n border:1px solid black;\n box-sizing:content-box;\n\n}\n\n.emoji-dialog .emoji-search {\n color:black;\n background-color:white;\n border-radius:0px;\n @include win95-inset();\n}\n\n.emoji-dialog .emoji-search-wrapper {\n border-bottom:2px groove $win95-bg;\n}\n\n.emoji-dialog .emoji-category-title {\n color:black;\n font-weight:bold;\n}\n\n.reply-indicator {\n background-color:$win95-bg;\n border-radius:3px;\n border:2px groove $win95-bg;\n}\n\n.button {\n background-color:$win95-bg;\n @include win95-outset();\n border-radius:0px;\n color:black;\n font-weight:bold;\n\n &:hover, &:focus, &:disabled {\n background-color:$win95-bg;\n }\n\n &:active {\n @include win95-inset();\n }\n\n &:disabled {\n color: #808080;\n text-shadow: 1px 1px 0px #efefef;\n\n &:active {\n @include win95-outset();\n }\n }\n\n}\n\n#Getting-started {\n background-color:$win95-bg;\n @include win95-inset();\n border-bottom-width:0px;\n}\n\n#Getting-started::before {\n content:\"Start\";\n color:black;\n font-weight:bold;\n font-size:15px;\n width:80%;\n text-align:center;\n display:block;\n position:absolute;\n right:2px;\n}\n\n#Getting-started {\n position:relative;\n padding:5px 15px;\n width:60px;\n font-size:0px;\n color:$win95-bg;\n\n background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n}\n\n.column-subheading {\n background-color:$win95-bg;\n color:black;\n border-bottom: 2px groove $win95-bg;\n text-transform: none;\n font-size: 16px;\n}\n\n.column-link {\n background-color:transparent;\n color:black;\n &:hover {\n background-color: $win95-window-header;\n color:white;\n }\n}\n\n.getting-started__wrapper {\n .column-subheading {\n font-size:0px;\n margin:0px;\n padding:0px;\n }\n\n .column-link {\n background-size:32px 32px;\n background-repeat:no-repeat;\n background-position: 36px 50%;\n padding-left:40px;\n\n &:hover {\n background-size:32px 32px;\n background-repeat:no-repeat;\n background-position: 36px 50%;\n }\n\n i {\n font-size: 0px;\n width:32px;\n }\n }\n}\n\n.column-link[href=\"/web/timelines/public\"] {\n background-image: url(\"~images/icon_public.png\");\n &:hover { background-image: url(\"~images/icon_public.png\"); }\n}\n.column-link[href=\"/web/timelines/public/local\"] {\n background-image: url(\"~images/icon_local.png\");\n &:hover { background-image: url(\"~images/icon_local.png\"); }\n}\n.column-link[href=\"/web/pinned\"] {\n background-image: url(\"~images/icon_pin.png\");\n &:hover { background-image: url(\"~images/icon_pin.png\"); }\n}\n.column-link[href=\"/web/favourites\"] {\n background-image: url(\"~images/icon_likes.png\");\n &:hover { background-image: url(\"~images/icon_likes.png\"); }\n}\n.column-link[href=\"/web/lists\"] {\n background-image: url(\"~images/icon_lists.png\");\n &:hover { background-image: url(\"~images/icon_lists.png\"); }\n}\n.column-link[href=\"/web/follow_requests\"] {\n background-image: url(\"~images/icon_follow_requests.png\");\n &:hover { background-image: url(\"~images/icon_follow_requests.png\"); }\n}\n.column-link[href=\"/web/keyboard-shortcuts\"] {\n background-image: url(\"~images/icon_keyboard_shortcuts.png\");\n &:hover { background-image: url(\"~images/icon_keyboard_shortcuts.png\"); }\n}\n.column-link[href=\"/web/blocks\"] {\n background-image: url(\"~images/icon_blocks.png\");\n &:hover { background-image: url(\"~images/icon_blocks.png\"); }\n}\n.column-link[href=\"/web/mutes\"] {\n background-image: url(\"~images/icon_mutes.png\");\n &:hover { background-image: url(\"~images/icon_mutes.png\"); }\n}\n.column-link[href=\"/settings/preferences\"] {\n background-image: url(\"~images/icon_settings.png\");\n &:hover { background-image: url(\"~images/icon_settings.png\"); }\n}\n.column-link[href=\"/about/more\"] {\n background-image: url(\"~images/icon_about.png\");\n &:hover { background-image: url(\"~images/icon_about.png\"); }\n}\n.column-link[href=\"/auth/sign_out\"] {\n background-image: url(\"~images/icon_logout.png\");\n &:hover { background-image: url(\"~images/icon_logout.png\"); }\n}\n\n.getting-started__footer {\n display:none;\n}\n\n.getting-started__wrapper::before {\n content:\"Mastodon 95\";\n font-weight:bold;\n font-size:23px;\n color:white;\n line-height:30px;\n padding-left:20px;\n padding-right:40px;\n\n left:0px;\n bottom:-30px;\n display:block;\n position:absolute;\n background-color:#7f7f7f;\n width:200%;\n height:30px;\n\n -ms-transform: rotate(-90deg);\n\n -webkit-transform: rotate(-90deg);\n transform: rotate(-90deg);\n transform-origin:top left;\n}\n\n.getting-started__wrapper {\n @include win95-border-outset();\n background-color:$win95-bg;\n}\n\n.column .static-content.getting-started {\n display:none;\n}\n\n.keyboard-shortcuts kbd {\n background-color: $win95-bg;\n}\n\n.account__header {\n background-color:#7f7f7f;\n}\n\n.account__header .account__header__content {\n color:white;\n}\n\n.account-authorize__wrapper {\n border: 2px groove $win95-bg;\n margin: 2px;\n padding:2px;\n}\n\n.account--panel {\n background-color: $win95-bg;\n border:0px;\n border-top: 2px groove $win95-bg;\n}\n\n.account-authorize .account__header__content {\n color:black;\n margin:10px;\n}\n\n.account__action-bar__tab > span {\n color:black;\n font-weight:bold;\n}\n\n.account__action-bar__tab strong {\n color:black;\n}\n\n.account__action-bar {\n border: unset;\n}\n\n.account__action-bar__tab {\n border: 1px outset $win95-bg;\n}\n\n.account__action-bar__tab:active {\n @include win95-inset();\n}\n\n.dropdown--active .dropdown__content > ul,\n.dropdown-menu {\n background:$win95-tooltip-yellow;\n border-radius:0px;\n border:1px solid black;\n box-shadow:unset;\n}\n\n.dropdown-menu a {\n background-color:transparent;\n}\n\n.dropdown--active::after {\n display:none;\n}\n\n.dropdown--active .icon-button {\n color:black;\n @include win95-inset();\n}\n\n.dropdown--active .dropdown__content > ul > li > a {\n background:transparent;\n}\n\n.dropdown--active .dropdown__content > ul > li > a:hover {\n background:transparent;\n color:black;\n text-decoration:underline;\n}\n\n.dropdown__sep,\n.dropdown-menu__separator\n{\n border-color:#7f7f7f;\n}\n\n.detailed-status__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__left {\n left:unset;\n}\n\n.dropdown > .icon-button, .detailed-status__button > .icon-button,\n.status__action-bar > .icon-button, .star-icon i {\n /* i don't know what's going on with the inline\n styles someone should look at the react code */\n height: 25px !important;\n width: 28px !important;\n box-sizing: border-box;\n}\n\n.status__action-bar-button .fa-floppy-o {\n padding-top: 2px;\n}\n\n.status__action-bar-dropdown {\n position: relative;\n top: -3px;\n}\n\n.detailed-status__action-bar-dropdown .dropdown {\n position: relative;\n top: -4px;\n}\n\n.notification .status__action-bar {\n border-bottom: none;\n}\n\n.notification .status {\n margin-bottom: 4px;\n}\n\n.status__wrapper .status {\n margin-bottom: 3px;\n}\n\n.status__wrapper {\n margin-bottom: 8px;\n}\n\n.icon-button .fa-retweet {\n position: relative;\n top: -1px;\n}\n\n.embed-modal, .error-modal, .onboarding-modal,\n.actions-modal, .boost-modal, .confirmation-modal, .report-modal {\n @include win95-outset();\n background:$win95-bg;\n}\n\n.actions-modal::before,\n.boost-modal::before,\n.confirmation-modal::before,\n.report-modal::before {\n content: \"Confirmation\";\n display:block;\n background:$win95-window-header;\n color:white;\n font-weight:bold;\n padding-left:2px;\n}\n\n.boost-modal::before {\n content: \"Boost confirmation\";\n}\n\n.boost-modal__action-bar > div > span:before {\n content: \"Tip: \";\n font-weight:bold;\n}\n\n.boost-modal__action-bar, .confirmation-modal__action-bar, .report-modal__action-bar {\n background:$win95-bg;\n margin-top:-15px;\n}\n\n.embed-modal h4, .error-modal h4, .onboarding-modal h4 {\n background:$win95-window-header;\n color:white;\n font-weight:bold;\n padding:2px;\n font-size:13px;\n text-align:left;\n}\n\n.confirmation-modal__action-bar {\n .confirmation-modal__cancel-button {\n color:black;\n\n &:active,\n &:focus,\n &:hover {\n color:black;\n }\n\n &:active {\n @include win95-inset();\n }\n }\n}\n\n.embed-modal .embed-modal__container .embed-modal__html,\n.embed-modal .embed-modal__container .embed-modal__html:focus {\n background:white;\n color:black;\n @include win95-inset();\n}\n\n.modal-root__overlay,\n.account__header > div {\n background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFnpUWHRUaXRsZQAACJnLzU9JzElKBwALgwLXaCRlPwAAABd6VFh0QXV0aG9yAAAImUvOKMrPS8wBAAuaAugOwWPyAAAAEUlEQVQImWNgYGD4z4AE/gMADwMB/414xEUAAAAASUVORK5CYII=');\n}\n\n.admin-wrapper::before {\n position:absolute;\n top:0px;\n content:\"Control Panel\";\n color:white;\n background-color:$win95-window-header;\n font-size:13px;\n font-weight:bold;\n width:calc(100%);\n margin: 2px;\n display:block;\n padding:2px;\n padding-left:22px;\n box-sizing:border-box;\n}\n\n.admin-wrapper {\n position:relative;\n background: $win95-bg;\n @include win95-outset();\n width:70vw;\n height:80vh;\n margin:10vh auto;\n color: black;\n padding-top:24px;\n flex-direction:column;\n overflow:hidden;\n}\n\n@media screen and (max-width: 1120px) {\n .admin-wrapper {\n width:90vw;\n height:95vh;\n margin:2.5vh auto;\n }\n}\n\n@media screen and (max-width: 740px) {\n .admin-wrapper {\n width:100vw;\n height:95vh;\n height:calc(100vh - 24px);\n margin:0px 0px 0px 0px;\n }\n}\n\n.admin-wrapper .sidebar-wrapper {\n position:static;\n height:auto;\n flex: 0 0 auto;\n margin:2px;\n}\n\n.admin-wrapper .content-wrapper {\n flex: 1 1 auto;\n width:calc(100% - 20px);\n @include win95-border-outset();\n position:relative;\n margin-left:10px;\n margin-right:10px;\n margin-bottom:40px;\n box-sizing:border-box;\n}\n\n.admin-wrapper .content {\n background-color: $win95-bg;\n width: 100%;\n max-width:100%;\n min-height:100%;\n box-sizing:border-box;\n position:relative;\n}\n\n.admin-wrapper .sidebar {\n position:static;\n background: $win95-bg;\n color:black;\n width: 100%;\n height:auto;\n padding-bottom: 20px;\n}\n\n.admin-wrapper .sidebar .logo {\n position:absolute;\n top:2px;\n left:4px;\n width:18px;\n height:18px;\n margin:0px;\n}\n\n.admin-wrapper .sidebar > ul {\n background: $win95-bg;\n margin:0px;\n margin-left:8px;\n color:black;\n\n & > li {\n display:inline-block;\n\n &#settings,\n &#admin {\n padding:2px;\n border: 0px solid transparent;\n }\n\n &#logout {\n position:absolute;\n @include win95-outset();\n right:12px;\n bottom:10px;\n }\n\n &#web {\n display:inline-block;\n @include win95-outset();\n position:absolute;\n left: 12px;\n bottom: 10px;\n }\n\n & > a {\n display:inline-block;\n @include win95-tab();\n padding:2px 5px;\n margin:0px;\n color:black;\n vertical-align:baseline;\n\n &.selected {\n background: $win95-bg;\n color:black;\n padding-top: 4px;\n padding-bottom:4px;\n }\n\n &:hover {\n background: $win95-bg;\n color:black;\n }\n }\n\n & > ul {\n width:calc(100% - 20px);\n background: transparent;\n position:absolute;\n left: 10px;\n top:54px;\n z-index:3;\n\n & > li {\n background: $win95-bg;\n display: inline-block;\n vertical-align:baseline;\n\n & > a {\n background: $win95-bg;\n @include win95-tab();\n color:black;\n padding:2px 5px;\n position:relative;\n z-index:3;\n\n &.selected {\n background: $win95-bg;\n color:black;\n padding-bottom:4px;\n padding-top: 4px;\n padding-right:7px;\n margin-left:-2px;\n margin-right:-2px;\n position:relative;\n z-index:4;\n\n &:first-child {\n margin-left:0px;\n }\n\n &:hover {\n background: transparent;\n color:black;\n }\n }\n\n &:hover {\n background: $win95-bg;\n color:black;\n }\n }\n }\n }\n }\n}\n\n@media screen and (max-width: 1520px) {\n .admin-wrapper .sidebar > ul > li > ul {\n max-width:1000px;\n }\n\n .admin-wrapper .sidebar {\n padding-bottom: 45px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .admin-wrapper .sidebar > ul > li > ul {\n max-width:500px;\n }\n\n .admin-wrapper {\n .sidebar {\n padding:0px;\n padding-bottom: 70px;\n width: 100%;\n height: auto;\n }\n .content-wrapper {\n overflow:auto;\n height:80%;\n height:calc(100% - 150px);\n }\n }\n}\n\n.flash-message {\n background-color:$win95-tooltip-yellow;\n color:black;\n border:1px solid black;\n border-radius:0px;\n position:absolute;\n top:0px;\n left:0px;\n width:100%;\n}\n\n.admin-wrapper table {\n background-color: white;\n @include win95-border-slight-inset();\n}\n\n.admin-wrapper .content h2,\n.simple_form .input.with_label .label_input > label,\n.admin-wrapper .content h6,\n.admin-wrapper .content > p,\n.admin-wrapper .content .muted-hint,\n.simple_form span.hint,\n.simple_form h4,\n.simple_form .check_boxes .checkbox label,\n.simple_form .input.with_label.boolean .label_input > label,\n.filters .filter-subset a,\n.simple_form .input.radio_buttons .radio label,\na.table-action-link,\na.table-action-link:hover,\n.simple_form .input.with_block_label > label,\n.simple_form p.hint {\n color:black;\n}\n\n.table > tbody > tr:nth-child(2n+1) > td,\n.table > tbody > tr:nth-child(2n+1) > th {\n background-color:white;\n}\n\n.simple_form input[type=text],\n.simple_form input[type=number],\n.simple_form input[type=email],\n.simple_form input[type=password],\n.simple_form textarea {\n color:black;\n background-color:white;\n @include win95-border-slight-inset();\n\n &:active, &:focus {\n background-color:white;\n }\n}\n\n.simple_form button,\n.simple_form .button,\n.simple_form .block-button\n{\n background: $win95-bg;\n @include win95-outset();\n color:black;\n font-weight: normal;\n\n &:hover {\n background: $win95-bg;\n }\n}\n\n.simple_form .warning, .table-form .warning\n{\n background: $win95-tooltip-yellow;\n color:black;\n box-shadow: unset;\n text-shadow:unset;\n border:1px solid black;\n\n a {\n color: blue;\n text-decoration:underline;\n }\n}\n\n.simple_form button.negative,\n.simple_form .button.negative,\n.simple_form .block-button.negative\n{\n background: $win95-bg;\n}\n\n.filters .filter-subset {\n border: 2px groove $win95-bg;\n padding:2px;\n}\n\n.filters .filter-subset a::before {\n content: \"\";\n background-color:white;\n border-radius:50%;\n border:2px solid black;\n border-top-color:#7f7f7f;\n border-left-color:#7f7f7f;\n border-bottom-color:#f5f5f5;\n border-right-color:#f5f5f5;\n width:12px;\n height:12px;\n display:inline-block;\n vertical-align:middle;\n margin-right:2px;\n}\n\n.filters .filter-subset a.selected::before {\n background-color:black;\n box-shadow: inset 0 0 0 3px white;\n}\n\n.filters .filter-subset a,\n.filters .filter-subset a:hover,\n.filters .filter-subset a.selected {\n color:black;\n border-bottom: 0px solid transparent;\n}\n","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 15px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 5px;\n right: 5px;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 12px;\n padding: 0 6px;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n strong {\n color: $inverted-text-color;\n }\n\n span {\n color: $light-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n cursor: default;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n font-size: 12px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 100%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 13px;\n font-weight: 500;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 13px;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n\n &.active {\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n top: 35px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 13px;\n font-weight: 400;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n font-size: 12px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 13px;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n font-size: 24px;\n line-height: 24px;\n margin-left: 2px;\n width: 24px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n margin-top: 2px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n font-size: 13px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &.autoplay {\n .media-gallery__gifv__label {\n display: none;\n }\n }\n\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n display: inline-block;\n border-radius: 4px;\n background: darken($ui-primary-color, 14%);\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__text {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-width: 4px;\n background: none;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 52px;\n font-weight: 700;\n padding: 0 10px;\n padding-left: 8px;\n text-align: right;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 52px;\n }\n\n &__vote__mark {\n float: left;\n line-height: 18px;\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__text {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n font-weight: 700;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n font-size: 14px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n font-size: 13px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n font-size: 13px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n margin-bottom: 20px;\n line-height: 20px;\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n padding: 10px;\n background: $ui-base-color;\n color: $darker-text-color;\n border-radius: 4px 4px 0 0;\n font-size: 14px;\n position: relative;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n &__extras {\n background: lighten($ui-base-color, 6%);\n border-radius: 0 0 4px 4px;\n padding: 10px;\n color: $darker-text-color;\n font-family: $font-monospace, monospace;\n font-size: 12px;\n word-wrap: break-word;\n min-height: 20px;\n }\n\n &__icon {\n font-size: 28px;\n margin-right: 10px;\n color: $dark-text-color;\n }\n\n &__icon__overlay {\n position: absolute;\n top: 10px;\n right: 10px;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n\n &.positive {\n background: $success-green;\n }\n\n &.negative {\n background: lighten($error-red, 12%);\n }\n\n &.neutral {\n background: $ui-highlight-color;\n }\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n\n .diff-old {\n color: lighten($error-red, 12%);\n }\n\n .diff-neutral {\n color: $secondary-text-color;\n }\n\n .diff-new {\n color: $success-green;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n"],"sourceRoot":""} \ No newline at end of file +{"version":3,"sources":["webpack:///common.scss","webpack:///./app/javascript/styles/win95.scss","webpack:///./app/javascript/styles/mastodon/reset.scss","webpack:///./app/javascript/styles/mastodon/variables.scss","webpack:///./app/javascript/styles/mastodon/basics.scss","webpack:///./app/javascript/styles/mastodon/containers.scss","webpack:///./app/javascript/styles/mastodon/lists.scss","webpack:///./app/javascript/styles/mastodon/footer.scss","webpack:///./app/javascript/styles/mastodon/compact_header.scss","webpack:///./app/javascript/styles/mastodon/widgets.scss","webpack:///./app/javascript/styles/mastodon/forms.scss","webpack:///./app/javascript/styles/mastodon/accounts.scss","webpack:///./app/javascript/styles/mastodon/statuses.scss","webpack:///./app/javascript/styles/mastodon/boost.scss","webpack:///./app/javascript/styles/mastodon/components.scss","webpack:///","webpack:///./app/javascript/styles/mastodon/_mixins.scss","webpack:///./app/javascript/styles/mastodon/polls.scss","webpack:///./app/javascript/styles/mastodon/modal.scss","webpack:///./app/javascript/styles/mastodon/emoji_picker.scss","webpack:///./app/javascript/styles/mastodon/about.scss","webpack:///./app/javascript/styles/mastodon/tables.scss","webpack:///./app/javascript/styles/mastodon/admin.scss","webpack:///./app/javascript/styles/mastodon/dashboard.scss","webpack:///./app/javascript/styles/mastodon/rtl.scss","webpack:///./app/javascript/styles/mastodon/accessibility.scss"],"names":[],"mappings":"AAAA,WCwEA,wBACE,+DACA,4ZCrEF,QAaE,UACA,SACA,eACA,aACA,wBACA,+EAIF,aAEE,MAGF,aACE,OAGF,eACE,cAGF,WACE,qDAGF,UAEE,aACA,OAGF,wBACE,iBACA,MAGF,sCACE,qBAGF,UACE,YACA,2BAGF,kBACE,cACA,mBACA,iCAGF,kBACE,kCAGF,kBACE,2BAGF,aACE,gBACA,0BACA,CCtEW,iED6Eb,kBC7Ea,4BDiFb,sBACE,MErFF,iDACE,mBACA,CACA,gBACA,gBACA,WDXM,kCCaN,6BACA,8BACA,CADA,0BACA,CADA,qBACA,0CACA,wCACA,kBAEA,iKAYE,eAGF,SACE,oCAEA,WACE,iBACA,kBACA,uCAGF,iBACE,WACA,YACA,mCAGF,iBACE,cAIJ,kBD7CW,kBCiDX,iBACE,kBACA,0BAEA,iBACE,aAIJ,iBACE,YAGF,kBACE,SACA,iBACA,uBAEA,iBACE,WACA,YACA,gBACA,YAIJ,kBACE,UACA,YAGF,iBACE,kBACA,cD3EoB,mBAPX,WCqFT,YACA,UACA,aACA,uBACA,mBACA,oBAEA,qBACE,YACA,sCAGE,aACE,gBACA,WACA,YACA,kBACA,uBAIJ,cACE,iBACA,gBACA,QAMR,mBACE,eACA,cAEA,YACE,kDAKF,YAGE,WACA,mBACA,uBACA,oBACA,sBAGF,YACE,yEAKF,gBAEE,+EAKF,WAEE,sCAIJ,qBAEE,eACA,gBACA,gBACA,cACA,kBACA,8CAEA,eACE,0CAGF,mBACE,gEAEA,eACE,0CAIJ,aHlLoB,kKGqLlB,oBAGE,sDAIJ,aH9LgB,eGgMd,0DAEA,aHlMc,oDGuMhB,cACE,SACA,uBACA,cH1Mc,aG4Md,UACA,SACA,oBACA,eACA,UACA,4BACA,0BACA,gMAEA,oBAGE,kEAGF,aD9NY,gBCgOV,gBCnON,WACE,CACA,kBACA,qCAEA,eALF,UAMI,SACA,kBAIJ,sBACE,qCAEA,gBAHF,kBAII,qBAGF,YACE,uBACA,mBACA,wBAEA,SFrBI,YEuBF,kBACA,sBAGF,YACE,uBACA,mBACA,WF9BE,qBEgCF,UACA,kBACA,iBACA,6CACA,gBACA,eACA,mCAMJ,WACE,CACA,cACA,mBACA,sBACA,qCAEA,kCAPF,UAQI,aACA,aACA,kBAKN,WACE,CACA,YACA,eACA,iBACA,sBACA,CACA,gBACA,CACA,sBACA,qCAEA,gBAZF,UAaI,CACA,eACA,CACA,mBACA,0BAGF,UACE,YACA,iBACA,6BAEA,UACE,YACA,cACA,SACA,kBACA,uBAIJ,aACE,cF7EsB,wBE+EtB,iCAEA,aACE,gBACA,uBACA,gBACA,8BAIJ,aACE,eACA,iBACA,gBACA,SAIJ,YACE,cACA,8BACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,qCAGF,QA3BF,UA4BI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,UAKN,YACE,cACA,8CACA,sBACA,mCACA,CADA,0BACA,mBAEA,eACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,mBAGF,eACE,WACA,mBAGF,aACE,WACA,uCAGF,eACE,wBAGF,kBACE,qCAGF,QAxCF,iDAyCI,uCAEA,YACE,aACA,mBACA,uBACA,iCAGF,UACE,uBACA,mBACA,sBAGF,YACE,sCAIJ,QA7DF,UA8DI,qCACA,mBAEA,aACE,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,mBAGF,aACE,WACA,sCAMJ,eADF,gBAEI,4BAGF,eACE,qCAEA,0BAHF,SAII,yBAIJ,kBACE,mCACA,kBACA,YACA,cACA,aACA,oBACA,uBACA,iBACA,gBACA,qCAEA,uBAZF,cAaI,WACA,MACA,OACA,SACA,gBACA,gBACA,YACA,6BAGF,cACE,eACA,kCAGF,YACE,oBACA,2BACA,iBACA,oCAGF,YACE,oBACA,uBACA,iBACA,mCAGF,YACE,oBACA,yBACA,iBACA,+BAGF,aACE,aACA,mCAEA,aACE,YACA,WACA,kBACA,YACA,UFxUA,qCE2UA,kCARF,WASI,+GAIJ,kBAGE,kCAIJ,YACE,mBACA,eACA,eACA,gBACA,qBACA,cF7UkB,mBE+UlB,kBACA,uHAEA,yBAGE,WFrWA,qCEyWF,0CACE,YACE,qCAKN,kBACE,CACA,oBACA,kBACA,6HAEA,oBAGE,mBACA,sBAON,YACE,cACA,0DACA,sBACA,mCACA,CADA,0BACA,gCAEA,UACE,cACA,gCAGF,UACE,cACA,qCAGF,qBAjBF,0BAkBI,WACA,gCAEA,YACE,kCAKN,iBACE,qCAEA,gCAHF,eAII,sCAKF,4BADF,eAEI,wCAIJ,eACE,mBACA,mCACA,gDAEA,UACE,qIAEA,8BAEE,CAFF,sBAEE,6DAGF,wBFtaoB,8CE2atB,yBACE,gBACA,aACA,kBACA,gBACA,oDAEA,UACE,cACA,kBACA,WACA,YACA,gDACA,MACA,OACA,kDAGF,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,qCAGF,6CA3BF,YA4BI,gDAIJ,eACE,6JAEA,iBAEE,qCAEA,4JAJF,eAKI,sCAKN,sCA/DF,eAgEI,gBACA,oDAEA,YACE,+FAGF,eAEE,6CAIJ,iBACE,iBACA,aACA,2BACA,mDAEA,UACE,cACA,mBACA,kBACA,SACA,OACA,QACA,YACA,0BACA,WACA,oDAGF,aACE,YACA,aACA,kBACA,cACA,wDAEA,aACE,WACA,YACA,SACA,kBACA,yBACA,mBACA,qCAIJ,2CArCF,YAsCI,mBACA,0BACA,YACA,mDAEA,YACE,oDAGF,UACE,YACA,CACA,sBACA,wDAEA,QACE,kBACA,2DAGF,mDAXF,YAYI,sCAKN,2CAhEF,eAiEI,sCAGF,2CApEF,cAqEI,8CAIJ,aACE,iBACA,mDAEA,gBACE,mBACA,sDAEA,cACE,iBACA,WF1kBF,gBE4kBE,gBACA,mBACA,uBACA,6BACA,4DAEA,aACE,eACA,WFplBJ,gBEslBI,gBACA,uBACA,qCAKN,4CA7BF,gBA8BI,aACA,8BACA,mBACA,mDAEA,aACE,iBACA,sDAEA,cACE,iBACA,iBACA,4DAEA,aF5lBY,oDEmmBlB,YACE,2BACA,oBACA,YACA,qEAEA,YACE,mBACA,gBACA,qCAGF,oEACE,YACE,6DAIJ,eACE,sBACA,cACA,cFxnBc,aE0nBd,+BACA,eACA,kBACA,kBACA,8DAEA,aACE,uEAGF,cACE,kEAGF,aACE,WACA,kBACA,SACA,OACA,WACA,gCACA,WACA,wBACA,yEAIA,+BACE,UACA,kFAGF,2BFzpBc,wEE+pBd,SACE,wBACA,8DAIJ,oBACE,cACA,2EAGF,cACE,cACA,4EAGF,eACE,eACA,kBACA,WFnsBJ,6CEqsBI,2DAIJ,aACE,WACA,4DAGF,eACE,8CAKN,YACE,eACA,kEAEA,eACE,gBACA,uBACA,cACA,2FAEA,4BACE,yEAGF,YACE,qDAIJ,gBACE,eACA,cFztBgB,uDE4tBhB,oBACE,cF7tBc,qBE+tBd,aACA,gBACA,8DAEA,eACE,WFpvBJ,qCE0vBF,6CAtCF,aAuCI,UACA,4CAKN,yBACE,qCAEA,0CAHF,eAII,wCAIJ,eACE,oCAGF,kBACE,mCACA,kBACA,gBACA,mBACA,qCAEA,mCAPF,eAQI,gBACA,gBACA,8DAGF,QACE,aACA,+DAEA,aACE,sFAGF,uBACE,yEAGF,aFryBU,8DE2yBV,mBACA,WF7yBE,qFEizBJ,YAEE,eACA,cFpyBkB,2CEwyBpB,gBACE,iCAIJ,YACE,cACA,kDACA,qCAEA,gCALF,aAMI,+CAGF,cACE,iCAIJ,eACE,2BAGF,YACE,eACA,eACA,cACA,+BAEA,qBACE,cACA,YACA,cACA,mBACA,kBACA,qCAEA,8BARF,aASI,sCAGF,8BAZF,cAaI,sCAIJ,0BAvBF,QAwBI,6BACA,+BAEA,UACE,UACA,gBACA,gCACA,0CAEA,eACE,0CAGF,kBF32BK,+IE82BH,kBAGE,WC53BZ,eACE,aAEA,oBACE,aACA,iBAIJ,eACE,cACA,oBAEA,cACE,gBACA,mBACA,wBCfF,eACE,iBACA,oBACA,eACA,cACA,qCAEA,uBAPF,iBAQI,mBACA,+BAGF,YACE,cACA,0CACA,wCAEA,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,kBACA,6CAEA,aACE,wCAIJ,aACE,WACA,YACA,wCAGF,aACE,WACA,YACA,qCAGF,6BAxCF,iCAyCI,+EAEA,aAEE,wCAGF,UACE,wCAGF,aACE,+EAGF,aAEE,wCAGF,UACE,sCAIJ,uCACE,aACE,sCAIJ,4JACE,YAIE,4BAKN,wBACE,gBACA,kBACA,cJhFkB,6BImFlB,aACE,qBACA,6BAIJ,oBACE,cACA,wGAEA,yBAGE,mCAKF,aACE,YACA,WACA,cACA,aACA,0HAMA,YACE,oBClIR,cACE,iBACA,cLeoB,gBKbpB,mBACA,eACA,qBACA,qCAEA,mBATF,iBAUI,oBACA,uBAGF,aACE,qBACA,0BAGF,eACE,cLFoB,wBKMtB,oBACE,mBACA,kBACA,WACA,YACA,cC9BN,kBACE,mCACA,mBAEA,UACE,kBACA,gBACA,0BACA,gBNPI,uBMUJ,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,0BACA,oBAIJ,kBNVW,aMYT,0BACA,eACA,cNPoB,iBMSpB,qBACA,gBACA,8BAEA,UACE,YACA,gBACA,sBAGF,kBACE,iCAEA,eACE,uBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,sBAGF,aNtCsB,qBMwCpB,4BAEA,yBACE,qCAKN,aAnEF,YAoEI,uBAIJ,kBACE,oBACA,yBAEA,YACE,yBACA,gBACA,eACA,cN9DoB,+BMkEtB,cACE,0CAEA,eACE,sDAGF,YACE,mBACA,gDAGF,UACE,YACA,0BACA,oCAIJ,YACE,mBAKF,aN3FsB,aMgGxB,YACE,kBACA,mBNzGW,mCM2GX,qBAGF,YACE,kBACA,0BACA,kBACA,cN3GsB,mBM6GtB,iBAGF,eACE,eACA,cNlHsB,iBMoHtB,qBACA,gBACA,UACA,oBAEA,YACE,yBACA,gBACA,eACA,cN7HoB,0BMiItB,eACE,CACA,kBACA,mBAGF,oBACE,CACA,mBACA,cN1IoB,qBM4IpB,mBACA,gBACA,uBACA,0EAEA,yBAGE,uBAMJ,sBACA,kBACA,mBNnKW,mCMqKX,cN7JwB,gBM+JxB,mBACA,sDAEA,eAEE,CAII,qXADF,eACE,yBAKN,aACE,0BACA,CAMI,wLAGF,oBAGE,mIAEA,yBACE,gCAMR,kBACE,oCAEA,gBACE,cNzMkB,8DM+MpB,iBACE,eACA,4DAGF,eACE,qBACA,iEAEA,eACE,kBAMR,YACE,CACA,eNlPM,CMoPN,cACA,cNpOsB,mBMsOtB,+BANA,iBACA,CNlPM,kCMgQN,CATA,aAGF,kBACE,CAEA,iBACA,kBACA,cACA,iBAEA,UNjQM,eMmQJ,gBACA,gBACA,mBACA,gBAGF,cACE,cN1PoB,qCM8PtB,aArBF,YAsBI,mBACA,iBAEA,cACE,aAKN,kBN/Qa,kBMiRX,mCACA,iBAEA,qBACE,mBACA,uCAEA,YAEE,mBACA,8BACA,mBN5RO,kBM8RP,aACA,qBACA,cACA,mCACA,0EAIA,kBAGE,0BAIJ,kBRhTkB,eQkThB,8BAGF,UACE,eACA,oBAGF,aACE,eACA,gBACA,WNnUE,mBMqUF,gBACA,uBACA,wBAEA,aNzTkB,0BM6TlB,aACE,gBACA,eACA,eACA,cNjUgB,0IMuUlB,UNvVE,+BM+VJ,aACE,YACA,uDAGF,oBR9VkB,wCQkWlB,eACE,eAKN,YACE,yBACA,gCAEA,aACE,WACA,YACA,kBACA,kBACA,kBACA,mBACA,yBACA,4CAEA,SACE,6CAGF,SACE,6CAGF,SACE,iBAKN,UACE,0BAEA,SACE,SACA,wBAGF,eACE,0BAGF,iBACE,yBACA,cNxYoB,gBM0YpB,aACA,sCAEA,eACE,0BAIJ,cACE,sBACA,gCACA,wCAGF,eACE,wBAGF,WACE,kBACA,eACA,gBACA,WNhbI,8BMmbJ,aACE,cNpakB,gBMsalB,eACA,0BAIJ,SACE,iCACA,qCAGF,kCACE,YACE,sCAYJ,qIAPF,eAQI,gBACA,gBACA,iBAOJ,gBACE,qCAEA,eAHF,oBAII,uBAGF,sBACE,sCAEA,qBAHF,sBAII,sCAGF,qBAPF,UAQI,sCAGF,qBAXF,WAYI,kCAIJ,iBACE,qCAEA,gCAHF,4BAII,iEAIA,eACE,0DAGF,cACE,iBACA,oEAEA,UACE,YACA,gBACA,yFAGF,gBACE,SACA,mKAIJ,eAGE,gBAON,aNrgBsB,iCMogBxB,kBAKI,6BAEA,eACE,kBAIJ,cACE,iBACA,wCAMF,oBACE,gBACA,cRpiBkB,4JQuiBlB,yBAGE,oBAKN,kBACE,gBACA,eACA,kBACA,yBAEA,aACE,gBACA,aACA,CACA,kBACA,gBACA,uBACA,qBACA,WNnkBI,gCMqkBJ,4FAEA,yBAGE,oCAIJ,eACE,0BAGF,iBACE,gCACA,MCplBJ,+CACE,gBACA,iBAGF,eACE,aACA,cACA,qBAIA,kBACE,gBACA,4BAEA,QACE,0CAIA,kBACE,qDAEA,eACE,gDAIJ,iBACE,kBACA,sDAEA,iBACE,SACA,OACA,6BAKN,iBACE,gBACA,gDAEA,mBACE,eACA,gBACA,WPhDA,cOkDA,WACA,4EAGF,iBAEE,mDAGF,eACE,4CAGF,iBACE,QACA,OACA,qCAGF,aT/DgB,0BSiEd,gIAEA,oBAGE,0CAIJ,iBACE,CACA,iBACA,mBAKN,YACE,cACA,0BAEA,qBACE,cACA,UACA,cACA,oBAIJ,aPpFsB,sBOuFpB,aTjGkB,yBSqGlB,iBACE,kBACA,gBACA,uBAGF,eACE,iBACA,sBAIJ,kBACE,wBAGF,aACE,eACA,eACA,qBAGF,kBACE,cPlHoB,iCOqHpB,iBACE,eACA,iBACA,gBACA,gBACA,oBAIJ,kBACE,qBAGF,eACE,CAII,0JADF,eACE,sDAMJ,YACE,4DAEA,mBACE,eACA,WPlKA,gBOoKA,gBACA,cACA,wHAGF,aAEE,sDAIJ,cACE,kBACA,mDAKF,mBACE,eACA,WPxLE,cO0LF,kBACA,qBACA,gBACA,sCAGF,cACE,mCAGF,UACE,sCAIJ,cACE,4CAEA,mBACE,eACA,WP9ME,cOgNF,gBACA,gBACA,4CAGF,kBACE,yCAGF,cACE,CADF,cACE,kDAIJ,oBACE,WACA,OACA,6BAGF,oBACE,cACA,4BAGF,kBACE,8CAEA,eACE,0BAIJ,YACE,CACA,eACA,oBACA,iCAEA,cACE,kCAGF,qBACE,eACA,cACA,eACA,oCAEA,aACE,2CAGF,eACE,6GAIJ,eAEE,qCAGF,yBA9BF,aA+BI,gBACA,kCAEA,cACE,0JAGF,kBAGE,iDAKN,iBACE,oBACA,eACA,WPlSI,cOoSJ,WACA,2CAKE,mBACE,eACA,WP5SA,qBO8SA,WACA,kBACA,gBACA,kBACA,cACA,0DAGF,iBACE,OACA,QACA,SACA,kDAKN,cACE,aACA,yBACA,kBACA,sJAGF,qBAKE,eACA,WP5UI,cO8UJ,WACA,UACA,oBACA,gBACA,mBACA,sBACA,kBACA,aACA,6RAEA,aACE,CAHF,+OAEA,aACE,CAHF,mQAEA,aACE,CAHF,sNAEA,aACE,8LAGF,eACE,oVAGF,oBACE,iOAGF,oBPnWY,oLOuWZ,iBACE,4WAGF,oBTtWkB,mBSyWhB,6CAKF,aACE,gUAGF,oBAME,8CAGF,aACE,gBACA,cACA,eACA,8BAIJ,UACE,uBAGF,eACE,aACA,oCAEA,YACE,mBACA,qEAIJ,aAGE,WACA,SACA,kBACA,mBTvZkB,WENd,eOgaJ,oBACA,YACA,aACA,yBACA,qBACA,kBACA,sBACA,eACA,gBACA,UACA,mBACA,kBACA,sGAEA,cACE,uFAGF,qBACE,gLAGF,qBAEE,kHAGF,wBP3aoB,gGO+apB,kBP7bQ,kHOgcN,wBACE,sOAGF,wBAEE,qBAKN,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,WPhdI,cOkdJ,WACA,UACA,oBACA,gBACA,wXACA,sBACA,kBACA,kBACA,mBACA,YACA,iBAGF,4BACE,oCAIA,iBACE,mCAGF,iBACE,UACA,QACA,CACA,qBACA,eACA,cT1eY,oBS4eZ,oBACA,eACA,gBACA,mBACA,gBACA,yCAEA,UACE,cACA,kBACA,MACA,QACA,WACA,UACA,8DACA,4BAKN,iBACE,0CAEA,wBACE,CADF,gBACE,qCAGF,iBACE,MACA,OACA,WACA,YACA,aACA,uBACA,mBACA,8BACA,kBACA,iBACA,gBACA,YACA,8CAEA,iBACE,6HAGE,UP9hBF,aOwiBR,aACE,CACA,kBACA,eACA,gBAGF,kBACE,cPhiBsB,kBOkiBtB,kBACA,mBACA,kBACA,uBAEA,qCACE,iCACA,cPxjBY,sBO4jBd,mCACE,+BACA,cP7jBQ,kBOikBV,oBACE,cPpjBoB,qBOsjBpB,wBAEA,UPxkBI,0BO0kBF,kBAIJ,kBACE,4BAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBPhlBS,WATL,eO4lBJ,SACA,8CAEA,QACE,iHAGF,mBAGE,kCAGF,kBACE,uBAIJ,eACE,CAII,oKADF,eACE,0DAKN,eAzEF,eA0EI,eAIJ,eACE,kBACA,gBAEA,aPjnBsB,qBOmnBpB,sBAEA,yBACE,YAKN,eACE,mBACA,eACA,eAEA,oBACE,kBACA,cAGF,aT/oBoB,yBSipBlB,qBACA,gBACA,2DAEA,aAGE,8BAKN,kBAEE,cPrpBsB,oCOwpBtB,cACE,mBACA,kBACA,4CAGF,aP7pBwB,gBO+pBtB,CAII,mUADF,eACE,0DAKN,6BAtBF,eAuBI,cAIJ,YACE,eACA,uBACA,UAGF,aACE,gBPrsBM,YOusBN,qBACA,mCACA,qBACA,cAEA,aACE,SACA,iBAIJ,kBACE,cPlsBwB,WOosBxB,sBAEA,aACE,eACA,eAKF,kBACE,sBAEA,eACE,CAII,+JADF,eACE,4CASR,qBACE,8BACA,WPjvBI,qCOmvBJ,oCACA,kBACA,aACA,mBACA,gDAEA,UAEE,oLAEA,oBAGE,0DAIJ,eACE,cACA,kBACA,CAII,yYADF,eACE,kEAIJ,eACE,oBAMR,YACE,eACA,mBACA,4DAEA,aAEE,6BAIA,wBACA,cACA,sBAIJ,iBACE,cPxxBsB,0BO2xBtB,iBACE,oBAIJ,eACE,mBACA,uBAEA,cACE,WPrzBI,kBOuzBJ,mBACA,SACA,UACA,4BAGF,aACE,eAIJ,aP/zBc,0SOy0BZ,+CACE,aAIJ,kBACE,sBACA,kBACA,aACA,mBACA,kBACA,kBACA,QACA,mCACA,sBAEA,aACE,8BAGF,sBACE,SACA,aACA,eACA,gDACA,oBAGF,aACE,WACA,oBACA,gBACA,eACA,CACA,oBACA,WACA,iCACA,oBAGF,oBPn3Bc,gBOq3BZ,2BAEA,kBPv3BY,gBOy3BV,oBAKN,kBACE,6BAEA,wBACE,mBACA,eACA,aACA,4BAGF,kBACE,aACA,OACA,sBACA,cACA,cACA,gCAEA,iBACE,YACA,iBACA,kBACA,UACA,8BAGF,qBACE,qCAIJ,kBACE,gCAGF,wBACE,mCACA,kBACA,kBACA,kBACA,kBACA,sCAEA,wBACE,WACA,cACA,YACA,SACA,kBACA,MACA,UACA,yBAIJ,sBACE,aACA,mBACA,SC17BF,aACE,qBACA,cACA,mCACA,qCAEA,QANF,eAOI,8EAMA,kBACE,YAKN,YACE,kBACA,gBACA,0BACA,gBAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,0BACA,qCAGF,WAfF,YAgBI,sCAGF,WAnBF,YAoBI,aAIJ,iBACE,aACA,aACA,2BACA,mBACA,mBACA,0BACA,qCAEA,WATF,eAUI,qBAGF,aACE,WACA,YACA,gBACA,wBAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,0BAIJ,gBACE,gBACA,iCAEA,cACE,WR7EA,gBQ+EA,gBACA,uBACA,+BAGF,aACE,eACA,cRtEgB,gBQwEhB,gBACA,uBACA,aAMR,cACE,kBACA,gBACA,6GAEA,cAME,WR3GI,gBQ6GJ,qBACA,iBACA,qBACA,sBAGF,eRnHM,oBQqHJ,cR5GS,eQ8GT,cACA,kBAGF,cACE,uCAGF,wBAEE,cRhHsB,oBQoHxB,UACE,eACA,wBAEA,oBACE,iBACA,oBAIJ,WACE,gBACA,wBAEA,oBACE,gBACA,uBAIJ,cACE,cACA,qCAGF,YA9DF,iBA+DI,mBAEA,YACE,uCAGF,oBAEE,gBAKN,kBRnKa,mCQqKX,cR9JsB,eQgKtB,gBACA,kBACA,aACA,uBACA,mBACA,eACA,kBACA,aACA,gBACA,2BAEA,yBACE,yBAGF,qBACE,gBACA,yCAIJ,oBAEE,gBACA,eACA,kBACA,eACA,iBACA,gBACA,cR5LwB,sCQ8LxB,sCACA,6DAEA,aRjNc,sCQmNZ,kCACA,qDAGF,aACE,sCACA,kCACA,0BAIJ,eACE,UACA,wBACA,gBACA,CADA,YACA,CACA,iCACA,CADA,uBACA,CADA,kBACA,eACA,iBACA,6BAEA,YACE,gCACA,yDAGF,qBAEE,aACA,kBACA,gBACA,gBACA,mBACA,uBACA,6BAGF,eACE,YACA,cACA,cR3OsB,0BQ6OtB,6BAGF,aACE,cRlPoB,4BQsPtB,aVhQoB,qBUkQlB,qGAEA,yBAGE,oCAIJ,qCACE,iCACA,sCAEA,aRpRY,gBQsRV,0CAGF,aRzRY,wCQ8Rd,eACE,wCAIJ,UACE,0BAIA,aRzRsB,4BQ4RpB,aR3RsB,qBQ6RpB,qGAEA,yBAGE,iCAIJ,URvTI,gBQyTF,wBAIJ,eACE,kBChUJ,kCACE,kBACA,gBACA,mBACA,8BAEA,yBACE,qCAGF,iBAVF,eAWI,gBACA,gBACA,6BAGF,eACE,SACA,gBACA,gFAEA,yBAEE,sCAIJ,UACE,yBAGF,kBTpBW,6GSuBT,sBAGE,CAHF,cAGE,8IAIA,eAGE,0BACA,iJAKF,yBAGE,kLAIA,iBAGE,qCAKN,4GACE,yBAGE,uCAKN,kBACE,qBAIJ,WACE,eACA,mBXzEoB,WENd,oBSkFN,iBACA,YACA,iBACA,SACA,yBAEA,UACE,YACA,sBACA,iBACA,UT5FI,gFSgGN,kBAGE,qNAKA,kBTxFoB,4ISgGpB,kBT9GQ,qCSqHV,wBACE,YACE,0DAOJ,YACE,uCAGF,2BACE,gBACA,uDAEA,SACE,SACA,yDAGF,eACE,yDAGF,gBACE,iBACA,mFAGF,UACE,qMAGF,eAGE,iCC/JN,u+KACE,uCAEA,u+KACE,0CAIJ,u+KACE,WCTF,gCACE,4CACA,kBAGF,mBACE,sBACA,oBACA,gBACA,kBACA,cAGF,aACE,eACA,iBACA,cbRoB,SaUpB,uBACA,UACA,eACA,wCAEA,yBAEE,uBAGF,aXVsB,eWYpB,SAIJ,wBb1BsB,Ya4BpB,kBACA,sBACA,WXpCM,eWsCN,qBACA,oBACA,eACA,gBACA,YACA,iBACA,iBACA,gBACA,eACA,kBACA,kBACA,yBACA,qBACA,uBACA,2BACA,mBACA,WACA,4CAEA,wBAGE,4BACA,sBAGF,eACE,mFAEA,wBXjEQ,gBWqEN,mCAIJ,wBX3DsB,eW8DpB,2BAGF,QACE,wDAGF,mBAGE,yGAGF,cAIE,iBACA,YACA,oBACA,iBACA,4BAGF,aX7FW,mBAOW,qGW0FpB,wBAGE,8BAIJ,kBb7GgB,2GagHd,wBAGE,0BAIJ,aX3GsB,uBW6GpB,iBACA,yBACA,+FAEA,oBAGE,cACA,mCAGF,UACE,uBAIJ,aACE,WACA,kBAIJ,YACE,cACA,kBACA,cAGF,oBACE,CACA,abvJgB,SayJhB,kBACA,uBACA,eACA,2BACA,2CACA,2DAEA,aAGE,oCACA,4BACA,2CACA,oBAGF,kCACE,uBAGF,aACE,6BACA,eACA,qBAGF,abjLoB,gCaqLpB,QACE,uEAGF,mBAGE,uBAGF,abjMgB,sFaoMd,aAGE,oCACA,6BAGF,kCACE,gCAGF,aACE,6BACA,8BAGF,ablNkB,uCaqNhB,aACE,wBAKN,sBACE,0BACA,yBACA,kBACA,YACA,8BAEA,yBACE,mBbrOY,Qa4OhB,kBACA,uBACA,eACA,gBACA,eACA,cACA,iBACA,UACA,2BACA,2CACA,0EAEA,aAGE,oCACA,4BACA,2CACA,yBAGF,kCACE,4BAGF,aACE,6BACA,eACA,0BAGF,abzQoB,qCa6QpB,QACE,sFAGF,mBAGE,CAKF,0BADF,iBAUE,CATA,WAGF,WACE,cACA,qBACA,QACA,SAEA,+BAEA,kBAEE,mBACA,oBACA,kBACA,mBACA,iBAKF,WACE,eAIJ,YACE,iCAGE,mBACA,eAEA,gBACA,wCAEA,ab9TkB,sDakUlB,YACE,2CAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,kDAEA,oBbnVgB,yDa0VpB,aXvVW,mBWyVT,mBXlVoB,oCWoVpB,iBACA,kBACA,eACA,gBACA,6CAEA,aXjWS,gBWmWP,CAII,kRADF,eACE,wCAKN,abjXc,gBamXZ,0BACA,yIAEA,oBAGE,sCAKN,iBACE,MACA,QACA,kDAGF,iBACE,mGAGF,iBAGE,WACA,8BAGF,QACE,wBACA,UACA,qDAEA,WACE,mBACA,UACA,mFAIJ,aAEE,sBACA,WACA,SACA,cX3ZS,gBATL,aWuaJ,oBACA,eACA,gBACA,SACA,UACA,yIAEA,ab1ac,Cawad,sHAEA,ab1ac,Cawad,8HAEA,ab1ac,Cawad,4GAEA,ab1ac,+Fa8ad,SACE,qCAGF,kFAvBF,cAwBI,sCAIJ,iBACE,+CAGF,gBACE,0BACA,iBACA,mBACA,YACA,qBACA,kEAEA,SACE,qCAGF,8CAZF,sBAaI,gBACA,2DAIJ,iBACE,SACA,kDAGF,qBACE,aACA,kBACA,SACA,WACA,WACA,sCACA,mBX5csB,0BW8ctB,cXtdS,eWwdT,YACA,6FAEA,aACE,wDAIJ,YACE,eACA,kBACA,yPAEA,kBAIE,wGAIJ,YAGE,mBACA,mBACA,2BACA,iBACA,eACA,oCAGF,6BACE,0CAEA,aACE,gBACA,uBACA,mBACA,2CAGF,eACE,0CAGF,aACE,iBACA,gBACA,uBACA,mBACA,8EAIJ,aAEE,iBACA,WACA,YACA,2DAGF,ab5hBgB,wCagiBhB,aX3hBW,oBW6hBT,eACA,gBXviBI,sEW0iBJ,eACE,uEAGF,YACE,mBACA,YACA,eACA,8DAGF,UACE,cACA,WACA,uEAEA,iFACE,aACA,uBACA,8BACA,UACA,4BACA,oFAEA,aACE,cXljBgB,eWojBhB,gBACA,aACA,oBACA,6QAEA,aAGE,8EAIJ,SACE,0EAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,gFACA,aACA,UACA,4BACA,mFAEA,sBACE,cXllBgB,SWolBhB,UACA,SACA,WACA,oBACA,eACA,gBACA,yFAEA,UX7mBF,8GWinBE,WACE,cXjmBc,CAjBlB,oGWinBE,WACE,cXjmBc,CAjBlB,wGWinBE,WACE,cXjmBc,CAjBlB,+FWinBE,WACE,cXjmBc,iFWsmBlB,SACE,wEAKN,iBACE,sBX/nBE,wBWioBF,sBACA,4BACA,aACA,WACA,gBACA,8CAIJ,YACE,mBACA,0BACA,aACA,8BACA,cACA,qEAEA,YACE,uGAEA,gBACE,qGAGF,YACE,6IAEA,aACE,2IAGF,gBACE,0HAKN,sBAEE,cACA,0EAGF,iBACE,iBACA,sCAIJ,YACE,yBACA,YACA,cACA,4EAEA,eACE,iBACA,oBAKN,cACE,kDACA,eACA,gBACA,cb9rBgB,4CaisBhB,aXlsBY,kCWusBd,2CACE,WC7sBF,8DDktBE,sBACA,CADA,kBACA,wBACA,WACA,YACA,eAEA,UACE,kBAIJ,iBACE,mBACA,mBX7sBsB,aW+sBtB,gBACA,gBACA,cACA,0BAGF,iBACE,gBACA,0BAGF,WACE,iBACA,gCAGF,aXtuBa,cWwuBX,eACA,iBACA,gBACA,mBACA,qBACA,kCAGF,UACE,iBACA,+BAGF,cACE,4CAGF,iBAEE,eACA,iBACA,qBACA,gBACA,gBACA,uBACA,gBACA,WX3wBM,wDW8wBN,SACE,wGAGF,kBACE,sJAEA,oBACE,gEAIJ,UACE,YACA,gBACA,oDAGF,cACE,iBACA,sBACA,CADA,gCACA,CADA,kBACA,gDAGF,kBACE,qBACA,sEAEA,eACE,gDAIJ,aXnyBc,qBWqyBZ,4DAEA,yBACE,oEAEA,aACE,4EAKF,oBACE,sFAEA,yBACE,wDAKN,abj0Bc,8Eas0BhB,aACE,0GAGF,kBb10BgB,sHa60Bd,kBACE,qBACA,8IAGF,QACE,0XAGF,mBAGE,0FAIJ,YACE,wJAEA,aACE,+BAKN,oBACE,gBACA,yCAEA,UACE,YACA,gBACA,iCAGF,kBACE,qBACA,4CAEA,eACE,iCAIJ,aX52BwB,qBW82BtB,uCAEA,yBACE,+CAIA,oBACE,oDAEA,yBACE,gDAKN,aACE,6CAKN,gBACE,oCAGF,aACE,eACA,iBACA,cACA,SACA,uBACA,CACA,eACA,qBACA,oFAEA,yBAEE,gCAIJ,oBACE,kBACA,uBACA,SACA,cXr6BW,gBWu6BX,eACA,cACA,yBACA,iBACA,eACA,sBACA,4BAGF,abr7BkB,Sau7BhB,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,gCACA,+BAGF,UACE,kBACA,kBAIA,SACE,mBACA,wCAEA,kBACE,8CAEA,sBACE,iFAIJ,kBAEE,SAMJ,yBACA,kBACA,gBACA,gCACA,eACA,UAaA,mCACA,CADA,0BACA,wDAZA,QARF,kBAWI,0BAGF,GACE,aACA,WALA,gBAGF,GACE,aACA,uDAMF,cAEE,kCAGF,kBACE,4BACA,sCAIA,aXj/BoB,qCWq/BpB,aX5/BS,6BWggCT,aXz/BoB,CAPX,kEWwgCT,aXxgCS,kCW2gCP,ab9gCgB,gEakhChB,UXxhCE,mBAgBgB,sEW4gChB,kBACE,+CAQR,sBACE,qEAEA,aACE,qDAKN,ab1iCkB,Ya6iChB,eACA,uBAGF,abjjCkB,qCaqjClB,aACE,eACA,mBACA,eAGF,cACE,mBAGF,+BACE,aACA,6CAEA,uBACE,OACA,gBACA,4DAEA,eACE,8DAGF,SACE,mBACA,qHAGF,cAEE,gBACA,4EAGF,cACE,0BAKN,kBACE,aACA,cACA,uBACA,aACA,kBAGF,gBACE,cbtmCgB,CawmChB,iBACA,eACA,kBACA,+CAEA,ab7mCgB,uBainChB,aACE,gBACA,uBACA,qBAIJ,kBACE,aACA,eACA,8BAEA,mBACE,kBACA,mBACA,yDAEA,gBACE,qCAGF,oBACE,WACA,eACA,gBACA,cb1oCY,4BagpClB,iBACE,8BAGF,cACE,cACA,uCAGF,aACE,aACA,mBACA,uBACA,kBACA,kBAGF,kBACE,kBACA,wBAEA,YACE,eACA,8BACA,uBACA,uFAEA,SAEE,mCAIJ,cACE,iBACA,6CAEA,UACE,YACA,gBACA,kEAGF,gBACE,gBACA,+DAIJ,cAEE,wBAIJ,eACE,cbxsCgB,ea0sChB,iBACA,8BAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,wBAGF,aACE,qBACA,uDAGF,oBAEE,gBACA,eACA,gBACA,2BAGF,aX/tCa,eWiuCX,6BAEA,abxuCgB,Sa6uClB,YACE,gCACA,8BAEA,aACE,cACA,WXvvCI,qBWyvCJ,eACA,gBACA,kBAIJ,YACE,iBAGF,WACE,aACA,mBACA,UAGF,YACE,gCACA,kBAEA,SACE,gBACA,2CAEA,aACE,iCAIJ,aACE,cACA,cXxwCoB,gBW0wCpB,qBACA,eACA,mBAIJ,YACE,0BAGF,UACE,iBACA,kBACA,kBAGF,iBE3yCE,iCACA,wBACA,4BACA,kBF0yCA,yBAEA,oBACE,sBACA,iBACA,4BAGF,iBErzCA,iCACA,wBACA,4BACA,kBFozCE,gBACA,kBACA,gCAEA,UACE,kBACA,sBACA,mCAGF,aACE,kBACA,QACA,SACA,+BACA,WXr0CE,6BWu0CF,gBACA,eACA,oBAKN,cACE,0BAGF,UACuB,sCE30CrB,+BF60CA,iBEt1CA,iCACA,wBACA,4BACA,WFq1CuB,sCE/0CvB,kCFk1CA,iBE31CA,iCACA,wBACA,4BACA,WF01CuB,sCEp1CvB,kBFs1CE,SACA,QACA,UACA,wBAIJ,WACE,aACA,mBACA,sBAGF,YACE,6BACA,cbz2CgB,6Ba42ChB,eACE,CAII,kMADF,eACE,wBAKN,eACE,cACA,0BACA,yFAEA,oBAGE,sBAKN,4BACE,gCACA,iBACA,gBACA,cACA,aACA,+BAGF,YACE,4CAEA,qBACE,oFAIA,QACE,WACA,uDAGF,WACE,iBACA,gBACA,WACA,4BAKN,YACE,cACA,iBACA,kBACA,2BAGF,oBACE,gBACA,cACA,+BACA,eACA,oCACA,kCAEA,+BACE,gCAGF,aACE,yBACA,eACA,cX56CoB,kCWg7CtB,aACE,eACA,gBACA,WXn8CI,CWw8CA,2NADF,eACE,oBAMR,iBACE,mDAEA,aACE,mBACA,gBACA,4BAIJ,UACE,kBACA,6JAGF,oBAME,4DAKA,UXx+CM,kBW8+CN,UACE,iKAQF,yBACE,+BAIJ,aACE,gBACA,uBACA,0DAGF,aAEE,sCAGF,kBACE,gCAGF,aX1/C0B,cW4/CxB,iBACA,mBACA,gBACA,2EAEA,aAEE,uBACA,gBACA,uCAGF,cACE,WX1hDI,kCW+hDR,UACE,kBACA,iBAGF,WACE,UACA,kBACA,SACA,WACA,iBAGF,UACE,kBACA,OACA,MACA,YACA,eACA,Cb9iDgB,gHawjDhB,abxjDgB,wBa4jDhB,UACE,wCAGF,kBbhkDgB,cEKL,8CW+jDT,kBACE,qBACA,wBAKN,oBACE,gBACA,eACA,cXlkDsB,eWokDtB,iBACA,kBACA,4BAEA,abllDoB,6BaslDpB,cACE,gBACA,uBACA,uCAIJ,UACE,kBACA,CXjmDU,mEWwmDZ,aXxmDY,uBW4mDZ,aX7mDc,4DWmnDV,4CACE,CADF,oCACE,8DAKF,6CACE,CADF,qCACE,6BAKN,aACE,gBACA,qBACA,mCAEA,UXvoDM,0BWyoDJ,8BAIJ,WACE,eAGF,aACE,eACA,gBACA,uBACA,mBACA,qBAGF,eACE,wBAGF,cACE,+DAKA,yBACE,eAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,sBACA,6CAEA,cX9nD4B,eAEC,0DW+nD3B,sBACA,CADA,gCACA,CADA,kBACA,4BAGF,iBACE,qEAGF,YACE,iBAIJ,iBACE,WACA,YACA,aACA,mBACA,uBACA,qBAEA,cXtpD4B,eAEC,WWupD3B,YACA,sBACA,CADA,gCACA,CADA,kBACA,iBAIJ,YACE,aACA,mBACA,cACA,eACA,cXvsDsB,wBW0sDtB,aXzsDwB,mBW6sDxB,aACE,4BAGF,oBACE,0CAGF,iBACE,6DAEA,iBACE,oBACA,qCACA,UACA,4EAGF,mBACE,gCACA,UACA,0BAKN,aACE,gBACA,iBACA,gBACA,gBACA,kCAGF,aACE,gBACA,gBACA,uBACA,+BAGF,aACE,qBACA,WAGF,oBACE,oBAGF,YACE,kBACA,2BAGF,+BACE,mBACA,SACA,gBAGF,kBX1wD0B,cW4wDxB,kBACA,uCACA,aACA,mBAEA,eACE,qBAGF,yBACE,oBAGF,yBACE,uBAGF,sBACE,sBAGF,sBACE,uBAIJ,iBACE,QACA,SACA,2BACA,4BAEA,UACE,gBACA,2BACA,0BX/yDsB,2BWmzDxB,WACE,iBACA,uBACA,yBXtzDsB,8BW0zDxB,QACE,iBACA,uBACA,4BX7zDsB,6BWi0DxB,SACE,gBACA,2BACA,2BXp0DsB,wBW00DxB,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBXh1DsB,cARb,gBW21DT,uBACA,mBACA,yFAEA,kBbl2DkB,cEWI,UW41DpB,sCAKN,aACE,iBACA,gBACA,QACA,gBACA,aACA,yCAEA,eACE,mBX12DsB,cW42DtB,kBACA,mCACA,gBACA,kBACA,sDAGF,OACE,wDAIA,UACE,8CAIJ,cACE,iBACA,cACA,iBACA,sBACA,qBACA,mBXn4DsB,cARb,gBW84DT,uBACA,mBACA,oDAEA,SACE,oDAGF,kBbz5DkB,cEWI,iBWq5D1B,qBACE,eAGF,YACE,cACA,mBACA,2BACA,gBACA,kBACA,4BAEA,iBACE,uBAGF,YACE,uBACA,WACA,YACA,iBACA,6BAEA,WACE,gBACA,oBACA,aACA,yBACA,gBACA,oCAEA,0BACE,oCAGF,cACE,YACA,oBACA,YACA,6BAIJ,qBACE,WACA,gBACA,cACA,aACA,sBACA,qCAEA,4BARF,cASI,qBAMR,kBACE,wBACA,CADA,eACA,MACA,UACA,cACA,qCAEA,mBAPF,gBAQI,+BAGF,eACE,qCAEA,6BAHF,kBAII,gKAMJ,WAIE,mCAIJ,YACE,mBACA,uBACA,YACA,SAGF,WACE,kBACA,sBACA,aACA,sBACA,qBAEA,kBXlgEW,8BWogET,+BACA,KAIJ,aACE,CACA,qBACA,WACA,YACA,aAJA,YAYA,CARA,QAGF,WACE,sBACA,CACA,qBACA,kBACA,cAGF,aACE,cACA,sBACA,cXrhEsB,qBWuhEtB,kBACA,eACA,oCACA,iBAGF,aAEE,gBACA,qCAGF,cACE,SACE,iBAGF,aAEE,CAEA,gBACA,yCAEA,iBACE,uCAGF,kBACE,qDAKF,gBAEE,kBACA,YAKN,qBACE,aACA,mBACA,cACA,gBACA,iBAGF,aACE,cACA,CACA,sBACA,WX7lEM,qBW+lEN,kBACA,eACA,gBACA,gCACA,2BACA,mDACA,qBAEA,eACE,eACA,qCAMA,mEAHF,kBAII,4BACA,yBAIJ,+BACE,cbhnEkB,sBaonEpB,eACE,aACA,qCAIJ,qBAEI,cACE,wBAKN,qBACE,WACA,YACA,cACA,6DAEA,UAEE,YACA,UACA,wCAGF,YACE,cACA,kDACA,qCAEA,uCALF,aAMI,yCAIJ,eACE,oCAGF,YACE,uDAGF,cACE,sCAGF,gBACE,eACA,CACA,2BACA,yCAGF,QACE,mCAGF,gBACE,yBAEA,kCAHF,eAII,sCAIJ,sBACE,gBACA,sCAGF,uCACE,YACE,iKAEA,eAGE,6CAIJ,gBACE,2EAGF,YAEE,kGAGF,gBACE,+BAGF,2BACE,gBACA,uCAEA,SACE,SACA,wCAGF,eACE,wCAGF,gBACE,iBACA,qDAGF,UACE,gLAGF,eAIE,gCAIJ,iBACE,6CAEA,cACE,8CAKF,gBACE,iBACA,6DAGF,UACE,CAIA,yFAGF,eACE,8DAGF,gBACE,kBACA,0BAMR,cACE,aACA,uBACA,mBACA,gBACA,iBACA,iBACA,gBACA,mBACA,WXpyEM,kBWsyEN,eACA,iBACA,qBACA,sCACA,4FAEA,kBAGE,qCAIJ,UACE,UACE,uDAGF,kCACE,4DAGF,kBAGE,yBAGF,aACE,iBAGF,eAEE,sCAIJ,2CACE,YACE,sCAOA,sEAGF,YACE,uCAIJ,0CACE,YACE,uCAIJ,UACE,YACE,mBAIJ,iBACE,yBAEA,iBACE,SACA,UACA,mBbz2EkB,yBa22ElB,gBACA,kBACA,eACA,gBACA,iBACA,WXt3EI,mDW23ER,oBACE,gBAGF,WACE,gBACA,aACA,sBACA,yBACA,kBACA,gCAEA,gBACE,oBACA,cACA,gBACA,6BAGF,sBACE,8BAGF,MACE,kBACA,aACA,sBACA,iBACA,oBACA,oBACA,mDAGF,eACE,sBX75EI,0BW+5EJ,cACA,gDAGF,iBACE,gDAGF,WACE,mBAIJ,eACE,mBACA,yBACA,gBACA,aACA,sBACA,qBAEA,aACE,sBAGF,aACE,SACA,CACA,4BACA,cACA,qDAHA,sBAOA,gBAMF,WACA,kBAGA,+BANF,qBACE,UACA,CAEA,eACA,aAiBA,CAhBA,eAGF,iBACE,MACA,OACA,mBACA,CAGA,qBACA,CACA,eACA,WACA,YACA,kBACA,uBAEA,kBXp9EW,0BWy9Eb,u1BACE,OACA,gBACA,aACA,8BAEA,aACE,sBACA,CADA,4DACA,CADA,kBACA,+BACA,CADA,2BACA,UACA,YACA,oBACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,sCAGF,yBAjBF,aAkBI,iBAIJ,kBACE,eACA,gBACA,iBAGF,aACE,eACA,mBACA,mBACA,aACA,mBACA,kBACA,mBAEA,iCACE,yBAEA,kBACE,mCACA,aAKN,iBACE,kBACA,cACA,iCACA,mCAEA,eACE,yBAGF,YAVF,cAWI,oBAGF,YACE,sBACA,qBAGF,aACE,kBACA,iBACA,yBAKF,uBADF,YAEI,sBAIJ,qBACE,WACA,mBACA,cb9iFoB,eagjFpB,cACA,eACA,oBACA,SACA,iBACA,aACA,SACA,UACA,UACA,2BAEA,yBACE,6BAIJ,kBACE,SACA,oBACA,cbnkFoB,eaqkFpB,mBACA,eACA,kBACA,UACA,mCAEA,yBACE,wCAGF,kBACE,2BAIJ,oBACE,iBACA,2BAGF,iBACE,kCAGF,cACE,cACA,eACA,aACA,CACA,OACA,UACA,eAGF,oBACE,kBACA,eACA,6BACA,SACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,0CACA,wCACA,iCAGF,QACE,mBACA,WACA,YACA,gBACA,UACA,kBACA,UACA,yBAGF,kBACE,WACA,wBACA,qBAGF,UACE,YACA,UACA,mBACA,yBXroFW,qCWuoFX,sEAGF,wBACE,4CAGF,wBbjpFsB,+EaqpFtB,wBACE,2BAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,SACA,UACA,6BACA,CAKA,uEAFF,SACE,6BAeA,CAdA,sBAGF,iBACE,WACA,YACA,MACA,SACA,gBACA,mBACA,cACA,WAGA,8CAGF,SACE,qBAGF,iBACE,QACA,SACA,WACA,YACA,yBACA,kBACA,yBACA,sBACA,yBACA,sCACA,4CAGF,SACE,qBb7sFoB,caitFtB,kBACE,WXxtFM,cW0tFN,eACA,aACA,qBACA,2DAEA,kBAGE,oBAGF,SACE,2BAGF,sBACE,cXztFsB,kGW4tFtB,sBAGE,WXhvFE,kCWovFJ,ab9uFkB,oBaovFtB,oBACE,iBACA,qBAGF,oBACE,kBACA,CACA,gBACA,CX1vFW,eW6vFX,iBACA,wCANA,cACA,CACA,eACA,mBAaA,CAVA,mBX9vFW,aFLK,iBaywFhB,CAEA,wBACA,eACA,yDAGF,kBX3wFa,cWixFb,aACE,kBAGF,ab1xFkB,ca4xFhB,8BACA,+BACA,4EAEA,0BAGE,CAHF,uBAGE,CAHF,kBAGE,kDAMA,sBACA,YACA,wDAEA,kBACE,8DAGF,cACE,sDAGF,cACE,0DAEA,abxzFY,0Ba0zFV,sDAIJ,oBACE,cXnzFkB,sMWszFlB,yBAGE,oDAKN,ab10FgB,0Bag1FhB,aACE,UACA,mCACA,CADA,0BACA,gBACA,6BAEA,cACE,yBACA,cX50FkB,aW80FlB,gBACA,gCACA,sCAGF,oDACE,YACE,uCAIJ,oDACE,YACE,uCAIJ,yBA3BF,YA4BI,yCAGF,eACE,aACA,iDAEA,aXv2FkB,qBW82FxB,eACE,gBACA,2BAEA,iBACE,aACA,wBAGF,kBACE,yBAGF,oBACE,gBACA,yBACA,yBACA,eAIJ,aACE,sBACA,WACA,SACA,cX94FW,gBATL,aW05FN,oBACA,eACA,gBACA,SACA,UACA,kBACA,qBAEA,SACE,qCAGF,cAnBF,cAoBI,oDAIJ,uBACE,YACA,6CACA,uBACA,sBACA,WACA,0DAEA,sBACE,0DAKJ,uBACE,2BACA,gDAGF,ab17FkB,6Ba47FhB,uDAGF,ab77FsB,cai8FtB,YACE,eACA,yBACA,kBACA,cbv8FgB,gBay8FhB,qBACA,gBACA,uBAEA,QACE,OACA,kBACA,QACA,MAIA,iDAHA,YACA,uBACA,mBAUE,CATF,0BAEA,yBACE,kBACA,iBACA,cAIA,sDAGF,cAEE,cXt9FoB,uBWw9FpB,SACA,cACA,qBACA,eACA,iBACA,sMAEA,UXh/FE,yBWu/FJ,cACE,kBACA,YACA,eAKN,cACE,qBAEA,kBACE,oBAIJ,cACE,cACA,qBACA,WACA,YACA,SACA,2BAIA,UACE,YACA,qBAIJ,aACE,gBACA,kBACA,cX1gGsB,gBW4gGtB,uBACA,mBACA,qBACA,uBAGF,aACE,gBACA,2BACA,2BAGF,aXxhGwB,oBW4hGxB,aACE,eACA,eACA,gBACA,uBACA,mBACA,qBAGF,cACE,mBACA,kBACA,yBAEA,cACE,kBACA,yBACA,QACA,SACA,+BACA,yBAIJ,aACE,6CAEA,UACE,mDAGF,yBACE,6CAGF,mBACE,sBAIJ,oBACE,kCAEA,QACE,4CAIA,oBACA,0CAGF,kBACE,0CAGF,aACE,6BAIJ,wBACE,2BAGF,yBACE,cACA,SACA,WACA,YACA,oBACA,CADA,8BACA,CADA,gBACA,sBACA,wBACA,YAGF,aACE,cbrnGgB,6BaunGhB,SACA,kBACA,kBACA,oBACA,SACA,aACA,sBACA,WACA,WACA,qBACA,kBAEA,kBACE,WAIJ,+BACE,yBAGF,iBACE,eACA,gBACA,cb/oGgB,mBEKL,eW6oGX,aACA,cACA,sBACA,mBACA,uBACA,aACA,qEAGE,aAEE,WACA,aACA,SACA,yCAIJ,gBACE,gCAGF,eACE,uCAEA,aACE,mBACA,cb7qGY,qCairGd,cACE,gBACA,yBAKN,iBACE,cACA,UACA,gCAEA,mCACE,uCAEA,aACE,WACA,kBACA,aACA,OACA,QACA,cACA,UACA,oBACA,YACA,UACA,4EACA,wCAIJ,SACE,kBACA,gBAIJ,YACE,eACA,mBACA,cACA,eACA,kBACA,UACA,UACA,gBACA,2BACA,4BACA,uBAEA,QACE,SACA,yBACA,cACA,uBACA,aACA,gBACA,uBACA,gBACA,mBACA,OACA,4CAGF,ab/uGoB,4CaovGlB,abpvGkB,sCasvGhB,4CAIJ,SAEE,yBAIJ,WACE,aACA,uBAGF,kBACE,iCAGF,iBACE,wBAGF,kBACE,SACA,cXrwGsB,eWuwGtB,eACA,eACA,8BAEA,aACE,CAKA,kEAEA,UXnyGI,mBWqyGF,6BAKN,eACE,gBACA,gBACA,cX7xGsB,0DW+xGtB,UACA,UACA,kBACA,uCAEA,YACE,WACA,uCAGF,iBACE,gCAGF,QACE,uBACA,SACA,6BACA,cACA,mCAIJ,kBACE,aACA,mCAIA,aX5zGsB,0BW8zGpB,gCAIJ,WACE,4DAEA,cACE,uEAEA,eACE,WAKN,oBACE,UACA,oBACA,kBACA,cACA,SACA,uBACA,eACA,sBAGF,oBACE,iBACA,oBAGF,ab12GkB,ea42GhB,gBACA,yBACA,iBACA,kBACA,QACA,SACA,+BACA,yBAEA,aACE,WACA,CACA,0BACA,oBACA,mBACA,4BAIJ,iBACE,QACA,SACA,+BACA,WACA,YACA,sBACA,6BACA,CACA,wBACA,kBACA,2CAGF,2EACE,CADF,mEACE,8CAGF,4EACE,CADF,oEACE,qCAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,EArBF,4BAGF,GACE,sBACE,KAGF,2BACE,KAGF,2BACE,KAGF,yBACE,IAGF,wBACE,uCAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,EAtBA,6BAIJ,GACE,wBACE,KAGF,0BACE,KAGF,2BACE,KAGF,uBACE,IAGF,sBACE,mCAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,EA5BA,yBAIJ,GACE,OACE,SACA,yBACA,KAGF,wBACE,KAGF,UACE,YACA,6BACA,kBACA,UACA,IAGF,UACE,YACA,eACA,UACA,6BACA,kCAIJ,GACE,gBACA,aACA,aAPE,wBAIJ,GACE,gBACA,aACA,gCAGF,kBACE,gBXz+GM,WACA,eW2+GN,aACA,sBACA,YACA,uBACA,eACA,kBACA,kBACA,YACA,gBAGF,eXv/GQ,cAiBgB,SWy+GtB,UACA,WACA,YACA,kBACA,wBACA,CADA,oBACA,CADA,eACA,iEAEA,SAGE,cACA,yBAIJ,aACE,eACA,yBAGF,aACE,eACA,gBACA,iBAGF,KACE,OACA,WACA,YACA,kBACA,YACA,2BAEA,aACE,SACA,QACA,WACA,YACA,6BAGF,mBACE,yBAGF,YACE,0BAGF,aACE,uBACA,WACA,YACA,SACA,iCAEA,oBACE,0BACA,kBACA,iBACA,WXtjHE,gBWwjHF,eACA,+LAMA,yBACE,mEAKF,yBACE,6BAMR,kBACE,iBAGF,kBACE,6BACA,gCACA,aACA,mBACA,eACA,kDAGF,aAEE,kBACA,yBAGF,kBACE,aACA,2BAGF,aXplHwB,eWslHtB,cACA,gBACA,mBACA,kDAIA,kBACE,oDAIA,SEtmHF,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,+EFgmHI,aACE,CEjmHN,qEFgmHI,aACE,CEjmHN,yEFgmHI,aACE,CEjmHN,gEFgmHI,aACE,sEAGF,QACE,yLAGF,mBAGE,0DAGF,kBACE,qCAGF,mDArBF,cAsBI,yDAIJ,abxoHc,iBa0oHZ,eACA,4DAGF,gBACE,wDAGF,kBACE,gEAEA,cACE,iNAEA,kBAGE,cACA,gHAKN,aXrpHoB,0HW0pHpB,cAEE,gBACA,cbzqHY,kZa4qHZ,aAGE,gEAIJ,wBACE,iDAGF,eX3rHI,kBa0BN,CAEA,eACA,cbbsB,uCaetB,UF8pHI,mBX5qHoB,oDagBxB,wBACE,cblBoB,eaoBpB,gBACA,mBACA,oDAGF,aACE,oDAGF,kBACE,oDAGF,eACE,cbzCS,sDWwrHT,WACE,mDAGF,aX5rHS,kBW8rHP,eACA,8HAEA,kBAEE,iCAON,kBACE,mBAIJ,UXxtHQ,kBW0tHN,cACA,mBACA,sBX7tHM,yBW+tHN,eACA,gBACA,YACA,kBACA,WACA,yBAEA,SACE,iBAIJ,aACE,iBACA,wBAGF,aX/tHwB,qBWiuHtB,mBACA,gBACA,sBACA,6EAGF,abnvHkB,mBEKL,kBWmvHX,aACA,eACA,gBACA,eACA,aACA,cACA,mBACA,uBACA,yBAEA,4EAfF,cAgBI,6FAGF,eACE,mFAGF,abxwHoB,qBa0wHlB,qGAEA,yBACE,uCAKN,kBACE,aACA,eAGF,qBACE,8BAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,EA1BF,qBAGF,GACE,kBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,oBACE,2CACA,CADA,kCACA,KAGF,oBACE,0CACA,CADA,iCACA,KAGF,kBACE,2CACA,CADA,kCACA,mCAIJ,8BACE,2DACA,CADA,kDACA,iCAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,EA/BF,wBAGF,MACE,sBAEE,0BACA,KAGF,sBACE,aAGF,uBAGE,aAGF,sBAGE,KAGF,uBACE,KAGF,sBACE,kCAIJ,yBACE,8EACA,CADA,qEACA,8BAGF,eXt2HQ,kBWw2HN,sCACA,kBACA,eAEA,iDAEA,2BACE,2DAGF,UACE,mCAIJ,iBACE,SACA,WACA,eACA,yCAGF,iBACE,UACA,SACA,UACA,gBXl4HM,kBWo4HN,sCACA,gBACA,gDAEA,aACE,eACA,SACA,gBACA,uBACA,iKAEA,+BAGE,2DAIJ,WACE,wBAKF,2BACE,cAIJ,kBACE,0BACA,aACA,YACA,uBACA,OACA,UACA,kBACA,MACA,kBACA,WACA,aACA,gBAEA,mBACE,oBAIJ,WACE,aACA,aACA,sBACA,kBACA,YACA,0BAGF,iBACE,MACA,QACA,SACA,OACA,WACA,kBACA,mBX37HW,kCW67HX,uBAGF,MACE,aACA,mBACA,uBACA,cX57HwB,eW87HxB,gBACA,0BACA,kBACA,kBAGF,YACE,cbl9HgB,gBao9HhB,aACA,sBAEA,cACE,kBACA,uBAGF,cACE,yBACA,gBACA,cACA,0BAIJ,aACE,4BAGF,UACE,WACA,kBACA,mBb3+HgB,kBa6+HhB,eACA,2BAGF,iBACE,OACA,MACA,WACA,mBbn/HoB,kBaq/HpB,eAGF,aACE,wBACA,UACA,eACA,0CAEA,mBAEE,mBAGF,8BACE,CADF,sBACE,WACA,cACA,SACA,WACA,YACA,CAQE,6GAKN,SACE,oBACA,CADA,WACA,6BAGF,iBACE,gBXliIM,uCWoiIN,kBACA,iBACA,gBACA,iCAEA,yBACE,oCAGF,sBACE,2BAIJ,aXziIa,aW2iIX,eACA,aACA,kEAEA,kBbljIoB,WENd,UW4jIJ,CX5jII,4RWikIF,UXjkIE,wCWukIN,kBACE,iCAIJ,YACE,mBACA,uBACA,kBACA,oCAGF,aACE,cbhlIgB,2CamlIhB,eACE,cACA,cXhlIS,CWqlIL,wQADF,eACE,mDAON,eXrmIM,0BWumIJ,qCACA,gEAEA,eACE,0DAGF,kBbxmIkB,uEa2mIhB,UXjnIE,uDWunIN,yBACE,sDAGF,aACE,sCACA,SAIJ,iBACE,gBAGF,SEznIE,sBACA,WACA,SACA,gBACA,oBACA,mBbRW,cAOW,eaItB,SACA,cFmnIA,CACA,2BACA,iBACA,eACA,2CAEA,aACE,CAHF,iCAEA,aACE,CAHF,qCAEA,aACE,CAHF,4BAEA,aACE,kCAGF,QACE,6EAGF,mBAGE,sBAGF,kBACE,qCAGF,eA3BF,cA4BI,kCAKF,QACE,qDAGF,mBAEE,mBAGF,iBACE,SACA,WACA,UACA,qBACA,UACA,0BACA,sCACA,eACA,WACA,YACA,cXzqIsB,eW2qItB,oBACA,0BAEA,mBACE,WACA,0BAIJ,uBACE,iCAEA,mBACE,uBACA,gCAIJ,QACE,uBACA,cb5sIc,ea8sId,uCAEA,uBACE,sCAGF,aACE,yBAKN,ab1tIkB,mBa4tIhB,aACA,gBACA,eACA,eACA,6BAEA,oBACE,iBACA,0BAIJ,iBACE,6BAEA,kBACE,gCACA,eACA,aACA,aACA,gBACA,eACA,cblvIc,iCaqvId,oBACE,iBACA,8FAIJ,eAEE,0BAIJ,aACE,aACA,cXtvIwB,qBWwvIxB,+FAEA,aAGE,0BACA,uBAIJ,YACE,cXpwIsB,kBWswItB,aAGF,iBACE,8BACA,oBACA,aACA,sBAGF,cACE,MACA,OACA,QACA,SACA,0BACA,wBAGF,cACE,MACA,OACA,WACA,YACA,aACA,sBACA,mBACA,uBACA,2BACA,aACA,oBACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,oBAGF,mBACE,aACA,aACA,yBAGF,eACE,iBACA,yBAGF,UACE,cAGF,UACE,YACA,kBACA,qCAEA,UACE,YACA,aACA,mBACA,uBACA,2CAEA,cXjyI0B,eAEC,CW2yI7B,8CALF,iBACE,MACA,OACA,QACA,SAYA,CAXA,yBAQA,mBACA,8BACA,oBACA,4BAEA,mBACE,0DAGF,SACE,4DAEA,mBACE,mBAKN,yBACE,sBACA,SACA,WX73IM,eW+3IN,aACA,mBACA,eACA,cACA,cACA,kBACA,kBACA,MACA,SACA,yBAGF,MACE,0BAGF,OACE,CASA,4CANF,UACE,kBACA,kBACA,OACA,YACA,oBAUA,6BAEA,WACE,sBAGF,mBACE,qBACA,gBACA,cX15IsB,mFW65ItB,yBAGE,wBAKN,oBACE,sBAGF,qBX17IQ,YW47IN,WACA,kBACA,YACA,UACA,SACA,YACA,8BAGF,wBb/7IsB,qBam8ItB,iBACE,UACA,QACA,YACA,6CAGF,kBX/7I0B,cARb,kBW48IX,gBACA,aACA,sBACA,oBAGF,WACE,WACA,gBACA,iBACA,kBACA,wBAEA,iBACE,MACA,OACA,WACA,YACA,sBACA,aACA,aACA,CAGA,YACA,UACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,2CANA,qBACA,mBACA,uBAaF,CATE,mBAIJ,YACE,CAGA,iBACA,mDAGF,aAEE,mBACA,aACA,aACA,2DAEA,cACE,uLAGF,abngJgB,SasgJd,eACA,gBACA,kBACA,oBACA,YACA,aACA,kBACA,6BACA,+mBAEA,aAGE,yBACA,qiBAGF,aXlhJS,qwDWshJP,aAGE,sBAMR,sBACE,eAGF,iBACE,eACA,mBACA,sBAEA,eACE,cXziJS,kBW2iJT,yBACA,eACA,qBAGF,kBXhjJW,cAQa,gBW2iJtB,aACA,kBACA,kBAIJ,oBACE,eACA,gBACA,iBACA,wFAGF,kBAME,cXtkJW,kBWwkJX,gBACA,eACA,YACA,kBACA,sBACA,4NAEA,aACE,eACA,mBACA,wLAGF,WACE,UACA,kBACA,SACA,WACA,kRAGF,aACE,wBAKF,eX5mJM,CAiBkB,gBW8lJtB,oBACA,iEXhnJI,2BAiBkB,yBWumJ1B,iBACE,aACA,iCAEA,wBACE,CADF,qBACE,CADF,oBACE,CADF,gBACE,gBACA,2GAIJ,YAIE,8BACA,mBXtnJwB,aWwnJxB,iBACA,2HAEA,aACE,iBACA,cb1oJc,mBa4oJd,2IAGF,aACE,6BAIJ,cACE,2BAGF,WACE,eACA,0BAGF,gBAEE,sDAGF,qBAEE,eAGF,UACE,gBACA,0BAGF,YACE,6BACA,qCAEA,yBAJF,cAKI,gBACA,iDAIJ,qBAEE,UACA,qCAEA,+CALF,UAMI,sDAIJ,aAEE,gBACA,gBACA,gBACA,kBACA,2FAEA,abtsJoB,iLa0sJpB,aXvsJW,qCW4sJX,oDAjBF,eAkBI,sCAKF,4BADF,eAEI,yBAIJ,YACE,+BACA,gBACA,0BAEA,cACE,iBACA,mBACA,sCAGF,aACE,sBACA,WACA,CACA,aXtuJS,gBATL,aWkvJJ,oBACA,eACA,YACA,CACA,SACA,kBACA,yBACA,iBACA,gBACA,gBACA,4CAEA,wBACE,+CAGF,eXlwJI,yBWowJF,mBACA,kBACA,6DAEA,QACE,gBACA,gBACA,mEAEA,QACE,0DAIJ,aXzwJO,oBW2wJL,eACA,gBXrxJA,+CW0xJJ,YACE,8BACA,mBACA,4CAIJ,aACE,cXzxJS,eW2xJT,gBACA,mBACA,wCAGF,eACE,mBACA,+CAEA,aXpyJS,eWsyJP,qCAIJ,uBAnFF,YAoFI,eACA,QACA,wCAEA,iBACE,iBAKN,eACE,eACA,wBAEA,eACE,iBACA,2CAGF,eACE,mBAGF,eACE,cACA,gBACA,+BAEA,4BACE,4BAGF,QACE,oCAIA,aXh1JO,aWk1JL,kBACA,eACA,mBACA,qBACA,8EAEA,eAEE,yWAOA,kBbp2JY,WENd,uDWi3JA,iBACE,oMAUR,aACE,iIAIJ,4BAIE,cbj4JgB,eam4JhB,gBACA,6cAEA,aAGE,6BACA,qGAIJ,YAIE,eACA,iIAEA,eACE,CAII,w1BADF,eACE,sDAMR,iBAEE,oDAKA,eACE,0DAGF,eACE,mBACA,aACA,mBACA,wEAEA,aX56JS,CW86JP,gBACA,uBAKN,YACE,2CAEA,QACE,WACA,cAIJ,wBbh8JsB,Wak8JpB,kBACA,MACA,OACA,aACA,6BAGF,aACE,kBACA,WXj9JM,0BWm9JN,WACA,SACA,gBACA,kBACA,eACA,gBACA,UACA,oBACA,WACA,4BACA,iBACA,wDAKE,SACE,uBAKN,eACE,6BAEA,UACE,kBAIJ,YACE,eACA,yBACA,kBACA,gBACA,gBACA,wBAEA,aACE,cbt/Jc,iBaw/Jd,eACA,+BACA,aACA,sBACA,mBACA,uBACA,eACA,4BAEA,aACE,wBAIJ,eACE,CACA,qBACA,aACA,sBACA,uBACA,2BAEA,aACE,cACA,0BAGF,oBACE,cbphKY,gBashKZ,gCAEA,yBACE,0BAKN,QACE,eACA,iDAEA,SACE,cACA,8BAGF,abviKc,gBa+iKhB,cACA,CACA,iBACA,CACA,UACA,qCANF,qBACE,CACA,eACA,CACA,iBAYA,CAVA,qBAGF,QACE,CACA,aACA,WACA,CACA,iBAEA,qEAGE,cACE,MACA,gCAKN,cACE,cACA,qBACA,cX9jKwB,kBWgkKxB,UACA,mEAEA,WAEE,WACA,CAIA,2DADF,mBACE,CADF,8BACE,CADF,gBX3lKM,CW4lKJ,wBAIJ,UACE,YACA,CACA,iBACA,MACA,OACA,UACA,gBXvmKM,iCW0mKN,YACE,sBAIJ,WACE,gBACA,kBACA,WACA,qCAGF,cACE,YACA,oBACA,CADA,8BACA,CADA,gBACA,kBACA,QACA,2BACA,WACA,UACA,sCAGF,0BACE,2BACA,gBACA,kBACA,qKAMA,WAEE,mFAGF,WACE,eAKJ,qBACE,kBACA,mBACA,kBACA,oBACA,cACA,wBAEA,eACE,YACA,yBAGF,cACE,kBACA,gBACA,gCAEA,UACE,cACA,kBACA,6BACA,WACA,SACA,OACA,oBACA,qCAIJ,iCACE,iCAGF,wBACE,uCAIA,mBACA,mBACA,6BACA,0BACA,eAIJ,eACE,kBACA,gBXvsKM,eWysKN,kBACA,sBACA,cACA,wBAEA,eACE,sBACA,qBAGF,SACE,qBAGF,eACE,gBACA,UACA,0BAGF,oBACE,sBACA,SACA,gCAEA,wBACE,0BACA,qBACA,sBACA,UACA,4BAKF,qBACE,CADF,gCACE,CADF,kBACE,kBACA,QACA,2BACA,yBAIJ,iBACE,UACA,SACA,OACA,QACA,sBACA,iFACA,eACA,UACA,4BACA,gCAEA,SACE,6EAKF,iBAEE,wBAIJ,YACE,kBACA,MACA,OACA,WACA,YACA,UACA,SACA,gBXpxKI,cAiBgB,gBWswKpB,oBACA,+BAEA,aACE,oBACA,8GAEA,aAGE,+BAIJ,aACE,eACA,kCAGF,aACE,eACA,gBACA,4BAIJ,YACE,8BACA,oBACA,0DAEA,aACE,wBAIJ,cACE,mBACA,gBACA,uBACA,oCAGE,cACE,qCAKF,eACE,+BAIJ,sBACE,iBACA,eACA,SACA,0BACA,8GAEA,UXn1KE,+EW21KN,cAGE,gBACA,6BAGF,UXl2KM,iBWo2KJ,yBAGF,oBACE,aACA,mDAGF,UX52KM,uBWi3KN,cACE,YACA,eACA,8BAEA,UACE,WACA,+BAOA,6DANA,iBACA,cACA,kBACA,WACA,UACA,YAWA,CAVA,+BASA,kBACA,+BAGF,iBACE,UACA,kBACA,WACA,YACA,YACA,UACA,4BACA,mBACA,sCACA,oBACA,qBAIJ,gBACE,uBAEA,oBACE,eACA,gBACA,WXj6KE,sFWo6KF,yBAGE,qBAKN,cACE,YACA,kBACA,4BAEA,UACE,WACA,+BACA,kBACA,cACA,kBACA,WACA,SACA,2DAGF,aAEE,kBACA,WACA,kBACA,SACA,mBACA,6BAGF,6BACE,6BAGF,iBACE,UACA,UACA,kBACA,WACA,YACA,QACA,iBACA,4BACA,mBACA,sCACA,oBACA,CAGE,yFAKF,SACE,6GAQF,gBACE,oBACA,kBAON,UACE,cACA,+BACA,0BAEA,UACE,qCAGF,iBATF,QAUI,mBAIJ,qBACE,mBACA,uBAEA,YACE,kBACA,gBACA,gBACA,2BAEA,aACE,WACA,YACA,SACA,oBACA,CADA,8BACA,CADA,gBACA,uBAIJ,YACE,mBACA,mBACA,aACA,6BAEA,aACE,aACA,mBACA,qBACA,gBACA,qCAGF,UACE,eACA,cACA,+BAGF,aACE,WACA,YACA,gBACA,mCAEA,UACE,YACA,cACA,SACA,kBACA,mBACA,oBACA,CADA,8BACA,CADA,gBACA,qCAIJ,gBACE,gBACA,4CAEA,cACE,WX3jLF,gBW6jLE,gBACA,uBACA,0CAGF,aACE,eACA,cXpjLc,gBWsjLd,gBACA,uBACA,yBAKN,kBXpkLS,aWskLP,mBACA,uBACA,gDAEA,YACE,cACA,eACA,mDAGF,qBACE,kBACA,gCACA,WACA,gBACA,mBACA,gBACA,uBACA,qDAEA,YACE,iEAEA,cACE,sDAIJ,YACE,6BAOV,YACE,eACA,gBACA,wBAGF,QACE,sBACA,cACA,kBACA,kBACA,gBACA,WACA,+BAEA,iBACE,QACA,SACA,+BACA,eACA,sDAIJ,kBAEE,gCACA,eACA,aACA,cACA,oEAEA,kBACE,SACA,SACA,6HAGF,aAEE,cACA,cX5oLoB,eW8oLpB,eACA,gBACA,kBACA,qBACA,kBACA,WACA,mBACA,yJAEA,aXtpLsB,qWWypLpB,aAEE,WACA,kBACA,SACA,SACA,QACA,SACA,2BACA,CAEA,4CACA,CADA,kBACA,CADA,wBACA,iLAGF,WACE,6CACA,8GAKN,kBACE,gCACA,qSAKI,YACE,iSAGF,4CACE,cAOV,kBX1sLa,sBW6sLX,iBACE,4BAGF,aACE,eAIJ,cACE,kBACA,qBACA,cACA,iBACA,eACA,mBACA,gBACA,uBACA,eACA,oEAEA,YAEE,sBAGF,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,8BAEA,oBACE,mBACA,2BAKN,eACE,gBAGF,eXxwLQ,kBa0BN,CACA,sBACA,gBACA,cbbsB,uCaetB,mBAEA,wBACE,cblBoB,eaoBpB,gBACA,mBACA,mBAGF,aACE,mBAGF,kBACE,mBAGF,eACE,cbzCS,UWmwLb,iBACE,cAEA,WACE,WACA,sCACA,CADA,6BACA,cAGF,cACE,iBACA,cXtwLsB,gBWwwLtB,gBAEA,abrxLkB,0BauxLhB,sBAEA,oBACE,4BAMR,GACE,cACA,eACA,WATM,mBAMR,GACE,cACA,eACA,qEAGF,kBAIE,sBAEE,8BACA,iBAGF,0BACE,kCACA,+BAIA,qDACE,uEACA,+CAGF,sBACE,8BACA,6DAIA,6BACE,6CACA,4EAIF,6BACE,6CACA,+CAOJ,gBAEE,+BAGF,gBACE,6CAEA,0BACE,wDAGF,eACE,6DAGF,iBACE,iBACA,2EAIA,mBACE,UACA,gCACA,WACA,0FAGF,mBACE,UACA,oCACA,eAOV,UACE,eACA,gBACA,iBAEA,YACE,gBACA,eACA,kBACA,sCAGF,YACE,4CAEA,kBACE,yDAGF,SACE,sBACA,cACA,WACA,SACA,aACA,gDACA,mBX94LO,WATL,eW05LF,CACA,eACA,kBACA,2EAEA,QACE,wMAGF,mBAGE,+DAGF,kBACE,qCAGF,wDA7BF,cA8BI,4DAIJ,WACE,eACA,gBACA,SACA,kBACA,sBAMJ,sBACA,mBACA,6BACA,gCACA,+BAEA,iBACE,iBACA,cbj8Lc,Cao8Ld,eACA,eACA,oCAEA,aACE,gBACA,uBACA,oCAIJ,UACE,kBACA,uDAGF,iBACE,qDAGF,eACE,qBAKF,wBACA,aACA,2BACA,mBACA,mBACA,2BAEA,aACE,iCAEA,UACE,uCAEA,SACE,kCAKN,aACE,cACA,mBAIJ,cACE,kBACA,MACA,OACA,WACA,YACA,0BACA,cAGF,kBX5/La,sBW8/LX,kBACA,uCACA,YACA,gBACA,qCAEA,aARF,SASI,kBAGF,cACE,mBACA,gBACA,eACA,kBACA,0BACA,6BAGF,WACE,6BAGF,yBACE,sCAEA,uBACE,uCACA,wBACA,wBAIJ,eACE,kDAIA,oBACE,+BAIJ,cACE,sBAGF,eACE,aAIJ,kBXljMa,sBWojMX,kBACA,uCACA,YACA,gBACA,qCAEA,YARF,SASI,uBAGF,kBACE,oBAGF,kBACE,YACA,0BACA,gBACA,mBAGF,YACE,gCACA,4BAGF,YACE,iCAGF,aACE,gBACA,qBACA,eACA,aACA,cAIJ,iBACE,YACA,gBACA,YACA,aACA,uBACA,mBACA,gBX5mMM,yDW+mMN,aAGE,gBACA,WACA,YACA,SACA,sBACA,CADA,gCACA,CADA,kBACA,gBXvnMI,uBW2nMN,iBACE,YACA,aACA,+BACA,iEACA,kBACA,wCACA,uBAGF,iBACE,WACA,YACA,MACA,OACA,uBAGF,iBACE,YACA,WACA,UACA,YACA,4BACA,6BAEA,UACE,8BAGF,UXxpMI,eW0pMF,gBACA,cACA,kBACA,2BAGF,iBACE,mCACA,qCAIJ,oCACE,eAEE,uBAGF,YACE,4BAKN,aXlqMwB,eWoqMtB,gBACA,gBACA,kBACA,qBACA,6BAEA,kBACE,wCAEA,eACE,6BAIJ,aACE,0BACA,mCAEA,oBACE,kBAKN,eACE,2BAEA,UACE,8FAEA,8BAEE,CAFF,sBAEE,wBAIJ,iBACE,SACA,UACA,yBAGF,eACE,aACA,kBACA,mBACA,6BAEA,mBACE,CADF,8BACE,CADF,gBACE,cACA,WACA,YACA,SACA,uBAIJ,iBACE,mBACA,YACA,gCACA,+BAEA,aACE,cACA,WACA,iBACA,gDAEA,kBACE,yBACA,wBAKN,YACE,uBACA,gBACA,iBACA,iCAEA,YACE,mBACA,iBACA,gBACA,8CAEA,wBACE,kBACA,uBACA,YACA,yCAGF,YACE,8BAIJ,WACE,4CAEA,kBACE,wCAGF,UACE,YACA,iCAGF,cACE,iBACA,WXtyMA,gBWwyMA,gBACA,mBACA,uBACA,uCAEA,aACE,eACA,cX/xMc,gBWiyMd,gBACA,uBACA,gCAKN,aACE,uBAIJ,eACE,cACA,iDAGE,qBACA,WXn0ME,gDWu0MJ,QACE,6BACA,kDAEA,aACE,yEAGF,uBACE,4DAGF,aXl1MU,yBWw1Md,cACE,gCAEA,cACE,cX70MkB,eW+0MlB,kCAEA,oBACE,cXl1MgB,qBWo1MhB,iBACA,gBACA,yCAEA,eACE,WXz2MF,iBWk3MN,ab92MgB,mBag3Md,gCACA,gBACA,aACA,eACA,eACA,qBAEA,oBACE,iBACA,eAIJ,YACE,mBACA,aACA,gCACA,0BAEA,eACE,qBAGF,aACE,cbx4MY,gBa04MZ,uBACA,mBACA,4BAEA,eACE,uBAGF,aXt4MkB,qBWw4MhB,eACA,gBACA,cACA,gBACA,uBACA,mBACA,qGAKE,yBACE,wBAMR,aACE,eACA,iBACA,gBACA,iBACA,mBACA,gBACA,cXh6MoB,0BWo6MtB,aACE,WACA,2CAEA,gCACE,yBACA,0CAGF,wBACE,eAMR,YACE,gCACA,CACA,iBACA,qBAEA,kBACE,UACA,uBAGF,aACE,CACA,sBACA,kBACA,eACA,uBAGF,oBACE,mBbn9MkB,kBaq9MlB,cACA,eACA,wBACA,wBAGF,aACE,CACA,0BACA,gBACA,8BAEA,eACE,aACA,2BACA,8BACA,uCAGF,cACE,cX/9MkB,kBWi+MlB,+BAGF,aXp+MoB,eWs+MlB,mBACA,gBACA,uBACA,kBACA,gBACA,YACA,iCAEA,UX9/ME,qBWggNA,oHAEA,yBAGE,0BAKN,qBACE,uBAIJ,kBACE,6BAEA,kBACE,oDAGF,eACE,6DAGF,UX1hNI,gBWgiNR,kBACE,eACA,aACA,qBACA,0BAEA,WACE,cACA,qCAEA,yBAJF,YAKI,4BAIJ,wBACE,cACA,kBACA,qCAEA,0BALF,UAMI,uBAIJ,qBACE,WACA,aACA,kBACA,eACA,iBACA,qBACA,gBACA,gBACA,gBACA,aACA,sBACA,6BAEA,aACE,gBACA,mBACA,mBACA,8BAGF,iBACE,SACA,WACA,cACA,mBb5kNgB,kBa8kNhB,cACA,eACA,4BAIJ,YACE,cX3kNoB,kBW6kNpB,WACA,QACA,mDAIJ,YACE,oDAGF,UACE,gBAGF,YACE,eACA,mBACA,gBACA,iBACA,wBACA,sBAEA,aACE,mBACA,SACA,kBACA,WACA,eACA,yBACA,CADA,qBACA,CADA,oBACA,CADA,gBACA,cACA,aACA,mBACA,2BACA,2CACA,6BAEA,aACE,aACA,WACA,YACA,iCAEA,aACE,SACA,WACA,YACA,eACA,gBACA,sBACA,sBACA,CADA,gCACA,CADA,kBACA,6BAIJ,aACE,cACA,eACA,gBACA,kBACA,gBACA,cXzoNkB,mFW6oNpB,kBAGE,4BACA,2CACA,wGAEA,aACE,6BAIJ,0BACE,2CACA,yBACA,yDAEA,aACE,uCAKN,UACE,oCAGF,WACE,8BAGF,aX5qNsB,SW8qNpB,eACA,WACA,cACA,cACA,YACA,aACA,mBACA,WACA,2BACA,2CACA,2GAEA,SAGE,cACA,4BACA,2CACA,qCAKF,SACE,OGxtNN,eACE,eACA,UAEA,kBACE,kBACA,cAGF,iBACE,cACA,mBACA,WACA,aACA,sBAEA,kBhBRkB,egBapB,iBACE,aACA,cACA,iBACA,eACA,gBACA,qBAEA,oBACE,qBACA,yBACA,4BACA,oEAGF,YAEE,kCAGF,aACE,gCAGF,aACE,sBACA,WACA,eACA,cdtCO,UcwCP,oBACA,gBdlDE,yBcoDF,kBACA,iBACA,sCAEA,oBhBlDgB,0BgBuDlB,cACE,wBAGF,YACE,mBACA,iBACA,cAIJ,oBACE,kBACA,yBACA,sBACA,WACA,YACA,cACA,kBACA,SACA,kBACA,sBACA,gBACA,mBACA,cACA,uBAEA,iBACE,qBAGF,oBd3FY,8EcgGZ,oBAGE,iBACA,gCAGF,mBACE,SACA,wCAGF,mBAEE,eAIJ,oBACE,WACA,gBACA,cACA,cAGF,aACE,qBACA,oBAEA,cACE,eAIJ,eACE,mBACA,chBjIc,agBqIhB,cACE,uBACA,UACA,SACA,SACA,chB1Ic,0BgB4Id,kBACA,mBAEA,oBACE,sCAGF,kCAEE,eAIJ,WACE,eACA,kBACA,eACA,6BAIJ,4BACE,gCAEA,YACE,2CAGF,4BACE,aACA,aACA,mBACA,mGAEA,YAEE,+GAEA,oBhBhLgB,sDgBsLpB,cACE,gBACA,iBACA,YACA,oBACA,chB7Lc,sCgBgMd,gCAGF,YACE,mBACA,8CAEA,aACE,wBACA,iBACA,oCAIJ,uBACE,CADF,oBACE,CADF,eACE,sBACA,eACA,cd5MS,qBc8MT,WACA,UACA,oBACA,qXACA,yBACA,kBACA,CACA,yBACA,mDAGF,aACE,cAIJ,ahBnOkB,qBgBsOhB,+BACE,6BAEA,2BACE,eChPN,k1BACE,aACA,sBACA,aACA,UACA,yBAGF,YACE,OACA,sBACA,yBACA,2BAEA,MACE,iBACA,qCAIJ,gBACE,YACE,cCtBJ,cACE,qBACA,chBSW,2BgBNX,qBAEE,iBACA,+BAGF,WACE,iBAIJ,sBACE,6BAEA,uBACE,2BACA,4BACA,mBhBHsB,4BgBOxB,oBACE,8BACA,+BACA,aACA,qBAIJ,YACE,8BACA,cACA,clB/BgB,ckBiChB,oBAGF,iBACE,OACA,kBACA,iBACA,gBACA,8BACA,eACA,0BAEA,aACE,6BAIJ,alBhDsB,mCkBmDpB,aACE,oDAGF,WACE,wBAIJ,iBACE,YACA,OACA,WACA,WACA,yBlBjEoB,uBkBsEpB,oBACE,WACA,eACA,yBAGF,iBACE,gBACA,oBAIJ,iBACE,aACA,gBACA,kBACA,gBhB5FM,sBgB8FN,sGAEA,+BAEE,oBAKF,2BACA,gBhBxGM,0BgB2GN,cACE,gBACA,gBACA,oBACA,cACA,WACA,gCACA,chBzGS,yBgB2GT,kBACA,4CAEA,QACE,2GAGF,mBAGE,wCAKN,cACE,6CAEA,SACE,kBACA,kBACA,qDAGF,SACE,WACA,kBACA,MACA,OACA,WACA,YACA,sCACA,mBACA,4BAIJ,SACE,kBACA,wBACA,gBACA,MACA,iCAEA,aACE,WACA,gBACA,gBACA,gBhBpKI,mBgByKR,iBACE,qBACA,YACA,wBAEA,UACE,YACA,wBAIJ,cACE,kBACA,iBACA,chBvKsB,mDgB0KtB,YACE,qDAGF,eACE,uDAGF,YACE,qBAIJ,YACE,YCrMF,qBACE,iBANc,cAQd,kBACA,sCAEA,WANF,UAOI,eACA,mBAIJ,iDACE,eACA,gBACA,gBACA,qBACA,cjBJsB,oBiBOtB,anBjBoB,0BmBmBlB,6EAEA,oBAGE,wCAIJ,ajBlBsB,oBiBuBtB,YACE,oBACA,+BAEA,eACE,yBAIJ,eACE,cjBhCsB,qBiBoCxB,iBACE,cjBrCsB,uBiByCxB,eACE,mBACA,kBACA,kBACA,yHAGF,4CAME,mBACA,oBACA,gBACA,cjBzDsB,qBiB6DxB,aACE,qBAGF,gBACE,qBAGF,eACE,qBAGF,gBACE,yCAGF,aAEE,qBAGF,eACE,qBAGF,kBACE,yCAMA,iBACA,iBACA,yDAEA,2BACE,yDAGF,2BACE,qBAIJ,UACE,SACA,SACA,gCACA,eACA,4BAEA,UACE,SACA,wBAIJ,UACE,yBACA,8BACA,CADA,iBACA,gBACA,mBACA,iEAEA,+BAEE,cACA,kBACA,gBACA,gBACA,cjBrIkB,iCiByIpB,uBACE,gBACA,gBACA,cnBxJY,qDmB4Jd,WAEE,iBACA,kBACA,qBACA,mEAEA,SACE,kBACA,iFAEA,gBACE,kBACA,6EAGF,iBACE,SACA,UACA,mBACA,gBACA,uBACA,+BAMR,YACE,oBAIJ,kBACE,eACA,mCAEA,iBACE,oBACA,8BAGF,YACE,8BACA,eACA,6BAGF,UACE,kDACA,eACA,iBACA,WjBpNI,iBiBsNJ,kBACA,qEAEA,aAEE,6CAIA,ajB9MoB,oCiBmNtB,4CACE,gBACA,eACA,iBACA,qCAGF,4BA3BF,iBA4BI,4BAIJ,iBACE,YACA,sBACA,mBACA,CACA,sBACA,0BACA,QACA,aACA,yCAEA,4CACE,eACA,iBACA,gBACA,cjB/OkB,mBiBiPlB,mBACA,gCACA,uBACA,mBACA,gBACA,wFAEA,eAEE,cACA,2CAGF,oBACE,2BAKN,iBACE,mCAEA,UACE,YACA,CACA,kBACA,uCAEA,aACE,WACA,YACA,mBACA,iCAIJ,cACE,mCAEA,aACE,WjBzSA,qBiB2SA,uDAGE,yBACE,2CAKN,aACE,cjBrSgB,kCiB6StB,iDAEE,CACA,eACA,eACA,iBACA,mBACA,cjBpToB,sCiBuTpB,anBjUkB,0BmBmUhB,kBAIJ,cACE,SACA,UACA,gBACA,uBACA,oBACA,kBACA,oBACA,cACA,kBAGF,4CACE,eACA,iBACA,gBACA,mBACA,cjB7UsB,wBiBgVtB,iDACE,cACA,eACA,gBACA,cACA,kBAIJ,4CACE,eACA,iBACA,gBACA,mBACA,cjB9VsB,kBiBmWtB,cjBnWsB,mCiBkWxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBvWsB,kBiB4WtB,cjB5WsB,kBiBqXtB,cjBrXsB,mCiBoXxB,4CACE,CACA,gBACA,gBACA,mBACA,cjBzXsB,kBiB8XtB,cjB9XsB,mCiBsYxB,gBAEE,mDAEA,2BACE,mDAGF,2BACE,kBAIJ,eACE,kBAGF,kBACE,yCAGF,cAEE,kBAGF,UACE,SACA,SACA,0CACA,cACA,yBAEA,UACE,SACA,iDAIJ,YAEE,+BAGF,kBjB1bW,kBiB4bT,kBACA,gBACA,sBACA,oCAEA,UACE,aACA,2BACA,iBACA,8BACA,mBACA,uDAGF,YACE,yBACA,qBACA,mFAEA,aACE,eACA,qCAGF,sDAVF,UAWI,8BACA,6CAIJ,MACE,sBACA,qCAEA,2CAJF,YAKI,sBAKN,iBACE,yBAEA,WACE,WACA,uBACA,4BAIJ,iBACE,mBACA,uCAEA,eACE,mCAGF,eACE,cACA,qCAGF,eACE,UACA,mDAEA,kBACE,aACA,iBACA,0FAKE,oBACE,gFAIJ,cACE,qDAIJ,aACE,cACA,6CAGF,UACE,YACA,0BACA,mDAGF,cACE,4DAEA,cACE,qCAKN,oCACE,eACE,sCAIJ,2BA7DF,iBA8DI,mFAIJ,qBAGE,mBjBnjBS,kBiBqjBT,kCACA,uBAGF,YACE,kBACA,WACA,YACA,2BAEA,YACE,WACA,uCAKF,YACE,eACA,mBACA,mBACA,qCAGF,sCACE,kBACE,uCAIJ,ajB3kBsB,qCiB+kBtB,eACE,WjBjmBE,gBiBmmBF,2CAEA,ajBrlBkB,gDiBwlBhB,ajBvlBkB,+CiB6lBtB,eACE,qBAIJ,kBACE,yBAEA,aACE,SACA,eACA,YACA,kBACA,qCAIJ,gDAEI,kBACE,yCAGF,eACE,gBACA,WACA,kBACA,uDAEA,iBACE,sCAMR,8BACE,aACE,uCAEA,gBACE,sDAGF,kBACE,6EAIJ,aAEE,qBAIJ,WACE,UAIJ,mBACE,qCAEA,SAHF,eAII,kBAGF,YACE,uBACA,mBACA,aACA,qBAEA,SjBvrBI,YiByrBF,qCAGF,gBAXF,SAYI,mBACA,sBAIJ,eACE,uBACA,gBACA,gBACA,uBAGF,eACE,gBACA,0BAEA,YACE,yBACA,gBACA,eACA,cjBjsBkB,6BiBqsBpB,eACE,iBACA,+BAGF,kBjBjtBS,aiBmtBP,0BACA,aACA,uCAEA,YACE,gCAIJ,cACE,gBACA,uDAEA,YACE,mBACA,iDAGF,UACE,YACA,0BACA,gCAIJ,YACE,uCAEA,4CACE,eACA,gBACA,cACA,qCAGF,cACE,cjBhvBgB,uFiBsvBtB,eACE,cASA,CjBhwBoB,2CiB6vBpB,iBACA,CACA,kBACA,gBAGF,eACE,cACA,aACA,kDACA,cACA,qCAEA,eAPF,oCAQI,cACA,8BAEA,UACE,aACA,sBACA,0CAEA,OACE,cACA,2CAGF,YACE,mBACA,QACA,cACA,qCAIJ,UACE,2BAGF,eACE,sCAIJ,eAtCF,UAuCI,6BAEA,aACE,gBACA,gBACA,2GAEA,eAGE,uFAIJ,+BAGE,2BAGF,YACE,gCAEA,eACE,qEAEA,eAEE,gBACA,2CAGF,eACE,SAQZ,iBACE,qBACA,iBAGF,aACE,kBACA,aACA,UACA,YACA,cjB71BsB,qBiB+1BtB,eACA,qCAEA,gBAVF,eAWI,WACA,gBACA,cnBj3Bc,SoBNlB,UACE,eACA,iBACA,yBACA,qBAEA,WAEE,iBACA,mBACA,6BACA,gBACA,mBACA,oBAGF,qBACE,gCACA,aACA,gBACA,oBAGF,eACE,qEAGF,kBlBhBW,UkBqBX,apBxBoB,0BoB0BlB,gBAEA,oBACE,eAIJ,eACE,CAII,4HADF,eACE,+FAOF,sBAEE,yFAKF,YAEE,gCAMJ,kBlBzDS,6BkB2DP,gCACA,4CAEA,qBACE,8BACA,2CAGF,uBACE,+BACA,0BAKN,qBACE,gBAIJ,aACE,mBACA,MAGF,+CACE,0BAGF,sBACE,SACA,aACA,8CAGF,oBAEE,qBACA,iBACA,eACA,clB5FsB,gBkB8FtB,0DAEA,UlBhHM,wDkBoHN,eACE,iBACA,sEAGF,cACE,yCAKF,YAEE,yDAEA,qBACE,iBACA,eACA,gBACA,qEAEA,cACE,2EAGF,YACE,mBACA,uFAEA,YACE,qHAOJ,sBACA,cACA,uBAIJ,wBACE,mBlBvJS,sBkByJT,YACA,mBACA,gCAEA,gBACE,mBACA,oBAIJ,YACE,yBACA,aACA,mBlBtKS,gCkByKT,aACE,gBACA,mBAIJ,wBACE,aACA,mBACA,qCAEA,wCACE,4BACE,0BAIJ,kBACE,iCAGF,kBlB9LS,uCkBiMP,kBACE,4BAIJ,gBACE,oBACA,sCAEA,SACE,wCAGF,YACE,mBACA,mCAGF,aACE,aACA,uBACA,mBACA,kBACA,6CAEA,UACE,YACA,kCAIJ,aACE,mCAGF,aACE,iBACA,clB/NgB,gBkBiOhB,mCAIJ,QACE,WACA,qCAEA,sBACE,gBACA,qCAOJ,4FAFF,YAGI,gCAIJ,aACE,uCAEA,iBACE,sCAGF,eACE,4BAIJ,wBACE,aACA,gBACA,qCAEA,2BALF,4BAMI,sCAIJ,+CACE,YACE,iBC7RN,YACE,uBACA,WACA,iBACA,iCAEA,gBACE,gBACA,oBACA,cACA,wCAEA,YACE,yBACA,mBnBPO,YmBSP,yBAIJ,WAvBc,UAyBZ,oBACA,iCAEA,YACE,mBACA,YACA,uCAEA,aACE,yCAEA,oBACE,aACA,2CAGF,SnBxCA,YmB0CE,kBACA,YACA,uCAIJ,aACE,cnBjCgB,qBmBmChB,cACA,eACA,aACA,0HAIA,kBAGE,+BAKN,aACE,iBACA,YACA,aACA,qCAGF,sCACE,YACE,6BAIJ,eACE,0BACA,gBACA,mBACA,qCAEA,2BANF,eAOI,+BAGF,aACE,aACA,cnB3EgB,qBmB6EhB,0BACA,2CACA,0BACA,mBACA,gBACA,uBACA,mCAEA,gBACE,oCAGF,UnBzGA,yBmB2GE,0BACA,2CACA,uCAGF,kBACE,sBACA,+BAIJ,kBACE,wBACA,SACA,iCAEA,QACE,kBACA,6DAIJ,UnBjIE,yBFMc,gBqB8Hd,gBACA,mEAEA,qBACE,6DAKN,yBACE,iCAKF,UACA,gBAEA,sCAGF,uCACE,YACE,iCAGF,WA/JY,cAiKV,sCAIJ,gCACE,UACE,0BAMF,2BACA,qCAEA,wBALF,cAMI,CACA,sBACA,kCAGF,YACE,oBAEA,gCACA,0BAEA,eAEA,mBACA,8BACA,mCAEA,eACE,kBACA,yCAGF,mBACE,4DAEA,eACE,qCAIJ,gCAzBF,eA0BI,iBACA,6BAIJ,anBnMsB,emBqMpB,iBACA,gBACA,qCAEA,2BANF,eAOI,6BAIJ,anB9MsB,emBgNpB,iBACA,gBACA,mBACA,4BAGF,wBACE,eACA,gBACA,cnB1NkB,mBmB4NlB,kBACA,gCACA,4BAGF,cACE,cnBjOoB,iBmBmOpB,gBACA,0CAGF,UnBxPI,gBmB0PF,uFAGF,eAEE,gEAGF,aACE,4CAGF,cACE,gBACA,WnBxQE,oBmB0QF,iBACA,gBACA,gBACA,2BAGF,cACE,iBACA,cnBjQoB,mBmBmQpB,kCAEA,UnBtRE,gBmBwRA,CAII,2NADF,eACE,4BAMR,UACE,SACA,SACA,0CACA,cACA,mCAEA,UACE,SACA,qCAKN,eA9SF,aA+SI,iCAEA,YACE,yBAGF,UACE,UACA,YACA,iCAEA,YACE,4BAGF,YACE,8DAGF,eAEE,gCACA,gBACA,0EAEA,eACE,+BAIJ,eACE,6DAGF,2BrB7UgB,YqBoVtB,UACE,SACA,cACA,WACA,sDAKA,anBnVsB,0DmBsVpB,arBhWkB,4DqBqWpB,anB1Wc,gBmB4WZ,4DAGF,anB9WU,gBmBgXR,0DAGF,arBjXgB,gBqBmXd,0DAGF,anBtXU,gBmBwXR,UAIJ,YACE,eACA,yBAEA,aACE,qBACA,oCAEA,kBACE,4BAGF,cACE,gBACA,+BAEA,oBACE,iBACA,gCAIJ,eACE,yBACA,eACA,CAII,iNADF,eACE,6CAKN,aACE,mBACA,2BAGF,oBACE,cnBxZkB,qBmB0ZlB,yBACA,eACA,gBACA,gCACA,iCAEA,UnBhbE,gCmBkbA,oCAGF,arB/agB,gCqBibd,CAkBJ,gBAIJ,aACE,iBACA,eACA,sBAGF,aACE,eACA,cACA,wBAEA,aACE,kBAIJ,YACE,eACA,mBACA,wBAGF,YACE,WACA,sBACA,aACA,+BAEA,aACE,qBACA,gBACA,eACA,iBACA,cnB7dsB,CmBkelB,4MADF,eACE,sCAKN,aACE,gCAIJ,YAEE,mBACA,kEAEA,UACE,kBACA,4BACA,gFAEA,iBACE,kDAKN,aAEE,aACA,sBACA,4EAEA,cACE,WACA,kBACA,mBACA,uEAIJ,cAEE,iBAGF,YACE,eACA,kBACA,2CAEA,kBACE,eACA,8BAGF,kBACE,+CAGF,gBACE,uDAEA,gBACE,mBACA,YACA,YAKN,kBACE,eACA,cAEA,arBvjBoB,qBqByjBlB,oBAEA,yBACE,SAKN,aACE,YAGF,gBACE,eACA,mBnBpkBW,gCmBskBX,uBAEA,eACE,oBAGF,YACE,2BACA,mBACA,cnBxkBoB,emB0kBpB,eACA,oBAGF,iBACE,4BAEA,aACE,SACA,kBACA,WACA,YACA,qBAIJ,2BACE,mBAGF,oBACE,uBAGF,arB9mBgB,sDqBknBhB,anBrmBwB,qBmBymBtB,gBACA,yDAIJ,oBAIE,cnBlnBwB,iGmBqnBxB,eACE,yIAIA,4BACE,cACA,iIAGF,8BACE,CADF,sBACE,WACA,sBAKN,YAEE,mBACA,sCAEA,aACE,CACA,gBACA,kBACA,0DAIA,8BACE,CADF,sBACE,WACA,gBAKN,kBACE,8BACA,yBAEA,yBnB9qBc,yBmBkrBd,yBACE,wBAGF,yBnBnrBU,wBmBwrBR,2BACA,eACA,iBACA,4BACA,kBACA,gBACA,0BAEA,anBprBoB,uBmB0rBpB,wBACA,qBAGF,arB1sBgB,cqB+sBlB,kBnB1sBa,kBmB4sBX,mBACA,uBAEA,YACE,8BACA,mBACA,aACA,gCAEA,SACE,SACA,gDAEA,aACE,8BAIJ,aACE,gBACA,cnBztBkB,yBmB2tBlB,iBACA,gCAEA,aACE,qBACA,iHAEA,aAGE,mCAIJ,anBvvBM,6BmB8vBR,YACE,2BACA,6BACA,mCAEA,kBACE,gFAGF,YAEE,cACA,sBACA,YACA,cnB9vBgB,mLmBiwBhB,kBAEE,gBACA,uBACA,sCAIJ,aACE,6BACA,4CAEA,arBzxBU,iBqB2xBR,gBACA,wCAIJ,aACE,sBACA,WACA,aACA,qBACA,cnBzxBgB,WmBgyBxB,kBAGE,0BAFA,eACA,uBASA,CARA,eAGF,oBACE,gBACA,CAEA,qBACA,oBAGF,YACE,eACA,CACA,kBACA,wBAEA,qBACE,cACA,mBACA,aACA,0FAGF,kBAEE,kBACA,YACA,6CAGF,QACE,SACA,+CAEA,aACE,sEAGF,uBACE,yDAGF,anB71BY,8CmBk2Bd,qBACE,aACA,WnBr2BI,cmB02BR,iBACE,qBAGF,wBACE,kBACA,2BAEA,cACE,mBnB12BS,gCmB42BT,kCAEA,cACE,cACA,gBACA,eACA,gBACA,cnB32BoB,qBmB62BpB,mBACA,uHAEA,UnBj4BE,iCmBw4BJ,cACE,crBr4BY,uCqBy4Bd,YACE,8BACA,mBACA,sCAGF,eACE,sBCt5BN,YACE,eACA,CACA,kBACA,0BAEA,qBACE,iBACA,cACA,mBACA,yDAEA,YAEE,mBACA,kBACA,sBACA,YACA,4BAGF,oBACE,cACA,cACA,qGAEA,kBAGE,sDAKN,iBAEE,gBACA,eACA,iBACA,WpBrCI,6CoBuCJ,mBACA,iBACA,4BAGF,cACE,6BAGF,cACE,cpBjCoB,kBoBmCpB,gBACA,qBAIJ,YACE,eACA,cACA,yBAEA,gBACE,mBACA,6BAEA,aACE,sCAIJ,apBrDwB,gBoBuDtB,qBACA,UC3EJ,aACE,gCAEA,gBACE,eACA,mBACA,+BAGF,cACE,iBACA,8CAGF,aACE,kBACA,wBAGF,gBACE,iCAGF,aACE,kBACA,uCAGF,oBACE,gDAGF,SACE,YACA,8BAGF,cACE,iBACA,mEAGF,aACE,kBACA,2DAGF,cAEE,gBACA,mFAGF,cACE,gBACA,mCAGF,aACE,iBACA,yBAGF,kBACE,kBACA,4BAGF,UACE,UACA,wBAGF,aACE,kCAGF,MACE,WACA,cACA,mBACA,2CAGF,aACE,iBACA,0CAGF,gBACE,eACA,mCAGF,WACE,sCAGF,gBACE,gBACA,yCAGF,UACE,iCAGF,aACE,iBACA,0BAGF,SACE,WACA,0DAGF,iBAEE,mBACA,4GAGF,iBAEE,gBACA,uCAGF,kBACE,eACA,2BAGF,aACE,kBACA,wCAGF,SACE,YACA,yDAGF,SACE,WACA,CAKA,oFAGF,UACE,OACA,uGAGF,UAEE,uCAIA,cACE,iBACA,kEAEA,cACE,gBACA,qCAKN,WACE,eACA,iBACA,uCAGF,WACE,sCAGF,aACE,kBACA,0CAGF,gBACE,eACA,uDAGF,gBACE,2CAGF,cACE,iBACA,YACA,yEAGF,aAEE,iBACA,iBAGF,wBACE,iBAGF,SACE,oBACA,yBAGF,aACE,8EAGF,cAEE,gBACA,oDAGF,cACE,mBACA,gEAGF,iBACE,gBACA,CAMA,8KAGF,SACE,QACA,yDAGF,kBACE,eACA,uDAGF,kBACE,gBACA,qDAGF,SACE,QACA,8FAGF,cAEE,mBACA,4CAGF,UACE,SACA,kDAEA,UACE,OACA,+DACA,8BAIJ,sXACE,uCAGF,gBAEE,kCAGF,cACE,iBACA,gDAGF,UACE,UACA,gEAGF,aACE,uDAGF,WACE,WACA,uDAGF,UACE,WACA,uDAGF,UACE,WACA,kDAGF,MACE,0CAGF,iBACE,yBACA,qDAGF,cACE,iBACA,qCAGF,kCACE,gBAEE,kBACA,2DAEA,gBACE,mBACA,uEAKF,gBAEE,kBACA,gFAKN,cAEE,gBACA,6CAKE,eACE,eACA,sDAIJ,aACE,kBACA,4DAKF,cACE,gBACA,8DAGF,gBACE,eACA,mCAIJ,aACE,kBACA,iBACA,kCAGF,WACE,mCAGF,WACE,oCAGF,cACE,gBACA,gFAGF,cACE,mBACA,+DAGF,SACE,QACA,kkEC7ZJ,kIACE,CADF,sIACE,qBACA,sCxB6EF,QACE,qBACE,gBACA,SAGF,SACE,gBACA,gBACA,+BAIJ,eAEE,sBACA,kBACA,gBACA,kBACA,kCACA,4BACA,gEAGF,gBAEE,uBACA,2BACA,qBAGF,eACE,UACA,wDAGF,qBAEE,sBACA,MAKF,cACE,oDACA,WACA,kCAGF,eAGE,cAGF,UACE,sBACA,WAGF,kBAzIW,sGAmBT,gBAIA,YAqHA,iBAGF,UACE,CAEA,oBACA,CADA,mBACA,CADA,4BACA,WACA,YACA,wBAGF,qGA7GE,eAIA,gBACA,WA0GA,mCAGF,eACE,WACA,gBACA,eACA,UACA,cACA,kBACA,QACA,4BAGF,iBACE,0BACA,YACA,cA3KS,yDA8KT,4BACA,uBACA,4BACA,yBACA,wBAGF,gBACE,eACA,mBAvLS,eA2LX,aACI,YACA,kBAEA,YACA,UACA,YACA,aACA,iGACA,4BACA,iCACA,UACA,gBAGJ,eACE,UACA,6BAGF,SACE,SAGF,gBACE,qBAGF,kBAvNW,CAcT,eACA,6CA2MA,CA3MA,kBA2MA,CA3MA,sBA2MA,CAMA,uCAHF,UACE,gBACA,mBAYA,CAXA,eAGF,WACE,eACA,CAvNA,eACA,6CAyNA,CAzNA,kBAyNA,CAzNA,sBAyNA,CAEA,oBACA,gCAGF,kBA3OsB,uCA+OtB,YACE,uBAEF,gBACE,mBAnPoB,4CAuPtB,UACE,yBAGF,eACE,eACA,wBAGF,kBAnQW,WAqQT,cACA,eACA,gBACA,cACA,eACA,sGAvPA,gBAIA,8BAsPA,UACE,mEAIJ,qGAvOE,eAIA,gBACA,yBAoOA,6BAMA,eACA,eAIA,iDARF,kBAvRW,WAyRT,YACA,CAEA,qGAzQA,gBAIA,eAuQA,gBAUA,kCAGF,iBACE,UACA,UACA,gBACA,eACA,cACA,2BAGF,cACE,gBACA,6BAGF,8BACE,gCACA,mCAGF,kBA9TW,WAgUT,oCAGF,UACE,oDAGF,yBACE,kBACA,kBACA,YACA,qBAGF,wBA9UW,CAcT,eACA,CAkUA,4CACA,CADA,kBACA,CADA,kBACA,2BAGF,UACE,gBACA,eACA,kBACA,UACA,SACA,yBA3VS,qBA6VT,cACA,gBACA,6CAGF,UACE,gBACA,kCAGF,WACE,iCAEF,WACE,iBAGF,gBACE,mCAIA,qBACA,CAvVA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,WAwVA,YACA,sEAGF,qBACE,yCAGF,QACE,iBACA,kDAGF,SACE,qCAGF,YACE,mCAGF,eACE,aACA,WAGF,wBAjZW,sGAmBT,gBAIA,YA6XA,iBAGF,oBACE,WACA,CAzWA,+BA4WF,qGAjXE,eAIA,gBAsXA,CArXA,cAgXF,UACE,sBACA,CAlXA,cAoXA,YACA,+FAGF,UAEE,gCACA,CAIA,iIAGF,gBACE,oBAGF,wBAtbW,WAwbT,sGAraA,gBAIA,wBAqaF,0lBACE,wBAEA,uCAGF,kBAlcW,WAqcT,kBAGF,yBACE,WACA,SAraA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,sBAwaA,CACA,mBACA,mBACA,uBAGF,wBArdW,kBAydX,cACE,uEAGF,aAEE,qBAGF,qBACE,kBACA,YACA,UACA,mBAteS,uBAweT,CAEA,eACA,iCACA,8BACA,iBACA,sCAGF,qBACE,4BAGF,WACE,8BAGF,gBACE,kBACA,2CAEA,cACE,kCAGJ,UACE,CAlgBS,+DAugBT,kBAvgBS,4DA2gBT,eACE,wBACA,gCAIJ,iBACE,oDAGF,cACE,kBAGF,eACE,4BACA,WACA,0BACA,YACA,gCAGF,aACE,uCAGF,UACE,gBACA,0GAlgBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,uBAugBA,CAvgBA,cAugBA,6CACA,CADA,oCACA,8BAGF,wBAljBW,SAojBT,iCACA,kBACA,mBACA,iBACA,cAEF,kBA1jBW,CAaT,4CACA,CADA,kBACA,CADA,gBACA,gBACA,UA8iBA,iBAGA,0HAFA,aAIE,qBAriBF,4CACA,CADA,kBACA,CADA,gBACA,gBACA,kCA2iBF,kBACE,eACA,sDAGF,sBAEE,YACA,CEjlBU,2IFslBV,aEtlBU,0BF2lBZ,kBA5lBW,CAaT,4CACA,CADA,kBACA,CADA,gBACA,gBACA,mBAglBA,iCAlkBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,mBAukBF,aArmBkB,uCAymBlB,gBACE,sBACA,mBACA,0BAGF,aACE,uCAGF,gBACE,mBACA,cAGF,eACE,gBACA,sBACA,WACA,oBAGF,qBACE,qBAGF,UACE,0BACA,gBACA,YAGF,UACE,gBACA,CA5oBS,qGAmBT,gBAIA,CAwnBA,eACA,6BAJA,kBA5oBS,CAuBT,UA6nBE,2BAIJ,UACC,4DAGD,UACE,gBACA,iCAGF,UACE,UAGF,gCACE,0GAGF,kBAzqBW,sGAmBT,gBAIA,sHAupBF,kBA9qBW,wHAkrBX,qGAvoBE,eAIA,gBACA,gDAsoBF,UACE,eAGF,yBACE,WACA,wBAGF,UACE,eACA,6BAGF,eACE,iBAGF,kBAxsBW,CAaT,+BACA,gBACA,qBA4rBA,gBACA,mBACA,6CACA,CADA,+BACA,CADA,gBACA,cAGF,UACE,sGA/rBA,gBAIA,YA6rBA,WACA,cACA,iCAGF,eACE,WACA,gBACA,eACA,UACA,cACA,kBACA,QACA,0BAIF,iBACE,iBACA,WACA,YACA,cAzuBS,yDA4uBT,4BACA,uBACA,4BACA,yBACA,yBAGF,4BACE,qCAGF,8YACE,4BACA,uBACA,4BACA,yBACA,iBACA,SAOF,kBApwBW,CAswBT,WACA,iCACA,CACA,oBACA,CADA,iCACA,CADA,sBACA,gBACA,eAIA,UACA,CA3uBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,oCAuuBF,qBAOE,gBAGF,gBACE,WACA,gBACA,sBAvxBqB,sBAyxBrB,mBAEA,UACE,oBACA,gBACA,yBAIJ,wBAtyBW,WAwyBT,iCACA,0BAGF,UACE,sOAGF,wBA7yBsB,WAkzBpB,mBAGF,UACE,0BAEA,SACE,yBAGF,UACE,sCAIJ,wBAp0BW,CAu0BT,yBACA,CADA,2BACA,iBAGF,UACE,wBAGF,UACE,gBACA,mFA5yBA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,+CAmzBF,eACE,gCAGF,eACE,gCACA,mBACA,+BAGF,6BACE,+BACA,8CAGF,wBAz2BW,0BA22BT,eACA,gBACA,wBAGF,wBAh3BW,gBAk3BT,iBACA,kCAGF,8BACE,4HAGF,kBA13BW,uEA+3BX,aA93BkB,mDAk4BlB,kBAn4BW,iBAs4BT,yGAGF,kBAt4BsB,4LA24BtB,gBAKE,WACA,sGAj4BA,gBAIA,mBAvBS,oCAy5BX,UACE,2CAGF,eACE,+BAGF,cACE,gBACA,cACA,kBACA,UACA,yBAt6BS,eAw6BT,cACA,wBAGF,iBACE,iBACA,0BACA,yBA/6BS,WAi7BT,0BAGF,UACE,+BAGF,UACE,0BACA,iDA75BA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,mBAg6BA,cACA,iCAGF,eACE,0BACA,yBAr8BS,YAu8BT,sBACA,8CAGF,cACE,gBACA,2BACA,qDAGF,WACE,eACA,gBACA,WACA,gDAGF,YACE,8BAGF,SACE,2BAGF,gBACE,gBACA,yBAl+BS,sBAo+BT,uBACA,6BAIF,UACE,sBACA,CACA,qGAj8BA,eAIA,gBACA,qCAg8BF,gCACE,qCAGF,UACE,gBACA,kBAGF,wBAz/BW,kBA2/BT,0BACA,SA5/BS,qGAmBT,CAIA,eA2+BA,WACA,gBACA,sDALF,wBA//BW,gBA0gCT,qGA/9BA,eAIA,gBACA,kBA89BA,UACE,8BACA,yBAEA,qGA//BF,gBAIA,kBAkgCF,wBAzhCW,sGA2CT,CAIA,eACA,eA4+BA,yBAGF,eACE,WACA,gBACA,eACA,UACA,kBACA,cACA,kBACA,UACA,kBAGF,iBACE,iBACA,WACA,YACA,cA/iCS,+YAkjCT,4BACA,uBACA,4BACA,yBACA,oBAGF,wBAzjCW,WA2jCT,iCACA,oBACA,eACA,cAGF,4BACE,WACA,oBACA,wBAjkCoB,WAmkClB,8CAKF,WACE,SACA,UACA,wCAMA,iBACA,qFAJF,yBACE,4BACA,6BAOE,0CAGF,WACE,WACA,CAMJ,4FACA,yDAGA,wGACA,yDAGA,wEACA,yDAGA,gFACA,yDAGA,sEACA,0DAGA,0FACA,0DAGA,gGACA,0DAGA,wEACA,0DAGA,sEACA,0DAGA,4FACA,0DAGA,wEACA,0DAGA,8EACA,mFAGF,YACE,kCAGF,qBACE,gBACA,eACA,WACA,iBACA,kBACA,mBACA,OAEA,aACA,cACA,kBACA,yBACA,WACA,YACA,CAIA,wBACA,0BACA,2BAjqCA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,yBAfS,yCAsrCX,YACE,yBAGF,wBA1rCW,kBA8rCX,wBACE,4CAGF,UACE,6BAGF,yBACE,WACA,YACA,iBAGF,wBA5sCW,SA8sCT,8BACA,8CAGF,UACE,YACA,gCAGF,UACE,gBACA,kCAGF,UACE,sBAGF,YACE,2BAGF,yBACE,kCAGF,qGA7rCE,eAIA,gBACA,wDA4rCF,eAxuCuB,gBA2uCrB,sBACA,iBACA,kBAGF,4BACE,yBAGF,YACE,gCAGF,UACE,sGAltCA,eAIA,gBACA,8CAitCF,sBACE,oDAGF,sBACE,WACA,0BACA,0CAGF,oBAGE,2FAGF,UACE,4GAGF,qBAII,qBACA,sBACA,yCAGJ,eACI,8BAGJ,iBACI,SACA,iDAGJ,iBACI,SACA,mCAGJ,kBACI,uBAGJ,iBACI,0BAGJ,iBACI,kBAGJ,iBACI,0BAGJ,iBACI,SACA,2GAGJ,qGA9yCE,gBAIA,mBAvBS,2FAu0CX,sBAIE,cACA,mBAz0CoB,WA20CpB,gBACA,iBACA,qBAGF,4BACE,0CAGF,eACE,gBACA,oFAGF,kBA51CW,iBA81CT,sDAGF,kBA91CsB,WAg2CpB,gBACA,YACA,eACA,gBACA,CAKE,+RAEA,UAGE,CAj0CJ,gMAo0CE,qGAz0CF,eAIA,gBACA,uHA00CF,eAEE,WA50CA,2CAi1CF,oQAEE,uBAGF,iBACE,MACA,wBACA,WACA,yBAv4CoB,eAy4CpB,gBACA,WACA,WACA,cACA,CACA,wBACA,sBACA,gBAGF,iBACE,mBAv5CS,sGAmBT,gBAIA,WAm4CA,YACA,iBACA,WACA,iBACA,sBACA,gBACA,sCAGF,eACE,UACE,YACA,kBACA,sCAIJ,eACE,WACE,YACA,0BACA,SACA,kCAIJ,eACE,YACA,cACA,WACA,iCAGF,aACE,wBACA,CAh7CA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,kBAg7CA,iBACA,kBACA,mBACA,sBACA,yBAGF,wBAt8CW,WAw8CT,eACA,gBACA,sBACA,kBACA,yBAGF,eACE,mBAh9CS,WAk9CT,WACA,YACA,oBACA,+BAGF,iBACE,QACA,SACA,WACA,YACA,SACA,4BAGF,kBAj+CW,CAm+CT,gBACA,WACA,+BAEA,oBACE,4EAEA,WAEE,2BACA,sCAt9CJ,UA69CI,wEAJF,iBACE,sGA99CJ,gBAIA,CA49CI,WASA,CARA,kCAGF,oBACE,CAEA,SAEA,iCAGF,oBACE,qIA58CJ,gBAMA,2BACA,4BACA,gBAs8CI,SACA,WACA,wBACA,0CAEA,kBAvgDK,WAygDH,gBACA,mBACA,uCAGF,kBA9gDK,WAghDH,kCAIJ,uBACE,uBACA,kBACA,UACA,SACA,UACA,qCAEA,kBA5hDK,qBA8hDH,wBACA,uCAEA,kBAjiDG,qIAoDT,gBAMA,2BACA,4BACA,WAw+CQ,gBACA,kBACA,UACA,gDAEA,kBAziDC,WA2iDC,mBACA,gBACA,kBACA,iBACA,kBACA,kBACA,UACA,4DAEA,aACE,sDAGF,sBACE,WACA,6CAIJ,kBA9jDC,WAgkDC,sCAQZ,iCACE,gBACE,yBAGF,mBACE,sCAIJ,iCACE,eACE,yBAKE,gBACA,WACA,YACA,iCAEF,aACE,WACA,0BACA,iBAKN,qBAlmDuB,WAomDrB,sBACA,gBACA,kBACA,MACA,OACA,WACA,sBAGF,qBACE,CA7kDA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,weA+kDF,UAeE,qEAGF,qBAEE,sJAGF,UAKE,sBACA,CA9mDA,4CACA,CADA,kBACA,CADA,gBACA,gBACA,4WA+mDA,qBACE,qEAIJ,kBA3pDW,sGAmBT,gBAIA,WA0oDA,gBACA,uFAEA,kBApqDS,4CAyqDX,eArqDuB,WAwqDrB,iBACA,kBACA,sBACA,gDAEA,UACE,0BACA,gGAIJ,kBAvrDW,yBA8rDX,yBACE,YACA,kCAGF,UACE,sBACA,kBACA,CAIA,4CACA,CADA,kBACA,CADA,gBACA,WACA,YACA,qBACA,sBACA,iBACA,2CAGF,qBACE,gCACA,8FAGF,UAGE,kCACA,ooB","file":"skins/vanilla/win95/common.css","sourcesContent":["@font-face{font-family:\"premillenium\";src:url(\"~fonts/premillenium/MSSansSerif.ttf\") format(\"truetype\")}html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:\"\";content:none}table{border-collapse:collapse;border-spacing:0}html{scrollbar-color:#192432 rgba(0,0,0,.1)}::-webkit-scrollbar{width:12px;height:12px}::-webkit-scrollbar-thumb{background:#192432;border:0px none #fff;border-radius:50px}::-webkit-scrollbar-thumb:hover{background:#1c2938}::-webkit-scrollbar-thumb:active{background:#192432}::-webkit-scrollbar-track{border:0px none #fff;border-radius:0;background:rgba(0,0,0,.1)}::-webkit-scrollbar-track:hover{background:#121a24}::-webkit-scrollbar-track:active{background:#121a24}::-webkit-scrollbar-corner{background:transparent}body{font-family:\"mastodon-font-sans-serif\",sans-serif;background:#06090c;font-size:13px;line-height:18px;font-weight:400;color:#fff;text-rendering:optimizelegibility;font-feature-settings:\"kern\";text-size-adjust:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}body.system-font{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"Oxygen\",\"Ubuntu\",\"Cantarell\",\"Fira Sans\",\"Droid Sans\",\"Helvetica Neue\",\"mastodon-font-sans-serif\",sans-serif}body.app-body{padding:0}body.app-body.layout-single-column{height:auto;min-height:100vh;overflow-y:scroll}body.app-body.layout-multiple-columns{position:absolute;width:100%;height:100%}body.app-body.with-modals--active{overflow-y:hidden}body.lighter{background:#121a24}body.with-modals{overflow-x:hidden;overflow-y:scroll}body.with-modals--active{overflow-y:hidden}body.player{text-align:center}body.embed{background:#192432;margin:0;padding-bottom:0}body.embed .container{position:absolute;width:100%;height:100%;overflow:hidden}body.admin{background:#0b1016;padding:0}body.error{position:absolute;text-align:center;color:#9baec8;background:#121a24;width:100%;height:100%;padding:0;display:flex;justify-content:center;align-items:center}body.error .dialog{vertical-align:middle;margin:20px}body.error .dialog__illustration img{display:block;max-width:470px;width:100%;height:auto;margin-top:-120px}body.error .dialog h1{font-size:20px;line-height:28px;font-weight:400}button{font-family:inherit;cursor:pointer}button:focus{outline:none}.app-holder,.app-holder>div,.app-holder>noscript{display:flex;width:100%;align-items:center;justify-content:center;outline:0 !important}.app-holder>noscript{height:100vh}.layout-single-column .app-holder,.layout-single-column .app-holder>div{min-height:100vh}.layout-multiple-columns .app-holder,.layout-multiple-columns .app-holder>div{height:100%}.error-boundary,.app-holder noscript{flex-direction:column;font-size:16px;font-weight:400;line-height:1.7;color:#e25169;text-align:center}.error-boundary>div,.app-holder noscript>div{max-width:500px}.error-boundary p,.app-holder noscript p{margin-bottom:.85em}.error-boundary p:last-child,.app-holder noscript p:last-child{margin-bottom:0}.error-boundary a,.app-holder noscript a{color:#00007f}.error-boundary a:hover,.error-boundary a:focus,.error-boundary a:active,.app-holder noscript a:hover,.app-holder noscript a:focus,.app-holder noscript a:active{text-decoration:none}.error-boundary__footer,.app-holder noscript__footer{color:#404040;font-size:13px}.error-boundary__footer a,.app-holder noscript__footer a{color:#404040}.error-boundary button,.app-holder noscript button{display:inline;border:0;background:transparent;color:#404040;font:inherit;padding:0;margin:0;line-height:inherit;cursor:pointer;outline:0;transition:color 300ms linear;text-decoration:underline}.error-boundary button:hover,.error-boundary button:focus,.error-boundary button:active,.app-holder noscript button:hover,.app-holder noscript button:focus,.app-holder noscript button:active{text-decoration:none}.error-boundary button.copied,.app-holder noscript button.copied{color:#79bd9a;transition:none}.container-alt{width:700px;margin:0 auto;margin-top:40px}@media screen and (max-width: 740px){.container-alt{width:100%;margin:0}}.logo-container{margin:100px auto 50px}@media screen and (max-width: 500px){.logo-container{margin:40px auto 0}}.logo-container h1{display:flex;justify-content:center;align-items:center}.logo-container h1 svg{fill:#fff;height:42px;margin-right:10px}.logo-container h1 a{display:flex;justify-content:center;align-items:center;color:#fff;text-decoration:none;outline:0;padding:12px 16px;line-height:32px;font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:14px}.compose-standalone .compose-form{width:400px;margin:0 auto;padding:20px 0;margin-top:40px;box-sizing:border-box}@media screen and (max-width: 400px){.compose-standalone .compose-form{width:100%;margin-top:0;padding:20px}}.account-header{width:400px;margin:0 auto;display:flex;font-size:13px;line-height:18px;box-sizing:border-box;padding:20px 0;padding-bottom:0;margin-bottom:-30px;margin-top:40px}@media screen and (max-width: 440px){.account-header{width:100%;margin:0;margin-bottom:10px;padding:20px;padding-bottom:0}}.account-header .avatar{width:40px;height:40px;margin-right:8px}.account-header .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px}.account-header .name{flex:1 1 auto;color:#d9e1e8;width:calc(100% - 88px)}.account-header .name .username{display:block;font-weight:500;text-overflow:ellipsis;overflow:hidden}.account-header .logout-link{display:block;font-size:32px;line-height:40px;margin-left:8px}.grid-3{display:grid;grid-gap:10px;grid-template-columns:3fr 1fr;grid-auto-columns:25%;grid-auto-rows:max-content}.grid-3 .column-0{grid-column:1/3;grid-row:1}.grid-3 .column-1{grid-column:1;grid-row:2}.grid-3 .column-2{grid-column:2;grid-row:2}.grid-3 .column-3{grid-column:1/3;grid-row:3}@media screen and (max-width: 415px){.grid-3{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-3 .column-0{grid-column:1}.grid-3 .column-1{grid-column:1;grid-row:3}.grid-3 .column-2{grid-column:1;grid-row:2}.grid-3 .column-3{grid-column:1;grid-row:4}}.grid-4{display:grid;grid-gap:10px;grid-template-columns:repeat(4, minmax(0, 1fr));grid-auto-columns:25%;grid-auto-rows:max-content}.grid-4 .column-0{grid-column:1/5;grid-row:1}.grid-4 .column-1{grid-column:1/4;grid-row:2}.grid-4 .column-2{grid-column:4;grid-row:2}.grid-4 .column-3{grid-column:2/5;grid-row:3}.grid-4 .column-4{grid-column:1;grid-row:3}.grid-4 .landing-page__call-to-action{min-height:100%}.grid-4 .flash-message{margin-bottom:10px}@media screen and (max-width: 738px){.grid-4{grid-template-columns:minmax(0, 50%) minmax(0, 50%)}.grid-4 .landing-page__call-to-action{padding:20px;display:flex;align-items:center;justify-content:center}.grid-4 .row__information-board{width:100%;justify-content:center;align-items:center}.grid-4 .row__mascot{display:none}}@media screen and (max-width: 415px){.grid-4{grid-gap:0;grid-template-columns:minmax(0, 100%)}.grid-4 .column-0{grid-column:1}.grid-4 .column-1{grid-column:1;grid-row:3}.grid-4 .column-2{grid-column:1;grid-row:2}.grid-4 .column-3{grid-column:1;grid-row:5}.grid-4 .column-4{grid-column:1;grid-row:4}}@media screen and (max-width: 415px){.public-layout{padding-top:48px}}.public-layout .container{max-width:960px}@media screen and (max-width: 415px){.public-layout .container{padding:0}}.public-layout .header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;height:48px;margin:10px 0;display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap;overflow:hidden}@media screen and (max-width: 415px){.public-layout .header{position:fixed;width:100%;top:0;left:0;margin:0;border-radius:0;box-shadow:none;z-index:110}}.public-layout .header>div{flex:1 1 33.3%;min-height:1px}.public-layout .header .nav-left{display:flex;align-items:stretch;justify-content:flex-start;flex-wrap:nowrap}.public-layout .header .nav-center{display:flex;align-items:stretch;justify-content:center;flex-wrap:nowrap}.public-layout .header .nav-right{display:flex;align-items:stretch;justify-content:flex-end;flex-wrap:nowrap}.public-layout .header .brand{display:block;padding:15px}.public-layout .header .brand svg{display:block;height:18px;width:auto;position:relative;bottom:-2px;fill:#fff}@media screen and (max-width: 415px){.public-layout .header .brand svg{height:20px}}.public-layout .header .brand:hover,.public-layout .header .brand:focus,.public-layout .header .brand:active{background:#26374d}.public-layout .header .nav-link{display:flex;align-items:center;padding:0 1rem;font-size:12px;font-weight:500;text-decoration:none;color:#9baec8;white-space:nowrap;text-align:center}.public-layout .header .nav-link:hover,.public-layout .header .nav-link:focus,.public-layout .header .nav-link:active{text-decoration:underline;color:#fff}@media screen and (max-width: 550px){.public-layout .header .nav-link.optional{display:none}}.public-layout .header .nav-button{background:#2d415a;margin:8px;margin-left:0;border-radius:4px}.public-layout .header .nav-button:hover,.public-layout .header .nav-button:focus,.public-layout .header .nav-button:active{text-decoration:none;background:#344b68}.public-layout .grid{display:grid;grid-gap:10px;grid-template-columns:minmax(300px, 3fr) minmax(298px, 1fr);grid-auto-columns:25%;grid-auto-rows:max-content}.public-layout .grid .column-0{grid-row:1;grid-column:1}.public-layout .grid .column-1{grid-row:1;grid-column:2}@media screen and (max-width: 600px){.public-layout .grid{grid-template-columns:100%;grid-gap:0}.public-layout .grid .column-1{display:none}}.public-layout .directory__card{border-radius:4px}@media screen and (max-width: 415px){.public-layout .directory__card{border-radius:0}}@media screen and (max-width: 415px){.public-layout .page-header{border-bottom:0}}.public-layout .public-account-header{overflow:hidden;margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.public-layout .public-account-header.inactive{opacity:.5}.public-layout .public-account-header.inactive .public-account-header__image,.public-layout .public-account-header.inactive .avatar{filter:grayscale(100%)}.public-layout .public-account-header.inactive .logo-button{background-color:#d9e1e8}.public-layout .public-account-header__image{border-radius:4px 4px 0 0;overflow:hidden;height:300px;position:relative;background:#000}.public-layout .public-account-header__image::after{content:\"\";display:block;position:absolute;width:100%;height:100%;box-shadow:inset 0 -1px 1px 1px rgba(0,0,0,.15);top:0;left:0}.public-layout .public-account-header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.public-layout .public-account-header__image{height:200px}}.public-layout .public-account-header--no-bar{margin-bottom:0}.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:4px}@media screen and (max-width: 415px){.public-layout .public-account-header--no-bar .public-account-header__image,.public-layout .public-account-header--no-bar .public-account-header__image img{border-radius:0}}@media screen and (max-width: 415px){.public-layout .public-account-header{margin-bottom:0;box-shadow:none}.public-layout .public-account-header__image::after{display:none}.public-layout .public-account-header__image,.public-layout .public-account-header__image img{border-radius:0}}.public-layout .public-account-header__bar{position:relative;margin-top:-80px;display:flex;justify-content:flex-start}.public-layout .public-account-header__bar::before{content:\"\";display:block;background:#192432;position:absolute;bottom:0;left:0;right:0;height:60px;border-radius:0 0 4px 4px;z-index:-1}.public-layout .public-account-header__bar .avatar{display:block;width:120px;height:120px;padding-left:16px;flex:0 0 auto}.public-layout .public-account-header__bar .avatar img{display:block;width:100%;height:100%;margin:0;border-radius:50%;border:4px solid #192432;background:#040609}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{margin-top:0;background:#192432;border-radius:0 0 4px 4px;padding:5px}.public-layout .public-account-header__bar::before{display:none}.public-layout .public-account-header__bar .avatar{width:48px;height:48px;padding:7px 0;padding-left:10px}.public-layout .public-account-header__bar .avatar img{border:0;border-radius:4px}}@media screen and (max-width: 600px)and (max-width: 360px){.public-layout .public-account-header__bar .avatar{display:none}}@media screen and (max-width: 415px){.public-layout .public-account-header__bar{border-radius:0}}@media screen and (max-width: 600px){.public-layout .public-account-header__bar{flex-wrap:wrap}}.public-layout .public-account-header__tabs{flex:1 1 auto;margin-left:20px}.public-layout .public-account-header__tabs__name{padding-top:20px;padding-bottom:8px}.public-layout .public-account-header__tabs__name h1{font-size:20px;line-height:27px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-shadow:1px 1px 1px #000}.public-layout .public-account-header__tabs__name h1 small{display:block;font-size:14px;color:#fff;font-weight:400;overflow:hidden;text-overflow:ellipsis}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs{margin-left:15px;display:flex;justify-content:space-between;align-items:center}.public-layout .public-account-header__tabs__name{padding-top:0;padding-bottom:0}.public-layout .public-account-header__tabs__name h1{font-size:16px;line-height:24px;text-shadow:none}.public-layout .public-account-header__tabs__name h1 small{color:#9baec8}}.public-layout .public-account-header__tabs__tabs{display:flex;justify-content:flex-start;align-items:stretch;height:58px}.public-layout .public-account-header__tabs__tabs .details-counters{display:flex;flex-direction:row;min-width:300px}@media screen and (max-width: 600px){.public-layout .public-account-header__tabs__tabs .details-counters{display:none}}.public-layout .public-account-header__tabs__tabs .counter{min-width:33.3%;box-sizing:border-box;flex:0 0 auto;color:#9baec8;padding:10px;border-right:1px solid #192432;cursor:default;text-align:center;position:relative}.public-layout .public-account-header__tabs__tabs .counter a{display:block}.public-layout .public-account-header__tabs__tabs .counter:last-child{border-right:0}.public-layout .public-account-header__tabs__tabs .counter::after{display:block;content:\"\";position:absolute;bottom:0;left:0;width:100%;border-bottom:4px solid #9baec8;opacity:.5;transition:all 400ms ease}.public-layout .public-account-header__tabs__tabs .counter.active::after{border-bottom:4px solid #00007f;opacity:1}.public-layout .public-account-header__tabs__tabs .counter.active.inactive::after{border-bottom-color:#d9e1e8}.public-layout .public-account-header__tabs__tabs .counter:hover::after{opacity:1;transition-duration:100ms}.public-layout .public-account-header__tabs__tabs .counter a{text-decoration:none;color:inherit}.public-layout .public-account-header__tabs__tabs .counter .counter-label{font-size:12px;display:block}.public-layout .public-account-header__tabs__tabs .counter .counter-number{font-weight:500;font-size:18px;margin-bottom:5px;color:#fff;font-family:\"mastodon-font-display\",sans-serif}.public-layout .public-account-header__tabs__tabs .spacer{flex:1 1 auto;height:1px}.public-layout .public-account-header__tabs__tabs__buttons{padding:7px 8px}.public-layout .public-account-header__extra{display:none;margin-top:4px}.public-layout .public-account-header__extra .public-account-bio{border-radius:0;box-shadow:none;background:transparent;margin:0 -5px}.public-layout .public-account-header__extra .public-account-bio .account__header__fields{border-top:1px solid #26374d}.public-layout .public-account-header__extra .public-account-bio .roles{display:none}.public-layout .public-account-header__extra__links{margin-top:-15px;font-size:14px;color:#9baec8}.public-layout .public-account-header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:15px;font-weight:500}.public-layout .public-account-header__extra__links a strong{font-weight:700;color:#fff}@media screen and (max-width: 600px){.public-layout .public-account-header__extra{display:block;flex:100%}}.public-layout .account__section-headline{border-radius:4px 4px 0 0}@media screen and (max-width: 415px){.public-layout .account__section-headline{border-radius:0}}.public-layout .detailed-status__meta{margin-top:25px}.public-layout .public-account-bio{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}@media screen and (max-width: 415px){.public-layout .public-account-bio{box-shadow:none;margin-bottom:0;border-radius:0}}.public-layout .public-account-bio .account__header__fields{margin:0;border-top:0}.public-layout .public-account-bio .account__header__fields a{color:#0000a8}.public-layout .public-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.public-layout .public-account-bio .account__header__fields .verified a{color:#79bd9a}.public-layout .public-account-bio .account__header__content{padding:20px;padding-bottom:0;color:#fff}.public-layout .public-account-bio__extra,.public-layout .public-account-bio .roles{padding:20px;font-size:14px;color:#9baec8}.public-layout .public-account-bio .roles{padding-bottom:0}.public-layout .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.public-layout .directory__list{display:block}}.public-layout .directory__list .icon-button{font-size:18px}.public-layout .directory__card{margin-bottom:0}.public-layout .card-grid{display:flex;flex-wrap:wrap;min-width:100%;margin:0 -5px}.public-layout .card-grid>div{box-sizing:border-box;flex:1 0 auto;width:300px;padding:0 5px;margin-bottom:10px;max-width:33.333%}@media screen and (max-width: 900px){.public-layout .card-grid>div{max-width:50%}}@media screen and (max-width: 600px){.public-layout .card-grid>div{max-width:100%}}@media screen and (max-width: 415px){.public-layout .card-grid{margin:0;border-top:1px solid #202e3f}.public-layout .card-grid>div{width:100%;padding:0;margin-bottom:0;border-bottom:1px solid #202e3f}.public-layout .card-grid>div:last-child{border-bottom:0}.public-layout .card-grid>div .card__bar{background:#121a24}.public-layout .card-grid>div .card__bar:hover,.public-layout .card-grid>div .card__bar:active,.public-layout .card-grid>div .card__bar:focus{background:#192432}}.no-list{list-style:none}.no-list li{display:inline-block;margin:0 5px}.recovery-codes{list-style:none;margin:0 auto}.recovery-codes li{font-size:125%;line-height:1.5;letter-spacing:1px}.public-layout .footer{text-align:left;padding-top:20px;padding-bottom:60px;font-size:12px;color:#4c6d98}@media screen and (max-width: 415px){.public-layout .footer{padding-left:20px;padding-right:20px}}.public-layout .footer .grid{display:grid;grid-gap:10px;grid-template-columns:1fr 1fr 2fr 1fr 1fr}.public-layout .footer .grid .column-0{grid-column:1;grid-row:1;min-width:0}.public-layout .footer .grid .column-1{grid-column:2;grid-row:1;min-width:0}.public-layout .footer .grid .column-2{grid-column:3;grid-row:1;min-width:0;text-align:center}.public-layout .footer .grid .column-2 h4 a{color:#4c6d98}.public-layout .footer .grid .column-3{grid-column:4;grid-row:1;min-width:0}.public-layout .footer .grid .column-4{grid-column:5;grid-row:1;min-width:0}@media screen and (max-width: 690px){.public-layout .footer .grid{grid-template-columns:1fr 2fr 1fr}.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1{grid-column:1}.public-layout .footer .grid .column-1{grid-row:2}.public-layout .footer .grid .column-2{grid-column:2}.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{grid-column:3}.public-layout .footer .grid .column-4{grid-row:2}}@media screen and (max-width: 600px){.public-layout .footer .grid .column-1{display:block}}@media screen and (max-width: 415px){.public-layout .footer .grid .column-0,.public-layout .footer .grid .column-1,.public-layout .footer .grid .column-3,.public-layout .footer .grid .column-4{display:none}}.public-layout .footer h4{text-transform:uppercase;font-weight:700;margin-bottom:8px;color:#9baec8}.public-layout .footer h4 a{color:inherit;text-decoration:none}.public-layout .footer ul a{text-decoration:none;color:#4c6d98}.public-layout .footer ul a:hover,.public-layout .footer ul a:active,.public-layout .footer ul a:focus{text-decoration:underline}.public-layout .footer .brand svg{display:block;height:36px;width:auto;margin:0 auto;fill:#4c6d98}.public-layout .footer .brand:hover svg,.public-layout .footer .brand:focus svg,.public-layout .footer .brand:active svg{fill:#5377a5}.compact-header h1{font-size:24px;line-height:28px;color:#9baec8;font-weight:500;margin-bottom:20px;padding:0 10px;word-wrap:break-word}@media screen and (max-width: 740px){.compact-header h1{text-align:center;padding:20px 10px 0}}.compact-header h1 a{color:inherit;text-decoration:none}.compact-header h1 small{font-weight:400;color:#d9e1e8}.compact-header h1 img{display:inline-block;margin-bottom:-5px;margin-right:15px;width:36px;height:36px}.hero-widget{margin-bottom:10px;box-shadow:0 0 15px rgba(0,0,0,.2)}.hero-widget__img{width:100%;position:relative;overflow:hidden;border-radius:4px 4px 0 0;background:#000}.hero-widget__img img{object-fit:cover;display:block;width:100%;height:100%;margin:0;border-radius:4px 4px 0 0}.hero-widget__text{background:#121a24;padding:20px;border-radius:0 0 4px 4px;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400}.hero-widget__text .emojione{width:20px;height:20px;margin:-3px 0 0}.hero-widget__text p{margin-bottom:20px}.hero-widget__text p:last-child{margin-bottom:0}.hero-widget__text em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.hero-widget__text a{color:#d9e1e8;text-decoration:none}.hero-widget__text a:hover{text-decoration:underline}@media screen and (max-width: 415px){.hero-widget{display:none}}.endorsements-widget{margin-bottom:10px;padding-bottom:10px}.endorsements-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.endorsements-widget .account{padding:10px 0}.endorsements-widget .account:last-child{border-bottom:0}.endorsements-widget .account .account__display-name{display:flex;align-items:center}.endorsements-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.endorsements-widget .trends__item{padding:10px}.trends-widget h4{color:#9baec8}.box-widget{padding:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2)}.placeholder-widget{padding:16px;border-radius:4px;border:2px dashed #404040;text-align:center;color:#9baec8;margin-bottom:10px}.contact-widget{min-height:100%;font-size:15px;color:#9baec8;line-height:20px;word-wrap:break-word;font-weight:400;padding:0}.contact-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.contact-widget .account{border-bottom:0;padding:10px 0;padding-top:5px}.contact-widget>a{display:inline-block;padding:10px;padding-top:0;color:#9baec8;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.contact-widget>a:hover,.contact-widget>a:focus,.contact-widget>a:active{text-decoration:underline}.moved-account-widget{padding:15px;padding-bottom:20px;border-radius:4px;background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#d9e1e8;font-weight:400;margin-bottom:10px}.moved-account-widget strong,.moved-account-widget a{font-weight:500}.moved-account-widget strong:lang(ja),.moved-account-widget a:lang(ja){font-weight:700}.moved-account-widget strong:lang(ko),.moved-account-widget a:lang(ko){font-weight:700}.moved-account-widget strong:lang(zh-CN),.moved-account-widget a:lang(zh-CN){font-weight:700}.moved-account-widget strong:lang(zh-HK),.moved-account-widget a:lang(zh-HK){font-weight:700}.moved-account-widget strong:lang(zh-TW),.moved-account-widget a:lang(zh-TW){font-weight:700}.moved-account-widget a{color:inherit;text-decoration:underline}.moved-account-widget a.mention{text-decoration:none}.moved-account-widget a.mention span{text-decoration:none}.moved-account-widget a.mention:focus,.moved-account-widget a.mention:hover,.moved-account-widget a.mention:active{text-decoration:none}.moved-account-widget a.mention:focus span,.moved-account-widget a.mention:hover span,.moved-account-widget a.mention:active span{text-decoration:underline}.moved-account-widget__message{margin-bottom:15px}.moved-account-widget__message .fa{margin-right:5px;color:#9baec8}.moved-account-widget__card .detailed-status__display-avatar{position:relative;cursor:pointer}.moved-account-widget__card .detailed-status__display-name{margin-bottom:0;text-decoration:none}.moved-account-widget__card .detailed-status__display-name span{font-weight:400}.memoriam-widget{padding:20px;border-radius:4px;background:#000;box-shadow:0 0 15px rgba(0,0,0,.2);font-size:14px;color:#9baec8;margin-bottom:10px}.page-header{background:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:60px 15px;text-align:center;margin:10px 0}.page-header h1{color:#fff;font-size:36px;line-height:1.1;font-weight:700;margin-bottom:10px}.page-header p{font-size:15px;color:#9baec8}@media screen and (max-width: 415px){.page-header{margin-top:0;background:#192432}.page-header h1{font-size:24px}}.directory{background:#121a24;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag{box-sizing:border-box;margin-bottom:10px}.directory__tag>a,.directory__tag>div{display:flex;align-items:center;justify-content:space-between;background:#121a24;border-radius:4px;padding:15px;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}.directory__tag>a:hover,.directory__tag>a:active,.directory__tag>a:focus{background:#202e3f}.directory__tag.active>a{background:#00007f;cursor:default}.directory__tag.disabled>div{opacity:.5;cursor:default}.directory__tag h4{flex:1 1 auto;font-size:18px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__tag h4 .fa{color:#9baec8}.directory__tag h4 small{display:block;font-weight:400;font-size:15px;margin-top:8px;color:#9baec8}.directory__tag.active h4,.directory__tag.active h4 .fa,.directory__tag.active h4 small,.directory__tag.active h4 .trends__item__current{color:#fff}.directory__tag .avatar-stack{flex:0 0 auto;width:120px}.directory__tag.active .avatar-stack .account__avatar{border-color:#00007f}.directory__tag .trends__item__current{padding-right:0}.avatar-stack{display:flex;justify-content:flex-end}.avatar-stack .account__avatar{flex:0 0 auto;width:36px;height:36px;border-radius:50%;position:relative;margin-left:-10px;background:#040609;border:2px solid #121a24}.avatar-stack .account__avatar:nth-child(1){z-index:1}.avatar-stack .account__avatar:nth-child(2){z-index:2}.avatar-stack .account__avatar:nth-child(3){z-index:3}.accounts-table{width:100%}.accounts-table .account{padding:0;border:0}.accounts-table strong{font-weight:700}.accounts-table thead th{text-align:center;text-transform:uppercase;color:#9baec8;font-weight:700;padding:10px}.accounts-table thead th:first-child{text-align:left}.accounts-table tbody td{padding:15px 0;vertical-align:middle;border-bottom:1px solid #202e3f}.accounts-table tbody tr:last-child td{border-bottom:0}.accounts-table__count{width:120px;text-align:center;font-size:15px;font-weight:500;color:#fff}.accounts-table__count small{display:block;color:#9baec8;font-weight:400;font-size:14px}.accounts-table__comment{width:50%;vertical-align:initial !important}@media screen and (max-width: 415px){.accounts-table tbody td.optional{display:none}}@media screen and (max-width: 415px){.moved-account-widget,.memoriam-widget,.box-widget,.contact-widget,.landing-page__information.contact-widget,.directory,.page-header{margin-bottom:0;box-shadow:none;border-radius:0}}.statuses-grid{min-height:600px}@media screen and (max-width: 640px){.statuses-grid{width:100% !important}}.statuses-grid__item{width:313.3333333333px}@media screen and (max-width: 1255px){.statuses-grid__item{width:306.6666666667px}}@media screen and (max-width: 640px){.statuses-grid__item{width:100%}}@media screen and (max-width: 415px){.statuses-grid__item{width:100vw}}.statuses-grid .detailed-status{border-radius:4px}@media screen and (max-width: 415px){.statuses-grid .detailed-status{border-top:1px solid #2d415a}}.statuses-grid .detailed-status.compact .detailed-status__meta{margin-top:15px}.statuses-grid .detailed-status.compact .status__content{font-size:15px;line-height:20px}.statuses-grid .detailed-status.compact .status__content .emojione{width:20px;height:20px;margin:-3px 0 0}.statuses-grid .detailed-status.compact .status__content .status__content__spoiler-link{line-height:20px;margin:0}.statuses-grid .detailed-status.compact .media-gallery,.statuses-grid .detailed-status.compact .status-card,.statuses-grid .detailed-status.compact .video-player{margin-top:15px}.notice-widget{margin-bottom:10px;color:#9baec8}.notice-widget p{margin-bottom:10px}.notice-widget p:last-child{margin-bottom:0}.notice-widget a{font-size:14px;line-height:20px}.notice-widget a,.placeholder-widget a{text-decoration:none;font-weight:500;color:#00007f}.notice-widget a:hover,.notice-widget a:focus,.notice-widget a:active,.placeholder-widget a:hover,.placeholder-widget a:focus,.placeholder-widget a:active{text-decoration:underline}.table-of-contents{background:#0b1016;min-height:100%;font-size:14px;border-radius:4px}.table-of-contents li a{display:block;font-weight:500;padding:15px;overflow:hidden;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:#fff;border-bottom:1px solid #192432}.table-of-contents li a:hover,.table-of-contents li a:focus,.table-of-contents li a:active{text-decoration:underline}.table-of-contents li:last-child a{border-bottom:0}.table-of-contents li ul{padding-left:20px;border-bottom:1px solid #192432}code{font-family:\"mastodon-font-monospace\",monospace;font-weight:400}.form-container{max-width:400px;padding:20px;margin:0 auto}.simple_form .input{margin-bottom:15px;overflow:hidden}.simple_form .input.hidden{margin:0}.simple_form .input.radio_buttons .radio{margin-bottom:15px}.simple_form .input.radio_buttons .radio:last-child{margin-bottom:0}.simple_form .input.radio_buttons .radio>label{position:relative;padding-left:28px}.simple_form .input.radio_buttons .radio>label input{position:absolute;top:-2px;left:0}.simple_form .input.boolean{position:relative;margin-bottom:0}.simple_form .input.boolean .label_input>label{font-family:inherit;font-size:14px;padding-top:5px;color:#fff;display:block;width:auto}.simple_form .input.boolean .label_input,.simple_form .input.boolean .hint{padding-left:28px}.simple_form .input.boolean .label_input__wrapper{position:static}.simple_form .input.boolean label.checkbox{position:absolute;top:2px;left:0}.simple_form .input.boolean label a{color:#00007f;text-decoration:underline}.simple_form .input.boolean label a:hover,.simple_form .input.boolean label a:active,.simple_form .input.boolean label a:focus{text-decoration:none}.simple_form .input.boolean .recommended{position:absolute;margin:0 4px;margin-top:-2px}.simple_form .row{display:flex;margin:0 -5px}.simple_form .row .input{box-sizing:border-box;flex:1 1 auto;width:50%;padding:0 5px}.simple_form .hint{color:#9baec8}.simple_form .hint a{color:#00007f}.simple_form .hint code{border-radius:3px;padding:.2em .4em;background:#000}.simple_form .hint li{list-style:disc;margin-left:18px}.simple_form ul.hint{margin-bottom:15px}.simple_form span.hint{display:block;font-size:12px;margin-top:4px}.simple_form p.hint{margin-bottom:15px;color:#9baec8}.simple_form p.hint.subtle-hint{text-align:center;font-size:12px;line-height:18px;margin-top:15px;margin-bottom:0}.simple_form .card{margin-bottom:15px}.simple_form strong{font-weight:500}.simple_form strong:lang(ja){font-weight:700}.simple_form strong:lang(ko){font-weight:700}.simple_form strong:lang(zh-CN){font-weight:700}.simple_form strong:lang(zh-HK){font-weight:700}.simple_form strong:lang(zh-TW){font-weight:700}.simple_form .input.with_floating_label .label_input{display:flex}.simple_form .input.with_floating_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;font-weight:500;min-width:150px;flex:0 0 auto}.simple_form .input.with_floating_label .label_input input,.simple_form .input.with_floating_label .label_input select{flex:1 1 auto}.simple_form .input.with_floating_label.select .hint{margin-top:6px;margin-left:150px}.simple_form .input.with_label .label_input>label{font-family:inherit;font-size:14px;color:#fff;display:block;margin-bottom:8px;word-wrap:break-word;font-weight:500}.simple_form .input.with_label .hint{margin-top:6px}.simple_form .input.with_label ul{flex:390px}.simple_form .input.with_block_label{max-width:none}.simple_form .input.with_block_label>label{font-family:inherit;font-size:16px;color:#fff;display:block;font-weight:500;padding-top:5px}.simple_form .input.with_block_label .hint{margin-bottom:15px}.simple_form .input.with_block_label ul{columns:2}.simple_form .input.datetime .label_input select{display:inline-block;width:auto;flex:0}.simple_form .required abbr{text-decoration:none;color:#e87487}.simple_form .fields-group{margin-bottom:25px}.simple_form .fields-group .input:last-child{margin-bottom:0}.simple_form .fields-row{display:flex;margin:0 -10px;padding-top:5px;margin-bottom:25px}.simple_form .fields-row .input{max-width:none}.simple_form .fields-row__column{box-sizing:border-box;padding:0 10px;flex:1 1 auto;min-height:1px}.simple_form .fields-row__column-6{max-width:50%}.simple_form .fields-row__column .actions{margin-top:27px}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group{margin-bottom:0}@media screen and (max-width: 600px){.simple_form .fields-row{display:block;margin-bottom:0}.simple_form .fields-row__column{max-width:none}.simple_form .fields-row .fields-group:last-child,.simple_form .fields-row .fields-row__column.fields-group,.simple_form .fields-row .fields-row__column{margin-bottom:25px}}.simple_form .input.radio_buttons .radio label{margin-bottom:5px;font-family:inherit;font-size:14px;color:#fff;display:block;width:auto}.simple_form .check_boxes .checkbox label{font-family:inherit;font-size:14px;color:#fff;display:inline-block;width:auto;position:relative;padding-top:5px;padding-left:25px;flex:1 1 auto}.simple_form .check_boxes .checkbox input[type=checkbox]{position:absolute;left:0;top:5px;margin:0}.simple_form .input.static .label_input__wrapper{font-size:16px;padding:10px;border:1px solid #404040;border-radius:4px}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102;border:1px solid #000;border-radius:4px;padding:10px}.simple_form input[type=text]::placeholder,.simple_form input[type=number]::placeholder,.simple_form input[type=email]::placeholder,.simple_form input[type=password]::placeholder,.simple_form textarea::placeholder{color:#a8b9cf}.simple_form input[type=text]:invalid,.simple_form input[type=number]:invalid,.simple_form input[type=email]:invalid,.simple_form input[type=password]:invalid,.simple_form textarea:invalid{box-shadow:none}.simple_form input[type=text]:focus:invalid:not(:placeholder-shown),.simple_form input[type=number]:focus:invalid:not(:placeholder-shown),.simple_form input[type=email]:focus:invalid:not(:placeholder-shown),.simple_form input[type=password]:focus:invalid:not(:placeholder-shown),.simple_form textarea:focus:invalid:not(:placeholder-shown){border-color:#e87487}.simple_form input[type=text]:required:valid,.simple_form input[type=number]:required:valid,.simple_form input[type=email]:required:valid,.simple_form input[type=password]:required:valid,.simple_form textarea:required:valid{border-color:#79bd9a}.simple_form input[type=text]:hover,.simple_form input[type=number]:hover,.simple_form input[type=email]:hover,.simple_form input[type=password]:hover,.simple_form textarea:hover{border-color:#000}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{border-color:#00007f;background:#040609}.simple_form .input.field_with_errors label{color:#e87487}.simple_form .input.field_with_errors input[type=text],.simple_form .input.field_with_errors input[type=number],.simple_form .input.field_with_errors input[type=email],.simple_form .input.field_with_errors input[type=password],.simple_form .input.field_with_errors textarea,.simple_form .input.field_with_errors select{border-color:#e87487}.simple_form .input.field_with_errors .error{display:block;font-weight:500;color:#e87487;margin-top:4px}.simple_form .input.disabled{opacity:.5}.simple_form .actions{margin-top:30px;display:flex}.simple_form .actions.actions--top{margin-top:0;margin-bottom:30px}.simple_form button,.simple_form .button,.simple_form .block-button{display:block;width:100%;border:0;border-radius:4px;background:#00007f;color:#fff;font-size:18px;line-height:inherit;height:auto;padding:10px;text-transform:uppercase;text-decoration:none;text-align:center;box-sizing:border-box;cursor:pointer;font-weight:500;outline:0;margin-bottom:10px;margin-right:10px}.simple_form button:last-child,.simple_form .button:last-child,.simple_form .block-button:last-child{margin-right:0}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background-color:#009}.simple_form button:active,.simple_form button:focus,.simple_form .button:active,.simple_form .button:focus,.simple_form .block-button:active,.simple_form .block-button:focus{background-color:#006}.simple_form button:disabled:hover,.simple_form .button:disabled:hover,.simple_form .block-button:disabled:hover{background-color:#9baec8}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#df405a}.simple_form button.negative:hover,.simple_form .button.negative:hover,.simple_form .block-button.negative:hover{background-color:#e3566d}.simple_form button.negative:active,.simple_form button.negative:focus,.simple_form .button.negative:active,.simple_form .button.negative:focus,.simple_form .block-button.negative:active,.simple_form .block-button.negative:focus{background-color:#db2a47}.simple_form select{appearance:none;box-sizing:border-box;font-size:16px;color:#fff;display:block;width:100%;outline:0;font-family:inherit;resize:vertical;background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #000;border-radius:4px;padding-left:10px;padding-right:30px;height:41px}.simple_form h4{margin-bottom:15px !important}.simple_form .label_input__wrapper{position:relative}.simple_form .label_input__append{position:absolute;right:3px;top:1px;padding:10px;padding-bottom:9px;font-size:16px;color:#404040;font-family:inherit;pointer-events:none;cursor:default;max-width:140px;white-space:nowrap;overflow:hidden}.simple_form .label_input__append::after{content:\"\";display:block;position:absolute;top:0;right:0;bottom:1px;width:5px;background-image:linear-gradient(to right, rgba(1, 1, 2, 0), #010102)}.simple_form__overlay-area{position:relative}.simple_form__overlay-area__blurred form{filter:blur(2px)}.simple_form__overlay-area__overlay{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;justify-content:center;align-items:center;background:rgba(18,26,36,.65);border-radius:4px;margin-left:-4px;margin-top:-4px;padding:4px}.simple_form__overlay-area__overlay__content{text-align:center}.simple_form__overlay-area__overlay__content.rich-formatting,.simple_form__overlay-area__overlay__content.rich-formatting p{color:#fff}.block-icon{display:block;margin:0 auto;margin-bottom:10px;font-size:24px}.flash-message{background:#202e3f;color:#9baec8;border-radius:4px;padding:15px 10px;margin-bottom:30px;text-align:center}.flash-message.notice{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25);color:#79bd9a}.flash-message.alert{border:1px solid rgba(223,64,90,.5);background:rgba(223,64,90,.25);color:#df405a}.flash-message a{display:inline-block;color:#9baec8;text-decoration:none}.flash-message a:hover{color:#fff;text-decoration:underline}.flash-message p{margin-bottom:15px}.flash-message .oauth-code{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0}.flash-message .oauth-code::-moz-focus-inner{border:0}.flash-message .oauth-code::-moz-focus-inner,.flash-message .oauth-code:focus,.flash-message .oauth-code:active{outline:0 !important}.flash-message .oauth-code:focus{background:#192432}.flash-message strong{font-weight:500}.flash-message strong:lang(ja){font-weight:700}.flash-message strong:lang(ko){font-weight:700}.flash-message strong:lang(zh-CN){font-weight:700}.flash-message strong:lang(zh-HK){font-weight:700}.flash-message strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.flash-message{margin-top:40px}}.form-footer{margin-top:30px;text-align:center}.form-footer a{color:#9baec8;text-decoration:none}.form-footer a:hover{text-decoration:underline}.quick-nav{list-style:none;margin-bottom:25px;font-size:14px}.quick-nav li{display:inline-block;margin-right:10px}.quick-nav a{color:#00007f;text-transform:uppercase;text-decoration:none;font-weight:700}.quick-nav a:hover,.quick-nav a:focus,.quick-nav a:active{color:#0000a8}.oauth-prompt,.follow-prompt{margin-bottom:30px;color:#9baec8}.oauth-prompt h2,.follow-prompt h2{font-size:16px;margin-bottom:30px;text-align:center}.oauth-prompt strong,.follow-prompt strong{color:#d9e1e8;font-weight:500}.oauth-prompt strong:lang(ja),.follow-prompt strong:lang(ja){font-weight:700}.oauth-prompt strong:lang(ko),.follow-prompt strong:lang(ko){font-weight:700}.oauth-prompt strong:lang(zh-CN),.follow-prompt strong:lang(zh-CN){font-weight:700}.oauth-prompt strong:lang(zh-HK),.follow-prompt strong:lang(zh-HK){font-weight:700}.oauth-prompt strong:lang(zh-TW),.follow-prompt strong:lang(zh-TW){font-weight:700}@media screen and (max-width: 740px)and (min-width: 441px){.oauth-prompt,.follow-prompt{margin-top:40px}}.qr-wrapper{display:flex;flex-wrap:wrap;align-items:flex-start}.qr-code{flex:0 0 auto;background:#fff;padding:4px;margin:0 10px 20px 0;box-shadow:0 0 15px rgba(0,0,0,.2);display:inline-block}.qr-code svg{display:block;margin:0}.qr-alternative{margin-bottom:20px;color:#d9e1e8;flex:150px}.qr-alternative samp{display:block;font-size:14px}.table-form p{margin-bottom:15px}.table-form p strong{font-weight:500}.table-form p strong:lang(ja){font-weight:700}.table-form p strong:lang(ko){font-weight:700}.table-form p strong:lang(zh-CN){font-weight:700}.table-form p strong:lang(zh-HK){font-weight:700}.table-form p strong:lang(zh-TW){font-weight:700}.simple_form .warning,.table-form .warning{box-sizing:border-box;background:rgba(223,64,90,.5);color:#fff;text-shadow:1px 1px 0 rgba(0,0,0,.3);box-shadow:0 2px 6px rgba(0,0,0,.4);border-radius:4px;padding:10px;margin-bottom:15px}.simple_form .warning a,.table-form .warning a{color:#fff;text-decoration:underline}.simple_form .warning a:hover,.simple_form .warning a:focus,.simple_form .warning a:active,.table-form .warning a:hover,.table-form .warning a:focus,.table-form .warning a:active{text-decoration:none}.simple_form .warning strong,.table-form .warning strong{font-weight:600;display:block;margin-bottom:5px}.simple_form .warning strong:lang(ja),.table-form .warning strong:lang(ja){font-weight:700}.simple_form .warning strong:lang(ko),.table-form .warning strong:lang(ko){font-weight:700}.simple_form .warning strong:lang(zh-CN),.table-form .warning strong:lang(zh-CN){font-weight:700}.simple_form .warning strong:lang(zh-HK),.table-form .warning strong:lang(zh-HK){font-weight:700}.simple_form .warning strong:lang(zh-TW),.table-form .warning strong:lang(zh-TW){font-weight:700}.simple_form .warning strong .fa,.table-form .warning strong .fa{font-weight:400}.action-pagination{display:flex;flex-wrap:wrap;align-items:center}.action-pagination .actions,.action-pagination .pagination{flex:1 1 auto}.action-pagination .actions{padding:30px 0;padding-right:20px;flex:0 0 auto}.post-follow-actions{text-align:center;color:#9baec8}.post-follow-actions div{margin-bottom:4px}.alternative-login{margin-top:20px;margin-bottom:20px}.alternative-login h4{font-size:16px;color:#fff;text-align:center;margin-bottom:20px;border:0;padding:0}.alternative-login .button{display:block}.scope-danger{color:#ff5050}.form_admin_settings_site_short_description textarea,.form_admin_settings_site_description textarea,.form_admin_settings_site_extended_description textarea,.form_admin_settings_site_terms textarea,.form_admin_settings_custom_css textarea,.form_admin_settings_closed_registrations_message textarea{font-family:\"mastodon-font-monospace\",monospace}.input-copy{background:#010102;border:1px solid #000;border-radius:4px;display:flex;align-items:center;padding-right:4px;position:relative;top:1px;transition:border-color 300ms linear}.input-copy__wrapper{flex:1 1 auto}.input-copy input[type=text]{background:transparent;border:0;padding:10px;font-size:14px;font-family:\"mastodon-font-monospace\",monospace}.input-copy button{flex:0 0 auto;margin:4px;text-transform:none;font-weight:400;font-size:14px;padding:7px 18px;padding-bottom:6px;width:auto;transition:background 300ms linear}.input-copy.copied{border-color:#79bd9a;transition:none}.input-copy.copied button{background:#79bd9a;transition:none}.connection-prompt{margin-bottom:25px}.connection-prompt .fa-link{background-color:#0b1016;border-radius:100%;font-size:24px;padding:10px}.connection-prompt__column{align-items:center;display:flex;flex:1;flex-direction:column;flex-shrink:1;max-width:50%}.connection-prompt__column-sep{align-self:center;flex-grow:0;overflow:visible;position:relative;z-index:1}.connection-prompt__column p{word-break:break-word}.connection-prompt .account__avatar{margin-bottom:20px}.connection-prompt__connection{background-color:#202e3f;box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;padding:25px 10px;position:relative;text-align:center}.connection-prompt__connection::after{background-color:#0b1016;content:\"\";display:block;height:100%;left:50%;position:absolute;top:0;width:1px}.connection-prompt__row{align-items:flex-start;display:flex;flex-direction:row}.card>a{display:block;text-decoration:none;color:inherit;box-shadow:0 0 15px rgba(0,0,0,.2)}@media screen and (max-width: 415px){.card>a{box-shadow:none}}.card>a:hover .card__bar,.card>a:active .card__bar,.card>a:focus .card__bar{background:#202e3f}.card__img{height:130px;position:relative;background:#000;border-radius:4px 4px 0 0}.card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover;border-radius:4px 4px 0 0}@media screen and (max-width: 600px){.card__img{height:200px}}@media screen and (max-width: 415px){.card__img{display:none}}.card__bar{position:relative;padding:15px;display:flex;justify-content:flex-start;align-items:center;background:#192432;border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.card__bar{border-radius:0}}.card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.card__bar .display-name{margin-left:15px;text-align:left}.card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.pagination{padding:30px 0;text-align:center;overflow:hidden}.pagination a,.pagination .current,.pagination .newer,.pagination .older,.pagination .page,.pagination .gap{font-size:14px;color:#fff;font-weight:500;display:inline-block;padding:6px 10px;text-decoration:none}.pagination .current{background:#fff;border-radius:100px;color:#121a24;cursor:default;margin:0 10px}.pagination .gap{cursor:default}.pagination .older,.pagination .newer{text-transform:uppercase;color:#d9e1e8}.pagination .older{float:left;padding-left:0}.pagination .older .fa{display:inline-block;margin-right:5px}.pagination .newer{float:right;padding-right:0}.pagination .newer .fa{display:inline-block;margin-left:5px}.pagination .disabled{cursor:default;color:#233346}@media screen and (max-width: 700px){.pagination{padding:30px 20px}.pagination .page{display:none}.pagination .newer,.pagination .older{display:inline-block}}.nothing-here{background:#121a24;box-shadow:0 0 15px rgba(0,0,0,.2);color:#9baec8;font-size:14px;font-weight:500;text-align:center;display:flex;justify-content:center;align-items:center;cursor:default;border-radius:4px;padding:20px;min-height:30vh}.nothing-here--under-tabs{border-radius:0 0 4px 4px}.nothing-here--flexible{box-sizing:border-box;min-height:100%}.account-role,.simple_form .recommended{display:inline-block;padding:4px 6px;cursor:default;border-radius:3px;font-size:12px;line-height:12px;font-weight:500;color:#d9e1e8;background-color:rgba(217,225,232,.1);border:1px solid rgba(217,225,232,.5)}.account-role.moderator,.simple_form .recommended.moderator{color:#79bd9a;background-color:rgba(121,189,154,.1);border-color:rgba(121,189,154,.5)}.account-role.admin,.simple_form .recommended.admin{color:#e87487;background-color:rgba(232,116,135,.1);border-color:rgba(232,116,135,.5)}.account__header__fields{max-width:100vw;padding:0;margin:15px -15px -15px;border:0 none;border-top:1px solid #26374d;border-bottom:1px solid #26374d;font-size:14px;line-height:20px}.account__header__fields dl{display:flex;border-bottom:1px solid #26374d}.account__header__fields dt,.account__header__fields dd{box-sizing:border-box;padding:14px;text-align:center;max-height:48px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__fields dt{font-weight:500;width:120px;flex:0 0 auto;color:#d9e1e8;background:rgba(4,6,9,.5)}.account__header__fields dd{flex:1 1 auto;color:#9baec8}.account__header__fields a{color:#00007f;text-decoration:none}.account__header__fields a:hover,.account__header__fields a:focus,.account__header__fields a:active{text-decoration:underline}.account__header__fields .verified{border:1px solid rgba(121,189,154,.5);background:rgba(121,189,154,.25)}.account__header__fields .verified a{color:#79bd9a;font-weight:500}.account__header__fields .verified__mark{color:#79bd9a}.account__header__fields dl:last-child{border-bottom:0}.directory__tag .trends__item__current{width:auto}.pending-account__header{color:#9baec8}.pending-account__header a{color:#d9e1e8;text-decoration:none}.pending-account__header a:hover,.pending-account__header a:active,.pending-account__header a:focus{text-decoration:underline}.pending-account__header strong{color:#fff;font-weight:700}.pending-account__body{margin-top:10px}.activity-stream{box-shadow:0 0 15px rgba(0,0,0,.2);border-radius:4px;overflow:hidden;margin-bottom:10px}.activity-stream--under-tabs{border-radius:0 0 4px 4px}@media screen and (max-width: 415px){.activity-stream{margin-bottom:0;border-radius:0;box-shadow:none}}.activity-stream--headless{border-radius:0;margin:0;box-shadow:none}.activity-stream--headless .detailed-status,.activity-stream--headless .status{border-radius:0 !important}.activity-stream div[data-component]{width:100%}.activity-stream .entry{background:#121a24}.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{animation:none}.activity-stream .entry:last-child .detailed-status,.activity-stream .entry:last-child .status,.activity-stream .entry:last-child .load-more{border-bottom:0;border-radius:0 0 4px 4px}.activity-stream .entry:first-child .detailed-status,.activity-stream .entry:first-child .status,.activity-stream .entry:first-child .load-more{border-radius:4px 4px 0 0}.activity-stream .entry:first-child:last-child .detailed-status,.activity-stream .entry:first-child:last-child .status,.activity-stream .entry:first-child:last-child .load-more{border-radius:4px}@media screen and (max-width: 740px){.activity-stream .entry .detailed-status,.activity-stream .entry .status,.activity-stream .entry .load-more{border-radius:0 !important}}.activity-stream--highlighted .entry{background:#202e3f}.button.logo-button{flex:0 auto;font-size:14px;background:#00007f;color:#fff;text-transform:none;line-height:36px;height:auto;padding:3px 15px;border:0}.button.logo-button svg{width:20px;height:auto;vertical-align:middle;margin-right:5px;fill:#fff}.button.logo-button:active,.button.logo-button:focus,.button.logo-button:hover{background:#0000b2}.button.logo-button:disabled:active,.button.logo-button:disabled:focus,.button.logo-button:disabled:hover,.button.logo-button.disabled:active,.button.logo-button.disabled:focus,.button.logo-button.disabled:hover{background:#9baec8}.button.logo-button.button--destructive:active,.button.logo-button.button--destructive:focus,.button.logo-button.button--destructive:hover{background:#df405a}@media screen and (max-width: 415px){.button.logo-button svg{display:none}}.embed .detailed-status,.public-layout .detailed-status{padding:15px}.embed .status,.public-layout .status{padding:15px 15px 15px 78px;min-height:50px}.embed .status__avatar,.public-layout .status__avatar{left:15px;top:17px}.embed .status__content,.public-layout .status__content{padding-top:5px}.embed .status__prepend,.public-layout .status__prepend{margin-left:78px;padding-top:15px}.embed .status__prepend-icon-wrapper,.public-layout .status__prepend-icon-wrapper{left:-32px}.embed .status .media-gallery,.embed .status__action-bar,.embed .status .video-player,.public-layout .status .media-gallery,.public-layout .status__action-bar,.public-layout .status .video-player{margin-top:10px}button.icon-button i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button i.fa-retweet:hover{background-image:url(\"data:image/svg+xml;utf8,\")}button.icon-button.disabled i.fa-retweet{background-image:url(\"data:image/svg+xml;utf8,\")}.app-body{-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar}.animated-number{display:inline-flex;flex-direction:column;align-items:stretch;overflow:hidden;position:relative}.link-button{display:block;font-size:15px;line-height:20px;color:#00007f;border:0;background:transparent;padding:0;cursor:pointer}.link-button:hover,.link-button:active{text-decoration:underline}.link-button:disabled{color:#9baec8;cursor:default}.button{background-color:#00007f;border:10px none;border-radius:4px;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:inherit;font-size:14px;font-weight:500;height:36px;letter-spacing:0;line-height:36px;overflow:hidden;padding:0 16px;position:relative;text-align:center;text-transform:uppercase;text-decoration:none;text-overflow:ellipsis;transition:all 100ms ease-in;white-space:nowrap;width:auto}.button:active,.button:focus,.button:hover{background-color:#0000b2;transition:all 200ms ease-out}.button--destructive{transition:none}.button--destructive:active,.button--destructive:focus,.button--destructive:hover{background-color:#df405a;transition:none}.button:disabled,.button.disabled{background-color:#9baec8;cursor:default}.button::-moz-focus-inner{border:0}.button::-moz-focus-inner,.button:focus,.button:active{outline:0 !important}.button.button-primary,.button.button-alternative,.button.button-secondary,.button.button-alternative-2{font-size:16px;line-height:36px;height:auto;text-transform:none;padding:4px 16px}.button.button-alternative{color:#121a24;background:#9baec8}.button.button-alternative:active,.button.button-alternative:focus,.button.button-alternative:hover{background-color:#a8b9cf}.button.button-alternative-2{background:#404040}.button.button-alternative-2:active,.button.button-alternative-2:focus,.button.button-alternative-2:hover{background-color:#4a4a4a}.button.button-secondary{color:#9baec8;background:transparent;padding:3px 15px;border:1px solid #9baec8}.button.button-secondary:active,.button.button-secondary:focus,.button.button-secondary:hover{border-color:#a8b9cf;color:#a8b9cf}.button.button-secondary:disabled{opacity:.5}.button.button--block{display:block;width:100%}.column__wrapper{display:flex;flex:1 1 auto;position:relative}.icon-button{display:inline-block;padding:0;color:#404040;border:0;border-radius:4px;background:transparent;cursor:pointer;transition:all 100ms ease-in;transition-property:background-color,color}.icon-button:hover,.icon-button:active,.icon-button:focus{color:#525252;background-color:rgba(64,64,64,.15);transition:all 200ms ease-out;transition-property:background-color,color}.icon-button:focus{background-color:rgba(64,64,64,.3)}.icon-button.disabled{color:#1f1f1f;background-color:transparent;cursor:default}.icon-button.active{color:#00007f}.icon-button::-moz-focus-inner{border:0}.icon-button::-moz-focus-inner,.icon-button:focus,.icon-button:active{outline:0 !important}.icon-button.inverted{color:#404040}.icon-button.inverted:hover,.icon-button.inverted:active,.icon-button.inverted:focus{color:#2e2e2e;background-color:rgba(64,64,64,.15)}.icon-button.inverted:focus{background-color:rgba(64,64,64,.3)}.icon-button.inverted.disabled{color:#525252;background-color:transparent}.icon-button.inverted.active{color:#00007f}.icon-button.inverted.active.disabled{color:#0000c1}.icon-button.overlayed{box-sizing:content-box;background:rgba(0,0,0,.6);color:rgba(255,255,255,.7);border-radius:4px;padding:2px}.icon-button.overlayed:hover{background:rgba(0,0,0,.9)}.text-icon-button{color:#404040;border:0;border-radius:4px;background:transparent;cursor:pointer;font-weight:600;font-size:11px;padding:0 3px;line-height:27px;outline:0;transition:all 100ms ease-in;transition-property:background-color,color}.text-icon-button:hover,.text-icon-button:active,.text-icon-button:focus{color:#2e2e2e;background-color:rgba(64,64,64,.15);transition:all 200ms ease-out;transition-property:background-color,color}.text-icon-button:focus{background-color:rgba(64,64,64,.3)}.text-icon-button.disabled{color:#737373;background-color:transparent;cursor:default}.text-icon-button.active{color:#00007f}.text-icon-button::-moz-focus-inner{border:0}.text-icon-button::-moz-focus-inner,.text-icon-button:focus,.text-icon-button:active{outline:0 !important}.dropdown-menu{position:absolute}.invisible{font-size:0;line-height:0;display:inline-block;width:0;height:0;position:absolute}.invisible img,.invisible svg{margin:0 !important;border:0 !important;padding:0 !important;width:0 !important;height:0 !important}.ellipsis::after{content:\"…\"}.compose-form{padding:10px}.compose-form__sensitive-button{padding:10px;padding-top:0;font-size:14px;font-weight:500}.compose-form__sensitive-button.active{color:#00007f}.compose-form__sensitive-button input[type=checkbox]{display:none}.compose-form__sensitive-button .checkbox{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:4px;vertical-align:middle}.compose-form__sensitive-button .checkbox.active{border-color:#00007f;background:#00007f}.compose-form .compose-form__warning{color:#121a24;margin-bottom:10px;background:#9baec8;box-shadow:0 2px 6px rgba(0,0,0,.3);padding:8px 10px;border-radius:4px;font-size:13px;font-weight:400}.compose-form .compose-form__warning strong{color:#121a24;font-weight:500}.compose-form .compose-form__warning strong:lang(ja){font-weight:700}.compose-form .compose-form__warning strong:lang(ko){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-CN){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-HK){font-weight:700}.compose-form .compose-form__warning strong:lang(zh-TW){font-weight:700}.compose-form .compose-form__warning a{color:#404040;font-weight:500;text-decoration:underline}.compose-form .compose-form__warning a:hover,.compose-form .compose-form__warning a:active,.compose-form .compose-form__warning a:focus{text-decoration:none}.compose-form .emoji-picker-dropdown{position:absolute;top:0;right:0}.compose-form .compose-form__autosuggest-wrapper{position:relative}.compose-form .autosuggest-textarea,.compose-form .autosuggest-input,.compose-form .spoiler-input{position:relative;width:100%}.compose-form .spoiler-input{height:0;transform-origin:bottom;opacity:0}.compose-form .spoiler-input.spoiler-input--visible{height:36px;margin-bottom:11px;opacity:1}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0}.compose-form .autosuggest-textarea__textarea::placeholder,.compose-form .spoiler-input__input::placeholder{color:#404040}.compose-form .autosuggest-textarea__textarea:focus,.compose-form .spoiler-input__input:focus{outline:0}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{font-size:16px}}.compose-form .spoiler-input__input{border-radius:4px}.compose-form .autosuggest-textarea__textarea{min-height:100px;border-radius:4px 4px 0 0;padding-bottom:0;padding-right:32px;resize:none;scrollbar-color:initial}.compose-form .autosuggest-textarea__textarea::-webkit-scrollbar{all:unset}@media screen and (max-width: 600px){.compose-form .autosuggest-textarea__textarea{height:100px !important;resize:vertical}}.compose-form .autosuggest-textarea__suggestions-wrapper{position:relative;height:0}.compose-form .autosuggest-textarea__suggestions{box-sizing:border-box;display:none;position:absolute;top:100%;width:100%;z-index:99;box-shadow:4px 4px 6px rgba(0,0,0,.4);background:#d9e1e8;border-radius:0 0 4px 4px;color:#121a24;font-size:14px;padding:6px}.compose-form .autosuggest-textarea__suggestions.autosuggest-textarea__suggestions--visible{display:block}.compose-form .autosuggest-textarea__suggestions__item{padding:10px;cursor:pointer;border-radius:4px}.compose-form .autosuggest-textarea__suggestions__item:hover,.compose-form .autosuggest-textarea__suggestions__item:focus,.compose-form .autosuggest-textarea__suggestions__item:active,.compose-form .autosuggest-textarea__suggestions__item.selected{background:#b9c8d5}.compose-form .autosuggest-account,.compose-form .autosuggest-emoji,.compose-form .autosuggest-hashtag{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;line-height:18px;font-size:14px}.compose-form .autosuggest-hashtag{justify-content:space-between}.compose-form .autosuggest-hashtag__name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-hashtag strong{font-weight:500}.compose-form .autosuggest-hashtag__uses{flex:0 0 auto;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compose-form .autosuggest-account-icon,.compose-form .autosuggest-emoji img{display:block;margin-right:8px;width:16px;height:16px}.compose-form .autosuggest-account .display-name__account{color:#404040}.compose-form .compose-form__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.compose-form .compose-form__modifiers .compose-form__upload-wrapper{overflow:hidden}.compose-form .compose-form__modifiers .compose-form__uploads-wrapper{display:flex;flex-direction:row;padding:5px;flex-wrap:wrap}.compose-form .compose-form__modifiers .compose-form__upload{flex:1 1 0;min-width:40%;margin:5px}.compose-form .compose-form__modifiers .compose-form__upload__actions{background:linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);display:flex;align-items:flex-start;justify-content:space-between;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button{flex:0 1 auto;color:#d9e1e8;font-size:14px;font-weight:500;padding:10px;font-family:inherit}.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:hover,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:focus,.compose-form .compose-form__modifiers .compose-form__upload__actions .icon-button:active{color:#eff3f5}.compose-form .compose-form__modifiers .compose-form__upload__actions.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-description{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0, rgba(0, 0, 0, 0.35) 80%, transparent);padding:10px;opacity:0;transition:opacity .1s ease}.compose-form .compose-form__modifiers .compose-form__upload-description textarea{background:transparent;color:#d9e1e8;border:0;padding:0;margin:0;width:100%;font-family:inherit;font-size:14px;font-weight:500}.compose-form .compose-form__modifiers .compose-form__upload-description textarea:focus{color:#fff}.compose-form .compose-form__modifiers .compose-form__upload-description textarea::placeholder{opacity:.75;color:#d9e1e8}.compose-form .compose-form__modifiers .compose-form__upload-description.active{opacity:1}.compose-form .compose-form__modifiers .compose-form__upload-thumbnail{border-radius:4px;background-color:#000;background-position:center;background-size:cover;background-repeat:no-repeat;height:140px;width:100%;overflow:hidden}.compose-form .compose-form__buttons-wrapper{padding:10px;background:#ebebeb;border-radius:0 0 4px 4px;display:flex;justify-content:space-between;flex:0 0 auto}.compose-form .compose-form__buttons-wrapper .compose-form__buttons{display:flex}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__upload-button-icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button{display:none}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button.compose-form__sensitive-button--visible{display:block}.compose-form .compose-form__buttons-wrapper .compose-form__buttons .compose-form__sensitive-button .compose-form__sensitive-button__icon{line-height:27px}.compose-form .compose-form__buttons-wrapper .icon-button,.compose-form .compose-form__buttons-wrapper .text-icon-button{box-sizing:content-box;padding:0 3px}.compose-form .compose-form__buttons-wrapper .character-counter__wrapper{align-self:center;margin-right:4px}.compose-form .compose-form__publish{display:flex;justify-content:flex-end;min-width:0;flex:0 0 auto}.compose-form .compose-form__publish .compose-form__publish-button-wrapper{overflow:hidden;padding-top:10px}.character-counter{cursor:default;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:600;color:#404040}.character-counter.character-counter--over{color:#ff5050}.no-reduce-motion .spoiler-input{transition:height .4s ease,opacity .4s ease}.emojione{font-size:inherit;vertical-align:middle;object-fit:contain;margin:-0.2ex .15em .2ex;width:16px;height:16px}.emojione img{width:auto}.reply-indicator{border-radius:4px;margin-bottom:10px;background:#9baec8;padding:10px;min-height:23px;overflow-y:auto;flex:0 2 auto}.reply-indicator__header{margin-bottom:5px;overflow:hidden}.reply-indicator__cancel{float:right;line-height:24px}.reply-indicator__display-name{color:#121a24;display:block;max-width:100%;line-height:24px;overflow:hidden;padding-right:25px;text-decoration:none}.reply-indicator__display-avatar{float:left;margin-right:5px}.status__content--with-action{cursor:pointer}.status__content,.reply-indicator__content{position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;overflow:hidden;text-overflow:ellipsis;padding-top:2px;color:#fff}.status__content:focus,.reply-indicator__content:focus{outline:0}.status__content.status__content--with-spoiler,.reply-indicator__content.status__content--with-spoiler{white-space:normal}.status__content.status__content--with-spoiler .status__content__text,.reply-indicator__content.status__content--with-spoiler .status__content__text{white-space:pre-wrap}.status__content .emojione,.reply-indicator__content .emojione{width:20px;height:20px;margin:-3px 0 0}.status__content img,.reply-indicator__content img{max-width:100%;max-height:400px;object-fit:contain}.status__content p,.reply-indicator__content p{margin-bottom:20px;white-space:pre-wrap}.status__content p:last-child,.reply-indicator__content p:last-child{margin-bottom:0}.status__content a,.reply-indicator__content a{color:#d8a070;text-decoration:none}.status__content a:hover,.reply-indicator__content a:hover{text-decoration:underline}.status__content a:hover .fa,.reply-indicator__content a:hover .fa{color:#525252}.status__content a.mention:hover,.reply-indicator__content a.mention:hover{text-decoration:none}.status__content a.mention:hover span,.reply-indicator__content a.mention:hover span{text-decoration:underline}.status__content a .fa,.reply-indicator__content a .fa{color:#404040}.status__content a.unhandled-link,.reply-indicator__content a.unhandled-link{color:#0000a8}.status__content .status__content__spoiler-link,.reply-indicator__content .status__content__spoiler-link{background:#404040}.status__content .status__content__spoiler-link:hover,.reply-indicator__content .status__content__spoiler-link:hover{background:#525252;text-decoration:none}.status__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner{border:0}.status__content .status__content__spoiler-link::-moz-focus-inner,.status__content .status__content__spoiler-link:focus,.status__content .status__content__spoiler-link:active,.reply-indicator__content .status__content__spoiler-link::-moz-focus-inner,.reply-indicator__content .status__content__spoiler-link:focus,.reply-indicator__content .status__content__spoiler-link:active{outline:0 !important}.status__content .status__content__text,.reply-indicator__content .status__content__text{display:none}.status__content .status__content__text.status__content__text--visible,.reply-indicator__content .status__content__text.status__content__text--visible{display:block}.announcements__item__content{word-wrap:break-word;overflow-y:auto}.announcements__item__content .emojione{width:20px;height:20px;margin:-3px 0 0}.announcements__item__content p{margin-bottom:10px;white-space:pre-wrap}.announcements__item__content p:last-child{margin-bottom:0}.announcements__item__content a{color:#d9e1e8;text-decoration:none}.announcements__item__content a:hover{text-decoration:underline}.announcements__item__content a.mention:hover{text-decoration:none}.announcements__item__content a.mention:hover span{text-decoration:underline}.announcements__item__content a.unhandled-link{color:#0000a8}.status__content.status__content--collapsed{max-height:300px}.status__content__read-more-button{display:block;font-size:15px;line-height:20px;color:#0000a8;border:0;background:transparent;padding:0;padding-top:8px;text-decoration:none}.status__content__read-more-button:hover,.status__content__read-more-button:active{text-decoration:underline}.status__content__spoiler-link{display:inline-block;border-radius:2px;background:transparent;border:0;color:#121a24;font-weight:700;font-size:11px;padding:0 6px;text-transform:uppercase;line-height:20px;cursor:pointer;vertical-align:middle}.status__wrapper--filtered{color:#404040;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;border-bottom:1px solid #202e3f}.status__prepend-icon-wrapper{left:-26px;position:absolute}.focusable:focus{outline:0;background:#192432}.focusable:focus .status.status-direct{background:#26374d}.focusable:focus .status.status-direct.muted{background:transparent}.focusable:focus .detailed-status,.focusable:focus .detailed-status__action-bar{background:#202e3f}.status{padding:8px 10px;padding-left:68px;position:relative;min-height:54px;border-bottom:1px solid #202e3f;cursor:default;opacity:1;animation:fade 150ms linear}@supports(-ms-overflow-style: -ms-autohiding-scrollbar){.status{padding-right:26px}}@keyframes fade{0%{opacity:0}100%{opacity:1}}.status .video-player,.status .audio-player{margin-top:8px}.status.status-direct:not(.read){background:#202e3f;border-bottom-color:#26374d}.status.light .status__relative-time{color:#9baec8}.status.light .status__display-name{color:#121a24}.status.light .display-name{color:#9baec8}.status.light .display-name strong{color:#121a24}.status.light .status__content{color:#121a24}.status.light .status__content a{color:#00007f}.status.light .status__content a.status__content__spoiler-link{color:#fff;background:#9baec8}.status.light .status__content a.status__content__spoiler-link:hover{background:#b5c3d6}.notification-favourite .status.status-direct{background:transparent}.notification-favourite .status.status-direct .icon-button.disabled{color:#616161}.status__relative-time,.notification__relative_time{color:#404040;float:right;font-size:14px}.status__display-name{color:#404040}.status__info .status__display-name{display:block;max-width:100%;padding-right:25px}.status__info{font-size:15px}.status-check-box{border-bottom:1px solid #d9e1e8;display:flex}.status-check-box .status-check-box__status{margin:10px 0 10px 10px;flex:1;overflow:hidden}.status-check-box .status-check-box__status .media-gallery{max-width:250px}.status-check-box .status-check-box__status .status__content{padding:0;white-space:normal}.status-check-box .status-check-box__status .video-player,.status-check-box .status-check-box__status .audio-player{margin-top:8px;max-width:250px}.status-check-box .status-check-box__status .media-gallery__item-thumbnail{cursor:default}.status-check-box-toggle{align-items:center;display:flex;flex:0 0 auto;justify-content:center;padding:10px}.status__prepend{margin-left:68px;color:#404040;padding:8px 0;padding-bottom:2px;font-size:14px;position:relative}.status__prepend .status__display-name strong{color:#404040}.status__prepend>span{display:block;overflow:hidden;text-overflow:ellipsis}.status__action-bar{align-items:center;display:flex;margin-top:8px}.status__action-bar__counter{display:inline-flex;margin-right:11px;align-items:center}.status__action-bar__counter .status__action-bar-button{margin-right:4px}.status__action-bar__counter__label{display:inline-block;width:14px;font-size:12px;font-weight:500;color:#404040}.status__action-bar-button{margin-right:18px}.status__action-bar-dropdown{height:23.15px;width:23.15px}.detailed-status__action-bar-dropdown{flex:1 1 auto;display:flex;align-items:center;justify-content:center;position:relative}.detailed-status{background:#192432;padding:14px 10px}.detailed-status--flex{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start}.detailed-status--flex .status__content,.detailed-status--flex .detailed-status__meta{flex:100%}.detailed-status .status__content{font-size:19px;line-height:24px}.detailed-status .status__content .emojione{width:24px;height:24px;margin:-1px 0 0}.detailed-status .status__content .status__content__spoiler-link{line-height:24px;margin:-1px 0 0}.detailed-status .video-player,.detailed-status .audio-player{margin-top:8px}.detailed-status__meta{margin-top:15px;color:#404040;font-size:14px;line-height:18px}.detailed-status__action-bar{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.detailed-status__link{color:inherit;text-decoration:none}.detailed-status__favorites,.detailed-status__reblogs{display:inline-block;font-weight:500;font-size:12px;margin-left:6px}.reply-indicator__content{color:#121a24;font-size:14px}.reply-indicator__content a{color:#404040}.domain{padding:10px;border-bottom:1px solid #202e3f}.domain .domain__domain-name{flex:1 1 auto;display:block;color:#fff;text-decoration:none;font-size:14px;font-weight:500}.domain__wrapper{display:flex}.domain_buttons{height:18px;padding:10px;white-space:nowrap}.account{padding:10px;border-bottom:1px solid #202e3f}.account.compact{padding:0;border-bottom:0}.account.compact .account__avatar-wrapper{margin-left:0}.account .account__display-name{flex:1 1 auto;display:block;color:#9baec8;overflow:hidden;text-decoration:none;font-size:14px}.account__wrapper{display:flex}.account__avatar-wrapper{float:left;margin-left:12px;margin-right:12px}.account__avatar{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;position:relative}.account__avatar-inline{display:inline-block;vertical-align:middle;margin-right:5px}.account__avatar-composite{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;border-radius:50%;overflow:hidden;position:relative}.account__avatar-composite>div{float:left;position:relative;box-sizing:border-box}.account__avatar-composite__label{display:block;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);color:#fff;text-shadow:1px 1px 2px #000;font-weight:700;font-size:15px}a .account__avatar{cursor:pointer}.account__avatar-overlay{width:48px;height:48px;background-size:48px 48px}.account__avatar-overlay-base{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:36px;height:36px;background-size:36px 36px}.account__avatar-overlay-overlay{border-radius:4px;background:transparent no-repeat;background-position:50%;background-clip:padding-box;width:24px;height:24px;background-size:24px 24px;position:absolute;bottom:0;right:0;z-index:1}.account__relationship{height:18px;padding:10px;white-space:nowrap}.account__disclaimer{padding:10px;border-top:1px solid #202e3f;color:#404040}.account__disclaimer strong{font-weight:500}.account__disclaimer strong:lang(ja){font-weight:700}.account__disclaimer strong:lang(ko){font-weight:700}.account__disclaimer strong:lang(zh-CN){font-weight:700}.account__disclaimer strong:lang(zh-HK){font-weight:700}.account__disclaimer strong:lang(zh-TW){font-weight:700}.account__disclaimer a{font-weight:500;color:inherit;text-decoration:underline}.account__disclaimer a:hover,.account__disclaimer a:focus,.account__disclaimer a:active{text-decoration:none}.account__action-bar{border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;line-height:36px;overflow:hidden;flex:0 0 auto;display:flex}.account__action-bar-dropdown{padding:10px}.account__action-bar-dropdown .icon-button{vertical-align:middle}.account__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__right{left:6px;right:initial}.account__action-bar-dropdown .dropdown--active::after{bottom:initial;margin-left:11px;margin-top:-7px;right:initial}.account__action-bar-links{display:flex;flex:1 1 auto;line-height:18px;text-align:center}.account__action-bar__tab{text-decoration:none;overflow:hidden;flex:0 1 100%;border-right:1px solid #202e3f;padding:10px 0;border-bottom:4px solid transparent}.account__action-bar__tab.active{border-bottom:4px solid #00007f}.account__action-bar__tab>span{display:block;text-transform:uppercase;font-size:11px;color:#9baec8}.account__action-bar__tab strong{display:block;font-size:15px;font-weight:500;color:#fff}.account__action-bar__tab strong:lang(ja){font-weight:700}.account__action-bar__tab strong:lang(ko){font-weight:700}.account__action-bar__tab strong:lang(zh-CN){font-weight:700}.account__action-bar__tab strong:lang(zh-HK){font-weight:700}.account__action-bar__tab strong:lang(zh-TW){font-weight:700}.account-authorize{padding:14px 10px}.account-authorize .detailed-status__display-name{display:block;margin-bottom:15px;overflow:hidden}.account-authorize__avatar{float:left;margin-right:10px}.status__display-name,.status__relative-time,.detailed-status__display-name,.detailed-status__datetime,.detailed-status__application,.account__display-name{text-decoration:none}.status__display-name strong,.account__display-name strong{color:#fff}.muted .emojione{opacity:.5}.status__display-name:hover strong,.reply-indicator__display-name:hover strong,.detailed-status__display-name:hover strong,a.account__display-name:hover strong{text-decoration:underline}.account__display-name strong{display:block;overflow:hidden;text-overflow:ellipsis}.detailed-status__application,.detailed-status__datetime{color:inherit}.detailed-status .button.logo-button{margin-bottom:15px}.detailed-status__display-name{color:#d9e1e8;display:block;line-height:24px;margin-bottom:15px;overflow:hidden}.detailed-status__display-name strong,.detailed-status__display-name span{display:block;text-overflow:ellipsis;overflow:hidden}.detailed-status__display-name strong{font-size:16px;color:#fff}.detailed-status__display-avatar{float:left;margin-right:10px}.status__avatar{height:48px;left:10px;position:absolute;top:10px;width:48px}.status__expand{width:68px;position:absolute;left:0;top:0;height:100%;cursor:pointer}.muted .status__content,.muted .status__content p,.muted .status__content a{color:#404040}.muted .status__display-name strong{color:#404040}.muted .status__avatar{opacity:.5}.muted a.status__content__spoiler-link{background:#404040;color:#121a24}.muted a.status__content__spoiler-link:hover{background:#525252;text-decoration:none}.notification__message{margin:0 10px 0 68px;padding:8px 0 0;cursor:default;color:#9baec8;font-size:15px;line-height:22px;position:relative}.notification__message .fa{color:#00007f}.notification__message>span{display:inline;overflow:hidden;text-overflow:ellipsis}.notification__favourite-icon-wrapper{left:-26px;position:absolute}.notification__favourite-icon-wrapper .star-icon{color:#ca8f04}.star-icon.active{color:#ca8f04}.bookmark-icon.active{color:#ff5050}.no-reduce-motion .icon-button.star-icon.activate>.fa-star{animation:spring-rotate-in 1s linear}.no-reduce-motion .icon-button.star-icon.deactivate>.fa-star{animation:spring-rotate-out 1s linear}.notification__display-name{color:inherit;font-weight:500;text-decoration:none}.notification__display-name:hover{color:#fff;text-decoration:underline}.notification__relative_time{float:right}.display-name{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.display-name__html{font-weight:500}.display-name__account{font-size:14px}.status__relative-time:hover,.detailed-status__datetime:hover{text-decoration:underline}.image-loader{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column}.image-loader .image-loader__preview-canvas{max-width:100%;max-height:80%;background:url(\"~images/void.png\") repeat;object-fit:contain}.image-loader .loading-bar{position:relative}.image-loader.image-loader--amorphous .image-loader__preview-canvas{display:none}.zoomable-image{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center}.zoomable-image img{max-width:100%;max-height:80%;width:auto;height:auto;object-fit:contain}.navigation-bar{padding:10px;display:flex;align-items:center;flex-shrink:0;cursor:default;color:#9baec8}.navigation-bar strong{color:#d9e1e8}.navigation-bar a{color:inherit}.navigation-bar .permalink{text-decoration:none}.navigation-bar .navigation-bar__actions{position:relative}.navigation-bar .navigation-bar__actions .icon-button.close{position:absolute;pointer-events:none;transform:scale(0, 1) translate(-100%, 0);opacity:0}.navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:auto;transform:scale(1, 1) translate(0, 0);opacity:1}.navigation-bar__profile{flex:1 1 auto;margin-left:8px;line-height:20px;margin-top:-1px;overflow:hidden}.navigation-bar__profile-account{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis}.navigation-bar__profile-edit{color:inherit;text-decoration:none}.dropdown{display:inline-block}.dropdown__content{display:none;position:absolute}.dropdown-menu__separator{border-bottom:1px solid #c0cdd9;margin:5px 7px 6px;height:0}.dropdown-menu{background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:2px 4px 15px rgba(0,0,0,.4);z-index:9999}.dropdown-menu ul{list-style:none}.dropdown-menu.left{transform-origin:100% 50%}.dropdown-menu.top{transform-origin:50% 100%}.dropdown-menu.bottom{transform-origin:50% 0}.dropdown-menu.right{transform-origin:0 50%}.dropdown-menu__arrow{position:absolute;width:0;height:0;border:0 solid transparent}.dropdown-menu__arrow.left{right:-5px;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#d9e1e8}.dropdown-menu__arrow.top{bottom:-5px;margin-left:-7px;border-width:5px 7px 0;border-top-color:#d9e1e8}.dropdown-menu__arrow.bottom{top:-5px;margin-left:-7px;border-width:0 7px 5px;border-bottom-color:#d9e1e8}.dropdown-menu__arrow.right{left:-5px;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#d9e1e8}.dropdown-menu__item a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown-menu__item a:focus,.dropdown-menu__item a:hover,.dropdown-menu__item a:active{background:#00007f;color:#d9e1e8;outline:0}.dropdown--active .dropdown__content{display:block;line-height:18px;max-width:311px;right:0;text-align:left;z-index:9999}.dropdown--active .dropdown__content>ul{list-style:none;background:#d9e1e8;padding:4px 0;border-radius:4px;box-shadow:0 0 15px rgba(0,0,0,.4);min-width:140px;position:relative}.dropdown--active .dropdown__content.dropdown__right{right:0}.dropdown--active .dropdown__content.dropdown__left>ul{left:-98px}.dropdown--active .dropdown__content>ul>li>a{font-size:13px;line-height:18px;display:block;padding:4px 14px;box-sizing:border-box;text-decoration:none;background:#d9e1e8;color:#121a24;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dropdown--active .dropdown__content>ul>li>a:focus{outline:0}.dropdown--active .dropdown__content>ul>li>a:hover{background:#00007f;color:#d9e1e8}.dropdown__icon{vertical-align:middle}.columns-area{display:flex;flex:1 1 auto;flex-direction:row;justify-content:flex-start;overflow-x:auto;position:relative}.columns-area.unscrollable{overflow-x:hidden}.columns-area__panels{display:flex;justify-content:center;width:100%;height:100%;min-height:100vh}.columns-area__panels__pane{height:100%;overflow:hidden;pointer-events:none;display:flex;justify-content:flex-end;min-width:285px}.columns-area__panels__pane--start{justify-content:flex-start}.columns-area__panels__pane__inner{position:fixed;width:285px;pointer-events:auto;height:100%}.columns-area__panels__main{box-sizing:border-box;width:100%;max-width:600px;flex:0 0 auto;display:flex;flex-direction:column}@media screen and (min-width: 415px){.columns-area__panels__main{padding:0 10px}}.tabs-bar__wrapper{background:#040609;position:sticky;top:0;z-index:2;padding-top:0}@media screen and (min-width: 415px){.tabs-bar__wrapper{padding-top:10px}}.tabs-bar__wrapper .tabs-bar{margin-bottom:0}@media screen and (min-width: 415px){.tabs-bar__wrapper .tabs-bar{margin-bottom:10px}}.react-swipeable-view-container,.react-swipeable-view-container .columns-area,.react-swipeable-view-container .drawer,.react-swipeable-view-container .column{height:100%}.react-swipeable-view-container>*{display:flex;align-items:center;justify-content:center;height:100%}.column{width:350px;position:relative;box-sizing:border-box;display:flex;flex-direction:column}.column>.scrollable{background:#121a24;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.ui{flex:0 0 auto;display:flex;flex-direction:column;width:100%;height:100%}.drawer{width:330px;box-sizing:border-box;display:flex;flex-direction:column;overflow-y:hidden}.drawer__tab{display:block;flex:1 1 auto;padding:15px 5px 13px;color:#9baec8;text-decoration:none;text-align:center;font-size:16px;border-bottom:2px solid transparent}.column,.drawer{flex:1 1 auto;overflow:hidden}@media screen and (min-width: 631px){.columns-area{padding:0}.column,.drawer{flex:0 0 auto;padding:10px;padding-left:5px;padding-right:5px}.column:first-child,.drawer:first-child{padding-left:10px}.column:last-child,.drawer:last-child{padding-right:10px}.columns-area>div .column,.columns-area>div .drawer{padding-left:5px;padding-right:5px}}.tabs-bar{box-sizing:border-box;display:flex;background:#202e3f;flex:0 0 auto;overflow-y:auto}.tabs-bar__link{display:block;flex:1 1 auto;padding:15px 10px;padding-bottom:13px;color:#fff;text-decoration:none;text-align:center;font-size:14px;font-weight:500;border-bottom:2px solid #202e3f;transition:all 50ms linear;transition-property:border-bottom,background,color}.tabs-bar__link .fa{font-weight:400;font-size:16px}@media screen and (min-width: 631px){.tabs-bar__link:hover,.tabs-bar__link:focus,.tabs-bar__link:active{background:#2a3c54;border-bottom-color:#2a3c54}}.tabs-bar__link.active{border-bottom:2px solid #00007f;color:#00007f}.tabs-bar__link span{margin-left:5px;display:none}@media screen and (min-width: 600px){.tabs-bar__link span{display:inline}}.columns-area--mobile{flex-direction:column;width:100%;height:100%;margin:0 auto}.columns-area--mobile .column,.columns-area--mobile .drawer{width:100%;height:100%;padding:0}.columns-area--mobile .directory__list{display:grid;grid-gap:10px;grid-template-columns:minmax(0, 50%) minmax(0, 50%)}@media screen and (max-width: 415px){.columns-area--mobile .directory__list{display:block}}.columns-area--mobile .directory__card{margin-bottom:0}.columns-area--mobile .filter-form{display:flex}.columns-area--mobile .autosuggest-textarea__textarea{font-size:16px}.columns-area--mobile .search__input{line-height:18px;font-size:16px;padding:15px;padding-right:30px}.columns-area--mobile .search__icon .fa{top:15px}.columns-area--mobile .scrollable{overflow:visible}@supports(display: grid){.columns-area--mobile .scrollable{contain:content}}@media screen and (min-width: 415px){.columns-area--mobile{padding:10px 0;padding-top:0}}@media screen and (min-width: 630px){.columns-area--mobile .detailed-status{padding:15px}.columns-area--mobile .detailed-status .media-gallery,.columns-area--mobile .detailed-status .video-player,.columns-area--mobile .detailed-status .audio-player{margin-top:15px}.columns-area--mobile .account__header__bar{padding:5px 10px}.columns-area--mobile .navigation-bar,.columns-area--mobile .compose-form{padding:15px}.columns-area--mobile .compose-form .compose-form__publish .compose-form__publish-button-wrapper{padding-top:15px}.columns-area--mobile .status{padding:15px 15px 15px 78px;min-height:50px}.columns-area--mobile .status__avatar{left:15px;top:17px}.columns-area--mobile .status__content{padding-top:5px}.columns-area--mobile .status__prepend{margin-left:78px;padding-top:15px}.columns-area--mobile .status__prepend-icon-wrapper{left:-32px}.columns-area--mobile .status .media-gallery,.columns-area--mobile .status__action-bar,.columns-area--mobile .status .video-player,.columns-area--mobile .status .audio-player{margin-top:10px}.columns-area--mobile .account{padding:15px 10px}.columns-area--mobile .account__header__bio{margin:0 -10px}.columns-area--mobile .notification__message{margin-left:78px;padding-top:15px}.columns-area--mobile .notification__favourite-icon-wrapper{left:-32px}.columns-area--mobile .notification .status{padding-top:8px}.columns-area--mobile .notification .account{padding-top:8px}.columns-area--mobile .notification .account__avatar-wrapper{margin-left:17px;margin-right:15px}}.floating-action-button{position:fixed;display:flex;justify-content:center;align-items:center;width:3.9375rem;height:3.9375rem;bottom:1.3125rem;right:1.3125rem;background:#000070;color:#fff;border-radius:50%;font-size:21px;line-height:21px;text-decoration:none;box-shadow:2px 3px 9px rgba(0,0,0,.4)}.floating-action-button:hover,.floating-action-button:focus,.floating-action-button:active{background:#0000a3}@media screen and (min-width: 415px){.tabs-bar{width:100%}.react-swipeable-view-container .columns-area--mobile{height:calc(100% - 10px) !important}.getting-started__wrapper,.getting-started__trends,.search{margin-bottom:10px}.getting-started__panel{margin:10px 0}.column,.drawer{min-width:330px}}@media screen and (max-width: 895px){.columns-area__panels__pane--compositional{display:none}}@media screen and (min-width: 895px){.floating-action-button,.tabs-bar__link.optional{display:none}.search-page .search{display:none}}@media screen and (max-width: 1190px){.columns-area__panels__pane--navigational{display:none}}@media screen and (min-width: 1190px){.tabs-bar{display:none}}.icon-with-badge{position:relative}.icon-with-badge__badge{position:absolute;left:9px;top:-13px;background:#00007f;border:2px solid #202e3f;padding:1px 6px;border-radius:6px;font-size:10px;font-weight:500;line-height:14px;color:#fff}.column-link--transparent .icon-with-badge__badge{border-color:#040609}.compose-panel{width:285px;margin-top:10px;display:flex;flex-direction:column;height:calc(100% - 10px);overflow-y:hidden}.compose-panel .navigation-bar{padding-top:20px;padding-bottom:20px;flex:0 1 48px;min-height:20px}.compose-panel .flex-spacer{background:transparent}.compose-panel .compose-form{flex:1;overflow-y:hidden;display:flex;flex-direction:column;min-height:310px;padding-bottom:71px;margin-bottom:-71px}.compose-panel .compose-form__autosuggest-wrapper{overflow-y:auto;background-color:#fff;border-radius:4px 4px 0 0;flex:0 1 auto}.compose-panel .autosuggest-textarea__textarea{overflow-y:hidden}.compose-panel .compose-form__upload-thumbnail{height:80px}.navigation-panel{margin-top:10px;margin-bottom:10px;height:calc(100% - 20px);overflow-y:auto;display:flex;flex-direction:column}.navigation-panel>a{flex:0 0 auto}.navigation-panel hr{flex:0 0 auto;border:0;background:transparent;border-top:1px solid #192432;margin:10px 0}.navigation-panel .flex-spacer{background:transparent}.drawer__pager{box-sizing:border-box;padding:0;flex-grow:1;position:relative;overflow:hidden;display:flex}.drawer__inner{position:absolute;top:0;left:0;background:#283a50;box-sizing:border-box;padding:0;display:flex;flex-direction:column;overflow:hidden;overflow-y:auto;width:100%;height:100%;border-radius:2px}.drawer__inner.darker{background:#121a24}.drawer__inner__mastodon{background:#283a50 url('data:image/svg+xml;utf8,') no-repeat bottom/100% auto;flex:1;min-height:47px;display:none}.drawer__inner__mastodon>img{display:block;object-fit:contain;object-position:bottom left;width:85%;height:100%;pointer-events:none;user-drag:none;user-select:none}@media screen and (min-height: 640px){.drawer__inner__mastodon{display:block}}.pseudo-drawer{background:#283a50;font-size:13px;text-align:left}.drawer__header{flex:0 0 auto;font-size:16px;background:#202e3f;margin-bottom:10px;display:flex;flex-direction:row;border-radius:2px}.drawer__header a{transition:background 100ms ease-in}.drawer__header a:hover{background:#17212e;transition:background 200ms ease-out}.scrollable{overflow-y:scroll;overflow-x:hidden;flex:1 1 auto;-webkit-overflow-scrolling:touch}.scrollable.optionally-scrollable{overflow-y:auto}@supports(display: grid){.scrollable{contain:strict}}.scrollable--flex{display:flex;flex-direction:column}.scrollable__append{flex:1 1 auto;position:relative;min-height:120px}@supports(display: grid){.scrollable.fullscreen{contain:none}}.column-back-button{box-sizing:border-box;width:100%;background:#192432;color:#00007f;cursor:pointer;flex:0 0 auto;font-size:16px;line-height:inherit;border:0;text-align:unset;padding:15px;margin:0;z-index:3;outline:0}.column-back-button:hover{text-decoration:underline}.column-header__back-button{background:#192432;border:0;font-family:inherit;color:#00007f;cursor:pointer;white-space:nowrap;font-size:16px;padding:0 5px 0 0;z-index:3}.column-header__back-button:hover{text-decoration:underline}.column-header__back-button:last-child{padding:0 15px 0 0}.column-back-button__icon{display:inline-block;margin-right:5px}.column-back-button--slim{position:relative}.column-back-button--slim-button{cursor:pointer;flex:0 0 auto;font-size:16px;padding:15px;position:absolute;right:0;top:-48px}.react-toggle{display:inline-block;position:relative;cursor:pointer;background-color:transparent;border:0;padding:0;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-tap-highlight-color:transparent}.react-toggle-screenreader-only{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.react-toggle--disabled{cursor:not-allowed;opacity:.5;transition:opacity .25s}.react-toggle-track{width:50px;height:24px;padding:0;border-radius:30px;background-color:#121a24;transition:background-color .2s ease}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#010102}.react-toggle--checked .react-toggle-track{background-color:#00007f}.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#0000b2}.react-toggle-track-check{position:absolute;width:14px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;left:8px;opacity:0;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-check{opacity:1;transition:opacity .25s ease}.react-toggle-track-x{position:absolute;width:10px;height:10px;top:0;bottom:0;margin-top:auto;margin-bottom:auto;line-height:0;right:10px;opacity:1;transition:opacity .25s ease}.react-toggle--checked .react-toggle-track-x{opacity:0}.react-toggle-thumb{position:absolute;top:1px;left:1px;width:22px;height:22px;border:1px solid #121a24;border-radius:50%;background-color:#fafafa;box-sizing:border-box;transition:all .25s ease;transition-property:border-color,left}.react-toggle--checked .react-toggle-thumb{left:27px;border-color:#00007f}.column-link{background:#202e3f;color:#fff;display:block;font-size:16px;padding:15px;text-decoration:none}.column-link:hover,.column-link:focus,.column-link:active{background:#253549}.column-link:focus{outline:0}.column-link--transparent{background:transparent;color:#d9e1e8}.column-link--transparent:hover,.column-link--transparent:focus,.column-link--transparent:active{background:transparent;color:#fff}.column-link--transparent.active{color:#00007f}.column-link__icon{display:inline-block;margin-right:5px}.column-link__badge{display:inline-block;border-radius:4px;font-size:12px;line-height:19px;font-weight:500;background:#121a24;padding:4px 8px;margin:-6px 10px}.column-subheading{background:#121a24;color:#404040;padding:8px 20px;font-size:12px;font-weight:500;text-transform:uppercase;cursor:default}.getting-started__wrapper,.getting-started,.flex-spacer{background:#121a24}.flex-spacer{flex:1 1 auto}.getting-started{color:#404040;overflow:auto;border-bottom-left-radius:2px;border-bottom-right-radius:2px}.getting-started__wrapper,.getting-started__panel,.getting-started__footer{height:min-content}.getting-started__panel,.getting-started__footer{padding:10px;padding-top:20px;flex-grow:0}.getting-started__panel ul,.getting-started__footer ul{margin-bottom:10px}.getting-started__panel ul li,.getting-started__footer ul li{display:inline}.getting-started__panel p,.getting-started__footer p{font-size:13px}.getting-started__panel p a,.getting-started__footer p a{color:#404040;text-decoration:underline}.getting-started__panel a,.getting-started__footer a{text-decoration:none;color:#9baec8}.getting-started__panel a:hover,.getting-started__panel a:focus,.getting-started__panel a:active,.getting-started__footer a:hover,.getting-started__footer a:focus,.getting-started__footer a:active{text-decoration:underline}.getting-started__wrapper,.getting-started__footer{color:#404040}.getting-started__trends{flex:0 1 auto;opacity:1;animation:fade 150ms linear;margin-top:10px}.getting-started__trends h4{font-size:12px;text-transform:uppercase;color:#9baec8;padding:10px;font-weight:500;border-bottom:1px solid #202e3f}@media screen and (max-height: 810px){.getting-started__trends .trends__item:nth-child(3){display:none}}@media screen and (max-height: 720px){.getting-started__trends .trends__item:nth-child(2){display:none}}@media screen and (max-height: 670px){.getting-started__trends{display:none}}.getting-started__trends .trends__item{border-bottom:0;padding:10px}.getting-started__trends .trends__item__current{color:#9baec8}.keyboard-shortcuts{padding:8px 0 0;overflow:hidden}.keyboard-shortcuts thead{position:absolute;left:-9999px}.keyboard-shortcuts td{padding:0 10px 8px}.keyboard-shortcuts kbd{display:inline-block;padding:3px 5px;background-color:#202e3f;border:1px solid #0b1016}.setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:vertical;border:0;outline:0;border-radius:4px}.setting-text:focus{outline:0}@media screen and (max-width: 600px){.setting-text{font-size:16px}}.no-reduce-motion button.icon-button i.fa-retweet{background-position:0 0;height:19px;transition:background-position .9s steps(10);transition-duration:0s;vertical-align:middle;width:22px}.no-reduce-motion button.icon-button i.fa-retweet::before{display:none !important}.no-reduce-motion button.icon-button.active i.fa-retweet{transition-duration:.9s;background-position:0 100%}.reduce-motion button.icon-button i.fa-retweet{color:#404040;transition:color 100ms ease-in}.reduce-motion button.icon-button.active i.fa-retweet{color:#00007f}.status-card{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;color:#404040;margin-top:14px;text-decoration:none;overflow:hidden}.status-card__actions{bottom:0;left:0;position:absolute;right:0;top:0;display:flex;justify-content:center;align-items:center}.status-card__actions>div{background:rgba(0,0,0,.6);border-radius:8px;padding:12px 9px;flex:0 0 auto;display:flex;justify-content:center;align-items:center}.status-card__actions button,.status-card__actions a{display:inline;color:#d9e1e8;background:transparent;border:0;padding:0 8px;text-decoration:none;font-size:18px;line-height:18px}.status-card__actions button:hover,.status-card__actions button:active,.status-card__actions button:focus,.status-card__actions a:hover,.status-card__actions a:active,.status-card__actions a:focus{color:#fff}.status-card__actions a{font-size:19px;position:relative;bottom:-1px}a.status-card{cursor:pointer}a.status-card:hover{background:#202e3f}.status-card-photo{cursor:zoom-in;display:block;text-decoration:none;width:100%;height:auto;margin:0}.status-card-video iframe{width:100%;height:100%}.status-card__title{display:block;font-weight:500;margin-bottom:5px;color:#9baec8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}.status-card__content{flex:1 1 auto;overflow:hidden;padding:14px 14px 14px 8px}.status-card__description{color:#9baec8}.status-card__host{display:block;margin-top:5px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-card__image{flex:0 0 100px;background:#202e3f;position:relative}.status-card__image>.fa{font-size:21px;position:absolute;transform-origin:50% 50%;top:50%;left:50%;transform:translate(-50%, -50%)}.status-card.horizontal{display:block}.status-card.horizontal .status-card__image{width:100%}.status-card.horizontal .status-card__image-image{border-radius:4px 4px 0 0}.status-card.horizontal .status-card__title{white-space:inherit}.status-card.compact{border-color:#192432}.status-card.compact.interactive{border:0}.status-card.compact .status-card__content{padding:8px;padding-top:10px}.status-card.compact .status-card__title{white-space:nowrap}.status-card.compact .status-card__image{flex:0 0 60px}a.status-card.compact:hover{background-color:#192432}.status-card__image-image{border-radius:4px 0 0 4px;display:block;margin:0;width:100%;height:100%;object-fit:cover;background-size:cover;background-position:center center}.load-more{display:block;color:#404040;background-color:transparent;border:0;font-size:inherit;text-align:center;line-height:inherit;margin:0;padding:15px;box-sizing:border-box;width:100%;clear:both;text-decoration:none}.load-more:hover{background:#151f2b}.load-gap{border-bottom:1px solid #202e3f}.regeneration-indicator{text-align:center;font-size:16px;font-weight:500;color:#404040;background:#121a24;cursor:default;display:flex;flex:1 1 auto;flex-direction:column;align-items:center;justify-content:center;padding:20px}.regeneration-indicator__figure,.regeneration-indicator__figure img{display:block;width:auto;height:160px;margin:0}.regeneration-indicator--without-header{padding-top:68px}.regeneration-indicator__label{margin-top:30px}.regeneration-indicator__label strong{display:block;margin-bottom:10px;color:#404040}.regeneration-indicator__label span{font-size:15px;font-weight:400}.column-header__wrapper{position:relative;flex:0 0 auto;z-index:1}.column-header__wrapper.active{box-shadow:0 1px 0 rgba(0,0,127,.3)}.column-header__wrapper.active::before{display:block;content:\"\";position:absolute;bottom:-13px;left:0;right:0;margin:0 auto;width:60%;pointer-events:none;height:28px;z-index:1;background:radial-gradient(ellipse, rgba(0, 0, 127, 0.23) 0%, rgba(0, 0, 127, 0) 60%)}.column-header__wrapper .announcements{z-index:1;position:relative}.column-header{display:flex;font-size:16px;background:#192432;flex:0 0 auto;cursor:pointer;position:relative;z-index:2;outline:0;overflow:hidden;border-top-left-radius:2px;border-top-right-radius:2px}.column-header>button{margin:0;border:0;padding:15px 0 15px 15px;color:inherit;background:transparent;font:inherit;text-align:left;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;flex:1}.column-header>.column-header__back-button{color:#00007f}.column-header.active .column-header__icon{color:#00007f;text-shadow:0 0 10px rgba(0,0,127,.4)}.column-header:focus,.column-header:active{outline:0}.column-header__buttons{height:48px;display:flex}.column-header__links{margin-bottom:14px}.column-header__links .text-btn{margin-right:10px}.column-header__button{background:#192432;border:0;color:#9baec8;cursor:pointer;font-size:16px;padding:0 15px}.column-header__button:hover{color:#b2c1d5}.column-header__button.active{color:#fff;background:#202e3f}.column-header__button.active:hover{color:#fff;background:#202e3f}.column-header__collapsible{max-height:70vh;overflow:hidden;overflow-y:auto;color:#9baec8;transition:max-height 150ms ease-in-out,opacity 300ms linear;opacity:1;z-index:1;position:relative}.column-header__collapsible.collapsed{max-height:0;opacity:.5}.column-header__collapsible.animating{overflow-y:hidden}.column-header__collapsible hr{height:0;background:transparent;border:0;border-top:1px solid #26374d;margin:10px 0}.column-header__collapsible-inner{background:#202e3f;padding:15px}.column-header__setting-btn:hover{color:#9baec8;text-decoration:underline}.column-header__setting-arrows{float:right}.column-header__setting-arrows .column-header__setting-btn{padding:0 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding-right:0}.text-btn{display:inline-block;padding:0;font-family:inherit;font-size:inherit;color:inherit;border:0;background:transparent;cursor:pointer}.column-header__icon{display:inline-block;margin-right:5px}.loading-indicator{color:#404040;font-size:12px;font-weight:400;text-transform:uppercase;overflow:visible;position:absolute;top:50%;left:50%;transform:translate(-50%, -50%)}.loading-indicator span{display:block;float:left;margin-left:50%;transform:translateX(-50%);margin:82px 0 0 50%;white-space:nowrap}.loading-indicator__figure{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:42px;height:42px;box-sizing:border-box;background-color:transparent;border:0 solid #3e5a7c;border-width:6px;border-radius:50%}.no-reduce-motion .loading-indicator span{animation:loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}.no-reduce-motion .loading-indicator__figure{animation:loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1)}@keyframes spring-rotate-in{0%{transform:rotate(0deg)}30%{transform:rotate(-484.8deg)}60%{transform:rotate(-316.7deg)}90%{transform:rotate(-375deg)}100%{transform:rotate(-360deg)}}@keyframes spring-rotate-out{0%{transform:rotate(-360deg)}30%{transform:rotate(124.8deg)}60%{transform:rotate(-43.27deg)}90%{transform:rotate(15deg)}100%{transform:rotate(0deg)}}@keyframes loader-figure{0%{width:0;height:0;background-color:#3e5a7c}29%{background-color:#3e5a7c}30%{width:42px;height:42px;background-color:transparent;border-width:21px;opacity:1}100%{width:42px;height:42px;border-width:0;opacity:0;background-color:transparent}}@keyframes loader-label{0%{opacity:.25}30%{opacity:1}100%{opacity:.25}}.video-error-cover{align-items:center;background:#000;color:#fff;cursor:pointer;display:flex;flex-direction:column;height:100%;justify-content:center;margin-top:8px;position:relative;text-align:center;z-index:100}.media-spoiler{background:#000;color:#9baec8;border:0;padding:0;width:100%;height:100%;border-radius:4px;appearance:none}.media-spoiler:hover,.media-spoiler:active,.media-spoiler:focus{padding:0;color:#b5c3d6}.media-spoiler__warning{display:block;font-size:14px}.media-spoiler__trigger{display:block;font-size:11px;font-weight:700}.spoiler-button{top:0;left:0;width:100%;height:100%;position:absolute;z-index:100}.spoiler-button--minified{display:block;left:4px;top:4px;width:auto;height:auto}.spoiler-button--click-thru{pointer-events:none}.spoiler-button--hidden{display:none}.spoiler-button__overlay{display:block;background:transparent;width:100%;height:100%;border:0}.spoiler-button__overlay__label{display:inline-block;background:rgba(0,0,0,.5);border-radius:8px;padding:8px 12px;color:#fff;font-weight:500;font-size:14px}.spoiler-button__overlay:hover .spoiler-button__overlay__label,.spoiler-button__overlay:focus .spoiler-button__overlay__label,.spoiler-button__overlay:active .spoiler-button__overlay__label{background:rgba(0,0,0,.8)}.spoiler-button__overlay:disabled .spoiler-button__overlay__label{background:rgba(0,0,0,.5)}.modal-container--preloader{background:#202e3f}.account--panel{background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f;display:flex;flex-direction:row;padding:10px 0}.account--panel__button,.detailed-status__button{flex:1 1 auto;text-align:center}.column-settings__outer{background:#202e3f;padding:15px}.column-settings__section{color:#9baec8;cursor:default;display:block;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-settings__row{margin-bottom:15px}.column-settings__hashtags .column-select__control{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0}.column-settings__hashtags .column-select__control::placeholder{color:#a8b9cf}.column-settings__hashtags .column-select__control::-moz-focus-inner{border:0}.column-settings__hashtags .column-select__control::-moz-focus-inner,.column-settings__hashtags .column-select__control:focus,.column-settings__hashtags .column-select__control:active{outline:0 !important}.column-settings__hashtags .column-select__control:focus{background:#192432}@media screen and (max-width: 600px){.column-settings__hashtags .column-select__control{font-size:16px}}.column-settings__hashtags .column-select__placeholder{color:#404040;padding-left:2px;font-size:12px}.column-settings__hashtags .column-select__value-container{padding-left:6px}.column-settings__hashtags .column-select__multi-value{background:#202e3f}.column-settings__hashtags .column-select__multi-value__remove{cursor:pointer}.column-settings__hashtags .column-select__multi-value__remove:hover,.column-settings__hashtags .column-select__multi-value__remove:active,.column-settings__hashtags .column-select__multi-value__remove:focus{background:#26374d;color:#a8b9cf}.column-settings__hashtags .column-select__multi-value__label,.column-settings__hashtags .column-select__input{color:#9baec8}.column-settings__hashtags .column-select__clear-indicator,.column-settings__hashtags .column-select__dropdown-indicator{cursor:pointer;transition:none;color:#404040}.column-settings__hashtags .column-select__clear-indicator:hover,.column-settings__hashtags .column-select__clear-indicator:active,.column-settings__hashtags .column-select__clear-indicator:focus,.column-settings__hashtags .column-select__dropdown-indicator:hover,.column-settings__hashtags .column-select__dropdown-indicator:active,.column-settings__hashtags .column-select__dropdown-indicator:focus{color:#4a4a4a}.column-settings__hashtags .column-select__indicator-separator{background-color:#202e3f}.column-settings__hashtags .column-select__menu{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4);padding:0;background:#d9e1e8}.column-settings__hashtags .column-select__menu h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.column-settings__hashtags .column-select__menu li{padding:4px 0}.column-settings__hashtags .column-select__menu ul{margin-bottom:10px}.column-settings__hashtags .column-select__menu em{font-weight:500;color:#121a24}.column-settings__hashtags .column-select__menu-list{padding:6px}.column-settings__hashtags .column-select__option{color:#121a24;border-radius:4px;font-size:14px}.column-settings__hashtags .column-select__option--is-focused,.column-settings__hashtags .column-select__option--is-selected{background:#b9c8d5}.column-settings__row .text-btn{margin-bottom:15px}.relationship-tag{color:#fff;margin-bottom:4px;display:block;vertical-align:top;background-color:#000;text-transform:uppercase;font-size:11px;font-weight:500;padding:4px;border-radius:4px;opacity:.7}.relationship-tag:hover{opacity:1}.setting-toggle{display:block;line-height:24px}.setting-toggle__label{color:#9baec8;display:inline-block;margin-bottom:14px;margin-left:8px;vertical-align:middle}.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{color:#404040;background:#121a24;text-align:center;padding:20px;font-size:15px;font-weight:400;cursor:default;display:flex;flex:1 1 auto;align-items:center;justify-content:center}@supports(display: grid){.empty-column-indicator,.error-column,.follow_requests-unlocked_explanation{contain:strict}}.empty-column-indicator>span,.error-column>span,.follow_requests-unlocked_explanation>span{max-width:400px}.empty-column-indicator a,.error-column a,.follow_requests-unlocked_explanation a{color:#00007f;text-decoration:none}.empty-column-indicator a:hover,.error-column a:hover,.follow_requests-unlocked_explanation a:hover{text-decoration:underline}.follow_requests-unlocked_explanation{background:#0b1016;contain:initial}.error-column{flex-direction:column}@keyframes heartbeat{from{transform:scale(1);animation-timing-function:ease-out}10%{transform:scale(0.91);animation-timing-function:ease-in}17%{transform:scale(0.98);animation-timing-function:ease-out}33%{transform:scale(0.87);animation-timing-function:ease-in}45%{transform:scale(1);animation-timing-function:ease-out}}.no-reduce-motion .pulse-loading{transform-origin:center center;animation:heartbeat 1.5s ease-in-out infinite both}@keyframes shake-bottom{0%,100%{transform:rotate(0deg);transform-origin:50% 100%}10%{transform:rotate(2deg)}20%,40%,60%{transform:rotate(-4deg)}30%,50%,70%{transform:rotate(4deg)}80%{transform:rotate(-2deg)}90%{transform:rotate(2deg)}}.no-reduce-motion .shake-bottom{transform-origin:50% 100%;animation:shake-bottom .8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both}.emoji-picker-dropdown__menu{background:#fff;position:absolute;box-shadow:4px 4px 6px rgba(0,0,0,.4);border-radius:4px;margin-top:5px;z-index:2}.emoji-picker-dropdown__menu .emoji-mart-scroll{transition:opacity 200ms ease}.emoji-picker-dropdown__menu.selecting .emoji-mart-scroll{opacity:.5}.emoji-picker-dropdown__modifiers{position:absolute;top:60px;right:11px;cursor:pointer}.emoji-picker-dropdown__modifiers__menu{position:absolute;z-index:4;top:-4px;left:-8px;background:#fff;border-radius:4px;box-shadow:1px 2px 6px rgba(0,0,0,.2);overflow:hidden}.emoji-picker-dropdown__modifiers__menu button{display:block;cursor:pointer;border:0;padding:4px 8px;background:transparent}.emoji-picker-dropdown__modifiers__menu button:hover,.emoji-picker-dropdown__modifiers__menu button:focus,.emoji-picker-dropdown__modifiers__menu button:active{background:rgba(217,225,232,.4)}.emoji-picker-dropdown__modifiers__menu .emoji-mart-emoji{height:22px}.emoji-mart-emoji span{background-repeat:no-repeat}.upload-area{align-items:center;background:rgba(0,0,0,.8);display:flex;height:100%;justify-content:center;left:0;opacity:0;position:absolute;top:0;visibility:hidden;width:100%;z-index:2000}.upload-area *{pointer-events:none}.upload-area__drop{width:320px;height:160px;display:flex;box-sizing:border-box;position:relative;padding:8px}.upload-area__background{position:absolute;top:0;right:0;bottom:0;left:0;z-index:-1;border-radius:4px;background:#121a24;box-shadow:0 0 5px rgba(0,0,0,.2)}.upload-area__content{flex:1;display:flex;align-items:center;justify-content:center;color:#d9e1e8;font-size:18px;font-weight:500;border:2px dashed #404040;border-radius:4px}.upload-progress{padding:10px;color:#404040;overflow:hidden;display:flex}.upload-progress .fa{font-size:34px;margin-right:10px}.upload-progress span{font-size:12px;text-transform:uppercase;font-weight:500;display:block}.upload-progess__message{flex:1 1 auto}.upload-progress__backdrop{width:100%;height:6px;border-radius:6px;background:#404040;position:relative;margin-top:5px}.upload-progress__tracker{position:absolute;left:0;top:0;height:6px;background:#00007f;border-radius:6px}.emoji-button{display:block;padding:5px 5px 2px 2px;outline:0;cursor:pointer}.emoji-button:active,.emoji-button:focus{outline:0 !important}.emoji-button img{filter:grayscale(100%);opacity:.8;display:block;margin:0;width:22px;height:22px}.emoji-button:hover img,.emoji-button:active img,.emoji-button:focus img{opacity:1;filter:none}.dropdown--active .emoji-button img{opacity:1;filter:none}.privacy-dropdown__dropdown{position:absolute;background:#fff;box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:4px;margin-left:40px;overflow:hidden}.privacy-dropdown__dropdown.top{transform-origin:50% 100%}.privacy-dropdown__dropdown.bottom{transform-origin:50% 0}.privacy-dropdown__option{color:#121a24;padding:10px;cursor:pointer;display:flex}.privacy-dropdown__option:hover,.privacy-dropdown__option.active{background:#00007f;color:#fff;outline:0}.privacy-dropdown__option:hover .privacy-dropdown__option__content,.privacy-dropdown__option.active .privacy-dropdown__option__content{color:#fff}.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,.privacy-dropdown__option.active .privacy-dropdown__option__content strong{color:#fff}.privacy-dropdown__option.active:hover{background:#000093}.privacy-dropdown__option__icon{display:flex;align-items:center;justify-content:center;margin-right:10px}.privacy-dropdown__option__content{flex:1 1 auto;color:#404040}.privacy-dropdown__option__content strong{font-weight:500;display:block;color:#121a24}.privacy-dropdown__option__content strong:lang(ja){font-weight:700}.privacy-dropdown__option__content strong:lang(ko){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-CN){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-HK){font-weight:700}.privacy-dropdown__option__content strong:lang(zh-TW){font-weight:700}.privacy-dropdown.active .privacy-dropdown__value{background:#fff;border-radius:4px 4px 0 0;box-shadow:0 -4px 4px rgba(0,0,0,.1)}.privacy-dropdown.active .privacy-dropdown__value .icon-button{transition:none}.privacy-dropdown.active .privacy-dropdown__value.active{background:#00007f}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#fff}.privacy-dropdown.active.top .privacy-dropdown__value{border-radius:0 0 4px 4px}.privacy-dropdown.active .privacy-dropdown__dropdown{display:block;box-shadow:2px 4px 6px rgba(0,0,0,.1)}.search{position:relative}.search__input{outline:0;box-sizing:border-box;width:100%;border:0;box-shadow:none;font-family:inherit;background:#121a24;color:#9baec8;font-size:14px;margin:0;display:block;padding:15px;padding-right:30px;line-height:18px;font-size:16px}.search__input::placeholder{color:#a8b9cf}.search__input::-moz-focus-inner{border:0}.search__input::-moz-focus-inner,.search__input:focus,.search__input:active{outline:0 !important}.search__input:focus{background:#192432}@media screen and (max-width: 600px){.search__input{font-size:16px}}.search__icon::-moz-focus-inner{border:0}.search__icon::-moz-focus-inner,.search__icon:focus{outline:0 !important}.search__icon .fa{position:absolute;top:16px;right:10px;z-index:2;display:inline-block;opacity:0;transition:all 100ms linear;transition-property:transform,opacity;font-size:18px;width:18px;height:18px;color:#d9e1e8;cursor:default;pointer-events:none}.search__icon .fa.active{pointer-events:auto;opacity:.3}.search__icon .fa-search{transform:rotate(90deg)}.search__icon .fa-search.active{pointer-events:none;transform:rotate(0deg)}.search__icon .fa-times-circle{top:17px;transform:rotate(0deg);color:#404040;cursor:pointer}.search__icon .fa-times-circle.active{transform:rotate(90deg)}.search__icon .fa-times-circle:hover{color:#525252}.search-results__header{color:#404040;background:#151f2b;padding:15px;font-weight:500;font-size:16px;cursor:default}.search-results__header .fa{display:inline-block;margin-right:5px}.search-results__section{margin-bottom:5px}.search-results__section h5{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;padding:15px;font-weight:500;font-size:16px;color:#404040}.search-results__section h5 .fa{display:inline-block;margin-right:5px}.search-results__section .account:last-child,.search-results__section>div:last-child .status{border-bottom:0}.search-results__hashtag{display:block;padding:10px;color:#d9e1e8;text-decoration:none}.search-results__hashtag:hover,.search-results__hashtag:active,.search-results__hashtag:focus{color:#e6ebf0;text-decoration:underline}.search-results__info{padding:20px;color:#9baec8;text-align:center}.modal-root{position:relative;transition:opacity .3s linear;will-change:opacity;z-index:9999}.modal-root__overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7)}.modal-root__container{position:fixed;top:0;left:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;align-content:space-around;z-index:9999;pointer-events:none;user-select:none}.modal-root__modal{pointer-events:auto;display:flex;z-index:9999}.video-modal__container{max-width:100vw;max-height:100vh}.audio-modal__container{width:50vw}.media-modal{width:100%;height:100%;position:relative}.media-modal .extended-video-player{width:100%;height:100%;display:flex;align-items:center;justify-content:center}.media-modal .extended-video-player video{max-width:100%;max-height:80%}.media-modal__closer{position:absolute;top:0;left:0;right:0;bottom:0}.media-modal__navigation{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;transition:opacity .3s linear;will-change:opacity}.media-modal__navigation *{pointer-events:auto}.media-modal__navigation.media-modal__navigation--hidden{opacity:0}.media-modal__navigation.media-modal__navigation--hidden *{pointer-events:none}.media-modal__nav{background:rgba(0,0,0,.5);box-sizing:border-box;border:0;color:#fff;cursor:pointer;display:flex;align-items:center;font-size:24px;height:20vmax;margin:auto 0;padding:30px 15px;position:absolute;top:0;bottom:0}.media-modal__nav--left{left:0}.media-modal__nav--right{right:0}.media-modal__pagination{width:100%;text-align:center;position:absolute;left:0;bottom:20px;pointer-events:none}.media-modal__meta{text-align:center;position:absolute;left:0;bottom:20px;width:100%;pointer-events:none}.media-modal__meta--shifted{bottom:62px}.media-modal__meta a{pointer-events:auto;text-decoration:none;font-weight:500;color:#d9e1e8}.media-modal__meta a:hover,.media-modal__meta a:focus,.media-modal__meta a:active{text-decoration:underline}.media-modal__page-dot{display:inline-block}.media-modal__button{background-color:#fff;height:12px;width:12px;border-radius:6px;margin:10px;padding:0;border:0;font-size:0}.media-modal__button--active{background-color:#00007f}.media-modal__close{position:absolute;right:8px;top:8px;z-index:100}.onboarding-modal,.error-modal,.embed-modal{background:#d9e1e8;color:#121a24;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}.error-modal__body{height:80vh;width:80vw;max-width:520px;max-height:420px;position:relative}.error-modal__body>div{position:absolute;top:0;left:0;width:100%;height:100%;box-sizing:border-box;padding:25px;display:none;flex-direction:column;align-items:center;justify-content:center;display:flex;opacity:0;user-select:text}.error-modal__body{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.onboarding-modal__paginator,.error-modal__footer{flex:0 0 auto;background:#c0cdd9;display:flex;padding:25px}.onboarding-modal__paginator>div,.error-modal__footer>div{min-width:33px}.onboarding-modal__paginator .onboarding-modal__nav,.onboarding-modal__paginator .error-modal__nav,.error-modal__footer .onboarding-modal__nav,.error-modal__footer .error-modal__nav{color:#404040;border:0;font-size:14px;font-weight:500;padding:10px 25px;line-height:inherit;height:auto;margin:-10px;border-radius:4px;background-color:transparent}.onboarding-modal__paginator .onboarding-modal__nav:hover,.onboarding-modal__paginator .onboarding-modal__nav:focus,.onboarding-modal__paginator .onboarding-modal__nav:active,.onboarding-modal__paginator .error-modal__nav:hover,.onboarding-modal__paginator .error-modal__nav:focus,.onboarding-modal__paginator .error-modal__nav:active,.error-modal__footer .onboarding-modal__nav:hover,.error-modal__footer .onboarding-modal__nav:focus,.error-modal__footer .onboarding-modal__nav:active,.error-modal__footer .error-modal__nav:hover,.error-modal__footer .error-modal__nav:focus,.error-modal__footer .error-modal__nav:active{color:#363636;background-color:#a6b9c9}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next,.error-modal__footer .error-modal__nav.onboarding-modal__done,.error-modal__footer .error-modal__nav.onboarding-modal__next{color:#121a24}.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .onboarding-modal__nav.onboarding-modal__next:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__done:active,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:hover,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:focus,.onboarding-modal__paginator .error-modal__nav.onboarding-modal__next:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__done:active,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:hover,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:focus,.error-modal__footer .onboarding-modal__nav.onboarding-modal__next:active,.error-modal__footer .error-modal__nav.onboarding-modal__done:hover,.error-modal__footer .error-modal__nav.onboarding-modal__done:focus,.error-modal__footer .error-modal__nav.onboarding-modal__done:active,.error-modal__footer .error-modal__nav.onboarding-modal__next:hover,.error-modal__footer .error-modal__nav.onboarding-modal__next:focus,.error-modal__footer .error-modal__nav.onboarding-modal__next:active{color:#192432}.error-modal__footer{justify-content:center}.display-case{text-align:center;font-size:15px;margin-bottom:15px}.display-case__label{font-weight:500;color:#121a24;margin-bottom:5px;text-transform:uppercase;font-size:12px}.display-case__case{background:#121a24;color:#d9e1e8;font-weight:500;padding:10px;border-radius:4px}.onboard-sliders{display:inline-block;max-width:30px;max-height:auto;margin-left:10px}.boost-modal,.confirmation-modal,.report-modal,.actions-modal,.mute-modal,.block-modal{background:#f2f5f7;color:#121a24;border-radius:8px;overflow:hidden;max-width:90vw;width:480px;position:relative;flex-direction:column}.boost-modal .status__display-name,.confirmation-modal .status__display-name,.report-modal .status__display-name,.actions-modal .status__display-name,.mute-modal .status__display-name,.block-modal .status__display-name{display:block;max-width:100%;padding-right:25px}.boost-modal .status__avatar,.confirmation-modal .status__avatar,.report-modal .status__avatar,.actions-modal .status__avatar,.mute-modal .status__avatar,.block-modal .status__avatar{height:28px;left:10px;position:absolute;top:10px;width:48px}.boost-modal .status__content__spoiler-link,.confirmation-modal .status__content__spoiler-link,.report-modal .status__content__spoiler-link,.actions-modal .status__content__spoiler-link,.mute-modal .status__content__spoiler-link,.block-modal .status__content__spoiler-link{color:#f2f5f7}.actions-modal .status{background:#fff;border-bottom-color:#d9e1e8;padding-top:10px;padding-bottom:10px}.actions-modal .dropdown-menu__separator{border-bottom-color:#d9e1e8}.boost-modal__container{overflow-x:scroll;padding:10px}.boost-modal__container .status{user-select:text;border-bottom:0}.boost-modal__action-bar,.confirmation-modal__action-bar,.mute-modal__action-bar,.block-modal__action-bar{display:flex;justify-content:space-between;background:#d9e1e8;padding:10px;line-height:36px}.boost-modal__action-bar>div,.confirmation-modal__action-bar>div,.mute-modal__action-bar>div,.block-modal__action-bar>div{flex:1 1 auto;text-align:right;color:#404040;padding-right:10px}.boost-modal__action-bar .button,.confirmation-modal__action-bar .button,.mute-modal__action-bar .button,.block-modal__action-bar .button{flex:0 0 auto}.boost-modal__status-header{font-size:15px}.boost-modal__status-time{float:right;font-size:14px}.mute-modal,.block-modal{line-height:24px}.mute-modal .react-toggle,.block-modal .react-toggle{vertical-align:middle}.report-modal{width:90vw;max-width:700px}.report-modal__container{display:flex;border-top:1px solid #d9e1e8}@media screen and (max-width: 480px){.report-modal__container{flex-wrap:wrap;overflow-y:auto}}.report-modal__statuses,.report-modal__comment{box-sizing:border-box;width:50%}@media screen and (max-width: 480px){.report-modal__statuses,.report-modal__comment{width:100%}}.report-modal__statuses,.focal-point-modal__content{flex:1 1 auto;min-height:20vh;max-height:80vh;overflow-y:auto;overflow-x:hidden}.report-modal__statuses .status__content a,.focal-point-modal__content .status__content a{color:#00007f}.report-modal__statuses .status__content,.report-modal__statuses .status__content p,.focal-point-modal__content .status__content,.focal-point-modal__content .status__content p{color:#121a24}@media screen and (max-width: 480px){.report-modal__statuses,.focal-point-modal__content{max-height:10vh}}@media screen and (max-width: 480px){.focal-point-modal__content{max-height:40vh}}.report-modal__comment{padding:20px;border-right:1px solid #d9e1e8;max-width:320px}.report-modal__comment p{font-size:14px;line-height:20px;margin-bottom:20px}.report-modal__comment .setting-text{display:block;box-sizing:border-box;width:100%;margin:0;color:#121a24;background:#fff;padding:10px;font-family:inherit;font-size:14px;resize:none;border:0;outline:0;border-radius:4px;border:1px solid #d9e1e8;min-height:100px;max-height:50vh;margin-bottom:10px}.report-modal__comment .setting-text:focus{border:1px solid #c0cdd9}.report-modal__comment .setting-text__wrapper{background:#fff;border:1px solid #d9e1e8;margin-bottom:10px;border-radius:4px}.report-modal__comment .setting-text__wrapper .setting-text{border:0;margin-bottom:0;border-radius:0}.report-modal__comment .setting-text__wrapper .setting-text:focus{border:0}.report-modal__comment .setting-text__wrapper__modifiers{color:#121a24;font-family:inherit;font-size:14px;background:#fff}.report-modal__comment .setting-text__toolbar{display:flex;justify-content:space-between;margin-bottom:20px}.report-modal__comment .setting-text-label{display:block;color:#121a24;font-size:14px;font-weight:500;margin-bottom:10px}.report-modal__comment .setting-toggle{margin-top:20px;margin-bottom:24px}.report-modal__comment .setting-toggle__label{color:#121a24;font-size:14px}@media screen and (max-width: 480px){.report-modal__comment{padding:10px;max-width:100%;order:2}.report-modal__comment .setting-toggle{margin-bottom:4px}}.actions-modal{max-height:80vh;max-width:80vw}.actions-modal .status{overflow-y:auto;max-height:300px}.actions-modal .actions-modal__item-label{font-weight:500}.actions-modal ul{overflow-y:auto;flex-shrink:0;max-height:80vh}.actions-modal ul.with-status{max-height:calc(80vh - 75px)}.actions-modal ul li:empty{margin:0}.actions-modal ul li:not(:empty) a{color:#121a24;display:flex;padding:12px 16px;font-size:15px;align-items:center;text-decoration:none}.actions-modal ul li:not(:empty) a,.actions-modal ul li:not(:empty) a button{transition:none}.actions-modal ul li:not(:empty) a.active,.actions-modal ul li:not(:empty) a.active button,.actions-modal ul li:not(:empty) a:hover,.actions-modal ul li:not(:empty) a:hover button,.actions-modal ul li:not(:empty) a:active,.actions-modal ul li:not(:empty) a:active button,.actions-modal ul li:not(:empty) a:focus,.actions-modal ul li:not(:empty) a:focus button{background:#00007f;color:#fff}.actions-modal ul li:not(:empty) a button:first-child{margin-right:10px}.confirmation-modal__action-bar .confirmation-modal__secondary-button,.mute-modal__action-bar .confirmation-modal__secondary-button,.block-modal__action-bar .confirmation-modal__secondary-button{flex-shrink:1}.confirmation-modal__secondary-button,.confirmation-modal__cancel-button,.mute-modal__cancel-button,.block-modal__cancel-button{background-color:transparent;color:#404040;font-size:14px;font-weight:500}.confirmation-modal__secondary-button:hover,.confirmation-modal__secondary-button:focus,.confirmation-modal__secondary-button:active,.confirmation-modal__cancel-button:hover,.confirmation-modal__cancel-button:focus,.confirmation-modal__cancel-button:active,.mute-modal__cancel-button:hover,.mute-modal__cancel-button:focus,.mute-modal__cancel-button:active,.block-modal__cancel-button:hover,.block-modal__cancel-button:focus,.block-modal__cancel-button:active{color:#363636;background-color:transparent}.confirmation-modal__container,.mute-modal__container,.block-modal__container,.report-modal__target{padding:30px;font-size:16px}.confirmation-modal__container strong,.mute-modal__container strong,.block-modal__container strong,.report-modal__target strong{font-weight:500}.confirmation-modal__container strong:lang(ja),.mute-modal__container strong:lang(ja),.block-modal__container strong:lang(ja),.report-modal__target strong:lang(ja){font-weight:700}.confirmation-modal__container strong:lang(ko),.mute-modal__container strong:lang(ko),.block-modal__container strong:lang(ko),.report-modal__target strong:lang(ko){font-weight:700}.confirmation-modal__container strong:lang(zh-CN),.mute-modal__container strong:lang(zh-CN),.block-modal__container strong:lang(zh-CN),.report-modal__target strong:lang(zh-CN){font-weight:700}.confirmation-modal__container strong:lang(zh-HK),.mute-modal__container strong:lang(zh-HK),.block-modal__container strong:lang(zh-HK),.report-modal__target strong:lang(zh-HK){font-weight:700}.confirmation-modal__container strong:lang(zh-TW),.mute-modal__container strong:lang(zh-TW),.block-modal__container strong:lang(zh-TW),.report-modal__target strong:lang(zh-TW){font-weight:700}.confirmation-modal__container,.report-modal__target{text-align:center}.block-modal__explanation,.mute-modal__explanation{margin-top:20px}.block-modal .setting-toggle,.mute-modal .setting-toggle{margin-top:20px;margin-bottom:24px;display:flex;align-items:center}.block-modal .setting-toggle__label,.mute-modal .setting-toggle__label{color:#121a24;margin:0;margin-left:8px}.report-modal__target{padding:15px}.report-modal__target .media-modal__close{top:14px;right:15px}.loading-bar{background-color:#00007f;height:3px;position:absolute;top:0;left:0;z-index:9999}.media-gallery__gifv__label{display:block;position:absolute;color:#fff;background:rgba(0,0,0,.5);bottom:6px;left:6px;padding:2px 6px;border-radius:2px;font-size:11px;font-weight:600;z-index:1;pointer-events:none;opacity:.9;transition:opacity .1s ease;line-height:18px}.media-gallery__gifv:hover .media-gallery__gifv__label{opacity:1}.media-gallery__audio{margin-top:32px}.media-gallery__audio audio{width:100%}.attachment-list{display:flex;font-size:14px;border:1px solid #202e3f;border-radius:4px;margin-top:14px;overflow:hidden}.attachment-list__icon{flex:0 0 auto;color:#404040;padding:8px 18px;cursor:default;border-right:1px solid #202e3f;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:26px}.attachment-list__icon .fa{display:block}.attachment-list__list{list-style:none;padding:4px 0;padding-left:8px;display:flex;flex-direction:column;justify-content:center}.attachment-list__list li{display:block;padding:4px 0}.attachment-list__list a{text-decoration:none;color:#404040;font-weight:500}.attachment-list__list a:hover{text-decoration:underline}.attachment-list.compact{border:0;margin-top:4px}.attachment-list.compact .attachment-list__list{padding:0;display:block}.attachment-list.compact .fa{color:#404040}.media-gallery{box-sizing:border-box;margin-top:8px;overflow:hidden;border-radius:4px;position:relative;width:100%}.media-gallery__item{border:0;box-sizing:border-box;display:block;float:left;position:relative;border-radius:4px;overflow:hidden}.media-gallery__item.standalone .media-gallery__item-gifv-thumbnail{transform:none;top:0}.media-gallery__item-thumbnail{cursor:zoom-in;display:block;text-decoration:none;color:#d9e1e8;position:relative;z-index:1}.media-gallery__item-thumbnail,.media-gallery__item-thumbnail img{height:100%;width:100%}.media-gallery__item-thumbnail img{object-fit:cover}.media-gallery__preview{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:0;background:#000}.media-gallery__preview--hidden{display:none}.media-gallery__gifv{height:100%;overflow:hidden;position:relative;width:100%}.media-gallery__item-gifv-thumbnail{cursor:zoom-in;height:100%;object-fit:cover;position:relative;top:50%;transform:translateY(-50%);width:100%;z-index:1}.media-gallery__item-thumbnail-label{clip:rect(1px 1px 1px 1px);clip:rect(1px, 1px, 1px, 1px);overflow:hidden;position:absolute}.detailed .video-player__volume__current,.detailed .video-player__volume::before,.fullscreen .video-player__volume__current,.fullscreen .video-player__volume::before{bottom:27px}.detailed .video-player__volume__handle,.fullscreen .video-player__volume__handle{bottom:23px}.audio-player{box-sizing:border-box;position:relative;background:#040609;border-radius:4px;padding-bottom:44px;direction:ltr}.audio-player.editable{border-radius:0;height:100%}.audio-player__waveform{padding:15px 0;position:relative;overflow:hidden}.audio-player__waveform::before{content:\"\";display:block;position:absolute;border-top:1px solid #192432;width:100%;height:0;left:0;top:calc(50% + 1px)}.audio-player__progress-placeholder{background-color:rgba(0,0,168,.5)}.audio-player__wave-placeholder{background-color:#2d415a}.audio-player .video-player__controls{padding:0 15px;padding-top:10px;background:#040609;border-top:1px solid #192432;border-radius:0 0 4px 4px}.video-player{overflow:hidden;position:relative;background:#000;max-width:100%;border-radius:4px;box-sizing:border-box;direction:ltr}.video-player.editable{border-radius:0;height:100% !important}.video-player:focus{outline:0}.video-player video{max-width:100vw;max-height:80vh;z-index:1}.video-player.fullscreen{width:100% !important;height:100% !important;margin:0}.video-player.fullscreen video{max-width:100% !important;max-height:100% !important;width:100% !important;height:100% !important;outline:0}.video-player.inline video{object-fit:contain;position:relative;top:50%;transform:translateY(-50%)}.video-player__controls{position:absolute;z-index:2;bottom:0;left:0;right:0;box-sizing:border-box;background:linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0, rgba(0, 0, 0, 0.45) 60%, transparent);padding:0 15px;opacity:0;transition:opacity .1s ease}.video-player__controls.active{opacity:1}.video-player.inactive video,.video-player.inactive .video-player__controls{visibility:hidden}.video-player__spoiler{display:none;position:absolute;top:0;left:0;width:100%;height:100%;z-index:4;border:0;background:#000;color:#9baec8;transition:none;pointer-events:none}.video-player__spoiler.active{display:block;pointer-events:auto}.video-player__spoiler.active:hover,.video-player__spoiler.active:active,.video-player__spoiler.active:focus{color:#b2c1d5}.video-player__spoiler__title{display:block;font-size:14px}.video-player__spoiler__subtitle{display:block;font-size:11px;font-weight:500}.video-player__buttons-bar{display:flex;justify-content:space-between;padding-bottom:10px}.video-player__buttons-bar .video-player__download__icon{color:inherit}.video-player__buttons{font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.video-player__buttons.left button{padding-left:0}.video-player__buttons.right button{padding-right:0}.video-player__buttons button{background:transparent;padding:2px 10px;font-size:16px;border:0;color:rgba(255,255,255,.75)}.video-player__buttons button:active,.video-player__buttons button:hover,.video-player__buttons button:focus{color:#fff}.video-player__time-sep,.video-player__time-total,.video-player__time-current{font-size:14px;font-weight:500}.video-player__time-current{color:#fff;margin-left:60px}.video-player__time-sep{display:inline-block;margin:0 6px}.video-player__time-sep,.video-player__time-total{color:#fff}.video-player__volume{cursor:pointer;height:24px;display:inline}.video-player__volume::before{content:\"\";width:50px;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;left:70px;bottom:20px}.video-player__volume__current{display:block;position:absolute;height:4px;border-radius:4px;left:70px;bottom:20px;background:#0000a8}.video-player__volume__handle{position:absolute;z-index:3;border-radius:50%;width:12px;height:12px;bottom:16px;left:70px;transition:opacity .1s ease;background:#0000a8;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__link{padding:2px 10px}.video-player__link a{text-decoration:none;font-size:14px;font-weight:500;color:#fff}.video-player__link a:hover,.video-player__link a:active,.video-player__link a:focus{text-decoration:underline}.video-player__seek{cursor:pointer;height:24px;position:relative}.video-player__seek::before{content:\"\";width:100%;background:rgba(255,255,255,.35);border-radius:4px;display:block;position:absolute;height:4px;top:10px}.video-player__seek__progress,.video-player__seek__buffer{display:block;position:absolute;height:4px;border-radius:4px;top:10px;background:#0000a8}.video-player__seek__buffer{background:rgba(255,255,255,.2)}.video-player__seek__handle{position:absolute;z-index:3;opacity:0;border-radius:50%;width:12px;height:12px;top:6px;margin-left:-6px;transition:opacity .1s ease;background:#0000a8;box-shadow:1px 2px 6px rgba(0,0,0,.2);pointer-events:none}.video-player__seek__handle.active{opacity:1}.video-player__seek:hover .video-player__seek__handle{opacity:1}.video-player.detailed .video-player__buttons button,.video-player.fullscreen .video-player__buttons button{padding-top:10px;padding-bottom:10px}.directory__list{width:100%;margin:10px 0;transition:opacity 100ms ease-in}.directory__list.loading{opacity:.7}@media screen and (max-width: 415px){.directory__list{margin:0}}.directory__card{box-sizing:border-box;margin-bottom:10px}.directory__card__img{height:125px;position:relative;background:#000;overflow:hidden}.directory__card__img img{display:block;width:100%;height:100%;margin:0;object-fit:cover}.directory__card__bar{display:flex;align-items:center;background:#192432;padding:10px}.directory__card__bar__name{flex:1 1 auto;display:flex;align-items:center;text-decoration:none;overflow:hidden}.directory__card__bar__relationship{width:23px;min-height:1px;flex:0 0 auto}.directory__card__bar .avatar{flex:0 0 auto;width:48px;height:48px;padding-top:2px}.directory__card__bar .avatar img{width:100%;height:100%;display:block;margin:0;border-radius:4px;background:#040609;object-fit:cover}.directory__card__bar .display-name{margin-left:15px;text-align:left}.directory__card__bar .display-name strong{font-size:15px;color:#fff;font-weight:500;overflow:hidden;text-overflow:ellipsis}.directory__card__bar .display-name span{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.directory__card__extra{background:#121a24;display:flex;align-items:center;justify-content:center}.directory__card__extra .accounts-table__count{width:33.33%;flex:0 0 auto;padding:15px 0}.directory__card__extra .account__header__content{box-sizing:border-box;padding:15px 10px;border-bottom:1px solid #202e3f;width:100%;min-height:48px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.directory__card__extra .account__header__content p{display:none}.directory__card__extra .account__header__content p:first-child{display:inline}.directory__card__extra .account__header__content br{display:none}.account-gallery__container{display:flex;flex-wrap:wrap;padding:4px 2px}.account-gallery__item{border:0;box-sizing:border-box;display:block;position:relative;border-radius:4px;overflow:hidden;margin:2px}.account-gallery__item__icons{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:24px}.notification__filter-bar,.account__section-headline{background:#0b1016;border-bottom:1px solid #202e3f;cursor:default;display:flex;flex-shrink:0}.notification__filter-bar button,.account__section-headline button{background:#0b1016;border:0;margin:0}.notification__filter-bar button,.notification__filter-bar a,.account__section-headline button,.account__section-headline a{display:block;flex:1 1 auto;color:#9baec8;padding:15px 0;font-size:14px;font-weight:500;text-align:center;text-decoration:none;position:relative;width:100%;white-space:nowrap}.notification__filter-bar button.active,.notification__filter-bar a.active,.account__section-headline button.active,.account__section-headline a.active{color:#d9e1e8}.notification__filter-bar button.active::before,.notification__filter-bar button.active::after,.notification__filter-bar a.active::before,.notification__filter-bar a.active::after,.account__section-headline button.active::before,.account__section-headline button.active::after,.account__section-headline a.active::before,.account__section-headline a.active::after{display:block;content:\"\";position:absolute;bottom:0;left:50%;width:0;height:0;transform:translateX(-50%);border-style:solid;border-width:0 10px 10px;border-color:transparent transparent #202e3f}.notification__filter-bar button.active::after,.notification__filter-bar a.active::after,.account__section-headline button.active::after,.account__section-headline a.active::after{bottom:-1px;border-color:transparent transparent #121a24}.notification__filter-bar.directory__section-headline,.account__section-headline.directory__section-headline{background:#0f151d;border-bottom-color:transparent}.notification__filter-bar.directory__section-headline a.active::before,.notification__filter-bar.directory__section-headline button.active::before,.account__section-headline.directory__section-headline a.active::before,.account__section-headline.directory__section-headline button.active::before{display:none}.notification__filter-bar.directory__section-headline a.active::after,.notification__filter-bar.directory__section-headline button.active::after,.account__section-headline.directory__section-headline a.active::after,.account__section-headline.directory__section-headline button.active::after{border-color:transparent transparent #06090c}.filter-form{background:#121a24}.filter-form__column{padding:10px 15px}.filter-form .radio-button{display:block}.radio-button{font-size:14px;position:relative;display:inline-block;padding:6px 0;line-height:18px;cursor:default;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.radio-button input[type=radio],.radio-button input[type=checkbox]{display:none}.radio-button__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle}.radio-button__input.checked{border-color:#0000a8;background:#0000a8}::-webkit-scrollbar-thumb{border-radius:0}.search-popout{background:#fff;border-radius:4px;padding:10px 14px;padding-bottom:14px;margin-top:10px;color:#9baec8;box-shadow:2px 4px 15px rgba(0,0,0,.4)}.search-popout h4{text-transform:uppercase;color:#9baec8;font-size:13px;font-weight:500;margin-bottom:10px}.search-popout li{padding:4px 0}.search-popout ul{margin-bottom:10px}.search-popout em{font-weight:500;color:#121a24}noscript{text-align:center}noscript img{width:200px;opacity:.5;animation:flicker 4s infinite}noscript div{font-size:14px;margin:30px auto;color:#d9e1e8;max-width:400px}noscript div a{color:#00007f;text-decoration:underline}noscript div a:hover{text-decoration:none}@keyframes flicker{0%{opacity:1}30%{opacity:.75}100%{opacity:1}}@media screen and (max-width: 630px)and (max-height: 400px){.tabs-bar,.search{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar{will-change:padding-bottom;transition:padding-bottom 400ms 100ms}.navigation-bar>a:first-child{will-change:margin-top,margin-left,margin-right,width;transition:margin-top 400ms 100ms,margin-left 400ms 500ms,margin-right 400ms 500ms}.navigation-bar>.navigation-bar__profile-edit{will-change:margin-top;transition:margin-top 400ms 100ms}.navigation-bar .navigation-bar__actions>.icon-button.close{will-change:opacity transform;transition:opacity 200ms 100ms,transform 400ms 100ms}.navigation-bar .navigation-bar__actions>.compose__action-bar .icon-button{will-change:opacity transform;transition:opacity 200ms 300ms,transform 400ms 100ms}.is-composing .tabs-bar,.is-composing .search{margin-top:-50px}.is-composing .navigation-bar{padding-bottom:0}.is-composing .navigation-bar>a:first-child{margin:-100px 10px 0 -50px}.is-composing .navigation-bar .navigation-bar__profile{padding-top:2px}.is-composing .navigation-bar .navigation-bar__profile-edit{position:absolute;margin-top:-60px}.is-composing .navigation-bar .navigation-bar__actions .icon-button.close{pointer-events:auto;opacity:1;transform:scale(1, 1) translate(0, 0);bottom:5px}.is-composing .navigation-bar .navigation-bar__actions .compose__action-bar .icon-button{pointer-events:none;opacity:0;transform:scale(0, 1) translate(100%, 0)}}.embed-modal{width:auto;max-width:80vw;max-height:80vh}.embed-modal h4{padding:30px;font-weight:500;font-size:16px;text-align:center}.embed-modal .embed-modal__container{padding:10px}.embed-modal .embed-modal__container .hint{margin-bottom:15px}.embed-modal .embed-modal__container .embed-modal__html{outline:0;box-sizing:border-box;display:block;width:100%;border:0;padding:10px;font-family:\"mastodon-font-monospace\",monospace;background:#121a24;color:#fff;font-size:14px;margin:0;margin-bottom:15px;border-radius:4px}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner{border:0}.embed-modal .embed-modal__container .embed-modal__html::-moz-focus-inner,.embed-modal .embed-modal__container .embed-modal__html:focus,.embed-modal .embed-modal__container .embed-modal__html:active{outline:0 !important}.embed-modal .embed-modal__container .embed-modal__html:focus{background:#192432}@media screen and (max-width: 600px){.embed-modal .embed-modal__container .embed-modal__html{font-size:16px}}.embed-modal .embed-modal__container .embed-modal__iframe{width:400px;max-width:100%;overflow:hidden;border:0;border-radius:4px}.account__moved-note{padding:14px 10px;padding-bottom:16px;background:#192432;border-top:1px solid #202e3f;border-bottom:1px solid #202e3f}.account__moved-note__message{position:relative;margin-left:58px;color:#404040;padding:8px 0;padding-top:0;padding-bottom:4px;font-size:14px}.account__moved-note__message>span{display:block;overflow:hidden;text-overflow:ellipsis}.account__moved-note__icon-wrapper{left:-26px;position:absolute}.account__moved-note .detailed-status__display-avatar{position:relative}.account__moved-note .detailed-status__display-name{margin-bottom:0}.column-inline-form{padding:15px;padding-right:0;display:flex;justify-content:flex-start;align-items:center;background:#192432}.column-inline-form label{flex:1 1 auto}.column-inline-form label input{width:100%}.column-inline-form label input:focus{outline:0}.column-inline-form .icon-button{flex:0 0 auto;margin:0 10px}.drawer__backdrop{cursor:pointer;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5)}.list-editor{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-editor{width:90%}}.list-editor h4{padding:15px 0;background:#283a50;font-weight:500;font-size:16px;text-align:center;border-radius:8px 8px 0 0}.list-editor .drawer__pager{height:50vh}.list-editor .drawer__inner{border-radius:0 0 8px 8px}.list-editor .drawer__inner.backdrop{width:calc(100% - 60px);box-shadow:2px 4px 15px rgba(0,0,0,.4);border-radius:0 0 0 8px}.list-editor__accounts{overflow-y:auto}.list-editor .account__display-name:hover strong{text-decoration:none}.list-editor .account__avatar{cursor:default}.list-editor .search{margin-bottom:0}.list-adder{background:#121a24;flex-direction:column;border-radius:8px;box-shadow:2px 4px 15px rgba(0,0,0,.4);width:380px;overflow:hidden}@media screen and (max-width: 420px){.list-adder{width:90%}}.list-adder__account{background:#283a50}.list-adder__lists{background:#283a50;height:50vh;border-radius:0 0 8px 8px;overflow-y:auto}.list-adder .list{padding:10px;border-bottom:1px solid #202e3f}.list-adder .list__wrapper{display:flex}.list-adder .list__display-name{flex:1 1 auto;overflow:hidden;text-decoration:none;font-size:16px;padding:10px}.focal-point{position:relative;cursor:move;overflow:hidden;height:100%;display:flex;justify-content:center;align-items:center;background:#000}.focal-point img,.focal-point video,.focal-point canvas{display:block;max-height:80vh;width:100%;height:auto;margin:0;object-fit:contain;background:#000}.focal-point__reticle{position:absolute;width:100px;height:100px;transform:translate(-50%, -50%);background:url(\"~images/reticle.png\") no-repeat 0 0;border-radius:50%;box-shadow:0 0 0 9999em rgba(0,0,0,.35)}.focal-point__overlay{position:absolute;width:100%;height:100%;top:0;left:0}.focal-point__preview{position:absolute;bottom:10px;right:10px;z-index:2;cursor:move;transition:opacity .1s ease}.focal-point__preview:hover{opacity:.5}.focal-point__preview strong{color:#fff;font-size:14px;font-weight:500;display:block;margin-bottom:5px}.focal-point__preview div{border-radius:4px;box-shadow:0 0 14px rgba(0,0,0,.2)}@media screen and (max-width: 480px){.focal-point img,.focal-point video{max-height:100%}.focal-point__preview{display:none}}.account__header__content{color:#9baec8;font-size:14px;font-weight:400;overflow:hidden;word-break:normal;word-wrap:break-word}.account__header__content p{margin-bottom:20px}.account__header__content p:last-child{margin-bottom:0}.account__header__content a{color:inherit;text-decoration:underline}.account__header__content a:hover{text-decoration:none}.account__header{overflow:hidden}.account__header.inactive{opacity:.5}.account__header.inactive .account__header__image,.account__header.inactive .account__avatar{filter:grayscale(100%)}.account__header__info{position:absolute;top:10px;left:10px}.account__header__image{overflow:hidden;height:145px;position:relative;background:#0b1016}.account__header__image img{object-fit:cover;display:block;width:100%;height:100%;margin:0}.account__header__bar{position:relative;background:#192432;padding:5px;border-bottom:1px solid #26374d}.account__header__bar .avatar{display:block;flex:0 0 auto;width:94px;margin-left:-2px}.account__header__bar .avatar .account__avatar{background:#040609;border:2px solid #192432}.account__header__tabs{display:flex;align-items:flex-start;padding:7px 5px;margin-top:-55px}.account__header__tabs__buttons{display:flex;align-items:center;padding-top:55px;overflow:hidden}.account__header__tabs__buttons .icon-button{border:1px solid #26374d;border-radius:4px;box-sizing:content-box;padding:2px}.account__header__tabs__buttons .button{margin:0 8px}.account__header__tabs__name{padding:5px}.account__header__tabs__name .account-role{vertical-align:top}.account__header__tabs__name .emojione{width:22px;height:22px}.account__header__tabs__name h1{font-size:16px;line-height:24px;color:#fff;font-weight:500;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.account__header__tabs__name h1 small{display:block;font-size:14px;color:#9baec8;font-weight:400;overflow:hidden;text-overflow:ellipsis}.account__header__tabs .spacer{flex:1 1 auto}.account__header__bio{overflow:hidden;margin:0 -5px}.account__header__bio .account__header__content{padding:20px 15px;padding-bottom:5px;color:#fff}.account__header__bio .account__header__fields{margin:0;border-top:1px solid #26374d}.account__header__bio .account__header__fields a{color:#0000a8}.account__header__bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.account__header__bio .account__header__fields .verified a{color:#79bd9a}.account__header__extra{margin-top:4px}.account__header__extra__links{font-size:14px;color:#9baec8;padding:10px 0}.account__header__extra__links a{display:inline-block;color:#9baec8;text-decoration:none;padding:5px 10px;font-weight:500}.account__header__extra__links a strong{font-weight:700;color:#fff}.trends__header{color:#404040;background:#151f2b;border-bottom:1px solid #0b1016;font-weight:500;padding:15px;font-size:16px;cursor:default}.trends__header .fa{display:inline-block;margin-right:5px}.trends__item{display:flex;align-items:center;padding:15px;border-bottom:1px solid #202e3f}.trends__item:last-child{border-bottom:0}.trends__item__name{flex:1 1 auto;color:#404040;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name strong{font-weight:500}.trends__item__name a{color:#9baec8;text-decoration:none;font-size:14px;font-weight:500;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trends__item__name a:hover span,.trends__item__name a:focus span,.trends__item__name a:active span{text-decoration:underline}.trends__item__current{flex:0 0 auto;font-size:24px;line-height:36px;font-weight:500;text-align:right;padding-right:15px;margin-left:5px;color:#d9e1e8}.trends__item__sparkline{flex:0 0 auto;width:50px}.trends__item__sparkline path:first-child{fill:rgba(0,0,127,.25) !important;fill-opacity:1 !important}.trends__item__sparkline path:last-child{stroke:#00009e !important}.conversation{display:flex;border-bottom:1px solid #202e3f;padding:5px;padding-bottom:0}.conversation:focus{background:#151f2b;outline:0}.conversation__avatar{flex:0 0 auto;padding:10px;padding-top:12px;position:relative;cursor:pointer}.conversation__unread{display:inline-block;background:#00007f;border-radius:50%;width:.625rem;height:.625rem;margin:-0.1ex .15em .1ex}.conversation__content{flex:1 1 auto;padding:10px 5px;padding-right:15px;overflow:hidden}.conversation__content__info{overflow:hidden;display:flex;flex-direction:row-reverse;justify-content:space-between}.conversation__content__relative-time{font-size:15px;color:#9baec8;padding-left:15px}.conversation__content__names{color:#9baec8;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;flex-basis:90px;flex-grow:1}.conversation__content__names a{color:#fff;text-decoration:none}.conversation__content__names a:hover,.conversation__content__names a:focus,.conversation__content__names a:active{text-decoration:underline}.conversation__content a{word-break:break-word}.conversation--unread{background:#151f2b}.conversation--unread:focus{background:#192432}.conversation--unread .conversation__content__info{font-weight:700}.conversation--unread .conversation__content__relative-time{color:#fff}.announcements{background:#202e3f;font-size:13px;display:flex;align-items:flex-end}.announcements__mastodon{width:124px;flex:0 0 auto}@media screen and (max-width: 424px){.announcements__mastodon{display:none}}.announcements__container{width:calc(100% - 124px);flex:0 0 auto;position:relative}@media screen and (max-width: 424px){.announcements__container{width:100%}}.announcements__item{box-sizing:border-box;width:100%;padding:15px;position:relative;font-size:15px;line-height:20px;word-wrap:break-word;font-weight:400;max-height:50vh;overflow:hidden;display:flex;flex-direction:column}.announcements__item__range{display:block;font-weight:500;margin-bottom:10px;padding-right:18px}.announcements__item__unread{position:absolute;top:19px;right:19px;display:block;background:#00007f;border-radius:50%;width:.625rem;height:.625rem}.announcements__pagination{padding:15px;color:#9baec8;position:absolute;bottom:3px;right:0}.layout-multiple-columns .announcements__mastodon{display:none}.layout-multiple-columns .announcements__container{width:100%}.reactions-bar{display:flex;flex-wrap:wrap;align-items:center;margin-top:15px;margin-left:-2px;width:calc(100% - (90px - 33px))}.reactions-bar__item{flex-shrink:0;background:#26374d;border:0;border-radius:3px;margin:2px;cursor:pointer;user-select:none;padding:0 6px;display:flex;align-items:center;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar__item__emoji{display:block;margin:3px 0;width:16px;height:16px}.reactions-bar__item__emoji img{display:block;margin:0;width:100%;height:100%;min-width:auto;min-height:auto;vertical-align:bottom;object-fit:contain}.reactions-bar__item__count{display:block;min-width:9px;font-size:13px;font-weight:500;text-align:center;margin-left:6px;color:#9baec8}.reactions-bar__item:hover,.reactions-bar__item:focus,.reactions-bar__item:active{background:#2d415a;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar__item:hover__count,.reactions-bar__item:focus__count,.reactions-bar__item:active__count{color:#a8b9cf}.reactions-bar__item.active{transition:all 100ms ease-in;transition-property:background-color,color;background-color:#1e2c57}.reactions-bar__item.active .reactions-bar__item__count{color:#0000a8}.reactions-bar .emoji-picker-dropdown{margin:2px}.reactions-bar:hover .emoji-button{opacity:.85}.reactions-bar .emoji-button{color:#9baec8;margin:0;font-size:16px;width:auto;flex-shrink:0;padding:0 6px;height:22px;display:flex;align-items:center;opacity:.5;transition:all 100ms ease-in;transition-property:background-color,color}.reactions-bar .emoji-button:hover,.reactions-bar .emoji-button:active,.reactions-bar .emoji-button:focus{opacity:1;color:#a8b9cf;transition:all 200ms ease-out;transition-property:background-color,color}.reactions-bar--empty .emoji-button{padding:0}.poll{margin-top:16px;font-size:14px}.poll li{margin-bottom:10px;position:relative}.poll__chart{border-radius:4px;display:block;background:#8ba1bf;height:5px;min-width:1%}.poll__chart.leading{background:#00007f}.poll__option{position:relative;display:flex;padding:6px 0;line-height:18px;cursor:default;overflow:hidden}.poll__option__text{display:inline-block;word-wrap:break-word;overflow-wrap:break-word;max-width:calc(100% - 45px - 25px)}.poll__option input[type=radio],.poll__option input[type=checkbox]{display:none}.poll__option .autossugest-input{flex:1 1 auto}.poll__option input[type=text]{display:block;box-sizing:border-box;width:100%;font-size:14px;color:#121a24;outline:0;font-family:inherit;background:#fff;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px}.poll__option input[type=text]:focus{border-color:#00007f}.poll__option.selectable{cursor:pointer}.poll__option.editable{display:flex;align-items:center;overflow:visible}.poll__input{display:inline-block;position:relative;border:1px solid #9baec8;box-sizing:border-box;width:18px;height:18px;flex:0 0 auto;margin-right:10px;top:-1px;border-radius:50%;vertical-align:middle;margin-top:auto;margin-bottom:auto;flex:0 0 18px}.poll__input.checkbox{border-radius:4px}.poll__input.active{border-color:#79bd9a;background:#79bd9a}.poll__input:active,.poll__input:focus,.poll__input:hover{border-color:#acd6c1;border-width:4px}.poll__input::-moz-focus-inner{outline:0 !important;border:0}.poll__input:focus,.poll__input:active{outline:0 !important}.poll__number{display:inline-block;width:45px;font-weight:700;flex:0 0 45px}.poll__voted{padding:0 5px;display:inline-block}.poll__voted__mark{font-size:18px}.poll__footer{padding-top:6px;padding-bottom:5px;color:#404040}.poll__link{display:inline;background:transparent;padding:0;margin:0;border:0;color:#404040;text-decoration:underline;font-size:inherit}.poll__link:hover{text-decoration:none}.poll__link:active,.poll__link:focus{background-color:rgba(64,64,64,.1)}.poll .button{height:36px;padding:0 16px;margin-right:10px;font-size:14px}.compose-form__poll-wrapper{border-top:1px solid #ebebeb}.compose-form__poll-wrapper ul{padding:10px}.compose-form__poll-wrapper .poll__footer{border-top:1px solid #ebebeb;padding:10px;display:flex;align-items:center}.compose-form__poll-wrapper .poll__footer button,.compose-form__poll-wrapper .poll__footer select{flex:1 1 50%}.compose-form__poll-wrapper .poll__footer button:focus,.compose-form__poll-wrapper .poll__footer select:focus{border-color:#00007f}.compose-form__poll-wrapper .button.button-secondary{font-size:14px;font-weight:400;padding:6px 10px;height:auto;line-height:inherit;color:#404040;border-color:#404040;margin-right:5px}.compose-form__poll-wrapper li{display:flex;align-items:center}.compose-form__poll-wrapper li .poll__option{flex:0 0 auto;width:calc(100% - (23px + 6px));margin-right:6px}.compose-form__poll-wrapper select{appearance:none;box-sizing:border-box;font-size:14px;color:#121a24;display:inline-block;width:auto;outline:0;font-family:inherit;background:#fff url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center/auto 16px;border:1px solid #dbdbdb;border-radius:4px;padding:6px 10px;padding-right:30px}.compose-form__poll-wrapper .icon-button.disabled{color:#dbdbdb}.muted .poll{color:#404040}.muted .poll__chart{background:rgba(109,137,175,.2)}.muted .poll__chart.leading{background:rgba(0,0,127,.2)}.modal-layout{background:#121a24 url('data:image/svg+xml;utf8,') repeat-x bottom fixed;display:flex;flex-direction:column;height:100vh;padding:0}.modal-layout__mastodon{display:flex;flex:1;flex-direction:column;justify-content:flex-end}.modal-layout__mastodon>*{flex:1;max-height:235px}@media screen and (max-width: 600px){.account-header{margin-top:0}}.emoji-mart{font-size:13px;display:inline-block;color:#121a24}.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #c0cdd9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px;background:#d9e1e8}.emoji-mart-bar:last-child{border-top-width:1px;border-bottom-left-radius:5px;border-bottom-right-radius:5px;display:none}.emoji-mart-anchors{display:flex;justify-content:space-between;padding:0 6px;color:#404040;line-height:0}.emoji-mart-anchor{position:relative;flex:1;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;cursor:pointer}.emoji-mart-anchor:hover{color:#363636}.emoji-mart-anchor-selected{color:#00007f}.emoji-mart-anchor-selected:hover{color:#00006b}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:-1px}.emoji-mart-anchor-bar{position:absolute;bottom:-5px;left:0;width:100%;height:4px;background-color:#00007f}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors svg{fill:currentColor;max-height:18px}.emoji-mart-scroll{overflow-y:scroll;height:270px;max-height:35vh;padding:0 6px 6px;background:#fff;will-change:transform}.emoji-mart-scroll::-webkit-scrollbar-track:hover,.emoji-mart-scroll::-webkit-scrollbar-track:active{background-color:rgba(0,0,0,.3)}.emoji-mart-search{padding:10px;padding-right:45px;background:#fff}.emoji-mart-search input{font-size:14px;font-weight:400;padding:7px 9px;font-family:inherit;display:block;width:100%;background:rgba(217,225,232,.3);color:#121a24;border:1px solid #d9e1e8;border-radius:4px}.emoji-mart-search input::-moz-focus-inner{border:0}.emoji-mart-search input::-moz-focus-inner,.emoji-mart-search input:focus,.emoji-mart-search input:active{outline:0 !important}.emoji-mart-category .emoji-mart-emoji{cursor:pointer}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center}.emoji-mart-category .emoji-mart-emoji:hover::before{z-index:0;content:\"\";position:absolute;top:0;left:0;width:100%;height:100%;background-color:rgba(217,225,232,.7);border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background:#fff}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0}.emoji-mart-emoji span{width:22px;height:22px}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#9baec8}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover::before{content:none}.emoji-mart-preview{display:none}.container{box-sizing:border-box;max-width:1235px;margin:0 auto;position:relative}@media screen and (max-width: 1255px){.container{width:100%;padding:0 10px}}.rich-formatting{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:14px;font-weight:400;line-height:1.7;word-wrap:break-word;color:#9baec8}.rich-formatting a{color:#00007f;text-decoration:underline}.rich-formatting a:hover,.rich-formatting a:focus,.rich-formatting a:active{text-decoration:none}.rich-formatting p,.rich-formatting li{color:#9baec8}.rich-formatting p{margin-top:0;margin-bottom:.85em}.rich-formatting p:last-child{margin-bottom:0}.rich-formatting strong{font-weight:700;color:#d9e1e8}.rich-formatting em{font-style:italic;color:#d9e1e8}.rich-formatting code{font-size:.85em;background:#040609;border-radius:4px;padding:.2em .3em}.rich-formatting h1,.rich-formatting h2,.rich-formatting h3,.rich-formatting h4,.rich-formatting h5,.rich-formatting h6{font-family:\"mastodon-font-display\",sans-serif;margin-top:1.275em;margin-bottom:.85em;font-weight:500;color:#d9e1e8}.rich-formatting h1{font-size:2em}.rich-formatting h2{font-size:1.75em}.rich-formatting h3{font-size:1.5em}.rich-formatting h4{font-size:1.25em}.rich-formatting h5,.rich-formatting h6{font-size:1em}.rich-formatting ul{list-style:disc}.rich-formatting ol{list-style:decimal}.rich-formatting ul,.rich-formatting ol{margin:0;padding:0;padding-left:2em;margin-bottom:.85em}.rich-formatting ul[type=a],.rich-formatting ol[type=a]{list-style-type:lower-alpha}.rich-formatting ul[type=i],.rich-formatting ol[type=i]{list-style-type:lower-roman}.rich-formatting hr{width:100%;height:0;border:0;border-bottom:1px solid #192432;margin:1.7em 0}.rich-formatting hr.spacer{height:1px;border:0}.rich-formatting table{width:100%;border-collapse:collapse;break-inside:auto;margin-top:24px;margin-bottom:32px}.rich-formatting table thead tr,.rich-formatting table tbody tr{border-bottom:1px solid #192432;font-size:1em;line-height:1.625;font-weight:400;text-align:left;color:#9baec8}.rich-formatting table thead tr{border-bottom-width:2px;line-height:1.5;font-weight:500;color:#404040}.rich-formatting table th,.rich-formatting table td{padding:8px;align-self:start;align-items:start;word-break:break-all}.rich-formatting table th.nowrap,.rich-formatting table td.nowrap{width:25%;position:relative}.rich-formatting table th.nowrap::before,.rich-formatting table td.nowrap::before{content:\" \";visibility:hidden}.rich-formatting table th.nowrap span,.rich-formatting table td.nowrap span{position:absolute;left:8px;right:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rich-formatting>:first-child{margin-top:0}.information-board{background:#0b1016;padding:20px 0}.information-board .container-alt{position:relative;padding-right:295px}.information-board__sections{display:flex;justify-content:space-between;flex-wrap:wrap}.information-board__section{flex:1 0 0;font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;line-height:28px;color:#fff;text-align:right;padding:10px 15px}.information-board__section span,.information-board__section strong{display:block}.information-board__section span:last-child{color:#d9e1e8}.information-board__section strong{font-family:\"mastodon-font-display\",sans-serif;font-weight:500;font-size:32px;line-height:48px}@media screen and (max-width: 700px){.information-board__section{text-align:center}}.information-board .panel{position:absolute;width:280px;box-sizing:border-box;background:#040609;padding:20px;padding-top:10px;border-radius:4px 4px 0 0;right:0;bottom:-40px}.information-board .panel .panel-header{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;color:#9baec8;padding-bottom:5px;margin-bottom:15px;border-bottom:1px solid #192432;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.information-board .panel .panel-header a,.information-board .panel .panel-header span{font-weight:400;color:#7a93b6}.information-board .panel .panel-header a{text-decoration:none}.information-board .owner{text-align:center}.information-board .owner .avatar{width:80px;height:80px;margin:0 auto;margin-bottom:15px}.information-board .owner .avatar img{display:block;width:80px;height:80px;border-radius:48px}.information-board .owner .name{font-size:14px}.information-board .owner .name a{display:block;color:#fff;text-decoration:none}.information-board .owner .name a:hover .display_name{text-decoration:underline}.information-board .owner .name .username{display:block;color:#9baec8}.landing-page p,.landing-page li{font-family:\"mastodon-font-sans-serif\",sans-serif;font-size:16px;font-weight:400;font-size:16px;line-height:30px;margin-bottom:12px;color:#9baec8}.landing-page p a,.landing-page li a{color:#00007f;text-decoration:underline}.landing-page em{display:inline;margin:0;padding:0;font-weight:700;background:transparent;font-family:inherit;font-size:inherit;line-height:inherit;color:#bcc9da}.landing-page h1{font-family:\"mastodon-font-display\",sans-serif;font-size:26px;line-height:30px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h1 small{font-family:\"mastodon-font-sans-serif\",sans-serif;display:block;font-size:18px;font-weight:400;color:#bcc9da}.landing-page h2{font-family:\"mastodon-font-display\",sans-serif;font-size:22px;line-height:26px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h3{font-family:\"mastodon-font-display\",sans-serif;font-size:18px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h4{font-family:\"mastodon-font-display\",sans-serif;font-size:16px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h5{font-family:\"mastodon-font-display\",sans-serif;font-size:14px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page h6{font-family:\"mastodon-font-display\",sans-serif;font-size:12px;line-height:24px;font-weight:500;margin-bottom:20px;color:#d9e1e8}.landing-page ul,.landing-page ol{margin-left:20px}.landing-page ul[type=a],.landing-page ol[type=a]{list-style-type:lower-alpha}.landing-page ul[type=i],.landing-page ol[type=i]{list-style-type:lower-roman}.landing-page ul{list-style:disc}.landing-page ol{list-style:decimal}.landing-page li>ol,.landing-page li>ul{margin-top:6px}.landing-page hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(64,64,64,.6);margin:20px 0}.landing-page hr.spacer{height:1px;border:0}.landing-page__information,.landing-page__forms{padding:20px}.landing-page__call-to-action{background:#121a24;border-radius:4px;padding:25px 40px;overflow:hidden;box-sizing:border-box}.landing-page__call-to-action .row{width:100%;display:flex;flex-direction:row-reverse;flex-wrap:nowrap;justify-content:space-between;align-items:center}.landing-page__call-to-action .row__information-board{display:flex;justify-content:flex-end;align-items:flex-end}.landing-page__call-to-action .row__information-board .information-board__section{flex:1 0 auto;padding:0 10px}@media screen and (max-width: 415px){.landing-page__call-to-action .row__information-board{width:100%;justify-content:space-between}}.landing-page__call-to-action .row__mascot{flex:1;margin:10px -50px 0 0}@media screen and (max-width: 415px){.landing-page__call-to-action .row__mascot{display:none}}.landing-page__logo{margin-right:20px}.landing-page__logo img{height:50px;width:auto;mix-blend-mode:lighten}.landing-page__information{padding:45px 40px;margin-bottom:10px}.landing-page__information:last-child{margin-bottom:0}.landing-page__information strong{font-weight:500;color:#bcc9da}.landing-page__information .account{border-bottom:0;padding:0}.landing-page__information .account__display-name{align-items:center;display:flex;margin-right:5px}.landing-page__information .account div.account__display-name:hover .display-name strong{text-decoration:none}.landing-page__information .account div.account__display-name .account__avatar{cursor:default}.landing-page__information .account__avatar-wrapper{margin-left:0;flex:0 0 auto}.landing-page__information .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing-page__information .account .display-name{font-size:15px}.landing-page__information .account .display-name__account{font-size:14px}@media screen and (max-width: 960px){.landing-page__information .contact{margin-top:30px}}@media screen and (max-width: 700px){.landing-page__information{padding:25px 20px}}.landing-page__information,.landing-page__forms,.landing-page #mastodon-timeline{box-sizing:border-box;background:#121a24;border-radius:4px;box-shadow:0 0 6px rgba(0,0,0,.1)}.landing-page__mascot{height:104px;position:relative;left:-40px;bottom:25px}.landing-page__mascot img{height:190px;width:auto}.landing-page__short-description .row{display:flex;flex-wrap:wrap;align-items:center;margin-bottom:40px}@media screen and (max-width: 700px){.landing-page__short-description .row{margin-bottom:20px}}.landing-page__short-description p a{color:#d9e1e8}.landing-page__short-description h1{font-weight:500;color:#fff;margin-bottom:0}.landing-page__short-description h1 small{color:#9baec8}.landing-page__short-description h1 small span{color:#d9e1e8}.landing-page__short-description p:last-child{margin-bottom:0}.landing-page__hero{margin-bottom:10px}.landing-page__hero img{display:block;margin:0;max-width:100%;height:auto;border-radius:4px}@media screen and (max-width: 840px){.landing-page .information-board .container-alt{padding-right:20px}.landing-page .information-board .panel{position:static;margin-top:20px;width:100%;border-radius:4px}.landing-page .information-board .panel .panel-header{text-align:center}}@media screen and (max-width: 675px){.landing-page .header-wrapper{padding-top:0}.landing-page .header-wrapper.compact{padding-bottom:0}.landing-page .header-wrapper.compact .hero .heading{text-align:initial}.landing-page .header .container-alt,.landing-page .features .container-alt{display:block}}.landing-page .cta{margin:20px}.landing{margin-bottom:100px}@media screen and (max-width: 738px){.landing{margin-bottom:0}}.landing__brand{display:flex;justify-content:center;align-items:center;padding:50px}.landing__brand svg{fill:#fff;height:52px}@media screen and (max-width: 415px){.landing__brand{padding:0;margin-bottom:30px}}.landing .directory{margin-top:30px;background:transparent;box-shadow:none;border-radius:0}.landing .hero-widget{margin-top:30px;margin-bottom:0}.landing .hero-widget h4{padding:10px;text-transform:uppercase;font-weight:700;font-size:13px;color:#9baec8}.landing .hero-widget__text{border-radius:0;padding-bottom:0}.landing .hero-widget__footer{background:#121a24;padding:10px;border-radius:0 0 4px 4px;display:flex}.landing .hero-widget__footer__column{flex:1 1 50%}.landing .hero-widget .account{padding:10px 0;border-bottom:0}.landing .hero-widget .account .account__display-name{display:flex;align-items:center}.landing .hero-widget .account .account__avatar{width:44px;height:44px;background-size:44px 44px}.landing .hero-widget__counter{padding:10px}.landing .hero-widget__counter strong{font-family:\"mastodon-font-display\",sans-serif;font-size:15px;font-weight:700;display:block}.landing .hero-widget__counter span{font-size:14px;color:#9baec8}.landing .simple_form .user_agreement .label_input>label{font-weight:400;color:#9baec8}.landing .simple_form p.lead{color:#9baec8;font-size:15px;line-height:20px;font-weight:400;margin-bottom:25px}.landing__grid{max-width:960px;margin:0 auto;display:grid;grid-template-columns:minmax(0, 50%) minmax(0, 50%);grid-gap:30px}@media screen and (max-width: 738px){.landing__grid{grid-template-columns:minmax(0, 100%);grid-gap:10px}.landing__grid__column-login{grid-row:1;display:flex;flex-direction:column}.landing__grid__column-login .box-widget{order:2;flex:0 0 auto}.landing__grid__column-login .hero-widget{margin-top:0;margin-bottom:10px;order:1;flex:0 0 auto}.landing__grid__column-registration{grid-row:2}.landing__grid .directory{margin-top:10px}}@media screen and (max-width: 415px){.landing__grid{grid-gap:0}.landing__grid .hero-widget{display:block;margin-bottom:0;box-shadow:none}.landing__grid .hero-widget__img,.landing__grid .hero-widget__img img,.landing__grid .hero-widget__footer{border-radius:0}.landing__grid .hero-widget,.landing__grid .box-widget,.landing__grid .directory__tag{border-bottom:1px solid #202e3f}.landing__grid .directory{margin-top:0}.landing__grid .directory__tag{margin-bottom:0}.landing__grid .directory__tag>a,.landing__grid .directory__tag>div{border-radius:0;box-shadow:none}.landing__grid .directory__tag:last-child{border-bottom:0}}.brand{position:relative;text-decoration:none}.brand__tagline{display:block;position:absolute;bottom:-10px;left:50px;width:300px;color:#9baec8;text-decoration:none;font-size:14px}@media screen and (max-width: 415px){.brand__tagline{position:static;width:auto;margin-top:20px;color:#404040}}.table{width:100%;max-width:100%;border-spacing:0;border-collapse:collapse}.table th,.table td{padding:8px;line-height:18px;vertical-align:top;border-top:1px solid #121a24;text-align:left;background:#0b1016}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #121a24;border-top:0;font-weight:500}.table>tbody>tr>th{font-weight:500}.table>tbody>tr:nth-child(odd)>td,.table>tbody>tr:nth-child(odd)>th{background:#121a24}.table a{color:#00007f;text-decoration:underline}.table a:hover{text-decoration:none}.table strong{font-weight:500}.table strong:lang(ja){font-weight:700}.table strong:lang(ko){font-weight:700}.table strong:lang(zh-CN){font-weight:700}.table strong:lang(zh-HK){font-weight:700}.table strong:lang(zh-TW){font-weight:700}.table.inline-table>tbody>tr:nth-child(odd)>td,.table.inline-table>tbody>tr:nth-child(odd)>th{background:transparent}.table.inline-table>tbody>tr:first-child>td,.table.inline-table>tbody>tr:first-child>th{border-top:0}.table.batch-table>thead>tr>th{background:#121a24;border-top:1px solid #040609;border-bottom:1px solid #040609}.table.batch-table>thead>tr>th:first-child{border-radius:4px 0 0;border-left:1px solid #040609}.table.batch-table>thead>tr>th:last-child{border-radius:0 4px 0 0;border-right:1px solid #040609}.table--invites tbody td{vertical-align:middle}.table-wrapper{overflow:auto;margin-bottom:20px}samp{font-family:\"mastodon-font-monospace\",monospace}button.table-action-link{background:transparent;border:0;font:inherit}button.table-action-link,a.table-action-link{text-decoration:none;display:inline-block;margin-right:5px;padding:0 10px;color:#9baec8;font-weight:500}button.table-action-link:hover,a.table-action-link:hover{color:#fff}button.table-action-link i.fa,a.table-action-link i.fa{font-weight:400;margin-right:5px}button.table-action-link:first-child,a.table-action-link:first-child{padding-left:0}.batch-table__toolbar,.batch-table__row{display:flex}.batch-table__toolbar__select,.batch-table__row__select{box-sizing:border-box;padding:8px 16px;cursor:pointer;min-height:100%}.batch-table__toolbar__select input,.batch-table__row__select input{margin-top:8px}.batch-table__toolbar__select--aligned,.batch-table__row__select--aligned{display:flex;align-items:center}.batch-table__toolbar__select--aligned input,.batch-table__row__select--aligned input{margin-top:0}.batch-table__toolbar__actions,.batch-table__toolbar__content,.batch-table__row__actions,.batch-table__row__content{padding:8px 0;padding-right:16px;flex:1 1 auto}.batch-table__toolbar{border:1px solid #040609;background:#121a24;border-radius:4px 0 0;height:47px;align-items:center}.batch-table__toolbar__actions{text-align:right;padding-right:11px}.batch-table__form{padding:16px;border:1px solid #040609;border-top:0;background:#121a24}.batch-table__form .fields-row{padding-top:0;margin-bottom:0}.batch-table__row{border:1px solid #040609;border-top:0;background:#0b1016}@media screen and (max-width: 415px){.optional .batch-table__row:first-child{border-top:1px solid #040609}}.batch-table__row:hover{background:#0f151d}.batch-table__row:nth-child(even){background:#121a24}.batch-table__row:nth-child(even):hover{background:#151f2b}.batch-table__row__content{padding-top:12px;padding-bottom:16px}.batch-table__row__content--unpadded{padding:0}.batch-table__row__content--with-image{display:flex;align-items:center}.batch-table__row__content__image{flex:0 0 auto;display:flex;justify-content:center;align-items:center;margin-right:10px}.batch-table__row__content__image .emojione{width:32px;height:32px}.batch-table__row__content__text{flex:1 1 auto}.batch-table__row__content__extra{flex:0 0 auto;text-align:right;color:#9baec8;font-weight:500}.batch-table__row .directory__tag{margin:0;width:100%}.batch-table__row .directory__tag a{background:transparent;border-radius:0}@media screen and (max-width: 415px){.batch-table.optional .batch-table__toolbar,.batch-table.optional .batch-table__row__select{display:none}}.batch-table .status__content{padding-top:0}.batch-table .status__content summary{display:list-item}.batch-table .status__content strong{font-weight:700}.batch-table .nothing-here{border:1px solid #040609;border-top:0;box-shadow:none}@media screen and (max-width: 415px){.batch-table .nothing-here{border-top:1px solid #040609}}@media screen and (max-width: 870px){.batch-table .accounts-table tbody td.optional{display:none}}.admin-wrapper{display:flex;justify-content:center;width:100%;min-height:100vh}.admin-wrapper .sidebar-wrapper{min-height:100vh;overflow:hidden;pointer-events:none;flex:1 1 auto}.admin-wrapper .sidebar-wrapper__inner{display:flex;justify-content:flex-end;background:#121a24;height:100%}.admin-wrapper .sidebar{width:240px;padding:0;pointer-events:auto}.admin-wrapper .sidebar__toggle{display:none;background:#202e3f;height:48px}.admin-wrapper .sidebar__toggle__logo{flex:1 1 auto}.admin-wrapper .sidebar__toggle__logo a{display:inline-block;padding:15px}.admin-wrapper .sidebar__toggle__logo svg{fill:#fff;height:20px;position:relative;bottom:-2px}.admin-wrapper .sidebar__toggle__icon{display:block;color:#9baec8;text-decoration:none;flex:0 0 auto;font-size:20px;padding:15px}.admin-wrapper .sidebar__toggle a:hover,.admin-wrapper .sidebar__toggle a:focus,.admin-wrapper .sidebar__toggle a:active{background:#26374d}.admin-wrapper .sidebar .logo{display:block;margin:40px auto;width:100px;height:100px}@media screen and (max-width: 600px){.admin-wrapper .sidebar>a:first-child{display:none}}.admin-wrapper .sidebar ul{list-style:none;border-radius:4px 0 0 4px;overflow:hidden;margin-bottom:20px}@media screen and (max-width: 600px){.admin-wrapper .sidebar ul{margin-bottom:0}}.admin-wrapper .sidebar ul a{display:block;padding:15px;color:#9baec8;text-decoration:none;transition:all 200ms linear;transition-property:color,background-color;border-radius:4px 0 0 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.admin-wrapper .sidebar ul a i.fa{margin-right:5px}.admin-wrapper .sidebar ul a:hover{color:#fff;background-color:#0a0e13;transition:all 100ms linear;transition-property:color,background-color}.admin-wrapper .sidebar ul a.selected{background:#0f151d;border-radius:4px 0 0}.admin-wrapper .sidebar ul ul{background:#0b1016;border-radius:0 0 0 4px;margin:0}.admin-wrapper .sidebar ul ul a{border:0;padding:15px 35px}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{color:#fff;background-color:#00007f;border-bottom:0;border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a:hover{background-color:#009}.admin-wrapper .sidebar>ul>.simple-navigation-active-leaf a{border-radius:4px 0 0 4px}.admin-wrapper .content-wrapper{box-sizing:border-box;width:100%;max-width:840px;flex:1 1 auto}@media screen and (max-width: 1080px){.admin-wrapper .sidebar-wrapper--empty{display:none}.admin-wrapper .sidebar-wrapper{width:240px;flex:0 0 auto}}@media screen and (max-width: 600px){.admin-wrapper .sidebar-wrapper{width:100%}}.admin-wrapper .content{padding:20px 15px;padding-top:60px;padding-left:25px}@media screen and (max-width: 600px){.admin-wrapper .content{max-width:none;padding:15px;padding-top:30px}}.admin-wrapper .content-heading{display:flex;padding-bottom:40px;border-bottom:1px solid #202e3f;margin:-15px -15px 40px 0;flex-wrap:wrap;align-items:center;justify-content:space-between}.admin-wrapper .content-heading>*{margin-top:15px;margin-right:15px}.admin-wrapper .content-heading-actions{display:inline-flex}.admin-wrapper .content-heading-actions>:not(:first-child){margin-left:5px}@media screen and (max-width: 600px){.admin-wrapper .content-heading{border-bottom:0;padding-bottom:0}}.admin-wrapper .content h2{color:#d9e1e8;font-size:24px;line-height:28px;font-weight:400}@media screen and (max-width: 600px){.admin-wrapper .content h2{font-weight:700}}.admin-wrapper .content h3{color:#d9e1e8;font-size:20px;line-height:28px;font-weight:400;margin-bottom:30px}.admin-wrapper .content h4{text-transform:uppercase;font-size:13px;font-weight:700;color:#9baec8;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid #202e3f}.admin-wrapper .content h6{font-size:16px;color:#d9e1e8;line-height:28px;font-weight:500}.admin-wrapper .content .fields-group h6{color:#fff;font-weight:500}.admin-wrapper .content .directory__tag>a,.admin-wrapper .content .directory__tag>div{box-shadow:none}.admin-wrapper .content .directory__tag .table-action-link .fa{color:inherit}.admin-wrapper .content .directory__tag h4{font-size:18px;font-weight:700;color:#fff;text-transform:none;padding-bottom:0;margin-bottom:0;border-bottom:0}.admin-wrapper .content>p{font-size:14px;line-height:21px;color:#d9e1e8;margin-bottom:20px}.admin-wrapper .content>p strong{color:#fff;font-weight:500}.admin-wrapper .content>p strong:lang(ja){font-weight:700}.admin-wrapper .content>p strong:lang(ko){font-weight:700}.admin-wrapper .content>p strong:lang(zh-CN){font-weight:700}.admin-wrapper .content>p strong:lang(zh-HK){font-weight:700}.admin-wrapper .content>p strong:lang(zh-TW){font-weight:700}.admin-wrapper .content hr{width:100%;height:0;border:0;border-bottom:1px solid rgba(64,64,64,.6);margin:20px 0}.admin-wrapper .content hr.spacer{height:1px;border:0}@media screen and (max-width: 600px){.admin-wrapper{display:block}.admin-wrapper .sidebar-wrapper{min-height:0}.admin-wrapper .sidebar{width:100%;padding:0;height:auto}.admin-wrapper .sidebar__toggle{display:flex}.admin-wrapper .sidebar>ul{display:none}.admin-wrapper .sidebar ul a,.admin-wrapper .sidebar ul ul a{border-radius:0;border-bottom:1px solid #192432;transition:none}.admin-wrapper .sidebar ul a:hover,.admin-wrapper .sidebar ul ul a:hover{transition:none}.admin-wrapper .sidebar ul ul{border-radius:0}.admin-wrapper .sidebar ul .simple-navigation-active-leaf a{border-bottom-color:#00007f}}hr.spacer{width:100%;border:0;margin:20px 0;height:1px}body .muted-hint,.admin-wrapper .content .muted-hint{color:#9baec8}body .muted-hint a,.admin-wrapper .content .muted-hint a{color:#00007f}body .positive-hint,.admin-wrapper .content .positive-hint{color:#79bd9a;font-weight:500}body .negative-hint,.admin-wrapper .content .negative-hint{color:#df405a;font-weight:500}body .neutral-hint,.admin-wrapper .content .neutral-hint{color:#404040;font-weight:500}body .warning-hint,.admin-wrapper .content .warning-hint{color:#ca8f04;font-weight:500}.filters{display:flex;flex-wrap:wrap}.filters .filter-subset{flex:0 0 auto;margin:0 40px 20px 0}.filters .filter-subset:last-child{margin-bottom:30px}.filters .filter-subset ul{margin-top:5px;list-style:none}.filters .filter-subset ul li{display:inline-block;margin-right:5px}.filters .filter-subset strong{font-weight:500;text-transform:uppercase;font-size:12px}.filters .filter-subset strong:lang(ja){font-weight:700}.filters .filter-subset strong:lang(ko){font-weight:700}.filters .filter-subset strong:lang(zh-CN){font-weight:700}.filters .filter-subset strong:lang(zh-HK){font-weight:700}.filters .filter-subset strong:lang(zh-TW){font-weight:700}.filters .filter-subset--with-select strong{display:block;margin-bottom:10px}.filters .filter-subset a{display:inline-block;color:#9baec8;text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:500;border-bottom:2px solid #121a24}.filters .filter-subset a:hover{color:#fff;border-bottom:2px solid #1b2635}.filters .filter-subset a.selected{color:#00007f;border-bottom:2px solid #00007f}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.flavour-screen{display:block;margin:10px auto;max-width:100%}.flavour-description{display:block;font-size:16px;margin:10px 0}.flavour-description>p{margin:10px 0}.report-accounts{display:flex;flex-wrap:wrap;margin-bottom:20px}.report-accounts__item{display:flex;flex:250px;flex-direction:column;margin:0 5px}.report-accounts__item>strong{display:block;margin:0 0 10px -5px;font-weight:500;font-size:14px;line-height:18px;color:#d9e1e8}.report-accounts__item>strong:lang(ja){font-weight:700}.report-accounts__item>strong:lang(ko){font-weight:700}.report-accounts__item>strong:lang(zh-CN){font-weight:700}.report-accounts__item>strong:lang(zh-HK){font-weight:700}.report-accounts__item>strong:lang(zh-TW){font-weight:700}.report-accounts__item .account-card{flex:1 1 auto}.report-status,.account-status{display:flex;margin-bottom:10px}.report-status .activity-stream,.account-status .activity-stream{flex:2 0 0;margin-right:20px;max-width:calc(100% - 60px)}.report-status .activity-stream .entry,.account-status .activity-stream .entry{border-radius:4px}.report-status__actions,.account-status__actions{flex:0 0 auto;display:flex;flex-direction:column}.report-status__actions .icon-button,.account-status__actions .icon-button{font-size:24px;width:24px;text-align:center;margin-bottom:10px}.simple_form.new_report_note,.simple_form.new_account_moderation_note{max-width:100%}.batch-form-box{display:flex;flex-wrap:wrap;margin-bottom:5px}.batch-form-box #form_status_batch_action{margin:0 5px 5px 0;font-size:14px}.batch-form-box input.button{margin:0 5px 5px 0}.batch-form-box .media-spoiler-toggle-buttons{margin-left:auto}.batch-form-box .media-spoiler-toggle-buttons .button{overflow:visible;margin:0 0 5px 5px;float:right}.back-link{margin-bottom:10px;font-size:14px}.back-link a{color:#00007f;text-decoration:none}.back-link a:hover{text-decoration:underline}.spacer{flex:1 1 auto}.log-entry{line-height:20px;padding:15px 0;background:#121a24;border-bottom:1px solid #192432}.log-entry:last-child{border-bottom:0}.log-entry__header{display:flex;justify-content:flex-start;align-items:center;color:#9baec8;font-size:14px;padding:0 10px}.log-entry__avatar{margin-right:10px}.log-entry__avatar .avatar{display:block;margin:0;border-radius:50%;width:40px;height:40px}.log-entry__content{max-width:calc(100% - 90px)}.log-entry__title{word-wrap:break-word}.log-entry__timestamp{color:#404040}.log-entry a,.log-entry .username,.log-entry .target{color:#d9e1e8;text-decoration:none;font-weight:500}a.name-tag,.name-tag,a.inline-name-tag,.inline-name-tag{text-decoration:none;color:#d9e1e8}a.name-tag .username,.name-tag .username,a.inline-name-tag .username,.inline-name-tag .username{font-weight:500}a.name-tag.suspended .username,.name-tag.suspended .username,a.inline-name-tag.suspended .username,.inline-name-tag.suspended .username{text-decoration:line-through;color:#e87487}a.name-tag.suspended .avatar,.name-tag.suspended .avatar,a.inline-name-tag.suspended .avatar,.inline-name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}a.name-tag,.name-tag{display:flex;align-items:center}a.name-tag .avatar,.name-tag .avatar{display:block;margin:0;margin-right:5px;border-radius:50%}a.name-tag.suspended .avatar,.name-tag.suspended .avatar{filter:grayscale(100%);opacity:.8}.speech-bubble{margin-bottom:20px;border-left:4px solid #00007f}.speech-bubble.positive{border-left-color:#79bd9a}.speech-bubble.negative{border-left-color:#e87487}.speech-bubble.warning{border-left-color:#ca8f04}.speech-bubble__bubble{padding:16px;padding-left:14px;font-size:15px;line-height:20px;border-radius:4px 4px 4px 0;position:relative;font-weight:500}.speech-bubble__bubble a{color:#9baec8}.speech-bubble__owner{padding:8px;padding-left:12px}.speech-bubble time{color:#404040}.report-card{background:#121a24;border-radius:4px;margin-bottom:20px}.report-card__profile{display:flex;justify-content:space-between;align-items:center;padding:15px}.report-card__profile .account{padding:0;border:0}.report-card__profile .account__avatar-wrapper{margin-left:0}.report-card__profile__stats{flex:0 0 auto;font-weight:500;color:#9baec8;text-transform:uppercase;text-align:right}.report-card__profile__stats a{color:inherit;text-decoration:none}.report-card__profile__stats a:focus,.report-card__profile__stats a:hover,.report-card__profile__stats a:active{color:#b5c3d6}.report-card__profile__stats .red{color:#df405a}.report-card__summary__item{display:flex;justify-content:flex-start;border-top:1px solid #0b1016}.report-card__summary__item:hover{background:#151f2b}.report-card__summary__item__reported-by,.report-card__summary__item__assigned{padding:15px;flex:0 0 auto;box-sizing:border-box;width:150px;color:#9baec8}.report-card__summary__item__reported-by,.report-card__summary__item__reported-by .username,.report-card__summary__item__assigned,.report-card__summary__item__assigned .username{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.report-card__summary__item__content{flex:1 1 auto;max-width:calc(100% - 300px)}.report-card__summary__item__content__icon{color:#404040;margin-right:4px;font-weight:500}.report-card__summary__item__content a{display:block;box-sizing:border-box;width:100%;padding:15px;text-decoration:none;color:#9baec8}.one-line{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ellipsized-ip{display:inline-block;max-width:120px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.admin-account-bio{display:flex;flex-wrap:wrap;margin:0 -5px;margin-top:20px}.admin-account-bio>div{box-sizing:border-box;padding:0 5px;margin-bottom:10px;flex:1 0 50%}.admin-account-bio .account__header__fields,.admin-account-bio .account__header__content{background:#202e3f;border-radius:4px;height:100%}.admin-account-bio .account__header__fields{margin:0;border:0}.admin-account-bio .account__header__fields a{color:#0000a8}.admin-account-bio .account__header__fields dl:first-child .verified{border-radius:0 4px 0 0}.admin-account-bio .account__header__fields .verified a{color:#79bd9a}.admin-account-bio .account__header__content{box-sizing:border-box;padding:20px;color:#fff}.center-text{text-align:center}.announcements-list{border:1px solid #192432;border-radius:4px}.announcements-list__item{padding:15px 0;background:#121a24;border-bottom:1px solid #192432}.announcements-list__item__title{padding:0 15px;display:block;font-weight:500;font-size:18px;line-height:1.5;color:#d9e1e8;text-decoration:none;margin-bottom:10px}.announcements-list__item__title:hover,.announcements-list__item__title:focus,.announcements-list__item__title:active{color:#fff}.announcements-list__item__meta{padding:0 15px;color:#404040}.announcements-list__item__action-bar{display:flex;justify-content:space-between;align-items:center}.announcements-list__item:last-child{border-bottom:0}.dashboard__counters{display:flex;flex-wrap:wrap;margin:0 -5px;margin-bottom:20px}.dashboard__counters>div{box-sizing:border-box;flex:0 0 33.333%;padding:0 5px;margin-bottom:10px}.dashboard__counters>div>div,.dashboard__counters>div>a{padding:20px;background:#192432;border-radius:4px;box-sizing:border-box;height:100%}.dashboard__counters>div>a{text-decoration:none;color:inherit;display:block}.dashboard__counters>div>a:hover,.dashboard__counters>div>a:focus,.dashboard__counters>div>a:active{background:#202e3f}.dashboard__counters__num,.dashboard__counters__text{text-align:center;font-weight:500;font-size:24px;line-height:21px;color:#fff;font-family:\"mastodon-font-display\",sans-serif;margin-bottom:20px;line-height:30px}.dashboard__counters__text{font-size:18px}.dashboard__counters__label{font-size:14px;color:#9baec8;text-align:center;font-weight:500}.dashboard__widgets{display:flex;flex-wrap:wrap;margin:0 -5px}.dashboard__widgets>div{flex:0 0 33.333%;margin-bottom:20px}.dashboard__widgets>div>div{padding:0 5px}.dashboard__widgets a:not(.name-tag){color:#d9e1e8;font-weight:500;text-decoration:none}body.rtl{direction:rtl}body.rtl .column-header>button{text-align:right;padding-left:0;padding-right:15px}body.rtl .radio-button__input{margin-right:0;margin-left:10px}body.rtl .directory__card__bar .display-name{margin-left:0;margin-right:15px}body.rtl .display-name{text-align:right}body.rtl .notification__message{margin-left:0;margin-right:68px}body.rtl .drawer__inner__mastodon>img{transform:scaleX(-1)}body.rtl .notification__favourite-icon-wrapper{left:auto;right:-26px}body.rtl .landing-page__logo{margin-right:0;margin-left:20px}body.rtl .landing-page .features-list .features-list__row .visual{margin-left:0;margin-right:15px}body.rtl .column-link__icon,body.rtl .column-header__icon{margin-right:0;margin-left:5px}body.rtl .compose-form .compose-form__buttons-wrapper .character-counter__wrapper{margin-right:0;margin-left:4px}body.rtl .navigation-bar__profile{margin-left:0;margin-right:8px}body.rtl .search__input{padding-right:10px;padding-left:30px}body.rtl .search__icon .fa{right:auto;left:10px}body.rtl .columns-area{direction:rtl}body.rtl .column-header__buttons{left:0;right:auto;margin-left:0;margin-right:-15px}body.rtl .column-inline-form .icon-button{margin-left:0;margin-right:5px}body.rtl .column-header__links .text-btn{margin-left:10px;margin-right:0}body.rtl .account__avatar-wrapper{float:right}body.rtl .column-header__back-button{padding-left:5px;padding-right:0}body.rtl .column-header__setting-arrows{float:left}body.rtl .setting-toggle__label{margin-left:0;margin-right:8px}body.rtl .status__avatar{left:auto;right:10px}body.rtl .status,body.rtl .activity-stream .status.light{padding-left:10px;padding-right:68px}body.rtl .status__info .status__display-name,body.rtl .activity-stream .status.light .status__display-name{padding-left:25px;padding-right:0}body.rtl .activity-stream .pre-header{padding-right:68px;padding-left:0}body.rtl .status__prepend{margin-left:0;margin-right:68px}body.rtl .status__prepend-icon-wrapper{left:auto;right:-26px}body.rtl .activity-stream .pre-header .pre-header__icon{left:auto;right:42px}body.rtl .account__avatar-overlay-overlay{right:auto;left:0}body.rtl .column-back-button--slim-button{right:auto;left:0}body.rtl .status__relative-time,body.rtl .activity-stream .status.light .status__header .status__meta{float:left}body.rtl .status__action-bar__counter{margin-right:0;margin-left:11px}body.rtl .status__action-bar__counter .status__action-bar-button{margin-right:0;margin-left:4px}body.rtl .status__action-bar-button{float:right;margin-right:0;margin-left:18px}body.rtl .status__action-bar-dropdown{float:right}body.rtl .privacy-dropdown__dropdown{margin-left:0;margin-right:40px}body.rtl .privacy-dropdown__option__icon{margin-left:10px;margin-right:0}body.rtl .detailed-status__display-name .display-name{text-align:right}body.rtl .detailed-status__display-avatar{margin-right:0;margin-left:10px;float:right}body.rtl .detailed-status__favorites,body.rtl .detailed-status__reblogs{margin-left:0;margin-right:6px}body.rtl .fa-ul{margin-left:2.14285714em}body.rtl .fa-li{left:auto;right:-2.14285714em}body.rtl .admin-wrapper{direction:rtl}body.rtl .admin-wrapper .sidebar ul a i.fa,body.rtl a.table-action-link i.fa{margin-right:0;margin-left:5px}body.rtl .simple_form .check_boxes .checkbox label{padding-left:0;padding-right:25px}body.rtl .simple_form .input.with_label.boolean label.checkbox{padding-left:25px;padding-right:0}body.rtl .simple_form .check_boxes .checkbox input[type=checkbox],body.rtl .simple_form .input.boolean input[type=checkbox]{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio{left:auto;right:0}body.rtl .simple_form .input.radio_buttons .radio>label{padding-right:28px;padding-left:0}body.rtl .simple_form .input-with-append .input input{padding-left:142px;padding-right:0}body.rtl .simple_form .input.boolean label.checkbox{left:auto;right:0}body.rtl .simple_form .input.boolean .label_input,body.rtl .simple_form .input.boolean .hint{padding-left:0;padding-right:28px}body.rtl .simple_form .label_input__append{right:auto;left:3px}body.rtl .simple_form .label_input__append::after{right:auto;left:0;background-image:linear-gradient(to left, rgba(1, 1, 2, 0), #010102)}body.rtl .simple_form select{background:#010102 url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center/auto 16px}body.rtl .table th,body.rtl .table td{text-align:right}body.rtl .filters .filter-subset{margin-right:0;margin-left:45px}body.rtl .landing-page .header-wrapper .mascot{right:60px;left:auto}body.rtl .landing-page__call-to-action .row__information-board{direction:rtl}body.rtl .landing-page .header .hero .floats .float-1{left:-120px;right:auto}body.rtl .landing-page .header .hero .floats .float-2{left:210px;right:auto}body.rtl .landing-page .header .hero .floats .float-3{left:110px;right:auto}body.rtl .landing-page .header .links .brand img{left:0}body.rtl .landing-page .fa-external-link{padding-right:5px;padding-left:0 !important}body.rtl .landing-page .features #mastodon-timeline{margin-right:0;margin-left:30px}@media screen and (min-width: 631px){body.rtl .column,body.rtl .drawer{padding-left:5px;padding-right:5px}body.rtl .column:first-child,body.rtl .drawer:first-child{padding-left:5px;padding-right:10px}body.rtl .columns-area>div .column,body.rtl .columns-area>div .drawer{padding-left:5px;padding-right:5px}}body.rtl .columns-area--mobile .column,body.rtl .columns-area--mobile .drawer{padding-left:0;padding-right:0}body.rtl .public-layout .header .nav-button{margin-left:8px;margin-right:0}body.rtl .public-layout .public-account-header__tabs{margin-left:0;margin-right:20px}body.rtl .landing-page__information .account__display-name{margin-right:0;margin-left:5px}body.rtl .landing-page__information .account__avatar-wrapper{margin-left:12px;margin-right:0}body.rtl .card__bar .display-name{margin-left:0;margin-right:15px;text-align:right}body.rtl .fa-chevron-left::before{content:\"\"}body.rtl .fa-chevron-right::before{content:\"\"}body.rtl .column-back-button__icon{margin-right:0;margin-left:5px}body.rtl .column-header__setting-arrows .column-header__setting-btn:last-child{padding-left:0;padding-right:10px}body.rtl .simple_form .input.radio_buttons .radio>label input{left:auto;right:0}.emojione[title=\":wavy_dash:\"],.emojione[title=\":waving_black_flag:\"],.emojione[title=\":water_buffalo:\"],.emojione[title=\":video_game:\"],.emojione[title=\":video_camera:\"],.emojione[title=\":vhs:\"],.emojione[title=\":turkey:\"],.emojione[title=\":tophat:\"],.emojione[title=\":top:\"],.emojione[title=\":tm:\"],.emojione[title=\":telephone_receiver:\"],.emojione[title=\":spider:\"],.emojione[title=\":speaking_head_in_silhouette:\"],.emojione[title=\":spades:\"],.emojione[title=\":soon:\"],.emojione[title=\":registered:\"],.emojione[title=\":on:\"],.emojione[title=\":musical_score:\"],.emojione[title=\":movie_camera:\"],.emojione[title=\":mortar_board:\"],.emojione[title=\":microphone:\"],.emojione[title=\":male-guard:\"],.emojione[title=\":lower_left_fountain_pen:\"],.emojione[title=\":lower_left_ballpoint_pen:\"],.emojione[title=\":kaaba:\"],.emojione[title=\":joystick:\"],.emojione[title=\":hole:\"],.emojione[title=\":hocho:\"],.emojione[title=\":heavy_plus_sign:\"],.emojione[title=\":heavy_multiplication_x:\"],.emojione[title=\":heavy_minus_sign:\"],.emojione[title=\":heavy_dollar_sign:\"],.emojione[title=\":heavy_division_sign:\"],.emojione[title=\":heavy_check_mark:\"],.emojione[title=\":guardsman:\"],.emojione[title=\":gorilla:\"],.emojione[title=\":fried_egg:\"],.emojione[title=\":film_projector:\"],.emojione[title=\":female-guard:\"],.emojione[title=\":end:\"],.emojione[title=\":electric_plug:\"],.emojione[title=\":eight_pointed_black_star:\"],.emojione[title=\":dark_sunglasses:\"],.emojione[title=\":currency_exchange:\"],.emojione[title=\":curly_loop:\"],.emojione[title=\":copyright:\"],.emojione[title=\":clubs:\"],.emojione[title=\":camera_with_flash:\"],.emojione[title=\":camera:\"],.emojione[title=\":busts_in_silhouette:\"],.emojione[title=\":bust_in_silhouette:\"],.emojione[title=\":bowling:\"],.emojione[title=\":bomb:\"],.emojione[title=\":black_small_square:\"],.emojione[title=\":black_nib:\"],.emojione[title=\":black_medium_square:\"],.emojione[title=\":black_medium_small_square:\"],.emojione[title=\":black_large_square:\"],.emojione[title=\":black_heart:\"],.emojione[title=\":black_circle:\"],.emojione[title=\":back:\"],.emojione[title=\":ant:\"],.emojione[title=\":8ball:\"]{filter:drop-shadow(1px 1px 0 #ffffff) drop-shadow(-1px 1px 0 #ffffff) drop-shadow(1px -1px 0 #ffffff) drop-shadow(-1px -1px 0 #ffffff);transform:scale(0.71)}@media screen and (min-width: 1300px){.column{flex-grow:1 !important;max-width:400px}.drawer{width:17%;max-width:400px;min-width:330px}}.media-gallery,.video-player{max-height:30vh;height:30vh !important;position:relative;margin-top:20px;margin-left:-68px;width:calc(100% + 80px) !important;max-width:calc(100% + 80px)}.detailed-status .media-gallery,.detailed-status .video-player{margin-left:-5px;width:calc(100% + 9px);max-width:calc(100% + 9px)}.video-player video{transform:unset;top:unset}.detailed-status .media-spoiler,.status .media-spoiler{height:100% !important;vertical-align:middle}body{font-size:13px;font-family:\"MS Sans Serif\",\"premillenium\",sans-serif;color:#000}.ui,.ui .columns-area,body.admin{background:teal}.loading-bar{height:5px;background-color:navy}.tabs-bar{background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;height:30px}.tabs-bar__link{color:#000;border:2px outset #bfbfbf;border-top-width:1px;border-left-width:1px;margin:2px;padding:3px}.tabs-bar__link.active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;color:#000}.tabs-bar__link:last-child::before{content:\"Start\";color:#000;font-weight:bold;font-size:15px;width:80%;display:block;position:absolute;right:0px}.tabs-bar__link:last-child{position:relative;flex-basis:60px !important;font-size:0px;color:#bfbfbf;background-image:url(\"~images/start.png\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%}.drawer .drawer__inner{overflow:visible;height:inherit;background:#bfbfbf}.drawer:after{display:block;content:\" \";position:absolute;bottom:15px;left:15px;width:132px;height:117px;background-image:url(\"~images/clippy_wave.gif\"),url(\"~images/clippy_frame.png\");background-repeat:no-repeat;background-position:4px 20px,0px 0px;z-index:0}.drawer__pager{overflow-y:auto;z-index:1}.privacy-dropdown__dropdown{z-index:2}.column{max-height:100vh}.column>.scrollable{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;border-top-width:0px}.column-header__wrapper{color:#fff;font-weight:bold;background:#7f7f7f}.column-header{padding:2px;font-size:13px;background:#7f7f7f;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;border-bottom-width:0px;color:#fff;font-weight:bold;align-items:baseline}.column-header__wrapper.active{background:#00007f}.column-header__wrapper.active::before{display:none}.column-header.active{box-shadow:unset;background:#00007f}.column-header.active .column-header__icon{color:#fff}.column-header__buttons{max-height:20px;margin-right:0px}.column-header__button{background:#bfbfbf;color:#000;line-height:0px;font-size:14px;max-height:20px;padding:0px 2px;margin-top:2px;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}.column-header__button:hover{color:#000}.column-header__button.active,.column-header__button.active:hover{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;background-color:#7f7f7f}.column-header__back-button{background:#bfbfbf;color:#000;padding:2px;max-height:20px;margin-top:2px;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;font-size:13px;font-weight:bold}.column-back-button{background:#bfbfbf;color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;padding:2px;font-size:13px;font-weight:bold}.column-back-button--slim-button{position:absolute;top:-22px;right:4px;max-height:20px;max-width:60px;padding:0px 2px}.column-back-button__icon{font-size:11px;margin-top:-3px}.column-header__collapsible{border-left:2px outset #bfbfbf;border-right:2px outset #bfbfbf}.column-header__collapsible-inner{background:#bfbfbf;color:#000}.column-header__collapsible__extra{color:#000}.column-header__collapsible__extra div[role=group]{border:2px groove #bfbfbf;border-radius:4px;margin-bottom:8px;padding:4px}.column-inline-form{background-color:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;border-bottom-width:0px;border-top-width:0px}.column-settings__section{color:#000;font-weight:bold;font-size:11px;position:relative;top:-12px;left:4px;background-color:#bfbfbf;display:inline-block;padding:0px 4px;margin-bottom:0px}.setting-meta__label,.setting-toggle__label{color:#000;font-weight:normal}.setting-meta__label span:before{content:\"(\"}.setting-meta__label span:after{content:\")\"}.setting-toggle{line-height:13px}.react-toggle .react-toggle-track{border-radius:0px;background-color:#fff;border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px;width:12px;height:12px}.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track{background-color:#fff}.react-toggle .react-toggle-track-check{left:2px;transition:unset}.react-toggle .react-toggle-track-check svg path{fill:#000}.react-toggle .react-toggle-track-x{display:none}.react-toggle .react-toggle-thumb{border-radius:0px;display:none}.text-btn{background-color:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;padding:4px}.text-btn:hover{text-decoration:none;color:#000}.text-btn:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.setting-text{color:#000;background-color:#fff;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;font-size:13px;padding:2px}.setting-text:active,.setting-text:focus,.setting-text.light:active,.setting-text.light:focus{color:#000;border-bottom:2px inset #bfbfbf}.column-header__setting-arrows .column-header__setting-btn{padding:3px 10px}.column-header__setting-arrows .column-header__setting-btn:last-child{padding:3px 10px}.missing-indicator{background-color:#bfbfbf;color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}.missing-indicator>div{background:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRUaXRsZQAACJnLyy9Jyy/NSwEAD5IDblIFOhoAAAAXelRYdEF1dGhvcgAACJlLzijKz0vMAQALmgLoDsFj8gAAAQpJREFUOMuVlD0OwjAMhd2oQl04Axfo0IGBgYELcAY6cqQuSO0ZOEAZGBg6VKg74gwsEaoESRVHjusI8aQqzY8/PbtOEz1qkFSn2YevlaNOpLMJh2DwvixhuXtOa6/LCh51DUMEFkAsgAZD207Doin8mQ562JpRE5CHBAAhmIqD1L8AqzUUUJkxc6kr3AgAJ+NuvIWRdk7WcrKl0AUqcIBBHOiEbpS4m27mIL5Onfg3k0rgggeQuS2sDOGSahKR+glgqaGLgUJs951NN1q9D72cQqQWR9cr3sm9YcEssEuz6eEuZh2bu0aSOhQ1MBezu2O/+TVSvEFII3qLsZWrSA2AAUQIh1HpyP/kC++zjVSMj6ntAAAAAElFTkSuQmCC\") no-repeat;background-position:center center}.empty-column-indicator,.error-column{background:#bfbfbf;color:#000}.status__wrapper{border:2px groove #bfbfbf;margin:4px}.status{border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px;background-color:#fff;margin:4px;padding-bottom:40px;margin-bottom:8px}.status.status-direct{background-color:#bfbfbf}.status__content{font-size:13px}.status.light .status__relative-time,.status.light .display-name span{color:#7f7f7f}.status__action-bar{box-sizing:border-box;position:absolute;bottom:-1px;left:-1px;background:#bfbfbf;width:calc(100% + 2px);padding-left:10px;padding:4px 2px;padding-bottom:4px;border-bottom:2px groove #bfbfbf;border-top:1px outset #bfbfbf;text-align:right}.status__wrapper .status__action-bar{border-bottom-width:0px}.status__action-bar-button{float:right}.status__action-bar-dropdown{margin-left:auto;margin-right:10px}.status__action-bar-dropdown .icon-button{min-width:28px}.status.light .status__content a{color:blue}.focusable:focus{background:#bfbfbf}.focusable:focus .detailed-status__action-bar{background:#bfbfbf}.focusable:focus .status,.focusable:focus .detailed-status{background:#fff;outline:2px dotted gray}.dropdown__trigger.icon-button{padding-right:6px}.detailed-status__action-bar-dropdown .icon-button{min-width:28px}.detailed-status{background:#fff;background-clip:padding-box;margin:4px;border:2px groove #bfbfbf;padding:4px}.detailed-status__display-name{color:#7f7f7f}.detailed-status__display-name strong{color:#000;font-weight:bold}.account__avatar,.account__avatar-overlay-base,.account__header__avatar,.account__avatar-overlay-overlay{border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px;clip-path:none;filter:saturate(1.8) brightness(1.1)}.detailed-status__action-bar{background-color:#bfbfbf;border:0px;border-bottom:2px groove #bfbfbf;margin-bottom:8px;justify-items:left;padding-left:4px}.icon-button{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;padding:0px 0px 0px 0px;margin-right:4px;color:#3f3f3f}.icon-button.inverted,.icon-button:hover,.icon-button.inverted:hover,.icon-button:active,.icon-button:focus{color:#3f3f3f}.icon-button:active{border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px}.status__action-bar>.icon-button{padding:0px 15px 0px 0px;min-width:25px}.icon-button.star-icon,.icon-button.star-icon:active{background:transparent;border:none}.icon-button.star-icon.active{color:#ca8f04}.icon-button.star-icon.active:active,.icon-button.star-icon.active:hover,.icon-button.star-icon.active:focus{color:#ca8f04}.icon-button.star-icon>i{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;padding-bottom:3px}.icon-button.star-icon:active>i{border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px}.text-icon-button{color:#404040}.detailed-status__action-bar-dropdown{margin-left:auto;justify-content:right;padding-right:16px}.detailed-status__button{flex:0 0 auto}.detailed-status__button .icon-button{padding-left:2px;padding-right:25px}.status-card{border-radius:0px;background:#fff;border:1px solid #000;color:#000}.status-card:hover{background-color:#fff}.status-card__title{color:blue;text-decoration:underline;font-weight:bold}.load-more{width:auto;margin:5px auto;background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;color:#000;padding:2px 5px}.load-more:hover{background:#bfbfbf;color:#000}.status-card__description{color:#000}.account__display-name strong,.status__display-name strong{color:#000;font-weight:bold}.account .account__display-name{color:#000}.account{border-bottom:2px groove #bfbfbf}.reply-indicator__content .status__content__spoiler-link,.status__content .status__content__spoiler-link{background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}.reply-indicator__content .status__content__spoiler-link:hover,.status__content .status__content__spoiler-link:hover{background:#bfbfbf}.reply-indicator__content .status__content__spoiler-link:active,.status__content .status__content__spoiler-link:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.reply-indicator__content a,.status__content a{color:blue}.notification{border:2px groove #bfbfbf;margin:4px}.notification__message{color:#000;font-size:13px}.notification__display-name{font-weight:bold}.drawer__header{background:#bfbfbf;border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;justify-content:left;margin-bottom:0px;padding-bottom:2px;border-bottom:2px groove #bfbfbf}.drawer__tab{color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;padding:5px;margin:2px;flex:0 0 auto}.drawer__tab:first-child::before{content:\"Start\";color:#000;font-weight:bold;font-size:15px;width:80%;display:block;position:absolute;right:0px}.drawer__tab:first-child{position:relative;padding:5px 15px;width:40px;font-size:0px;color:#bfbfbf;background-image:url(\"~images/start.png\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%}.drawer__header a:hover{background-color:transparent}.drawer__header a:first-child:hover{background-image:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%;transition:unset}.search{background:#bfbfbf;padding-top:2px;padding:2px;border:2px outset #bfbfbf;border-top-width:0px;border-bottom:2px groove #bfbfbf;margin-bottom:0px}.search input{background-color:#fff;color:#000;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.search__input:focus{background-color:#fff}.search-popout{box-shadow:unset;color:#000;border-radius:0px;background-color:#ffc;border:1px solid #000}.search-popout h4{color:#000;text-transform:none;font-weight:bold}.search-results__header{background-color:#bfbfbf;color:#000;border-bottom:2px groove #bfbfbf}.search-results__hashtag{color:blue}.search-results__section .account:hover,.search-results__section .account:hover .account__display-name,.search-results__section .account:hover .account__display-name strong,.search-results__section .search-results__hashtag:hover{background-color:#00007f;color:#fff}.search__icon .fa{color:gray}.search__icon .fa.active{opacity:1}.search__icon .fa:hover{color:gray}.drawer__inner,.drawer__inner.darker{background-color:#bfbfbf;border:2px outset #bfbfbf;border-top-width:0px}.navigation-bar{color:#000}.navigation-bar strong{color:#000;font-weight:bold}.compose-form .autosuggest-textarea__textarea,.compose-form .spoiler-input__input{border-radius:0px;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.compose-form .autosuggest-textarea__textarea{border-bottom:0px}.compose-form__uploads-wrapper{border-radius:0px;border-bottom:1px inset #bfbfbf;border-top-width:0px}.compose-form__upload-wrapper{border-left:1px inset #bfbfbf;border-right:1px inset #bfbfbf}.compose-form .compose-form__buttons-wrapper{background-color:#bfbfbf;border:2px groove #bfbfbf;margin-top:4px;padding:4px 8px}.compose-form__buttons{background-color:#bfbfbf;border-radius:0px;box-shadow:unset}.compose-form__buttons-separator{border-left:2px groove #bfbfbf}.privacy-dropdown.active .privacy-dropdown__value.active,.advanced-options-dropdown.open .advanced-options-dropdown__value{background:#bfbfbf}.privacy-dropdown.active .privacy-dropdown__value.active .icon-button{color:#404040}.privacy-dropdown.active .privacy-dropdown__value{background:#bfbfbf;box-shadow:unset}.privacy-dropdown__option.active,.privacy-dropdown__option:hover,.privacy-dropdown__option.active:hover{background:#00007f}.privacy-dropdown__dropdown,.privacy-dropdown.active .privacy-dropdown__dropdown,.advanced-options-dropdown__dropdown,.advanced-options-dropdown.open .advanced-options-dropdown__dropdown{box-shadow:unset;color:#000;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;background:#bfbfbf}.privacy-dropdown__option__content{color:#000}.privacy-dropdown__option__content strong{font-weight:bold}.compose-form__warning::before{content:\"Tip:\";font-weight:bold;display:block;position:absolute;top:-10px;background-color:#bfbfbf;font-size:11px;padding:0px 5px}.compose-form__warning{position:relative;box-shadow:unset;border:2px groove #bfbfbf;background-color:#bfbfbf;color:#000}.compose-form__warning a{color:blue}.compose-form__warning strong{color:#000;text-decoration:underline}.compose-form__buttons button.active:last-child{border-left:2px solid #404040;border-top:2px solid #404040;border-right:2px solid #efefef;border-bottom:2px solid #efefef;border-radius:0px;background:#dfdfdf;color:#7f7f7f}.compose-form__upload-thumbnail{border-radius:0px;border:2px groove #bfbfbf;background-color:#bfbfbf;padding:2px;box-sizing:border-box}.compose-form__upload-thumbnail .icon-button{max-width:20px;max-height:20px;line-height:10px !important}.compose-form__upload-thumbnail .icon-button::before{content:\"X\";font-size:13px;font-weight:bold;color:#000}.compose-form__upload-thumbnail .icon-button i{display:none}.emoji-picker-dropdown__menu{z-index:2}.emoji-dialog.with-search{box-shadow:unset;border-radius:0px;background-color:#bfbfbf;border:1px solid #000;box-sizing:content-box}.emoji-dialog .emoji-search{color:#000;background-color:#fff;border-radius:0px;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.emoji-dialog .emoji-search-wrapper{border-bottom:2px groove #bfbfbf}.emoji-dialog .emoji-category-title{color:#000;font-weight:bold}.reply-indicator{background-color:#bfbfbf;border-radius:3px;border:2px groove #bfbfbf}.button{background-color:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;border-radius:0px;color:#000;font-weight:bold}.button:hover,.button:focus,.button:disabled{background-color:#bfbfbf}.button:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.button:disabled{color:gray;text-shadow:1px 1px 0px #efefef}.button:disabled:active{box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px}#Getting-started{background-color:#bfbfbf;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px;border-bottom-width:0px}#Getting-started::before{content:\"Start\";color:#000;font-weight:bold;font-size:15px;width:80%;text-align:center;display:block;position:absolute;right:2px}#Getting-started{position:relative;padding:5px 15px;width:60px;font-size:0px;color:#bfbfbf;background-image:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");background-repeat:no-repeat;background-position:8%;background-clip:padding-box;background-size:auto 50%}.column-subheading{background-color:#bfbfbf;color:#000;border-bottom:2px groove #bfbfbf;text-transform:none;font-size:16px}.column-link{background-color:transparent;color:#000}.column-link:hover{background-color:#00007f;color:#fff}.getting-started__wrapper .column-subheading{font-size:0px;margin:0px;padding:0px}.getting-started__wrapper .column-link{background-size:32px 32px;background-repeat:no-repeat;background-position:36px 50%;padding-left:40px}.getting-started__wrapper .column-link:hover{background-size:32px 32px;background-repeat:no-repeat;background-position:36px 50%}.getting-started__wrapper .column-link i{font-size:0px;width:32px}.column-link[href=\"/web/timelines/public\"]{background-image:url(\"~images/icon_public.png\")}.column-link[href=\"/web/timelines/public\"]:hover{background-image:url(\"~images/icon_public.png\")}.column-link[href=\"/web/timelines/public/local\"]{background-image:url(\"~images/icon_local.png\")}.column-link[href=\"/web/timelines/public/local\"]:hover{background-image:url(\"~images/icon_local.png\")}.column-link[href=\"/web/pinned\"]{background-image:url(\"~images/icon_pin.png\")}.column-link[href=\"/web/pinned\"]:hover{background-image:url(\"~images/icon_pin.png\")}.column-link[href=\"/web/favourites\"]{background-image:url(\"~images/icon_likes.png\")}.column-link[href=\"/web/favourites\"]:hover{background-image:url(\"~images/icon_likes.png\")}.column-link[href=\"/web/lists\"]{background-image:url(\"~images/icon_lists.png\")}.column-link[href=\"/web/lists\"]:hover{background-image:url(\"~images/icon_lists.png\")}.column-link[href=\"/web/follow_requests\"]{background-image:url(\"~images/icon_follow_requests.png\")}.column-link[href=\"/web/follow_requests\"]:hover{background-image:url(\"~images/icon_follow_requests.png\")}.column-link[href=\"/web/keyboard-shortcuts\"]{background-image:url(\"~images/icon_keyboard_shortcuts.png\")}.column-link[href=\"/web/keyboard-shortcuts\"]:hover{background-image:url(\"~images/icon_keyboard_shortcuts.png\")}.column-link[href=\"/web/blocks\"]{background-image:url(\"~images/icon_blocks.png\")}.column-link[href=\"/web/blocks\"]:hover{background-image:url(\"~images/icon_blocks.png\")}.column-link[href=\"/web/mutes\"]{background-image:url(\"~images/icon_mutes.png\")}.column-link[href=\"/web/mutes\"]:hover{background-image:url(\"~images/icon_mutes.png\")}.column-link[href=\"/settings/preferences\"]{background-image:url(\"~images/icon_settings.png\")}.column-link[href=\"/settings/preferences\"]:hover{background-image:url(\"~images/icon_settings.png\")}.column-link[href=\"/about/more\"]{background-image:url(\"~images/icon_about.png\")}.column-link[href=\"/about/more\"]:hover{background-image:url(\"~images/icon_about.png\")}.column-link[href=\"/auth/sign_out\"]{background-image:url(\"~images/icon_logout.png\")}.column-link[href=\"/auth/sign_out\"]:hover{background-image:url(\"~images/icon_logout.png\")}.getting-started__footer{display:none}.getting-started__wrapper::before{content:\"Mastodon 95\";font-weight:bold;font-size:23px;color:#fff;line-height:30px;padding-left:20px;padding-right:40px;left:0px;bottom:-30px;display:block;position:absolute;background-color:#7f7f7f;width:200%;height:30px;-ms-transform:rotate(-90deg);-webkit-transform:rotate(-90deg);transform:rotate(-90deg);transform-origin:top left}.getting-started__wrapper{border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;background-color:#bfbfbf}.column .static-content.getting-started{display:none}.keyboard-shortcuts kbd{background-color:#bfbfbf}.account__header{background-color:#7f7f7f}.account__header .account__header__content{color:#fff}.account-authorize__wrapper{border:2px groove #bfbfbf;margin:2px;padding:2px}.account--panel{background-color:#bfbfbf;border:0px;border-top:2px groove #bfbfbf}.account-authorize .account__header__content{color:#000;margin:10px}.account__action-bar__tab>span{color:#000;font-weight:bold}.account__action-bar__tab strong{color:#000}.account__action-bar{border:unset}.account__action-bar__tab{border:1px outset #bfbfbf}.account__action-bar__tab:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.dropdown--active .dropdown__content>ul,.dropdown-menu{background:#ffc;border-radius:0px;border:1px solid #000;box-shadow:unset}.dropdown-menu a{background-color:transparent}.dropdown--active::after{display:none}.dropdown--active .icon-button{color:#000;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.dropdown--active .dropdown__content>ul>li>a{background:transparent}.dropdown--active .dropdown__content>ul>li>a:hover{background:transparent;color:#000;text-decoration:underline}.dropdown__sep,.dropdown-menu__separator{border-color:#7f7f7f}.detailed-status__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__left{left:unset}.dropdown>.icon-button,.detailed-status__button>.icon-button,.status__action-bar>.icon-button,.star-icon i{height:25px !important;width:28px !important;box-sizing:border-box}.status__action-bar-button .fa-floppy-o{padding-top:2px}.status__action-bar-dropdown{position:relative;top:-3px}.detailed-status__action-bar-dropdown .dropdown{position:relative;top:-4px}.notification .status__action-bar{border-bottom:none}.notification .status{margin-bottom:4px}.status__wrapper .status{margin-bottom:3px}.status__wrapper{margin-bottom:8px}.icon-button .fa-retweet{position:relative;top:-1px}.embed-modal,.error-modal,.onboarding-modal,.actions-modal,.boost-modal,.confirmation-modal,.report-modal{box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;background:#bfbfbf}.actions-modal::before,.boost-modal::before,.confirmation-modal::before,.report-modal::before{content:\"Confirmation\";display:block;background:#00007f;color:#fff;font-weight:bold;padding-left:2px}.boost-modal::before{content:\"Boost confirmation\"}.boost-modal__action-bar>div>span:before{content:\"Tip: \";font-weight:bold}.boost-modal__action-bar,.confirmation-modal__action-bar,.report-modal__action-bar{background:#bfbfbf;margin-top:-15px}.embed-modal h4,.error-modal h4,.onboarding-modal h4{background:#00007f;color:#fff;font-weight:bold;padding:2px;font-size:13px;text-align:left}.confirmation-modal__action-bar .confirmation-modal__cancel-button{color:#000}.confirmation-modal__action-bar .confirmation-modal__cancel-button:active,.confirmation-modal__action-bar .confirmation-modal__cancel-button:focus,.confirmation-modal__action-bar .confirmation-modal__cancel-button:hover{color:#000}.confirmation-modal__action-bar .confirmation-modal__cancel-button:active{box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.embed-modal .embed-modal__container .embed-modal__html,.embed-modal .embed-modal__container .embed-modal__html:focus{background:#fff;color:#000;box-shadow:inset 1px 1px 0px #000,inset -1px -1px 0px #fff,inset 2px 2px 0px gray,inset -2px -2px 0px #dfdfdf;border-width:0px;border-radius:0px}.modal-root__overlay,.account__header>div{background:url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFnpUWHRUaXRsZQAACJnLzU9JzElKBwALgwLXaCRlPwAAABd6VFh0QXV0aG9yAAAImUvOKMrPS8wBAAuaAugOwWPyAAAAEUlEQVQImWNgYGD4z4AE/gMADwMB/414xEUAAAAASUVORK5CYII=\")}.admin-wrapper::before{position:absolute;top:0px;content:\"Control Panel\";color:#fff;background-color:#00007f;font-size:13px;font-weight:bold;width:calc(100%);margin:2px;display:block;padding:2px;padding-left:22px;box-sizing:border-box}.admin-wrapper{position:relative;background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;width:70vw;height:80vh;margin:10vh auto;color:#000;padding-top:24px;flex-direction:column;overflow:hidden}@media screen and (max-width: 1120px){.admin-wrapper{width:90vw;height:95vh;margin:2.5vh auto}}@media screen and (max-width: 740px){.admin-wrapper{width:100vw;height:95vh;height:calc(100vh - 24px);margin:0px 0px 0px 0px}}.admin-wrapper .sidebar-wrapper{position:static;height:auto;flex:0 0 auto;margin:2px}.admin-wrapper .content-wrapper{flex:1 1 auto;width:calc(100% - 20px);border-left:2px solid #efefef;border-top:2px solid #efefef;border-right:2px solid #404040;border-bottom:2px solid #404040;border-radius:0px;position:relative;margin-left:10px;margin-right:10px;margin-bottom:40px;box-sizing:border-box}.admin-wrapper .content{background-color:#bfbfbf;width:100%;max-width:100%;min-height:100%;box-sizing:border-box;position:relative}.admin-wrapper .sidebar{position:static;background:#bfbfbf;color:#000;width:100%;height:auto;padding-bottom:20px}.admin-wrapper .sidebar .logo{position:absolute;top:2px;left:4px;width:18px;height:18px;margin:0px}.admin-wrapper .sidebar>ul{background:#bfbfbf;margin:0px;margin-left:8px;color:#000}.admin-wrapper .sidebar>ul>li{display:inline-block}.admin-wrapper .sidebar>ul>li#settings,.admin-wrapper .sidebar>ul>li#admin{padding:2px;border:0px solid transparent}.admin-wrapper .sidebar>ul>li#logout{position:absolute;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;right:12px;bottom:10px}.admin-wrapper .sidebar>ul>li#web{display:inline-block;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;position:absolute;left:12px;bottom:10px}.admin-wrapper .sidebar>ul>li>a{display:inline-block;box-shadow:inset -1px 0px 0px #000,inset 1px 0px 0px #fff,inset 0px 1px 0px #fff,inset 0px 2px 0px #dfdfdf,inset -2px 0px 0px gray,inset 2px 0px 0px #dfdfdf;border-radius:0px;border-top-left-radius:1px;border-top-right-radius:1px;padding:2px 5px;margin:0px;color:#000;vertical-align:baseline}.admin-wrapper .sidebar>ul>li>a.selected{background:#bfbfbf;color:#000;padding-top:4px;padding-bottom:4px}.admin-wrapper .sidebar>ul>li>a:hover{background:#bfbfbf;color:#000}.admin-wrapper .sidebar>ul>li>ul{width:calc(100% - 20px);background:transparent;position:absolute;left:10px;top:54px;z-index:3}.admin-wrapper .sidebar>ul>li>ul>li{background:#bfbfbf;display:inline-block;vertical-align:baseline}.admin-wrapper .sidebar>ul>li>ul>li>a{background:#bfbfbf;box-shadow:inset -1px 0px 0px #000,inset 1px 0px 0px #fff,inset 0px 1px 0px #fff,inset 0px 2px 0px #dfdfdf,inset -2px 0px 0px gray,inset 2px 0px 0px #dfdfdf;border-radius:0px;border-top-left-radius:1px;border-top-right-radius:1px;color:#000;padding:2px 5px;position:relative;z-index:3}.admin-wrapper .sidebar>ul>li>ul>li>a.selected{background:#bfbfbf;color:#000;padding-bottom:4px;padding-top:4px;padding-right:7px;margin-left:-2px;margin-right:-2px;position:relative;z-index:4}.admin-wrapper .sidebar>ul>li>ul>li>a.selected:first-child{margin-left:0px}.admin-wrapper .sidebar>ul>li>ul>li>a.selected:hover{background:transparent;color:#000}.admin-wrapper .sidebar>ul>li>ul>li>a:hover{background:#bfbfbf;color:#000}@media screen and (max-width: 1520px){.admin-wrapper .sidebar>ul>li>ul{max-width:1000px}.admin-wrapper .sidebar{padding-bottom:45px}}@media screen and (max-width: 600px){.admin-wrapper .sidebar>ul>li>ul{max-width:500px}.admin-wrapper .sidebar{padding:0px;padding-bottom:70px;width:100%;height:auto}.admin-wrapper .content-wrapper{overflow:auto;height:80%;height:calc(100% - 150px)}}.flash-message{background-color:#ffc;color:#000;border:1px solid #000;border-radius:0px;position:absolute;top:0px;left:0px;width:100%}.admin-wrapper table{background-color:#fff;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.admin-wrapper .content h2,.simple_form .input.with_label .label_input>label,.admin-wrapper .content h6,.admin-wrapper .content>p,.admin-wrapper .content .muted-hint,.simple_form span.hint,.simple_form h4,.simple_form .check_boxes .checkbox label,.simple_form .input.with_label.boolean .label_input>label,.filters .filter-subset a,.simple_form .input.radio_buttons .radio label,a.table-action-link,a.table-action-link:hover,.simple_form .input.with_block_label>label,.simple_form p.hint{color:#000}.table>tbody>tr:nth-child(2n+1)>td,.table>tbody>tr:nth-child(2n+1)>th{background-color:#fff}.simple_form input[type=text],.simple_form input[type=number],.simple_form input[type=email],.simple_form input[type=password],.simple_form textarea{color:#000;background-color:#fff;border-left:1px solid #404040;border-top:1px solid #404040;border-right:1px solid #efefef;border-bottom:1px solid #efefef;border-radius:0px}.simple_form input[type=text]:active,.simple_form input[type=text]:focus,.simple_form input[type=number]:active,.simple_form input[type=number]:focus,.simple_form input[type=email]:active,.simple_form input[type=email]:focus,.simple_form input[type=password]:active,.simple_form input[type=password]:focus,.simple_form textarea:active,.simple_form textarea:focus{background-color:#fff}.simple_form button,.simple_form .button,.simple_form .block-button{background:#bfbfbf;box-shadow:inset -1px -1px 0px #000,inset 1px 1px 0px #fff,inset -2px -2px 0px gray,inset 2px 2px 0px #dfdfdf;border-radius:0px;color:#000;font-weight:normal}.simple_form button:hover,.simple_form .button:hover,.simple_form .block-button:hover{background:#bfbfbf}.simple_form .warning,.table-form .warning{background:#ffc;color:#000;box-shadow:unset;text-shadow:unset;border:1px solid #000}.simple_form .warning a,.table-form .warning a{color:blue;text-decoration:underline}.simple_form button.negative,.simple_form .button.negative,.simple_form .block-button.negative{background:#bfbfbf}.filters .filter-subset{border:2px groove #bfbfbf;padding:2px}.filters .filter-subset a::before{content:\"\";background-color:#fff;border-radius:50%;border:2px solid #000;border-top-color:#7f7f7f;border-left-color:#7f7f7f;border-bottom-color:#f5f5f5;border-right-color:#f5f5f5;width:12px;height:12px;display:inline-block;vertical-align:middle;margin-right:2px}.filters .filter-subset a.selected::before{background-color:#000;box-shadow:inset 0 0 0 3px #fff}.filters .filter-subset a,.filters .filter-subset a:hover,.filters .filter-subset a.selected{color:#000;border-bottom:0px solid transparent}","// win95 theme from cybrespace.\n\n// Modified by kibi! to use webpack package syntax for urls (eg,\n// `url(~images/…)`) for easy importing into skins.\n\n$win95-bg: #bfbfbf;\n$win95-dark-grey: #404040;\n$win95-mid-grey: #808080;\n$win95-window-header: #00007f;\n$win95-tooltip-yellow: #ffffcc;\n$win95-blue: blue;\n\n$ui-base-lighter-color: $win95-dark-grey;\n$ui-highlight-color: $win95-window-header;\n\n@mixin win95-border-outset() {\n border-left: 2px solid #efefef;\n border-top: 2px solid #efefef;\n border-right: 2px solid #404040;\n border-bottom: 2px solid #404040;\n border-radius:0px;\n}\n\n@mixin win95-outset() {\n box-shadow: inset -1px -1px 0px #000000,\n inset 1px 1px 0px #ffffff,\n inset -2px -2px 0px #808080,\n inset 2px 2px 0px #dfdfdf;\n border-radius:0px;\n}\n\n@mixin win95-border-inset() {\n border-left: 2px solid #404040;\n border-top: 2px solid #404040;\n border-right: 2px solid #efefef;\n border-bottom: 2px solid #efefef;\n border-radius:0px;\n}\n\n@mixin win95-border-slight-inset() {\n border-left: 1px solid #404040;\n border-top: 1px solid #404040;\n border-right: 1px solid #efefef;\n border-bottom: 1px solid #efefef;\n border-radius:0px;\n}\n\n@mixin win95-inset() {\n box-shadow: inset 1px 1px 0px #000000,\n inset -1px -1px 0px #ffffff,\n inset 2px 2px 0px #808080,\n inset -2px -2px 0px #dfdfdf;\n border-width:0px;\n border-radius:0px;\n}\n\n@mixin win95-tab() {\n box-shadow: inset -1px 0px 0px #000000,\n inset 1px 0px 0px #ffffff,\n inset 0px 1px 0px #ffffff,\n inset 0px 2px 0px #dfdfdf,\n inset -2px 0px 0px #808080,\n inset 2px 0px 0px #dfdfdf;\n border-radius:0px;\n border-top-left-radius: 1px;\n border-top-right-radius: 1px;\n}\n\n@mixin win95-reset() {\n box-shadow: unset;\n}\n\n@font-face {\n font-family:\"premillenium\";\n src: url('~fonts/premillenium/MSSansSerif.ttf') format('truetype');\n}\n\n@import 'application';\n\n/* borrowed from cybrespace style: wider columns and full column width images */\n\n@media screen and (min-width: 1300px) {\n .column {\n flex-grow: 1 !important;\n max-width: 400px;\n }\n\n .drawer {\n width: 17%;\n max-width: 400px;\n min-width: 330px;\n }\n}\n\n.media-gallery,\n.video-player {\n max-height:30vh;\n height:30vh !important;\n position:relative;\n margin-top:20px;\n margin-left:-68px;\n width: calc(100% + 80px) !important;\n max-width: calc(100% + 80px);\n}\n\n.detailed-status .media-gallery,\n.detailed-status .video-player {\n margin-left:-5px;\n width: calc(100% + 9px);\n max-width: calc(100% + 9px);\n}\n\n.video-player video {\n transform: unset;\n top: unset;\n}\n\n.detailed-status .media-spoiler,\n.status .media-spoiler {\n height: 100%!important;\n vertical-align: middle;\n}\n\n/* main win95 style */\n\nbody {\n font-size:13px;\n font-family: \"MS Sans Serif\", \"premillenium\", sans-serif;\n color:black;\n}\n\n.ui,\n.ui .columns-area,\nbody.admin {\n background: #008080;\n}\n\n.loading-bar {\n height:5px;\n background-color: #000080;\n}\n\n.tabs-bar {\n background: $win95-bg;\n @include win95-outset();\n height: 30px;\n}\n\n.tabs-bar__link {\n color:black;\n border:2px outset $win95-bg;\n border-top-width: 1px;\n border-left-width: 1px;\n margin:2px;\n padding:3px;\n}\n\n.tabs-bar__link.active {\n @include win95-inset();\n color:black;\n}\n\n.tabs-bar__link:last-child::before {\n content:\"Start\";\n color:black;\n font-weight:bold;\n font-size:15px;\n width:80%;\n display:block;\n position:absolute;\n right:0px;\n}\n\n.tabs-bar__link:last-child {\n position:relative;\n flex-basis:60px !important;\n font-size:0px;\n color:$win95-bg;\n\n background-image: url(\"~images/start.png\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n}\n\n.drawer .drawer__inner {\n overflow: visible;\n height:inherit;\n background:$win95-bg;\n}\n\n.drawer:after {\n display:block;\n content: \" \";\n\n position:absolute;\n bottom:15px;\n left:15px;\n width:132px;\n height:117px;\n background-image:url(\"~images/clippy_wave.gif\"), url(\"~images/clippy_frame.png\");\n background-repeat:no-repeat;\n background-position: 4px 20px, 0px 0px;\n z-index:0;\n}\n\n.drawer__pager {\n overflow-y:auto;\n z-index:1;\n}\n\n.privacy-dropdown__dropdown {\n z-index:2;\n}\n\n.column {\n max-height:100vh;\n}\n\n.column > .scrollable {\n background: $win95-bg;\n @include win95-border-outset();\n border-top-width:0px;\n}\n\n.column-header__wrapper {\n color:white;\n font-weight:bold;\n background:#7f7f7f;\n}\n\n.column-header {\n padding:2px;\n font-size:13px;\n background:#7f7f7f;\n @include win95-border-outset();\n border-bottom-width:0px;\n color:white;\n font-weight:bold;\n align-items:baseline;\n}\n\n.column-header__wrapper.active {\n background:$win95-window-header;\n}\n\n.column-header__wrapper.active::before {\n display:none;\n}\n.column-header.active {\n box-shadow:unset;\n background:$win95-window-header;\n}\n\n.column-header.active .column-header__icon {\n color:white;\n}\n\n.column-header__buttons {\n max-height: 20px;\n margin-right:0px;\n}\n\n.column-header__button {\n background: $win95-bg;\n color: black;\n line-height:0px;\n font-size:14px;\n max-height:20px;\n padding:0px 2px;\n margin-top:2px;\n @include win95-outset();\n\n &:hover {\n color: black;\n }\n}\n\n.column-header__button.active, .column-header__button.active:hover {\n @include win95-inset();\n background-color:#7f7f7f;\n}\n\n.column-header__back-button {\n background: $win95-bg;\n color: black;\n padding:2px;\n max-height:20px;\n margin-top:2px;\n @include win95-outset();\n font-size:13px;\n font-weight:bold;\n}\n\n.column-back-button {\n background:$win95-bg;\n color:black;\n @include win95-outset();\n padding:2px;\n font-size:13px;\n font-weight:bold;\n}\n\n.column-back-button--slim-button {\n position:absolute;\n top:-22px;\n right:4px;\n max-height:20px;\n max-width:60px;\n padding:0px 2px;\n}\n\n.column-back-button__icon {\n font-size:11px;\n margin-top:-3px;\n}\n\n.column-header__collapsible {\n border-left:2px outset $win95-bg;\n border-right:2px outset $win95-bg;\n}\n\n.column-header__collapsible-inner {\n background:$win95-bg;\n color:black;\n}\n\n.column-header__collapsible__extra {\n color:black;\n}\n\n.column-header__collapsible__extra div[role=\"group\"] {\n border: 2px groove $win95-bg;\n border-radius:4px;\n margin-bottom:8px;\n padding:4px;\n}\n\n.column-inline-form {\n background-color: $win95-bg;\n @include win95-border-outset();\n border-bottom-width:0px;\n border-top-width:0px;\n}\n\n.column-settings__section {\n color:black;\n font-weight:bold;\n font-size:11px;\n position:relative;\n top: -12px;\n left:4px;\n background-color:$win95-bg;\n display:inline-block;\n padding:0px 4px;\n margin-bottom:0px;\n}\n\n.setting-meta__label, .setting-toggle__label {\n color:black;\n font-weight:normal;\n}\n\n.setting-meta__label span:before {\n content:\"(\";\n}\n.setting-meta__label span:after {\n content:\")\";\n}\n\n.setting-toggle {\n line-height:13px;\n}\n\n.react-toggle .react-toggle-track {\n border-radius:0px;\n background-color:white;\n @include win95-border-inset();\n\n width:12px;\n height:12px;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color:white;\n}\n\n.react-toggle .react-toggle-track-check {\n left:2px;\n transition:unset;\n}\n\n.react-toggle .react-toggle-track-check svg path {\n fill: black;\n}\n\n.react-toggle .react-toggle-track-x {\n display:none;\n}\n\n.react-toggle .react-toggle-thumb {\n border-radius:0px;\n display:none;\n}\n\n.text-btn {\n background-color:$win95-bg;\n @include win95-outset();\n padding:4px;\n}\n\n.text-btn:hover {\n text-decoration:none;\n color:black;\n}\n\n.text-btn:active {\n @include win95-inset();\n}\n\n.setting-text {\n color:black;\n background-color:white;\n @include win95-inset();\n font-size:13px;\n padding:2px;\n}\n\n.setting-text:active, .setting-text:focus,\n.setting-text.light:active, .setting-text.light:focus {\n color:black;\n border-bottom:2px inset $win95-bg;\n}\n\n.column-header__setting-arrows .column-header__setting-btn {\n padding:3px 10px;\n}\n\n.column-header__setting-arrows .column-header__setting-btn:last-child {\n padding:3px 10px;\n}\n\n.missing-indicator {\n background-color:$win95-bg;\n color:black;\n @include win95-outset();\n}\n\n.missing-indicator > div {\n background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRUaXRsZQAACJnLyy9Jyy/NSwEAD5IDblIFOhoAAAAXelRYdEF1dGhvcgAACJlLzijKz0vMAQALmgLoDsFj8gAAAQpJREFUOMuVlD0OwjAMhd2oQl04Axfo0IGBgYELcAY6cqQuSO0ZOEAZGBg6VKg74gwsEaoESRVHjusI8aQqzY8/PbtOEz1qkFSn2YevlaNOpLMJh2DwvixhuXtOa6/LCh51DUMEFkAsgAZD207Doin8mQ562JpRE5CHBAAhmIqD1L8AqzUUUJkxc6kr3AgAJ+NuvIWRdk7WcrKl0AUqcIBBHOiEbpS4m27mIL5Onfg3k0rgggeQuS2sDOGSahKR+glgqaGLgUJs951NN1q9D72cQqQWR9cr3sm9YcEssEuz6eEuZh2bu0aSOhQ1MBezu2O/+TVSvEFII3qLsZWrSA2AAUQIh1HpyP/kC++zjVSMj6ntAAAAAElFTkSuQmCC')\n no-repeat;\n background-position:center center;\n}\n\n.empty-column-indicator,\n.error-column {\n background: $win95-bg;\n color: black;\n}\n\n.status__wrapper {\n border: 2px groove $win95-bg;\n margin:4px;\n}\n\n.status {\n @include win95-border-slight-inset();\n background-color:white;\n margin:4px;\n padding-bottom:40px;\n margin-bottom:8px;\n}\n\n.status.status-direct {\n background-color:$win95-bg;\n}\n\n.status__content {\n font-size:13px;\n}\n\n.status.light .status__relative-time,\n.status.light .display-name span {\n color: #7f7f7f;\n}\n\n.status__action-bar {\n box-sizing:border-box;\n position:absolute;\n bottom:-1px;\n left:-1px;\n background:$win95-bg;\n width:calc(100% + 2px);\n padding-left:10px;\n padding: 4px 2px;\n padding-bottom:4px;\n border-bottom:2px groove $win95-bg;\n border-top:1px outset $win95-bg;\n text-align: right;\n}\n\n.status__wrapper .status__action-bar {\n border-bottom-width:0px;\n}\n\n.status__action-bar-button {\n float:right;\n}\n\n.status__action-bar-dropdown {\n margin-left:auto;\n margin-right:10px;\n\n .icon-button {\n min-width:28px;\n }\n}\n.status.light .status__content a {\n color:blue;\n}\n\n.focusable:focus {\n background: $win95-bg;\n .detailed-status__action-bar {\n background: $win95-bg;\n }\n\n .status, .detailed-status {\n background: white;\n outline:2px dotted $win95-mid-grey;\n }\n}\n\n.dropdown__trigger.icon-button {\n padding-right:6px;\n}\n\n.detailed-status__action-bar-dropdown .icon-button {\n min-width:28px;\n}\n\n.detailed-status {\n background:white;\n background-clip:padding-box;\n margin:4px;\n border: 2px groove $win95-bg;\n padding:4px;\n}\n\n.detailed-status__display-name {\n color:#7f7f7f;\n}\n\n.detailed-status__display-name strong {\n color:black;\n font-weight:bold;\n}\n.account__avatar,\n.account__avatar-overlay-base,\n.account__header__avatar,\n.account__avatar-overlay-overlay {\n @include win95-border-slight-inset();\n clip-path:none;\n filter: saturate(1.8) brightness(1.1);\n}\n\n.detailed-status__action-bar {\n background-color:$win95-bg;\n border:0px;\n border-bottom:2px groove $win95-bg;\n margin-bottom:8px;\n justify-items:left;\n padding-left:4px;\n}\n.icon-button {\n background:$win95-bg;\n @include win95-border-outset();\n padding:0px 0px 0px 0px;\n margin-right:4px;\n\n color:#3f3f3f;\n &.inverted, &:hover, &.inverted:hover, &:active, &:focus {\n color:#3f3f3f;\n }\n}\n\n.icon-button:active {\n @include win95-border-inset();\n}\n\n.status__action-bar > .icon-button {\n padding:0px 15px 0px 0px;\n min-width:25px;\n}\n\n.icon-button.star-icon,\n.icon-button.star-icon:active {\n background:transparent;\n border:none;\n}\n\n.icon-button.star-icon.active {\n color: $gold-star;\n &:active, &:hover, &:focus {\n color: $gold-star;\n }\n}\n\n.icon-button.star-icon > i {\n background:$win95-bg;\n @include win95-border-outset();\n padding-bottom:3px;\n}\n\n.icon-button.star-icon:active > i {\n @include win95-border-inset();\n}\n\n.text-icon-button {\n color:$win95-dark-grey;\n}\n\n.detailed-status__action-bar-dropdown {\n margin-left:auto;\n justify-content:right;\n padding-right:16px;\n}\n\n.detailed-status__button {\n flex:0 0 auto;\n}\n\n.detailed-status__button .icon-button {\n padding-left:2px;\n padding-right:25px;\n}\n\n.status-card {\n border-radius:0px;\n background:white;\n border: 1px solid black;\n color:black;\n}\n\n.status-card:hover {\n background-color:white;\n}\n\n.status-card__title {\n color:blue;\n text-decoration:underline;\n font-weight:bold;\n}\n\n.load-more {\n width:auto;\n margin:5px auto;\n background: $win95-bg;\n @include win95-outset();\n color:black;\n padding: 2px 5px;\n\n &:hover {\n background: $win95-bg;\n color:black;\n }\n}\n\n.status-card__description {\n color:black;\n}\n\n.account__display-name strong, .status__display-name strong {\n color:black;\n font-weight:bold;\n}\n\n.account .account__display-name {\n color:black;\n}\n\n.account {\n border-bottom: 2px groove $win95-bg;\n}\n\n.reply-indicator__content .status__content__spoiler-link, .status__content .status__content__spoiler-link {\n background:$win95-bg;\n @include win95-outset();\n}\n\n.reply-indicator__content .status__content__spoiler-link:hover, .status__content .status__content__spoiler-link:hover {\n background:$win95-bg;\n}\n\n.reply-indicator__content .status__content__spoiler-link:active, .status__content .status__content__spoiler-link:active {\n @include win95-inset();\n}\n\n.reply-indicator__content a, .status__content a {\n color:blue;\n}\n\n.notification {\n border: 2px groove $win95-bg;\n margin:4px;\n}\n\n.notification__message {\n color:black;\n font-size:13px;\n}\n\n.notification__display-name {\n font-weight:bold;\n}\n\n.drawer__header {\n background: $win95-bg;\n @include win95-border-outset();\n justify-content:left;\n margin-bottom:0px;\n padding-bottom:2px;\n border-bottom:2px groove $win95-bg;\n}\n\n.drawer__tab {\n color:black;\n @include win95-outset();\n padding:5px;\n margin:2px;\n flex: 0 0 auto;\n}\n\n.drawer__tab:first-child::before {\n content:\"Start\";\n color:black;\n font-weight:bold;\n font-size:15px;\n width:80%;\n display:block;\n position:absolute;\n right:0px;\n\n}\n\n.drawer__tab:first-child {\n position:relative;\n padding:5px 15px;\n width:40px;\n font-size:0px;\n color:$win95-bg;\n\n background-image: url(\"~images/start.png\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n}\n\n.drawer__header a:hover {\n background-color:transparent;\n}\n\n.drawer__header a:first-child:hover {\n background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n transition:unset;\n}\n\n.drawer__tab:first-child {\n\n}\n\n.search {\n background:$win95-bg;\n padding-top:2px;\n padding:2px;\n border:2px outset $win95-bg;\n border-top-width:0px;\n border-bottom: 2px groove $win95-bg;\n margin-bottom:0px;\n}\n\n.search input {\n background-color:white;\n color:black;\n @include win95-border-slight-inset();\n}\n\n.search__input:focus {\n background-color:white;\n}\n\n.search-popout {\n box-shadow: unset;\n color:black;\n border-radius:0px;\n background-color:$win95-tooltip-yellow;\n border:1px solid black;\n\n h4 {\n color:black;\n text-transform: none;\n font-weight:bold;\n }\n}\n\n.search-results__header {\n background-color: $win95-bg;\n color:black;\n border-bottom:2px groove $win95-bg;\n}\n\n.search-results__hashtag {\n color:blue;\n}\n\n.search-results__section .account:hover,\n.search-results__section .account:hover .account__display-name,\n.search-results__section .account:hover .account__display-name strong,\n.search-results__section .search-results__hashtag:hover {\n background-color:$win95-window-header;\n color:white;\n}\n\n.search__icon .fa {\n color:#808080;\n\n &.active {\n opacity:1.0;\n }\n\n &:hover {\n color: #808080;\n }\n}\n\n.drawer__inner,\n.drawer__inner.darker {\n background-color:$win95-bg;\n border: 2px outset $win95-bg;\n border-top-width:0px;\n}\n\n.navigation-bar {\n color:black;\n}\n\n.navigation-bar strong {\n color:black;\n font-weight:bold;\n}\n\n.compose-form .autosuggest-textarea__textarea,\n.compose-form .spoiler-input__input {\n border-radius:0px;\n @include win95-border-slight-inset();\n}\n\n.compose-form .autosuggest-textarea__textarea {\n border-bottom:0px;\n}\n\n.compose-form__uploads-wrapper {\n border-radius:0px;\n border-bottom:1px inset $win95-bg;\n border-top-width:0px;\n}\n\n.compose-form__upload-wrapper {\n border-left:1px inset $win95-bg;\n border-right:1px inset $win95-bg;\n}\n\n.compose-form .compose-form__buttons-wrapper {\n background-color: $win95-bg;\n border:2px groove $win95-bg;\n margin-top:4px;\n padding:4px 8px;\n}\n\n.compose-form__buttons {\n background-color:$win95-bg;\n border-radius:0px;\n box-shadow:unset;\n}\n\n.compose-form__buttons-separator {\n border-left: 2px groove $win95-bg;\n}\n\n.privacy-dropdown.active .privacy-dropdown__value.active,\n.advanced-options-dropdown.open .advanced-options-dropdown__value {\n background: $win95-bg;\n}\n\n.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {\n color: $win95-dark-grey;\n}\n\n.privacy-dropdown.active\n.privacy-dropdown__value {\n background: $win95-bg;\n box-shadow:unset;\n}\n\n.privacy-dropdown__option.active, .privacy-dropdown__option:hover,\n.privacy-dropdown__option.active:hover {\n background:$win95-window-header;\n}\n\n.privacy-dropdown__dropdown,\n.privacy-dropdown.active .privacy-dropdown__dropdown,\n.advanced-options-dropdown__dropdown,\n.advanced-options-dropdown.open .advanced-options-dropdown__dropdown\n{\n box-shadow:unset;\n color:black;\n @include win95-outset();\n background: $win95-bg;\n}\n\n.privacy-dropdown__option__content {\n color:black;\n}\n\n.privacy-dropdown__option__content strong {\n font-weight:bold;\n}\n\n.compose-form__warning::before {\n content:\"Tip:\";\n font-weight:bold;\n display:block;\n position:absolute;\n top:-10px;\n background-color:$win95-bg;\n font-size:11px;\n padding: 0px 5px;\n}\n\n.compose-form__warning {\n position:relative;\n box-shadow:unset;\n border:2px groove $win95-bg;\n background-color:$win95-bg;\n color:black;\n}\n\n.compose-form__warning a {\n color:blue;\n}\n\n.compose-form__warning strong {\n color:black;\n text-decoration:underline;\n}\n\n.compose-form__buttons button.active:last-child {\n @include win95-border-inset();\n background: #dfdfdf;\n color:#7f7f7f;\n}\n\n.compose-form__upload-thumbnail {\n border-radius:0px;\n border:2px groove $win95-bg;\n background-color:$win95-bg;\n padding:2px;\n box-sizing:border-box;\n}\n\n.compose-form__upload-thumbnail .icon-button {\n max-width:20px;\n max-height:20px;\n line-height:10px !important;\n}\n\n.compose-form__upload-thumbnail .icon-button::before {\n content:\"X\";\n font-size:13px;\n font-weight:bold;\n color:black;\n}\n\n.compose-form__upload-thumbnail .icon-button i {\n display:none;\n}\n\n.emoji-picker-dropdown__menu {\n z-index:2;\n}\n\n.emoji-dialog.with-search {\n box-shadow:unset;\n border-radius:0px;\n background-color:$win95-bg;\n border:1px solid black;\n box-sizing:content-box;\n\n}\n\n.emoji-dialog .emoji-search {\n color:black;\n background-color:white;\n border-radius:0px;\n @include win95-inset();\n}\n\n.emoji-dialog .emoji-search-wrapper {\n border-bottom:2px groove $win95-bg;\n}\n\n.emoji-dialog .emoji-category-title {\n color:black;\n font-weight:bold;\n}\n\n.reply-indicator {\n background-color:$win95-bg;\n border-radius:3px;\n border:2px groove $win95-bg;\n}\n\n.button {\n background-color:$win95-bg;\n @include win95-outset();\n border-radius:0px;\n color:black;\n font-weight:bold;\n\n &:hover, &:focus, &:disabled {\n background-color:$win95-bg;\n }\n\n &:active {\n @include win95-inset();\n }\n\n &:disabled {\n color: #808080;\n text-shadow: 1px 1px 0px #efefef;\n\n &:active {\n @include win95-outset();\n }\n }\n\n}\n\n#Getting-started {\n background-color:$win95-bg;\n @include win95-inset();\n border-bottom-width:0px;\n}\n\n#Getting-started::before {\n content:\"Start\";\n color:black;\n font-weight:bold;\n font-size:15px;\n width:80%;\n text-align:center;\n display:block;\n position:absolute;\n right:2px;\n}\n\n#Getting-started {\n position:relative;\n padding:5px 15px;\n width:60px;\n font-size:0px;\n color:$win95-bg;\n\n background-image: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAOCAIAAACpTQvdAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAF3pUWHRBdXRob3IAAAiZS84oys9LzAEAC5oC6A7BY/IAAACWSURBVCiRhVJJDsQgDEuqOfRZ7a1P5gbP4uaJaEjTADMWQhHYjlk4p0wLnNdptdF4KvBUDyGzVwc2xO+uKtH+1o0ytEEmqFpuxlvFCGCxKbNIT56QCi2MzaA/2Mz+mERSOeqzJG2RUxkjdTabgPtFoZ1bZxcKvgPcLZVufAyR9Ni8v5dWDzfFx0giC1RvZFv6l35QQ/Mvv39XXgGzQpoAAAAASUVORK5CYII=\");\n background-repeat:no-repeat;\n background-position:8%;\n background-clip:padding-box;\n background-size:auto 50%;\n}\n\n.column-subheading {\n background-color:$win95-bg;\n color:black;\n border-bottom: 2px groove $win95-bg;\n text-transform: none;\n font-size: 16px;\n}\n\n.column-link {\n background-color:transparent;\n color:black;\n &:hover {\n background-color: $win95-window-header;\n color:white;\n }\n}\n\n.getting-started__wrapper {\n .column-subheading {\n font-size:0px;\n margin:0px;\n padding:0px;\n }\n\n .column-link {\n background-size:32px 32px;\n background-repeat:no-repeat;\n background-position: 36px 50%;\n padding-left:40px;\n\n &:hover {\n background-size:32px 32px;\n background-repeat:no-repeat;\n background-position: 36px 50%;\n }\n\n i {\n font-size: 0px;\n width:32px;\n }\n }\n}\n\n.column-link[href=\"/web/timelines/public\"] {\n background-image: url(\"~images/icon_public.png\");\n &:hover { background-image: url(\"~images/icon_public.png\"); }\n}\n.column-link[href=\"/web/timelines/public/local\"] {\n background-image: url(\"~images/icon_local.png\");\n &:hover { background-image: url(\"~images/icon_local.png\"); }\n}\n.column-link[href=\"/web/pinned\"] {\n background-image: url(\"~images/icon_pin.png\");\n &:hover { background-image: url(\"~images/icon_pin.png\"); }\n}\n.column-link[href=\"/web/favourites\"] {\n background-image: url(\"~images/icon_likes.png\");\n &:hover { background-image: url(\"~images/icon_likes.png\"); }\n}\n.column-link[href=\"/web/lists\"] {\n background-image: url(\"~images/icon_lists.png\");\n &:hover { background-image: url(\"~images/icon_lists.png\"); }\n}\n.column-link[href=\"/web/follow_requests\"] {\n background-image: url(\"~images/icon_follow_requests.png\");\n &:hover { background-image: url(\"~images/icon_follow_requests.png\"); }\n}\n.column-link[href=\"/web/keyboard-shortcuts\"] {\n background-image: url(\"~images/icon_keyboard_shortcuts.png\");\n &:hover { background-image: url(\"~images/icon_keyboard_shortcuts.png\"); }\n}\n.column-link[href=\"/web/blocks\"] {\n background-image: url(\"~images/icon_blocks.png\");\n &:hover { background-image: url(\"~images/icon_blocks.png\"); }\n}\n.column-link[href=\"/web/mutes\"] {\n background-image: url(\"~images/icon_mutes.png\");\n &:hover { background-image: url(\"~images/icon_mutes.png\"); }\n}\n.column-link[href=\"/settings/preferences\"] {\n background-image: url(\"~images/icon_settings.png\");\n &:hover { background-image: url(\"~images/icon_settings.png\"); }\n}\n.column-link[href=\"/about/more\"] {\n background-image: url(\"~images/icon_about.png\");\n &:hover { background-image: url(\"~images/icon_about.png\"); }\n}\n.column-link[href=\"/auth/sign_out\"] {\n background-image: url(\"~images/icon_logout.png\");\n &:hover { background-image: url(\"~images/icon_logout.png\"); }\n}\n\n.getting-started__footer {\n display:none;\n}\n\n.getting-started__wrapper::before {\n content:\"Mastodon 95\";\n font-weight:bold;\n font-size:23px;\n color:white;\n line-height:30px;\n padding-left:20px;\n padding-right:40px;\n\n left:0px;\n bottom:-30px;\n display:block;\n position:absolute;\n background-color:#7f7f7f;\n width:200%;\n height:30px;\n\n -ms-transform: rotate(-90deg);\n\n -webkit-transform: rotate(-90deg);\n transform: rotate(-90deg);\n transform-origin:top left;\n}\n\n.getting-started__wrapper {\n @include win95-border-outset();\n background-color:$win95-bg;\n}\n\n.column .static-content.getting-started {\n display:none;\n}\n\n.keyboard-shortcuts kbd {\n background-color: $win95-bg;\n}\n\n.account__header {\n background-color:#7f7f7f;\n}\n\n.account__header .account__header__content {\n color:white;\n}\n\n.account-authorize__wrapper {\n border: 2px groove $win95-bg;\n margin: 2px;\n padding:2px;\n}\n\n.account--panel {\n background-color: $win95-bg;\n border:0px;\n border-top: 2px groove $win95-bg;\n}\n\n.account-authorize .account__header__content {\n color:black;\n margin:10px;\n}\n\n.account__action-bar__tab > span {\n color:black;\n font-weight:bold;\n}\n\n.account__action-bar__tab strong {\n color:black;\n}\n\n.account__action-bar {\n border: unset;\n}\n\n.account__action-bar__tab {\n border: 1px outset $win95-bg;\n}\n\n.account__action-bar__tab:active {\n @include win95-inset();\n}\n\n.dropdown--active .dropdown__content > ul,\n.dropdown-menu {\n background:$win95-tooltip-yellow;\n border-radius:0px;\n border:1px solid black;\n box-shadow:unset;\n}\n\n.dropdown-menu a {\n background-color:transparent;\n}\n\n.dropdown--active::after {\n display:none;\n}\n\n.dropdown--active .icon-button {\n color:black;\n @include win95-inset();\n}\n\n.dropdown--active .dropdown__content > ul > li > a {\n background:transparent;\n}\n\n.dropdown--active .dropdown__content > ul > li > a:hover {\n background:transparent;\n color:black;\n text-decoration:underline;\n}\n\n.dropdown__sep,\n.dropdown-menu__separator\n{\n border-color:#7f7f7f;\n}\n\n.detailed-status__action-bar-dropdown .dropdown--active .dropdown__content.dropdown__left {\n left:unset;\n}\n\n.dropdown > .icon-button, .detailed-status__button > .icon-button,\n.status__action-bar > .icon-button, .star-icon i {\n /* i don't know what's going on with the inline\n styles someone should look at the react code */\n height: 25px !important;\n width: 28px !important;\n box-sizing: border-box;\n}\n\n.status__action-bar-button .fa-floppy-o {\n padding-top: 2px;\n}\n\n.status__action-bar-dropdown {\n position: relative;\n top: -3px;\n}\n\n.detailed-status__action-bar-dropdown .dropdown {\n position: relative;\n top: -4px;\n}\n\n.notification .status__action-bar {\n border-bottom: none;\n}\n\n.notification .status {\n margin-bottom: 4px;\n}\n\n.status__wrapper .status {\n margin-bottom: 3px;\n}\n\n.status__wrapper {\n margin-bottom: 8px;\n}\n\n.icon-button .fa-retweet {\n position: relative;\n top: -1px;\n}\n\n.embed-modal, .error-modal, .onboarding-modal,\n.actions-modal, .boost-modal, .confirmation-modal, .report-modal {\n @include win95-outset();\n background:$win95-bg;\n}\n\n.actions-modal::before,\n.boost-modal::before,\n.confirmation-modal::before,\n.report-modal::before {\n content: \"Confirmation\";\n display:block;\n background:$win95-window-header;\n color:white;\n font-weight:bold;\n padding-left:2px;\n}\n\n.boost-modal::before {\n content: \"Boost confirmation\";\n}\n\n.boost-modal__action-bar > div > span:before {\n content: \"Tip: \";\n font-weight:bold;\n}\n\n.boost-modal__action-bar, .confirmation-modal__action-bar, .report-modal__action-bar {\n background:$win95-bg;\n margin-top:-15px;\n}\n\n.embed-modal h4, .error-modal h4, .onboarding-modal h4 {\n background:$win95-window-header;\n color:white;\n font-weight:bold;\n padding:2px;\n font-size:13px;\n text-align:left;\n}\n\n.confirmation-modal__action-bar {\n .confirmation-modal__cancel-button {\n color:black;\n\n &:active,\n &:focus,\n &:hover {\n color:black;\n }\n\n &:active {\n @include win95-inset();\n }\n }\n}\n\n.embed-modal .embed-modal__container .embed-modal__html,\n.embed-modal .embed-modal__container .embed-modal__html:focus {\n background:white;\n color:black;\n @include win95-inset();\n}\n\n.modal-root__overlay,\n.account__header > div {\n background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFnpUWHRUaXRsZQAACJnLzU9JzElKBwALgwLXaCRlPwAAABd6VFh0QXV0aG9yAAAImUvOKMrPS8wBAAuaAugOwWPyAAAAEUlEQVQImWNgYGD4z4AE/gMADwMB/414xEUAAAAASUVORK5CYII=');\n}\n\n.admin-wrapper::before {\n position:absolute;\n top:0px;\n content:\"Control Panel\";\n color:white;\n background-color:$win95-window-header;\n font-size:13px;\n font-weight:bold;\n width:calc(100%);\n margin: 2px;\n display:block;\n padding:2px;\n padding-left:22px;\n box-sizing:border-box;\n}\n\n.admin-wrapper {\n position:relative;\n background: $win95-bg;\n @include win95-outset();\n width:70vw;\n height:80vh;\n margin:10vh auto;\n color: black;\n padding-top:24px;\n flex-direction:column;\n overflow:hidden;\n}\n\n@media screen and (max-width: 1120px) {\n .admin-wrapper {\n width:90vw;\n height:95vh;\n margin:2.5vh auto;\n }\n}\n\n@media screen and (max-width: 740px) {\n .admin-wrapper {\n width:100vw;\n height:95vh;\n height:calc(100vh - 24px);\n margin:0px 0px 0px 0px;\n }\n}\n\n.admin-wrapper .sidebar-wrapper {\n position:static;\n height:auto;\n flex: 0 0 auto;\n margin:2px;\n}\n\n.admin-wrapper .content-wrapper {\n flex: 1 1 auto;\n width:calc(100% - 20px);\n @include win95-border-outset();\n position:relative;\n margin-left:10px;\n margin-right:10px;\n margin-bottom:40px;\n box-sizing:border-box;\n}\n\n.admin-wrapper .content {\n background-color: $win95-bg;\n width: 100%;\n max-width:100%;\n min-height:100%;\n box-sizing:border-box;\n position:relative;\n}\n\n.admin-wrapper .sidebar {\n position:static;\n background: $win95-bg;\n color:black;\n width: 100%;\n height:auto;\n padding-bottom: 20px;\n}\n\n.admin-wrapper .sidebar .logo {\n position:absolute;\n top:2px;\n left:4px;\n width:18px;\n height:18px;\n margin:0px;\n}\n\n.admin-wrapper .sidebar > ul {\n background: $win95-bg;\n margin:0px;\n margin-left:8px;\n color:black;\n\n & > li {\n display:inline-block;\n\n &#settings,\n &#admin {\n padding:2px;\n border: 0px solid transparent;\n }\n\n &#logout {\n position:absolute;\n @include win95-outset();\n right:12px;\n bottom:10px;\n }\n\n &#web {\n display:inline-block;\n @include win95-outset();\n position:absolute;\n left: 12px;\n bottom: 10px;\n }\n\n & > a {\n display:inline-block;\n @include win95-tab();\n padding:2px 5px;\n margin:0px;\n color:black;\n vertical-align:baseline;\n\n &.selected {\n background: $win95-bg;\n color:black;\n padding-top: 4px;\n padding-bottom:4px;\n }\n\n &:hover {\n background: $win95-bg;\n color:black;\n }\n }\n\n & > ul {\n width:calc(100% - 20px);\n background: transparent;\n position:absolute;\n left: 10px;\n top:54px;\n z-index:3;\n\n & > li {\n background: $win95-bg;\n display: inline-block;\n vertical-align:baseline;\n\n & > a {\n background: $win95-bg;\n @include win95-tab();\n color:black;\n padding:2px 5px;\n position:relative;\n z-index:3;\n\n &.selected {\n background: $win95-bg;\n color:black;\n padding-bottom:4px;\n padding-top: 4px;\n padding-right:7px;\n margin-left:-2px;\n margin-right:-2px;\n position:relative;\n z-index:4;\n\n &:first-child {\n margin-left:0px;\n }\n\n &:hover {\n background: transparent;\n color:black;\n }\n }\n\n &:hover {\n background: $win95-bg;\n color:black;\n }\n }\n }\n }\n }\n}\n\n@media screen and (max-width: 1520px) {\n .admin-wrapper .sidebar > ul > li > ul {\n max-width:1000px;\n }\n\n .admin-wrapper .sidebar {\n padding-bottom: 45px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .admin-wrapper .sidebar > ul > li > ul {\n max-width:500px;\n }\n\n .admin-wrapper {\n .sidebar {\n padding:0px;\n padding-bottom: 70px;\n width: 100%;\n height: auto;\n }\n .content-wrapper {\n overflow:auto;\n height:80%;\n height:calc(100% - 150px);\n }\n }\n}\n\n.flash-message {\n background-color:$win95-tooltip-yellow;\n color:black;\n border:1px solid black;\n border-radius:0px;\n position:absolute;\n top:0px;\n left:0px;\n width:100%;\n}\n\n.admin-wrapper table {\n background-color: white;\n @include win95-border-slight-inset();\n}\n\n.admin-wrapper .content h2,\n.simple_form .input.with_label .label_input > label,\n.admin-wrapper .content h6,\n.admin-wrapper .content > p,\n.admin-wrapper .content .muted-hint,\n.simple_form span.hint,\n.simple_form h4,\n.simple_form .check_boxes .checkbox label,\n.simple_form .input.with_label.boolean .label_input > label,\n.filters .filter-subset a,\n.simple_form .input.radio_buttons .radio label,\na.table-action-link,\na.table-action-link:hover,\n.simple_form .input.with_block_label > label,\n.simple_form p.hint {\n color:black;\n}\n\n.table > tbody > tr:nth-child(2n+1) > td,\n.table > tbody > tr:nth-child(2n+1) > th {\n background-color:white;\n}\n\n.simple_form input[type=text],\n.simple_form input[type=number],\n.simple_form input[type=email],\n.simple_form input[type=password],\n.simple_form textarea {\n color:black;\n background-color:white;\n @include win95-border-slight-inset();\n\n &:active, &:focus {\n background-color:white;\n }\n}\n\n.simple_form button,\n.simple_form .button,\n.simple_form .block-button\n{\n background: $win95-bg;\n @include win95-outset();\n color:black;\n font-weight: normal;\n\n &:hover {\n background: $win95-bg;\n }\n}\n\n.simple_form .warning, .table-form .warning\n{\n background: $win95-tooltip-yellow;\n color:black;\n box-shadow: unset;\n text-shadow:unset;\n border:1px solid black;\n\n a {\n color: blue;\n text-decoration:underline;\n }\n}\n\n.simple_form button.negative,\n.simple_form .button.negative,\n.simple_form .block-button.negative\n{\n background: $win95-bg;\n}\n\n.filters .filter-subset {\n border: 2px groove $win95-bg;\n padding:2px;\n}\n\n.filters .filter-subset a::before {\n content: \"\";\n background-color:white;\n border-radius:50%;\n border:2px solid black;\n border-top-color:#7f7f7f;\n border-left-color:#7f7f7f;\n border-bottom-color:#f5f5f5;\n border-right-color:#f5f5f5;\n width:12px;\n height:12px;\n display:inline-block;\n vertical-align:middle;\n margin-right:2px;\n}\n\n.filters .filter-subset a.selected::before {\n background-color:black;\n box-shadow: inset 0 0 0 3px white;\n}\n\n.filters .filter-subset a,\n.filters .filter-subset a:hover,\n.filters .filter-subset a.selected {\n color:black;\n border-bottom: 0px solid transparent;\n}\n","/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\n\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n margin: 0;\n padding: 0;\n border: 0;\n font-size: 100%;\n font: inherit;\n vertical-align: baseline;\n}\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n display: block;\n}\n\nbody {\n line-height: 1;\n}\n\nol, ul {\n list-style: none;\n}\n\nblockquote, q {\n quotes: none;\n}\n\nblockquote:before, blockquote:after,\nq:before, q:after {\n content: '';\n content: none;\n}\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\nhtml {\n scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar {\n width: 12px;\n height: 12px;\n}\n\n::-webkit-scrollbar-thumb {\n background: lighten($ui-base-color, 4%);\n border: 0px none $base-border-color;\n border-radius: 50px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n background: lighten($ui-base-color, 6%);\n}\n\n::-webkit-scrollbar-thumb:active {\n background: lighten($ui-base-color, 4%);\n}\n\n::-webkit-scrollbar-track {\n border: 0px none $base-border-color;\n border-radius: 0;\n background: rgba($base-overlay-background, 0.1);\n}\n\n::-webkit-scrollbar-track:hover {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-track:active {\n background: $ui-base-color;\n}\n\n::-webkit-scrollbar-corner {\n background: transparent;\n}\n","// Commonly used web colors\n$black: #000000; // Black\n$white: #ffffff; // White\n$success-green: #79bd9a !default; // Padua\n$error-red: #df405a !default; // Cerise\n$warning-red: #ff5050 !default; // Sunset Orange\n$gold-star: #ca8f04 !default; // Dark Goldenrod\n\n$red-bookmark: $warning-red;\n\n// Pleroma-Dark colors\n$pleroma-bg: #121a24;\n$pleroma-fg: #182230;\n$pleroma-text: #b9b9ba;\n$pleroma-links: #d8a070;\n\n// Values from the classic Mastodon UI\n$classic-base-color: $pleroma-bg;\n$classic-primary-color: #9baec8;\n$classic-secondary-color: #d9e1e8;\n$classic-highlight-color: #d8a070;\n\n// Variables for defaults in UI\n$base-shadow-color: $black !default;\n$base-overlay-background: $black !default;\n$base-border-color: $white !default;\n$simple-background-color: $white !default;\n$valid-value-color: $success-green !default;\n$error-value-color: $error-red !default;\n\n// Tell UI to use selected colors\n$ui-base-color: $classic-base-color !default; // Darkest\n$ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest\n$ui-primary-color: $classic-primary-color !default; // Lighter\n$ui-secondary-color: $classic-secondary-color !default; // Lightest\n$ui-highlight-color: $classic-highlight-color !default;\n\n// Variables for texts\n$primary-text-color: $white !default;\n$darker-text-color: $ui-primary-color !default;\n$dark-text-color: $ui-base-lighter-color !default;\n$secondary-text-color: $ui-secondary-color !default;\n$highlight-text-color: $ui-highlight-color !default;\n$action-button-color: $ui-base-lighter-color !default;\n// For texts on inverted backgrounds\n$inverted-text-color: $ui-base-color !default;\n$lighter-text-color: $ui-base-lighter-color !default;\n$light-text-color: $ui-primary-color !default;\n\n// Language codes that uses CJK fonts\n$cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;\n\n// Variables for components\n$media-modal-media-max-width: 100%;\n// put margins on top and bottom of image to avoid the screen covered by image.\n$media-modal-media-max-height: 80%;\n\n$no-gap-breakpoint: 415px;\n\n$font-sans-serif: 'mastodon-font-sans-serif' !default;\n$font-display: 'mastodon-font-display' !default;\n$font-monospace: 'mastodon-font-monospace' !default;\n","@function hex-color($color) {\n @if type-of($color) == 'color' {\n $color: str-slice(ie-hex-str($color), 4);\n }\n\n @return '%23' + unquote($color);\n}\n\nbody {\n font-family: $font-sans-serif, sans-serif;\n background: darken($ui-base-color, 7%);\n font-size: 13px;\n line-height: 18px;\n font-weight: 400;\n color: $primary-text-color;\n text-rendering: optimizelegibility;\n font-feature-settings: \"kern\";\n text-size-adjust: none;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -webkit-tap-highlight-color: transparent;\n\n &.system-font {\n // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)\n // -apple-system => Safari <11 specific\n // BlinkMacSystemFont => Chrome <56 on macOS specific\n // Segoe UI => Windows 7/8/10\n // Oxygen => KDE\n // Ubuntu => Unity/Ubuntu\n // Cantarell => GNOME\n // Fira Sans => Firefox OS\n // Droid Sans => Older Androids (<4.0)\n // Helvetica Neue => Older macOS <10.11\n // $font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)\n font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\", $font-sans-serif, sans-serif;\n }\n\n &.app-body {\n padding: 0;\n\n &.layout-single-column {\n height: auto;\n min-height: 100vh;\n overflow-y: scroll;\n }\n\n &.layout-multiple-columns {\n position: absolute;\n width: 100%;\n height: 100%;\n }\n\n &.with-modals--active {\n overflow-y: hidden;\n }\n }\n\n &.lighter {\n background: $ui-base-color;\n }\n\n &.with-modals {\n overflow-x: hidden;\n overflow-y: scroll;\n\n &--active {\n overflow-y: hidden;\n }\n }\n\n &.player {\n text-align: center;\n }\n\n &.embed {\n background: lighten($ui-base-color, 4%);\n margin: 0;\n padding-bottom: 0;\n\n .container {\n position: absolute;\n width: 100%;\n height: 100%;\n overflow: hidden;\n }\n }\n\n &.admin {\n background: darken($ui-base-color, 4%);\n padding: 0;\n }\n\n &.error {\n position: absolute;\n text-align: center;\n color: $darker-text-color;\n background: $ui-base-color;\n width: 100%;\n height: 100%;\n padding: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n .dialog {\n vertical-align: middle;\n margin: 20px;\n\n &__illustration {\n img {\n display: block;\n max-width: 470px;\n width: 100%;\n height: auto;\n margin-top: -120px;\n }\n }\n\n h1 {\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n }\n }\n }\n}\n\nbutton {\n font-family: inherit;\n cursor: pointer;\n\n &:focus {\n outline: none;\n }\n}\n\n.app-holder {\n &,\n & > div,\n & > noscript {\n display: flex;\n width: 100%;\n align-items: center;\n justify-content: center;\n outline: 0 !important;\n }\n\n & > noscript {\n height: 100vh;\n }\n}\n\n.layout-single-column .app-holder {\n &,\n & > div {\n min-height: 100vh;\n }\n}\n\n.layout-multiple-columns .app-holder {\n &,\n & > div {\n height: 100%;\n }\n}\n\n.error-boundary,\n.app-holder noscript {\n flex-direction: column;\n font-size: 16px;\n font-weight: 400;\n line-height: 1.7;\n color: lighten($error-red, 4%);\n text-align: center;\n\n & > div {\n max-width: 500px;\n }\n\n p {\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $highlight-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n &__footer {\n color: $dark-text-color;\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n }\n }\n\n button {\n display: inline;\n border: 0;\n background: transparent;\n color: $dark-text-color;\n font: inherit;\n padding: 0;\n margin: 0;\n line-height: inherit;\n cursor: pointer;\n outline: 0;\n transition: color 300ms linear;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n\n &.copied {\n color: $valid-value-color;\n transition: none;\n }\n }\n}\n",".container-alt {\n width: 700px;\n margin: 0 auto;\n margin-top: 40px;\n\n @media screen and (max-width: 740px) {\n width: 100%;\n margin: 0;\n }\n}\n\n.logo-container {\n margin: 100px auto 50px;\n\n @media screen and (max-width: 500px) {\n margin: 40px auto 0;\n }\n\n h1 {\n display: flex;\n justify-content: center;\n align-items: center;\n\n svg {\n fill: $primary-text-color;\n height: 42px;\n margin-right: 10px;\n }\n\n a {\n display: flex;\n justify-content: center;\n align-items: center;\n color: $primary-text-color;\n text-decoration: none;\n outline: 0;\n padding: 12px 16px;\n line-height: 32px;\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 14px;\n }\n }\n}\n\n.compose-standalone {\n .compose-form {\n width: 400px;\n margin: 0 auto;\n padding: 20px 0;\n margin-top: 40px;\n box-sizing: border-box;\n\n @media screen and (max-width: 400px) {\n width: 100%;\n margin-top: 0;\n padding: 20px;\n }\n }\n}\n\n.account-header {\n width: 400px;\n margin: 0 auto;\n display: flex;\n font-size: 13px;\n line-height: 18px;\n box-sizing: border-box;\n padding: 20px 0;\n padding-bottom: 0;\n margin-bottom: -30px;\n margin-top: 40px;\n\n @media screen and (max-width: 440px) {\n width: 100%;\n margin: 0;\n margin-bottom: 10px;\n padding: 20px;\n padding-bottom: 0;\n }\n\n .avatar {\n width: 40px;\n height: 40px;\n margin-right: 8px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n }\n }\n\n .name {\n flex: 1 1 auto;\n color: $secondary-text-color;\n width: calc(100% - 88px);\n\n .username {\n display: block;\n font-weight: 500;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n }\n\n .logout-link {\n display: block;\n font-size: 32px;\n line-height: 40px;\n margin-left: 8px;\n }\n}\n\n.grid-3 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 3fr 1fr;\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 3;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1 / 3;\n grid-row: 3;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.grid-4 {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-column: 1 / 5;\n grid-row: 1;\n }\n\n .column-1 {\n grid-column: 1 / 4;\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 4;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 2 / 5;\n grid-row: 3;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .landing-page__call-to-action {\n min-height: 100%;\n }\n\n .flash-message {\n margin-bottom: 10px;\n }\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n .landing-page__call-to-action {\n padding: 20px;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n\n .row__information-board {\n width: 100%;\n justify-content: center;\n align-items: center;\n }\n\n .row__mascot {\n display: none;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n grid-template-columns: minmax(0, 100%);\n\n .column-0 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-column: 1;\n grid-row: 3;\n }\n\n .column-2 {\n grid-column: 1;\n grid-row: 2;\n }\n\n .column-3 {\n grid-column: 1;\n grid-row: 5;\n }\n\n .column-4 {\n grid-column: 1;\n grid-row: 4;\n }\n }\n}\n\n.public-layout {\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-top: 48px;\n }\n\n .container {\n max-width: 960px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n }\n }\n\n .header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n height: 48px;\n margin: 10px 0;\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n overflow: hidden;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: fixed;\n width: 100%;\n top: 0;\n left: 0;\n margin: 0;\n border-radius: 0;\n box-shadow: none;\n z-index: 110;\n }\n\n & > div {\n flex: 1 1 33.3%;\n min-height: 1px;\n }\n\n .nav-left {\n display: flex;\n align-items: stretch;\n justify-content: flex-start;\n flex-wrap: nowrap;\n }\n\n .nav-center {\n display: flex;\n align-items: stretch;\n justify-content: center;\n flex-wrap: nowrap;\n }\n\n .nav-right {\n display: flex;\n align-items: stretch;\n justify-content: flex-end;\n flex-wrap: nowrap;\n }\n\n .brand {\n display: block;\n padding: 15px;\n\n svg {\n display: block;\n height: 18px;\n width: auto;\n position: relative;\n bottom: -2px;\n fill: $primary-text-color;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n height: 20px;\n }\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n\n .nav-link {\n display: flex;\n align-items: center;\n padding: 0 1rem;\n font-size: 12px;\n font-weight: 500;\n text-decoration: none;\n color: $darker-text-color;\n white-space: nowrap;\n text-align: center;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n color: $primary-text-color;\n }\n\n @media screen and (max-width: 550px) {\n &.optional {\n display: none;\n }\n }\n }\n\n .nav-button {\n background: lighten($ui-base-color, 16%);\n margin: 8px;\n margin-left: 0;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n background: lighten($ui-base-color, 20%);\n }\n }\n }\n\n $no-columns-breakpoint: 600px;\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(300px, 3fr) minmax(298px, 1fr);\n grid-auto-columns: 25%;\n grid-auto-rows: max-content;\n\n .column-0 {\n grid-row: 1;\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 1;\n grid-column: 2;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n grid-template-columns: 100%;\n grid-gap: 0;\n\n .column-1 {\n display: none;\n }\n }\n }\n\n .directory__card {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n border-bottom: 0;\n }\n }\n\n .public-account-header {\n overflow: hidden;\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &.inactive {\n opacity: 0.5;\n\n .public-account-header__image,\n .avatar {\n filter: grayscale(100%);\n }\n\n .logo-button {\n background-color: $secondary-text-color;\n }\n }\n\n &__image {\n border-radius: 4px 4px 0 0;\n overflow: hidden;\n height: 300px;\n position: relative;\n background: darken($ui-base-color, 12%);\n\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 100%;\n height: 100%;\n box-shadow: inset 0 -1px 1px 1px rgba($base-shadow-color, 0.15);\n top: 0;\n left: 0;\n }\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n }\n\n &--no-bar {\n margin-bottom: 0;\n\n .public-account-header__image,\n .public-account-header__image img {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n\n &__image::after {\n display: none;\n }\n\n &__image,\n &__image img {\n border-radius: 0;\n }\n }\n\n &__bar {\n position: relative;\n margin-top: -80px;\n display: flex;\n justify-content: flex-start;\n\n &::before {\n content: \"\";\n display: block;\n background: lighten($ui-base-color, 4%);\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 60px;\n border-radius: 0 0 4px 4px;\n z-index: -1;\n }\n\n .avatar {\n display: block;\n width: 120px;\n height: 120px;\n padding-left: 20px - 4px;\n flex: 0 0 auto;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 50%;\n border: 4px solid lighten($ui-base-color, 4%);\n background: darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n padding: 5px;\n\n &::before {\n display: none;\n }\n\n .avatar {\n width: 48px;\n height: 48px;\n padding: 7px 0;\n padding-left: 10px;\n\n img {\n border: 0;\n border-radius: 4px;\n }\n\n @media screen and (max-width: 360px) {\n display: none;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n flex-wrap: wrap;\n }\n }\n\n &__tabs {\n flex: 1 1 auto;\n margin-left: 20px;\n\n &__name {\n padding-top: 20px;\n padding-bottom: 8px;\n\n h1 {\n font-size: 20px;\n line-height: 18px * 1.5;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n text-shadow: 1px 1px 1px $base-shadow-color;\n\n small {\n display: block;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n @media screen and (max-width: 600px) {\n margin-left: 15px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n\n &__name {\n padding-top: 0;\n padding-bottom: 0;\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n text-shadow: none;\n\n small {\n color: $darker-text-color;\n }\n }\n }\n }\n\n &__tabs {\n display: flex;\n justify-content: flex-start;\n align-items: stretch;\n height: 58px;\n\n .details-counters {\n display: flex;\n flex-direction: row;\n min-width: 300px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .details-counters {\n display: none;\n }\n }\n\n .counter {\n min-width: 33.3%;\n box-sizing: border-box;\n flex: 0 0 auto;\n color: $darker-text-color;\n padding: 10px;\n border-right: 1px solid lighten($ui-base-color, 4%);\n cursor: default;\n text-align: center;\n position: relative;\n\n a {\n display: block;\n }\n\n &:last-child {\n border-right: 0;\n }\n\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n border-bottom: 4px solid $ui-primary-color;\n opacity: 0.5;\n transition: all 400ms ease;\n }\n\n &.active {\n &::after {\n border-bottom: 4px solid $highlight-text-color;\n opacity: 1;\n }\n\n &.inactive::after {\n border-bottom-color: $secondary-text-color;\n }\n }\n\n &:hover {\n &::after {\n opacity: 1;\n transition-duration: 100ms;\n }\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n .counter-label {\n font-size: 12px;\n display: block;\n }\n\n .counter-number {\n font-weight: 500;\n font-size: 18px;\n margin-bottom: 5px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n height: 1px;\n }\n\n &__buttons {\n padding: 7px 8px;\n }\n }\n }\n\n &__extra {\n display: none;\n margin-top: 4px;\n\n .public-account-bio {\n border-radius: 0;\n box-shadow: none;\n background: transparent;\n margin: 0 -5px;\n\n .account__header__fields {\n border-top: 1px solid lighten($ui-base-color, 12%);\n }\n\n .roles {\n display: none;\n }\n }\n\n &__links {\n margin-top: -15px;\n font-size: 14px;\n color: $darker-text-color;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 15px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n flex: 100%;\n }\n }\n }\n\n .account__section-headline {\n border-radius: 4px 4px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n }\n\n .detailed-status__meta {\n margin-top: 25px;\n }\n\n .public-account-bio {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n margin-bottom: 0;\n border-radius: 0;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n padding: 20px;\n padding-bottom: 0;\n color: $primary-text-color;\n }\n\n &__extra,\n .roles {\n padding: 20px;\n font-size: 14px;\n color: $darker-text-color;\n }\n\n .roles {\n padding-bottom: 0;\n }\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n\n .icon-button {\n font-size: 18px;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .card-grid {\n display: flex;\n flex-wrap: wrap;\n min-width: 100%;\n margin: 0 -5px;\n\n & > div {\n box-sizing: border-box;\n flex: 1 0 auto;\n width: 300px;\n padding: 0 5px;\n margin-bottom: 10px;\n max-width: 33.333%;\n\n @media screen and (max-width: 900px) {\n max-width: 50%;\n }\n\n @media screen and (max-width: 600px) {\n max-width: 100%;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 8%);\n\n & > div {\n width: 100%;\n padding: 0;\n margin-bottom: 0;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n .card__bar {\n background: $ui-base-color;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n }\n }\n }\n}\n",".no-list {\n list-style: none;\n\n li {\n display: inline-block;\n margin: 0 5px;\n }\n}\n\n.recovery-codes {\n list-style: none;\n margin: 0 auto;\n\n li {\n font-size: 125%;\n line-height: 1.5;\n letter-spacing: 1px;\n }\n}\n",".public-layout {\n .footer {\n text-align: left;\n padding-top: 20px;\n padding-bottom: 60px;\n font-size: 12px;\n color: lighten($ui-base-color, 34%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding-left: 20px;\n padding-right: 20px;\n }\n\n .grid {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: 1fr 1fr 2fr 1fr 1fr;\n\n .column-0 {\n grid-column: 1;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-1 {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-2 {\n grid-column: 3;\n grid-row: 1;\n min-width: 0;\n text-align: center;\n\n h4 a {\n color: lighten($ui-base-color, 34%);\n }\n }\n\n .column-3 {\n grid-column: 4;\n grid-row: 1;\n min-width: 0;\n }\n\n .column-4 {\n grid-column: 5;\n grid-row: 1;\n min-width: 0;\n }\n\n @media screen and (max-width: 690px) {\n grid-template-columns: 1fr 2fr 1fr;\n\n .column-0,\n .column-1 {\n grid-column: 1;\n }\n\n .column-1 {\n grid-row: 2;\n }\n\n .column-2 {\n grid-column: 2;\n }\n\n .column-3,\n .column-4 {\n grid-column: 3;\n }\n\n .column-4 {\n grid-row: 2;\n }\n }\n\n @media screen and (max-width: 600px) {\n .column-1 {\n display: block;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .column-0,\n .column-1,\n .column-3,\n .column-4 {\n display: none;\n }\n }\n }\n\n h4 {\n text-transform: uppercase;\n font-weight: 700;\n margin-bottom: 8px;\n color: $darker-text-color;\n\n a {\n color: inherit;\n text-decoration: none;\n }\n }\n\n ul a {\n text-decoration: none;\n color: lighten($ui-base-color, 34%);\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n .brand {\n svg {\n display: block;\n height: 36px;\n width: auto;\n margin: 0 auto;\n fill: lighten($ui-base-color, 34%);\n }\n\n &:hover,\n &:focus,\n &:active {\n svg {\n fill: lighten($ui-base-color, 38%);\n }\n }\n }\n }\n}\n",".compact-header {\n h1 {\n font-size: 24px;\n line-height: 28px;\n color: $darker-text-color;\n font-weight: 500;\n margin-bottom: 20px;\n padding: 0 10px;\n word-wrap: break-word;\n\n @media screen and (max-width: 740px) {\n text-align: center;\n padding: 20px 10px 0;\n }\n\n a {\n color: inherit;\n text-decoration: none;\n }\n\n small {\n font-weight: 400;\n color: $secondary-text-color;\n }\n\n img {\n display: inline-block;\n margin-bottom: -5px;\n margin-right: 15px;\n width: 36px;\n height: 36px;\n }\n }\n}\n",".hero-widget {\n margin-bottom: 10px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__img {\n width: 100%;\n position: relative;\n overflow: hidden;\n border-radius: 4px 4px 0 0;\n background: $base-shadow-color;\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n border-radius: 4px 4px 0 0;\n }\n }\n\n &__text {\n background: $ui-base-color;\n padding: 20px;\n border-radius: 0 0 4px 4px;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n}\n\n.endorsements-widget {\n margin-bottom: 10px;\n padding-bottom: 10px;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n padding: 10px 0;\n\n &:last-child {\n border-bottom: 0;\n }\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n .trends__item {\n padding: 10px;\n }\n}\n\n.trends-widget {\n h4 {\n color: $darker-text-color;\n }\n}\n\n.box-widget {\n padding: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n}\n\n.placeholder-widget {\n padding: 16px;\n border-radius: 4px;\n border: 2px dashed $dark-text-color;\n text-align: center;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.contact-widget {\n min-height: 100%;\n font-size: 15px;\n color: $darker-text-color;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n padding: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n .account {\n border-bottom: 0;\n padding: 10px 0;\n padding-top: 5px;\n }\n\n & > a {\n display: inline-block;\n padding: 10px;\n padding-top: 0;\n color: $darker-text-color;\n text-decoration: none;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.moved-account-widget {\n padding: 15px;\n padding-bottom: 20px;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $secondary-text-color;\n font-weight: 400;\n margin-bottom: 10px;\n\n strong,\n a {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &.mention {\n text-decoration: none;\n\n span {\n text-decoration: none;\n }\n\n &:focus,\n &:hover,\n &:active {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__message {\n margin-bottom: 15px;\n\n .fa {\n margin-right: 5px;\n color: $darker-text-color;\n }\n }\n\n &__card {\n .detailed-status__display-avatar {\n position: relative;\n cursor: pointer;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n text-decoration: none;\n\n span {\n font-weight: 400;\n }\n }\n }\n}\n\n.memoriam-widget {\n padding: 20px;\n border-radius: 4px;\n background: $base-shadow-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n font-size: 14px;\n color: $darker-text-color;\n margin-bottom: 10px;\n}\n\n.page-header {\n background: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 60px 15px;\n text-align: center;\n margin: 10px 0;\n\n h1 {\n color: $primary-text-color;\n font-size: 36px;\n line-height: 1.1;\n font-weight: 700;\n margin-bottom: 10px;\n }\n\n p {\n font-size: 15px;\n color: $darker-text-color;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-top: 0;\n background: lighten($ui-base-color, 4%);\n\n h1 {\n font-size: 24px;\n }\n }\n}\n\n.directory {\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n &__tag {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n & > a,\n & > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: $ui-base-color;\n border-radius: 4px;\n padding: 15px;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n }\n\n & > a {\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 8%);\n }\n }\n\n &.active > a {\n background: $ui-highlight-color;\n cursor: default;\n }\n\n &.disabled > div {\n opacity: 0.5;\n cursor: default;\n }\n\n h4 {\n flex: 1 1 auto;\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n .fa {\n color: $darker-text-color;\n }\n\n small {\n display: block;\n font-weight: 400;\n font-size: 15px;\n margin-top: 8px;\n color: $darker-text-color;\n }\n }\n\n &.active h4 {\n &,\n .fa,\n small,\n .trends__item__current {\n color: $primary-text-color;\n }\n }\n\n .avatar-stack {\n flex: 0 0 auto;\n width: (36px + 4px) * 3;\n }\n\n &.active .avatar-stack .account__avatar {\n border-color: $ui-highlight-color;\n }\n\n .trends__item__current {\n padding-right: 0;\n }\n }\n}\n\n.avatar-stack {\n display: flex;\n justify-content: flex-end;\n\n .account__avatar {\n flex: 0 0 auto;\n width: 36px;\n height: 36px;\n border-radius: 50%;\n position: relative;\n margin-left: -10px;\n background: darken($ui-base-color, 8%);\n border: 2px solid $ui-base-color;\n\n &:nth-child(1) {\n z-index: 1;\n }\n\n &:nth-child(2) {\n z-index: 2;\n }\n\n &:nth-child(3) {\n z-index: 3;\n }\n }\n}\n\n.accounts-table {\n width: 100%;\n\n .account {\n padding: 0;\n border: 0;\n }\n\n strong {\n font-weight: 700;\n }\n\n thead th {\n text-align: center;\n text-transform: uppercase;\n color: $darker-text-color;\n font-weight: 700;\n padding: 10px;\n\n &:first-child {\n text-align: left;\n }\n }\n\n tbody td {\n padding: 15px 0;\n vertical-align: middle;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n tbody tr:last-child td {\n border-bottom: 0;\n }\n\n &__count {\n width: 120px;\n text-align: center;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n small {\n display: block;\n color: $darker-text-color;\n font-weight: 400;\n font-size: 14px;\n }\n }\n\n &__comment {\n width: 50%;\n vertical-align: initial !important;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n tbody td.optional {\n display: none;\n }\n }\n}\n\n.moved-account-widget,\n.memoriam-widget,\n.box-widget,\n.contact-widget,\n.landing-page__information.contact-widget,\n.directory,\n.page-header {\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n box-shadow: none;\n border-radius: 0;\n }\n}\n\n$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n\n.statuses-grid {\n min-height: 600px;\n\n @media screen and (max-width: 640px) {\n width: 100% !important; // Masonry layout is unnecessary at this width\n }\n\n &__item {\n width: (960px - 20px) / 3;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: (940px - 20px) / 3;\n }\n\n @media screen and (max-width: 640px) {\n width: 100%;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100vw;\n }\n }\n\n .detailed-status {\n border-radius: 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid lighten($ui-base-color, 16%);\n }\n\n &.compact {\n .detailed-status__meta {\n margin-top: 15px;\n }\n\n .status__content {\n font-size: 15px;\n line-height: 20px;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 20px;\n margin: 0;\n }\n }\n\n .media-gallery,\n .status-card,\n .video-player {\n margin-top: 15px;\n }\n }\n }\n}\n\n.notice-widget {\n margin-bottom: 10px;\n color: $darker-text-color;\n\n p {\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n font-size: 14px;\n line-height: 20px;\n }\n}\n\n.notice-widget,\n.placeholder-widget {\n a {\n text-decoration: none;\n font-weight: 500;\n color: $ui-highlight-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.table-of-contents {\n background: darken($ui-base-color, 4%);\n min-height: 100%;\n font-size: 14px;\n border-radius: 4px;\n\n li a {\n display: block;\n font-weight: 500;\n padding: 15px;\n overflow: hidden;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n text-decoration: none;\n color: $primary-text-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n li:last-child a {\n border-bottom: 0;\n }\n\n li ul {\n padding-left: 20px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n }\n}\n","$no-columns-breakpoint: 600px;\n\ncode {\n font-family: $font-monospace, monospace;\n font-weight: 400;\n}\n\n.form-container {\n max-width: 400px;\n padding: 20px;\n margin: 0 auto;\n}\n\n.simple_form {\n .input {\n margin-bottom: 15px;\n overflow: hidden;\n\n &.hidden {\n margin: 0;\n }\n\n &.radio_buttons {\n .radio {\n margin-bottom: 15px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n .radio > label {\n position: relative;\n padding-left: 28px;\n\n input {\n position: absolute;\n top: -2px;\n left: 0;\n }\n }\n }\n\n &.boolean {\n position: relative;\n margin-bottom: 0;\n\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n padding-top: 5px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .label_input,\n .hint {\n padding-left: 28px;\n }\n\n .label_input__wrapper {\n position: static;\n }\n\n label.checkbox {\n position: absolute;\n top: 2px;\n left: 0;\n }\n\n label a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n\n .recommended {\n position: absolute;\n margin: 0 4px;\n margin-top: -2px;\n }\n }\n }\n\n .row {\n display: flex;\n margin: 0 -5px;\n\n .input {\n box-sizing: border-box;\n flex: 1 1 auto;\n width: 50%;\n padding: 0 5px;\n }\n }\n\n .hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n code {\n border-radius: 3px;\n padding: 0.2em 0.4em;\n background: darken($ui-base-color, 12%);\n }\n\n li {\n list-style: disc;\n margin-left: 18px;\n }\n }\n\n ul.hint {\n margin-bottom: 15px;\n }\n\n span.hint {\n display: block;\n font-size: 12px;\n margin-top: 4px;\n }\n\n p.hint {\n margin-bottom: 15px;\n color: $darker-text-color;\n\n &.subtle-hint {\n text-align: center;\n font-size: 12px;\n line-height: 18px;\n margin-top: 15px;\n margin-bottom: 0;\n }\n }\n\n .card {\n margin-bottom: 15px;\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .input.with_floating_label {\n .label_input {\n display: flex;\n\n & > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n min-width: 150px;\n flex: 0 0 auto;\n }\n\n input,\n select {\n flex: 1 1 auto;\n }\n }\n\n &.select .hint {\n margin-top: 6px;\n margin-left: 150px;\n }\n }\n\n .input.with_label {\n .label_input > label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n margin-bottom: 8px;\n word-wrap: break-word;\n font-weight: 500;\n }\n\n .hint {\n margin-top: 6px;\n }\n\n ul {\n flex: 390px;\n }\n }\n\n .input.with_block_label {\n max-width: none;\n\n & > label {\n font-family: inherit;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n font-weight: 500;\n padding-top: 5px;\n }\n\n .hint {\n margin-bottom: 15px;\n }\n\n ul {\n columns: 2;\n }\n }\n\n .input.datetime .label_input select {\n display: inline-block;\n width: auto;\n flex: 0;\n }\n\n .required abbr {\n text-decoration: none;\n color: lighten($error-value-color, 12%);\n }\n\n .fields-group {\n margin-bottom: 25px;\n\n .input:last-child {\n margin-bottom: 0;\n }\n }\n\n .fields-row {\n display: flex;\n margin: 0 -10px;\n padding-top: 5px;\n margin-bottom: 25px;\n\n .input {\n max-width: none;\n }\n\n &__column {\n box-sizing: border-box;\n padding: 0 10px;\n flex: 1 1 auto;\n min-height: 1px;\n\n &-6 {\n max-width: 50%;\n }\n\n .actions {\n margin-top: 27px;\n }\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group {\n margin-bottom: 0;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n margin-bottom: 0;\n\n &__column {\n max-width: none;\n }\n\n .fields-group:last-child,\n .fields-row__column.fields-group,\n .fields-row__column {\n margin-bottom: 25px;\n }\n }\n }\n\n .input.radio_buttons .radio label {\n margin-bottom: 5px;\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: block;\n width: auto;\n }\n\n .check_boxes {\n .checkbox {\n label {\n font-family: inherit;\n font-size: 14px;\n color: $primary-text-color;\n display: inline-block;\n width: auto;\n position: relative;\n padding-top: 5px;\n padding-left: 25px;\n flex: 1 1 auto;\n }\n\n input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 5px;\n margin: 0;\n }\n }\n }\n\n .input.static .label_input__wrapper {\n font-size: 16px;\n padding: 10px;\n border: 1px solid $dark-text-color;\n border-radius: 4px;\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea {\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding: 10px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &:invalid {\n box-shadow: none;\n }\n\n &:focus:invalid:not(:placeholder-shown) {\n border-color: lighten($error-red, 12%);\n }\n\n &:required:valid {\n border-color: $valid-value-color;\n }\n\n &:hover {\n border-color: darken($ui-base-color, 20%);\n }\n\n &:active,\n &:focus {\n border-color: $highlight-text-color;\n background: darken($ui-base-color, 8%);\n }\n }\n\n .input.field_with_errors {\n label {\n color: lighten($error-red, 12%);\n }\n\n input[type=text],\n input[type=number],\n input[type=email],\n input[type=password],\n textarea,\n select {\n border-color: lighten($error-red, 12%);\n }\n\n .error {\n display: block;\n font-weight: 500;\n color: lighten($error-red, 12%);\n margin-top: 4px;\n }\n }\n\n .input.disabled {\n opacity: 0.5;\n }\n\n .actions {\n margin-top: 30px;\n display: flex;\n\n &.actions--top {\n margin-top: 0;\n margin-bottom: 30px;\n }\n }\n\n button,\n .button,\n .block-button {\n display: block;\n width: 100%;\n border: 0;\n border-radius: 4px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n font-size: 18px;\n line-height: inherit;\n height: auto;\n padding: 10px;\n text-transform: uppercase;\n text-decoration: none;\n text-align: center;\n box-sizing: border-box;\n cursor: pointer;\n font-weight: 500;\n outline: 0;\n margin-bottom: 10px;\n margin-right: 10px;\n\n &:last-child {\n margin-right: 0;\n }\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($ui-highlight-color, 5%);\n }\n\n &:disabled:hover {\n background-color: $ui-primary-color;\n }\n\n &.negative {\n background: $error-value-color;\n\n &:hover {\n background-color: lighten($error-value-color, 5%);\n }\n\n &:active,\n &:focus {\n background-color: darken($error-value-color, 5%);\n }\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 16px;\n color: $primary-text-color;\n display: block;\n width: 100%;\n outline: 0;\n font-family: inherit;\n resize: vertical;\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n padding-left: 10px;\n padding-right: 30px;\n height: 41px;\n }\n\n h4 {\n margin-bottom: 15px !important;\n }\n\n .label_input {\n &__wrapper {\n position: relative;\n }\n\n &__append {\n position: absolute;\n right: 3px;\n top: 1px;\n padding: 10px;\n padding-bottom: 9px;\n font-size: 16px;\n color: $dark-text-color;\n font-family: inherit;\n pointer-events: none;\n cursor: default;\n max-width: 140px;\n white-space: nowrap;\n overflow: hidden;\n\n &::after {\n content: '';\n display: block;\n position: absolute;\n top: 0;\n right: 0;\n bottom: 1px;\n width: 5px;\n background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n }\n\n &__overlay-area {\n position: relative;\n\n &__blurred form {\n filter: blur(2px);\n }\n\n &__overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: rgba($ui-base-color, 0.65);\n border-radius: 4px;\n margin-left: -4px;\n margin-top: -4px;\n padding: 4px;\n\n &__content {\n text-align: center;\n\n &.rich-formatting {\n &,\n p {\n color: $primary-text-color;\n }\n }\n }\n }\n }\n}\n\n.block-icon {\n display: block;\n margin: 0 auto;\n margin-bottom: 10px;\n font-size: 24px;\n}\n\n.flash-message {\n background: lighten($ui-base-color, 8%);\n color: $darker-text-color;\n border-radius: 4px;\n padding: 15px 10px;\n margin-bottom: 30px;\n text-align: center;\n\n &.notice {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n color: $valid-value-color;\n }\n\n &.alert {\n border: 1px solid rgba($error-value-color, 0.5);\n background: rgba($error-value-color, 0.25);\n color: $error-value-color;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n }\n\n p {\n margin-bottom: 15px;\n }\n\n .oauth-code {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.form-footer {\n margin-top: 30px;\n text-align: center;\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.quick-nav {\n list-style: none;\n margin-bottom: 25px;\n font-size: 14px;\n\n li {\n display: inline-block;\n margin-right: 10px;\n }\n\n a {\n color: $highlight-text-color;\n text-transform: uppercase;\n text-decoration: none;\n font-weight: 700;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($highlight-text-color, 8%);\n }\n }\n}\n\n.oauth-prompt,\n.follow-prompt {\n margin-bottom: 30px;\n color: $darker-text-color;\n\n h2 {\n font-size: 16px;\n margin-bottom: 30px;\n text-align: center;\n }\n\n strong {\n color: $secondary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n @media screen and (max-width: 740px) and (min-width: 441px) {\n margin-top: 40px;\n }\n}\n\n.qr-wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n}\n\n.qr-code {\n flex: 0 0 auto;\n background: $simple-background-color;\n padding: 4px;\n margin: 0 10px 20px 0;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n display: inline-block;\n\n svg {\n display: block;\n margin: 0;\n }\n}\n\n.qr-alternative {\n margin-bottom: 20px;\n color: $secondary-text-color;\n flex: 150px;\n\n samp {\n display: block;\n font-size: 14px;\n }\n}\n\n.table-form {\n p {\n margin-bottom: 15px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n}\n\n.simple_form,\n.table-form {\n .warning {\n box-sizing: border-box;\n background: rgba($error-value-color, 0.5);\n color: $primary-text-color;\n text-shadow: 1px 1px 0 rgba($base-shadow-color, 0.3);\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n padding: 10px;\n margin-bottom: 15px;\n\n a {\n color: $primary-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 600;\n display: block;\n margin-bottom: 5px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n\n .fa {\n font-weight: 400;\n }\n }\n }\n}\n\n.action-pagination {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n\n .actions,\n .pagination {\n flex: 1 1 auto;\n }\n\n .actions {\n padding: 30px 0;\n padding-right: 20px;\n flex: 0 0 auto;\n }\n}\n\n.post-follow-actions {\n text-align: center;\n color: $darker-text-color;\n\n div {\n margin-bottom: 4px;\n }\n}\n\n.alternative-login {\n margin-top: 20px;\n margin-bottom: 20px;\n\n h4 {\n font-size: 16px;\n color: $primary-text-color;\n text-align: center;\n margin-bottom: 20px;\n border: 0;\n padding: 0;\n }\n\n .button {\n display: block;\n }\n}\n\n.scope-danger {\n color: $warning-red;\n}\n\n.form_admin_settings_site_short_description,\n.form_admin_settings_site_description,\n.form_admin_settings_site_extended_description,\n.form_admin_settings_site_terms,\n.form_admin_settings_custom_css,\n.form_admin_settings_closed_registrations_message {\n textarea {\n font-family: $font-monospace, monospace;\n }\n}\n\n.input-copy {\n background: darken($ui-base-color, 10%);\n border: 1px solid darken($ui-base-color, 14%);\n border-radius: 4px;\n display: flex;\n align-items: center;\n padding-right: 4px;\n position: relative;\n top: 1px;\n transition: border-color 300ms linear;\n\n &__wrapper {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n background: transparent;\n border: 0;\n padding: 10px;\n font-size: 14px;\n font-family: $font-monospace, monospace;\n }\n\n button {\n flex: 0 0 auto;\n margin: 4px;\n text-transform: none;\n font-weight: 400;\n font-size: 14px;\n padding: 7px 18px;\n padding-bottom: 6px;\n width: auto;\n transition: background 300ms linear;\n }\n\n &.copied {\n border-color: $valid-value-color;\n transition: none;\n\n button {\n background: $valid-value-color;\n transition: none;\n }\n }\n}\n\n.connection-prompt {\n margin-bottom: 25px;\n\n .fa-link {\n background-color: darken($ui-base-color, 4%);\n border-radius: 100%;\n font-size: 24px;\n padding: 10px;\n }\n\n &__column {\n align-items: center;\n display: flex;\n flex: 1;\n flex-direction: column;\n flex-shrink: 1;\n max-width: 50%;\n\n &-sep {\n align-self: center;\n flex-grow: 0;\n overflow: visible;\n position: relative;\n z-index: 1;\n }\n\n p {\n word-break: break-word;\n }\n }\n\n .account__avatar {\n margin-bottom: 20px;\n }\n\n &__connection {\n background-color: lighten($ui-base-color, 8%);\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n padding: 25px 10px;\n position: relative;\n text-align: center;\n\n &::after {\n background-color: darken($ui-base-color, 4%);\n content: '';\n display: block;\n height: 100%;\n left: 50%;\n position: absolute;\n top: 0;\n width: 1px;\n }\n }\n\n &__row {\n align-items: flex-start;\n display: flex;\n flex-direction: row;\n }\n}\n",".card {\n & > a {\n display: block;\n text-decoration: none;\n color: inherit;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n box-shadow: none;\n }\n\n &:hover,\n &:active,\n &:focus {\n .card__bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__img {\n height: 130px;\n position: relative;\n background: darken($ui-base-color, 12%);\n border-radius: 4px 4px 0 0;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n border-radius: 4px 4px 0 0;\n }\n\n @media screen and (max-width: 600px) {\n height: 200px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n &__bar {\n position: relative;\n padding: 15px;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-radius: 0;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n}\n\n.pagination {\n padding: 30px 0;\n text-align: center;\n overflow: hidden;\n\n a,\n .current,\n .newer,\n .older,\n .page,\n .gap {\n font-size: 14px;\n color: $primary-text-color;\n font-weight: 500;\n display: inline-block;\n padding: 6px 10px;\n text-decoration: none;\n }\n\n .current {\n background: $simple-background-color;\n border-radius: 100px;\n color: $inverted-text-color;\n cursor: default;\n margin: 0 10px;\n }\n\n .gap {\n cursor: default;\n }\n\n .older,\n .newer {\n text-transform: uppercase;\n color: $secondary-text-color;\n }\n\n .older {\n float: left;\n padding-left: 0;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .newer {\n float: right;\n padding-right: 0;\n\n .fa {\n display: inline-block;\n margin-left: 5px;\n }\n }\n\n .disabled {\n cursor: default;\n color: lighten($inverted-text-color, 10%);\n }\n\n @media screen and (max-width: 700px) {\n padding: 30px 20px;\n\n .page {\n display: none;\n }\n\n .newer,\n .older {\n display: inline-block;\n }\n }\n}\n\n.nothing-here {\n background: $ui-base-color;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n color: $light-text-color;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n cursor: default;\n border-radius: 4px;\n padding: 20px;\n min-height: 30vh;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n &--flexible {\n box-sizing: border-box;\n min-height: 100%;\n }\n}\n\n.account-role,\n.simple_form .recommended {\n display: inline-block;\n padding: 4px 6px;\n cursor: default;\n border-radius: 3px;\n font-size: 12px;\n line-height: 12px;\n font-weight: 500;\n color: $ui-secondary-color;\n background-color: rgba($ui-secondary-color, 0.1);\n border: 1px solid rgba($ui-secondary-color, 0.5);\n\n &.moderator {\n color: $success-green;\n background-color: rgba($success-green, 0.1);\n border-color: rgba($success-green, 0.5);\n }\n\n &.admin {\n color: lighten($error-red, 12%);\n background-color: rgba(lighten($error-red, 12%), 0.1);\n border-color: rgba(lighten($error-red, 12%), 0.5);\n }\n}\n\n.account__header__fields {\n max-width: 100vw;\n padding: 0;\n margin: 15px -15px -15px;\n border: 0 none;\n border-top: 1px solid lighten($ui-base-color, 12%);\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n font-size: 14px;\n line-height: 20px;\n\n dl {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n }\n\n dt,\n dd {\n box-sizing: border-box;\n padding: 14px;\n text-align: center;\n max-height: 48px;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n }\n\n dt {\n font-weight: 500;\n width: 120px;\n flex: 0 0 auto;\n color: $secondary-text-color;\n background: rgba(darken($ui-base-color, 8%), 0.5);\n }\n\n dd {\n flex: 1 1 auto;\n color: $darker-text-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n\n .verified {\n border: 1px solid rgba($valid-value-color, 0.5);\n background: rgba($valid-value-color, 0.25);\n\n a {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n &__mark {\n color: $valid-value-color;\n }\n }\n\n dl:last-child {\n border-bottom: 0;\n }\n}\n\n.directory__tag .trends__item__current {\n width: auto;\n}\n\n.pending-account {\n &__header {\n color: $darker-text-color;\n\n a {\n color: $ui-secondary-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n\n strong {\n color: $primary-text-color;\n font-weight: 700;\n }\n }\n\n &__body {\n margin-top: 10px;\n }\n}\n",".activity-stream {\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 10px;\n\n &--under-tabs {\n border-radius: 0 0 4px 4px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin-bottom: 0;\n border-radius: 0;\n box-shadow: none;\n }\n\n &--headless {\n border-radius: 0;\n margin: 0;\n box-shadow: none;\n\n .detailed-status,\n .status {\n border-radius: 0 !important;\n }\n }\n\n div[data-component] {\n width: 100%;\n }\n\n .entry {\n background: $ui-base-color;\n\n .detailed-status,\n .status,\n .load-more {\n animation: none;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-bottom: 0;\n border-radius: 0 0 4px 4px;\n }\n }\n\n &:first-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px 4px 0 0;\n }\n\n &:last-child {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 4px;\n }\n }\n }\n\n @media screen and (max-width: 740px) {\n .detailed-status,\n .status,\n .load-more {\n border-radius: 0 !important;\n }\n }\n }\n\n &--highlighted .entry {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.button.logo-button {\n flex: 0 auto;\n font-size: 14px;\n background: $ui-highlight-color;\n color: $primary-text-color;\n text-transform: none;\n line-height: 36px;\n height: auto;\n padding: 3px 15px;\n border: 0;\n\n svg {\n width: 20px;\n height: auto;\n vertical-align: middle;\n margin-right: 5px;\n fill: $primary-text-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n background: lighten($ui-highlight-color, 10%);\n }\n\n &:disabled,\n &.disabled {\n &:active,\n &:focus,\n &:hover {\n background: $ui-primary-color;\n }\n }\n\n &.button--destructive {\n &:active,\n &:focus,\n &:hover {\n background: $error-red;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n svg {\n display: none;\n }\n }\n}\n\n.embed,\n.public-layout {\n .detailed-status {\n padding: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player {\n margin-top: 10px;\n }\n }\n}\n","button.icon-button i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n\n &:hover {\n background-image: url(\"data:image/svg+xml;utf8,\");\n }\n}\n\nbutton.icon-button.disabled i.fa-retweet {\n background-image: url(\"data:image/svg+xml;utf8,\");\n}\n",".app-body {\n -webkit-overflow-scrolling: touch;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n}\n\n.animated-number {\n display: inline-flex;\n flex-direction: column;\n align-items: stretch;\n overflow: hidden;\n position: relative;\n}\n\n.link-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: $ui-highlight-color;\n border: 0;\n background: transparent;\n padding: 0;\n cursor: pointer;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n\n &:disabled {\n color: $ui-primary-color;\n cursor: default;\n }\n}\n\n.button {\n background-color: $ui-highlight-color;\n border: 10px none;\n border-radius: 4px;\n box-sizing: border-box;\n color: $primary-text-color;\n cursor: pointer;\n display: inline-block;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n height: 36px;\n letter-spacing: 0;\n line-height: 36px;\n overflow: hidden;\n padding: 0 16px;\n position: relative;\n text-align: center;\n text-transform: uppercase;\n text-decoration: none;\n text-overflow: ellipsis;\n transition: all 100ms ease-in;\n white-space: nowrap;\n width: auto;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-highlight-color, 10%);\n transition: all 200ms ease-out;\n }\n\n &--destructive {\n transition: none;\n\n &:active,\n &:focus,\n &:hover {\n background-color: $error-red;\n transition: none;\n }\n }\n\n &:disabled,\n &.disabled {\n background-color: $ui-primary-color;\n cursor: default;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.button-primary,\n &.button-alternative,\n &.button-secondary,\n &.button-alternative-2 {\n font-size: 16px;\n line-height: 36px;\n height: auto;\n text-transform: none;\n padding: 4px 16px;\n }\n\n &.button-alternative {\n color: $inverted-text-color;\n background: $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-primary-color, 4%);\n }\n }\n\n &.button-alternative-2 {\n background: $ui-base-lighter-color;\n\n &:active,\n &:focus,\n &:hover {\n background-color: lighten($ui-base-lighter-color, 4%);\n }\n }\n\n &.button-secondary {\n color: $darker-text-color;\n background: transparent;\n padding: 3px 15px;\n border: 1px solid $ui-primary-color;\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($ui-primary-color, 4%);\n color: lighten($darker-text-color, 4%);\n }\n\n &:disabled {\n opacity: 0.5;\n }\n }\n\n &.button--block {\n display: block;\n width: 100%;\n }\n}\n\n.column__wrapper {\n display: flex;\n flex: 1 1 auto;\n position: relative;\n}\n\n.icon-button {\n display: inline-block;\n padding: 0;\n color: $action-button-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($action-button-color, 7%);\n background-color: rgba($action-button-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($action-button-color, 0.3);\n }\n\n &.disabled {\n color: darken($action-button-color, 13%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &.inverted {\n color: $lighter-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 7%);\n background-color: transparent;\n }\n\n &.active {\n color: $highlight-text-color;\n\n &.disabled {\n color: lighten($highlight-text-color, 13%);\n }\n }\n }\n\n &.overlayed {\n box-sizing: content-box;\n background: rgba($base-overlay-background, 0.6);\n color: rgba($primary-text-color, 0.7);\n border-radius: 4px;\n padding: 2px;\n\n &:hover {\n background: rgba($base-overlay-background, 0.9);\n }\n }\n}\n\n.text-icon-button {\n color: $lighter-text-color;\n border: 0;\n border-radius: 4px;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n font-size: 11px;\n padding: 0 3px;\n line-height: 27px;\n outline: 0;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n color: darken($lighter-text-color, 7%);\n background-color: rgba($lighter-text-color, 0.15);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n\n &:focus {\n background-color: rgba($lighter-text-color, 0.3);\n }\n\n &.disabled {\n color: lighten($lighter-text-color, 20%);\n background-color: transparent;\n cursor: default;\n }\n\n &.active {\n color: $highlight-text-color;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n}\n\n.dropdown-menu {\n position: absolute;\n}\n\n.invisible {\n font-size: 0;\n line-height: 0;\n display: inline-block;\n width: 0;\n height: 0;\n position: absolute;\n\n img,\n svg {\n margin: 0 !important;\n border: 0 !important;\n padding: 0 !important;\n width: 0 !important;\n height: 0 !important;\n }\n}\n\n.ellipsis {\n &::after {\n content: \"…\";\n }\n}\n\n.compose-form {\n padding: 10px;\n\n &__sensitive-button {\n padding: 10px;\n padding-top: 0;\n\n font-size: 14px;\n font-weight: 500;\n\n &.active {\n color: $highlight-text-color;\n }\n\n input[type=checkbox] {\n display: none;\n }\n\n .checkbox {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 4px;\n vertical-align: middle;\n\n &.active {\n border-color: $highlight-text-color;\n background: $highlight-text-color;\n }\n }\n }\n\n .compose-form__warning {\n color: $inverted-text-color;\n margin-bottom: 10px;\n background: $ui-primary-color;\n box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);\n padding: 8px 10px;\n border-radius: 4px;\n font-size: 13px;\n font-weight: 400;\n\n strong {\n color: $inverted-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n color: $lighter-text-color;\n font-weight: 500;\n text-decoration: underline;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: none;\n }\n }\n }\n\n .emoji-picker-dropdown {\n position: absolute;\n top: 0;\n right: 0;\n }\n\n .compose-form__autosuggest-wrapper {\n position: relative;\n }\n\n .autosuggest-textarea,\n .autosuggest-input,\n .spoiler-input {\n position: relative;\n width: 100%;\n }\n\n .spoiler-input {\n height: 0;\n transform-origin: bottom;\n opacity: 0;\n\n &.spoiler-input--visible {\n height: 36px;\n margin-bottom: 11px;\n opacity: 1;\n }\n }\n\n .autosuggest-textarea__textarea,\n .spoiler-input__input {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n\n &::placeholder {\n color: $dark-text-color;\n }\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .spoiler-input__input {\n border-radius: 4px;\n }\n\n .autosuggest-textarea__textarea {\n min-height: 100px;\n border-radius: 4px 4px 0 0;\n padding-bottom: 0;\n padding-right: 10px + 22px;\n resize: none;\n scrollbar-color: initial;\n\n &::-webkit-scrollbar {\n all: unset;\n }\n\n @media screen and (max-width: 600px) {\n height: 100px !important; // prevent auto-resize textarea\n resize: vertical;\n }\n }\n\n .autosuggest-textarea__suggestions-wrapper {\n position: relative;\n height: 0;\n }\n\n .autosuggest-textarea__suggestions {\n box-sizing: border-box;\n display: none;\n position: absolute;\n top: 100%;\n width: 100%;\n z-index: 99;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n background: $ui-secondary-color;\n border-radius: 0 0 4px 4px;\n color: $inverted-text-color;\n font-size: 14px;\n padding: 6px;\n\n &.autosuggest-textarea__suggestions--visible {\n display: block;\n }\n }\n\n .autosuggest-textarea__suggestions__item {\n padding: 10px;\n cursor: pointer;\n border-radius: 4px;\n\n &:hover,\n &:focus,\n &:active,\n &.selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n\n .autosuggest-account,\n .autosuggest-emoji,\n .autosuggest-hashtag {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: flex-start;\n line-height: 18px;\n font-size: 14px;\n }\n\n .autosuggest-hashtag {\n justify-content: space-between;\n\n &__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n strong {\n font-weight: 500;\n }\n\n &__uses {\n flex: 0 0 auto;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n }\n\n .autosuggest-account-icon,\n .autosuggest-emoji img {\n display: block;\n margin-right: 8px;\n width: 16px;\n height: 16px;\n }\n\n .autosuggest-account .display-name__account {\n color: $lighter-text-color;\n }\n\n .compose-form__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $simple-background-color;\n\n .compose-form__upload-wrapper {\n overflow: hidden;\n }\n\n .compose-form__uploads-wrapper {\n display: flex;\n flex-direction: row;\n padding: 5px;\n flex-wrap: wrap;\n }\n\n .compose-form__upload {\n flex: 1 1 0;\n min-width: 40%;\n margin: 5px;\n\n &__actions {\n background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n opacity: 0;\n transition: opacity .1s ease;\n\n .icon-button {\n flex: 0 1 auto;\n color: $secondary-text-color;\n font-size: 14px;\n font-weight: 500;\n padding: 10px;\n font-family: inherit;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($secondary-text-color, 7%);\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n\n &-description {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);\n padding: 10px;\n opacity: 0;\n transition: opacity .1s ease;\n\n textarea {\n background: transparent;\n color: $secondary-text-color;\n border: 0;\n padding: 0;\n margin: 0;\n width: 100%;\n font-family: inherit;\n font-size: 14px;\n font-weight: 500;\n\n &:focus {\n color: $white;\n }\n\n &::placeholder {\n opacity: 0.75;\n color: $secondary-text-color;\n }\n }\n\n &.active {\n opacity: 1;\n }\n }\n }\n\n .compose-form__upload-thumbnail {\n border-radius: 4px;\n background-color: $base-shadow-color;\n background-position: center;\n background-size: cover;\n background-repeat: no-repeat;\n height: 140px;\n width: 100%;\n overflow: hidden;\n }\n }\n\n .compose-form__buttons-wrapper {\n padding: 10px;\n background: darken($simple-background-color, 8%);\n border-radius: 0 0 4px 4px;\n display: flex;\n justify-content: space-between;\n flex: 0 0 auto;\n\n .compose-form__buttons {\n display: flex;\n\n .compose-form__upload-button-icon {\n line-height: 27px;\n }\n\n .compose-form__sensitive-button {\n display: none;\n\n &.compose-form__sensitive-button--visible {\n display: block;\n }\n\n .compose-form__sensitive-button__icon {\n line-height: 27px;\n }\n }\n }\n\n .icon-button,\n .text-icon-button {\n box-sizing: content-box;\n padding: 0 3px;\n }\n\n .character-counter__wrapper {\n align-self: center;\n margin-right: 4px;\n }\n }\n\n .compose-form__publish {\n display: flex;\n justify-content: flex-end;\n min-width: 0;\n flex: 0 0 auto;\n\n .compose-form__publish-button-wrapper {\n overflow: hidden;\n padding-top: 10px;\n }\n }\n}\n\n.character-counter {\n cursor: default;\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 600;\n color: $lighter-text-color;\n\n &.character-counter--over {\n color: $warning-red;\n }\n}\n\n.no-reduce-motion .spoiler-input {\n transition: height 0.4s ease, opacity 0.4s ease;\n}\n\n.emojione {\n font-size: inherit;\n vertical-align: middle;\n object-fit: contain;\n margin: -.2ex .15em .2ex;\n width: 16px;\n height: 16px;\n\n img {\n width: auto;\n }\n}\n\n.reply-indicator {\n border-radius: 4px;\n margin-bottom: 10px;\n background: $ui-primary-color;\n padding: 10px;\n min-height: 23px;\n overflow-y: auto;\n flex: 0 2 auto;\n}\n\n.reply-indicator__header {\n margin-bottom: 5px;\n overflow: hidden;\n}\n\n.reply-indicator__cancel {\n float: right;\n line-height: 24px;\n}\n\n.reply-indicator__display-name {\n color: $inverted-text-color;\n display: block;\n max-width: 100%;\n line-height: 24px;\n overflow: hidden;\n padding-right: 25px;\n text-decoration: none;\n}\n\n.reply-indicator__display-avatar {\n float: left;\n margin-right: 5px;\n}\n\n.status__content--with-action {\n cursor: pointer;\n}\n\n.status__content,\n.reply-indicator__content {\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n padding-top: 2px;\n color: $primary-text-color;\n\n &:focus {\n outline: 0;\n }\n\n &.status__content--with-spoiler {\n white-space: normal;\n\n .status__content__text {\n white-space: pre-wrap;\n }\n }\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n img {\n max-width: 100%;\n max-height: 400px;\n object-fit: contain;\n }\n\n p {\n margin-bottom: 20px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $pleroma-links;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n\n .fa {\n color: lighten($dark-text-color, 7%);\n }\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n\n a.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n\n .status__content__spoiler-link {\n background: $action-button-color;\n\n &:hover {\n background: lighten($action-button-color, 7%);\n text-decoration: none;\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n .status__content__text {\n display: none;\n\n &.status__content__text--visible {\n display: block;\n }\n }\n}\n\n.announcements__item__content {\n word-wrap: break-word;\n overflow-y: auto;\n\n .emojione {\n width: 20px;\n height: 20px;\n margin: -3px 0 0;\n }\n\n p {\n margin-bottom: 10px;\n white-space: pre-wrap;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n\n &.mention {\n &:hover {\n text-decoration: none;\n\n span {\n text-decoration: underline;\n }\n }\n }\n\n &.unhandled-link {\n color: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n.status__content.status__content--collapsed {\n max-height: 20px * 15; // 15 lines is roughly above 500 characters\n}\n\n.status__content__read-more-button {\n display: block;\n font-size: 15px;\n line-height: 20px;\n color: lighten($ui-highlight-color, 8%);\n border: 0;\n background: transparent;\n padding: 0;\n padding-top: 8px;\n text-decoration: none;\n\n &:hover,\n &:active {\n text-decoration: underline;\n }\n}\n\n.status__content__spoiler-link {\n display: inline-block;\n border-radius: 2px;\n background: transparent;\n border: 0;\n color: $inverted-text-color;\n font-weight: 700;\n font-size: 11px;\n padding: 0 6px;\n text-transform: uppercase;\n line-height: 20px;\n cursor: pointer;\n vertical-align: middle;\n}\n\n.status__wrapper--filtered {\n color: $dark-text-color;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.status__prepend-icon-wrapper {\n left: -26px;\n position: absolute;\n}\n\n.focusable {\n &:focus {\n outline: 0;\n background: lighten($ui-base-color, 4%);\n\n .status.status-direct {\n background: lighten($ui-base-color, 12%);\n\n &.muted {\n background: transparent;\n }\n }\n\n .detailed-status,\n .detailed-status__action-bar {\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.status {\n padding: 8px 10px;\n padding-left: 68px;\n position: relative;\n min-height: 54px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n\n @supports (-ms-overflow-style: -ms-autohiding-scrollbar) {\n // Add margin to avoid Edge auto-hiding scrollbar appearing over content.\n // On Edge 16 this is 16px and Edge <=15 it's 12px, so aim for 16px.\n padding-right: 26px; // 10px + 16px\n }\n\n @keyframes fade {\n 0% { opacity: 0; }\n 100% { opacity: 1; }\n }\n\n opacity: 1;\n animation: fade 150ms linear;\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n\n &.status-direct:not(.read) {\n background: lighten($ui-base-color, 8%);\n border-bottom-color: lighten($ui-base-color, 12%);\n }\n\n &.light {\n .status__relative-time {\n color: $light-text-color;\n }\n\n .status__display-name {\n color: $inverted-text-color;\n }\n\n .display-name {\n color: $light-text-color;\n\n strong {\n color: $inverted-text-color;\n }\n }\n\n .status__content {\n color: $inverted-text-color;\n\n a {\n color: $highlight-text-color;\n }\n\n a.status__content__spoiler-link {\n color: $primary-text-color;\n background: $ui-primary-color;\n\n &:hover {\n background: lighten($ui-primary-color, 8%);\n }\n }\n }\n }\n}\n\n.notification-favourite {\n .status.status-direct {\n background: transparent;\n\n .icon-button.disabled {\n color: lighten($action-button-color, 13%);\n }\n }\n}\n\n.status__relative-time,\n.notification__relative_time {\n color: $dark-text-color;\n float: right;\n font-size: 14px;\n}\n\n.status__display-name {\n color: $dark-text-color;\n}\n\n.status__info .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n}\n\n.status__info {\n font-size: 15px;\n}\n\n.status-check-box {\n border-bottom: 1px solid $ui-secondary-color;\n display: flex;\n\n .status-check-box__status {\n margin: 10px 0 10px 10px;\n flex: 1;\n overflow: hidden;\n\n .media-gallery {\n max-width: 250px;\n }\n\n .status__content {\n padding: 0;\n white-space: normal;\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n max-width: 250px;\n }\n\n .media-gallery__item-thumbnail {\n cursor: default;\n }\n }\n}\n\n.status-check-box-toggle {\n align-items: center;\n display: flex;\n flex: 0 0 auto;\n justify-content: center;\n padding: 10px;\n}\n\n.status__prepend {\n margin-left: 68px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-bottom: 2px;\n font-size: 14px;\n position: relative;\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.status__action-bar {\n align-items: center;\n display: flex;\n margin-top: 8px;\n\n &__counter {\n display: inline-flex;\n margin-right: 11px;\n align-items: center;\n\n .status__action-bar-button {\n margin-right: 4px;\n }\n\n &__label {\n display: inline-block;\n width: 14px;\n font-size: 12px;\n font-weight: 500;\n color: $action-button-color;\n }\n }\n}\n\n.status__action-bar-button {\n margin-right: 18px;\n}\n\n.status__action-bar-dropdown {\n height: 23.15px;\n width: 23.15px;\n}\n\n.detailed-status__action-bar-dropdown {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n justify-content: center;\n position: relative;\n}\n\n.detailed-status {\n background: lighten($ui-base-color, 4%);\n padding: 14px 10px;\n\n &--flex {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n\n .status__content,\n .detailed-status__meta {\n flex: 100%;\n }\n }\n\n .status__content {\n font-size: 19px;\n line-height: 24px;\n\n .emojione {\n width: 24px;\n height: 24px;\n margin: -1px 0 0;\n }\n\n .status__content__spoiler-link {\n line-height: 24px;\n margin: -1px 0 0;\n }\n }\n\n .video-player,\n .audio-player {\n margin-top: 8px;\n }\n}\n\n.detailed-status__meta {\n margin-top: 15px;\n color: $dark-text-color;\n font-size: 14px;\n line-height: 18px;\n}\n\n.detailed-status__action-bar {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.detailed-status__link {\n color: inherit;\n text-decoration: none;\n}\n\n.detailed-status__favorites,\n.detailed-status__reblogs {\n display: inline-block;\n font-weight: 500;\n font-size: 12px;\n margin-left: 6px;\n}\n\n.reply-indicator__content {\n color: $inverted-text-color;\n font-size: 14px;\n\n a {\n color: $lighter-text-color;\n }\n}\n\n.domain {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n .domain__domain-name {\n flex: 1 1 auto;\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n }\n}\n\n.domain__wrapper {\n display: flex;\n}\n\n.domain_buttons {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &.compact {\n padding: 0;\n border-bottom: 0;\n\n .account__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n .account__display-name {\n flex: 1 1 auto;\n display: block;\n color: $darker-text-color;\n overflow: hidden;\n text-decoration: none;\n font-size: 14px;\n }\n}\n\n.account__wrapper {\n display: flex;\n}\n\n.account__avatar-wrapper {\n float: left;\n margin-left: 12px;\n margin-right: 12px;\n}\n\n.account__avatar {\n @include avatar-radius;\n position: relative;\n\n &-inline {\n display: inline-block;\n vertical-align: middle;\n margin-right: 5px;\n }\n\n &-composite {\n @include avatar-radius;\n border-radius: 50%;\n overflow: hidden;\n position: relative;\n\n & > div {\n float: left;\n position: relative;\n box-sizing: border-box;\n }\n\n &__label {\n display: block;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n color: $primary-text-color;\n text-shadow: 1px 1px 2px $base-shadow-color;\n font-weight: 700;\n font-size: 15px;\n }\n }\n}\n\na .account__avatar {\n cursor: pointer;\n}\n\n.account__avatar-overlay {\n @include avatar-size(48px);\n\n &-base {\n @include avatar-radius;\n @include avatar-size(36px);\n }\n\n &-overlay {\n @include avatar-radius;\n @include avatar-size(24px);\n\n position: absolute;\n bottom: 0;\n right: 0;\n z-index: 1;\n }\n}\n\n.account__relationship {\n height: 18px;\n padding: 10px;\n white-space: nowrap;\n}\n\n.account__disclaimer {\n padding: 10px;\n border-top: 1px solid lighten($ui-base-color, 8%);\n color: $dark-text-color;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n a {\n font-weight: 500;\n color: inherit;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n}\n\n.account__action-bar {\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n line-height: 36px;\n overflow: hidden;\n flex: 0 0 auto;\n display: flex;\n}\n\n.account__action-bar-dropdown {\n padding: 10px;\n\n .icon-button {\n vertical-align: middle;\n }\n\n .dropdown--active {\n .dropdown__content.dropdown__right {\n left: 6px;\n right: initial;\n }\n\n &::after {\n bottom: initial;\n margin-left: 11px;\n margin-top: -7px;\n right: initial;\n }\n }\n}\n\n.account__action-bar-links {\n display: flex;\n flex: 1 1 auto;\n line-height: 18px;\n text-align: center;\n}\n\n.account__action-bar__tab {\n text-decoration: none;\n overflow: hidden;\n flex: 0 1 100%;\n border-right: 1px solid lighten($ui-base-color, 8%);\n padding: 10px 0;\n border-bottom: 4px solid transparent;\n\n &.active {\n border-bottom: 4px solid $ui-highlight-color;\n }\n\n & > span {\n display: block;\n text-transform: uppercase;\n font-size: 11px;\n color: $darker-text-color;\n }\n\n strong {\n display: block;\n font-size: 15px;\n font-weight: 500;\n color: $primary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.account-authorize {\n padding: 14px 10px;\n\n .detailed-status__display-name {\n display: block;\n margin-bottom: 15px;\n overflow: hidden;\n }\n}\n\n.account-authorize__avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__display-name,\n.status__relative-time,\n.detailed-status__display-name,\n.detailed-status__datetime,\n.detailed-status__application,\n.account__display-name {\n text-decoration: none;\n}\n\n.status__display-name,\n.account__display-name {\n strong {\n color: $primary-text-color;\n }\n}\n\n.muted {\n .emojione {\n opacity: 0.5;\n }\n}\n\n.status__display-name,\n.reply-indicator__display-name,\n.detailed-status__display-name,\na.account__display-name {\n &:hover strong {\n text-decoration: underline;\n }\n}\n\n.account__display-name strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.detailed-status__application,\n.detailed-status__datetime {\n color: inherit;\n}\n\n.detailed-status .button.logo-button {\n margin-bottom: 15px;\n}\n\n.detailed-status__display-name {\n color: $secondary-text-color;\n display: block;\n line-height: 24px;\n margin-bottom: 15px;\n overflow: hidden;\n\n strong,\n span {\n display: block;\n text-overflow: ellipsis;\n overflow: hidden;\n }\n\n strong {\n font-size: 16px;\n color: $primary-text-color;\n }\n}\n\n.detailed-status__display-avatar {\n float: left;\n margin-right: 10px;\n}\n\n.status__avatar {\n height: 48px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n}\n\n.status__expand {\n width: 68px;\n position: absolute;\n left: 0;\n top: 0;\n height: 100%;\n cursor: pointer;\n}\n\n.muted {\n .status__content,\n .status__content p,\n .status__content a {\n color: $dark-text-color;\n }\n\n .status__display-name strong {\n color: $dark-text-color;\n }\n\n .status__avatar {\n opacity: 0.5;\n }\n\n a.status__content__spoiler-link {\n background: $ui-base-lighter-color;\n color: $inverted-text-color;\n\n &:hover {\n background: lighten($ui-base-lighter-color, 7%);\n text-decoration: none;\n }\n }\n}\n\n.notification__message {\n margin: 0 10px 0 68px;\n padding: 8px 0 0;\n cursor: default;\n color: $darker-text-color;\n font-size: 15px;\n line-height: 22px;\n position: relative;\n\n .fa {\n color: $highlight-text-color;\n }\n\n > span {\n display: inline;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n}\n\n.notification__favourite-icon-wrapper {\n left: -26px;\n position: absolute;\n\n .star-icon {\n color: $gold-star;\n }\n}\n\n.star-icon.active {\n color: $gold-star;\n}\n\n.bookmark-icon.active {\n color: $red-bookmark;\n}\n\n.no-reduce-motion .icon-button.star-icon {\n &.activate {\n & > .fa-star {\n animation: spring-rotate-in 1s linear;\n }\n }\n\n &.deactivate {\n & > .fa-star {\n animation: spring-rotate-out 1s linear;\n }\n }\n}\n\n.notification__display-name {\n color: inherit;\n font-weight: 500;\n text-decoration: none;\n\n &:hover {\n color: $primary-text-color;\n text-decoration: underline;\n }\n}\n\n.notification__relative_time {\n float: right;\n}\n\n.display-name {\n display: block;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.display-name__html {\n font-weight: 500;\n}\n\n.display-name__account {\n font-size: 14px;\n}\n\n.status__relative-time,\n.detailed-status__datetime {\n &:hover {\n text-decoration: underline;\n }\n}\n\n.image-loader {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-direction: column;\n\n .image-loader__preview-canvas {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n background: url('~images/void.png') repeat;\n object-fit: contain;\n }\n\n .loading-bar {\n position: relative;\n }\n\n &.image-loader--amorphous .image-loader__preview-canvas {\n display: none;\n }\n}\n\n.zoomable-image {\n position: relative;\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n img {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n width: auto;\n height: auto;\n object-fit: contain;\n }\n}\n\n.navigation-bar {\n padding: 10px;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n cursor: default;\n color: $darker-text-color;\n\n strong {\n color: $secondary-text-color;\n }\n\n a {\n color: inherit;\n }\n\n .permalink {\n text-decoration: none;\n }\n\n .navigation-bar__actions {\n position: relative;\n\n .icon-button.close {\n position: absolute;\n pointer-events: none;\n transform: scale(0, 1) translate(-100%, 0);\n opacity: 0;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: auto;\n transform: scale(1, 1) translate(0, 0);\n opacity: 1;\n }\n }\n}\n\n.navigation-bar__profile {\n flex: 1 1 auto;\n margin-left: 8px;\n line-height: 20px;\n margin-top: -1px;\n overflow: hidden;\n}\n\n.navigation-bar__profile-account {\n display: block;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.navigation-bar__profile-edit {\n color: inherit;\n text-decoration: none;\n}\n\n.dropdown {\n display: inline-block;\n}\n\n.dropdown__content {\n display: none;\n position: absolute;\n}\n\n.dropdown-menu__separator {\n border-bottom: 1px solid darken($ui-secondary-color, 8%);\n margin: 5px 7px 6px;\n height: 0;\n}\n\n.dropdown-menu {\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n z-index: 9999;\n\n ul {\n list-style: none;\n }\n\n &.left {\n transform-origin: 100% 50%;\n }\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n\n &.right {\n transform-origin: 0 50%;\n }\n}\n\n.dropdown-menu__arrow {\n position: absolute;\n width: 0;\n height: 0;\n border: 0 solid transparent;\n\n &.left {\n right: -5px;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: $ui-secondary-color;\n }\n\n &.top {\n bottom: -5px;\n margin-left: -7px;\n border-width: 5px 7px 0;\n border-top-color: $ui-secondary-color;\n }\n\n &.bottom {\n top: -5px;\n margin-left: -7px;\n border-width: 0 7px 5px;\n border-bottom-color: $ui-secondary-color;\n }\n\n &.right {\n left: -5px;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: $ui-secondary-color;\n }\n}\n\n.dropdown-menu__item {\n a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus,\n &:hover,\n &:active {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n outline: 0;\n }\n }\n}\n\n.dropdown--active .dropdown__content {\n display: block;\n line-height: 18px;\n max-width: 311px;\n right: 0;\n text-align: left;\n z-index: 9999;\n\n & > ul {\n list-style: none;\n background: $ui-secondary-color;\n padding: 4px 0;\n border-radius: 4px;\n box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);\n min-width: 140px;\n position: relative;\n }\n\n &.dropdown__right {\n right: 0;\n }\n\n &.dropdown__left {\n & > ul {\n left: -98px;\n }\n }\n\n & > ul > li > a {\n font-size: 13px;\n line-height: 18px;\n display: block;\n padding: 4px 14px;\n box-sizing: border-box;\n text-decoration: none;\n background: $ui-secondary-color;\n color: $inverted-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:focus {\n outline: 0;\n }\n\n &:hover {\n background: $ui-highlight-color;\n color: $secondary-text-color;\n }\n }\n}\n\n.dropdown__icon {\n vertical-align: middle;\n}\n\n.columns-area {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n justify-content: flex-start;\n overflow-x: auto;\n position: relative;\n\n &.unscrollable {\n overflow-x: hidden;\n }\n\n &__panels {\n display: flex;\n justify-content: center;\n width: 100%;\n height: 100%;\n min-height: 100vh;\n\n &__pane {\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n display: flex;\n justify-content: flex-end;\n min-width: 285px;\n\n &--start {\n justify-content: flex-start;\n }\n\n &__inner {\n position: fixed;\n width: 285px;\n pointer-events: auto;\n height: 100%;\n }\n }\n\n &__main {\n box-sizing: border-box;\n width: 100%;\n max-width: 600px;\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 0 10px;\n }\n }\n }\n}\n\n.tabs-bar__wrapper {\n background: darken($ui-base-color, 8%);\n position: sticky;\n top: 0;\n z-index: 2;\n padding-top: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding-top: 10px;\n }\n\n .tabs-bar {\n margin-bottom: 0;\n\n @media screen and (min-width: $no-gap-breakpoint) {\n margin-bottom: 10px;\n }\n }\n}\n\n.react-swipeable-view-container {\n &,\n .columns-area,\n .drawer,\n .column {\n height: 100%;\n }\n}\n\n.react-swipeable-view-container > * {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n\n.column {\n width: 350px;\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n\n > .scrollable {\n background: $ui-base-color;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n }\n}\n\n.ui {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n}\n\n.drawer {\n width: 330px;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n overflow-y: hidden;\n}\n\n.drawer__tab {\n display: block;\n flex: 1 1 auto;\n padding: 15px 5px 13px;\n color: $darker-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 16px;\n border-bottom: 2px solid transparent;\n}\n\n.column,\n.drawer {\n flex: 1 1 auto;\n overflow: hidden;\n}\n\n@media screen and (min-width: 631px) {\n .columns-area {\n padding: 0;\n }\n\n .column,\n .drawer {\n flex: 0 0 auto;\n padding: 10px;\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 10px;\n }\n\n &:last-child {\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n}\n\n.tabs-bar {\n box-sizing: border-box;\n display: flex;\n background: lighten($ui-base-color, 8%);\n flex: 0 0 auto;\n overflow-y: auto;\n}\n\n.tabs-bar__link {\n display: block;\n flex: 1 1 auto;\n padding: 15px 10px;\n padding-bottom: 13px;\n color: $primary-text-color;\n text-decoration: none;\n text-align: center;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid lighten($ui-base-color, 8%);\n transition: all 50ms linear;\n transition-property: border-bottom, background, color;\n\n .fa {\n font-weight: 400;\n font-size: 16px;\n }\n\n &:hover,\n &:focus,\n &:active {\n @media screen and (min-width: 631px) {\n background: lighten($ui-base-color, 14%);\n border-bottom-color: lighten($ui-base-color, 14%);\n }\n }\n\n &.active {\n border-bottom: 2px solid $highlight-text-color;\n color: $highlight-text-color;\n }\n\n span {\n margin-left: 5px;\n display: none;\n }\n}\n\n@media screen and (min-width: 600px) {\n .tabs-bar__link {\n span {\n display: inline;\n }\n }\n}\n\n.columns-area--mobile {\n flex-direction: column;\n width: 100%;\n height: 100%;\n margin: 0 auto;\n\n .column,\n .drawer {\n width: 100%;\n height: 100%;\n padding: 0;\n }\n\n .directory__list {\n display: grid;\n grid-gap: 10px;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: block;\n }\n }\n\n .directory__card {\n margin-bottom: 0;\n }\n\n .filter-form {\n display: flex;\n }\n\n .autosuggest-textarea__textarea {\n font-size: 16px;\n }\n\n .search__input {\n line-height: 18px;\n font-size: 16px;\n padding: 15px;\n padding-right: 30px;\n }\n\n .search__icon .fa {\n top: 15px;\n }\n\n .scrollable {\n overflow: visible;\n\n @supports(display: grid) {\n contain: content;\n }\n }\n\n @media screen and (min-width: $no-gap-breakpoint) {\n padding: 10px 0;\n padding-top: 0;\n }\n\n @media screen and (min-width: 630px) {\n .detailed-status {\n padding: 15px;\n\n .media-gallery,\n .video-player,\n .audio-player {\n margin-top: 15px;\n }\n }\n\n .account__header__bar {\n padding: 5px 10px;\n }\n\n .navigation-bar,\n .compose-form {\n padding: 15px;\n }\n\n .compose-form .compose-form__publish .compose-form__publish-button-wrapper {\n padding-top: 15px;\n }\n\n .status {\n padding: 15px 15px 15px (48px + 15px * 2);\n min-height: 48px + 2px;\n\n &__avatar {\n left: 15px;\n top: 17px;\n }\n\n &__content {\n padding-top: 5px;\n }\n\n &__prepend {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__prepend-icon-wrapper {\n left: -32px;\n }\n\n .media-gallery,\n &__action-bar,\n .video-player,\n .audio-player {\n margin-top: 10px;\n }\n }\n\n .account {\n padding: 15px 10px;\n\n &__header__bio {\n margin: 0 -10px;\n }\n }\n\n .notification {\n &__message {\n margin-left: 48px + 15px * 2;\n padding-top: 15px;\n }\n\n &__favourite-icon-wrapper {\n left: -32px;\n }\n\n .status {\n padding-top: 8px;\n }\n\n .account {\n padding-top: 8px;\n }\n\n .account__avatar-wrapper {\n margin-left: 17px;\n margin-right: 15px;\n }\n }\n }\n}\n\n.floating-action-button {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 3.9375rem;\n height: 3.9375rem;\n bottom: 1.3125rem;\n right: 1.3125rem;\n background: darken($ui-highlight-color, 3%);\n color: $white;\n border-radius: 50%;\n font-size: 21px;\n line-height: 21px;\n text-decoration: none;\n box-shadow: 2px 3px 9px rgba($base-shadow-color, 0.4);\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-highlight-color, 7%);\n }\n}\n\n@media screen and (min-width: $no-gap-breakpoint) {\n .tabs-bar {\n width: 100%;\n }\n\n .react-swipeable-view-container .columns-area--mobile {\n height: calc(100% - 10px) !important;\n }\n\n .getting-started__wrapper,\n .getting-started__trends,\n .search {\n margin-bottom: 10px;\n }\n\n .getting-started__panel {\n margin: 10px 0;\n }\n\n .column,\n .drawer {\n min-width: 330px;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 1) + (10px * 1)) {\n .columns-area__panels__pane--compositional {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 1) + (10px * 1)) {\n .floating-action-button,\n .tabs-bar__link.optional {\n display: none;\n }\n\n .search-page .search {\n display: none;\n }\n}\n\n@media screen and (max-width: 600px + (285px * 2) + (10px * 2)) {\n .columns-area__panels__pane--navigational {\n display: none;\n }\n}\n\n@media screen and (min-width: 600px + (285px * 2) + (10px * 2)) {\n .tabs-bar {\n display: none;\n }\n}\n\n.icon-with-badge {\n position: relative;\n\n &__badge {\n position: absolute;\n left: 9px;\n top: -13px;\n background: $ui-highlight-color;\n border: 2px solid lighten($ui-base-color, 8%);\n padding: 1px 6px;\n border-radius: 6px;\n font-size: 10px;\n font-weight: 500;\n line-height: 14px;\n color: $primary-text-color;\n }\n}\n\n.column-link--transparent .icon-with-badge__badge {\n border-color: darken($ui-base-color, 8%);\n}\n\n.compose-panel {\n width: 285px;\n margin-top: 10px;\n display: flex;\n flex-direction: column;\n height: calc(100% - 10px);\n overflow-y: hidden;\n\n .navigation-bar {\n padding-top: 20px;\n padding-bottom: 20px;\n flex: 0 1 48px;\n min-height: 20px;\n }\n\n .flex-spacer {\n background: transparent;\n }\n\n .compose-form {\n flex: 1;\n overflow-y: hidden;\n display: flex;\n flex-direction: column;\n min-height: 310px;\n padding-bottom: 71px;\n margin-bottom: -71px;\n }\n\n .compose-form__autosuggest-wrapper {\n overflow-y: auto;\n background-color: $white;\n border-radius: 4px 4px 0 0;\n flex: 0 1 auto;\n }\n\n .autosuggest-textarea__textarea {\n overflow-y: hidden;\n }\n\n .compose-form__upload-thumbnail {\n height: 80px;\n }\n}\n\n.navigation-panel {\n margin-top: 10px;\n margin-bottom: 10px;\n height: calc(100% - 20px);\n overflow-y: auto;\n display: flex;\n flex-direction: column;\n\n & > a {\n flex: 0 0 auto;\n }\n\n hr {\n flex: 0 0 auto;\n border: 0;\n background: transparent;\n border-top: 1px solid lighten($ui-base-color, 4%);\n margin: 10px 0;\n }\n\n .flex-spacer {\n background: transparent;\n }\n}\n\n.drawer__pager {\n box-sizing: border-box;\n padding: 0;\n flex-grow: 1;\n position: relative;\n overflow: hidden;\n display: flex;\n}\n\n.drawer__inner {\n position: absolute;\n top: 0;\n left: 0;\n background: lighten($ui-base-color, 13%);\n box-sizing: border-box;\n padding: 0;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n overflow-y: auto;\n width: 100%;\n height: 100%;\n border-radius: 2px;\n\n &.darker {\n background: $ui-base-color;\n }\n}\n\n.drawer__inner__mastodon {\n background: lighten($ui-base-color, 13%) url('data:image/svg+xml;utf8,') no-repeat bottom / 100% auto;\n flex: 1;\n min-height: 47px;\n display: none;\n\n > img {\n display: block;\n object-fit: contain;\n object-position: bottom left;\n width: 85%;\n height: 100%;\n pointer-events: none;\n user-drag: none;\n user-select: none;\n }\n\n @media screen and (min-height: 640px) {\n display: block;\n }\n}\n\n.pseudo-drawer {\n background: lighten($ui-base-color, 13%);\n font-size: 13px;\n text-align: left;\n}\n\n.drawer__header {\n flex: 0 0 auto;\n font-size: 16px;\n background: lighten($ui-base-color, 8%);\n margin-bottom: 10px;\n display: flex;\n flex-direction: row;\n border-radius: 2px;\n\n a {\n transition: background 100ms ease-in;\n\n &:hover {\n background: lighten($ui-base-color, 3%);\n transition: background 200ms ease-out;\n }\n }\n}\n\n.scrollable {\n overflow-y: scroll;\n overflow-x: hidden;\n flex: 1 1 auto;\n -webkit-overflow-scrolling: touch;\n\n &.optionally-scrollable {\n overflow-y: auto;\n }\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n &--flex {\n display: flex;\n flex-direction: column;\n }\n\n &__append {\n flex: 1 1 auto;\n position: relative;\n min-height: 120px;\n }\n}\n\n.scrollable.fullscreen {\n @supports(display: grid) { // hack to fix Chrome <57\n contain: none;\n }\n}\n\n.column-back-button {\n box-sizing: border-box;\n width: 100%;\n background: lighten($ui-base-color, 4%);\n color: $highlight-text-color;\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n line-height: inherit;\n border: 0;\n text-align: unset;\n padding: 15px;\n margin: 0;\n z-index: 3;\n outline: 0;\n\n &:hover {\n text-decoration: underline;\n }\n}\n\n.column-header__back-button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n font-family: inherit;\n color: $highlight-text-color;\n cursor: pointer;\n white-space: nowrap;\n font-size: 16px;\n padding: 0 5px 0 0;\n z-index: 3;\n\n &:hover {\n text-decoration: underline;\n }\n\n &:last-child {\n padding: 0 15px 0 0;\n }\n}\n\n.column-back-button__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-back-button--slim {\n position: relative;\n}\n\n.column-back-button--slim-button {\n cursor: pointer;\n flex: 0 0 auto;\n font-size: 16px;\n padding: 15px;\n position: absolute;\n right: 0;\n top: -48px;\n}\n\n.react-toggle {\n display: inline-block;\n position: relative;\n cursor: pointer;\n background-color: transparent;\n border: 0;\n padding: 0;\n user-select: none;\n -webkit-tap-highlight-color: rgba($base-overlay-background, 0);\n -webkit-tap-highlight-color: transparent;\n}\n\n.react-toggle-screenreader-only {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n}\n\n.react-toggle--disabled {\n cursor: not-allowed;\n opacity: 0.5;\n transition: opacity 0.25s;\n}\n\n.react-toggle-track {\n width: 50px;\n height: 24px;\n padding: 0;\n border-radius: 30px;\n background-color: $ui-base-color;\n transition: background-color 0.2s ease;\n}\n\n.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: darken($ui-base-color, 10%);\n}\n\n.react-toggle--checked .react-toggle-track {\n background-color: $ui-highlight-color;\n}\n\n.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {\n background-color: lighten($ui-highlight-color, 10%);\n}\n\n.react-toggle-track-check {\n position: absolute;\n width: 14px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n left: 8px;\n opacity: 0;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-check {\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle-track-x {\n position: absolute;\n width: 10px;\n height: 10px;\n top: 0;\n bottom: 0;\n margin-top: auto;\n margin-bottom: auto;\n line-height: 0;\n right: 10px;\n opacity: 1;\n transition: opacity 0.25s ease;\n}\n\n.react-toggle--checked .react-toggle-track-x {\n opacity: 0;\n}\n\n.react-toggle-thumb {\n position: absolute;\n top: 1px;\n left: 1px;\n width: 22px;\n height: 22px;\n border: 1px solid $ui-base-color;\n border-radius: 50%;\n background-color: darken($simple-background-color, 2%);\n box-sizing: border-box;\n transition: all 0.25s ease;\n transition-property: border-color, left;\n}\n\n.react-toggle--checked .react-toggle-thumb {\n left: 27px;\n border-color: $ui-highlight-color;\n}\n\n.column-link {\n background: lighten($ui-base-color, 8%);\n color: $primary-text-color;\n display: block;\n font-size: 16px;\n padding: 15px;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 11%);\n }\n\n &:focus {\n outline: 0;\n }\n\n &--transparent {\n background: transparent;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n background: transparent;\n color: $primary-text-color;\n }\n\n &.active {\n color: $ui-highlight-color;\n }\n }\n}\n\n.column-link__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.column-link__badge {\n display: inline-block;\n border-radius: 4px;\n font-size: 12px;\n line-height: 19px;\n font-weight: 500;\n background: $ui-base-color;\n padding: 4px 8px;\n margin: -6px 10px;\n}\n\n.column-subheading {\n background: $ui-base-color;\n color: $dark-text-color;\n padding: 8px 20px;\n font-size: 12px;\n font-weight: 500;\n text-transform: uppercase;\n cursor: default;\n}\n\n.getting-started__wrapper,\n.getting-started,\n.flex-spacer {\n background: $ui-base-color;\n}\n\n.flex-spacer {\n flex: 1 1 auto;\n}\n\n.getting-started {\n color: $dark-text-color;\n overflow: auto;\n border-bottom-left-radius: 2px;\n border-bottom-right-radius: 2px;\n\n &__wrapper,\n &__panel,\n &__footer {\n height: min-content;\n }\n\n &__panel,\n &__footer\n {\n padding: 10px;\n padding-top: 20px;\n flex-grow: 0;\n\n ul {\n margin-bottom: 10px;\n }\n\n ul li {\n display: inline;\n }\n\n p {\n font-size: 13px;\n\n a {\n color: $dark-text-color;\n text-decoration: underline;\n }\n }\n\n a {\n text-decoration: none;\n color: $darker-text-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n &__wrapper,\n &__footer\n {\n color: $dark-text-color;\n }\n\n &__trends {\n flex: 0 1 auto;\n opacity: 1;\n animation: fade 150ms linear;\n margin-top: 10px;\n\n h4 {\n font-size: 12px;\n text-transform: uppercase;\n color: $darker-text-color;\n padding: 10px;\n font-weight: 500;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n @media screen and (max-height: 810px) {\n .trends__item:nth-child(3) {\n display: none;\n }\n }\n\n @media screen and (max-height: 720px) {\n .trends__item:nth-child(2) {\n display: none;\n }\n }\n\n @media screen and (max-height: 670px) {\n display: none;\n }\n\n .trends__item {\n border-bottom: 0;\n padding: 10px;\n\n &__current {\n color: $darker-text-color;\n }\n }\n }\n}\n\n.keyboard-shortcuts {\n padding: 8px 0 0;\n overflow: hidden;\n\n thead {\n position: absolute;\n left: -9999px;\n }\n\n td {\n padding: 0 10px 8px;\n }\n\n kbd {\n display: inline-block;\n padding: 3px 5px;\n background-color: lighten($ui-base-color, 8%);\n border: 1px solid darken($ui-base-color, 4%);\n }\n}\n\n.setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $simple-background-color;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: vertical;\n border: 0;\n outline: 0;\n border-radius: 4px;\n\n &:focus {\n outline: 0;\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.no-reduce-motion button.icon-button i.fa-retweet {\n background-position: 0 0;\n height: 19px;\n transition: background-position 0.9s steps(10);\n transition-duration: 0s;\n vertical-align: middle;\n width: 22px;\n\n &::before {\n display: none !important;\n }\n\n}\n\n.no-reduce-motion button.icon-button.active i.fa-retweet {\n transition-duration: 0.9s;\n background-position: 0 100%;\n}\n\n.reduce-motion button.icon-button i.fa-retweet {\n color: $action-button-color;\n transition: color 100ms ease-in;\n}\n\n.reduce-motion button.icon-button.active i.fa-retweet {\n color: $highlight-text-color;\n}\n\n.status-card {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n color: $dark-text-color;\n margin-top: 14px;\n text-decoration: none;\n overflow: hidden;\n\n &__actions {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n display: flex;\n justify-content: center;\n align-items: center;\n\n & > div {\n background: rgba($base-shadow-color, 0.6);\n border-radius: 8px;\n padding: 12px 9px;\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n }\n\n button,\n a {\n display: inline;\n color: $secondary-text-color;\n background: transparent;\n border: 0;\n padding: 0 8px;\n text-decoration: none;\n font-size: 18px;\n line-height: 18px;\n\n &:hover,\n &:active,\n &:focus {\n color: $primary-text-color;\n }\n }\n\n a {\n font-size: 19px;\n position: relative;\n bottom: -1px;\n }\n }\n}\n\na.status-card {\n cursor: pointer;\n\n &:hover {\n background: lighten($ui-base-color, 8%);\n }\n}\n\n.status-card-photo {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n width: 100%;\n height: auto;\n margin: 0;\n}\n\n.status-card-video {\n iframe {\n width: 100%;\n height: 100%;\n }\n}\n\n.status-card__title {\n display: block;\n font-weight: 500;\n margin-bottom: 5px;\n color: $darker-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-decoration: none;\n}\n\n.status-card__content {\n flex: 1 1 auto;\n overflow: hidden;\n padding: 14px 14px 14px 8px;\n}\n\n.status-card__description {\n color: $darker-text-color;\n}\n\n.status-card__host {\n display: block;\n margin-top: 5px;\n font-size: 13px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.status-card__image {\n flex: 0 0 100px;\n background: lighten($ui-base-color, 8%);\n position: relative;\n\n & > .fa {\n font-size: 21px;\n position: absolute;\n transform-origin: 50% 50%;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n }\n}\n\n.status-card.horizontal {\n display: block;\n\n .status-card__image {\n width: 100%;\n }\n\n .status-card__image-image {\n border-radius: 4px 4px 0 0;\n }\n\n .status-card__title {\n white-space: inherit;\n }\n}\n\n.status-card.compact {\n border-color: lighten($ui-base-color, 4%);\n\n &.interactive {\n border: 0;\n }\n\n .status-card__content {\n padding: 8px;\n padding-top: 10px;\n }\n\n .status-card__title {\n white-space: nowrap;\n }\n\n .status-card__image {\n flex: 0 0 60px;\n }\n}\n\na.status-card.compact:hover {\n background-color: lighten($ui-base-color, 4%);\n}\n\n.status-card__image-image {\n border-radius: 4px 0 0 4px;\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n object-fit: cover;\n background-size: cover;\n background-position: center center;\n}\n\n.load-more {\n display: block;\n color: $dark-text-color;\n background-color: transparent;\n border: 0;\n font-size: inherit;\n text-align: center;\n line-height: inherit;\n margin: 0;\n padding: 15px;\n box-sizing: border-box;\n width: 100%;\n clear: both;\n text-decoration: none;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n}\n\n.load-gap {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n}\n\n.regeneration-indicator {\n text-align: center;\n font-size: 16px;\n font-weight: 500;\n color: $dark-text-color;\n background: $ui-base-color;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 20px;\n\n &__figure {\n &,\n img {\n display: block;\n width: auto;\n height: 160px;\n margin: 0;\n }\n }\n\n &--without-header {\n padding-top: 20px + 48px;\n }\n\n &__label {\n margin-top: 30px;\n\n strong {\n display: block;\n margin-bottom: 10px;\n color: $dark-text-color;\n }\n\n span {\n font-size: 15px;\n font-weight: 400;\n }\n }\n}\n\n.column-header__wrapper {\n position: relative;\n flex: 0 0 auto;\n z-index: 1;\n\n &.active {\n box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3);\n\n &::before {\n display: block;\n content: \"\";\n position: absolute;\n bottom: -13px;\n left: 0;\n right: 0;\n margin: 0 auto;\n width: 60%;\n pointer-events: none;\n height: 28px;\n z-index: 1;\n background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%);\n }\n }\n\n .announcements {\n z-index: 1;\n position: relative;\n }\n}\n\n.column-header {\n display: flex;\n font-size: 16px;\n background: lighten($ui-base-color, 4%);\n flex: 0 0 auto;\n cursor: pointer;\n position: relative;\n z-index: 2;\n outline: 0;\n overflow: hidden;\n border-top-left-radius: 2px;\n border-top-right-radius: 2px;\n\n & > button {\n margin: 0;\n border: 0;\n padding: 15px 0 15px 15px;\n color: inherit;\n background: transparent;\n font: inherit;\n text-align: left;\n text-overflow: ellipsis;\n overflow: hidden;\n white-space: nowrap;\n flex: 1;\n }\n\n & > .column-header__back-button {\n color: $highlight-text-color;\n }\n\n &.active {\n .column-header__icon {\n color: $highlight-text-color;\n text-shadow: 0 0 10px rgba($highlight-text-color, 0.4);\n }\n }\n\n &:focus,\n &:active {\n outline: 0;\n }\n}\n\n.column-header__buttons {\n height: 48px;\n display: flex;\n}\n\n.column-header__links {\n margin-bottom: 14px;\n}\n\n.column-header__links .text-btn {\n margin-right: 10px;\n}\n\n.column-header__button {\n background: lighten($ui-base-color, 4%);\n border: 0;\n color: $darker-text-color;\n cursor: pointer;\n font-size: 16px;\n padding: 0 15px;\n\n &:hover {\n color: lighten($darker-text-color, 7%);\n }\n\n &.active {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n\n &:hover {\n color: $primary-text-color;\n background: lighten($ui-base-color, 8%);\n }\n }\n}\n\n.column-header__collapsible {\n max-height: 70vh;\n overflow: hidden;\n overflow-y: auto;\n color: $darker-text-color;\n transition: max-height 150ms ease-in-out, opacity 300ms linear;\n opacity: 1;\n z-index: 1;\n position: relative;\n\n &.collapsed {\n max-height: 0;\n opacity: 0.5;\n }\n\n &.animating {\n overflow-y: hidden;\n }\n\n hr {\n height: 0;\n background: transparent;\n border: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n margin: 10px 0;\n }\n}\n\n.column-header__collapsible-inner {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-header__setting-btn {\n &:hover {\n color: $darker-text-color;\n text-decoration: underline;\n }\n}\n\n.column-header__setting-arrows {\n float: right;\n\n .column-header__setting-btn {\n padding: 0 10px;\n\n &:last-child {\n padding-right: 0;\n }\n }\n}\n\n.text-btn {\n display: inline-block;\n padding: 0;\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n border: 0;\n background: transparent;\n cursor: pointer;\n}\n\n.column-header__icon {\n display: inline-block;\n margin-right: 5px;\n}\n\n.loading-indicator {\n color: $dark-text-color;\n font-size: 12px;\n font-weight: 400;\n text-transform: uppercase;\n overflow: visible;\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n\n span {\n display: block;\n float: left;\n margin-left: 50%;\n transform: translateX(-50%);\n margin: 82px 0 0 50%;\n white-space: nowrap;\n }\n}\n\n.loading-indicator__figure {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 42px;\n height: 42px;\n box-sizing: border-box;\n background-color: transparent;\n border: 0 solid lighten($ui-base-color, 26%);\n border-width: 6px;\n border-radius: 50%;\n}\n\n.no-reduce-motion .loading-indicator span {\n animation: loader-label 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n.no-reduce-motion .loading-indicator__figure {\n animation: loader-figure 1.15s infinite cubic-bezier(0.215, 0.61, 0.355, 1);\n}\n\n@keyframes spring-rotate-in {\n 0% {\n transform: rotate(0deg);\n }\n\n 30% {\n transform: rotate(-484.8deg);\n }\n\n 60% {\n transform: rotate(-316.7deg);\n }\n\n 90% {\n transform: rotate(-375deg);\n }\n\n 100% {\n transform: rotate(-360deg);\n }\n}\n\n@keyframes spring-rotate-out {\n 0% {\n transform: rotate(-360deg);\n }\n\n 30% {\n transform: rotate(124.8deg);\n }\n\n 60% {\n transform: rotate(-43.27deg);\n }\n\n 90% {\n transform: rotate(15deg);\n }\n\n 100% {\n transform: rotate(0deg);\n }\n}\n\n@keyframes loader-figure {\n 0% {\n width: 0;\n height: 0;\n background-color: lighten($ui-base-color, 26%);\n }\n\n 29% {\n background-color: lighten($ui-base-color, 26%);\n }\n\n 30% {\n width: 42px;\n height: 42px;\n background-color: transparent;\n border-width: 21px;\n opacity: 1;\n }\n\n 100% {\n width: 42px;\n height: 42px;\n border-width: 0;\n opacity: 0;\n background-color: transparent;\n }\n}\n\n@keyframes loader-label {\n 0% { opacity: 0.25; }\n 30% { opacity: 1; }\n 100% { opacity: 0.25; }\n}\n\n.video-error-cover {\n align-items: center;\n background: $base-overlay-background;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n height: 100%;\n justify-content: center;\n margin-top: 8px;\n position: relative;\n text-align: center;\n z-index: 100;\n}\n\n.media-spoiler {\n background: $base-overlay-background;\n color: $darker-text-color;\n border: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n border-radius: 4px;\n appearance: none;\n\n &:hover,\n &:active,\n &:focus {\n padding: 0;\n color: lighten($darker-text-color, 8%);\n }\n}\n\n.media-spoiler__warning {\n display: block;\n font-size: 14px;\n}\n\n.media-spoiler__trigger {\n display: block;\n font-size: 11px;\n font-weight: 700;\n}\n\n.spoiler-button {\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n position: absolute;\n z-index: 100;\n\n &--minified {\n display: block;\n left: 4px;\n top: 4px;\n width: auto;\n height: auto;\n }\n\n &--click-thru {\n pointer-events: none;\n }\n\n &--hidden {\n display: none;\n }\n\n &__overlay {\n display: block;\n background: transparent;\n width: 100%;\n height: 100%;\n border: 0;\n\n &__label {\n display: inline-block;\n background: rgba($base-overlay-background, 0.5);\n border-radius: 8px;\n padding: 8px 12px;\n color: $primary-text-color;\n font-weight: 500;\n font-size: 14px;\n }\n\n &:hover,\n &:focus,\n &:active {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.8);\n }\n }\n\n &:disabled {\n .spoiler-button__overlay__label {\n background: rgba($base-overlay-background, 0.5);\n }\n }\n }\n}\n\n.modal-container--preloader {\n background: lighten($ui-base-color, 8%);\n}\n\n.account--panel {\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: row;\n padding: 10px 0;\n}\n\n.account--panel__button,\n.detailed-status__button {\n flex: 1 1 auto;\n text-align: center;\n}\n\n.column-settings__outer {\n background: lighten($ui-base-color, 8%);\n padding: 15px;\n}\n\n.column-settings__section {\n color: $darker-text-color;\n cursor: default;\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n}\n\n.column-settings__hashtags {\n .column-settings__row {\n margin-bottom: 15px;\n }\n\n .column-select {\n &__control {\n @include search-input;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n &__placeholder {\n color: $dark-text-color;\n padding-left: 2px;\n font-size: 12px;\n }\n\n &__value-container {\n padding-left: 6px;\n }\n\n &__multi-value {\n background: lighten($ui-base-color, 8%);\n\n &__remove {\n cursor: pointer;\n\n &:hover,\n &:active,\n &:focus {\n background: lighten($ui-base-color, 12%);\n color: lighten($darker-text-color, 4%);\n }\n }\n }\n\n &__multi-value__label,\n &__input {\n color: $darker-text-color;\n }\n\n &__clear-indicator,\n &__dropdown-indicator {\n cursor: pointer;\n transition: none;\n color: $dark-text-color;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($dark-text-color, 4%);\n }\n }\n\n &__indicator-separator {\n background-color: lighten($ui-base-color, 8%);\n }\n\n &__menu {\n @include search-popout;\n padding: 0;\n background: $ui-secondary-color;\n }\n\n &__menu-list {\n padding: 6px;\n }\n\n &__option {\n color: $inverted-text-color;\n border-radius: 4px;\n font-size: 14px;\n\n &--is-focused,\n &--is-selected {\n background: darken($ui-secondary-color, 10%);\n }\n }\n }\n}\n\n.column-settings__row {\n .text-btn {\n margin-bottom: 15px;\n }\n}\n\n.relationship-tag {\n color: $primary-text-color;\n margin-bottom: 4px;\n display: block;\n vertical-align: top;\n background-color: $base-overlay-background;\n text-transform: uppercase;\n font-size: 11px;\n font-weight: 500;\n padding: 4px;\n border-radius: 4px;\n opacity: 0.7;\n\n &:hover {\n opacity: 1;\n }\n}\n\n.setting-toggle {\n display: block;\n line-height: 24px;\n}\n\n.setting-toggle__label {\n color: $darker-text-color;\n display: inline-block;\n margin-bottom: 14px;\n margin-left: 8px;\n vertical-align: middle;\n}\n\n.empty-column-indicator,\n.error-column,\n.follow_requests-unlocked_explanation {\n color: $dark-text-color;\n background: $ui-base-color;\n text-align: center;\n padding: 20px;\n font-size: 15px;\n font-weight: 400;\n cursor: default;\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n justify-content: center;\n\n @supports(display: grid) { // hack to fix Chrome <57\n contain: strict;\n }\n\n & > span {\n max-width: 400px;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.follow_requests-unlocked_explanation {\n background: darken($ui-base-color, 4%);\n contain: initial;\n}\n\n.error-column {\n flex-direction: column;\n}\n\n@keyframes heartbeat {\n from {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n\n 10% {\n transform: scale(0.91);\n animation-timing-function: ease-in;\n }\n\n 17% {\n transform: scale(0.98);\n animation-timing-function: ease-out;\n }\n\n 33% {\n transform: scale(0.87);\n animation-timing-function: ease-in;\n }\n\n 45% {\n transform: scale(1);\n animation-timing-function: ease-out;\n }\n}\n\n.no-reduce-motion .pulse-loading {\n transform-origin: center center;\n animation: heartbeat 1.5s ease-in-out infinite both;\n}\n\n@keyframes shake-bottom {\n 0%,\n 100% {\n transform: rotate(0deg);\n transform-origin: 50% 100%;\n }\n\n 10% {\n transform: rotate(2deg);\n }\n\n 20%,\n 40%,\n 60% {\n transform: rotate(-4deg);\n }\n\n 30%,\n 50%,\n 70% {\n transform: rotate(4deg);\n }\n\n 80% {\n transform: rotate(-2deg);\n }\n\n 90% {\n transform: rotate(2deg);\n }\n}\n\n.no-reduce-motion .shake-bottom {\n transform-origin: 50% 100%;\n animation: shake-bottom 0.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) 2s 2 both;\n}\n\n.emoji-picker-dropdown__menu {\n background: $simple-background-color;\n position: absolute;\n box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-top: 5px;\n z-index: 2;\n\n .emoji-mart-scroll {\n transition: opacity 200ms ease;\n }\n\n &.selecting .emoji-mart-scroll {\n opacity: 0.5;\n }\n}\n\n.emoji-picker-dropdown__modifiers {\n position: absolute;\n top: 60px;\n right: 11px;\n cursor: pointer;\n}\n\n.emoji-picker-dropdown__modifiers__menu {\n position: absolute;\n z-index: 4;\n top: -4px;\n left: -8px;\n background: $simple-background-color;\n border-radius: 4px;\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n overflow: hidden;\n\n button {\n display: block;\n cursor: pointer;\n border: 0;\n padding: 4px 8px;\n background: transparent;\n\n &:hover,\n &:focus,\n &:active {\n background: rgba($ui-secondary-color, 0.4);\n }\n }\n\n .emoji-mart-emoji {\n height: 22px;\n }\n}\n\n.emoji-mart-emoji {\n span {\n background-repeat: no-repeat;\n }\n}\n\n.upload-area {\n align-items: center;\n background: rgba($base-overlay-background, 0.8);\n display: flex;\n height: 100%;\n justify-content: center;\n left: 0;\n opacity: 0;\n position: absolute;\n top: 0;\n visibility: hidden;\n width: 100%;\n z-index: 2000;\n\n * {\n pointer-events: none;\n }\n}\n\n.upload-area__drop {\n width: 320px;\n height: 160px;\n display: flex;\n box-sizing: border-box;\n position: relative;\n padding: 8px;\n}\n\n.upload-area__background {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: -1;\n border-radius: 4px;\n background: $ui-base-color;\n box-shadow: 0 0 5px rgba($base-shadow-color, 0.2);\n}\n\n.upload-area__content {\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n color: $secondary-text-color;\n font-size: 18px;\n font-weight: 500;\n border: 2px dashed $ui-base-lighter-color;\n border-radius: 4px;\n}\n\n.upload-progress {\n padding: 10px;\n color: $lighter-text-color;\n overflow: hidden;\n display: flex;\n\n .fa {\n font-size: 34px;\n margin-right: 10px;\n }\n\n span {\n font-size: 12px;\n text-transform: uppercase;\n font-weight: 500;\n display: block;\n }\n}\n\n.upload-progess__message {\n flex: 1 1 auto;\n}\n\n.upload-progress__backdrop {\n width: 100%;\n height: 6px;\n border-radius: 6px;\n background: $ui-base-lighter-color;\n position: relative;\n margin-top: 5px;\n}\n\n.upload-progress__tracker {\n position: absolute;\n left: 0;\n top: 0;\n height: 6px;\n background: $ui-highlight-color;\n border-radius: 6px;\n}\n\n.emoji-button {\n display: block;\n padding: 5px 5px 2px 2px;\n outline: 0;\n cursor: pointer;\n\n &:active,\n &:focus {\n outline: 0 !important;\n }\n\n img {\n filter: grayscale(100%);\n opacity: 0.8;\n display: block;\n margin: 0;\n width: 22px;\n height: 22px;\n }\n\n &:hover,\n &:active,\n &:focus {\n img {\n opacity: 1;\n filter: none;\n }\n }\n}\n\n.dropdown--active .emoji-button img {\n opacity: 1;\n filter: none;\n}\n\n.privacy-dropdown__dropdown {\n position: absolute;\n background: $simple-background-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 4px;\n margin-left: 40px;\n overflow: hidden;\n\n &.top {\n transform-origin: 50% 100%;\n }\n\n &.bottom {\n transform-origin: 50% 0;\n }\n}\n\n.privacy-dropdown__option {\n color: $inverted-text-color;\n padding: 10px;\n cursor: pointer;\n display: flex;\n\n &:hover,\n &.active {\n background: $ui-highlight-color;\n color: $primary-text-color;\n outline: 0;\n\n .privacy-dropdown__option__content {\n color: $primary-text-color;\n\n strong {\n color: $primary-text-color;\n }\n }\n }\n\n &.active:hover {\n background: lighten($ui-highlight-color, 4%);\n }\n}\n\n.privacy-dropdown__option__icon {\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: 10px;\n}\n\n.privacy-dropdown__option__content {\n flex: 1 1 auto;\n color: $lighter-text-color;\n\n strong {\n font-weight: 500;\n display: block;\n color: $inverted-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.privacy-dropdown.active {\n .privacy-dropdown__value {\n background: $simple-background-color;\n border-radius: 4px 4px 0 0;\n box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);\n\n .icon-button {\n transition: none;\n }\n\n &.active {\n background: $ui-highlight-color;\n\n .icon-button {\n color: $primary-text-color;\n }\n }\n }\n\n &.top .privacy-dropdown__value {\n border-radius: 0 0 4px 4px;\n }\n\n .privacy-dropdown__dropdown {\n display: block;\n box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);\n }\n}\n\n.search {\n position: relative;\n}\n\n.search__input {\n @include search-input;\n\n display: block;\n padding: 15px;\n padding-right: 30px;\n line-height: 18px;\n font-size: 16px;\n\n &::placeholder {\n color: lighten($darker-text-color, 4%);\n }\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n}\n\n.search__icon {\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus {\n outline: 0 !important;\n }\n\n .fa {\n position: absolute;\n top: 16px;\n right: 10px;\n z-index: 2;\n display: inline-block;\n opacity: 0;\n transition: all 100ms linear;\n transition-property: transform, opacity;\n font-size: 18px;\n width: 18px;\n height: 18px;\n color: $secondary-text-color;\n cursor: default;\n pointer-events: none;\n\n &.active {\n pointer-events: auto;\n opacity: 0.3;\n }\n }\n\n .fa-search {\n transform: rotate(90deg);\n\n &.active {\n pointer-events: none;\n transform: rotate(0deg);\n }\n }\n\n .fa-times-circle {\n top: 17px;\n transform: rotate(0deg);\n color: $action-button-color;\n cursor: pointer;\n\n &.active {\n transform: rotate(90deg);\n }\n\n &:hover {\n color: lighten($action-button-color, 7%);\n }\n }\n}\n\n.search-results__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n}\n\n.search-results__section {\n margin-bottom: 5px;\n\n h5 {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n padding: 15px;\n font-weight: 500;\n font-size: 16px;\n color: $dark-text-color;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n .account:last-child,\n & > div:last-child .status {\n border-bottom: 0;\n }\n}\n\n.search-results__hashtag {\n display: block;\n padding: 10px;\n color: $secondary-text-color;\n text-decoration: none;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($secondary-text-color, 4%);\n text-decoration: underline;\n }\n}\n\n.search-results__info {\n padding: 20px;\n color: $darker-text-color;\n text-align: center;\n}\n\n.modal-root {\n position: relative;\n transition: opacity 0.3s linear;\n will-change: opacity;\n z-index: 9999;\n}\n\n.modal-root__overlay {\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba($base-overlay-background, 0.7);\n}\n\n.modal-root__container {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n align-content: space-around;\n z-index: 9999;\n pointer-events: none;\n user-select: none;\n}\n\n.modal-root__modal {\n pointer-events: auto;\n display: flex;\n z-index: 9999;\n}\n\n.video-modal__container {\n max-width: 100vw;\n max-height: 100vh;\n}\n\n.audio-modal__container {\n width: 50vw;\n}\n\n.media-modal {\n width: 100%;\n height: 100%;\n position: relative;\n\n .extended-video-player {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n\n video {\n max-width: $media-modal-media-max-width;\n max-height: $media-modal-media-max-height;\n }\n }\n}\n\n.media-modal__closer {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n}\n\n.media-modal__navigation {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n pointer-events: none;\n transition: opacity 0.3s linear;\n will-change: opacity;\n\n * {\n pointer-events: auto;\n }\n\n &.media-modal__navigation--hidden {\n opacity: 0;\n\n * {\n pointer-events: none;\n }\n }\n}\n\n.media-modal__nav {\n background: rgba($base-overlay-background, 0.5);\n box-sizing: border-box;\n border: 0;\n color: $primary-text-color;\n cursor: pointer;\n display: flex;\n align-items: center;\n font-size: 24px;\n height: 20vmax;\n margin: auto 0;\n padding: 30px 15px;\n position: absolute;\n top: 0;\n bottom: 0;\n}\n\n.media-modal__nav--left {\n left: 0;\n}\n\n.media-modal__nav--right {\n right: 0;\n}\n\n.media-modal__pagination {\n width: 100%;\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n pointer-events: none;\n}\n\n.media-modal__meta {\n text-align: center;\n position: absolute;\n left: 0;\n bottom: 20px;\n width: 100%;\n pointer-events: none;\n\n &--shifted {\n bottom: 62px;\n }\n\n a {\n pointer-events: auto;\n text-decoration: none;\n font-weight: 500;\n color: $ui-secondary-color;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n}\n\n.media-modal__page-dot {\n display: inline-block;\n}\n\n.media-modal__button {\n background-color: $primary-text-color;\n height: 12px;\n width: 12px;\n border-radius: 6px;\n margin: 10px;\n padding: 0;\n border: 0;\n font-size: 0;\n}\n\n.media-modal__button--active {\n background-color: $highlight-text-color;\n}\n\n.media-modal__close {\n position: absolute;\n right: 8px;\n top: 8px;\n z-index: 100;\n}\n\n.onboarding-modal,\n.error-modal,\n.embed-modal {\n background: $ui-secondary-color;\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n}\n\n.error-modal__body {\n height: 80vh;\n width: 80vw;\n max-width: 520px;\n max-height: 420px;\n position: relative;\n\n & > div {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n box-sizing: border-box;\n padding: 25px;\n display: none;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n display: flex;\n opacity: 0;\n user-select: text;\n }\n}\n\n.error-modal__body {\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n}\n\n.onboarding-modal__paginator,\n.error-modal__footer {\n flex: 0 0 auto;\n background: darken($ui-secondary-color, 8%);\n display: flex;\n padding: 25px;\n\n & > div {\n min-width: 33px;\n }\n\n .onboarding-modal__nav,\n .error-modal__nav {\n color: $lighter-text-color;\n border: 0;\n font-size: 14px;\n font-weight: 500;\n padding: 10px 25px;\n line-height: inherit;\n height: auto;\n margin: -10px;\n border-radius: 4px;\n background-color: transparent;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: darken($ui-secondary-color, 16%);\n }\n\n &.onboarding-modal__done,\n &.onboarding-modal__next {\n color: $inverted-text-color;\n\n &:hover,\n &:focus,\n &:active {\n color: lighten($inverted-text-color, 4%);\n }\n }\n }\n}\n\n.error-modal__footer {\n justify-content: center;\n}\n\n.display-case {\n text-align: center;\n font-size: 15px;\n margin-bottom: 15px;\n\n &__label {\n font-weight: 500;\n color: $inverted-text-color;\n margin-bottom: 5px;\n text-transform: uppercase;\n font-size: 12px;\n }\n\n &__case {\n background: $ui-base-color;\n color: $secondary-text-color;\n font-weight: 500;\n padding: 10px;\n border-radius: 4px;\n }\n}\n\n.onboard-sliders {\n display: inline-block;\n max-width: 30px;\n max-height: auto;\n margin-left: 10px;\n}\n\n.boost-modal,\n.confirmation-modal,\n.report-modal,\n.actions-modal,\n.mute-modal,\n.block-modal {\n background: lighten($ui-secondary-color, 8%);\n color: $inverted-text-color;\n border-radius: 8px;\n overflow: hidden;\n max-width: 90vw;\n width: 480px;\n position: relative;\n flex-direction: column;\n\n .status__display-name {\n display: block;\n max-width: 100%;\n padding-right: 25px;\n }\n\n .status__avatar {\n height: 28px;\n left: 10px;\n position: absolute;\n top: 10px;\n width: 48px;\n }\n\n .status__content__spoiler-link {\n color: lighten($secondary-text-color, 8%);\n }\n}\n\n.actions-modal {\n .status {\n background: $white;\n border-bottom-color: $ui-secondary-color;\n padding-top: 10px;\n padding-bottom: 10px;\n }\n\n .dropdown-menu__separator {\n border-bottom-color: $ui-secondary-color;\n }\n}\n\n.boost-modal__container {\n overflow-x: scroll;\n padding: 10px;\n\n .status {\n user-select: text;\n border-bottom: 0;\n }\n}\n\n.boost-modal__action-bar,\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n display: flex;\n justify-content: space-between;\n background: $ui-secondary-color;\n padding: 10px;\n line-height: 36px;\n\n & > div {\n flex: 1 1 auto;\n text-align: right;\n color: $lighter-text-color;\n padding-right: 10px;\n }\n\n .button {\n flex: 0 0 auto;\n }\n}\n\n.boost-modal__status-header {\n font-size: 15px;\n}\n\n.boost-modal__status-time {\n float: right;\n font-size: 14px;\n}\n\n.mute-modal,\n.block-modal {\n line-height: 24px;\n}\n\n.mute-modal .react-toggle,\n.block-modal .react-toggle {\n vertical-align: middle;\n}\n\n.report-modal {\n width: 90vw;\n max-width: 700px;\n}\n\n.report-modal__container {\n display: flex;\n border-top: 1px solid $ui-secondary-color;\n\n @media screen and (max-width: 480px) {\n flex-wrap: wrap;\n overflow-y: auto;\n }\n}\n\n.report-modal__statuses,\n.report-modal__comment {\n box-sizing: border-box;\n width: 50%;\n\n @media screen and (max-width: 480px) {\n width: 100%;\n }\n}\n\n.report-modal__statuses,\n.focal-point-modal__content {\n flex: 1 1 auto;\n min-height: 20vh;\n max-height: 80vh;\n overflow-y: auto;\n overflow-x: hidden;\n\n .status__content a {\n color: $highlight-text-color;\n }\n\n .status__content,\n .status__content p {\n color: $inverted-text-color;\n }\n\n @media screen and (max-width: 480px) {\n max-height: 10vh;\n }\n}\n\n.focal-point-modal__content {\n @media screen and (max-width: 480px) {\n max-height: 40vh;\n }\n}\n\n.report-modal__comment {\n padding: 20px;\n border-right: 1px solid $ui-secondary-color;\n max-width: 320px;\n\n p {\n font-size: 14px;\n line-height: 20px;\n margin-bottom: 20px;\n }\n\n .setting-text {\n display: block;\n box-sizing: border-box;\n width: 100%;\n margin: 0;\n color: $inverted-text-color;\n background: $white;\n padding: 10px;\n font-family: inherit;\n font-size: 14px;\n resize: none;\n border: 0;\n outline: 0;\n border-radius: 4px;\n border: 1px solid $ui-secondary-color;\n min-height: 100px;\n max-height: 50vh;\n margin-bottom: 10px;\n\n &:focus {\n border: 1px solid darken($ui-secondary-color, 8%);\n }\n\n &__wrapper {\n background: $white;\n border: 1px solid $ui-secondary-color;\n margin-bottom: 10px;\n border-radius: 4px;\n\n .setting-text {\n border: 0;\n margin-bottom: 0;\n border-radius: 0;\n\n &:focus {\n border: 0;\n }\n }\n\n &__modifiers {\n color: $inverted-text-color;\n font-family: inherit;\n font-size: 14px;\n background: $white;\n }\n }\n\n &__toolbar {\n display: flex;\n justify-content: space-between;\n margin-bottom: 20px;\n }\n }\n\n .setting-text-label {\n display: block;\n color: $inverted-text-color;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n\n &__label {\n color: $inverted-text-color;\n font-size: 14px;\n }\n }\n\n @media screen and (max-width: 480px) {\n padding: 10px;\n max-width: 100%;\n order: 2;\n\n .setting-toggle {\n margin-bottom: 4px;\n }\n }\n}\n\n.actions-modal {\n max-height: 80vh;\n max-width: 80vw;\n\n .status {\n overflow-y: auto;\n max-height: 300px;\n }\n\n .actions-modal__item-label {\n font-weight: 500;\n }\n\n ul {\n overflow-y: auto;\n flex-shrink: 0;\n max-height: 80vh;\n\n &.with-status {\n max-height: calc(80vh - 75px);\n }\n\n li:empty {\n margin: 0;\n }\n\n li:not(:empty) {\n a {\n color: $inverted-text-color;\n display: flex;\n padding: 12px 16px;\n font-size: 15px;\n align-items: center;\n text-decoration: none;\n\n &,\n button {\n transition: none;\n }\n\n &.active,\n &:hover,\n &:active,\n &:focus {\n &,\n button {\n background: $ui-highlight-color;\n color: $primary-text-color;\n }\n }\n\n button:first-child {\n margin-right: 10px;\n }\n }\n }\n }\n}\n\n.confirmation-modal__action-bar,\n.mute-modal__action-bar,\n.block-modal__action-bar {\n .confirmation-modal__secondary-button {\n flex-shrink: 1;\n }\n}\n\n.confirmation-modal__secondary-button,\n.confirmation-modal__cancel-button,\n.mute-modal__cancel-button,\n.block-modal__cancel-button {\n background-color: transparent;\n color: $lighter-text-color;\n font-size: 14px;\n font-weight: 500;\n\n &:hover,\n &:focus,\n &:active {\n color: darken($lighter-text-color, 4%);\n background-color: transparent;\n }\n}\n\n.confirmation-modal__container,\n.mute-modal__container,\n.block-modal__container,\n.report-modal__target {\n padding: 30px;\n font-size: 16px;\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n}\n\n.confirmation-modal__container,\n.report-modal__target {\n text-align: center;\n}\n\n.block-modal,\n.mute-modal {\n &__explanation {\n margin-top: 20px;\n }\n\n .setting-toggle {\n margin-top: 20px;\n margin-bottom: 24px;\n display: flex;\n align-items: center;\n\n &__label {\n color: $inverted-text-color;\n margin: 0;\n margin-left: 8px;\n }\n }\n}\n\n.report-modal__target {\n padding: 15px;\n\n .media-modal__close {\n top: 14px;\n right: 15px;\n }\n}\n\n.loading-bar {\n background-color: $highlight-text-color;\n height: 3px;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 9999;\n}\n\n.media-gallery__gifv__label {\n display: block;\n position: absolute;\n color: $primary-text-color;\n background: rgba($base-overlay-background, 0.5);\n bottom: 6px;\n left: 6px;\n padding: 2px 6px;\n border-radius: 2px;\n font-size: 11px;\n font-weight: 600;\n z-index: 1;\n pointer-events: none;\n opacity: 0.9;\n transition: opacity 0.1s ease;\n line-height: 18px;\n}\n\n.media-gallery__gifv {\n &:hover {\n .media-gallery__gifv__label {\n opacity: 1;\n }\n }\n}\n\n.media-gallery__audio {\n margin-top: 32px;\n\n audio {\n width: 100%;\n }\n}\n\n.attachment-list {\n display: flex;\n font-size: 14px;\n border: 1px solid lighten($ui-base-color, 8%);\n border-radius: 4px;\n margin-top: 14px;\n overflow: hidden;\n\n &__icon {\n flex: 0 0 auto;\n color: $dark-text-color;\n padding: 8px 18px;\n cursor: default;\n border-right: 1px solid lighten($ui-base-color, 8%);\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n font-size: 26px;\n\n .fa {\n display: block;\n }\n }\n\n &__list {\n list-style: none;\n padding: 4px 0;\n padding-left: 8px;\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n li {\n display: block;\n padding: 4px 0;\n }\n\n a {\n text-decoration: none;\n color: $dark-text-color;\n font-weight: 500;\n\n &:hover {\n text-decoration: underline;\n }\n }\n }\n\n &.compact {\n border: 0;\n margin-top: 4px;\n\n .attachment-list__list {\n padding: 0;\n display: block;\n }\n\n .fa {\n color: $dark-text-color;\n }\n }\n}\n\n/* Media Gallery */\n.media-gallery {\n box-sizing: border-box;\n margin-top: 8px;\n overflow: hidden;\n border-radius: 4px;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n float: left;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n\n &.standalone {\n .media-gallery__item-gifv-thumbnail {\n transform: none;\n top: 0;\n }\n }\n}\n\n.media-gallery__item-thumbnail {\n cursor: zoom-in;\n display: block;\n text-decoration: none;\n color: $secondary-text-color;\n position: relative;\n z-index: 1;\n\n &,\n img {\n height: 100%;\n width: 100%;\n }\n\n img {\n object-fit: cover;\n }\n}\n\n.media-gallery__preview {\n width: 100%;\n height: 100%;\n object-fit: cover;\n position: absolute;\n top: 0;\n left: 0;\n z-index: 0;\n background: $base-overlay-background;\n\n &--hidden {\n display: none;\n }\n}\n\n.media-gallery__gifv {\n height: 100%;\n overflow: hidden;\n position: relative;\n width: 100%;\n}\n\n.media-gallery__item-gifv-thumbnail {\n cursor: zoom-in;\n height: 100%;\n object-fit: cover;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n width: 100%;\n z-index: 1;\n}\n\n.media-gallery__item-thumbnail-label {\n clip: rect(1px 1px 1px 1px); /* IE6, IE7 */\n clip: rect(1px, 1px, 1px, 1px);\n overflow: hidden;\n position: absolute;\n}\n/* End Media Gallery */\n\n.detailed,\n.fullscreen {\n .video-player__volume__current,\n .video-player__volume::before {\n bottom: 27px;\n }\n\n .video-player__volume__handle {\n bottom: 23px;\n }\n\n}\n\n.audio-player {\n box-sizing: border-box;\n position: relative;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding-bottom: 44px;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100%;\n }\n\n &__waveform {\n padding: 15px 0;\n position: relative;\n overflow: hidden;\n\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n border-top: 1px solid lighten($ui-base-color, 4%);\n width: 100%;\n height: 0;\n left: 0;\n top: calc(50% + 1px);\n }\n }\n\n &__progress-placeholder {\n background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);\n }\n\n &__wave-placeholder {\n background-color: lighten($ui-base-color, 16%);\n }\n\n .video-player__controls {\n padding: 0 15px;\n padding-top: 10px;\n background: darken($ui-base-color, 8%);\n border-top: 1px solid lighten($ui-base-color, 4%);\n border-radius: 0 0 4px 4px;\n }\n}\n\n.video-player {\n overflow: hidden;\n position: relative;\n background: $base-shadow-color;\n max-width: 100%;\n border-radius: 4px;\n box-sizing: border-box;\n direction: ltr;\n\n &.editable {\n border-radius: 0;\n height: 100% !important;\n }\n\n &:focus {\n outline: 0;\n }\n\n video {\n max-width: 100vw;\n max-height: 80vh;\n z-index: 1;\n }\n\n &.fullscreen {\n width: 100% !important;\n height: 100% !important;\n margin: 0;\n\n video {\n max-width: 100% !important;\n max-height: 100% !important;\n width: 100% !important;\n height: 100% !important;\n outline: 0;\n }\n }\n\n &.inline {\n video {\n object-fit: contain;\n position: relative;\n top: 50%;\n transform: translateY(-50%);\n }\n }\n\n &__controls {\n position: absolute;\n z-index: 2;\n bottom: 0;\n left: 0;\n right: 0;\n box-sizing: border-box;\n background: linear-gradient(0deg, rgba($base-shadow-color, 0.85) 0, rgba($base-shadow-color, 0.45) 60%, transparent);\n padding: 0 15px;\n opacity: 0;\n transition: opacity .1s ease;\n\n &.active {\n opacity: 1;\n }\n }\n\n &.inactive {\n video,\n .video-player__controls {\n visibility: hidden;\n }\n }\n\n &__spoiler {\n display: none;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 4;\n border: 0;\n background: $base-overlay-background;\n color: $darker-text-color;\n transition: none;\n pointer-events: none;\n\n &.active {\n display: block;\n pointer-events: auto;\n\n &:hover,\n &:active,\n &:focus {\n color: lighten($darker-text-color, 7%);\n }\n }\n\n &__title {\n display: block;\n font-size: 14px;\n }\n\n &__subtitle {\n display: block;\n font-size: 11px;\n font-weight: 500;\n }\n }\n\n &__buttons-bar {\n display: flex;\n justify-content: space-between;\n padding-bottom: 10px;\n\n .video-player__download__icon {\n color: inherit;\n }\n }\n\n &__buttons {\n font-size: 16px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n &.left {\n button {\n padding-left: 0;\n }\n }\n\n &.right {\n button {\n padding-right: 0;\n }\n }\n\n button {\n background: transparent;\n padding: 2px 10px;\n font-size: 16px;\n border: 0;\n color: rgba($white, 0.75);\n\n &:active,\n &:hover,\n &:focus {\n color: $white;\n }\n }\n }\n\n &__time-sep,\n &__time-total,\n &__time-current {\n font-size: 14px;\n font-weight: 500;\n }\n\n &__time-current {\n color: $white;\n margin-left: 60px;\n }\n\n &__time-sep {\n display: inline-block;\n margin: 0 6px;\n }\n\n &__time-sep,\n &__time-total {\n color: $white;\n }\n\n &__volume {\n cursor: pointer;\n height: 24px;\n display: inline;\n\n &::before {\n content: \"\";\n width: 50px;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n left: 70px;\n bottom: 20px;\n }\n\n &__current {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n left: 70px;\n bottom: 20px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n bottom: 16px;\n left: 70px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n }\n }\n\n &__link {\n padding: 2px 10px;\n\n a {\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n color: $white;\n\n &:hover,\n &:active,\n &:focus {\n text-decoration: underline;\n }\n }\n }\n\n &__seek {\n cursor: pointer;\n height: 24px;\n position: relative;\n\n &::before {\n content: \"\";\n width: 100%;\n background: rgba($white, 0.35);\n border-radius: 4px;\n display: block;\n position: absolute;\n height: 4px;\n top: 10px;\n }\n\n &__progress,\n &__buffer {\n display: block;\n position: absolute;\n height: 4px;\n border-radius: 4px;\n top: 10px;\n background: lighten($ui-highlight-color, 8%);\n }\n\n &__buffer {\n background: rgba($white, 0.2);\n }\n\n &__handle {\n position: absolute;\n z-index: 3;\n opacity: 0;\n border-radius: 50%;\n width: 12px;\n height: 12px;\n top: 6px;\n margin-left: -6px;\n transition: opacity .1s ease;\n background: lighten($ui-highlight-color, 8%);\n box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);\n pointer-events: none;\n\n &.active {\n opacity: 1;\n }\n }\n\n &:hover {\n .video-player__seek__handle {\n opacity: 1;\n }\n }\n }\n\n &.detailed,\n &.fullscreen {\n .video-player__buttons {\n button {\n padding-top: 10px;\n padding-bottom: 10px;\n }\n }\n }\n}\n\n.directory {\n &__list {\n width: 100%;\n margin: 10px 0;\n transition: opacity 100ms ease-in;\n\n &.loading {\n opacity: 0.7;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n margin: 0;\n }\n }\n\n &__card {\n box-sizing: border-box;\n margin-bottom: 10px;\n\n &__img {\n height: 125px;\n position: relative;\n background: darken($ui-base-color, 12%);\n overflow: hidden;\n\n img {\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n object-fit: cover;\n }\n }\n\n &__bar {\n display: flex;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n padding: 10px;\n\n &__name {\n flex: 1 1 auto;\n display: flex;\n align-items: center;\n text-decoration: none;\n overflow: hidden;\n }\n\n &__relationship {\n width: 23px;\n min-height: 1px;\n flex: 0 0 auto;\n }\n\n .avatar {\n flex: 0 0 auto;\n width: 48px;\n height: 48px;\n padding-top: 2px;\n\n img {\n width: 100%;\n height: 100%;\n display: block;\n margin: 0;\n border-radius: 4px;\n background: darken($ui-base-color, 8%);\n object-fit: cover;\n }\n }\n\n .display-name {\n margin-left: 15px;\n text-align: left;\n\n strong {\n font-size: 15px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n span {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n &__extra {\n background: $ui-base-color;\n display: flex;\n align-items: center;\n justify-content: center;\n\n .accounts-table__count {\n width: 33.33%;\n flex: 0 0 auto;\n padding: 15px 0;\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 15px 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n width: 100%;\n min-height: 18px + 30px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n p {\n display: none;\n\n &:first-child {\n display: inline;\n }\n }\n\n br {\n display: none;\n }\n }\n }\n }\n}\n\n.account-gallery__container {\n display: flex;\n flex-wrap: wrap;\n padding: 4px 2px;\n}\n\n.account-gallery__item {\n border: 0;\n box-sizing: border-box;\n display: block;\n position: relative;\n border-radius: 4px;\n overflow: hidden;\n margin: 2px;\n\n &__icons {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n font-size: 24px;\n }\n}\n\n.notification__filter-bar,\n.account__section-headline {\n background: darken($ui-base-color, 4%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n cursor: default;\n display: flex;\n flex-shrink: 0;\n\n button {\n background: darken($ui-base-color, 4%);\n border: 0;\n margin: 0;\n }\n\n button,\n a {\n display: block;\n flex: 1 1 auto;\n color: $darker-text-color;\n padding: 15px 0;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n text-decoration: none;\n position: relative;\n width: 100%;\n white-space: nowrap;\n\n &.active {\n color: $secondary-text-color;\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n bottom: 0;\n left: 50%;\n width: 0;\n height: 0;\n transform: translateX(-50%);\n border-style: solid;\n border-width: 0 10px 10px;\n border-color: transparent transparent lighten($ui-base-color, 8%);\n }\n\n &::after {\n bottom: -1px;\n border-color: transparent transparent $ui-base-color;\n }\n }\n }\n\n &.directory__section-headline {\n background: darken($ui-base-color, 2%);\n border-bottom-color: transparent;\n\n a,\n button {\n &.active {\n &::before {\n display: none;\n }\n\n &::after {\n border-color: transparent transparent darken($ui-base-color, 7%);\n }\n }\n }\n }\n}\n\n.filter-form {\n background: $ui-base-color;\n\n &__column {\n padding: 10px 15px;\n }\n\n .radio-button {\n display: block;\n }\n}\n\n.radio-button {\n font-size: 14px;\n position: relative;\n display: inline-block;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n cursor: pointer;\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n\n &.checked {\n border-color: lighten($ui-highlight-color, 8%);\n background: lighten($ui-highlight-color, 8%);\n }\n }\n}\n\n::-webkit-scrollbar-thumb {\n border-radius: 0;\n}\n\n.search-popout {\n @include search-popout;\n}\n\nnoscript {\n text-align: center;\n\n img {\n width: 200px;\n opacity: 0.5;\n animation: flicker 4s infinite;\n }\n\n div {\n font-size: 14px;\n margin: 30px auto;\n color: $secondary-text-color;\n max-width: 400px;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n }\n}\n\n@keyframes flicker {\n 0% { opacity: 1; }\n 30% { opacity: 0.75; }\n 100% { opacity: 1; }\n}\n\n@media screen and (max-width: 630px) and (max-height: 400px) {\n $duration: 400ms;\n $delay: 100ms;\n\n .tabs-bar,\n .search {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar {\n will-change: padding-bottom;\n transition: padding-bottom $duration $delay;\n }\n\n .navigation-bar {\n & > a:first-child {\n will-change: margin-top, margin-left, margin-right, width;\n transition: margin-top $duration $delay, margin-left $duration ($duration + $delay), margin-right $duration ($duration + $delay);\n }\n\n & > .navigation-bar__profile-edit {\n will-change: margin-top;\n transition: margin-top $duration $delay;\n }\n\n .navigation-bar__actions {\n & > .icon-button.close {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay,\n transform $duration $delay;\n }\n\n & > .compose__action-bar .icon-button {\n will-change: opacity transform;\n transition: opacity $duration * 0.5 $delay + $duration * 0.5,\n transform $duration $delay;\n }\n }\n }\n\n .is-composing {\n .tabs-bar,\n .search {\n margin-top: -50px;\n }\n\n .navigation-bar {\n padding-bottom: 0;\n\n & > a:first-child {\n margin: -100px 10px 0 -50px;\n }\n\n .navigation-bar__profile {\n padding-top: 2px;\n }\n\n .navigation-bar__profile-edit {\n position: absolute;\n margin-top: -60px;\n }\n\n .navigation-bar__actions {\n .icon-button.close {\n pointer-events: auto;\n opacity: 1;\n transform: scale(1, 1) translate(0, 0);\n bottom: 5px;\n }\n\n .compose__action-bar .icon-button {\n pointer-events: none;\n opacity: 0;\n transform: scale(0, 1) translate(100%, 0);\n }\n }\n }\n }\n}\n\n.embed-modal {\n width: auto;\n max-width: 80vw;\n max-height: 80vh;\n\n h4 {\n padding: 30px;\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n }\n\n .embed-modal__container {\n padding: 10px;\n\n .hint {\n margin-bottom: 15px;\n }\n\n .embed-modal__html {\n outline: 0;\n box-sizing: border-box;\n display: block;\n width: 100%;\n border: 0;\n padding: 10px;\n font-family: $font-monospace, monospace;\n background: $ui-base-color;\n color: $primary-text-color;\n font-size: 14px;\n margin: 0;\n margin-bottom: 15px;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n @media screen and (max-width: 600px) {\n font-size: 16px;\n }\n }\n\n .embed-modal__iframe {\n width: 400px;\n max-width: 100%;\n overflow: hidden;\n border: 0;\n border-radius: 4px;\n }\n }\n}\n\n.account__moved-note {\n padding: 14px 10px;\n padding-bottom: 16px;\n background: lighten($ui-base-color, 4%);\n border-top: 1px solid lighten($ui-base-color, 8%);\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &__message {\n position: relative;\n margin-left: 58px;\n color: $dark-text-color;\n padding: 8px 0;\n padding-top: 0;\n padding-bottom: 4px;\n font-size: 14px;\n\n > span {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__icon-wrapper {\n left: -26px;\n position: absolute;\n }\n\n .detailed-status__display-avatar {\n position: relative;\n }\n\n .detailed-status__display-name {\n margin-bottom: 0;\n }\n}\n\n.column-inline-form {\n padding: 15px;\n padding-right: 0;\n display: flex;\n justify-content: flex-start;\n align-items: center;\n background: lighten($ui-base-color, 4%);\n\n label {\n flex: 1 1 auto;\n\n input {\n width: 100%;\n\n &:focus {\n outline: 0;\n }\n }\n }\n\n .icon-button {\n flex: 0 0 auto;\n margin: 0 10px;\n }\n}\n\n.drawer__backdrop {\n cursor: pointer;\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba($base-overlay-background, 0.5);\n}\n\n.list-editor {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n h4 {\n padding: 15px 0;\n background: lighten($ui-base-color, 13%);\n font-weight: 500;\n font-size: 16px;\n text-align: center;\n border-radius: 8px 8px 0 0;\n }\n\n .drawer__pager {\n height: 50vh;\n }\n\n .drawer__inner {\n border-radius: 0 0 8px 8px;\n\n &.backdrop {\n width: calc(100% - 60px);\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n border-radius: 0 0 0 8px;\n }\n }\n\n &__accounts {\n overflow-y: auto;\n }\n\n .account__display-name {\n &:hover strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n\n .search {\n margin-bottom: 0;\n }\n}\n\n.list-adder {\n background: $ui-base-color;\n flex-direction: column;\n border-radius: 8px;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n width: 380px;\n overflow: hidden;\n\n @media screen and (max-width: 420px) {\n width: 90%;\n }\n\n &__account {\n background: lighten($ui-base-color, 13%);\n }\n\n &__lists {\n background: lighten($ui-base-color, 13%);\n height: 50vh;\n border-radius: 0 0 8px 8px;\n overflow-y: auto;\n }\n\n .list {\n padding: 10px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .list__wrapper {\n display: flex;\n }\n\n .list__display-name {\n flex: 1 1 auto;\n overflow: hidden;\n text-decoration: none;\n font-size: 16px;\n padding: 10px;\n }\n}\n\n.focal-point {\n position: relative;\n cursor: move;\n overflow: hidden;\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n background: $base-shadow-color;\n\n img,\n video,\n canvas {\n display: block;\n max-height: 80vh;\n width: 100%;\n height: auto;\n margin: 0;\n object-fit: contain;\n background: $base-shadow-color;\n }\n\n &__reticle {\n position: absolute;\n width: 100px;\n height: 100px;\n transform: translate(-50%, -50%);\n background: url('~images/reticle.png') no-repeat 0 0;\n border-radius: 50%;\n box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);\n }\n\n &__overlay {\n position: absolute;\n width: 100%;\n height: 100%;\n top: 0;\n left: 0;\n }\n\n &__preview {\n position: absolute;\n bottom: 10px;\n right: 10px;\n z-index: 2;\n cursor: move;\n transition: opacity 0.1s ease;\n\n &:hover {\n opacity: 0.5;\n }\n\n strong {\n color: $primary-text-color;\n font-size: 14px;\n font-weight: 500;\n display: block;\n margin-bottom: 5px;\n }\n\n div {\n border-radius: 4px;\n box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);\n }\n }\n\n @media screen and (max-width: 480px) {\n img,\n video {\n max-height: 100%;\n }\n\n &__preview {\n display: none;\n }\n }\n}\n\n.account__header__content {\n color: $darker-text-color;\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n word-break: normal;\n word-wrap: break-word;\n\n p {\n margin-bottom: 20px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n a {\n color: inherit;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n}\n\n.account__header {\n overflow: hidden;\n\n &.inactive {\n opacity: 0.5;\n\n .account__header__image,\n .account__avatar {\n filter: grayscale(100%);\n }\n }\n\n &__info {\n position: absolute;\n top: 10px;\n left: 10px;\n }\n\n &__image {\n overflow: hidden;\n height: 145px;\n position: relative;\n background: darken($ui-base-color, 4%);\n\n img {\n object-fit: cover;\n display: block;\n width: 100%;\n height: 100%;\n margin: 0;\n }\n }\n\n &__bar {\n position: relative;\n background: lighten($ui-base-color, 4%);\n padding: 5px;\n border-bottom: 1px solid lighten($ui-base-color, 12%);\n\n .avatar {\n display: block;\n flex: 0 0 auto;\n width: 94px;\n margin-left: -2px;\n\n .account__avatar {\n background: darken($ui-base-color, 8%);\n border: 2px solid lighten($ui-base-color, 4%);\n }\n }\n }\n\n &__tabs {\n display: flex;\n align-items: flex-start;\n padding: 7px 5px;\n margin-top: -55px;\n\n &__buttons {\n display: flex;\n align-items: center;\n padding-top: 55px;\n overflow: hidden;\n\n .icon-button {\n border: 1px solid lighten($ui-base-color, 12%);\n border-radius: 4px;\n box-sizing: content-box;\n padding: 2px;\n }\n\n .button {\n margin: 0 8px;\n }\n }\n\n &__name {\n padding: 5px;\n\n .account-role {\n vertical-align: top;\n }\n\n .emojione {\n width: 22px;\n height: 22px;\n }\n\n h1 {\n font-size: 16px;\n line-height: 24px;\n color: $primary-text-color;\n font-weight: 500;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n\n small {\n display: block;\n font-size: 14px;\n color: $darker-text-color;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n\n .spacer {\n flex: 1 1 auto;\n }\n }\n\n &__bio {\n overflow: hidden;\n margin: 0 -5px;\n\n .account__header__content {\n padding: 20px 15px;\n padding-bottom: 5px;\n color: $primary-text-color;\n }\n\n .account__header__fields {\n margin: 0;\n border-top: 1px solid lighten($ui-base-color, 12%);\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n }\n\n &__extra {\n margin-top: 4px;\n\n &__links {\n font-size: 14px;\n color: $darker-text-color;\n padding: 10px 0;\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n padding: 5px 10px;\n font-weight: 500;\n\n strong {\n font-weight: 700;\n color: $primary-text-color;\n }\n }\n }\n }\n}\n\n.trends {\n &__header {\n color: $dark-text-color;\n background: lighten($ui-base-color, 2%);\n border-bottom: 1px solid darken($ui-base-color, 4%);\n font-weight: 500;\n padding: 15px;\n font-size: 16px;\n cursor: default;\n\n .fa {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n &__item {\n display: flex;\n align-items: center;\n padding: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__name {\n flex: 1 1 auto;\n color: $dark-text-color;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n strong {\n font-weight: 500;\n }\n\n a {\n color: $darker-text-color;\n text-decoration: none;\n font-size: 14px;\n font-weight: 500;\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n &:hover,\n &:focus,\n &:active {\n span {\n text-decoration: underline;\n }\n }\n }\n }\n\n &__current {\n flex: 0 0 auto;\n font-size: 24px;\n line-height: 36px;\n font-weight: 500;\n text-align: right;\n padding-right: 15px;\n margin-left: 5px;\n color: $secondary-text-color;\n }\n\n &__sparkline {\n flex: 0 0 auto;\n width: 50px;\n\n path:first-child {\n fill: rgba($highlight-text-color, 0.25) !important;\n fill-opacity: 1 !important;\n }\n\n path:last-child {\n stroke: lighten($highlight-text-color, 6%) !important;\n }\n }\n }\n}\n\n.conversation {\n display: flex;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n padding: 5px;\n padding-bottom: 0;\n\n &:focus {\n background: lighten($ui-base-color, 2%);\n outline: 0;\n }\n\n &__avatar {\n flex: 0 0 auto;\n padding: 10px;\n padding-top: 12px;\n position: relative;\n cursor: pointer;\n }\n\n &__unread {\n display: inline-block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n margin: -.1ex .15em .1ex;\n }\n\n &__content {\n flex: 1 1 auto;\n padding: 10px 5px;\n padding-right: 15px;\n overflow: hidden;\n\n &__info {\n overflow: hidden;\n display: flex;\n flex-direction: row-reverse;\n justify-content: space-between;\n }\n\n &__relative-time {\n font-size: 15px;\n color: $darker-text-color;\n padding-left: 15px;\n }\n\n &__names {\n color: $darker-text-color;\n font-size: 15px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin-bottom: 4px;\n flex-basis: 90px;\n flex-grow: 1;\n\n a {\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: underline;\n }\n }\n }\n\n a {\n word-break: break-word;\n }\n }\n\n &--unread {\n background: lighten($ui-base-color, 2%);\n\n &:focus {\n background: lighten($ui-base-color, 4%);\n }\n\n .conversation__content__info {\n font-weight: 700;\n }\n\n .conversation__content__relative-time {\n color: $primary-text-color;\n }\n }\n}\n\n.announcements {\n background: lighten($ui-base-color, 8%);\n font-size: 13px;\n display: flex;\n align-items: flex-end;\n\n &__mastodon {\n width: 124px;\n flex: 0 0 auto;\n\n @media screen and (max-width: 124px + 300px) {\n display: none;\n }\n }\n\n &__container {\n width: calc(100% - 124px);\n flex: 0 0 auto;\n position: relative;\n\n @media screen and (max-width: 124px + 300px) {\n width: 100%;\n }\n }\n\n &__item {\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n position: relative;\n font-size: 15px;\n line-height: 20px;\n word-wrap: break-word;\n font-weight: 400;\n max-height: 50vh;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n\n &__range {\n display: block;\n font-weight: 500;\n margin-bottom: 10px;\n padding-right: 18px;\n }\n\n &__unread {\n position: absolute;\n top: 19px;\n right: 19px;\n display: block;\n background: $highlight-text-color;\n border-radius: 50%;\n width: 0.625rem;\n height: 0.625rem;\n }\n }\n\n &__pagination {\n padding: 15px;\n color: $darker-text-color;\n position: absolute;\n bottom: 3px;\n right: 0;\n }\n}\n\n.layout-multiple-columns .announcements__mastodon {\n display: none;\n}\n\n.layout-multiple-columns .announcements__container {\n width: 100%;\n}\n\n.reactions-bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-top: 15px;\n margin-left: -2px;\n width: calc(100% - (90px - 33px));\n\n &__item {\n flex-shrink: 0;\n background: lighten($ui-base-color, 12%);\n border: 0;\n border-radius: 3px;\n margin: 2px;\n cursor: pointer;\n user-select: none;\n padding: 0 6px;\n display: flex;\n align-items: center;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &__emoji {\n display: block;\n margin: 3px 0;\n width: 16px;\n height: 16px;\n\n img {\n display: block;\n margin: 0;\n width: 100%;\n height: 100%;\n min-width: auto;\n min-height: auto;\n vertical-align: bottom;\n object-fit: contain;\n }\n }\n\n &__count {\n display: block;\n min-width: 9px;\n font-size: 13px;\n font-weight: 500;\n text-align: center;\n margin-left: 6px;\n color: $darker-text-color;\n }\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 16%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n\n &__count {\n color: lighten($darker-text-color, 4%);\n }\n }\n\n &.active {\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%);\n\n .reactions-bar__item__count {\n color: lighten($highlight-text-color, 8%);\n }\n }\n }\n\n .emoji-picker-dropdown {\n margin: 2px;\n }\n\n &:hover .emoji-button {\n opacity: 0.85;\n }\n\n .emoji-button {\n color: $darker-text-color;\n margin: 0;\n font-size: 16px;\n width: auto;\n flex-shrink: 0;\n padding: 0 6px;\n height: 22px;\n display: flex;\n align-items: center;\n opacity: 0.5;\n transition: all 100ms ease-in;\n transition-property: background-color, color;\n\n &:hover,\n &:active,\n &:focus {\n opacity: 1;\n color: lighten($darker-text-color, 4%);\n transition: all 200ms ease-out;\n transition-property: background-color, color;\n }\n }\n\n &--empty {\n .emoji-button {\n padding: 0;\n }\n }\n}\n",null,"@mixin avatar-radius {\n border-radius: 4px;\n background: transparent no-repeat;\n background-position: 50%;\n background-clip: padding-box;\n}\n\n@mixin avatar-size($size: 48px) {\n width: $size;\n height: $size;\n background-size: $size $size;\n}\n\n@mixin search-input {\n outline: 0;\n box-sizing: border-box;\n width: 100%;\n border: 0;\n box-shadow: none;\n font-family: inherit;\n background: $ui-base-color;\n color: $darker-text-color;\n font-size: 14px;\n margin: 0;\n}\n\n@mixin search-popout {\n background: $simple-background-color;\n border-radius: 4px;\n padding: 10px 14px;\n padding-bottom: 14px;\n margin-top: 10px;\n color: $light-text-color;\n box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);\n\n h4 {\n text-transform: uppercase;\n color: $light-text-color;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 10px;\n }\n\n li {\n padding: 4px 0;\n }\n\n ul {\n margin-bottom: 10px;\n }\n\n em {\n font-weight: 500;\n color: $inverted-text-color;\n }\n}\n",".poll {\n margin-top: 16px;\n font-size: 14px;\n\n li {\n margin-bottom: 10px;\n position: relative;\n }\n\n &__chart {\n border-radius: 4px;\n display: block;\n background: darken($ui-primary-color, 5%);\n height: 5px;\n min-width: 1%;\n\n &.leading {\n background: $ui-highlight-color;\n }\n }\n\n &__option {\n position: relative;\n display: flex;\n padding: 6px 0;\n line-height: 18px;\n cursor: default;\n overflow: hidden;\n\n &__text {\n display: inline-block;\n word-wrap: break-word;\n overflow-wrap: break-word;\n max-width: calc(100% - 45px - 25px);\n }\n\n input[type=radio],\n input[type=checkbox] {\n display: none;\n }\n\n .autossugest-input {\n flex: 1 1 auto;\n }\n\n input[type=text] {\n display: block;\n box-sizing: border-box;\n width: 100%;\n font-size: 14px;\n color: $inverted-text-color;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n\n &.selectable {\n cursor: pointer;\n }\n\n &.editable {\n display: flex;\n align-items: center;\n overflow: visible;\n }\n }\n\n &__input {\n display: inline-block;\n position: relative;\n border: 1px solid $ui-primary-color;\n box-sizing: border-box;\n width: 18px;\n height: 18px;\n flex: 0 0 auto;\n margin-right: 10px;\n top: -1px;\n border-radius: 50%;\n vertical-align: middle;\n margin-top: auto;\n margin-bottom: auto;\n flex: 0 0 18px;\n\n &.checkbox {\n border-radius: 4px;\n }\n\n &.active {\n border-color: $valid-value-color;\n background: $valid-value-color;\n }\n\n &:active,\n &:focus,\n &:hover {\n border-color: lighten($valid-value-color, 15%);\n border-width: 4px;\n }\n\n &::-moz-focus-inner {\n outline: 0 !important;\n border: 0;\n }\n\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n\n &__number {\n display: inline-block;\n width: 45px;\n font-weight: 700;\n flex: 0 0 45px;\n }\n\n &__voted {\n padding: 0 5px;\n display: inline-block;\n\n &__mark {\n font-size: 18px;\n }\n }\n\n &__footer {\n padding-top: 6px;\n padding-bottom: 5px;\n color: $dark-text-color;\n }\n\n &__link {\n display: inline;\n background: transparent;\n padding: 0;\n margin: 0;\n border: 0;\n color: $dark-text-color;\n text-decoration: underline;\n font-size: inherit;\n\n &:hover {\n text-decoration: none;\n }\n\n &:active,\n &:focus {\n background-color: rgba($dark-text-color, .1);\n }\n }\n\n .button {\n height: 36px;\n padding: 0 16px;\n margin-right: 10px;\n font-size: 14px;\n }\n}\n\n.compose-form__poll-wrapper {\n border-top: 1px solid darken($simple-background-color, 8%);\n\n ul {\n padding: 10px;\n }\n\n .poll__footer {\n border-top: 1px solid darken($simple-background-color, 8%);\n padding: 10px;\n display: flex;\n align-items: center;\n\n button,\n select {\n flex: 1 1 50%;\n\n &:focus {\n border-color: $highlight-text-color;\n }\n }\n }\n\n .button.button-secondary {\n font-size: 14px;\n font-weight: 400;\n padding: 6px 10px;\n height: auto;\n line-height: inherit;\n color: $action-button-color;\n border-color: $action-button-color;\n margin-right: 5px;\n }\n\n li {\n display: flex;\n align-items: center;\n\n .poll__option {\n flex: 0 0 auto;\n width: calc(100% - (23px + 6px));\n margin-right: 6px;\n }\n }\n\n select {\n appearance: none;\n box-sizing: border-box;\n font-size: 14px;\n color: $inverted-text-color;\n display: inline-block;\n width: auto;\n outline: 0;\n font-family: inherit;\n background: $simple-background-color url(\"data:image/svg+xml;utf8,\") no-repeat right 8px center / auto 16px;\n border: 1px solid darken($simple-background-color, 14%);\n border-radius: 4px;\n padding: 6px 10px;\n padding-right: 30px;\n }\n\n .icon-button.disabled {\n color: darken($simple-background-color, 14%);\n }\n}\n\n.muted .poll {\n color: $dark-text-color;\n\n &__chart {\n background: rgba(darken($ui-primary-color, 14%), 0.2);\n\n &.leading {\n background: rgba($ui-highlight-color, 0.2);\n }\n }\n}\n",".modal-layout {\n background: $ui-base-color url('data:image/svg+xml;utf8,') repeat-x bottom fixed;\n display: flex;\n flex-direction: column;\n height: 100vh;\n padding: 0;\n}\n\n.modal-layout__mastodon {\n display: flex;\n flex: 1;\n flex-direction: column;\n justify-content: flex-end;\n\n > * {\n flex: 1;\n max-height: 235px;\n }\n}\n\n@media screen and (max-width: 600px) {\n .account-header {\n margin-top: 0;\n }\n}\n",".emoji-mart {\n font-size: 13px;\n display: inline-block;\n color: $inverted-text-color;\n\n &,\n * {\n box-sizing: border-box;\n line-height: 1.15;\n }\n\n .emoji-mart-emoji {\n padding: 6px;\n }\n}\n\n.emoji-mart-bar {\n border: 0 solid darken($ui-secondary-color, 8%);\n\n &:first-child {\n border-bottom-width: 1px;\n border-top-left-radius: 5px;\n border-top-right-radius: 5px;\n background: $ui-secondary-color;\n }\n\n &:last-child {\n border-top-width: 1px;\n border-bottom-left-radius: 5px;\n border-bottom-right-radius: 5px;\n display: none;\n }\n}\n\n.emoji-mart-anchors {\n display: flex;\n justify-content: space-between;\n padding: 0 6px;\n color: $lighter-text-color;\n line-height: 0;\n}\n\n.emoji-mart-anchor {\n position: relative;\n flex: 1;\n text-align: center;\n padding: 12px 4px;\n overflow: hidden;\n transition: color .1s ease-out;\n cursor: pointer;\n\n &:hover {\n color: darken($lighter-text-color, 4%);\n }\n}\n\n.emoji-mart-anchor-selected {\n color: $highlight-text-color;\n\n &:hover {\n color: darken($highlight-text-color, 4%);\n }\n\n .emoji-mart-anchor-bar {\n bottom: -1px;\n }\n}\n\n.emoji-mart-anchor-bar {\n position: absolute;\n bottom: -5px;\n left: 0;\n width: 100%;\n height: 4px;\n background-color: $highlight-text-color;\n}\n\n.emoji-mart-anchors {\n i {\n display: inline-block;\n width: 100%;\n max-width: 22px;\n }\n\n svg {\n fill: currentColor;\n max-height: 18px;\n }\n}\n\n.emoji-mart-scroll {\n overflow-y: scroll;\n height: 270px;\n max-height: 35vh;\n padding: 0 6px 6px;\n background: $simple-background-color;\n will-change: transform;\n\n &::-webkit-scrollbar-track:hover,\n &::-webkit-scrollbar-track:active {\n background-color: rgba($base-overlay-background, 0.3);\n }\n}\n\n.emoji-mart-search {\n padding: 10px;\n padding-right: 45px;\n background: $simple-background-color;\n\n input {\n font-size: 14px;\n font-weight: 400;\n padding: 7px 9px;\n font-family: inherit;\n display: block;\n width: 100%;\n background: rgba($ui-secondary-color, 0.3);\n color: $inverted-text-color;\n border: 1px solid $ui-secondary-color;\n border-radius: 4px;\n\n &::-moz-focus-inner {\n border: 0;\n }\n\n &::-moz-focus-inner,\n &:focus,\n &:active {\n outline: 0 !important;\n }\n }\n}\n\n.emoji-mart-category .emoji-mart-emoji {\n cursor: pointer;\n\n span {\n z-index: 1;\n position: relative;\n text-align: center;\n }\n\n &:hover::before {\n z-index: 0;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba($ui-secondary-color, 0.7);\n border-radius: 100%;\n }\n}\n\n.emoji-mart-category-label {\n z-index: 2;\n position: relative;\n position: -webkit-sticky;\n position: sticky;\n top: 0;\n\n span {\n display: block;\n width: 100%;\n font-weight: 500;\n padding: 5px 6px;\n background: $simple-background-color;\n }\n}\n\n.emoji-mart-emoji {\n position: relative;\n display: inline-block;\n font-size: 0;\n\n span {\n width: 22px;\n height: 22px;\n }\n}\n\n.emoji-mart-no-results {\n font-size: 14px;\n text-align: center;\n padding-top: 70px;\n color: $light-text-color;\n\n .emoji-mart-category-label {\n display: none;\n }\n\n .emoji-mart-no-results-label {\n margin-top: .2em;\n }\n\n .emoji-mart-emoji:hover::before {\n content: none;\n }\n}\n\n.emoji-mart-preview {\n display: none;\n}\n","$maximum-width: 1235px;\n$fluid-breakpoint: $maximum-width + 20px;\n$column-breakpoint: 700px;\n$small-breakpoint: 960px;\n\n.container {\n box-sizing: border-box;\n max-width: $maximum-width;\n margin: 0 auto;\n position: relative;\n\n @media screen and (max-width: $fluid-breakpoint) {\n width: 100%;\n padding: 0 10px;\n }\n}\n\n.rich-formatting {\n font-family: $font-sans-serif, sans-serif;\n font-size: 14px;\n font-weight: 400;\n line-height: 1.7;\n word-wrap: break-word;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover,\n &:focus,\n &:active {\n text-decoration: none;\n }\n }\n\n p,\n li {\n color: $darker-text-color;\n }\n\n p {\n margin-top: 0;\n margin-bottom: .85em;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n strong {\n font-weight: 700;\n color: $secondary-text-color;\n }\n\n em {\n font-style: italic;\n color: $secondary-text-color;\n }\n\n code {\n font-size: 0.85em;\n background: darken($ui-base-color, 8%);\n border-radius: 4px;\n padding: 0.2em 0.3em;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: $font-display, sans-serif;\n margin-top: 1.275em;\n margin-bottom: .85em;\n font-weight: 500;\n color: $secondary-text-color;\n }\n\n h1 {\n font-size: 2em;\n }\n\n h2 {\n font-size: 1.75em;\n }\n\n h3 {\n font-size: 1.5em;\n }\n\n h4 {\n font-size: 1.25em;\n }\n\n h5,\n h6 {\n font-size: 1em;\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n ul,\n ol {\n margin: 0;\n padding: 0;\n padding-left: 2em;\n margin-bottom: 0.85em;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n margin: 1.7em 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n table {\n width: 100%;\n border-collapse: collapse;\n break-inside: auto;\n margin-top: 24px;\n margin-bottom: 32px;\n\n thead tr,\n tbody tr {\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n font-size: 1em;\n line-height: 1.625;\n font-weight: 400;\n text-align: left;\n color: $darker-text-color;\n }\n\n thead tr {\n border-bottom-width: 2px;\n line-height: 1.5;\n font-weight: 500;\n color: $dark-text-color;\n }\n\n th,\n td {\n padding: 8px;\n align-self: start;\n align-items: start;\n word-break: break-all;\n\n &.nowrap {\n width: 25%;\n position: relative;\n\n &::before {\n content: ' ';\n visibility: hidden;\n }\n\n span {\n position: absolute;\n left: 8px;\n right: 8px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n }\n }\n\n & > :first-child {\n margin-top: 0;\n }\n}\n\n.information-board {\n background: darken($ui-base-color, 4%);\n padding: 20px 0;\n\n .container-alt {\n position: relative;\n padding-right: 280px + 15px;\n }\n\n &__sections {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n }\n\n &__section {\n flex: 1 0 0;\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n line-height: 28px;\n color: $primary-text-color;\n text-align: right;\n padding: 10px 15px;\n\n span,\n strong {\n display: block;\n }\n\n span {\n &:last-child {\n color: $secondary-text-color;\n }\n }\n\n strong {\n font-family: $font-display, sans-serif;\n font-weight: 500;\n font-size: 32px;\n line-height: 48px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n text-align: center;\n }\n }\n\n .panel {\n position: absolute;\n width: 280px;\n box-sizing: border-box;\n background: darken($ui-base-color, 8%);\n padding: 20px;\n padding-top: 10px;\n border-radius: 4px 4px 0 0;\n right: 0;\n bottom: -40px;\n\n .panel-header {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n color: $darker-text-color;\n padding-bottom: 5px;\n margin-bottom: 15px;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n\n a,\n span {\n font-weight: 400;\n color: darken($darker-text-color, 10%);\n }\n\n a {\n text-decoration: none;\n }\n }\n }\n\n .owner {\n text-align: center;\n\n .avatar {\n width: 80px;\n height: 80px;\n margin: 0 auto;\n margin-bottom: 15px;\n\n img {\n display: block;\n width: 80px;\n height: 80px;\n border-radius: 48px;\n }\n }\n\n .name {\n font-size: 14px;\n\n a {\n display: block;\n color: $primary-text-color;\n text-decoration: none;\n\n &:hover {\n .display_name {\n text-decoration: underline;\n }\n }\n }\n\n .username {\n display: block;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.landing-page {\n p,\n li {\n font-family: $font-sans-serif, sans-serif;\n font-size: 16px;\n font-weight: 400;\n font-size: 16px;\n line-height: 30px;\n margin-bottom: 12px;\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n }\n }\n\n em {\n display: inline;\n margin: 0;\n padding: 0;\n font-weight: 700;\n background: transparent;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: lighten($darker-text-color, 10%);\n }\n\n h1 {\n font-family: $font-display, sans-serif;\n font-size: 26px;\n line-height: 30px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n\n small {\n font-family: $font-sans-serif, sans-serif;\n display: block;\n font-size: 18px;\n font-weight: 400;\n color: lighten($darker-text-color, 10%);\n }\n }\n\n h2 {\n font-family: $font-display, sans-serif;\n font-size: 22px;\n line-height: 26px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h3 {\n font-family: $font-display, sans-serif;\n font-size: 18px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h4 {\n font-family: $font-display, sans-serif;\n font-size: 16px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h5 {\n font-family: $font-display, sans-serif;\n font-size: 14px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n h6 {\n font-family: $font-display, sans-serif;\n font-size: 12px;\n line-height: 24px;\n font-weight: 500;\n margin-bottom: 20px;\n color: $secondary-text-color;\n }\n\n ul,\n ol {\n margin-left: 20px;\n\n &[type='a'] {\n list-style-type: lower-alpha;\n }\n\n &[type='i'] {\n list-style-type: lower-roman;\n }\n }\n\n ul {\n list-style: disc;\n }\n\n ol {\n list-style: decimal;\n }\n\n li > ol,\n li > ul {\n margin-top: 6px;\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n\n &__information,\n &__forms {\n padding: 20px;\n }\n\n &__call-to-action {\n background: $ui-base-color;\n border-radius: 4px;\n padding: 25px 40px;\n overflow: hidden;\n box-sizing: border-box;\n\n .row {\n width: 100%;\n display: flex;\n flex-direction: row-reverse;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: center;\n }\n\n .row__information-board {\n display: flex;\n justify-content: flex-end;\n align-items: flex-end;\n\n .information-board__section {\n flex: 1 0 auto;\n padding: 0 10px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n width: 100%;\n justify-content: space-between;\n }\n }\n\n .row__mascot {\n flex: 1;\n margin: 10px -50px 0 0;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n }\n\n &__logo {\n margin-right: 20px;\n\n img {\n height: 50px;\n width: auto;\n mix-blend-mode: lighten;\n }\n }\n\n &__information {\n padding: 45px 40px;\n margin-bottom: 10px;\n\n &:last-child {\n margin-bottom: 0;\n }\n\n strong {\n font-weight: 500;\n color: lighten($darker-text-color, 10%);\n }\n\n .account {\n border-bottom: 0;\n padding: 0;\n\n &__display-name {\n align-items: center;\n display: flex;\n margin-right: 5px;\n }\n\n div.account__display-name {\n &:hover {\n .display-name strong {\n text-decoration: none;\n }\n }\n\n .account__avatar {\n cursor: default;\n }\n }\n\n &__avatar-wrapper {\n margin-left: 0;\n flex: 0 0 auto;\n }\n\n &__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n\n .display-name {\n font-size: 15px;\n\n &__account {\n font-size: 14px;\n }\n }\n }\n\n @media screen and (max-width: $small-breakpoint) {\n .contact {\n margin-top: 30px;\n }\n }\n\n @media screen and (max-width: $column-breakpoint) {\n padding: 25px 20px;\n }\n }\n\n &__information,\n &__forms,\n #mastodon-timeline {\n box-sizing: border-box;\n background: $ui-base-color;\n border-radius: 4px;\n box-shadow: 0 0 6px rgba($black, 0.1);\n }\n\n &__mascot {\n height: 104px;\n position: relative;\n left: -40px;\n bottom: 25px;\n\n img {\n height: 190px;\n width: auto;\n }\n }\n\n &__short-description {\n .row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n margin-bottom: 40px;\n }\n\n @media screen and (max-width: $column-breakpoint) {\n .row {\n margin-bottom: 20px;\n }\n }\n\n p a {\n color: $secondary-text-color;\n }\n\n h1 {\n font-weight: 500;\n color: $primary-text-color;\n margin-bottom: 0;\n\n small {\n color: $darker-text-color;\n\n span {\n color: $secondary-text-color;\n }\n }\n }\n\n p:last-child {\n margin-bottom: 0;\n }\n }\n\n &__hero {\n margin-bottom: 10px;\n\n img {\n display: block;\n margin: 0;\n max-width: 100%;\n height: auto;\n border-radius: 4px;\n }\n }\n\n @media screen and (max-width: 840px) {\n .information-board {\n .container-alt {\n padding-right: 20px;\n }\n\n .panel {\n position: static;\n margin-top: 20px;\n width: 100%;\n border-radius: 4px;\n\n .panel-header {\n text-align: center;\n }\n }\n }\n }\n\n @media screen and (max-width: 675px) {\n .header-wrapper {\n padding-top: 0;\n\n &.compact {\n padding-bottom: 0;\n }\n\n &.compact .hero .heading {\n text-align: initial;\n }\n }\n\n .header .container-alt,\n .features .container-alt {\n display: block;\n }\n }\n\n .cta {\n margin: 20px;\n }\n}\n\n.landing {\n margin-bottom: 100px;\n\n @media screen and (max-width: 738px) {\n margin-bottom: 0;\n }\n\n &__brand {\n display: flex;\n justify-content: center;\n align-items: center;\n padding: 50px;\n\n svg {\n fill: $primary-text-color;\n height: 52px;\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n padding: 0;\n margin-bottom: 30px;\n }\n }\n\n .directory {\n margin-top: 30px;\n background: transparent;\n box-shadow: none;\n border-radius: 0;\n }\n\n .hero-widget {\n margin-top: 30px;\n margin-bottom: 0;\n\n h4 {\n padding: 10px;\n text-transform: uppercase;\n font-weight: 700;\n font-size: 13px;\n color: $darker-text-color;\n }\n\n &__text {\n border-radius: 0;\n padding-bottom: 0;\n }\n\n &__footer {\n background: $ui-base-color;\n padding: 10px;\n border-radius: 0 0 4px 4px;\n display: flex;\n\n &__column {\n flex: 1 1 50%;\n }\n }\n\n .account {\n padding: 10px 0;\n border-bottom: 0;\n\n .account__display-name {\n display: flex;\n align-items: center;\n }\n\n .account__avatar {\n width: 44px;\n height: 44px;\n background-size: 44px 44px;\n }\n }\n\n &__counter {\n padding: 10px;\n\n strong {\n font-family: $font-display, sans-serif;\n font-size: 15px;\n font-weight: 700;\n display: block;\n }\n\n span {\n font-size: 14px;\n color: $darker-text-color;\n }\n }\n }\n\n .simple_form .user_agreement .label_input > label {\n font-weight: 400;\n color: $darker-text-color;\n }\n\n .simple_form p.lead {\n color: $darker-text-color;\n font-size: 15px;\n line-height: 20px;\n font-weight: 400;\n margin-bottom: 25px;\n }\n\n &__grid {\n max-width: 960px;\n margin: 0 auto;\n display: grid;\n grid-template-columns: minmax(0, 50%) minmax(0, 50%);\n grid-gap: 30px;\n\n @media screen and (max-width: 738px) {\n grid-template-columns: minmax(0, 100%);\n grid-gap: 10px;\n\n &__column-login {\n grid-row: 1;\n display: flex;\n flex-direction: column;\n\n .box-widget {\n order: 2;\n flex: 0 0 auto;\n }\n\n .hero-widget {\n margin-top: 0;\n margin-bottom: 10px;\n order: 1;\n flex: 0 0 auto;\n }\n }\n\n &__column-registration {\n grid-row: 2;\n }\n\n .directory {\n margin-top: 10px;\n }\n }\n\n @media screen and (max-width: $no-gap-breakpoint) {\n grid-gap: 0;\n\n .hero-widget {\n display: block;\n margin-bottom: 0;\n box-shadow: none;\n\n &__img,\n &__img img,\n &__footer {\n border-radius: 0;\n }\n }\n\n .hero-widget,\n .box-widget,\n .directory__tag {\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n .directory {\n margin-top: 0;\n\n &__tag {\n margin-bottom: 0;\n\n & > a,\n & > div {\n border-radius: 0;\n box-shadow: none;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n }\n }\n }\n}\n\n.brand {\n position: relative;\n text-decoration: none;\n}\n\n.brand__tagline {\n display: block;\n position: absolute;\n bottom: -10px;\n left: 50px;\n width: 300px;\n color: $ui-primary-color;\n text-decoration: none;\n font-size: 14px;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n position: static;\n width: auto;\n margin-top: 20px;\n color: $dark-text-color;\n }\n}\n\n",".table {\n width: 100%;\n max-width: 100%;\n border-spacing: 0;\n border-collapse: collapse;\n\n th,\n td {\n padding: 8px;\n line-height: 18px;\n vertical-align: top;\n border-top: 1px solid $ui-base-color;\n text-align: left;\n background: darken($ui-base-color, 4%);\n }\n\n & > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid $ui-base-color;\n border-top: 0;\n font-weight: 500;\n }\n\n & > tbody > tr > th {\n font-weight: 500;\n }\n\n & > tbody > tr:nth-child(odd) > td,\n & > tbody > tr:nth-child(odd) > th {\n background: $ui-base-color;\n }\n\n a {\n color: $highlight-text-color;\n text-decoration: underline;\n\n &:hover {\n text-decoration: none;\n }\n }\n\n strong {\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &.inline-table {\n & > tbody > tr:nth-child(odd) {\n & > td,\n & > th {\n background: transparent;\n }\n }\n\n & > tbody > tr:first-child {\n & > td,\n & > th {\n border-top: 0;\n }\n }\n }\n\n &.batch-table {\n & > thead > tr > th {\n background: $ui-base-color;\n border-top: 1px solid darken($ui-base-color, 8%);\n border-bottom: 1px solid darken($ui-base-color, 8%);\n\n &:first-child {\n border-radius: 4px 0 0;\n border-left: 1px solid darken($ui-base-color, 8%);\n }\n\n &:last-child {\n border-radius: 0 4px 0 0;\n border-right: 1px solid darken($ui-base-color, 8%);\n }\n }\n }\n\n &--invites tbody td {\n vertical-align: middle;\n }\n}\n\n.table-wrapper {\n overflow: auto;\n margin-bottom: 20px;\n}\n\nsamp {\n font-family: $font-monospace, monospace;\n}\n\nbutton.table-action-link {\n background: transparent;\n border: 0;\n font: inherit;\n}\n\nbutton.table-action-link,\na.table-action-link {\n text-decoration: none;\n display: inline-block;\n margin-right: 5px;\n padding: 0 10px;\n color: $darker-text-color;\n font-weight: 500;\n\n &:hover {\n color: $primary-text-color;\n }\n\n i.fa {\n font-weight: 400;\n margin-right: 5px;\n }\n\n &:first-child {\n padding-left: 0;\n }\n}\n\n.batch-table {\n &__toolbar,\n &__row {\n display: flex;\n\n &__select {\n box-sizing: border-box;\n padding: 8px 16px;\n cursor: pointer;\n min-height: 100%;\n\n input {\n margin-top: 8px;\n }\n\n &--aligned {\n display: flex;\n align-items: center;\n\n input {\n margin-top: 0;\n }\n }\n }\n\n &__actions,\n &__content {\n padding: 8px 0;\n padding-right: 16px;\n flex: 1 1 auto;\n }\n }\n\n &__toolbar {\n border: 1px solid darken($ui-base-color, 8%);\n background: $ui-base-color;\n border-radius: 4px 0 0;\n height: 47px;\n align-items: center;\n\n &__actions {\n text-align: right;\n padding-right: 16px - 5px;\n }\n }\n\n &__form {\n padding: 16px;\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: $ui-base-color;\n\n .fields-row {\n padding-top: 0;\n margin-bottom: 0;\n }\n }\n\n &__row {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n background: darken($ui-base-color, 4%);\n\n @media screen and (max-width: $no-gap-breakpoint) {\n .optional &:first-child {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n &:hover {\n background: darken($ui-base-color, 2%);\n }\n\n &:nth-child(even) {\n background: $ui-base-color;\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n }\n\n &__content {\n padding-top: 12px;\n padding-bottom: 16px;\n\n &--unpadded {\n padding: 0;\n }\n\n &--with-image {\n display: flex;\n align-items: center;\n }\n\n &__image {\n flex: 0 0 auto;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-right: 10px;\n\n .emojione {\n width: 32px;\n height: 32px;\n }\n }\n\n &__text {\n flex: 1 1 auto;\n }\n\n &__extra {\n flex: 0 0 auto;\n text-align: right;\n color: $darker-text-color;\n font-weight: 500;\n }\n }\n\n .directory__tag {\n margin: 0;\n width: 100%;\n\n a {\n background: transparent;\n border-radius: 0;\n }\n }\n }\n\n &.optional .batch-table__toolbar,\n &.optional .batch-table__row__select {\n @media screen and (max-width: $no-gap-breakpoint) {\n display: none;\n }\n }\n\n .status__content {\n padding-top: 0;\n\n summary {\n display: list-item;\n }\n\n strong {\n font-weight: 700;\n }\n }\n\n .nothing-here {\n border: 1px solid darken($ui-base-color, 8%);\n border-top: 0;\n box-shadow: none;\n\n @media screen and (max-width: $no-gap-breakpoint) {\n border-top: 1px solid darken($ui-base-color, 8%);\n }\n }\n\n @media screen and (max-width: 870px) {\n .accounts-table tbody td.optional {\n display: none;\n }\n }\n}\n","$no-columns-breakpoint: 600px;\n$sidebar-width: 240px;\n$content-width: 840px;\n\n.admin-wrapper {\n display: flex;\n justify-content: center;\n width: 100%;\n min-height: 100vh;\n\n .sidebar-wrapper {\n min-height: 100vh;\n overflow: hidden;\n pointer-events: none;\n flex: 1 1 auto;\n\n &__inner {\n display: flex;\n justify-content: flex-end;\n background: $ui-base-color;\n height: 100%;\n }\n }\n\n .sidebar {\n width: $sidebar-width;\n padding: 0;\n pointer-events: auto;\n\n &__toggle {\n display: none;\n background: lighten($ui-base-color, 8%);\n height: 48px;\n\n &__logo {\n flex: 1 1 auto;\n\n a {\n display: inline-block;\n padding: 15px;\n }\n\n svg {\n fill: $primary-text-color;\n height: 20px;\n position: relative;\n bottom: -2px;\n }\n }\n\n &__icon {\n display: block;\n color: $darker-text-color;\n text-decoration: none;\n flex: 0 0 auto;\n font-size: 20px;\n padding: 15px;\n }\n\n a {\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 12%);\n }\n }\n }\n\n .logo {\n display: block;\n margin: 40px auto;\n width: 100px;\n height: 100px;\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n & > a:first-child {\n display: none;\n }\n }\n\n ul {\n list-style: none;\n border-radius: 4px 0 0 4px;\n overflow: hidden;\n margin-bottom: 20px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n margin-bottom: 0;\n }\n\n a {\n display: block;\n padding: 15px;\n color: $darker-text-color;\n text-decoration: none;\n transition: all 200ms linear;\n transition-property: color, background-color;\n border-radius: 4px 0 0 4px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n\n i.fa {\n margin-right: 5px;\n }\n\n &:hover {\n color: $primary-text-color;\n background-color: darken($ui-base-color, 5%);\n transition: all 100ms linear;\n transition-property: color, background-color;\n }\n\n &.selected {\n background: darken($ui-base-color, 2%);\n border-radius: 4px 0 0;\n }\n }\n\n ul {\n background: darken($ui-base-color, 4%);\n border-radius: 0 0 0 4px;\n margin: 0;\n\n a {\n border: 0;\n padding: 15px 35px;\n }\n }\n\n .simple-navigation-active-leaf a {\n color: $primary-text-color;\n background-color: $ui-highlight-color;\n border-bottom: 0;\n border-radius: 0;\n\n &:hover {\n background-color: lighten($ui-highlight-color, 5%);\n }\n }\n }\n\n & > ul > .simple-navigation-active-leaf a {\n border-radius: 4px 0 0 4px;\n }\n }\n\n .content-wrapper {\n box-sizing: border-box;\n width: 100%;\n max-width: $content-width;\n flex: 1 1 auto;\n }\n\n @media screen and (max-width: $content-width + $sidebar-width) {\n .sidebar-wrapper--empty {\n display: none;\n }\n\n .sidebar-wrapper {\n width: $sidebar-width;\n flex: 0 0 auto;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n .sidebar-wrapper {\n width: 100%;\n }\n }\n\n .content {\n padding: 20px 15px;\n padding-top: 60px;\n padding-left: 25px;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n max-width: none;\n padding: 15px;\n padding-top: 30px;\n }\n\n &-heading {\n display: flex;\n\n padding-bottom: 40px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n\n margin: -15px -15px 40px 0;\n\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n\n & > * {\n margin-top: 15px;\n margin-right: 15px;\n }\n\n &-actions {\n display: inline-flex;\n\n & > :not(:first-child) {\n margin-left: 5px;\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n border-bottom: 0;\n padding-bottom: 0;\n }\n }\n\n h2 {\n color: $secondary-text-color;\n font-size: 24px;\n line-height: 28px;\n font-weight: 400;\n\n @media screen and (max-width: $no-columns-breakpoint) {\n font-weight: 700;\n }\n }\n\n h3 {\n color: $secondary-text-color;\n font-size: 20px;\n line-height: 28px;\n font-weight: 400;\n margin-bottom: 30px;\n }\n\n h4 {\n text-transform: uppercase;\n font-size: 13px;\n font-weight: 700;\n color: $darker-text-color;\n padding-bottom: 8px;\n margin-bottom: 8px;\n border-bottom: 1px solid lighten($ui-base-color, 8%);\n }\n\n h6 {\n font-size: 16px;\n color: $secondary-text-color;\n line-height: 28px;\n font-weight: 500;\n }\n\n .fields-group h6 {\n color: $primary-text-color;\n font-weight: 500;\n }\n\n .directory__tag > a,\n .directory__tag > div {\n box-shadow: none;\n }\n\n .directory__tag .table-action-link .fa {\n color: inherit;\n }\n\n .directory__tag h4 {\n font-size: 18px;\n font-weight: 700;\n color: $primary-text-color;\n text-transform: none;\n padding-bottom: 0;\n margin-bottom: 0;\n border-bottom: 0;\n }\n\n & > p {\n font-size: 14px;\n line-height: 21px;\n color: $secondary-text-color;\n margin-bottom: 20px;\n\n strong {\n color: $primary-text-color;\n font-weight: 500;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n }\n\n hr {\n width: 100%;\n height: 0;\n border: 0;\n border-bottom: 1px solid rgba($ui-base-lighter-color, .6);\n margin: 20px 0;\n\n &.spacer {\n height: 1px;\n border: 0;\n }\n }\n }\n\n @media screen and (max-width: $no-columns-breakpoint) {\n display: block;\n\n .sidebar-wrapper {\n min-height: 0;\n }\n\n .sidebar {\n width: 100%;\n padding: 0;\n height: auto;\n\n &__toggle {\n display: flex;\n }\n\n & > ul {\n display: none;\n }\n\n ul a,\n ul ul a {\n border-radius: 0;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n transition: none;\n\n &:hover {\n transition: none;\n }\n }\n\n ul ul {\n border-radius: 0;\n }\n\n ul .simple-navigation-active-leaf a {\n border-bottom-color: $ui-highlight-color;\n }\n }\n }\n}\n\nhr.spacer {\n width: 100%;\n border: 0;\n margin: 20px 0;\n height: 1px;\n}\n\nbody,\n.admin-wrapper .content {\n .muted-hint {\n color: $darker-text-color;\n\n a {\n color: $highlight-text-color;\n }\n }\n\n .positive-hint {\n color: $valid-value-color;\n font-weight: 500;\n }\n\n .negative-hint {\n color: $error-value-color;\n font-weight: 500;\n }\n\n .neutral-hint {\n color: $dark-text-color;\n font-weight: 500;\n }\n\n .warning-hint {\n color: $gold-star;\n font-weight: 500;\n }\n}\n\n.filters {\n display: flex;\n flex-wrap: wrap;\n\n .filter-subset {\n flex: 0 0 auto;\n margin: 0 40px 20px 0;\n\n &:last-child {\n margin-bottom: 30px;\n }\n\n ul {\n margin-top: 5px;\n list-style: none;\n\n li {\n display: inline-block;\n margin-right: 5px;\n }\n }\n\n strong {\n font-weight: 500;\n text-transform: uppercase;\n font-size: 12px;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n &--with-select strong {\n display: block;\n margin-bottom: 10px;\n }\n\n a {\n display: inline-block;\n color: $darker-text-color;\n text-decoration: none;\n text-transform: uppercase;\n font-size: 12px;\n font-weight: 500;\n border-bottom: 2px solid $ui-base-color;\n\n &:hover {\n color: $primary-text-color;\n border-bottom: 2px solid lighten($ui-base-color, 5%);\n }\n\n &.selected {\n color: $highlight-text-color;\n border-bottom: 2px solid $ui-highlight-color;\n }\n }\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.flavour-screen {\n display: block;\n margin: 10px auto;\n max-width: 100%;\n}\n\n.flavour-description {\n display: block;\n font-size: 16px;\n margin: 10px 0;\n\n & > p {\n margin: 10px 0;\n }\n}\n\n.report-accounts {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 20px;\n}\n\n.report-accounts__item {\n display: flex;\n flex: 250px;\n flex-direction: column;\n margin: 0 5px;\n\n & > strong {\n display: block;\n margin: 0 0 10px -5px;\n font-weight: 500;\n font-size: 14px;\n line-height: 18px;\n color: $secondary-text-color;\n\n @each $lang in $cjk-langs {\n &:lang(#{$lang}) {\n font-weight: 700;\n }\n }\n }\n\n .account-card {\n flex: 1 1 auto;\n }\n}\n\n.report-status,\n.account-status {\n display: flex;\n margin-bottom: 10px;\n\n .activity-stream {\n flex: 2 0 0;\n margin-right: 20px;\n max-width: calc(100% - 60px);\n\n .entry {\n border-radius: 4px;\n }\n }\n}\n\n.report-status__actions,\n.account-status__actions {\n flex: 0 0 auto;\n display: flex;\n flex-direction: column;\n\n .icon-button {\n font-size: 24px;\n width: 24px;\n text-align: center;\n margin-bottom: 10px;\n }\n}\n\n.simple_form.new_report_note,\n.simple_form.new_account_moderation_note {\n max-width: 100%;\n}\n\n.batch-form-box {\n display: flex;\n flex-wrap: wrap;\n margin-bottom: 5px;\n\n #form_status_batch_action {\n margin: 0 5px 5px 0;\n font-size: 14px;\n }\n\n input.button {\n margin: 0 5px 5px 0;\n }\n\n .media-spoiler-toggle-buttons {\n margin-left: auto;\n\n .button {\n overflow: visible;\n margin: 0 0 5px 5px;\n float: right;\n }\n }\n}\n\n.back-link {\n margin-bottom: 10px;\n font-size: 14px;\n\n a {\n color: $highlight-text-color;\n text-decoration: none;\n\n &:hover {\n text-decoration: underline;\n }\n }\n}\n\n.spacer {\n flex: 1 1 auto;\n}\n\n.log-entry {\n line-height: 20px;\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &:last-child {\n border-bottom: 0;\n }\n\n &__header {\n display: flex;\n justify-content: flex-start;\n align-items: center;\n color: $darker-text-color;\n font-size: 14px;\n padding: 0 10px;\n }\n\n &__avatar {\n margin-right: 10px;\n\n .avatar {\n display: block;\n margin: 0;\n border-radius: 50%;\n width: 40px;\n height: 40px;\n }\n }\n\n &__content {\n max-width: calc(100% - 90px);\n }\n\n &__title {\n word-wrap: break-word;\n }\n\n &__timestamp {\n color: $dark-text-color;\n }\n\n a,\n .username,\n .target {\n color: $secondary-text-color;\n text-decoration: none;\n font-weight: 500;\n }\n}\n\na.name-tag,\n.name-tag,\na.inline-name-tag,\n.inline-name-tag {\n text-decoration: none;\n color: $secondary-text-color;\n\n .username {\n font-weight: 500;\n }\n\n &.suspended {\n .username {\n text-decoration: line-through;\n color: lighten($error-red, 12%);\n }\n\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\na.name-tag,\n.name-tag {\n display: flex;\n align-items: center;\n\n .avatar {\n display: block;\n margin: 0;\n margin-right: 5px;\n border-radius: 50%;\n }\n\n &.suspended {\n .avatar {\n filter: grayscale(100%);\n opacity: 0.8;\n }\n }\n}\n\n.speech-bubble {\n margin-bottom: 20px;\n border-left: 4px solid $ui-highlight-color;\n\n &.positive {\n border-left-color: $success-green;\n }\n\n &.negative {\n border-left-color: lighten($error-red, 12%);\n }\n\n &.warning {\n border-left-color: $gold-star;\n }\n\n &__bubble {\n padding: 16px;\n padding-left: 14px;\n font-size: 15px;\n line-height: 20px;\n border-radius: 4px 4px 4px 0;\n position: relative;\n font-weight: 500;\n\n a {\n color: $darker-text-color;\n }\n }\n\n &__owner {\n padding: 8px;\n padding-left: 12px;\n }\n\n time {\n color: $dark-text-color;\n }\n}\n\n.report-card {\n background: $ui-base-color;\n border-radius: 4px;\n margin-bottom: 20px;\n\n &__profile {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 15px;\n\n .account {\n padding: 0;\n border: 0;\n\n &__avatar-wrapper {\n margin-left: 0;\n }\n }\n\n &__stats {\n flex: 0 0 auto;\n font-weight: 500;\n color: $darker-text-color;\n text-transform: uppercase;\n text-align: right;\n\n a {\n color: inherit;\n text-decoration: none;\n\n &:focus,\n &:hover,\n &:active {\n color: lighten($darker-text-color, 8%);\n }\n }\n\n .red {\n color: $error-value-color;\n }\n }\n }\n\n &__summary {\n &__item {\n display: flex;\n justify-content: flex-start;\n border-top: 1px solid darken($ui-base-color, 4%);\n\n &:hover {\n background: lighten($ui-base-color, 2%);\n }\n\n &__reported-by,\n &__assigned {\n padding: 15px;\n flex: 0 0 auto;\n box-sizing: border-box;\n width: 150px;\n color: $darker-text-color;\n\n &,\n .username {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n }\n\n &__content {\n flex: 1 1 auto;\n max-width: calc(100% - 300px);\n\n &__icon {\n color: $dark-text-color;\n margin-right: 4px;\n font-weight: 500;\n }\n }\n\n &__content a {\n display: block;\n box-sizing: border-box;\n width: 100%;\n padding: 15px;\n text-decoration: none;\n color: $darker-text-color;\n }\n }\n }\n}\n\n.one-line {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.ellipsized-ip {\n display: inline-block;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n vertical-align: middle;\n}\n\n.admin-account-bio {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-top: 20px;\n\n > div {\n box-sizing: border-box;\n padding: 0 5px;\n margin-bottom: 10px;\n flex: 1 0 50%;\n }\n\n .account__header__fields,\n .account__header__content {\n background: lighten($ui-base-color, 8%);\n border-radius: 4px;\n height: 100%;\n }\n\n .account__header__fields {\n margin: 0;\n border: 0;\n\n a {\n color: lighten($ui-highlight-color, 8%);\n }\n\n dl:first-child .verified {\n border-radius: 0 4px 0 0;\n }\n\n .verified a {\n color: $valid-value-color;\n }\n }\n\n .account__header__content {\n box-sizing: border-box;\n padding: 20px;\n color: $primary-text-color;\n }\n}\n\n.center-text {\n text-align: center;\n}\n\n.announcements-list {\n border: 1px solid lighten($ui-base-color, 4%);\n border-radius: 4px;\n\n &__item {\n padding: 15px 0;\n background: $ui-base-color;\n border-bottom: 1px solid lighten($ui-base-color, 4%);\n\n &__title {\n padding: 0 15px;\n display: block;\n font-weight: 500;\n font-size: 18px;\n line-height: 1.5;\n color: $secondary-text-color;\n text-decoration: none;\n margin-bottom: 10px;\n\n &:hover,\n &:focus,\n &:active {\n color: $primary-text-color;\n }\n }\n\n &__meta {\n padding: 0 15px;\n color: $dark-text-color;\n }\n\n &__action-bar {\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n &:last-child {\n border-bottom: 0;\n }\n }\n}\n",".dashboard__counters {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n margin-bottom: 20px;\n\n & > div {\n box-sizing: border-box;\n flex: 0 0 33.333%;\n padding: 0 5px;\n margin-bottom: 10px;\n\n & > div,\n & > a {\n padding: 20px;\n background: lighten($ui-base-color, 4%);\n border-radius: 4px;\n box-sizing: border-box;\n height: 100%;\n }\n\n & > a {\n text-decoration: none;\n color: inherit;\n display: block;\n\n &:hover,\n &:focus,\n &:active {\n background: lighten($ui-base-color, 8%);\n }\n }\n }\n\n &__num,\n &__text {\n text-align: center;\n font-weight: 500;\n font-size: 24px;\n line-height: 21px;\n color: $primary-text-color;\n font-family: $font-display, sans-serif;\n margin-bottom: 20px;\n line-height: 30px;\n }\n\n &__text {\n font-size: 18px;\n }\n\n &__label {\n font-size: 14px;\n color: $darker-text-color;\n text-align: center;\n font-weight: 500;\n }\n}\n\n.dashboard__widgets {\n display: flex;\n flex-wrap: wrap;\n margin: 0 -5px;\n\n & > div {\n flex: 0 0 33.333%;\n margin-bottom: 20px;\n\n & > div {\n padding: 0 5px;\n }\n }\n\n a:not(.name-tag) {\n color: $ui-secondary-color;\n font-weight: 500;\n text-decoration: none;\n }\n}\n","body.rtl {\n direction: rtl;\n\n .column-header > button {\n text-align: right;\n padding-left: 0;\n padding-right: 15px;\n }\n\n .radio-button__input {\n margin-right: 0;\n margin-left: 10px;\n }\n\n .directory__card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .display-name {\n text-align: right;\n }\n\n .notification__message {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .drawer__inner__mastodon > img {\n transform: scaleX(-1);\n }\n\n .notification__favourite-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .landing-page__logo {\n margin-right: 0;\n margin-left: 20px;\n }\n\n .landing-page .features-list .features-list__row .visual {\n margin-left: 0;\n margin-right: 15px;\n }\n\n .column-link__icon,\n .column-header__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {\n margin-right: 0;\n margin-left: 4px;\n }\n\n .navigation-bar__profile {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .search__input {\n padding-right: 10px;\n padding-left: 30px;\n }\n\n .search__icon .fa {\n right: auto;\n left: 10px;\n }\n\n .columns-area {\n direction: rtl;\n }\n\n .column-header__buttons {\n left: 0;\n right: auto;\n margin-left: 0;\n margin-right: -15px;\n }\n\n .column-inline-form .icon-button {\n margin-left: 0;\n margin-right: 5px;\n }\n\n .column-header__links .text-btn {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .account__avatar-wrapper {\n float: right;\n }\n\n .column-header__back-button {\n padding-left: 5px;\n padding-right: 0;\n }\n\n .column-header__setting-arrows {\n float: left;\n }\n\n .setting-toggle__label {\n margin-left: 0;\n margin-right: 8px;\n }\n\n .status__avatar {\n left: auto;\n right: 10px;\n }\n\n .status,\n .activity-stream .status.light {\n padding-left: 10px;\n padding-right: 68px;\n }\n\n .status__info .status__display-name,\n .activity-stream .status.light .status__display-name {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .activity-stream .pre-header {\n padding-right: 68px;\n padding-left: 0;\n }\n\n .status__prepend {\n margin-left: 0;\n margin-right: 68px;\n }\n\n .status__prepend-icon-wrapper {\n left: auto;\n right: -26px;\n }\n\n .activity-stream .pre-header .pre-header__icon {\n left: auto;\n right: 42px;\n }\n\n .account__avatar-overlay-overlay {\n right: auto;\n left: 0;\n }\n\n .column-back-button--slim-button {\n right: auto;\n left: 0;\n }\n\n .status__relative-time,\n .activity-stream .status.light .status__header .status__meta {\n float: left;\n }\n\n .status__action-bar {\n &__counter {\n margin-right: 0;\n margin-left: 11px;\n\n .status__action-bar-button {\n margin-right: 0;\n margin-left: 4px;\n }\n }\n }\n\n .status__action-bar-button {\n float: right;\n margin-right: 0;\n margin-left: 18px;\n }\n\n .status__action-bar-dropdown {\n float: right;\n }\n\n .privacy-dropdown__dropdown {\n margin-left: 0;\n margin-right: 40px;\n }\n\n .privacy-dropdown__option__icon {\n margin-left: 10px;\n margin-right: 0;\n }\n\n .detailed-status__display-name .display-name {\n text-align: right;\n }\n\n .detailed-status__display-avatar {\n margin-right: 0;\n margin-left: 10px;\n float: right;\n }\n\n .detailed-status__favorites,\n .detailed-status__reblogs {\n margin-left: 0;\n margin-right: 6px;\n }\n\n .fa-ul {\n margin-left: 2.14285714em;\n }\n\n .fa-li {\n left: auto;\n right: -2.14285714em;\n }\n\n .admin-wrapper {\n direction: rtl;\n }\n\n .admin-wrapper .sidebar ul a i.fa,\n a.table-action-link i.fa {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .simple_form .check_boxes .checkbox label {\n padding-left: 0;\n padding-right: 25px;\n }\n\n .simple_form .input.with_label.boolean label.checkbox {\n padding-left: 25px;\n padding-right: 0;\n }\n\n .simple_form .check_boxes .checkbox input[type=\"checkbox\"],\n .simple_form .input.boolean input[type=\"checkbox\"] {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.radio_buttons .radio > label {\n padding-right: 28px;\n padding-left: 0;\n }\n\n .simple_form .input-with-append .input input {\n padding-left: 142px;\n padding-right: 0;\n }\n\n .simple_form .input.boolean label.checkbox {\n left: auto;\n right: 0;\n }\n\n .simple_form .input.boolean .label_input,\n .simple_form .input.boolean .hint {\n padding-left: 0;\n padding-right: 28px;\n }\n\n .simple_form .label_input__append {\n right: auto;\n left: 3px;\n\n &::after {\n right: auto;\n left: 0;\n background-image: linear-gradient(to left, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%));\n }\n }\n\n .simple_form select {\n background: darken($ui-base-color, 10%) url(\"data:image/svg+xml;utf8,\") no-repeat left 8px center / auto 16px;\n }\n\n .table th,\n .table td {\n text-align: right;\n }\n\n .filters .filter-subset {\n margin-right: 0;\n margin-left: 45px;\n }\n\n .landing-page .header-wrapper .mascot {\n right: 60px;\n left: auto;\n }\n\n .landing-page__call-to-action .row__information-board {\n direction: rtl;\n }\n\n .landing-page .header .hero .floats .float-1 {\n left: -120px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-2 {\n left: 210px;\n right: auto;\n }\n\n .landing-page .header .hero .floats .float-3 {\n left: 110px;\n right: auto;\n }\n\n .landing-page .header .links .brand img {\n left: 0;\n }\n\n .landing-page .fa-external-link {\n padding-right: 5px;\n padding-left: 0 !important;\n }\n\n .landing-page .features #mastodon-timeline {\n margin-right: 0;\n margin-left: 30px;\n }\n\n @media screen and (min-width: 631px) {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n\n &:first-child {\n padding-left: 5px;\n padding-right: 10px;\n }\n }\n\n .columns-area > div {\n .column,\n .drawer {\n padding-left: 5px;\n padding-right: 5px;\n }\n }\n }\n\n .columns-area--mobile .column,\n .columns-area--mobile .drawer {\n padding-left: 0;\n padding-right: 0;\n }\n\n .public-layout {\n .header {\n .nav-button {\n margin-left: 8px;\n margin-right: 0;\n }\n }\n\n .public-account-header__tabs {\n margin-left: 0;\n margin-right: 20px;\n }\n }\n\n .landing-page__information {\n .account__display-name {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .account__avatar-wrapper {\n margin-left: 12px;\n margin-right: 0;\n }\n }\n\n .card__bar .display-name {\n margin-left: 0;\n margin-right: 15px;\n text-align: right;\n }\n\n .fa-chevron-left::before {\n content: \"\\F054\";\n }\n\n .fa-chevron-right::before {\n content: \"\\F053\";\n }\n\n .column-back-button__icon {\n margin-right: 0;\n margin-left: 5px;\n }\n\n .column-header__setting-arrows .column-header__setting-btn:last-child {\n padding-left: 0;\n padding-right: 10px;\n }\n\n .simple_form .input.radio_buttons .radio > label input {\n left: auto;\n right: 0;\n }\n}\n","$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';\n\n%white-emoji-outline {\n filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);\n transform: scale(.71);\n}\n\n.emojione {\n @each $emoji in $black-emojis {\n &[title=':#{$emoji}:'] {\n @extend %white-emoji-outline;\n }\n }\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/packs/skins/vanilla/win95/common.js b/priv/static/packs/skins/vanilla/win95/common.js index 67f57683a..ad508dd52 100644 Binary files a/priv/static/packs/skins/vanilla/win95/common.js and b/priv/static/packs/skins/vanilla/win95/common.js differ diff --git a/priv/static/packs/tesseract.js b/priv/static/packs/tesseract.js index 3dca2bfe2..8d0a37718 100644 Binary files a/priv/static/packs/tesseract.js and b/priv/static/packs/tesseract.js differ diff --git a/priv/static/packs/tesseract.js.LICENSE b/priv/static/packs/tesseract.js.LICENSE deleted file mode 100644 index acafca218..000000000 --- a/priv/static/packs/tesseract.js.LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * Determine if an object is a Buffer - * - * @author Feross Aboukhadijeh - * @license MIT - */ - -/** @license zlib.js 2012 - imaya [ https://github.com/imaya/zlib.js ] The MIT License */ diff --git a/priv/static/packs/tesseract.js.LICENSE.txt b/priv/static/packs/tesseract.js.LICENSE.txt new file mode 100644 index 000000000..acafca218 --- /dev/null +++ b/priv/static/packs/tesseract.js.LICENSE.txt @@ -0,0 +1,8 @@ +/*! + * Determine if an object is a Buffer + * + * @author Feross Aboukhadijeh + * @license MIT + */ + +/** @license zlib.js 2012 - imaya [ https://github.com/imaya/zlib.js ] The MIT License */ diff --git a/priv/static/packs/tesseract.js.map b/priv/static/packs/tesseract.js.map index 5e9c4cd5c..0a5962033 100644 Binary files a/priv/static/packs/tesseract.js.map and b/priv/static/packs/tesseract.js.map differ diff --git a/priv/static/sw.js b/priv/static/sw.js index b462115f9..0fde0f440 100644 Binary files a/priv/static/sw.js and b/priv/static/sw.js differ -- cgit v1.2.3 From 8a6ddf26ac63f63f68ad065392dc7f8fde2215d4 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 20 May 2020 12:12:07 +0200 Subject: InstanceOperation: Add background image to example --- lib/pleroma/web/api_spec/operations/instance_operation.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 880bd3f1b..9d189d029 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -130,6 +130,7 @@ defp instance do example: %{ "avatar_upload_limit" => 2_000_000, "background_upload_limit" => 4_000_000, + "background_image" => "/static/image.png", "banner_upload_limit" => 4_000_000, "description" => "A Pleroma instance, an alternative fediverse server", "email" => "lain@lain.com", -- cgit v1.2.3 From 5b8105928b1b4c7e0846d6bf52df2dedad426106 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 20 May 2020 12:13:57 +0200 Subject: Docs: Add background_image in instance --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 6d37d9008..e65fd5da4 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -216,6 +216,7 @@ Has theses additional parameters (which are the same as in Pleroma-API): - `avatar_upload_limit`: The same for avatars - `background_upload_limit`: The same for backgrounds - `banner_upload_limit`: The same for banners +- `background_image`: A background image that frontends can use - `pleroma.metadata.features`: A list of supported features - `pleroma.metadata.federation`: The federation restrictions of this instance - `vapid_public_key`: The public key needed for push messages -- cgit v1.2.3 From 2a74565090ebd7107e1419b4a56f6c6aa4a06ea1 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 20 May 2020 12:14:17 +0200 Subject: Changelog: Add background to instance --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index feda41320..66b160a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added +- Instance: Add `background_image` to configuration and `/api/v1/instance` - Instance: Extend `/api/v1/instance` with Pleroma-specific information. - NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. - NodeInfo: `pleroma_emoji_reactions` to the `features` list. -- cgit v1.2.3 From 490a3a34b63fa10e9151e9a385920c10615a1a3c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 19 May 2020 21:52:26 +0400 Subject: Add OpenAPI spec for PleromaAPI.PleromaAPIController --- docs/API/pleroma_api.md | 4 +- .../api_spec/operations/notification_operation.ex | 2 +- .../web/api_spec/operations/pleroma_operation.ex | 223 +++++++++++++++++++++ .../controllers/pleroma_api_controller.ex | 28 +-- test/support/api_spec_helpers.ex | 2 +- .../controllers/pleroma_api_controller_test.exs | 44 ++-- 6 files changed, 264 insertions(+), 39 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_operation.ex diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 867f59919..d6dbafc06 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -358,7 +358,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * `recipients`: A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though. * Response: JSON, statuses (200 - healthy, 503 unhealthy) -## `GET /api/v1/pleroma/conversations/read` +## `POST /api/v1/pleroma/conversations/read` ### Marks all user's conversations as read. * Method `POST` * Authentication: required @@ -536,7 +536,7 @@ Emoji reactions work a lot like favourites do. They make it possible to react to ``` ## `GET /api/v1/pleroma/statuses/:id/reactions/:emoji` -### Get an object of emoji to account mappings with accounts that reacted to the post for a specific emoji` +### Get an object of emoji to account mappings with accounts that reacted to the post for a specific emoji * Method: `GET` * Authentication: optional * Params: None diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 64adc5319..46e72f8bf 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -145,7 +145,7 @@ def destroy_multiple_operation do } end - defp notification do + def notification do %Schema{ title: "Notification", description: "Response schema for a notification", diff --git a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex new file mode 100644 index 000000000..c6df5c854 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex @@ -0,0 +1,223 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.StatusOperation + alias Pleroma.Web.ApiSpec.NotificationOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def emoji_reactions_by_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: + "Get an object of emoji to account mappings with accounts that reacted to the post", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", + required: false + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaController.emoji_reactions_by", + responses: %{ + 200 => array_of_reactions_response() + } + } + end + + def react_with_emoji_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: "React to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "PleromaController.react_with_emoji", + responses: %{ + 200 => Operation.response("Status", "application/json", Status) + } + } + end + + def unreact_with_emoji_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: "Remove a reaction to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "PleromaController.unreact_with_emoji", + responses: %{ + 200 => Operation.response("Status", "application/json", Status) + } + } + end + + defp array_of_reactions_response do + Operation.response("Array of Emoji Reactions", "application/json", %Schema{ + type: :array, + items: emoji_reaction(), + example: [emoji_reaction().example] + }) + end + + defp emoji_reaction do + %Schema{ + title: "EmojiReaction", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Emoji"}, + count: %Schema{type: :integer, description: "Count of reactions with this emoji"}, + me: %Schema{type: :boolean, description: "Did I react with this emoji?"}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Array of accounts reacted with this emoji" + } + }, + example: %{ + "name" => "😱", + "count" => 1, + "me" => false, + "accounts" => [Account.schema().example] + } + } + end + + def conversation_operation do + %Operation{ + tags: ["Conversations"], + summary: "The conversation with the given ID", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaController.conversation", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def conversation_statuses_operation do + %Operation{ + tags: ["Conversations"], + summary: "Timeline for a given conversation", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + | pagination_params() + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaController.conversation_statuses", + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ) + } + } + end + + def update_conversation_operation do + %Operation{ + tags: ["Conversations"], + summary: "Update a conversation. Used to change the set of recipients.", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ), + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.", + required: true + ) + ], + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaController.update_conversation", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def mark_conversations_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Marks all user's conversations as read", + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaController.mark_conversations_as_read", + responses: %{ + 200 => + Operation.response( + "Array of Conversations that were marked as read", + "application/json", + %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + } + ) + } + } + end + + def mark_notifications_as_read_operation do + %Operation{ + tags: ["Notifications"], + summary: "Mark notifications as read. Query parameters are mutually exclusive.", + parameters: [ + Operation.parameter(:id, :query, :string, "A single notification ID to read"), + Operation.parameter(:max_id, :query, :string, "Read all notifications up to this id") + ], + security: [%{"oAuth" => ["write:notifications"]}], + operationId: "PleromaController.mark_notifications_as_read", + responses: %{ + 200 => + Operation.response( + "A Notification or array of Motifications", + "application/json", + %Schema{ + anyOf: [ + %Schema{type: :array, items: NotificationOperation.notification()}, + NotificationOperation.notification() + ] + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index e834133b2..8220d13bc 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -20,6 +20,8 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["read:statuses"]} @@ -49,14 +51,16 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read ) - def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} = params) do + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaOperation + + def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), %Object{data: %{"reactions" => emoji_reactions}} when is_list(emoji_reactions) <- Object.normalize(activity) do reactions = emoji_reactions |> Enum.map(fn [emoji, user_ap_ids] -> - if params["emoji"] && params["emoji"] != emoji do + if params[:emoji] && params[:emoji] != emoji do nil else users = @@ -79,7 +83,7 @@ def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} } end end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) conn |> json(reactions) @@ -90,7 +94,7 @@ def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{"id" => activity_id} end end - def react_with_emoji(%{assigns: %{user: user}} = conn, %{"id" => activity_id, "emoji" => emoji}) do + def react_with_emoji(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji), activity <- Activity.get_by_id(activity_id) do conn @@ -99,10 +103,7 @@ def react_with_emoji(%{assigns: %{user: user}} = conn, %{"id" => activity_id, "e end end - def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{ - "id" => activity_id, - "emoji" => emoji - }) do + def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji), activity <- Activity.get_by_id(activity_id) do @@ -112,7 +113,7 @@ def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{ end end - def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do + def conversation(%{assigns: %{user: user}} = conn, %{id: participation_id}) do with %Participation{} = participation <- Participation.get(participation_id), true <- user.id == participation.user_id do conn @@ -128,12 +129,13 @@ def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) def conversation_statuses( %{assigns: %{user: %{id: user_id} = user}} = conn, - %{"id" => participation_id} = params + %{id: participation_id} = params ) do with %Participation{user_id: ^user_id} = participation <- Participation.get(participation_id, preload: [:conversation]) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("user", user) @@ -162,7 +164,7 @@ def conversation_statuses( def update_conversation( %{assigns: %{user: user}} = conn, - %{"id" => participation_id, "recipients" => recipients} + %{id: participation_id, recipients: recipients} ) do with %Participation{} = participation <- Participation.get(participation_id), true <- user.id == participation.user_id, @@ -192,7 +194,7 @@ def mark_conversations_as_read(%{assigns: %{user: user}} = conn, _params) do end end - def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do + def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{id: notification_id}) do with {:ok, notification} <- Notification.read_one(user, notification_id) do conn |> put_view(NotificationView) @@ -205,7 +207,7 @@ def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"id" => notif end end - def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{"max_id" => max_id}) do + def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{max_id: max_id}) do with notifications <- Notification.set_read_up_to(user, max_id) do notifications = Enum.take(notifications, 80) diff --git a/test/support/api_spec_helpers.ex b/test/support/api_spec_helpers.ex index 80c69c788..46388f92c 100644 --- a/test/support/api_spec_helpers.ex +++ b/test/support/api_spec_helpers.ex @@ -51,7 +51,7 @@ def api_operations do |> Map.take([:delete, :get, :head, :options, :patch, :post, :put, :trace]) |> Map.values() |> Enum.reject(&is_nil/1) - |> Enum.uniq() end) + |> Enum.uniq() end end diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index cfd1dbd24..f0cdc2f08 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -27,7 +27,7 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do |> assign(:user, other_user) |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕") - |> json_response(200) + |> json_response_and_validate_schema(200) # We return the status, but this our implementation detail. assert %{"id" => id} = result @@ -53,7 +53,7 @@ test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕") - assert %{"id" => id} = json_response(result, 200) + assert %{"id" => id} = json_response_and_validate_schema(result, 200) assert to_string(activity.id) == id ObanHelpers.perform_all() @@ -73,7 +73,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do result = conn |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") - |> json_response(200) + |> json_response_and_validate_schema(200) assert result == [] @@ -85,7 +85,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do result = conn |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") - |> json_response(200) + |> json_response_and_validate_schema(200) [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result @@ -96,7 +96,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do |> assign(:user, other_user) |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:statuses"])) |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") - |> json_response(200) + |> json_response_and_validate_schema(200) assert [%{"name" => "🎅", "count" => 1, "accounts" => [_represented_user], "me" => true}] = result @@ -111,7 +111,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do result = conn |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅") - |> json_response(200) + |> json_response_and_validate_schema(200) assert result == [] @@ -121,7 +121,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do result = conn |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅") - |> json_response(200) + |> json_response_and_validate_schema(200) [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result @@ -140,7 +140,7 @@ test "/api/v1/pleroma/conversations/:id" do result = conn |> get("/api/v1/pleroma/conversations/#{participation.id}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert result["id"] == participation.id |> to_string() end @@ -168,7 +168,7 @@ test "/api/v1/pleroma/conversations/:id/statuses" do result = conn |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(result) == 2 @@ -186,12 +186,12 @@ test "/api/v1/pleroma/conversations/:id/statuses" do assert [%{"id" => ^id_two}, %{"id" => ^id_three}] = conn |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?limit=2") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert [%{"id" => ^id_three}] = conn |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?min_id=#{id_two}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) end test "PATCH /api/v1/pleroma/conversations/:id" do @@ -208,12 +208,12 @@ test "PATCH /api/v1/pleroma/conversations/:id" do assert [user] == participation.recipients assert other_user not in participation.recipients + query = "recipients[]=#{user.id}&recipients[]=#{other_user.id}" + result = conn - |> patch("/api/v1/pleroma/conversations/#{participation.id}", %{ - "recipients" => [user.id, other_user.id] - }) - |> json_response(200) + |> patch("/api/v1/pleroma/conversations/#{participation.id}?#{query}") + |> json_response_and_validate_schema(200) assert result["id"] == participation.id |> to_string @@ -242,7 +242,7 @@ test "POST /api/v1/pleroma/conversations/read" do [%{"unread" => false}, %{"unread" => false}] = conn |> post("/api/v1/pleroma/conversations/read", %{}) - |> json_response(200) + |> json_response_and_validate_schema(200) [participation2, participation1] = Participation.for_user(other_user) assert Participation.get(participation2.id).read == true @@ -262,8 +262,8 @@ test "it marks a single notification as read", %{user: user1, conn: conn} do response = conn - |> post("/api/v1/pleroma/notifications/read", %{"id" => "#{notification1.id}"}) - |> json_response(:ok) + |> post("/api/v1/pleroma/notifications/read?id=#{notification1.id}") + |> json_response_and_validate_schema(:ok) assert %{"pleroma" => %{"is_seen" => true}} = response assert Repo.get(Notification, notification1.id).seen @@ -280,8 +280,8 @@ test "it marks multiple notifications as read", %{user: user1, conn: conn} do [response1, response2] = conn - |> post("/api/v1/pleroma/notifications/read", %{"max_id" => "#{notification2.id}"}) - |> json_response(:ok) + |> post("/api/v1/pleroma/notifications/read?max_id=#{notification2.id}") + |> json_response_and_validate_schema(:ok) assert %{"pleroma" => %{"is_seen" => true}} = response1 assert %{"pleroma" => %{"is_seen" => true}} = response2 @@ -293,8 +293,8 @@ test "it marks multiple notifications as read", %{user: user1, conn: conn} do test "it returns error when notification not found", %{conn: conn} do response = conn - |> post("/api/v1/pleroma/notifications/read", %{"id" => "22222222222222"}) - |> json_response(:bad_request) + |> post("/api/v1/pleroma/notifications/read?id=22222222222222") + |> json_response_and_validate_schema(:bad_request) assert response == %{"error" => "Cannot get notification"} end -- cgit v1.2.3 From 9a5de0f4548cfe6b62265596bbe3cef2d639b978 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 19 May 2020 23:50:49 +0400 Subject: Move reaction actions to EmojiReactionController --- .../operations/emoji_reaction_operation.ex | 102 +++++++++++++++++ .../web/api_spec/operations/pleroma_operation.ex | 92 +-------------- .../controllers/emoji_reaction_controller.ex | 61 ++++++++++ .../controllers/pleroma_api_controller.ex | 77 ------------- .../web/pleroma_api/views/emoji_reaction_view.ex | 33 ++++++ lib/pleroma/web/router.ex | 8 +- .../controllers/emoji_reaction_controller_test.exs | 125 +++++++++++++++++++++ .../controllers/pleroma_api_controller_test.exs | 115 ------------------- 8 files changed, 327 insertions(+), 286 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex create mode 100644 lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex create mode 100644 test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs diff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex new file mode 100644 index 000000000..7c08fbaa7 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex @@ -0,0 +1,102 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: + "Get an object of emoji to account mappings with accounts that reacted to the post", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", + required: false + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "EmojiReactionController.index", + responses: %{ + 200 => array_of_reactions_response() + } + } + end + + def create_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: "React to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "EmojiReactionController.create", + responses: %{ + 200 => Operation.response("Status", "application/json", Status) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Emoji Reactions"], + summary: "Remove a reaction to a post with a unicode emoji", + parameters: [ + Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), + Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", + required: true + ) + ], + security: [%{"oAuth" => ["write:statuses"]}], + operationId: "EmojiReactionController.delete", + responses: %{ + 200 => Operation.response("Status", "application/json", Status) + } + } + end + + defp array_of_reactions_response do + Operation.response("Array of Emoji Reactions", "application/json", %Schema{ + type: :array, + items: emoji_reaction(), + example: [emoji_reaction().example] + }) + end + + defp emoji_reaction do + %Schema{ + title: "EmojiReaction", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Emoji"}, + count: %Schema{type: :integer, description: "Count of reactions with this emoji"}, + me: %Schema{type: :boolean, description: "Did I react with this emoji?"}, + accounts: %Schema{ + type: :array, + items: Account, + description: "Array of accounts reacted with this emoji" + } + }, + example: %{ + "name" => "😱", + "count" => 1, + "me" => false, + "accounts" => [Account.schema().example] + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex index c6df5c854..7e46ba553 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex @@ -5,13 +5,11 @@ defmodule Pleroma.Web.ApiSpec.PleromaOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.NotificationOperation alias Pleroma.Web.ApiSpec.Schemas.ApiError - alias Pleroma.Web.ApiSpec.Schemas.FlakeID - alias Pleroma.Web.ApiSpec.Schemas.Status alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.StatusOperation - alias Pleroma.Web.ApiSpec.NotificationOperation import Pleroma.Web.ApiSpec.Helpers @@ -20,92 +18,6 @@ def open_api_operation(action) do apply(__MODULE__, operation, []) end - def emoji_reactions_by_operation do - %Operation{ - tags: ["Emoji Reactions"], - summary: - "Get an object of emoji to account mappings with accounts that reacted to the post", - parameters: [ - Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), - Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji", - required: false - ) - ], - security: [%{"oAuth" => ["read:statuses"]}], - operationId: "PleromaController.emoji_reactions_by", - responses: %{ - 200 => array_of_reactions_response() - } - } - end - - def react_with_emoji_operation do - %Operation{ - tags: ["Emoji Reactions"], - summary: "React to a post with a unicode emoji", - parameters: [ - Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), - Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", - required: true - ) - ], - security: [%{"oAuth" => ["write:statuses"]}], - operationId: "PleromaController.react_with_emoji", - responses: %{ - 200 => Operation.response("Status", "application/json", Status) - } - } - end - - def unreact_with_emoji_operation do - %Operation{ - tags: ["Emoji Reactions"], - summary: "Remove a reaction to a post with a unicode emoji", - parameters: [ - Operation.parameter(:id, :path, FlakeID, "Status ID", required: true), - Operation.parameter(:emoji, :path, :string, "A single character unicode emoji", - required: true - ) - ], - security: [%{"oAuth" => ["write:statuses"]}], - operationId: "PleromaController.unreact_with_emoji", - responses: %{ - 200 => Operation.response("Status", "application/json", Status) - } - } - end - - defp array_of_reactions_response do - Operation.response("Array of Emoji Reactions", "application/json", %Schema{ - type: :array, - items: emoji_reaction(), - example: [emoji_reaction().example] - }) - end - - defp emoji_reaction do - %Schema{ - title: "EmojiReaction", - type: :object, - properties: %{ - name: %Schema{type: :string, description: "Emoji"}, - count: %Schema{type: :integer, description: "Count of reactions with this emoji"}, - me: %Schema{type: :boolean, description: "Did I react with this emoji?"}, - accounts: %Schema{ - type: :array, - items: Account, - description: "Array of accounts reacted with this emoji" - } - }, - example: %{ - "name" => "😱", - "count" => 1, - "me" => false, - "accounts" => [Account.schema().example] - } - } - end - def conversation_operation do %Operation{ tags: ["Conversations"], diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex new file mode 100644 index 000000000..a002912f3 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI.StatusView + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action in [:create, :delete]) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} + when action == :index + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.EmojiReactionOperation + + def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do + with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), + %Object{data: %{"reactions" => reactions}} when is_list(reactions) <- + Object.normalize(activity) do + reactions = filter(reactions, params) + render(conn, "index.json", emoji_reactions: reactions, user: user) + else + _e -> json(conn, []) + end + end + + defp filter(reactions, %{emoji: emoji}) when is_binary(emoji) do + Enum.filter(reactions, fn [e, _] -> e == emoji end) + end + + defp filter(reactions, _), do: reactions + + def create(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do + with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do + activity = Activity.get_by_id(activity_id) + + conn + |> put_view(StatusView) + |> render("show.json", activity: activity, for: user, as: :activity) + end + end + + def delete(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do + with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do + activity = Activity.get_by_id(activity_id) + + conn + |> put_view(StatusView) + |> render("show.json", activity: activity, for: user, as: :activity) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 8220d13bc..61273f7ee 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -7,15 +7,10 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - alias Pleroma.Activity alias Pleroma.Conversation.Participation alias Pleroma.Notification - alias Pleroma.Object alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView @@ -28,18 +23,6 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do when action in [:conversation, :conversation_statuses] ) - plug( - OAuthScopesPlug, - %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} - when action == :emoji_reactions_by - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:statuses"]} - when action in [:react_with_emoji, :unreact_with_emoji] - ) - plug( OAuthScopesPlug, %{scopes: ["write:conversations"]} @@ -53,66 +36,6 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaOperation - def emoji_reactions_by(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do - with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), - %Object{data: %{"reactions" => emoji_reactions}} when is_list(emoji_reactions) <- - Object.normalize(activity) do - reactions = - emoji_reactions - |> Enum.map(fn [emoji, user_ap_ids] -> - if params[:emoji] && params[:emoji] != emoji do - nil - else - users = - Enum.map(user_ap_ids, &User.get_cached_by_ap_id/1) - |> Enum.filter(fn - %{deactivated: false} -> true - _ -> false - end) - - %{ - name: emoji, - count: length(users), - accounts: - AccountView.render("index.json", %{ - users: users, - for: user, - as: :user - }), - me: !!(user && user.ap_id in user_ap_ids) - } - end - end) - |> Enum.reject(&is_nil/1) - - conn - |> json(reactions) - else - _e -> - conn - |> json([]) - end - end - - def react_with_emoji(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do - with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji), - activity <- Activity.get_by_id(activity_id) do - conn - |> put_view(StatusView) - |> render("show.json", %{activity: activity, for: user, as: :activity}) - end - end - - def unreact_with_emoji(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do - with {:ok, _activity} <- - CommonAPI.unreact_with_emoji(activity_id, user, emoji), - activity <- Activity.get_by_id(activity_id) do - conn - |> put_view(StatusView) - |> render("show.json", %{activity: activity, for: user, as: :activity}) - end - end - def conversation(%{assigns: %{user: user}} = conn, %{id: participation_id}) do with %Participation{} = participation <- Participation.get(participation_id), true <- user.id == participation.user_id do diff --git a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex new file mode 100644 index 000000000..84d2d303d --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.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.PleromaAPI.EmojiReactionView do + use Pleroma.Web, :view + + alias Pleroma.Web.MastodonAPI.AccountView + + def render("index.json", %{emoji_reactions: emoji_reactions} = opts) do + render_many(emoji_reactions, __MODULE__, "show.json", opts) + end + + def render("show.json", %{emoji_reaction: [emoji, user_ap_ids], user: user}) do + users = fetch_users(user_ap_ids) + + %{ + name: emoji, + count: length(users), + accounts: render(AccountView, "index.json", users: users, for: user, as: :user), + me: !!(user && user.ap_id in user_ap_ids) + } + end + + defp fetch_users(user_ap_ids) do + user_ap_ids + |> Enum.map(&Pleroma.User.get_cached_by_ap_id/1) + |> Enum.filter(fn + %{deactivated: false} -> true + _ -> false + end) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 369c54cf4..12381511e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -297,8 +297,8 @@ defmodule Pleroma.Web.Router do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do pipe_through(:api) - get("/statuses/:id/reactions/:emoji", PleromaAPIController, :emoji_reactions_by) - get("/statuses/:id/reactions", PleromaAPIController, :emoji_reactions_by) + get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index) + get("/statuses/:id/reactions", EmojiReactionController, :index) end scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do @@ -314,8 +314,8 @@ defmodule Pleroma.Web.Router do pipe_through(:authenticated_api) patch("/conversations/:id", PleromaAPIController, :update_conversation) - put("/statuses/:id/reactions/:emoji", PleromaAPIController, :react_with_emoji) - delete("/statuses/:id/reactions/:emoji", PleromaAPIController, :unreact_with_emoji) + put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create) + delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete) post("/notifications/read", PleromaAPIController, :mark_notifications_as_read) patch("/accounts/update_avatar", AccountController, :update_avatar) diff --git a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs new file mode 100644 index 000000000..ee66ebf87 --- /dev/null +++ b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -0,0 +1,125 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.Web.ConnCase + + alias Pleroma.Object + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + + result = + conn + |> assign(:user, other_user) + |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) + |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕") + |> json_response_and_validate_schema(200) + + # We return the status, but this our implementation detail. + assert %{"id" => id} = result + assert to_string(activity.id) == id + + assert result["pleroma"]["emoji_reactions"] == [ + %{"name" => "☕", "count" => 1, "me" => true} + ] + end + + test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + + ObanHelpers.perform_all() + + result = + conn + |> assign(:user, other_user) + |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) + |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕") + + assert %{"id" => id} = json_response_and_validate_schema(result, 200) + assert to_string(activity.id) == id + + ObanHelpers.perform_all() + + object = Object.get_by_ap_id(activity.data["object"]) + + assert object.data["reaction_count"] == 0 + end + + test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + doomed_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + + result = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") + |> json_response_and_validate_schema(200) + + assert result == [] + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, doomed_user, "🎅") + + User.perform(:delete, doomed_user) + + result = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") + |> json_response_and_validate_schema(200) + + [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result + + assert represented_user["id"] == other_user.id + + result = + conn + |> assign(:user, other_user) + |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:statuses"])) + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") + |> json_response_and_validate_schema(200) + + assert [%{"name" => "🎅", "count" => 1, "accounts" => [_represented_user], "me" => true}] = + result + end + + test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + + result = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅") + |> json_response_and_validate_schema(200) + + assert result == [] + + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + + assert [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅") + |> json_response_and_validate_schema(200) + + assert represented_user["id"] == other_user.id + end +end diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index f0cdc2f08..6f4f01e6f 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -3,131 +3,16 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do - use Oban.Testing, repo: Pleroma.Repo use Pleroma.Web.ConnCase alias Pleroma.Conversation.Participation alias Pleroma.Notification - alias Pleroma.Object alias Pleroma.Repo - alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.CommonAPI import Pleroma.Factory - test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) - - result = - conn - |> assign(:user, other_user) - |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) - |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕") - |> json_response_and_validate_schema(200) - - # We return the status, but this our implementation detail. - assert %{"id" => id} = result - assert to_string(activity.id) == id - - assert result["pleroma"]["emoji_reactions"] == [ - %{"name" => "☕", "count" => 1, "me" => true} - ] - end - - test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) - {:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") - - ObanHelpers.perform_all() - - result = - conn - |> assign(:user, other_user) - |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) - |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕") - - assert %{"id" => id} = json_response_and_validate_schema(result, 200) - assert to_string(activity.id) == id - - ObanHelpers.perform_all() - - object = Object.get_by_ap_id(activity.data["object"]) - - assert object.data["reaction_count"] == 0 - end - - test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - doomed_user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) - - result = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") - |> json_response_and_validate_schema(200) - - assert result == [] - - {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") - {:ok, _} = CommonAPI.react_with_emoji(activity.id, doomed_user, "🎅") - - User.perform(:delete, doomed_user) - - result = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") - |> json_response_and_validate_schema(200) - - [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result - - assert represented_user["id"] == other_user.id - - result = - conn - |> assign(:user, other_user) - |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:statuses"])) - |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") - |> json_response_and_validate_schema(200) - - assert [%{"name" => "🎅", "count" => 1, "accounts" => [_represented_user], "me" => true}] = - result - end - - test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) - - result = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅") - |> json_response_and_validate_schema(200) - - assert result == [] - - {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") - {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") - - result = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions/🎅") - |> json_response_and_validate_schema(200) - - [%{"name" => "🎅", "count" => 1, "accounts" => [represented_user], "me" => false}] = result - - assert represented_user["id"] == other_user.id - end - test "/api/v1/pleroma/conversations/:id" do user = insert(:user) %{user: other_user, conn: conn} = oauth_access(["read:statuses"]) -- cgit v1.2.3 From f3fc8b22b1dca8d432d066417e2bb9b62a3f1520 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 20 May 2020 15:00:11 +0400 Subject: Move conversation actions to PleromaAPI.ConversationController --- .../operations/pleroma_conversation_operation.ex | 106 ++++++++++++++++ .../web/api_spec/operations/pleroma_operation.ex | 93 -------------- .../controllers/conversation_controller.ex | 95 ++++++++++++++ .../controllers/pleroma_api_controller.ex | 99 --------------- lib/pleroma/web/router.ex | 12 +- .../controllers/conversation_controller_test.exs | 136 +++++++++++++++++++++ .../controllers/pleroma_api_controller_test.exs | 124 ------------------- 7 files changed, 341 insertions(+), 324 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex create mode 100644 test/web/pleroma_api/controllers/conversation_controller_test.exs diff --git a/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex new file mode 100644 index 000000000..e885eab20 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_conversation_operation.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaConversationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Conversation + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.StatusOperation + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Conversations"], + summary: "The conversation with the given ID", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaAPI.ConversationController.show", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def statuses_operation do + %Operation{ + tags: ["Conversations"], + summary: "Timeline for a given conversation", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ) + | pagination_params() + ], + security: [%{"oAuth" => ["read:statuses"]}], + operationId: "PleromaAPI.ConversationController.statuses", + responses: %{ + 200 => + Operation.response( + "Array of Statuses", + "application/json", + StatusOperation.array_of_statuses() + ) + } + } + end + + def update_operation do + %Operation{ + tags: ["Conversations"], + summary: "Update a conversation. Used to change the set of recipients.", + parameters: [ + Operation.parameter(:id, :path, :string, "Conversation ID", + example: "123", + required: true + ), + Operation.parameter( + :recipients, + :query, + %Schema{type: :array, items: FlakeID}, + "A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.", + required: true + ) + ], + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaAPI.ConversationController.update", + responses: %{ + 200 => Operation.response("Conversation", "application/json", Conversation) + } + } + end + + def mark_as_read_operation do + %Operation{ + tags: ["Conversations"], + summary: "Marks all user's conversations as read", + security: [%{"oAuth" => ["write:conversations"]}], + operationId: "PleromaAPI.ConversationController.mark_as_read", + responses: %{ + 200 => + Operation.response( + "Array of Conversations that were marked as read", + "application/json", + %Schema{ + type: :array, + items: Conversation, + example: [Conversation.schema().example] + } + ) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex index 7e46ba553..d28451933 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex @@ -7,105 +7,12 @@ defmodule Pleroma.Web.ApiSpec.PleromaOperation do alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.NotificationOperation alias Pleroma.Web.ApiSpec.Schemas.ApiError - alias Pleroma.Web.ApiSpec.Schemas.Conversation - alias Pleroma.Web.ApiSpec.Schemas.FlakeID - alias Pleroma.Web.ApiSpec.StatusOperation - - import Pleroma.Web.ApiSpec.Helpers def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") apply(__MODULE__, operation, []) end - def conversation_operation do - %Operation{ - tags: ["Conversations"], - summary: "The conversation with the given ID", - parameters: [ - Operation.parameter(:id, :path, :string, "Conversation ID", - example: "123", - required: true - ) - ], - security: [%{"oAuth" => ["read:statuses"]}], - operationId: "PleromaController.conversation", - responses: %{ - 200 => Operation.response("Conversation", "application/json", Conversation) - } - } - end - - def conversation_statuses_operation do - %Operation{ - tags: ["Conversations"], - summary: "Timeline for a given conversation", - parameters: [ - Operation.parameter(:id, :path, :string, "Conversation ID", - example: "123", - required: true - ) - | pagination_params() - ], - security: [%{"oAuth" => ["read:statuses"]}], - operationId: "PleromaController.conversation_statuses", - responses: %{ - 200 => - Operation.response( - "Array of Statuses", - "application/json", - StatusOperation.array_of_statuses() - ) - } - } - end - - def update_conversation_operation do - %Operation{ - tags: ["Conversations"], - summary: "Update a conversation. Used to change the set of recipients.", - parameters: [ - Operation.parameter(:id, :path, :string, "Conversation ID", - example: "123", - required: true - ), - Operation.parameter( - :recipients, - :query, - %Schema{type: :array, items: FlakeID}, - "A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.", - required: true - ) - ], - security: [%{"oAuth" => ["write:conversations"]}], - operationId: "PleromaController.update_conversation", - responses: %{ - 200 => Operation.response("Conversation", "application/json", Conversation) - } - } - end - - def mark_conversations_as_read_operation do - %Operation{ - tags: ["Conversations"], - summary: "Marks all user's conversations as read", - security: [%{"oAuth" => ["write:conversations"]}], - operationId: "PleromaController.mark_conversations_as_read", - responses: %{ - 200 => - Operation.response( - "Array of Conversations that were marked as read", - "application/json", - %Schema{ - type: :array, - items: Conversation, - example: [Conversation.schema().example] - } - ) - } - } - end - def mark_notifications_as_read_operation do %Operation{ tags: ["Notifications"], diff --git a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex new file mode 100644 index 000000000..21d5eb8d5 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex @@ -0,0 +1,95 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ConversationController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] + + alias Pleroma.Conversation.Participation + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:put_view, Pleroma.Web.MastodonAPI.ConversationView) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:show, :statuses]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:conversations"]} when action in [:update, :mark_as_read] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaConversationOperation + + def show(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: participation_id}) do + with %Participation{user_id: ^user_id} = participation <- Participation.get(participation_id) do + render(conn, "participation.json", participation: participation, for: user) + else + _error -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Unknown conversation id"}) + end + end + + def statuses( + %{assigns: %{user: %{id: user_id} = user}} = conn, + %{id: participation_id} = params + ) do + with %Participation{user_id: ^user_id} = participation <- + Participation.get(participation_id, preload: [:conversation]) do + params = + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + + activities = + participation.conversation.ap_id + |> ActivityPub.fetch_activities_for_context_query(params) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, "total", false)) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> put_view(StatusView) + |> render("index.json", activities: activities, for: user, as: :activity) + else + _error -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Unknown conversation id"}) + end + end + + def update( + %{assigns: %{user: %{id: user_id} = user}} = conn, + %{id: participation_id, recipients: recipients} + ) do + with %Participation{user_id: ^user_id} = participation <- Participation.get(participation_id), + {:ok, participation} <- Participation.set_recipients(participation, recipients) do + render(conn, "participation.json", participation: participation, for: user) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + + _error -> + conn + |> put_status(:not_found) + |> json(%{"error" => "Unknown conversation id"}) + end + end + + def mark_as_read(%{assigns: %{user: user}} = conn, _params) do + with {:ok, _, participations} <- Participation.mark_all_as_read(user) do + conn + |> add_link_headers(participations) + |> render("participations.json", participations: participations, for: user) + end + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex index 61273f7ee..a58665abe 100644 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex @@ -5,30 +5,12 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - - alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.NotificationView - alias Pleroma.Web.MastodonAPI.StatusView plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug( - OAuthScopesPlug, - %{scopes: ["read:statuses"]} - when action in [:conversation, :conversation_statuses] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:conversations"]} - when action in [:update_conversation, :mark_conversations_as_read] - ) - plug( OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read @@ -36,87 +18,6 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaOperation - def conversation(%{assigns: %{user: user}} = conn, %{id: participation_id}) do - with %Participation{} = participation <- Participation.get(participation_id), - true <- user.id == participation.user_id do - conn - |> put_view(ConversationView) - |> render("participation.json", %{participation: participation, for: user}) - else - _error -> - conn - |> put_status(404) - |> json(%{"error" => "Unknown conversation id"}) - end - end - - def conversation_statuses( - %{assigns: %{user: %{id: user_id} = user}} = conn, - %{id: participation_id} = params - ) do - with %Participation{user_id: ^user_id} = participation <- - Participation.get(participation_id, preload: [:conversation]) do - params = - params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - - activities = - participation.conversation.ap_id - |> ActivityPub.fetch_activities_for_context_query(params) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, "total", false)) - |> Enum.reverse() - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", - activities: activities, - for: user, - as: :activity - ) - else - _error -> - conn - |> put_status(404) - |> json(%{"error" => "Unknown conversation id"}) - end - end - - def update_conversation( - %{assigns: %{user: user}} = conn, - %{id: participation_id, recipients: recipients} - ) do - with %Participation{} = participation <- Participation.get(participation_id), - true <- user.id == participation.user_id, - {:ok, participation} <- Participation.set_recipients(participation, recipients) do - conn - |> put_view(ConversationView) - |> render("participation.json", %{participation: participation, for: user}) - else - {:error, message} -> - conn - |> put_status(:bad_request) - |> json(%{"error" => message}) - - _error -> - conn - |> put_status(404) - |> json(%{"error" => "Unknown conversation id"}) - end - end - - def mark_conversations_as_read(%{assigns: %{user: user}} = conn, _params) do - with {:ok, _, participations} <- Participation.mark_all_as_read(user) do - conn - |> add_link_headers(participations) - |> put_view(ConversationView) - |> render("participations.json", participations: participations, for: user) - end - end - def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{id: notification_id}) do with {:ok, notification} <- Notification.read_one(user, notification_id) do conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 12381511e..78da4a871 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -305,15 +305,11 @@ defmodule Pleroma.Web.Router do scope [] do pipe_through(:authenticated_api) - get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) - get("/conversations/:id", PleromaAPIController, :conversation) - post("/conversations/read", PleromaAPIController, :mark_conversations_as_read) - end - - scope [] do - pipe_through(:authenticated_api) + get("/conversations/:id/statuses", ConversationController, :statuses) + get("/conversations/:id", ConversationController, :show) + post("/conversations/read", ConversationController, :mark_as_read) + patch("/conversations/:id", ConversationController, :update) - patch("/conversations/:id", PleromaAPIController, :update_conversation) put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create) delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete) post("/notifications/read", PleromaAPIController, :mark_notifications_as_read) diff --git a/test/web/pleroma_api/controllers/conversation_controller_test.exs b/test/web/pleroma_api/controllers/conversation_controller_test.exs new file mode 100644 index 000000000..e6d0b3e37 --- /dev/null +++ b/test/web/pleroma_api/controllers/conversation_controller_test.exs @@ -0,0 +1,136 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ConversationControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Conversation.Participation + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "/api/v1/pleroma/conversations/:id" do + user = insert(:user) + %{user: other_user, conn: conn} = oauth_access(["read:statuses"]) + + {:ok, _activity} = + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"}) + + [participation] = Participation.for_user(other_user) + + result = + conn + |> get("/api/v1/pleroma/conversations/#{participation.id}") + |> json_response_and_validate_schema(200) + + assert result["id"] == participation.id |> to_string() + end + + test "/api/v1/pleroma/conversations/:id/statuses" do + user = insert(:user) + %{user: other_user, conn: conn} = oauth_access(["read:statuses"]) + third_user = insert(:user) + + {:ok, _activity} = + CommonAPI.post(user, %{status: "Hi @#{third_user.nickname}!", visibility: "direct"}) + + {:ok, activity} = + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"}) + + [participation] = Participation.for_user(other_user) + + {:ok, activity_two} = + CommonAPI.post(other_user, %{ + status: "Hi!", + in_reply_to_status_id: activity.id, + in_reply_to_conversation_id: participation.id + }) + + result = + conn + |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses") + |> json_response_and_validate_schema(200) + + assert length(result) == 2 + + id_one = activity.id + id_two = activity_two.id + assert [%{"id" => ^id_one}, %{"id" => ^id_two}] = result + + {:ok, %{id: id_three}} = + CommonAPI.post(other_user, %{ + status: "Bye!", + in_reply_to_status_id: activity.id, + in_reply_to_conversation_id: participation.id + }) + + assert [%{"id" => ^id_two}, %{"id" => ^id_three}] = + conn + |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?limit=2") + |> json_response_and_validate_schema(:ok) + + assert [%{"id" => ^id_three}] = + conn + |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?min_id=#{id_two}") + |> json_response_and_validate_schema(:ok) + end + + test "PATCH /api/v1/pleroma/conversations/:id" do + %{user: user, conn: conn} = oauth_access(["write:conversations"]) + other_user = insert(:user) + + {:ok, _activity} = CommonAPI.post(user, %{status: "Hi", visibility: "direct"}) + + [participation] = Participation.for_user(user) + + participation = Repo.preload(participation, :recipients) + + user = User.get_cached_by_id(user.id) + assert [user] == participation.recipients + assert other_user not in participation.recipients + + query = "recipients[]=#{user.id}&recipients[]=#{other_user.id}" + + result = + conn + |> patch("/api/v1/pleroma/conversations/#{participation.id}?#{query}") + |> json_response_and_validate_schema(200) + + assert result["id"] == participation.id |> to_string + + [participation] = Participation.for_user(user) + participation = Repo.preload(participation, :recipients) + + assert user in participation.recipients + assert other_user in participation.recipients + end + + test "POST /api/v1/pleroma/conversations/read" do + user = insert(:user) + %{user: other_user, conn: conn} = oauth_access(["write:conversations"]) + + {:ok, _activity} = + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"}) + + {:ok, _activity} = + CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"}) + + [participation2, participation1] = Participation.for_user(other_user) + assert Participation.get(participation2.id).read == false + assert Participation.get(participation1.id).read == false + assert User.get_cached_by_id(other_user.id).unread_conversation_count == 2 + + [%{"unread" => false}, %{"unread" => false}] = + conn + |> post("/api/v1/pleroma/conversations/read", %{}) + |> json_response_and_validate_schema(200) + + [participation2, participation1] = Participation.for_user(other_user) + assert Participation.get(participation2.id).read == true + assert Participation.get(participation1.id).read == true + assert User.get_cached_by_id(other_user.id).unread_conversation_count == 0 + end +end diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs index 6f4f01e6f..c4c661266 100644 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs @@ -5,136 +5,12 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Repo - alias Pleroma.User alias Pleroma.Web.CommonAPI import Pleroma.Factory - test "/api/v1/pleroma/conversations/:id" do - user = insert(:user) - %{user: other_user, conn: conn} = oauth_access(["read:statuses"]) - - {:ok, _activity} = - CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"}) - - [participation] = Participation.for_user(other_user) - - result = - conn - |> get("/api/v1/pleroma/conversations/#{participation.id}") - |> json_response_and_validate_schema(200) - - assert result["id"] == participation.id |> to_string() - end - - test "/api/v1/pleroma/conversations/:id/statuses" do - user = insert(:user) - %{user: other_user, conn: conn} = oauth_access(["read:statuses"]) - third_user = insert(:user) - - {:ok, _activity} = - CommonAPI.post(user, %{status: "Hi @#{third_user.nickname}!", visibility: "direct"}) - - {:ok, activity} = - CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}!", visibility: "direct"}) - - [participation] = Participation.for_user(other_user) - - {:ok, activity_two} = - CommonAPI.post(other_user, %{ - status: "Hi!", - in_reply_to_status_id: activity.id, - in_reply_to_conversation_id: participation.id - }) - - result = - conn - |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses") - |> json_response_and_validate_schema(200) - - assert length(result) == 2 - - id_one = activity.id - id_two = activity_two.id - assert [%{"id" => ^id_one}, %{"id" => ^id_two}] = result - - {:ok, %{id: id_three}} = - CommonAPI.post(other_user, %{ - status: "Bye!", - in_reply_to_status_id: activity.id, - in_reply_to_conversation_id: participation.id - }) - - assert [%{"id" => ^id_two}, %{"id" => ^id_three}] = - conn - |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?limit=2") - |> json_response_and_validate_schema(:ok) - - assert [%{"id" => ^id_three}] = - conn - |> get("/api/v1/pleroma/conversations/#{participation.id}/statuses?min_id=#{id_two}") - |> json_response_and_validate_schema(:ok) - end - - test "PATCH /api/v1/pleroma/conversations/:id" do - %{user: user, conn: conn} = oauth_access(["write:conversations"]) - other_user = insert(:user) - - {:ok, _activity} = CommonAPI.post(user, %{status: "Hi", visibility: "direct"}) - - [participation] = Participation.for_user(user) - - participation = Repo.preload(participation, :recipients) - - user = User.get_cached_by_id(user.id) - assert [user] == participation.recipients - assert other_user not in participation.recipients - - query = "recipients[]=#{user.id}&recipients[]=#{other_user.id}" - - result = - conn - |> patch("/api/v1/pleroma/conversations/#{participation.id}?#{query}") - |> json_response_and_validate_schema(200) - - assert result["id"] == participation.id |> to_string - - [participation] = Participation.for_user(user) - participation = Repo.preload(participation, :recipients) - - assert user in participation.recipients - assert other_user in participation.recipients - end - - test "POST /api/v1/pleroma/conversations/read" do - user = insert(:user) - %{user: other_user, conn: conn} = oauth_access(["write:conversations"]) - - {:ok, _activity} = - CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"}) - - {:ok, _activity} = - CommonAPI.post(user, %{status: "Hi @#{other_user.nickname}", visibility: "direct"}) - - [participation2, participation1] = Participation.for_user(other_user) - assert Participation.get(participation2.id).read == false - assert Participation.get(participation1.id).read == false - assert User.get_cached_by_id(other_user.id).unread_conversation_count == 2 - - [%{"unread" => false}, %{"unread" => false}] = - conn - |> post("/api/v1/pleroma/conversations/read", %{}) - |> json_response_and_validate_schema(200) - - [participation2, participation1] = Participation.for_user(other_user) - assert Participation.get(participation2.id).read == true - assert Participation.get(participation1.id).read == true - assert User.get_cached_by_id(other_user.id).unread_conversation_count == 0 - end - describe "POST /api/v1/pleroma/notifications/read" do setup do: oauth_access(["write:notifications"]) -- cgit v1.2.3 From 5ba6e1c322c0937849eca53fc816f348659fb34c Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 20 May 2020 15:14:11 +0400 Subject: Move notification actions to PleromaAPI.NotificationController --- .../operations/pleroma_notification_operation.ex | 42 +++++++++++++++ .../web/api_spec/operations/pleroma_operation.ex | 42 --------------- .../controllers/notification_controller.ex | 36 +++++++++++++ .../controllers/pleroma_api_controller.ex | 46 ---------------- lib/pleroma/web/router.ex | 2 +- .../controllers/notification_controller_test.exs | 63 ++++++++++++++++++++++ .../controllers/pleroma_api_controller_test.exs | 63 ---------------------- 7 files changed, 142 insertions(+), 152 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex delete mode 100644 lib/pleroma/web/api_spec/operations/pleroma_operation.ex create mode 100644 lib/pleroma/web/pleroma_api/controllers/notification_controller.ex delete mode 100644 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex create mode 100644 test/web/pleroma_api/controllers/notification_controller_test.exs delete mode 100644 test/web/pleroma_api/controllers/pleroma_api_controller_test.exs diff --git a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex new file mode 100644 index 000000000..636c39a15 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaNotificationOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.NotificationOperation + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def mark_as_read_operation do + %Operation{ + tags: ["Notifications"], + summary: "Mark notifications as read. Query parameters are mutually exclusive.", + parameters: [ + Operation.parameter(:id, :query, :string, "A single notification ID to read"), + Operation.parameter(:max_id, :query, :string, "Read all notifications up to this id") + ], + security: [%{"oAuth" => ["write:notifications"]}], + operationId: "PleromaAPI.NotificationController.mark_as_read", + responses: %{ + 200 => + Operation.response( + "A Notification or array of Motifications", + "application/json", + %Schema{ + anyOf: [ + %Schema{type: :array, items: NotificationOperation.notification()}, + NotificationOperation.notification() + ] + } + ), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_operation.ex deleted file mode 100644 index d28451933..000000000 --- a/lib/pleroma/web/api_spec/operations/pleroma_operation.ex +++ /dev/null @@ -1,42 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.PleromaOperation do - alias OpenApiSpex.Operation - alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.NotificationOperation - alias Pleroma.Web.ApiSpec.Schemas.ApiError - - def open_api_operation(action) do - operation = String.to_existing_atom("#{action}_operation") - apply(__MODULE__, operation, []) - end - - def mark_notifications_as_read_operation do - %Operation{ - tags: ["Notifications"], - summary: "Mark notifications as read. Query parameters are mutually exclusive.", - parameters: [ - Operation.parameter(:id, :query, :string, "A single notification ID to read"), - Operation.parameter(:max_id, :query, :string, "Read all notifications up to this id") - ], - security: [%{"oAuth" => ["write:notifications"]}], - operationId: "PleromaController.mark_notifications_as_read", - responses: %{ - 200 => - Operation.response( - "A Notification or array of Motifications", - "application/json", - %Schema{ - anyOf: [ - %Schema{type: :array, items: NotificationOperation.notification()}, - NotificationOperation.notification() - ] - } - ), - 400 => Operation.response("Bad Request", "application/json", ApiError) - } - } - end -end diff --git a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex new file mode 100644 index 000000000..0b2f678c5 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.NotificationController do + use Pleroma.Web, :controller + + alias Pleroma.Notification + alias Pleroma.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :mark_as_read) + plug(:put_view, Pleroma.Web.MastodonAPI.NotificationView) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaNotificationOperation + + def mark_as_read(%{assigns: %{user: user}} = conn, %{id: notification_id}) do + with {:ok, notification} <- Notification.read_one(user, notification_id) do + render(conn, "show.json", notification: notification, for: user) + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + end + end + + def mark_as_read(%{assigns: %{user: user}} = conn, %{max_id: max_id}) do + notifications = + user + |> Notification.set_read_up_to(max_id) + |> Enum.take(80) + + render(conn, "index.json", notifications: notifications, for: user) + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex b/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex deleted file mode 100644 index a58665abe..000000000 --- a/lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex +++ /dev/null @@ -1,46 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do - use Pleroma.Web, :controller - - alias Pleroma.Notification - alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.Web.MastodonAPI.NotificationView - - plug(Pleroma.Web.ApiSpec.CastAndValidate) - - plug( - OAuthScopesPlug, - %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read - ) - - defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaOperation - - def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{id: notification_id}) do - with {:ok, notification} <- Notification.read_one(user, notification_id) do - conn - |> put_view(NotificationView) - |> render("show.json", %{notification: notification, for: user}) - else - {:error, message} -> - conn - |> put_status(:bad_request) - |> json(%{"error" => message}) - end - end - - def mark_notifications_as_read(%{assigns: %{user: user}} = conn, %{max_id: max_id}) do - with notifications <- Notification.set_read_up_to(user, max_id) do - notifications = Enum.take(notifications, 80) - - conn - |> put_view(NotificationView) - |> render("index.json", - notifications: notifications, - for: user - ) - end - end -end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 78da4a871..0e29e5645 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -312,7 +312,7 @@ defmodule Pleroma.Web.Router do put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create) delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete) - post("/notifications/read", PleromaAPIController, :mark_notifications_as_read) + post("/notifications/read", NotificationController, :mark_as_read) patch("/accounts/update_avatar", AccountController, :update_avatar) patch("/accounts/update_banner", AccountController, :update_banner) diff --git a/test/web/pleroma_api/controllers/notification_controller_test.exs b/test/web/pleroma_api/controllers/notification_controller_test.exs new file mode 100644 index 000000000..7c5ace804 --- /dev/null +++ b/test/web/pleroma_api/controllers/notification_controller_test.exs @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.NotificationControllerTest do + use Pleroma.Web.ConnCase + + alias Pleroma.Notification + alias Pleroma.Repo + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "POST /api/v1/pleroma/notifications/read" do + setup do: oauth_access(["write:notifications"]) + + test "it marks a single notification as read", %{user: user1, conn: conn} do + user2 = insert(:user) + {:ok, activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, [notification1]} = Notification.create_notifications(activity1) + {:ok, [notification2]} = Notification.create_notifications(activity2) + + response = + conn + |> post("/api/v1/pleroma/notifications/read?id=#{notification1.id}") + |> json_response_and_validate_schema(:ok) + + assert %{"pleroma" => %{"is_seen" => true}} = response + assert Repo.get(Notification, notification1.id).seen + refute Repo.get(Notification, notification2.id).seen + end + + test "it marks multiple notifications as read", %{user: user1, conn: conn} do + user2 = insert(:user) + {:ok, _activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, _activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) + {:ok, _activity3} = CommonAPI.post(user2, %{status: "HIE @#{user1.nickname}"}) + + [notification3, notification2, notification1] = Notification.for_user(user1, %{limit: 3}) + + [response1, response2] = + conn + |> post("/api/v1/pleroma/notifications/read?max_id=#{notification2.id}") + |> json_response_and_validate_schema(:ok) + + assert %{"pleroma" => %{"is_seen" => true}} = response1 + assert %{"pleroma" => %{"is_seen" => true}} = response2 + assert Repo.get(Notification, notification1.id).seen + assert Repo.get(Notification, notification2.id).seen + refute Repo.get(Notification, notification3.id).seen + end + + test "it returns error when notification not found", %{conn: conn} do + response = + conn + |> post("/api/v1/pleroma/notifications/read?id=22222222222222") + |> json_response_and_validate_schema(:bad_request) + + assert response == %{"error" => "Cannot get notification"} + end + end +end diff --git a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs b/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs deleted file mode 100644 index c4c661266..000000000 --- a/test/web/pleroma_api/controllers/pleroma_api_controller_test.exs +++ /dev/null @@ -1,63 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do - use Pleroma.Web.ConnCase - - alias Pleroma.Notification - alias Pleroma.Repo - alias Pleroma.Web.CommonAPI - - import Pleroma.Factory - - describe "POST /api/v1/pleroma/notifications/read" do - setup do: oauth_access(["write:notifications"]) - - test "it marks a single notification as read", %{user: user1, conn: conn} do - user2 = insert(:user) - {:ok, activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) - {:ok, activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) - {:ok, [notification1]} = Notification.create_notifications(activity1) - {:ok, [notification2]} = Notification.create_notifications(activity2) - - response = - conn - |> post("/api/v1/pleroma/notifications/read?id=#{notification1.id}") - |> json_response_and_validate_schema(:ok) - - assert %{"pleroma" => %{"is_seen" => true}} = response - assert Repo.get(Notification, notification1.id).seen - refute Repo.get(Notification, notification2.id).seen - end - - test "it marks multiple notifications as read", %{user: user1, conn: conn} do - user2 = insert(:user) - {:ok, _activity1} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) - {:ok, _activity2} = CommonAPI.post(user2, %{status: "hi @#{user1.nickname}"}) - {:ok, _activity3} = CommonAPI.post(user2, %{status: "HIE @#{user1.nickname}"}) - - [notification3, notification2, notification1] = Notification.for_user(user1, %{limit: 3}) - - [response1, response2] = - conn - |> post("/api/v1/pleroma/notifications/read?max_id=#{notification2.id}") - |> json_response_and_validate_schema(:ok) - - assert %{"pleroma" => %{"is_seen" => true}} = response1 - assert %{"pleroma" => %{"is_seen" => true}} = response2 - assert Repo.get(Notification, notification1.id).seen - assert Repo.get(Notification, notification2.id).seen - refute Repo.get(Notification, notification3.id).seen - end - - test "it returns error when notification not found", %{conn: conn} do - response = - conn - |> post("/api/v1/pleroma/notifications/read?id=22222222222222") - |> json_response_and_validate_schema(:bad_request) - - assert response == %{"error" => "Cannot get notification"} - end - end -end -- cgit v1.2.3 From 26f01744bcabf36aed01670254b3cc724758f7ca Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 20 May 2020 15:18:58 +0400 Subject: Add `background_image` to `InstanceOperation` --- lib/pleroma/web/api_spec/operations/instance_operation.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 9d189d029..d5c335d0c 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -125,7 +125,12 @@ defp instance do }, avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"}, background_upload_limit: %Schema{type: :integer, description: "The title of the website"}, - banner_upload_limit: %Schema{type: :integer, description: "The title of the website"} + banner_upload_limit: %Schema{type: :integer, description: "The title of the website"}, + background_image: %Schema{ + type: :string, + format: :uri, + description: "The background image for the website" + } }, example: %{ "avatar_upload_limit" => 2_000_000, -- cgit v1.2.3 From eb5f4285651c923aa3d776a2bc317c2a902031cc Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 20 May 2020 13:38:47 +0200 Subject: CommonAPI: Change public->private implicit addressing. This will not add the OP to the `to` field anymore when going from public to private. --- lib/pleroma/web/common_api/utils.ex | 3 ++- test/web/common_api/common_api_test.exs | 26 ++++++++++++++++++++++++++ test/web/common_api/common_api_utils_test.exs | 12 ++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index e8deee223..b9fa21648 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -102,7 +102,8 @@ def get_to_and_cc(user, mentioned_users, inReplyTo, "private", _) do end def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct", _) do - if inReplyTo do + # If the OP is a DM already, add the implicit actor. + if inReplyTo && Visibility.is_direct?(inReplyTo) do {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []} else {mentioned_users, []} diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 52e95397c..6014ffdac 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -335,6 +335,32 @@ test "it does not allow replies to direct messages that are not direct messages end) end + test "replying with a direct message will NOT auto-add the author of the reply to the recipient list" do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{status: "I'm stupid"}) + + {:ok, open_answer} = + CommonAPI.post(other_user, %{status: "No ur smart", in_reply_to_status_id: post.id}) + + # The OP is implicitly added + assert user.ap_id in open_answer.recipients + + {:ok, secret_answer} = + CommonAPI.post(other_user, %{ + status: "lol, that guy really is stupid, right, @#{third_user.nickname}?", + in_reply_to_status_id: post.id, + visibility: "direct" + }) + + assert third_user.ap_id in secret_answer.recipients + + # The OP is not added + refute user.ap_id in secret_answer.recipients + end + test "it allows to address a list" do user = insert(:user) {:ok, list} = Pleroma.List.create("foo", user) diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index 5708db6a4..d7d2d10d5 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -297,11 +297,10 @@ test "for private posts, a reply" do {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "private", nil) - assert length(to) == 3 + assert length(to) == 2 assert Enum.empty?(cc) assert mentioned_user.ap_id in to - assert third_user.ap_id in to assert user.follower_address in to end @@ -327,6 +326,15 @@ test "for direct posts, a reply" do {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "direct", nil) + assert length(to) == 1 + assert Enum.empty?(cc) + + assert mentioned_user.ap_id in to + + {:ok, direct_activity} = CommonAPI.post(third_user, %{status: "uguu", visibility: "direct"}) + + {to, cc} = Utils.get_to_and_cc(user, mentions, direct_activity, "direct", nil) + assert length(to) == 2 assert Enum.empty?(cc) -- cgit v1.2.3 From e42bc5f55732d42bf40ed9129ec737e654a911b8 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 20 May 2020 15:44:37 +0200 Subject: Announcements: Handle through common pipeline. --- lib/pleroma/web/activity_pub/activity_pub.ex | 30 ------------- lib/pleroma/web/activity_pub/builder.ex | 15 ++++++- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- .../object_validators/announce_validator.ex | 5 ++- lib/pleroma/web/activity_pub/side_effects.ex | 12 ++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 19 +------- lib/pleroma/web/common_api/common_api.ex | 23 +++++----- test/fixtures/mastodon-note-object.json | 50 +++++++++++++++++++--- test/web/activity_pub/side_effects_test.exs | 25 +++++++++++ .../transmogrifier/announce_handling_test.exs | 20 +++++++-- test/web/common_api/common_api_test.exs | 13 +++--- 11 files changed, 135 insertions(+), 79 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d752f4f04..2cea55285 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -356,36 +356,6 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do end end - @spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) :: - {:ok, Activity.t(), Object.t()} | {:error, any()} - def announce( - %User{ap_id: _} = user, - %Object{data: %{"id" => _}} = object, - activity_id \\ nil, - local \\ true, - public \\ true - ) do - with {:ok, result} <- - Repo.transaction(fn -> do_announce(user, object, activity_id, local, public) end) do - result - end - end - - defp do_announce(user, object, activity_id, local, public) do - with true <- is_announceable?(object, user, public), - object <- Object.get_by_id(object.id), - announce_data <- make_announce_data(user, object, activity_id, public), - {:ok, activity} <- insert(announce_data, local), - {:ok, object} <- add_announce_to_object(activity, object), - _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity, object} - else - false -> {:error, false} - {:error, error} -> Repo.rollback(error) - end - end - @spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | {:error, any()} def follow(follower, followed, activity_id \\ nil, local \\ true) do diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 63f89c2b4..7ece764f5 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + require Pleroma.Constants + @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} def emoji_react(actor, object, emoji) do with {:ok, data, meta} <- object_action(actor, object) do @@ -83,9 +85,17 @@ def like(actor, object) do end end - def announce(actor, object) do + def announce(actor, object, options \\ []) do + public? = Keyword.get(options, :public, false) to = [actor.follower_address, object.data["actor"]] + to = + if public? do + [Pleroma.Constants.as_public() | to] + else + to + end + {:ok, %{ "id" => Utils.generate_activity_id(), @@ -93,7 +103,8 @@ def announce(actor, object) do "object" => object.data["id"], "to" => to, "context" => object.data["context"], - "type" => "Announce" + "type" => "Announce", + "published" => Utils.make_date() }, []} end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 600e58123..2599067a8 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -88,7 +88,7 @@ def fetch_actor(object) do def fetch_actor_and_object(object) do fetch_actor(object) - Object.normalize(object["object"]) + Object.normalize(object["object"], true) :ok end end diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 158ae199d..082fdea4d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -18,9 +18,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do field(:type, :string) field(:object, Types.ObjectID) field(:actor, Types.ObjectID) - field(:context, :string) + field(:context, :string, autogenerate: {Utils, :generate_context_id, []}) field(:to, Types.Recipients, default: []) field(:cc, Types.Recipients, default: []) + field(:published, Types.DateTime) end def cast_and_validate(data) do @@ -47,7 +48,7 @@ def fix_after_cast(cng) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Announce"]) - |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) + |> validate_required([:id, :type, :object, :actor, :to, :cc]) |> validate_actor_presence() |> validate_object_presence() |> validate_existing_announce() diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index bfc2ab845..bc0d31c45 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -27,6 +27,18 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do {:ok, object, meta} end + # Tasks this handles: + # - Add announce to object + # - Set up notification + def handle(%{data: %{"type" => "Announce"}} = object, meta) do + announced_object = Object.get_by_ap_id(object.data["object"]) + Utils.add_announce_to_object(object, announced_object) + + Notification.create_notifications(object) + + {:ok, object, meta} + end + def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do with undone_object <- Activity.get_by_ap_id(undone_object), :ok <- handle_undoing(undone_object) do diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 6104af4f9..d594c64f4 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -662,7 +662,8 @@ def handle_incoming( |> handle_incoming(options) end - def handle_incoming(%{"type" => type} = data, _options) when type in ["Like", "EmojiReact"] do + def handle_incoming(%{"type" => type} = data, _options) + when type in ["Like", "EmojiReact", "Announce"] do with :ok <- ObjectValidator.fetch_actor_and_object(data), {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do @@ -672,22 +673,6 @@ def handle_incoming(%{"type" => type} = data, _options) when type in ["Like", "E end end - def handle_incoming( - %{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data, - _options - ) do - with actor <- Containment.get_actor(data), - {_, {:ok, %User{} = actor}} <- {:fetch_user, User.get_or_fetch_by_ap_id(actor)}, - {_, {:ok, object}} <- {:get_embedded, get_embedded_obj_helper(object_id, actor)}, - public <- Visibility.is_public?(data), - {_, {:ok, activity, _object}} <- - {:announce, ActivityPub.announce(actor, object, id, false, public)} do - {:ok, activity} - else - e -> {:error, e} - end - end - def handle_incoming( %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} = data, diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 447dbe4e6..dbb3d7ade 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -127,18 +127,19 @@ def delete(activity_id, user) do end def repeat(id, user, params \\ %{}) do - with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id) do - object = Object.normalize(activity) - announce_activity = Utils.get_existing_announce(user.ap_id, object) - public = public_announce?(object, params) - - if announce_activity do - {:ok, announce_activity, object} - else - ActivityPub.announce(user, object, nil, true, public) - end + with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), + object = %Object{} <- Object.normalize(activity, false), + {_, nil} <- {:existing_announce, Utils.get_existing_announce(user.ap_id, object)}, + public = public_announce?(object, params), + {:ok, announce, _} <- Builder.announce(user, object, public: public), + {:ok, activity, _} <- Pipeline.common_pipeline(announce, local: true) do + {:ok, activity} else - _ -> {:error, :not_found} + {:existing_announce, %Activity{} = announce} -> + {:ok, announce} + + _ -> + {:error, :not_found} end end diff --git a/test/fixtures/mastodon-note-object.json b/test/fixtures/mastodon-note-object.json index 75bed9625..d28c7fbe9 100644 --- a/test/fixtures/mastodon-note-object.json +++ b/test/fixtures/mastodon-note-object.json @@ -1,9 +1,45 @@ -{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"manuallyApprovesFollowers":"as:manuallyApprovesFollowers","sensitive":"as:sensitive","movedTo":"as:movedTo","Hashtag":"as:Hashtag","ostatus":"http://ostatus.org#","atomUri":"ostatus:atomUri","inReplyToAtomUri":"ostatus:inReplyToAtomUri","conversation":"ostatus:conversation","toot":"http://joinmastodon.org/ns#","Emoji":"toot:Emoji"}],"id":"http://mastodon.example.org/users/admin/statuses/99541947525187367","type":"Note","summary":null,"content":"\u003cp\u003eyeah.\u003c/p\u003e","inReplyTo":null,"published":"2018-02-17T17:46:20Z","url":"http://mastodon.example.org/@admin/99541947525187367","attributedTo":"http://mastodon.example.org/users/admin","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["http://mastodon.example.org/users/admin/followers"],"sensitive":false,"atomUri":"http://mastodon.example.org/users/admin/statuses/99541947525187367","inReplyToAtomUri":null,"conversation":"tag:mastodon.example.org,2018-02-17:objectId=59:objectType=Conversation","tag":[], - "attachment": [ +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", { - "url": "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", - "type": "Document", - "name": null, - "mediaType": "image/jpeg" + "Emoji" : "toot:Emoji", + "Hashtag" : "as:Hashtag", + "atomUri" : "ostatus:atomUri", + "conversation" : "ostatus:conversation", + "inReplyToAtomUri" : "ostatus:inReplyToAtomUri", + "manuallyApprovesFollowers" : "as:manuallyApprovesFollowers", + "movedTo" : "as:movedTo", + "ostatus" : "http://ostatus.org#", + "sensitive" : "as:sensitive", + "toot" : "http://joinmastodon.org/ns#" } - ]} + ], + "atomUri" : "http://mastodon.example.org/users/admin/statuses/99541947525187367", + "attachment" : [ + { + "mediaType" : "image/jpeg", + "name" : null, + "type" : "Document", + "url" : "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg" + } + ], + "attributedTo" : "http://mastodon.example.org/users/admin", + "cc" : [ + "http://mastodon.example.org/users/admin/followers" + ], + "content" : "

    yeah.

    ", + "conversation" : "tag:mastodon.example.org,2018-02-17:objectId=59:objectType=Conversation", + "id" : "http://mastodon.example.org/users/admin/statuses/99541947525187367", + "inReplyTo" : null, + "inReplyToAtomUri" : null, + "published" : "2018-02-17T17:46:20Z", + "sensitive" : false, + "summary" : null, + "tag" : [], + "to" : [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type" : "Note", + "url" : "http://mastodon.example.org/@admin/99541947525187367" +} diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index a46254a05..5dede3957 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -289,4 +289,29 @@ test "creates a notification", %{like: like, poster: poster} do assert Repo.get_by(Notification, user_id: poster.id, activity_id: like.id) end end + + describe "announce objects" do + setup do + poster = insert(:user) + user = insert(:user) + {:ok, post} = CommonAPI.post(poster, %{status: "hey"}) + + {:ok, announce_data, _meta} = Builder.announce(user, post.object) + {:ok, announce, _meta} = ActivityPub.persist(announce_data, local: true) + + %{announce: announce, user: user, poster: poster} + end + + test "add the announce to the original object", %{announce: announce, user: user} do + {:ok, announce, _} = SideEffects.handle(announce) + object = Object.get_by_ap_id(announce.data["object"]) + assert object.data["announcement_count"] == 1 + assert user.ap_id in object.data["announcements"] + end + + test "creates a notification", %{announce: announce, poster: poster} do + {:ok, announce, _} = SideEffects.handle(announce) + assert Repo.get_by(Notification, user_id: poster.id, activity_id: announce.id) + end + end end diff --git a/test/web/activity_pub/transmogrifier/announce_handling_test.exs b/test/web/activity_pub/transmogrifier/announce_handling_test.exs index 8a4af6546..50bcb307f 100644 --- a/test/web/activity_pub/transmogrifier/announce_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/announce_handling_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.AnnounceHandlingTest do import Pleroma.Factory test "it works for incoming honk announces" do - _user = insert(:user, ap_id: "https://honktest/u/test", local: false) + user = insert(:user, ap_id: "https://honktest/u/test", local: false) other_user = insert(:user) {:ok, post} = CommonAPI.post(other_user, %{status: "bonkeronk"}) @@ -28,6 +28,11 @@ test "it works for incoming honk announces" do } {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(announce) + + object = Object.get_by_ap_id(post.data["object"]) + + assert length(object.data["announcements"]) == 1 + assert user.ap_id in object.data["announcements"] end test "it works for incoming announces with actor being inlined (kroeg)" do @@ -48,8 +53,15 @@ test "it works for incoming announces with actor being inlined (kroeg)" do end test "it works for incoming announces, fetching the announced object" do - Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) - data = File.read!("test/fixtures/mastodon-announce.json") |> Poison.decode!() + data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", "http://mastodon.example.org/users/admin/statuses/99541947525187367") + + Tesla.Mock.mock(fn + %{method: :get} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/mastodon-note-object.json")} + end) _user = insert(:user, local: false, ap_id: data["actor"]) @@ -92,6 +104,8 @@ test "it works for incoming announces with an existing activity" do assert Activity.get_create_by_object_ap_id(data["object"]).id == activity.id end + # Ignore inlined activities for now + @tag skip: true test "it works for incoming announces with an inlined activity" do data = File.read!("test/fixtures/mastodon-announce-private.json") diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 52e95397c..e68a6a7d2 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -416,7 +416,8 @@ test "repeating a status" do {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) - {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, user) + {:ok, %Activity{} = announce_activity} = CommonAPI.repeat(activity.id, user) + assert Visibility.is_public?(announce_activity) end test "can't repeat a repeat" do @@ -424,9 +425,9 @@ test "can't repeat a repeat" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) - {:ok, %Activity{} = announce, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, %Activity{} = announce} = CommonAPI.repeat(activity.id, other_user) - refute match?({:ok, %Activity{}, _}, CommonAPI.repeat(announce.id, user)) + refute match?({:ok, %Activity{}}, CommonAPI.repeat(announce.id, user)) end test "repeating a status privately" do @@ -435,7 +436,7 @@ test "repeating a status privately" do {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) - {:ok, %Activity{} = announce_activity, _} = + {:ok, %Activity{} = announce_activity} = CommonAPI.repeat(activity.id, user, %{visibility: "private"}) assert Visibility.is_private?(announce_activity) @@ -458,8 +459,8 @@ test "retweeting a status twice returns the status" do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) - {:ok, %Activity{} = announce, object} = CommonAPI.repeat(activity.id, user) - {:ok, ^announce, ^object} = CommonAPI.repeat(activity.id, user) + {:ok, %Activity{} = announce} = CommonAPI.repeat(activity.id, user) + {:ok, ^announce} = CommonAPI.repeat(activity.id, user) end test "favoriting a status twice returns ok, but without the like activity" do -- cgit v1.2.3 From 39031f4860c91dee9418f69cc3b295cdfc9316bd Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 20 May 2020 16:36:55 +0200 Subject: Pipeline: Don't federate if federation is disabled. --- lib/pleroma/web/activity_pub/pipeline.ex | 3 ++- test/web/activity_pub/pipeline_test.exs | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 657cdfdb1..1d6bc2000 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.Federator + alias Pleroma.Config @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} @@ -44,7 +45,7 @@ defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do - do_not_federate = meta[:do_not_federate] + do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating]) if !do_not_federate && local do Federator.publish(activity) diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index f3c437498..26557720b 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -9,6 +9,11 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do import Pleroma.Factory describe "common_pipeline/2" do + setup do + clear_config([:instance, :federating], true) + :ok + end + test "it goes through validation, filtering, persisting, side effects and federation for local activities" do activity = insert(:note_activity) meta = [local: true] @@ -83,5 +88,44 @@ test "it goes through validation, filtering, persisting, side effects without fe assert_called(Pleroma.Web.ActivityPub.SideEffects.handle(activity, meta)) end end + + test "it goes through validation, filtering, persisting, side effects without federation for local activities if federation is deactivated" do + clear_config([:instance, :federating], false) + + 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, + [], + [] + } + ]) 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 -- cgit v1.2.3 From 4c48626585d2efae21625d106dac7e28b3227925 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 20 May 2020 12:22:31 -0500 Subject: Also add new sidebarRight setting --- config/config.exs | 1 + config/description.exs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/config/config.exs b/config/config.exs index 7b99a41aa..e9a727557 100644 --- a/config/config.exs +++ b/config/config.exs @@ -292,6 +292,7 @@ redirectRootLogin: "/main/friends", redirectRootNoLogin: "/main/all", scopeCopy: true, + sidebarRight: false, showFeaturesPanel: true, showInstanceSpecificPanel: false, subjectLineBehavior: "email", diff --git a/config/description.exs b/config/description.exs index 324cae8cf..716bcf4ff 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1117,6 +1117,7 @@ redirectRootLogin: "/main/friends", redirectRootNoLogin: "/main/all", scopeCopy: true, + sidebarRight: false, showFeaturesPanel: true, showInstanceSpecificPanel: false, subjectLineBehavior: "email", @@ -1256,6 +1257,12 @@ type: :boolean, description: "Copy the scope (private/unlisted/public) in replies to posts by default" }, + %{ + key: :sidebarRight, + label: "Sidebar on Right", + type: :boolean, + description: "Change alignment of sidebar and panels to the right." + }, %{ key: :showFeaturesPanel, label: "Show instance features panel", -- cgit v1.2.3 From c96f425cb0fbac04b2ad5be2cff3805903bbd9b9 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 20 May 2020 21:16:40 +0300 Subject: fixed `mix pleroma.instance gen` --- lib/mix/tasks/pleroma/instance.ex | 8 ++++---- test/tasks/instance_test.exs | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index bc842a59f..86409738a 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -147,6 +147,7 @@ def run(["gen" | rest]) do "What directory should media uploads go in (when using the local uploader)?", Pleroma.Config.get([Pleroma.Uploaders.Local, :uploads]) ) + |> Path.expand() static_dir = get_option( @@ -155,6 +156,7 @@ def run(["gen" | rest]) do "What directory should custom public files be read from (custom emojis, frontend bundle overrides, robots.txt, etc.)?", Pleroma.Config.get([:instance, :static_dir]) ) + |> Path.expand() Config.put([:instance, :static_dir], static_dir) @@ -204,7 +206,7 @@ def run(["gen" | rest]) do shell_info("Writing the postgres script to #{psql_path}.") File.write(psql_path, result_psql) - write_robots_txt(indexable, template_dir) + write_robots_txt(static_dir, indexable, template_dir) shell_info( "\n All files successfully written! Refer to the installation instructions for your platform for next steps." @@ -224,15 +226,13 @@ def run(["gen" | rest]) do end end - defp write_robots_txt(indexable, template_dir) do + defp write_robots_txt(static_dir, indexable, template_dir) do robots_txt = EEx.eval_file( template_dir <> "/robots_txt.eex", indexable: indexable ) - static_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static/") - unless File.exists?(static_dir) do File.mkdir_p!(static_dir) end diff --git a/test/tasks/instance_test.exs b/test/tasks/instance_test.exs index f6a4ba508..3b4c041d9 100644 --- a/test/tasks/instance_test.exs +++ b/test/tasks/instance_test.exs @@ -63,7 +63,7 @@ test "running gen" do "--uploads-dir", "test/uploads", "--static-dir", - "instance/static/" + "./test/../test/instance/static/" ]) end @@ -83,6 +83,7 @@ test "running gen" do assert generated_config =~ "configurable_from_database: true" assert generated_config =~ "http: [ip: {127, 0, 0, 1}, port: 4000]" assert File.read!(tmp_path() <> "setup.psql") == generated_setup_psql() + assert File.exists?(Path.expand("./test/instance/static/robots.txt")) end defp generated_setup_psql do -- cgit v1.2.3 From b7fc61e17b995e3aa4e52f85b91d320a1cd1e106 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Sun, 12 Apr 2020 22:54:43 +0300 Subject: Added the ability to upload background, logo, default user avatar, instance thumbnail, and the NSFW hiding image via AdminFE --- config/description.exs | 17 +++++++++++++++-- lib/pleroma/emails/new_users_digest_email.ex | 6 ++++-- lib/pleroma/helpers/uri_helper.ex | 3 +++ lib/pleroma/user.ex | 9 +++++++-- lib/pleroma/web/mastodon_api/views/instance_view.ex | 7 ++++++- test/user_test.exs | 12 ++++++++++++ test/workers/cron/new_users_digest_worker_test.exs | 1 + 7 files changed, 48 insertions(+), 7 deletions(-) diff --git a/config/description.exs b/config/description.exs index 716bcf4ff..cf7cc297a 100644 --- a/config/description.exs +++ b/config/description.exs @@ -969,6 +969,13 @@ ] } ] + }, + %{ + key: :instance_thumbnail, + type: :string, + description: + "The instance thumbnail image. It will appear in [Pleroma Instances](http://distsn.org/pleroma-instances.html)", + suggestions: ["/instance/thumbnail.jpeg"] } ] }, @@ -1112,7 +1119,7 @@ logoMask: true, minimalScopesMode: false, noAttachmentLinks: false, - nsfwCensorImage: "", + nsfwCensorImage: "/static/img/nsfw.74818f9.png", postContentType: "text/plain", redirectRootLogin: "/main/friends", redirectRootNoLogin: "/main/all", @@ -1226,7 +1233,7 @@ type: :string, description: "URL of the image to use for hiding NSFW media attachments in the timeline.", - suggestions: ["/static/img/nsfw.png"] + suggestions: ["/static/img/nsfw.74818f9.png"] }, %{ key: :postContentType, @@ -1346,6 +1353,12 @@ suggestions: [ :pleroma_fox_tan ] + }, + %{ + key: :default_user_avatar, + type: :string, + description: "URL of the default user avatar.", + suggestions: ["/images/avi.png"] } ] }, diff --git a/lib/pleroma/emails/new_users_digest_email.ex b/lib/pleroma/emails/new_users_digest_email.ex index 7d16b807f..348cbac9c 100644 --- a/lib/pleroma/emails/new_users_digest_email.ex +++ b/lib/pleroma/emails/new_users_digest_email.ex @@ -14,8 +14,10 @@ def new_users(to, users_and_statuses) do styling = Pleroma.Config.get([Pleroma.Emails.UserEmail, :styling]) logo_url = - Pleroma.Web.Endpoint.url() <> - Pleroma.Config.get([:frontend_configurations, :pleroma_fe, :logo]) + Pleroma.Helpers.UriHelper.maybe_add_base( + Pleroma.Config.get([:frontend_configurations, :pleroma_fe, :logo]), + Pleroma.Web.Endpoint.url() + ) new() |> to({to.name, to.email}) diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex index 256252ddb..69d8c8fe0 100644 --- a/lib/pleroma/helpers/uri_helper.ex +++ b/lib/pleroma/helpers/uri_helper.ex @@ -24,4 +24,7 @@ def append_param_if_present(%{} = params, param_name, param_value) do params end end + + def maybe_add_base("/" <> uri, base), do: Path.join([base, uri]) + def maybe_add_base(uri, _base), do: uri end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index e8013bf40..eb9533d78 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -305,8 +305,13 @@ def invisible?(_), do: false def avatar_url(user, options \\ []) do case user.avatar do - %{"url" => [%{"href" => href} | _]} -> href - _ -> !options[:no_default] && "#{Web.base_url()}/images/avi.png" + %{"url" => [%{"href" => href} | _]} -> + href + + _ -> + unless options[:no_default] do + Config.get([:assets, :default_user_avatar], "#{Web.base_url()}/images/avi.png") + end end end diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 8088306c3..6a630eafa 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -23,7 +23,7 @@ def render("show.json", _) do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: Pleroma.Web.base_url() <> "/instance/thumbnail.jpeg", + thumbnail: instance_thumbnail(), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), # Extra (not present in Mastodon): @@ -87,4 +87,9 @@ def federation do end |> Map.put(:enabled, Config.get([:instance, :federating])) end + + defp instance_thumbnail do + Pleroma.Config.get([:instance, :instance_thumbnail]) || + "#{Pleroma.Web.base_url()}/instance/thumbnail.jpeg" + end end diff --git a/test/user_test.exs b/test/user_test.exs index 863e0106c..ea192ad10 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1777,4 +1777,16 @@ test "Notifications are updated", %{user: user} do assert result.email_notifications["digest"] == false end end + + test "avatar fallback" do + user = insert(:user) + assert User.avatar_url(user) =~ "/images/avi.png" + + Pleroma.Config.put([:assets, :default_user_avatar], "avatar.png") + + user = User.get_cached_by_nickname_or_id(user.nickname) + assert User.avatar_url(user) =~ "avatar.png" + + assert User.avatar_url(user, no_default: true) == nil + end end diff --git a/test/workers/cron/new_users_digest_worker_test.exs b/test/workers/cron/new_users_digest_worker_test.exs index 54cf0ca46..ee589bb55 100644 --- a/test/workers/cron/new_users_digest_worker_test.exs +++ b/test/workers/cron/new_users_digest_worker_test.exs @@ -28,6 +28,7 @@ test "it sends new users digest emails" do assert email.html_body =~ user.nickname assert email.html_body =~ user2.nickname assert email.html_body =~ "cofe" + assert email.html_body =~ "#{Pleroma.Web.Endpoint.url()}/static/logo.png" end test "it doesn't fail when admin has no email" do -- cgit v1.2.3 From 9bc5e18adeef2c68c5fae2435ed01555f1b29c93 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 21 May 2020 08:06:57 +0300 Subject: rename mix task: `pleroma.user unsubscribe` -> `pleroma.user deactivate` --- docs/administration/CLI_tasks/user.md | 19 +++++++++---------- lib/mix/tasks/pleroma/user.ex | 6 +++--- test/tasks/user_test.exs | 8 ++++---- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index f535dad82..797641898 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -95,33 +95,33 @@ mix pleroma.user sign_out ``` -## Deactivate or activate a user +## Deactivate or activate a user ```sh tab="OTP" - ./bin/pleroma_ctl user toggle_activated + ./bin/pleroma_ctl user toggle_activated ``` ```sh tab="From Source" -mix pleroma.user toggle_activated +mix pleroma.user toggle_activated ``` -## Unsubscribe local users from a user and deactivate the user +## Deactivate a user and unsubscribes local users from the user ```sh tab="OTP" - ./bin/pleroma_ctl user unsubscribe NICKNAME + ./bin/pleroma_ctl user deactivate NICKNAME ``` ```sh tab="From Source" -mix pleroma.user unsubscribe NICKNAME +mix pleroma.user deactivate NICKNAME ``` -## Unsubscribe local users from an instance and deactivate all accounts on it +## Deactivate all accounts from an instance and unsubscribe local users on it ```sh tab="OTP" - ./bin/pleroma_ctl user unsubscribe_all_from_instance + ./bin/pleroma_ctl user deacitivate_all_from_instance ``` ```sh tab="From Source" -mix pleroma.user unsubscribe_all_from_instance +mix pleroma.user deactivate_all_from_instance ``` @@ -177,4 +177,3 @@ mix pleroma.user untag ```sh tab="From Source" mix pleroma.user toggle_confirmed ``` - diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 93ecb4631..3635c02bc 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -144,7 +144,7 @@ def run(["reset_password", nickname]) do end end - def run(["unsubscribe", nickname]) do + def run(["deactivate", nickname]) do start_pleroma() with %User{} = user <- User.get_cached_by_nickname(nickname) do @@ -163,7 +163,7 @@ def run(["unsubscribe", nickname]) do end end - def run(["unsubscribe_all_from_instance", instance]) do + def run(["deactivate_all_from_instance", instance]) do start_pleroma() Pleroma.User.Query.build(%{nickname: "@#{instance}"}) @@ -171,7 +171,7 @@ def run(["unsubscribe_all_from_instance", instance]) do |> Stream.each(fn users -> users |> Enum.each(fn user -> - run(["unsubscribe", user.nickname]) + run(["deactivate", user.nickname]) end) end) |> Stream.run() diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 4b3ab5a87..ab7637511 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -169,7 +169,7 @@ test "no user to toggle" do end end - describe "running unsubscribe" do + describe "running deactivate" do test "user is unsubscribed" do followed = insert(:user) remote_followed = insert(:user, local: false) @@ -178,7 +178,7 @@ test "user is unsubscribed" do User.follow(user, followed, :follow_accept) User.follow(user, remote_followed, :follow_accept) - Mix.Tasks.Pleroma.User.run(["unsubscribe", user.nickname]) + Mix.Tasks.Pleroma.User.run(["deactivate", user.nickname]) assert_received {:mix_shell, :info, [message]} assert message =~ "Deactivating" @@ -192,8 +192,8 @@ test "user is unsubscribed" do assert user.deactivated end - test "no user to unsubscribe" do - Mix.Tasks.Pleroma.User.run(["unsubscribe", "nonexistent"]) + test "no user to deactivate" do + Mix.Tasks.Pleroma.User.run(["deactivate", "nonexistent"]) assert_received {:mix_shell, :error, [message]} assert message =~ "No user" -- cgit v1.2.3 From 9de9760aa696657400c762d46dced273c3475be4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 20 May 2020 18:00:41 +0400 Subject: Move status actions to AdminAPI.StatusController --- lib/pleroma/web/admin_api/admin_api_controller.ex | 1207 ------ .../admin_api/controllers/admin_api_controller.ex | 1103 ++++++ .../admin_api/controllers/fallback_controller.ex | 31 + .../web/admin_api/controllers/status_controller.ex | 112 + lib/pleroma/web/router.ex | 8 +- test/web/admin_api/admin_api_controller_test.exs | 3864 -------------------- .../controllers/admin_api_controller_test.exs | 3707 +++++++++++++++++++ .../controllers/status_controller_test.exs | 185 + 8 files changed, 5142 insertions(+), 5075 deletions(-) delete mode 100644 lib/pleroma/web/admin_api/admin_api_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/fallback_controller.ex create mode 100644 lib/pleroma/web/admin_api/controllers/status_controller.ex delete mode 100644 test/web/admin_api/admin_api_controller_test.exs create mode 100644 test/web/admin_api/controllers/admin_api_controller_test.exs create mode 100644 test/web/admin_api/controllers/status_controller_test.exs diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex deleted file mode 100644 index 647ceb3ba..000000000 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ /dev/null @@ -1,1207 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.AdminAPI.AdminAPIController do - use Pleroma.Web, :controller - - import Pleroma.Web.ControllerHelper, only: [json_response: 3] - - alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.ConfigDB - alias Pleroma.MFA - alias Pleroma.ModerationLog - alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.ReportNote - alias Pleroma.Stats - alias Pleroma.User - alias Pleroma.UserInviteToken - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.Pipeline - alias Pleroma.Web.ActivityPub.Relay - alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.AdminAPI - alias Pleroma.Web.AdminAPI.AccountView - alias Pleroma.Web.AdminAPI.ConfigView - alias Pleroma.Web.AdminAPI.ModerationLogView - alias Pleroma.Web.AdminAPI.Report - alias Pleroma.Web.AdminAPI.ReportView - alias Pleroma.Web.AdminAPI.Search - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.Endpoint - alias Pleroma.Web.MastodonAPI - alias Pleroma.Web.MastodonAPI.AppView - alias Pleroma.Web.OAuth.App - alias Pleroma.Web.Router - - require Logger - - @descriptions Pleroma.Docs.JSON.compile() - @users_page_size 50 - - plug( - OAuthScopesPlug, - %{scopes: ["read:accounts"], admin: true} - when action in [:list_users, :user_show, :right_get, :show_user_credentials] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:accounts"], admin: true} - when action in [ - :get_password_reset, - :force_password_reset, - :user_delete, - :users_create, - :user_toggle_activation, - :user_activate, - :user_deactivate, - :tag_users, - :untag_users, - :right_add, - :right_add_multiple, - :right_delete, - :disable_mfa, - :right_delete_multiple, - :update_user_credentials - ] - ) - - plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :invites) - - plug( - OAuthScopesPlug, - %{scopes: ["write:invites"], admin: true} - when action in [:create_invite_token, :revoke_invite, :email_invite] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:follows"], admin: true} - when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["read:reports"], admin: true} - when action in [:list_reports, :report_show] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:reports"], admin: true} - when action in [:reports_update, :report_notes_create, :report_notes_delete] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["read:statuses"], admin: true} - when action in [:list_statuses, :list_user_statuses, :list_instance_statuses, :status_show] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:statuses"], admin: true} - when action in [:status_update, :status_delete] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["read"], admin: true} - when action in [ - :config_show, - :list_log, - :stats, - :relay_list, - :config_descriptions, - :need_reboot - ] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write"], admin: true} - when action in [ - :restart, - :config_update, - :resend_confirmation_email, - :confirm_email, - :oauth_app_create, - :oauth_app_list, - :oauth_app_update, - :oauth_app_delete, - :reload_emoji - ] - ) - - action_fallback(:errors) - - def user_delete(conn, %{"nickname" => nickname}) do - user_delete(conn, %{"nicknames" => [nickname]}) - end - - def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = - nicknames - |> Enum.map(&User.get_cached_by_nickname/1) - - users - |> Enum.each(fn user -> - {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) - Pipeline.common_pipeline(delete_data, local: true) - end) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "delete" - }) - - conn - |> json(nicknames) - end - - def user_follow(%{assigns: %{user: admin}} = conn, %{ - "follower" => follower_nick, - "followed" => followed_nick - }) do - with %User{} = follower <- User.get_cached_by_nickname(follower_nick), - %User{} = followed <- User.get_cached_by_nickname(followed_nick) do - User.follow(follower, followed) - - ModerationLog.insert_log(%{ - actor: admin, - followed: followed, - follower: follower, - action: "follow" - }) - end - - conn - |> json("ok") - end - - def user_unfollow(%{assigns: %{user: admin}} = conn, %{ - "follower" => follower_nick, - "followed" => followed_nick - }) do - with %User{} = follower <- User.get_cached_by_nickname(follower_nick), - %User{} = followed <- User.get_cached_by_nickname(followed_nick) do - User.unfollow(follower, followed) - - ModerationLog.insert_log(%{ - actor: admin, - followed: followed, - follower: follower, - action: "unfollow" - }) - end - - conn - |> json("ok") - end - - def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do - changesets = - Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} -> - user_data = %{ - nickname: nickname, - name: nickname, - email: email, - password: password, - password_confirmation: password, - bio: "." - } - - User.register_changeset(%User{}, user_data, need_confirmation: false) - end) - |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> - Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) - end) - - case Pleroma.Repo.transaction(changesets) do - {:ok, users} -> - res = - users - |> Map.values() - |> Enum.map(fn user -> - {:ok, user} = User.post_register_action(user) - - user - end) - |> Enum.map(&AccountView.render("created.json", %{user: &1})) - - ModerationLog.insert_log(%{ - actor: admin, - subjects: Map.values(users), - action: "create" - }) - - conn - |> json(res) - - {:error, id, changeset, _} -> - res = - Enum.map(changesets.operations, fn - {current_id, {:changeset, _current_changeset, _}} when current_id == id -> - AccountView.render("create-error.json", %{changeset: changeset}) - - {_, {:changeset, current_changeset, _}} -> - AccountView.render("create-error.json", %{changeset: current_changeset}) - end) - - conn - |> put_status(:conflict) - |> json(res) - end - end - - def user_show(conn, %{"nickname" => nickname}) do - with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do - conn - |> put_view(AccountView) - |> render("show.json", %{user: user}) - else - _ -> {:error, :not_found} - end - end - - def list_instance_statuses(conn, %{"instance" => instance} = params) do - with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true - {page, page_size} = page_params(params) - - activities = - ActivityPub.fetch_statuses(nil, %{ - "instance" => instance, - "limit" => page_size, - "offset" => (page - 1) * page_size, - "exclude_reblogs" => !with_reblogs && "true" - }) - - conn - |> put_view(AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity}) - end - - def list_user_statuses(conn, %{"nickname" => nickname} = params) do - with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true - godmode = params["godmode"] == "true" || params["godmode"] == true - - with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do - {_, page_size} = page_params(params) - - activities = - ActivityPub.fetch_user_activities(user, nil, %{ - "limit" => page_size, - "godmode" => godmode, - "exclude_reblogs" => !with_reblogs && "true" - }) - - conn - |> put_view(MastodonAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity}) - else - _ -> {:error, :not_found} - end - end - - def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do - user = User.get_cached_by_nickname(nickname) - - {:ok, updated_user} = User.deactivate(user, !user.deactivated) - - action = if user.deactivated, do: "activate", else: "deactivate" - - ModerationLog.insert_log(%{ - actor: admin, - subject: [user], - action: action - }) - - conn - |> put_view(AccountView) - |> render("show.json", %{user: updated_user}) - end - - def user_activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - {:ok, updated_users} = User.deactivate(users, false) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "activate" - }) - - conn - |> put_view(AccountView) - |> render("index.json", %{users: Keyword.values(updated_users)}) - end - - def user_deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = Enum.map(nicknames, &User.get_cached_by_nickname/1) - {:ok, updated_users} = User.deactivate(users, true) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "deactivate" - }) - - conn - |> put_view(AccountView) - |> render("index.json", %{users: Keyword.values(updated_users)}) - end - - def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do - with {:ok, _} <- User.tag(nicknames, tags) do - ModerationLog.insert_log(%{ - actor: admin, - nicknames: nicknames, - tags: tags, - action: "tag" - }) - - json_response(conn, :no_content, "") - end - end - - def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do - with {:ok, _} <- User.untag(nicknames, tags) do - ModerationLog.insert_log(%{ - actor: admin, - nicknames: nicknames, - tags: tags, - action: "untag" - }) - - json_response(conn, :no_content, "") - end - end - - def list_users(conn, params) do - {page, page_size} = page_params(params) - filters = maybe_parse_filters(params["filters"]) - - search_params = %{ - query: params["query"], - page: page, - page_size: page_size, - tags: params["tags"], - name: params["name"], - email: params["email"] - } - - with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do - json( - conn, - AccountView.render("index.json", users: users, count: count, page_size: page_size) - ) - end - end - - @filters ~w(local external active deactivated is_admin is_moderator) - - @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} - defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} - - defp maybe_parse_filters(filters) do - filters - |> String.split(",") - |> Enum.filter(&Enum.member?(@filters, &1)) - |> Enum.map(&String.to_atom(&1)) - |> Enum.into(%{}, &{&1, true}) - end - - def right_add_multiple(%{assigns: %{user: admin}} = conn, %{ - "permission_group" => permission_group, - "nicknames" => nicknames - }) - when permission_group in ["moderator", "admin"] do - update = %{:"is_#{permission_group}" => true} - - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - - for u <- users, do: User.admin_api_update(u, update) - - ModerationLog.insert_log(%{ - action: "grant", - actor: admin, - subject: users, - permission: permission_group - }) - - json(conn, update) - end - - def right_add_multiple(conn, _) do - render_error(conn, :not_found, "No such permission_group") - end - - def right_add(%{assigns: %{user: admin}} = conn, %{ - "permission_group" => permission_group, - "nickname" => nickname - }) - when permission_group in ["moderator", "admin"] do - fields = %{:"is_#{permission_group}" => true} - - {:ok, user} = - nickname - |> User.get_cached_by_nickname() - |> User.admin_api_update(fields) - - ModerationLog.insert_log(%{ - action: "grant", - actor: admin, - subject: [user], - permission: permission_group - }) - - json(conn, fields) - end - - def right_add(conn, _) do - render_error(conn, :not_found, "No such permission_group") - end - - def right_get(conn, %{"nickname" => nickname}) do - user = User.get_cached_by_nickname(nickname) - - conn - |> json(%{ - is_moderator: user.is_moderator, - is_admin: user.is_admin - }) - end - - def right_delete_multiple( - %{assigns: %{user: %{nickname: admin_nickname} = admin}} = conn, - %{ - "permission_group" => permission_group, - "nicknames" => nicknames - } - ) - when permission_group in ["moderator", "admin"] do - with false <- Enum.member?(nicknames, admin_nickname) do - update = %{:"is_#{permission_group}" => false} - - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - - for u <- users, do: User.admin_api_update(u, update) - - ModerationLog.insert_log(%{ - action: "revoke", - actor: admin, - subject: users, - permission: permission_group - }) - - json(conn, update) - else - _ -> render_error(conn, :forbidden, "You can't revoke your own admin/moderator status.") - end - end - - def right_delete_multiple(conn, _) do - render_error(conn, :not_found, "No such permission_group") - end - - def right_delete( - %{assigns: %{user: admin}} = conn, - %{ - "permission_group" => permission_group, - "nickname" => nickname - } - ) - when permission_group in ["moderator", "admin"] do - fields = %{:"is_#{permission_group}" => false} - - {:ok, user} = - nickname - |> User.get_cached_by_nickname() - |> User.admin_api_update(fields) - - ModerationLog.insert_log(%{ - action: "revoke", - actor: admin, - subject: [user], - permission: permission_group - }) - - json(conn, fields) - end - - def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do - render_error(conn, :forbidden, "You can't revoke your own admin status.") - end - - def relay_list(conn, _params) do - with {:ok, list} <- Relay.list() do - json(conn, %{relays: list}) - else - _ -> - conn - |> put_status(500) - end - end - - def relay_follow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do - with {:ok, _message} <- Relay.follow(target) do - ModerationLog.insert_log(%{ - action: "relay_follow", - actor: admin, - target: target - }) - - json(conn, target) - else - _ -> - conn - |> put_status(500) - |> json(target) - end - end - - def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do - with {:ok, _message} <- Relay.unfollow(target) do - ModerationLog.insert_log(%{ - action: "relay_unfollow", - actor: admin, - target: target - }) - - json(conn, target) - else - _ -> - conn - |> put_status(500) - |> json(target) - end - end - - @doc "Sends registration invite via email" - def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do - 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( - user, - invite_token, - email, - params["name"] - ), - {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do - json_response(conn, :no_content, "") - else - {:registrations_open, _} -> - errors( - conn, - {:error, "To send invites you need to set the `registrations_open` option to false."} - ) - - {:invites_enabled, _} -> - errors( - conn, - {:error, "To send invites you need to set the `invites_enabled` option to true."} - ) - end - end - - @doc "Create an account registration invite token" - def create_invite_token(conn, params) do - opts = %{} - - opts = - if params["max_use"], - do: Map.put(opts, :max_use, params["max_use"]), - else: opts - - opts = - if params["expires_at"], - do: Map.put(opts, :expires_at, params["expires_at"]), - else: opts - - {:ok, invite} = UserInviteToken.create_invite(opts) - - json(conn, AccountView.render("invite.json", %{invite: invite})) - end - - @doc "Get list of created invites" - def invites(conn, _params) do - invites = UserInviteToken.list_invites() - - conn - |> put_view(AccountView) - |> render("invites.json", %{invites: invites}) - end - - @doc "Revokes invite by token" - def revoke_invite(conn, %{"token" => token}) do - with {:ok, invite} <- UserInviteToken.find_by_token(token), - {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do - conn - |> put_view(AccountView) - |> render("invite.json", %{invite: updated_invite}) - else - nil -> {:error, :not_found} - end - end - - @doc "Get a password reset token (base64 string) for given nickname" - def get_password_reset(conn, %{"nickname" => nickname}) do - (%User{local: true} = user) = User.get_cached_by_nickname(nickname) - {:ok, token} = Pleroma.PasswordResetToken.create_token(user) - - conn - |> json(%{ - token: token.token, - link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) - }) - end - - @doc "Force password reset for a given user" - def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - - Enum.each(users, &User.force_password_reset_async/1) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "force_password_reset" - }) - - json_response(conn, :no_content, "") - end - - @doc "Disable mfa for user's account." - def disable_mfa(conn, %{"nickname" => nickname}) do - case User.get_by_nickname(nickname) do - %User{} = user -> - MFA.disable(user) - json(conn, nickname) - - _ -> - {:error, :not_found} - end - end - - @doc "Show a given user's credentials" - def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do - with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do - 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.update_as_admin(user, params) do - ModerationLog.insert_log(%{ - actor: admin, - subject: [user], - action: "updated_users" - }) - - if params["password"] do - User.force_password_reset_async(user) - end - - 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) - - reports = Utils.get_reports(params, page, page_size) - - conn - |> put_view(ReportView) - |> render("index.json", %{reports: reports}) - end - - def report_show(conn, %{"id" => id}) do - with %Activity{} = report <- Activity.get_by_id(id) do - conn - |> put_view(ReportView) - |> render("show.json", Report.extract_report_info(report)) - else - _ -> {:error, :not_found} - end - end - - def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do - result = - reports - |> Enum.map(fn report -> - with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do - ModerationLog.insert_log(%{ - action: "report_update", - actor: admin, - subject: activity - }) - - activity - else - {:error, message} -> %{id: report["id"], error: message} - end - end) - - case Enum.any?(result, &Map.has_key?(&1, :error)) do - true -> json_response(conn, :bad_request, result) - false -> json_response(conn, :no_content, "") - end - end - - def report_notes_create(%{assigns: %{user: user}} = conn, %{ - "id" => report_id, - "content" => content - }) do - with {:ok, _} <- ReportNote.create(user.id, report_id, content) do - ModerationLog.insert_log(%{ - action: "report_note", - actor: user, - subject: Activity.get_by_id(report_id), - text: content - }) - - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - - def report_notes_delete(%{assigns: %{user: user}} = conn, %{ - "id" => note_id, - "report_id" => report_id - }) do - with {:ok, note} <- ReportNote.destroy(note_id) do - ModerationLog.insert_log(%{ - action: "report_note_delete", - actor: user, - subject: Activity.get_by_id(report_id), - text: note.content - }) - - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - - def list_statuses(%{assigns: %{user: _admin}} = conn, params) do - godmode = params["godmode"] == "true" || params["godmode"] == true - local_only = params["local_only"] == "true" || params["local_only"] == true - with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true - {page, page_size} = page_params(params) - - activities = - ActivityPub.fetch_statuses(nil, %{ - "godmode" => godmode, - "local_only" => local_only, - "limit" => page_size, - "offset" => (page - 1) * page_size, - "exclude_reblogs" => !with_reblogs && "true" - }) - - conn - |> put_view(AdminAPI.StatusView) - |> render("index.json", %{activities: activities, as: :activity}) - end - - def status_show(conn, %{"id" => id}) do - with %Activity{} = activity <- Activity.get_by_id(id) do - conn - |> put_view(MastodonAPI.StatusView) - |> render("show.json", %{activity: activity}) - else - _ -> errors(conn, {:error, :not_found}) - end - end - - def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do - params = - params - |> Map.take(["sensitive", "visibility"]) - |> Map.new(fn {key, value} -> {String.to_existing_atom(key), value} end) - - with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do - {:ok, sensitive} = Ecto.Type.cast(:boolean, params[:sensitive]) - - ModerationLog.insert_log(%{ - action: "status_update", - actor: admin, - subject: activity, - sensitive: sensitive, - visibility: params[:visibility] - }) - - conn - |> put_view(MastodonAPI.StatusView) - |> render("show.json", %{activity: activity}) - end - end - - def status_delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do - with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do - ModerationLog.insert_log(%{ - action: "status_delete", - actor: user, - subject_id: id - }) - - json(conn, %{}) - end - end - - def list_log(conn, params) do - {page, page_size} = page_params(params) - - log = - ModerationLog.get_all(%{ - page: page, - page_size: page_size, - start_date: params["start_date"], - end_date: params["end_date"], - user_id: params["user_id"], - search: params["search"] - }) - - conn - |> put_view(ModerationLogView) - |> render("index.json", %{log: log}) - end - - def config_descriptions(conn, _params) do - descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) - - json(conn, descriptions) - end - - def config_show(conn, %{"only_db" => true}) do - with :ok <- configurable_from_database(conn) do - configs = Pleroma.Repo.all(ConfigDB) - - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: configs}) - end - end - - def config_show(conn, _params) do - with :ok <- configurable_from_database(conn) do - configs = ConfigDB.get_all_as_keyword() - - merged = - Config.Holder.default_config() - |> ConfigDB.merge(configs) - |> Enum.map(fn {group, values} -> - Enum.map(values, fn {key, value} -> - db = - if configs[group][key] do - ConfigDB.get_db_keys(configs[group][key], key) - end - - db_value = configs[group][key] - - merged_value = - if !is_nil(db_value) and Keyword.keyword?(db_value) and - ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do - ConfigDB.merge_group(group, key, value, db_value) - else - value - end - - setting = %{ - group: ConfigDB.convert(group), - key: ConfigDB.convert(key), - value: ConfigDB.convert(merged_value) - } - - if db, do: Map.put(setting, :db, db), else: setting - end) - end) - |> List.flatten() - - json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) - end - end - - def config_update(conn, %{"configs" => configs}) do - with :ok <- configurable_from_database(conn) do - {_errors, results} = - configs - |> Enum.filter(&whitelisted_config?/1) - |> Enum.map(fn - %{"group" => group, "key" => key, "delete" => true} = params -> - ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) - - %{"group" => group, "key" => key, "value" => value} -> - ConfigDB.update_or_create(%{group: group, key: key, value: value}) - end) - |> Enum.split_with(fn result -> elem(result, 0) == :error end) - - {deleted, updated} = - results - |> Enum.map(fn {:ok, config} -> - Map.put(config, :db, ConfigDB.get_db_keys(config)) - end) - |> Enum.split_with(fn config -> - Ecto.get_meta(config, :state) == :deleted - end) - - Config.TransferTask.load_and_update_env(deleted, false) - - if !Restarter.Pleroma.need_reboot?() do - changed_reboot_settings? = - (updated ++ deleted) - |> Enum.any?(fn config -> - group = ConfigDB.from_string(config.group) - key = ConfigDB.from_string(config.key) - value = ConfigDB.from_binary(config.value) - Config.TransferTask.pleroma_need_restart?(group, key, value) - end) - - if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() - end - - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: updated, need_reboot: Restarter.Pleroma.need_reboot?()}) - end - end - - def restart(conn, _params) do - with :ok <- configurable_from_database(conn) do - Restarter.Pleroma.restart(Config.get(:env), 50) - - json(conn, %{}) - end - end - - def need_reboot(conn, _params) do - json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) - end - - defp configurable_from_database(conn) do - if Config.get(:configurable_from_database) do - :ok - else - errors( - conn, - {:error, "To use this endpoint you need to enable configuration from database."} - ) - end - end - - defp whitelisted_config?(group, key) do - if whitelisted_configs = Config.get(:database_config_whitelist) do - Enum.any?(whitelisted_configs, fn - {whitelisted_group} -> - group == inspect(whitelisted_group) - - {whitelisted_group, whitelisted_key} -> - group == inspect(whitelisted_group) && key == inspect(whitelisted_key) - end) - else - true - end - end - - defp whitelisted_config?(%{"group" => group, "key" => key}) do - whitelisted_config?(group, key) - end - - defp whitelisted_config?(%{:group => group} = config) do - whitelisted_config?(group, config[:key]) - end - - def reload_emoji(conn, _params) do - Pleroma.Emoji.reload() - - conn |> json("ok") - end - - def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - - User.toggle_confirmation(users) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "confirm_email" - }) - - conn |> json("") - end - - def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - - User.try_send_confirmation_email(users) - - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "resend_confirmation_email" - }) - - conn |> json("") - end - - def oauth_app_create(conn, params) do - params = - if params["name"] do - Map.put(params, "client_name", params["name"]) - else - params - end - - result = - case App.create(params) do - {:ok, app} -> - AppView.render("show.json", %{app: app, admin: true}) - - {:error, changeset} -> - App.errors(changeset) - end - - json(conn, result) - end - - def oauth_app_update(conn, params) do - params = - if params["name"] do - Map.put(params, "client_name", params["name"]) - else - params - end - - with {:ok, app} <- App.update(params) do - json(conn, AppView.render("show.json", %{app: app, admin: true})) - else - {:error, changeset} -> - json(conn, App.errors(changeset)) - - nil -> - json_response(conn, :bad_request, "") - end - end - - def oauth_app_list(conn, params) do - {page, page_size} = page_params(params) - - search_params = %{ - client_name: params["name"], - client_id: params["client_id"], - page: page, - page_size: page_size - } - - search_params = - if Map.has_key?(params, "trusted") do - Map.put(search_params, :trusted, params["trusted"]) - else - search_params - end - - with {:ok, apps, count} <- App.search(search_params) do - json( - conn, - AppView.render("index.json", - apps: apps, - count: count, - page_size: page_size, - admin: true - ) - ) - end - end - - def oauth_app_delete(conn, params) do - with {:ok, _app} <- App.destroy(params["id"]) do - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - - def stats(conn, _) do - count = Stats.get_status_visibility_count() - - conn - |> json(%{"status_visibility" => count}) - end - - defp errors(conn, {:error, :not_found}) do - conn - |> put_status(:not_found) - |> json(dgettext("errors", "Not found")) - end - - defp errors(conn, {:error, reason}) do - conn - |> put_status(:bad_request) - |> json(reason) - end - - defp errors(conn, {:param_cast, _}) do - conn - |> put_status(:bad_request) - |> json(dgettext("errors", "Invalid parameters")) - end - - defp errors(conn, _) do - conn - |> put_status(:internal_server_error) - |> json(dgettext("errors", "Something went wrong")) - end - - defp page_params(params) do - {get_page(params["page"]), get_page_size(params["page_size"])} - end - - defp get_page(page_string) when is_nil(page_string), do: 1 - - defp get_page(page_string) do - case Integer.parse(page_string) do - {page, _} -> page - :error -> 1 - end - end - - defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size - - defp get_page_size(page_size_string) do - case Integer.parse(page_size_string) do - {page_size, _} -> page_size - :error -> @users_page_size - end - end -end diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex new file mode 100644 index 000000000..6b1d64a2e --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -0,0 +1,1103 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AdminAPIController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.MFA + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.ReportNote + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.UserInviteToken + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.AccountView + alias Pleroma.Web.AdminAPI.ConfigView + alias Pleroma.Web.AdminAPI.ModerationLogView + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.AdminAPI.ReportView + alias Pleroma.Web.AdminAPI.Search + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MastodonAPI + alias Pleroma.Web.MastodonAPI.AppView + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.Router + + require Logger + + @descriptions Pleroma.Docs.JSON.compile() + @users_page_size 50 + + plug( + OAuthScopesPlug, + %{scopes: ["read:accounts"], admin: true} + when action in [:list_users, :user_show, :right_get, :show_user_credentials] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:accounts"], admin: true} + when action in [ + :get_password_reset, + :force_password_reset, + :user_delete, + :users_create, + :user_toggle_activation, + :user_activate, + :user_deactivate, + :tag_users, + :untag_users, + :right_add, + :right_add_multiple, + :right_delete, + :disable_mfa, + :right_delete_multiple, + :update_user_credentials + ] + ) + + plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :invites) + + plug( + OAuthScopesPlug, + %{scopes: ["write:invites"], admin: true} + when action in [:create_invite_token, :revoke_invite, :email_invite] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:follows"], admin: true} + when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:reports"], admin: true} + when action in [:list_reports, :report_show] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:reports"], admin: true} + when action in [:reports_update, :report_notes_create, :report_notes_delete] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read:statuses"], admin: true} + when action in [:list_user_statuses, :list_instance_statuses] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["read"], admin: true} + when action in [ + :config_show, + :list_log, + :stats, + :relay_list, + :config_descriptions, + :need_reboot + ] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write"], admin: true} + when action in [ + :restart, + :config_update, + :resend_confirmation_email, + :confirm_email, + :oauth_app_create, + :oauth_app_list, + :oauth_app_update, + :oauth_app_delete, + :reload_emoji + ] + ) + + action_fallback(AdminAPI.FallbackController) + + def user_delete(conn, %{"nickname" => nickname}) do + user_delete(conn, %{"nicknames" => [nickname]}) + end + + def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = + nicknames + |> Enum.map(&User.get_cached_by_nickname/1) + + users + |> Enum.each(fn user -> + {:ok, delete_data, _} = Builder.delete(admin, user.ap_id) + Pipeline.common_pipeline(delete_data, local: true) + end) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "delete" + }) + + conn + |> json(nicknames) + end + + def user_follow(%{assigns: %{user: admin}} = conn, %{ + "follower" => follower_nick, + "followed" => followed_nick + }) do + with %User{} = follower <- User.get_cached_by_nickname(follower_nick), + %User{} = followed <- User.get_cached_by_nickname(followed_nick) do + User.follow(follower, followed) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "follow" + }) + end + + conn + |> json("ok") + end + + def user_unfollow(%{assigns: %{user: admin}} = conn, %{ + "follower" => follower_nick, + "followed" => followed_nick + }) do + with %User{} = follower <- User.get_cached_by_nickname(follower_nick), + %User{} = followed <- User.get_cached_by_nickname(followed_nick) do + User.unfollow(follower, followed) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "unfollow" + }) + end + + conn + |> json("ok") + end + + def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do + changesets = + Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} -> + user_data = %{ + nickname: nickname, + name: nickname, + email: email, + password: password, + password_confirmation: password, + bio: "." + } + + User.register_changeset(%User{}, user_data, need_confirmation: false) + end) + |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> + Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) + end) + + case Pleroma.Repo.transaction(changesets) do + {:ok, users} -> + res = + users + |> Map.values() + |> Enum.map(fn user -> + {:ok, user} = User.post_register_action(user) + + user + end) + |> Enum.map(&AccountView.render("created.json", %{user: &1})) + + ModerationLog.insert_log(%{ + actor: admin, + subjects: Map.values(users), + action: "create" + }) + + conn + |> json(res) + + {:error, id, changeset, _} -> + res = + Enum.map(changesets.operations, fn + {current_id, {:changeset, _current_changeset, _}} when current_id == id -> + AccountView.render("create-error.json", %{changeset: changeset}) + + {_, {:changeset, current_changeset, _}} -> + AccountView.render("create-error.json", %{changeset: current_changeset}) + end) + + conn + |> put_status(:conflict) + |> json(res) + end + end + + def user_show(conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + conn + |> put_view(AccountView) + |> render("show.json", %{user: user}) + else + _ -> {:error, :not_found} + end + end + + def list_instance_statuses(conn, %{"instance" => instance} = params) do + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true + {page, page_size} = page_params(params) + + activities = + ActivityPub.fetch_statuses(nil, %{ + "instance" => instance, + "limit" => page_size, + "offset" => (page - 1) * page_size, + "exclude_reblogs" => !with_reblogs && "true" + }) + + conn + |> put_view(AdminAPI.StatusView) + |> render("index.json", %{activities: activities, as: :activity}) + end + + def list_user_statuses(conn, %{"nickname" => nickname} = params) do + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true + godmode = params["godmode"] == "true" || params["godmode"] == true + + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + {_, page_size} = page_params(params) + + activities = + ActivityPub.fetch_user_activities(user, nil, %{ + "limit" => page_size, + "godmode" => godmode, + "exclude_reblogs" => !with_reblogs && "true" + }) + + conn + |> put_view(MastodonAPI.StatusView) + |> render("index.json", %{activities: activities, as: :activity}) + else + _ -> {:error, :not_found} + end + end + + def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + user = User.get_cached_by_nickname(nickname) + + {:ok, updated_user} = User.deactivate(user, !user.deactivated) + + action = if user.deactivated, do: "activate", else: "deactivate" + + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: action + }) + + conn + |> put_view(AccountView) + |> render("show.json", %{user: updated_user}) + end + + def user_activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.deactivate(users, false) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "activate" + }) + + conn + |> put_view(AccountView) + |> render("index.json", %{users: Keyword.values(updated_users)}) + end + + def user_deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.deactivate(users, true) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "deactivate" + }) + + conn + |> put_view(AccountView) + |> render("index.json", %{users: Keyword.values(updated_users)}) + end + + def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do + with {:ok, _} <- User.tag(nicknames, tags) do + ModerationLog.insert_log(%{ + actor: admin, + nicknames: nicknames, + tags: tags, + action: "tag" + }) + + json_response(conn, :no_content, "") + end + end + + def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do + with {:ok, _} <- User.untag(nicknames, tags) do + ModerationLog.insert_log(%{ + actor: admin, + nicknames: nicknames, + tags: tags, + action: "untag" + }) + + json_response(conn, :no_content, "") + end + end + + def list_users(conn, params) do + {page, page_size} = page_params(params) + filters = maybe_parse_filters(params["filters"]) + + search_params = %{ + query: params["query"], + page: page, + page_size: page_size, + tags: params["tags"], + name: params["name"], + email: params["email"] + } + + with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do + json( + conn, + AccountView.render("index.json", users: users, count: count, page_size: page_size) + ) + end + end + + @filters ~w(local external active deactivated is_admin is_moderator) + + @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} + defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} + + defp maybe_parse_filters(filters) do + filters + |> String.split(",") + |> Enum.filter(&Enum.member?(@filters, &1)) + |> Enum.map(&String.to_atom(&1)) + |> Enum.into(%{}, &{&1, true}) + end + + def right_add_multiple(%{assigns: %{user: admin}} = conn, %{ + "permission_group" => permission_group, + "nicknames" => nicknames + }) + when permission_group in ["moderator", "admin"] do + update = %{:"is_#{permission_group}" => true} + + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + for u <- users, do: User.admin_api_update(u, update) + + ModerationLog.insert_log(%{ + action: "grant", + actor: admin, + subject: users, + permission: permission_group + }) + + json(conn, update) + end + + def right_add_multiple(conn, _) do + render_error(conn, :not_found, "No such permission_group") + end + + def right_add(%{assigns: %{user: admin}} = conn, %{ + "permission_group" => permission_group, + "nickname" => nickname + }) + when permission_group in ["moderator", "admin"] do + fields = %{:"is_#{permission_group}" => true} + + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.admin_api_update(fields) + + ModerationLog.insert_log(%{ + action: "grant", + actor: admin, + subject: [user], + permission: permission_group + }) + + json(conn, fields) + end + + def right_add(conn, _) do + render_error(conn, :not_found, "No such permission_group") + end + + def right_get(conn, %{"nickname" => nickname}) do + user = User.get_cached_by_nickname(nickname) + + conn + |> json(%{ + is_moderator: user.is_moderator, + is_admin: user.is_admin + }) + end + + def right_delete_multiple( + %{assigns: %{user: %{nickname: admin_nickname} = admin}} = conn, + %{ + "permission_group" => permission_group, + "nicknames" => nicknames + } + ) + when permission_group in ["moderator", "admin"] do + with false <- Enum.member?(nicknames, admin_nickname) do + update = %{:"is_#{permission_group}" => false} + + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + for u <- users, do: User.admin_api_update(u, update) + + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: users, + permission: permission_group + }) + + json(conn, update) + else + _ -> render_error(conn, :forbidden, "You can't revoke your own admin/moderator status.") + end + end + + def right_delete_multiple(conn, _) do + render_error(conn, :not_found, "No such permission_group") + end + + def right_delete( + %{assigns: %{user: admin}} = conn, + %{ + "permission_group" => permission_group, + "nickname" => nickname + } + ) + when permission_group in ["moderator", "admin"] do + fields = %{:"is_#{permission_group}" => false} + + {:ok, user} = + nickname + |> User.get_cached_by_nickname() + |> User.admin_api_update(fields) + + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: [user], + permission: permission_group + }) + + json(conn, fields) + end + + def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" => nickname}) do + render_error(conn, :forbidden, "You can't revoke your own admin status.") + end + + def relay_list(conn, _params) do + with {:ok, list} <- Relay.list() do + json(conn, %{relays: list}) + else + _ -> + conn + |> put_status(500) + end + end + + def relay_follow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do + with {:ok, _message} <- Relay.follow(target) do + ModerationLog.insert_log(%{ + action: "relay_follow", + actor: admin, + target: target + }) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end + + def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do + with {:ok, _message} <- Relay.unfollow(target) do + ModerationLog.insert_log(%{ + action: "relay_unfollow", + actor: admin, + target: target + }) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end + + @doc "Sends registration invite via email" + def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do + 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( + user, + invite_token, + email, + params["name"] + ), + {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do + json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + {:error, "To send invites you need to set the `registrations_open` option to false."} + + {:invites_enabled, _} -> + {:error, "To send invites you need to set the `invites_enabled` option to true."} + end + end + + @doc "Create an account registration invite token" + def create_invite_token(conn, params) do + opts = %{} + + opts = + if params["max_use"], + do: Map.put(opts, :max_use, params["max_use"]), + else: opts + + opts = + if params["expires_at"], + do: Map.put(opts, :expires_at, params["expires_at"]), + else: opts + + {:ok, invite} = UserInviteToken.create_invite(opts) + + json(conn, AccountView.render("invite.json", %{invite: invite})) + end + + @doc "Get list of created invites" + def invites(conn, _params) do + invites = UserInviteToken.list_invites() + + conn + |> put_view(AccountView) + |> render("invites.json", %{invites: invites}) + end + + @doc "Revokes invite by token" + def revoke_invite(conn, %{"token" => token}) do + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do + conn + |> put_view(AccountView) + |> render("invite.json", %{invite: updated_invite}) + else + nil -> {:error, :not_found} + end + end + + @doc "Get a password reset token (base64 string) for given nickname" + def get_password_reset(conn, %{"nickname" => nickname}) do + (%User{local: true} = user) = User.get_cached_by_nickname(nickname) + {:ok, token} = Pleroma.PasswordResetToken.create_token(user) + + conn + |> json(%{ + token: token.token, + link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) + }) + end + + @doc "Force password reset for a given user" + def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + Enum.each(users, &User.force_password_reset_async/1) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "force_password_reset" + }) + + json_response(conn, :no_content, "") + end + + @doc "Disable mfa for user's account." + def disable_mfa(conn, %{"nickname" => nickname}) do + case User.get_by_nickname(nickname) do + %User{} = user -> + MFA.disable(user) + json(conn, nickname) + + _ -> + {:error, :not_found} + end + end + + @doc "Show a given user's credentials" + def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + 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.update_as_admin(user, params) do + ModerationLog.insert_log(%{ + actor: admin, + subject: [user], + action: "updated_users" + }) + + if params["password"] do + User.force_password_reset_async(user) + end + + 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) + + reports = Utils.get_reports(params, page, page_size) + + conn + |> put_view(ReportView) + |> render("index.json", %{reports: reports}) + end + + def report_show(conn, %{"id" => id}) do + with %Activity{} = report <- Activity.get_by_id(id) do + conn + |> put_view(ReportView) + |> render("show.json", Report.extract_report_info(report)) + else + _ -> {:error, :not_found} + end + end + + def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do + result = + reports + |> Enum.map(fn report -> + with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity + }) + + activity + else + {:error, message} -> %{id: report["id"], error: message} + end + end) + + case Enum.any?(result, &Map.has_key?(&1, :error)) do + true -> json_response(conn, :bad_request, result) + false -> json_response(conn, :no_content, "") + end + end + + def report_notes_create(%{assigns: %{user: user}} = conn, %{ + "id" => report_id, + "content" => content + }) do + with {:ok, _} <- ReportNote.create(user.id, report_id, content) do + ModerationLog.insert_log(%{ + action: "report_note", + actor: user, + subject: Activity.get_by_id(report_id), + text: content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + def report_notes_delete(%{assigns: %{user: user}} = conn, %{ + "id" => note_id, + "report_id" => report_id + }) do + with {:ok, note} <- ReportNote.destroy(note_id) do + ModerationLog.insert_log(%{ + action: "report_note_delete", + actor: user, + subject: Activity.get_by_id(report_id), + text: note.content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + def list_log(conn, params) do + {page, page_size} = page_params(params) + + log = + ModerationLog.get_all(%{ + page: page, + page_size: page_size, + start_date: params["start_date"], + end_date: params["end_date"], + user_id: params["user_id"], + search: params["search"] + }) + + conn + |> put_view(ModerationLogView) + |> render("index.json", %{log: log}) + end + + def config_descriptions(conn, _params) do + descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) + + json(conn, descriptions) + end + + def config_show(conn, %{"only_db" => true}) do + with :ok <- configurable_from_database() do + configs = Pleroma.Repo.all(ConfigDB) + + conn + |> put_view(ConfigView) + |> render("index.json", %{configs: configs}) + end + end + + def config_show(conn, _params) do + with :ok <- configurable_from_database() do + configs = ConfigDB.get_all_as_keyword() + + merged = + Config.Holder.default_config() + |> ConfigDB.merge(configs) + |> Enum.map(fn {group, values} -> + Enum.map(values, fn {key, value} -> + db = + if configs[group][key] do + ConfigDB.get_db_keys(configs[group][key], key) + end + + db_value = configs[group][key] + + merged_value = + if !is_nil(db_value) and Keyword.keyword?(db_value) and + ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do + ConfigDB.merge_group(group, key, value, db_value) + else + value + end + + setting = %{ + group: ConfigDB.convert(group), + key: ConfigDB.convert(key), + value: ConfigDB.convert(merged_value) + } + + if db, do: Map.put(setting, :db, db), else: setting + end) + end) + |> List.flatten() + + json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) + end + end + + def config_update(conn, %{"configs" => configs}) do + with :ok <- configurable_from_database() do + {_errors, results} = + configs + |> Enum.filter(&whitelisted_config?/1) + |> Enum.map(fn + %{"group" => group, "key" => key, "delete" => true} = params -> + ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) + + %{"group" => group, "key" => key, "value" => value} -> + ConfigDB.update_or_create(%{group: group, key: key, value: value}) + end) + |> Enum.split_with(fn result -> elem(result, 0) == :error end) + + {deleted, updated} = + results + |> Enum.map(fn {:ok, config} -> + Map.put(config, :db, ConfigDB.get_db_keys(config)) + end) + |> Enum.split_with(fn config -> + Ecto.get_meta(config, :state) == :deleted + end) + + Config.TransferTask.load_and_update_env(deleted, false) + + if !Restarter.Pleroma.need_reboot?() do + changed_reboot_settings? = + (updated ++ deleted) + |> Enum.any?(fn config -> + group = ConfigDB.from_string(config.group) + key = ConfigDB.from_string(config.key) + value = ConfigDB.from_binary(config.value) + Config.TransferTask.pleroma_need_restart?(group, key, value) + end) + + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() + end + + conn + |> put_view(ConfigView) + |> render("index.json", %{configs: updated, need_reboot: Restarter.Pleroma.need_reboot?()}) + end + end + + def restart(conn, _params) do + with :ok <- configurable_from_database() do + Restarter.Pleroma.restart(Config.get(:env), 50) + + json(conn, %{}) + end + end + + def need_reboot(conn, _params) do + json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) + end + + defp configurable_from_database do + if Config.get(:configurable_from_database) do + :ok + else + {:error, "To use this endpoint you need to enable configuration from database."} + end + end + + defp whitelisted_config?(group, key) do + if whitelisted_configs = Config.get(:database_config_whitelist) do + Enum.any?(whitelisted_configs, fn + {whitelisted_group} -> + group == inspect(whitelisted_group) + + {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + end) + else + true + end + end + + defp whitelisted_config?(%{"group" => group, "key" => key}) do + whitelisted_config?(group, key) + end + + defp whitelisted_config?(%{:group => group} = config) do + whitelisted_config?(group, config[:key]) + end + + def reload_emoji(conn, _params) do + Pleroma.Emoji.reload() + + conn |> json("ok") + end + + def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + User.toggle_confirmation(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "confirm_email" + }) + + conn |> json("") + end + + def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + + User.try_send_confirmation_email(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "resend_confirmation_email" + }) + + conn |> json("") + end + + def oauth_app_create(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end + + result = + case App.create(params) do + {:ok, app} -> + AppView.render("show.json", %{app: app, admin: true}) + + {:error, changeset} -> + App.errors(changeset) + end + + json(conn, result) + end + + def oauth_app_update(conn, params) do + params = + if params["name"] do + Map.put(params, "client_name", params["name"]) + else + params + end + + with {:ok, app} <- App.update(params) do + json(conn, AppView.render("show.json", %{app: app, admin: true})) + else + {:error, changeset} -> + json(conn, App.errors(changeset)) + + nil -> + json_response(conn, :bad_request, "") + end + end + + def oauth_app_list(conn, params) do + {page, page_size} = page_params(params) + + search_params = %{ + client_name: params["name"], + client_id: params["client_id"], + page: page, + page_size: page_size + } + + search_params = + if Map.has_key?(params, "trusted") do + Map.put(search_params, :trusted, params["trusted"]) + else + search_params + end + + with {:ok, apps, count} <- App.search(search_params) do + json( + conn, + AppView.render("index.json", + apps: apps, + count: count, + page_size: page_size, + admin: true + ) + ) + end + end + + def oauth_app_delete(conn, params) do + with {:ok, _app} <- App.destroy(params["id"]) do + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + def stats(conn, _) do + count = Stats.get_status_visibility_count() + + conn + |> json(%{"status_visibility" => count}) + end + + defp page_params(params) do + {get_page(params["page"]), get_page_size(params["page_size"])} + end + + defp get_page(page_string) when is_nil(page_string), do: 1 + + defp get_page(page_string) do + case Integer.parse(page_string) do + {page, _} -> page + :error -> 1 + end + end + + defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size + + defp get_page_size(page_size_string) do + case Integer.parse(page_size_string) do + {page_size, _} -> page_size + :error -> @users_page_size + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex new file mode 100644 index 000000000..9f7bb92ce --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.FallbackController do + use Pleroma.Web, :controller + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> json(dgettext("errors", "Not found")) + end + + def call(conn, {:error, reason}) do + conn + |> put_status(:bad_request) + |> json(reason) + end + + def call(conn, {:param_cast, _}) do + conn + |> put_status(:bad_request) + |> json(dgettext("errors", "Invalid parameters")) + end + + def call(conn, _) do + conn + |> put_status(:internal_server_error) + |> json(dgettext("errors", "Something went wrong")) + end +end diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex new file mode 100644 index 000000000..1e9763979 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -0,0 +1,112 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.StatusController do + use Pleroma.Web, :controller + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MastodonAPI + + require Logger + + @users_page_size 50 + + plug(OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:statuses"], admin: true} when action in [:update, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + def index(%{assigns: %{user: _admin}} = conn, params) do + godmode = params["godmode"] == "true" || params["godmode"] == true + local_only = params["local_only"] == "true" || params["local_only"] == true + with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true + {page, page_size} = page_params(params) + + activities = + ActivityPub.fetch_statuses(nil, %{ + "godmode" => godmode, + "local_only" => local_only, + "limit" => page_size, + "offset" => (page - 1) * page_size, + "exclude_reblogs" => !with_reblogs && "true" + }) + + render(conn, "index.json", %{activities: activities, as: :activity}) + end + + def show(conn, %{"id" => id}) do + with %Activity{} = activity <- Activity.get_by_id(id) do + conn + |> put_view(MastodonAPI.StatusView) + |> render("show.json", %{activity: activity}) + else + nil -> {:error, :not_found} + end + end + + def update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do + params = + params + |> Map.take(["sensitive", "visibility"]) + |> Map.new(fn {key, value} -> {String.to_existing_atom(key), value} end) + + with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do + {:ok, sensitive} = Ecto.Type.cast(:boolean, params[:sensitive]) + + ModerationLog.insert_log(%{ + action: "status_update", + actor: admin, + subject: activity, + sensitive: sensitive, + visibility: params[:visibility] + }) + + conn + |> put_view(MastodonAPI.StatusView) + |> render("show.json", %{activity: activity}) + end + end + + def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do + with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + ModerationLog.insert_log(%{ + action: "status_delete", + actor: user, + subject_id: id + }) + + json(conn, %{}) + end + end + + defp page_params(params) do + {get_page(params["page"]), get_page_size(params["page_size"])} + end + + defp get_page(page_string) when is_nil(page_string), do: 1 + + defp get_page(page_string) do + case Integer.parse(page_string) do + {page, _} -> page + :error -> 1 + end + end + + defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size + + defp get_page_size(page_size_string) do + case Integer.parse(page_size_string) do + {page_size, _} -> page_size + :error -> @users_page_size + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 4cacf6255..9e99ab218 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -189,10 +189,10 @@ defmodule Pleroma.Web.Router do post("/reports/:id/notes", AdminAPIController, :report_notes_create) delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete) - get("/statuses/:id", AdminAPIController, :status_show) - put("/statuses/:id", AdminAPIController, :status_update) - delete("/statuses/:id", AdminAPIController, :status_delete) - get("/statuses", AdminAPIController, :list_statuses) + get("/statuses/:id", StatusController, :show) + put("/statuses/:id", StatusController, :update) + delete("/statuses/:id", StatusController, :delete) + get("/statuses", StatusController, :index) get("/config", AdminAPIController, :config_show) post("/config", AdminAPIController, :config_update) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs deleted file mode 100644 index 370d876d0..000000000 --- a/test/web/admin_api/admin_api_controller_test.exs +++ /dev/null @@ -1,3864 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do - use Pleroma.Web.ConnCase - use Oban.Testing, repo: Pleroma.Repo - - import ExUnit.CaptureLog - import Mock - import Pleroma.Factory - - alias Pleroma.Activity - alias Pleroma.Config - alias Pleroma.ConfigDB - alias Pleroma.HTML - alias Pleroma.MFA - alias Pleroma.ModerationLog - alias Pleroma.Repo - alias Pleroma.ReportNote - alias Pleroma.Tests.ObanHelpers - alias Pleroma.User - alias Pleroma.UserInviteToken - alias Pleroma.Web - alias Pleroma.Web.ActivityPub.Relay - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MediaProxy - - setup_all do - Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) - - :ok - end - - setup do - admin = insert(:user, is_admin: true) - token = insert(:oauth_admin_token, user: admin) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - - {:ok, %{admin: admin, token: token, conn: conn}} - end - - describe "with [:auth, :enforce_oauth_admin_scope_usage]," do - setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) - - test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope", - %{admin: admin} do - user = insert(:user) - url = "/api/pleroma/admin/users/#{user.nickname}" - - good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"]) - good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"]) - good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"]) - - bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts"]) - bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"]) - bad_token3 = nil - - for good_token <- [good_token1, good_token2, good_token3] do - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, good_token) - |> get(url) - - assert json_response(conn, 200) - end - - for good_token <- [good_token1, good_token2, good_token3] do - conn = - build_conn() - |> assign(:user, nil) - |> assign(:token, good_token) - |> get(url) - - assert json_response(conn, :forbidden) - end - - for bad_token <- [bad_token1, bad_token2, bad_token3] do - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, bad_token) - |> get(url) - - assert json_response(conn, :forbidden) - end - end - end - - describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do - setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) - - test "GET /api/pleroma/admin/users/:nickname requires " <> - "read:accounts or admin:read:accounts or broader scope", - %{admin: admin} do - user = insert(:user) - url = "/api/pleroma/admin/users/#{user.nickname}" - - good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"]) - good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"]) - good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"]) - good_token4 = insert(:oauth_token, user: admin, scopes: ["read:accounts"]) - good_token5 = insert(:oauth_token, user: admin, scopes: ["read"]) - - good_tokens = [good_token1, good_token2, good_token3, good_token4, good_token5] - - bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts:partial"]) - bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"]) - bad_token3 = nil - - for good_token <- good_tokens do - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, good_token) - |> get(url) - - assert json_response(conn, 200) - end - - for good_token <- good_tokens do - conn = - build_conn() - |> assign(:user, nil) - |> assign(:token, good_token) - |> get(url) - - assert json_response(conn, :forbidden) - end - - for bad_token <- [bad_token1, bad_token2, bad_token3] do - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, bad_token) - |> get(url) - - assert json_response(conn, :forbidden) - end - end - end - - describe "DELETE /api/pleroma/admin/users" do - test "single user", %{admin: admin, conn: conn} do - user = insert(:user) - - with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end do - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") - - ObanHelpers.perform_all() - - assert User.get_by_nickname(user.nickname).deactivated - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deleted users: @#{user.nickname}" - - assert json_response(conn, 200) == [user.nickname] - - assert called(Pleroma.Web.Federator.publish(:_)) - end - end - - test "multiple users", %{admin: admin, conn: conn} do - user_one = insert(:user) - user_two = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users", %{ - nicknames: [user_one.nickname, user_two.nickname] - }) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}" - - response = json_response(conn, 200) - assert response -- [user_one.nickname, user_two.nickname] == [] - end - end - - describe "/api/pleroma/admin/users" do - test "Create", %{conn: conn} do - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => "lain", - "email" => "lain@example.org", - "password" => "test" - }, - %{ - "nickname" => "lain2", - "email" => "lain2@example.org", - "password" => "test" - } - ] - }) - - response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type")) - assert response == ["success", "success"] - - log_entry = Repo.one(ModerationLog) - - assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] - end - - test "Cannot create user with existing email", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => "lain", - "email" => user.email, - "password" => "test" - } - ] - }) - - assert json_response(conn, 409) == [ - %{ - "code" => 409, - "data" => %{ - "email" => user.email, - "nickname" => "lain" - }, - "error" => "email has already been taken", - "type" => "error" - } - ] - end - - test "Cannot create user with existing nickname", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => user.nickname, - "email" => "someuser@plerama.social", - "password" => "test" - } - ] - }) - - assert json_response(conn, 409) == [ - %{ - "code" => 409, - "data" => %{ - "email" => "someuser@plerama.social", - "nickname" => user.nickname - }, - "error" => "nickname has already been taken", - "type" => "error" - } - ] - end - - test "Multiple user creation works in transaction", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users", %{ - "users" => [ - %{ - "nickname" => "newuser", - "email" => "newuser@pleroma.social", - "password" => "test" - }, - %{ - "nickname" => "lain", - "email" => user.email, - "password" => "test" - } - ] - }) - - assert json_response(conn, 409) == [ - %{ - "code" => 409, - "data" => %{ - "email" => user.email, - "nickname" => "lain" - }, - "error" => "email has already been taken", - "type" => "error" - }, - %{ - "code" => 409, - "data" => %{ - "email" => "newuser@pleroma.social", - "nickname" => "newuser" - }, - "error" => "", - "type" => "error" - } - ] - - assert User.get_by_nickname("newuser") === nil - end - end - - describe "/api/pleroma/admin/users/:nickname" do - test "Show", %{conn: conn} do - user = insert(:user) - - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - - expected = %{ - "deactivated" => false, - "id" => to_string(user.id), - "local" => true, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - - assert expected == json_response(conn, 200) - end - - test "when the user doesn't exist", %{conn: conn} do - user = build(:user) - - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - - assert "Not found" == json_response(conn, 404) - end - end - - describe "/api/pleroma/admin/users/follow" do - test "allows to force-follow another user", %{admin: admin, conn: conn} do - user = insert(:user) - follower = insert(:user) - - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users/follow", %{ - "follower" => follower.nickname, - "followed" => user.nickname - }) - - user = User.get_cached_by_id(user.id) - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, user) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} made @#{follower.nickname} follow @#{user.nickname}" - end - end - - describe "/api/pleroma/admin/users/unfollow" do - test "allows to force-unfollow another user", %{admin: admin, conn: conn} do - user = insert(:user) - follower = insert(:user) - - User.follow(follower, user) - - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users/unfollow", %{ - "follower" => follower.nickname, - "followed" => user.nickname - }) - - user = User.get_cached_by_id(user.id) - follower = User.get_cached_by_id(follower.id) - - refute User.following?(follower, user) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} made @#{follower.nickname} unfollow @#{user.nickname}" - end - end - - describe "PUT /api/pleroma/admin/users/tag" do - setup %{conn: conn} do - user1 = insert(:user, %{tags: ["x"]}) - user2 = insert(:user, %{tags: ["y"]}) - user3 = insert(:user, %{tags: ["unchanged"]}) - - conn = - conn - |> put_req_header("accept", "application/json") - |> put( - "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=" <> - "#{user2.nickname}&tags[]=foo&tags[]=bar" - ) - - %{conn: conn, user1: user1, user2: user2, user3: user3} - end - - test "it appends specified tags to users with specified nicknames", %{ - conn: conn, - admin: admin, - user1: user1, - user2: user2 - } do - assert json_response(conn, :no_content) - assert User.get_cached_by_id(user1.id).tags == ["x", "foo", "bar"] - assert User.get_cached_by_id(user2.id).tags == ["y", "foo", "bar"] - - log_entry = Repo.one(ModerationLog) - - users = - [user1.nickname, user2.nickname] - |> Enum.map(&"@#{&1}") - |> Enum.join(", ") - - tags = ["foo", "bar"] |> Enum.join(", ") - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} added tags: #{tags} to users: #{users}" - end - - test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do - assert json_response(conn, :no_content) - assert User.get_cached_by_id(user3.id).tags == ["unchanged"] - end - end - - describe "DELETE /api/pleroma/admin/users/tag" do - setup %{conn: conn} do - user1 = insert(:user, %{tags: ["x"]}) - user2 = insert(:user, %{tags: ["y", "z"]}) - user3 = insert(:user, %{tags: ["unchanged"]}) - - conn = - conn - |> put_req_header("accept", "application/json") - |> delete( - "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=" <> - "#{user2.nickname}&tags[]=x&tags[]=z" - ) - - %{conn: conn, user1: user1, user2: user2, user3: user3} - end - - test "it removes specified tags from users with specified nicknames", %{ - conn: conn, - admin: admin, - user1: user1, - user2: user2 - } do - assert json_response(conn, :no_content) - assert User.get_cached_by_id(user1.id).tags == [] - assert User.get_cached_by_id(user2.id).tags == ["y"] - - log_entry = Repo.one(ModerationLog) - - users = - [user1.nickname, user2.nickname] - |> Enum.map(&"@#{&1}") - |> Enum.join(", ") - - tags = ["x", "z"] |> Enum.join(", ") - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} removed tags: #{tags} from users: #{users}" - end - - test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do - assert json_response(conn, :no_content) - assert User.get_cached_by_id(user3.id).tags == ["unchanged"] - end - end - - describe "/api/pleroma/admin/users/:nickname/permission_group" do - test "GET is giving user_info", %{admin: admin, conn: conn} do - conn = - conn - |> put_req_header("accept", "application/json") - |> get("/api/pleroma/admin/users/#{admin.nickname}/permission_group/") - - assert json_response(conn, 200) == %{ - "is_admin" => true, - "is_moderator" => false - } - end - - test "/:right POST, can add to a permission group", %{admin: admin, conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin") - - assert json_response(conn, 200) == %{ - "is_admin" => true - } - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} made @#{user.nickname} admin" - end - - test "/:right POST, can add to a permission group (multiple)", %{admin: admin, conn: conn} do - user_one = insert(:user) - user_two = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> post("/api/pleroma/admin/users/permission_group/admin", %{ - nicknames: [user_one.nickname, user_two.nickname] - }) - - assert json_response(conn, 200) == %{"is_admin" => true} - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} made @#{user_one.nickname}, @#{user_two.nickname} admin" - end - - test "/:right DELETE, can remove from a permission group", %{admin: admin, conn: conn} do - user = insert(:user, is_admin: true) - - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin") - - assert json_response(conn, 200) == %{"is_admin" => false} - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} revoked admin role from @#{user.nickname}" - end - - test "/:right DELETE, can remove from a permission group (multiple)", %{ - admin: admin, - conn: conn - } do - user_one = insert(:user, is_admin: true) - user_two = insert(:user, is_admin: true) - - conn = - conn - |> put_req_header("accept", "application/json") - |> delete("/api/pleroma/admin/users/permission_group/admin", %{ - nicknames: [user_one.nickname, user_two.nickname] - }) - - assert json_response(conn, 200) == %{"is_admin" => false} - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} revoked admin role from @#{user_one.nickname}, @#{ - user_two.nickname - }" - end - end - - describe "POST /api/pleroma/admin/email_invite, with valid config" do - setup do: clear_config([:instance, :registrations_open], false) - setup do: clear_config([:instance, :invites_enabled], true) - - test "sends invitation and returns 204", %{admin: admin, conn: conn} do - recipient_email = "foo@bar.com" - recipient_name = "J. D." - - conn = - post( - conn, - "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" - ) - - assert json_response(conn, :no_content) - - token_record = List.last(Repo.all(Pleroma.UserInviteToken)) - 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, - recipient_name - ) - - Swoosh.TestAssertions.assert_email_sent( - from: {instance_name, notify_email}, - to: {recipient_name, recipient_email}, - html_body: email.html_body - ) - end - - test "it returns 403 if requested by a non-admin" do - non_admin_user = insert(:user) - token = insert(:oauth_token, user: non_admin_user) - - conn = - build_conn() - |> assign(:user, non_admin_user) - |> assign(:token, token) - |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - 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 - setup do: clear_config([:instance, :registrations_open]) - setup do: clear_config([:instance, :invites_enabled]) - - test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do - Config.put([:instance, :registrations_open], false) - Config.put([:instance, :invites_enabled], false) - - 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 to set the `invites_enabled` option to true." - end - - test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do - Config.put([:instance, :registrations_open], true) - Config.put([:instance, :invites_enabled], true) - - 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 to set the `registrations_open` option to false." - end - end - - test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> put_req_header("accept", "application/json") - |> get("/api/pleroma/admin/users/#{user.nickname}/password_reset") - - resp = json_response(conn, 200) - - assert Regex.match?(~r/(http:\/\/|https:\/\/)/, resp["link"]) - end - - describe "GET /api/pleroma/admin/users" do - test "renders users array for the first page", %{conn: conn, admin: admin} do - user = insert(:user, local: false, tags: ["foo", "bar"]) - conn = get(conn, "/api/pleroma/admin/users?page=1") - - users = - [ - %{ - "deactivated" => admin.deactivated, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false - }, - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => false, - "tags" => ["foo", "bar"], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => users - } - end - - test "pagination works correctly with service users", %{conn: conn} do - service1 = insert(:user, ap_id: Web.base_url() <> "/relay") - service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch") - insert_list(25, :user) - - assert %{"count" => 26, "page_size" => 10, "users" => users1} = - conn - |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"}) - |> json_response(200) - - assert Enum.count(users1) == 10 - assert service1 not in [users1] - assert service2 not in [users1] - - assert %{"count" => 26, "page_size" => 10, "users" => users2} = - conn - |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"}) - |> json_response(200) - - assert Enum.count(users2) == 10 - assert service1 not in [users2] - assert service2 not in [users2] - - assert %{"count" => 26, "page_size" => 10, "users" => users3} = - conn - |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"}) - |> json_response(200) - - assert Enum.count(users3) == 6 - assert service1 not in [users3] - assert service2 not in [users3] - end - - test "renders empty array for the second page", %{conn: conn} do - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?page=2") - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => [] - } - end - - test "regular search", %{conn: conn} do - user = insert(:user, nickname: "bob") - - conn = get(conn, "/api/pleroma/admin/users?query=bo") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "search by domain", %{conn: conn} do - user = insert(:user, nickname: "nickname@domain.com") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?query=domain.com") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "search by full nickname", %{conn: conn} do - user = insert(:user, nickname: "nickname@domain.com") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?query=nickname@domain.com") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "search by display name", %{conn: conn} do - user = insert(:user, name: "Display name") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?name=display") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "search by email", %{conn: conn} do - user = insert(:user, email: "email@example.com") - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?email=email@example.com") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "regular search with page size", %{conn: conn} do - user = insert(:user, nickname: "aalice") - user2 = insert(:user, nickname: "alice") - - conn1 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=1") - - assert json_response(conn1, 200) == %{ - "count" => 2, - "page_size" => 1, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - - conn2 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=2") - - assert json_response(conn2, 200) == %{ - "count" => 2, - "page_size" => 1, - "users" => [ - %{ - "deactivated" => user2.deactivated, - "id" => user2.id, - "nickname" => user2.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user2) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "only local users" do - admin = insert(:user, is_admin: true, nickname: "john") - token = insert(:oauth_admin_token, user: admin) - user = insert(:user, nickname: "bob") - - insert(:user, nickname: "bobb", local: false) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> get("/api/pleroma/admin/users?query=bo&filters=local") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "only local users with no query", %{conn: conn, admin: old_admin} do - admin = insert(:user, is_admin: true, nickname: "john") - user = insert(:user, nickname: "bob") - - insert(:user, nickname: "bobb", local: false) - - conn = get(conn, "/api/pleroma/admin/users?filters=local") - - users = - [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - }, - %{ - "deactivated" => admin.deactivated, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false - }, - %{ - "deactivated" => false, - "id" => old_admin.id, - "local" => true, - "nickname" => old_admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "tags" => [], - "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), - "confirmation_pending" => false - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 3, - "page_size" => 50, - "users" => users - } - end - - test "load only admins", %{conn: conn, admin: admin} do - second_admin = insert(:user, is_admin: true) - insert(:user) - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?filters=is_admin") - - users = - [ - %{ - "deactivated" => false, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => admin.local, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false - }, - %{ - "deactivated" => false, - "id" => second_admin.id, - "nickname" => second_admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => second_admin.local, - "tags" => [], - "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), - "confirmation_pending" => false - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => users - } - end - - test "load only moderators", %{conn: conn} do - moderator = insert(:user, is_moderator: true) - insert(:user) - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?filters=is_moderator") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => false, - "id" => moderator.id, - "nickname" => moderator.nickname, - "roles" => %{"admin" => false, "moderator" => true}, - "local" => moderator.local, - "tags" => [], - "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "load users with tags list", %{conn: conn} do - user1 = insert(:user, tags: ["first"]) - user2 = insert(:user, tags: ["second"]) - insert(:user) - insert(:user) - - conn = get(conn, "/api/pleroma/admin/users?tags[]=first&tags[]=second") - - users = - [ - %{ - "deactivated" => false, - "id" => user1.id, - "nickname" => user1.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => user1.local, - "tags" => ["first"], - "avatar" => User.avatar_url(user1) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user1.name || user1.nickname), - "confirmation_pending" => false - }, - %{ - "deactivated" => false, - "id" => user2.id, - "nickname" => user2.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => user2.local, - "tags" => ["second"], - "avatar" => User.avatar_url(user2) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false - } - ] - |> Enum.sort_by(& &1["nickname"]) - - assert json_response(conn, 200) == %{ - "count" => 2, - "page_size" => 50, - "users" => users - } - end - - test "it works with multiple filters" do - admin = insert(:user, nickname: "john", is_admin: true) - token = insert(:oauth_admin_token, user: admin) - user = insert(:user, nickname: "bob", local: false, deactivated: true) - - insert(:user, nickname: "ken", local: true, deactivated: true) - insert(:user, nickname: "bobb", local: false, deactivated: false) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> get("/api/pleroma/admin/users?filters=deactivated,external") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => user.local, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - ] - } - end - - test "it omits relay user", %{admin: admin, conn: conn} do - assert %User{} = Relay.get_actor() - - conn = get(conn, "/api/pleroma/admin/users") - - assert json_response(conn, 200) == %{ - "count" => 1, - "page_size" => 50, - "users" => [ - %{ - "deactivated" => admin.deactivated, - "id" => admin.id, - "nickname" => admin.nickname, - "roles" => %{"admin" => true, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(admin) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false - } - ] - } - end - end - - test "PATCH /api/pleroma/admin/users/activate", %{admin: admin, conn: conn} do - user_one = insert(:user, deactivated: true) - user_two = insert(:user, deactivated: true) - - conn = - patch( - conn, - "/api/pleroma/admin/users/activate", - %{nicknames: [user_one.nickname, user_two.nickname]} - ) - - response = json_response(conn, 200) - assert Enum.map(response["users"], & &1["deactivated"]) == [false, false] - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} activated users: @#{user_one.nickname}, @#{user_two.nickname}" - end - - test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do - user_one = insert(:user, deactivated: false) - user_two = insert(:user, deactivated: false) - - conn = - patch( - conn, - "/api/pleroma/admin/users/deactivate", - %{nicknames: [user_one.nickname, user_two.nickname]} - ) - - response = json_response(conn, 200) - assert Enum.map(response["users"], & &1["deactivated"]) == [true, true] - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}" - end - - test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do - user = insert(:user) - - conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation") - - assert json_response(conn, 200) == - %{ - "deactivated" => !user.deactivated, - "id" => user.id, - "nickname" => user.nickname, - "roles" => %{"admin" => false, "moderator" => false}, - "local" => true, - "tags" => [], - "avatar" => User.avatar_url(user) |> MediaProxy.url(), - "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false - } - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deactivated users: @#{user.nickname}" - end - - describe "PUT disable_mfa" do - test "returns 200 and disable 2fa", %{conn: conn} do - user = - insert(:user, - multi_factor_authentication_settings: %MFA.Settings{ - enabled: true, - totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true} - } - ) - - response = - conn - |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname}) - |> json_response(200) - - assert response == user.nickname - mfa_settings = refresh_record(user).multi_factor_authentication_settings - - refute mfa_settings.enabled - refute mfa_settings.totp.confirmed - end - - test "returns 404 if user not found", %{conn: conn} do - response = - conn - |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"}) - |> json_response(404) - - assert response == "Not found" - end - end - - describe "POST /api/pleroma/admin/users/invite_token" do - test "without options", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token") - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - refute invite.max_use - assert invite.invite_type == "one_time" - end - - test "with expires_at", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - - refute invite.used - assert invite.expires_at == Date.utc_today() - refute invite.max_use - assert invite.invite_type == "date_limited" - end - - test "with max_use", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - assert invite.max_use == 150 - assert invite.invite_type == "reusable" - end - - test "with max use and expires_at", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ - "max_use" => 150, - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - assert invite.expires_at == Date.utc_today() - assert invite.max_use == 150 - assert invite.invite_type == "reusable_date_limited" - end - end - - describe "GET /api/pleroma/admin/users/invites" do - test "no invites", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response(conn, 200) == %{"invites" => []} - end - - test "with invite", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response(conn, 200) == %{ - "invites" => [ - %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => false, - "uses" => 0 - } - ] - } - end - end - - describe "POST /api/pleroma/admin/users/revoke_invite" do - test "with token", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) - - assert json_response(conn, 200) == %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => true, - "uses" => 0 - } - end - - test "with invalid token", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - - assert json_response(conn, :not_found) == "Not found" - end - end - - describe "GET /api/pleroma/admin/reports/:id" do - test "returns report by its id", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - response = - conn - |> get("/api/pleroma/admin/reports/#{report_id}") - |> json_response(:ok) - - assert response["id"] == report_id - end - - test "returns 404 when report id is invalid", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/reports/test") - - assert json_response(conn, :not_found) == "Not found" - end - end - - describe "PATCH /api/pleroma/admin/reports" do - setup do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - {:ok, %{id: second_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel very offended", - status_ids: [activity.id] - }) - - %{ - id: report_id, - second_report_id: second_report_id - } - end - - test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do - read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"]) - write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"]) - - response = - conn - |> assign(:token, read_token) - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [%{"state" => "resolved", "id" => id}] - }) - |> json_response(403) - - assert response == %{ - "error" => "Insufficient permissions: admin:write:reports." - } - - conn - |> assign(:token, write_token) - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [%{"state" => "resolved", "id" => id}] - }) - |> json_response(:no_content) - end - - test "mark report as resolved", %{conn: conn, id: id, admin: admin} do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "resolved", "id" => id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - assert activity.data["state"] == "resolved" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" - end - - test "closes report", %{conn: conn, id: id, admin: admin} do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "closed", "id" => id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - assert activity.data["state"] == "closed" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'closed' state" - end - - test "returns 400 when state is unknown", %{conn: conn, id: id} do - conn = - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "test", "id" => id} - ] - }) - - assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" - end - - test "returns 404 when report is not exist", %{conn: conn} do - conn = - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "closed", "id" => "test"} - ] - }) - - assert hd(json_response(conn, :bad_request))["error"] == "not_found" - end - - test "updates state of multiple reports", %{ - conn: conn, - id: id, - admin: admin, - second_report_id: second_report_id - } do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "resolved", "id" => id}, - %{"state" => "closed", "id" => second_report_id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - second_activity = Activity.get_by_id(second_report_id) - assert activity.data["state"] == "resolved" - assert second_activity.data["state"] == "closed" - - [first_log_entry, second_log_entry] = Repo.all(ModerationLog) - - assert ModerationLog.get_log_entry_message(first_log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" - - assert ModerationLog.get_log_entry_message(second_log_entry) == - "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" - end - end - - describe "GET /api/pleroma/admin/reports" do - test "returns empty response when no reports created", %{conn: conn} do - response = - conn - |> get("/api/pleroma/admin/reports") - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 - end - - test "returns reports", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - response = - conn - |> get("/api/pleroma/admin/reports") - |> json_response(:ok) - - [report] = response["reports"] - - assert length(response["reports"]) == 1 - assert report["id"] == report_id - - assert response["total"] == 1 - end - - test "returns reports with specified state", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: first_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - {:ok, %{id: second_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I don't like this user" - }) - - CommonAPI.update_report_state(second_report_id, "closed") - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "open" - }) - |> json_response(:ok) - - [open_report] = response["reports"] - - assert length(response["reports"]) == 1 - assert open_report["id"] == first_report_id - - assert response["total"] == 1 - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "closed" - }) - |> json_response(:ok) - - [closed_report] = response["reports"] - - assert length(response["reports"]) == 1 - assert closed_report["id"] == second_report_id - - assert response["total"] == 1 - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "resolved" - }) - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 - end - - test "returns 403 when requested by a non-admin" do - user = insert(:user) - token = insert(:oauth_token, user: user) - - conn = - build_conn() - |> assign(:user, user) - |> assign(:token, token) - |> get("/api/pleroma/admin/reports") - - assert json_response(conn, :forbidden) == - %{"error" => "User is not an admin or OAuth admin scope is not granted."} - end - - test "returns 403 when requested by anonymous" do - conn = get(build_conn(), "/api/pleroma/admin/reports") - - assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} - end - end - - describe "GET /api/pleroma/admin/statuses/:id" do - test "not found", %{conn: conn} do - assert conn - |> get("/api/pleroma/admin/statuses/not_found") - |> json_response(:not_found) - end - - test "shows activity", %{conn: conn} do - activity = insert(:note_activity) - - response = - conn - |> get("/api/pleroma/admin/statuses/#{activity.id}") - |> json_response(200) - - assert response["id"] == activity.id - end - end - - describe "PUT /api/pleroma/admin/statuses/:id" do - setup do - activity = insert(:note_activity) - - %{id: activity.id} - end - - test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do - response = - conn - |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "true"}) - |> json_response(:ok) - - assert response["sensitive"] - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated status ##{id}, set sensitive: 'true'" - - response = - conn - |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "false"}) - |> json_response(:ok) - - refute response["sensitive"] - end - - test "change visibility flag", %{conn: conn, id: id, admin: admin} do - response = - conn - |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "public"}) - |> json_response(:ok) - - assert response["visibility"] == "public" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated status ##{id}, set visibility: 'public'" - - response = - conn - |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "private"}) - |> json_response(:ok) - - assert response["visibility"] == "private" - - response = - conn - |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "unlisted"}) - |> json_response(:ok) - - assert response["visibility"] == "unlisted" - end - - test "returns 400 when visibility is unknown", %{conn: conn, id: id} do - conn = put(conn, "/api/pleroma/admin/statuses/#{id}", %{visibility: "test"}) - - assert json_response(conn, :bad_request) == "Unsupported visibility" - end - end - - describe "DELETE /api/pleroma/admin/statuses/:id" do - setup do - activity = insert(:note_activity) - - %{id: activity.id} - end - - test "deletes status", %{conn: conn, id: id, admin: admin} do - conn - |> delete("/api/pleroma/admin/statuses/#{id}") - |> json_response(:ok) - - refute Activity.get_by_id(id) - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} deleted status ##{id}" - end - - test "returns 404 when the status does not exist", %{conn: conn} do - conn = delete(conn, "/api/pleroma/admin/statuses/test") - - assert json_response(conn, :not_found) == "Not found" - end - end - - describe "GET /api/pleroma/admin/config" do - setup do: clear_config(:configurable_from_database, true) - - test "when configuration from database is off", %{conn: conn} do - Config.put(:configurable_from_database, false) - conn = get(conn, "/api/pleroma/admin/config") - - assert json_response(conn, 400) == - "To use this endpoint you need to enable configuration from database." - end - - test "with settings only in db", %{conn: conn} do - config1 = insert(:config) - config2 = insert(:config) - - conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) - - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => key1, - "value" => _ - }, - %{ - "group" => ":pleroma", - "key" => key2, - "value" => _ - } - ] - } = json_response(conn, 200) - - assert key1 == config1.key - assert key2 == config2.key - end - - test "db is added to settings that are in db", %{conn: conn} do - _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - [instance_config] = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key == ":instance" - end) - - assert instance_config["db"] == [":name"] - end - - test "merged default setting with db settings", %{conn: conn} do - config1 = insert(:config) - config2 = insert(:config) - - config3 = - insert(:config, - value: ConfigDB.to_binary(k1: :v1, k2: :v2) - ) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert length(configs) > 3 - - received_configs = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key, config3.key] - end) - - assert length(received_configs) == 3 - - db_keys = - config3.value - |> ConfigDB.from_binary() - |> Keyword.keys() - |> ConfigDB.convert() - - Enum.each(received_configs, fn %{"value" => value, "db" => db} -> - assert db in [[config1.key], [config2.key], db_keys] - - assert value in [ - ConfigDB.from_binary_with_convert(config1.value), - ConfigDB.from_binary_with_convert(config2.value), - ConfigDB.from_binary_with_convert(config3.value) - ] - end) - end - - test "subkeys with full update right merge", %{conn: conn} do - config1 = - insert(:config, - key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) - ) - - config2 = - insert(:config, - key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) - ) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - vals = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key] - end) - - emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) - assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) - - emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) - assets_val = ConfigDB.transform_with_out_binary(assets["value"]) - - assert emoji_val[:groups] == [a: 1, b: 2] - assert assets_val[:mascots] == [a: 1, b: 2] - end - end - - test "POST /api/pleroma/admin/config error", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) - - assert json_response(conn, 400) == - "To use this endpoint you need to enable configuration from database." - end - - describe "POST /api/pleroma/admin/config" do - setup do - http = Application.get_env(:pleroma, :http) - - on_exit(fn -> - Application.delete_env(:pleroma, :key1) - Application.delete_env(:pleroma, :key2) - Application.delete_env(:pleroma, :key3) - Application.delete_env(:pleroma, :key4) - Application.delete_env(:pleroma, :keyaa1) - Application.delete_env(:pleroma, :keyaa2) - Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) - Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) - Application.put_env(:pleroma, :http, http) - Application.put_env(:tesla, :adapter, Tesla.Mock) - Restarter.Pleroma.refresh() - end) - end - - setup do: clear_config(:configurable_from_database, true) - - @tag capture_log: true - test "create new config setting in db", %{conn: conn} do - ueberauth = Application.get_env(:ueberauth, Ueberauth) - on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: "value1"}, - %{ - group: ":ueberauth", - key: "Ueberauth", - value: [%{"tuple" => [":consumer_secret", "aaaa"]}] - }, - %{ - group: ":pleroma", - key: ":key2", - value: %{ - ":nested_1" => "nested_value1", - ":nested_2" => [ - %{":nested_22" => "nested_value222"}, - %{":nested_33" => %{":nested_44" => "nested_444"}} - ] - } - }, - %{ - group: ":pleroma", - key: ":key3", - value: [ - %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, - %{"nested_4" => true} - ] - }, - %{ - group: ":pleroma", - key: ":key4", - value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} - }, - %{ - group: ":idna", - key: ":key5", - value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => "value1", - "db" => [":key1"] - }, - %{ - "group" => ":ueberauth", - "key" => "Ueberauth", - "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}], - "db" => [":consumer_secret"] - }, - %{ - "group" => ":pleroma", - "key" => ":key2", - "value" => %{ - ":nested_1" => "nested_value1", - ":nested_2" => [ - %{":nested_22" => "nested_value222"}, - %{":nested_33" => %{":nested_44" => "nested_444"}} - ] - }, - "db" => [":key2"] - }, - %{ - "group" => ":pleroma", - "key" => ":key3", - "value" => [ - %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, - %{"nested_4" => true} - ], - "db" => [":key3"] - }, - %{ - "group" => ":pleroma", - "key" => ":key4", - "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}, - "db" => [":key4"] - }, - %{ - "group" => ":idna", - "key" => ":key5", - "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, - "db" => [":key5"] - } - ] - } - - assert Application.get_env(:pleroma, :key1) == "value1" - - assert Application.get_env(:pleroma, :key2) == %{ - nested_1: "nested_value1", - nested_2: [ - %{nested_22: "nested_value222"}, - %{nested_33: %{nested_44: "nested_444"}} - ] - } - - assert Application.get_env(:pleroma, :key3) == [ - %{"nested_3" => :nested_3, "nested_33" => "nested_33"}, - %{"nested_4" => true} - ] - - assert Application.get_env(:pleroma, :key4) == %{ - "endpoint" => "https://example.com", - nested_5: :upload - } - - assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} - end - - test "save configs setting without explicit key", %{conn: conn} do - level = Application.get_env(:quack, :level) - meta = Application.get_env(:quack, :meta) - webhook_url = Application.get_env(:quack, :webhook_url) - - on_exit(fn -> - Application.put_env(:quack, :level, level) - Application.put_env(:quack, :meta, meta) - Application.put_env(:quack, :webhook_url, webhook_url) - end) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":quack", - key: ":level", - value: ":info" - }, - %{ - group: ":quack", - key: ":meta", - value: [":none"] - }, - %{ - group: ":quack", - key: ":webhook_url", - value: "https://hooks.slack.com/services/KEY" - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":quack", - "key" => ":level", - "value" => ":info", - "db" => [":level"] - }, - %{ - "group" => ":quack", - "key" => ":meta", - "value" => [":none"], - "db" => [":meta"] - }, - %{ - "group" => ":quack", - "key" => ":webhook_url", - "value" => "https://hooks.slack.com/services/KEY", - "db" => [":webhook_url"] - } - ] - } - - assert Application.get_env(:quack, :level) == :info - assert Application.get_env(:quack, :meta) == [:none] - assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" - end - - test "saving config with partial update", %{conn: conn} do - config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key1", 1]}, - %{"tuple" => [":key2", 2]}, - %{"tuple" => [":key3", 3]} - ], - "db" => [":key1", ":key2", ":key3"] - } - ] - } - end - - test "saving config which need pleroma reboot", %{conn: conn} do - chat = Config.get(:chat) - on_exit(fn -> Config.put(:chat, chat) end) - - assert post( - conn, - "/api/pleroma/admin/config", - %{ - configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} - ] - } - ) - |> json_response(200) == %{ - "configs" => [ - %{ - "db" => [":enabled"], - "group" => ":pleroma", - "key" => ":chat", - "value" => [%{"tuple" => [":enabled", true]}] - } - ], - "need_reboot" => true - } - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] - - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] == false - end - - test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do - chat = Config.get(:chat) - on_exit(fn -> Config.put(:chat, chat) end) - - assert post( - conn, - "/api/pleroma/admin/config", - %{ - configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} - ] - } - ) - |> json_response(200) == %{ - "configs" => [ - %{ - "db" => [":enabled"], - "group" => ":pleroma", - "key" => ":chat", - "value" => [%{"tuple" => [":enabled", true]}] - } - ], - "need_reboot" => true - } - - assert post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} - ] - }) - |> json_response(200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key3", 3]} - ], - "db" => [":key3"] - } - ], - "need_reboot" => true - } - - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] == false - end - - test "saving config with nested merge", %{conn: conn} do - config = - insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - value: [ - %{"tuple" => [":key3", 3]}, - %{ - "tuple" => [ - ":key2", - [ - %{"tuple" => [":k2", 1]}, - %{"tuple" => [":k3", 3]} - ] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key1", 1]}, - %{"tuple" => [":key3", 3]}, - %{ - "tuple" => [ - ":key2", - [ - %{"tuple" => [":k1", 1]}, - %{"tuple" => [":k2", 1]}, - %{"tuple" => [":k3", 3]} - ] - ] - } - ], - "db" => [":key1", ":key3", ":key2"] - } - ] - } - end - - test "saving special atoms", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{ - "tuple" => [ - ":ssl_options", - [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{ - "tuple" => [ - ":ssl_options", - [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] - ] - } - ], - "db" => [":ssl_options"] - } - ] - } - - assert Application.get_env(:pleroma, :key1) == [ - ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] - ] - end - - test "saving full setting if value is in full_key_update list", %{conn: conn} do - backends = Application.get_env(:logger, :backends) - on_exit(fn -> Application.put_env(:logger, :backends, backends) end) - - config = - insert(:config, - group: ":logger", - key: ":backends", - value: :erlang.term_to_binary([]) - ) - - Pleroma.Config.TransferTask.load_and_update_env([], false) - - assert Application.get_env(:logger, :backends) == [] - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - value: [":console"] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":logger", - "key" => ":backends", - "value" => [ - ":console" - ], - "db" => [":backends"] - } - ] - } - - assert Application.get_env(:logger, :backends) == [ - :console - ] - end - - test "saving full setting if value is not keyword", %{conn: conn} do - config = - insert(:config, - group: ":tesla", - key: ":adapter", - value: :erlang.term_to_binary(Tesla.Adapter.Hackey) - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":tesla", - "key" => ":adapter", - "value" => "Tesla.Adapter.Httpc", - "db" => [":adapter"] - } - ] - } - end - - test "update config setting & delete with fallback to default value", %{ - conn: conn, - admin: admin, - token: token - } do - ueberauth = Application.get_env(:ueberauth, Ueberauth) - config1 = insert(:config, key: ":keyaa1") - config2 = insert(:config, key: ":keyaa2") - - config3 = - insert(:config, - group: ":ueberauth", - key: "Ueberauth" - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config1.group, key: config1.key, value: "another_value"}, - %{group: config2.group, key: config2.key, value: "another_value"} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => config1.key, - "value" => "another_value", - "db" => [":keyaa1"] - }, - %{ - "group" => ":pleroma", - "key" => config2.key, - "value" => "another_value", - "db" => [":keyaa2"] - } - ] - } - - assert Application.get_env(:pleroma, :keyaa1) == "another_value" - assert Application.get_env(:pleroma, :keyaa2) == "another_value" - assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> post("/api/pleroma/admin/config", %{ - configs: [ - %{group: config2.group, key: config2.key, delete: true}, - %{ - group: ":ueberauth", - key: "Ueberauth", - delete: true - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [] - } - - assert Application.get_env(:ueberauth, Ueberauth) == ueberauth - refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2) - end - - test "common config example", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Captcha.NotReal", - "value" => [ - %{"tuple" => [":enabled", false]}, - %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, - %{"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"]}, - %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, - %{"tuple" => [":name", "Pleroma"]} - ] - } - ] - }) - - assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Captcha.NotReal", - "value" => [ - %{"tuple" => [":enabled", false]}, - %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, - %{"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"]}, - %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, - %{"tuple" => [":name", "Pleroma"]} - ], - "db" => [ - ":enabled", - ":method", - ":seconds_valid", - ":path", - ":key1", - ":partial_chain", - ":regex1", - ":regex2", - ":regex3", - ":regex4", - ":name" - ] - } - ] - } - end - - test "tuples with more than two values", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Web.Endpoint.NotReal", - "value" => [ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key2", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Web.Endpoint.NotReal", - "value" => [ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key2", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ], - "db" => [":http"] - } - ] - } - end - - test "settings with nesting map", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key2", "some_val"]}, - %{ - "tuple" => [ - ":key3", - %{ - ":max_options" => 20, - ":max_option_chars" => 200, - ":min_expiration" => 0, - ":max_expiration" => 31_536_000, - "nested" => %{ - ":max_options" => 20, - ":max_option_chars" => 200, - ":min_expiration" => 0, - ":max_expiration" => 31_536_000 - } - } - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key2", "some_val"]}, - %{ - "tuple" => [ - ":key3", - %{ - ":max_expiration" => 31_536_000, - ":max_option_chars" => 200, - ":max_options" => 20, - ":min_expiration" => 0, - "nested" => %{ - ":max_expiration" => 31_536_000, - ":max_option_chars" => 200, - ":max_options" => 20, - ":min_expiration" => 0 - } - } - ] - } - ], - "db" => [":key2", ":key3"] - } - ] - } - end - - test "value as map", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => %{"key" => "some_val"} - } - ] - }) - - assert json_response(conn, 200) == - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => %{"key" => "some_val"}, - "db" => [":key1"] - } - ] - } - end - - test "queues key as atom", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":oban", - "key" => ":queues", - "value" => [ - %{"tuple" => [":federator_incoming", 50]}, - %{"tuple" => [":federator_outgoing", 50]}, - %{"tuple" => [":web_push", 50]}, - %{"tuple" => [":mailer", 10]}, - %{"tuple" => [":transmogrifier", 20]}, - %{"tuple" => [":scheduled_activities", 10]}, - %{"tuple" => [":background", 5]} - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":oban", - "key" => ":queues", - "value" => [ - %{"tuple" => [":federator_incoming", 50]}, - %{"tuple" => [":federator_outgoing", 50]}, - %{"tuple" => [":web_push", 50]}, - %{"tuple" => [":mailer", 10]}, - %{"tuple" => [":transmogrifier", 20]}, - %{"tuple" => [":scheduled_activities", 10]}, - %{"tuple" => [":background", 5]} - ], - "db" => [ - ":federator_incoming", - ":federator_outgoing", - ":web_push", - ":mailer", - ":transmogrifier", - ":scheduled_activities", - ":background" - ] - } - ] - } - end - - test "delete part of settings by atom subkeys", %{conn: conn} do - config = - insert(:config, - key: ":keyaa1", - value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - subkeys: [":subkey1", ":subkey3"], - delete: true - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":keyaa1", - "value" => [%{"tuple" => [":subkey2", "val2"]}], - "db" => [":subkey2"] - } - ] - } - end - - test "proxy tuple localhost", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value - assert ":proxy_url" in db - end - - test "proxy tuple domain", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value - assert ":proxy_url" in db - end - - test "proxy tuple ip", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value - assert ":proxy_url" in db - end - - test "doesn't set keys not in the whitelist", %{conn: conn} do - clear_config(:database_config_whitelist, [ - {:pleroma, :key1}, - {:pleroma, :key2}, - {:pleroma, Pleroma.Captcha.NotReal}, - {:not_real} - ]) - - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: "value1"}, - %{group: ":pleroma", key: ":key2", value: "value2"}, - %{group: ":pleroma", key: ":key3", value: "value3"}, - %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, - %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, - %{group: ":not_real", key: ":anything", value: "value6"} - ] - }) - - assert Application.get_env(:pleroma, :key1) == "value1" - assert Application.get_env(:pleroma, :key2) == "value2" - assert Application.get_env(:pleroma, :key3) == nil - assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil - assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" - assert Application.get_env(:not_real, :anything) == "value6" - end - end - - describe "GET /api/pleroma/admin/restart" do - setup do: clear_config(:configurable_from_database, true) - - test "pleroma restarts", %{conn: conn} do - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - refute Restarter.Pleroma.need_reboot?() - end - end - - test "need_reboot flag", %{conn: conn} do - assert conn - |> get("/api/pleroma/admin/need_reboot") - |> json_response(200) == %{"need_reboot" => false} - - Restarter.Pleroma.need_reboot() - - assert conn - |> get("/api/pleroma/admin/need_reboot") - |> json_response(200) == %{"need_reboot" => true} - - on_exit(fn -> Restarter.Pleroma.refresh() end) - end - - describe "GET /api/pleroma/admin/statuses" do - test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do - blocked = insert(:user) - user = insert(:user) - User.block(admin, blocked) - - {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"}) - - {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"}) - {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) - {:ok, _} = CommonAPI.post(blocked, %{status: ".", visibility: "public"}) - - response = - conn - |> get("/api/pleroma/admin/statuses") - |> json_response(200) - - refute "private" in Enum.map(response, & &1["visibility"]) - assert length(response) == 3 - end - - test "returns only local statuses with local_only on", %{conn: conn} do - user = insert(:user) - remote_user = insert(:user, local: false, nickname: "archaeme@archae.me") - insert(:note_activity, user: user, local: true) - insert(:note_activity, user: remote_user, local: false) - - response = - conn - |> get("/api/pleroma/admin/statuses?local_only=true") - |> json_response(200) - - assert length(response) == 1 - end - - test "returns private and direct statuses with godmode on", %{conn: conn, admin: admin} do - user = insert(:user) - - {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"}) - - {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) - conn = get(conn, "/api/pleroma/admin/statuses?godmode=true") - assert json_response(conn, 200) |> length() == 3 - end - end - - describe "GET /api/pleroma/admin/users/:nickname/statuses" do - setup do - user = insert(:user) - - date1 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!() - date2 = (DateTime.to_unix(DateTime.utc_now()) + 1000) |> DateTime.from_unix!() - date3 = (DateTime.to_unix(DateTime.utc_now()) + 3000) |> DateTime.from_unix!() - - insert(:note_activity, user: user, published: date1) - insert(:note_activity, user: user, published: date2) - insert(:note_activity, user: user, published: date3) - - %{user: user} - end - - test "renders user's statuses", %{conn: conn, user: user} do - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses") - - assert json_response(conn, 200) |> length() == 3 - end - - test "renders user's statuses with a limit", %{conn: conn, user: user} do - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?page_size=2") - - assert json_response(conn, 200) |> length() == 2 - end - - test "doesn't return private statuses by default", %{conn: conn, user: user} do - {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"}) - - {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"}) - - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses") - - assert json_response(conn, 200) |> length() == 4 - end - - test "returns private statuses with godmode on", %{conn: conn, user: user} do - {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"}) - - {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"}) - - conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?godmode=true") - - assert json_response(conn, 200) |> length() == 5 - end - - test "excludes reblogs by default", %{conn: conn, user: user} do - other_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{status: "."}) - {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, other_user) - - conn_res = get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses") - assert json_response(conn_res, 200) |> length() == 0 - - conn_res = - get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses?with_reblogs=true") - - assert json_response(conn_res, 200) |> length() == 1 - end - end - - describe "GET /api/pleroma/admin/moderation_log" do - setup do - moderator = insert(:user, is_moderator: true) - - %{moderator: moderator} - end - - test "returns the log", %{conn: conn, admin: admin} do - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_follow", - target: "https://example.org/relay" - }, - inserted_at: NaiveDateTime.truncate(~N[2017-08-15 15:47:06.597036], :second) - }) - - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_unfollow", - target: "https://example.org/relay" - }, - inserted_at: NaiveDateTime.truncate(~N[2017-08-16 15:47:06.597036], :second) - }) - - conn = get(conn, "/api/pleroma/admin/moderation_log") - - response = json_response(conn, 200) - [first_entry, second_entry] = response["items"] - - assert response["total"] == 2 - assert first_entry["data"]["action"] == "relay_unfollow" - - assert first_entry["message"] == - "@#{admin.nickname} unfollowed relay: https://example.org/relay" - - assert second_entry["data"]["action"] == "relay_follow" - - assert second_entry["message"] == - "@#{admin.nickname} followed relay: https://example.org/relay" - end - - test "returns the log with pagination", %{conn: conn, admin: admin} do - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_follow", - target: "https://example.org/relay" - }, - inserted_at: NaiveDateTime.truncate(~N[2017-08-15 15:47:06.597036], :second) - }) - - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_unfollow", - target: "https://example.org/relay" - }, - inserted_at: NaiveDateTime.truncate(~N[2017-08-16 15:47:06.597036], :second) - }) - - conn1 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=1") - - response1 = json_response(conn1, 200) - [first_entry] = response1["items"] - - assert response1["total"] == 2 - assert response1["items"] |> length() == 1 - assert first_entry["data"]["action"] == "relay_unfollow" - - assert first_entry["message"] == - "@#{admin.nickname} unfollowed relay: https://example.org/relay" - - conn2 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=2") - - response2 = json_response(conn2, 200) - [second_entry] = response2["items"] - - assert response2["total"] == 2 - assert response2["items"] |> length() == 1 - assert second_entry["data"]["action"] == "relay_follow" - - assert second_entry["message"] == - "@#{admin.nickname} followed relay: https://example.org/relay" - end - - test "filters log by date", %{conn: conn, admin: admin} do - first_date = "2017-08-15T15:47:06Z" - second_date = "2017-08-20T15:47:06Z" - - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_follow", - target: "https://example.org/relay" - }, - inserted_at: NaiveDateTime.from_iso8601!(first_date) - }) - - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_unfollow", - target: "https://example.org/relay" - }, - inserted_at: NaiveDateTime.from_iso8601!(second_date) - }) - - conn1 = - get( - conn, - "/api/pleroma/admin/moderation_log?start_date=#{second_date}" - ) - - response1 = json_response(conn1, 200) - [first_entry] = response1["items"] - - assert response1["total"] == 1 - assert first_entry["data"]["action"] == "relay_unfollow" - - assert first_entry["message"] == - "@#{admin.nickname} unfollowed relay: https://example.org/relay" - end - - test "returns log filtered by user", %{conn: conn, admin: admin, moderator: moderator} do - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => admin.id, - "nickname" => admin.nickname, - "type" => "user" - }, - action: "relay_follow", - target: "https://example.org/relay" - } - }) - - Repo.insert(%ModerationLog{ - data: %{ - actor: %{ - "id" => moderator.id, - "nickname" => moderator.nickname, - "type" => "user" - }, - action: "relay_unfollow", - target: "https://example.org/relay" - } - }) - - conn1 = get(conn, "/api/pleroma/admin/moderation_log?user_id=#{moderator.id}") - - response1 = json_response(conn1, 200) - [first_entry] = response1["items"] - - assert response1["total"] == 1 - assert get_in(first_entry, ["data", "actor", "id"]) == moderator.id - end - - test "returns log filtered by search", %{conn: conn, moderator: moderator} do - ModerationLog.insert_log(%{ - actor: moderator, - action: "relay_follow", - target: "https://example.org/relay" - }) - - ModerationLog.insert_log(%{ - actor: moderator, - action: "relay_unfollow", - target: "https://example.org/relay" - }) - - conn1 = get(conn, "/api/pleroma/admin/moderation_log?search=unfo") - - response1 = json_response(conn1, 200) - [first_entry] = response1["items"] - - assert response1["total"] == 1 - - assert get_in(first_entry, ["data", "message"]) == - "@#{moderator.nickname} unfollowed relay: https://example.org/relay" - end - end - - 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}/credentials", %{ - "password" => "new_password", - "email" => "new_email@example.com", - "name" => "new_name" - }) - - assert json_response(conn, 200) == %{"status" => "success"} - - ObanHelpers.perform_all() - - updated_user = User.get_by_id(user.id) - - 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} 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 - test "sets password_reset_pending to true", %{conn: conn} do - user = insert(:user) - assert user.password_reset_pending == false - - conn = - patch(conn, "/api/pleroma/admin/users/force_password_reset", %{nicknames: [user.nickname]}) - - assert json_response(conn, 204) == "" - - ObanHelpers.perform_all() - - assert User.get_by_id(user.id).password_reset_pending == true - end - end - - describe "relays" do - test "POST /relay", %{conn: conn, admin: admin} do - conn = - post(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" - end - - test "GET /relay", %{conn: conn} do - relay_user = Pleroma.Web.ActivityPub.Relay.get_actor() - - ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"] - |> Enum.each(fn ap_id -> - {:ok, user} = User.get_or_fetch_by_ap_id(ap_id) - User.follow(relay_user, user) - end) - - conn = get(conn, "/api/pleroma/admin/relay") - - assert json_response(conn, 200)["relays"] -- ["mastodon.example.org", "mstdn.io"] == [] - end - - test "DELETE /relay", %{conn: conn, admin: admin} do - post(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - conn = - delete(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" - - [log_entry_one, log_entry_two] = Repo.all(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry_one) == - "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" - - assert ModerationLog.get_log_entry_message(log_entry_two) == - "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin" - end - end - - describe "instances" do - test "GET /instances/:instance/statuses", %{conn: conn} do - user = insert(:user, local: false, nickname: "archaeme@archae.me") - user2 = insert(:user, local: false, nickname: "test@test.com") - insert_pair(:note_activity, user: user) - activity = insert(:note_activity, user: user2) - - ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses") - - response = json_response(ret_conn, 200) - - assert length(response) == 2 - - ret_conn = get(conn, "/api/pleroma/admin/instances/test.com/statuses") - - response = json_response(ret_conn, 200) - - assert length(response) == 1 - - ret_conn = get(conn, "/api/pleroma/admin/instances/nonexistent.com/statuses") - - response = json_response(ret_conn, 200) - - assert Enum.empty?(response) - - CommonAPI.repeat(activity.id, user) - - ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses") - response = json_response(ret_conn, 200) - assert length(response) == 2 - - ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses?with_reblogs=true") - response = json_response(ret_conn, 200) - assert length(response) == 3 - end - end - - describe "PATCH /confirm_email" do - test "it confirms emails of two users", %{conn: conn, admin: admin} do - [first_user, second_user] = insert_pair(:user, confirmation_pending: true) - - assert first_user.confirmation_pending == true - assert second_user.confirmation_pending == true - - ret_conn = - patch(conn, "/api/pleroma/admin/users/confirm_email", %{ - nicknames: [ - first_user.nickname, - second_user.nickname - ] - }) - - assert ret_conn.status == 200 - - assert first_user.confirmation_pending == true - assert second_user.confirmation_pending == true - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} confirmed email for users: @#{first_user.nickname}, @#{ - second_user.nickname - }" - end - end - - describe "PATCH /resend_confirmation_email" do - test "it resend emails for two users", %{conn: conn, admin: admin} do - [first_user, second_user] = insert_pair(:user, confirmation_pending: true) - - ret_conn = - patch(conn, "/api/pleroma/admin/users/resend_confirmation_email", %{ - nicknames: [ - first_user.nickname, - second_user.nickname - ] - }) - - assert ret_conn.status == 200 - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} re-sent confirmation email for users: @#{first_user.nickname}, @#{ - second_user.nickname - }" - end - end - - describe "POST /reports/:id/notes" do - setup %{conn: conn, admin: admin} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ - content: "this is disgusting!" - }) - - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ - content: "this is disgusting2!" - }) - - %{ - admin_id: admin.id, - report_id: report_id - } - end - - test "it creates report note", %{admin_id: admin_id, report_id: report_id} do - [note, _] = Repo.all(ReportNote) - - assert %{ - activity_id: ^report_id, - content: "this is disgusting!", - user_id: ^admin_id - } = note - end - - test "it returns reports with notes", %{conn: conn, admin: admin} do - conn = get(conn, "/api/pleroma/admin/reports") - - response = json_response(conn, 200) - notes = hd(response["reports"])["notes"] - [note, _] = notes - - assert note["user"]["nickname"] == admin.nickname - assert note["content"] == "this is disgusting!" - assert note["created_at"] - assert response["total"] == 1 - end - - test "it deletes the note", %{conn: conn, report_id: report_id} do - assert ReportNote |> Repo.all() |> length() == 2 - - [note, _] = Repo.all(ReportNote) - - delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") - - assert ReportNote |> Repo.all() |> length() == 1 - end - end - - describe "GET /api/pleroma/admin/config/descriptions" do - test "structure", %{conn: conn} do - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") - - assert [child | _others] = json_response(conn, 200) - - assert child["children"] - assert child["key"] - assert String.starts_with?(child["group"], ":") - assert child["description"] - end - - test "filters by database configuration whitelist", %{conn: conn} do - clear_config(:database_config_whitelist, [ - {:pleroma, :instance}, - {:pleroma, :activitypub}, - {:pleroma, Pleroma.Upload}, - {:esshd} - ]) - - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") - - children = json_response(conn, 200) - - assert length(children) == 4 - - assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 - - instance = Enum.find(children, fn c -> c["key"] == ":instance" end) - assert instance["children"] - - activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) - assert activitypub["children"] - - web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) - assert web_endpoint["children"] - - esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) - assert esshd["children"] - end - end - - describe "/api/pleroma/admin/stats" do - test "status visibility count", %{conn: conn} do - admin = insert(:user, is_admin: true) - user = insert(:user) - CommonAPI.post(user, %{visibility: "public", status: "hey"}) - CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) - CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) - - response = - conn - |> assign(:user, admin) - |> get("/api/pleroma/admin/stats") - |> json_response(200) - - assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 2} = - response["status_visibility"] - end - end - - describe "POST /api/pleroma/admin/oauth_app" do - test "errors", %{conn: conn} do - response = conn |> post("/api/pleroma/admin/oauth_app", %{}) |> json_response(200) - - assert response == %{"name" => "can't be blank", "redirect_uris" => "can't be blank"} - end - - test "success", %{conn: conn} do - base_url = Web.base_url() - app_name = "Trusted app" - - response = - conn - |> post("/api/pleroma/admin/oauth_app", %{ - name: app_name, - redirect_uris: base_url - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "name" => ^app_name, - "redirect_uri" => ^base_url, - "trusted" => false - } = response - end - - test "with trusted", %{conn: conn} do - base_url = Web.base_url() - app_name = "Trusted app" - - response = - conn - |> post("/api/pleroma/admin/oauth_app", %{ - name: app_name, - redirect_uris: base_url, - trusted: true - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "name" => ^app_name, - "redirect_uri" => ^base_url, - "trusted" => true - } = response - end - end - - describe "GET /api/pleroma/admin/oauth_app" do - setup do - app = insert(:oauth_app) - {:ok, app: app} - end - - test "list", %{conn: conn} do - response = - conn - |> get("/api/pleroma/admin/oauth_app") - |> json_response(200) - - assert %{"apps" => apps, "count" => count, "page_size" => _} = response - - assert length(apps) == count - end - - test "with page size", %{conn: conn} do - insert(:oauth_app) - page_size = 1 - - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{page_size: to_string(page_size)}) - |> json_response(200) - - assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response - - assert length(apps) == page_size - end - - test "search by client name", %{conn: conn, app: app} do - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{name: app.client_name}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - - test "search by client id", %{conn: conn, app: app} do - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{client_id: app.client_id}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - - test "only trusted", %{conn: conn} do - app = insert(:oauth_app, trusted: true) - - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{trusted: true}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - end - - describe "DELETE /api/pleroma/admin/oauth_app/:id" do - test "with id", %{conn: conn} do - app = insert(:oauth_app) - - response = - conn - |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) - |> json_response(:no_content) - - assert response == "" - end - - test "with non existance id", %{conn: conn} do - response = - conn - |> delete("/api/pleroma/admin/oauth_app/0") - |> json_response(:bad_request) - - assert response == "" - end - end - - describe "PATCH /api/pleroma/admin/oauth_app/:id" do - test "with id", %{conn: conn} do - app = insert(:oauth_app) - - name = "another name" - url = "https://example.com" - scopes = ["admin"] - id = app.id - website = "http://website.com" - - response = - conn - |> patch("/api/pleroma/admin/oauth_app/" <> to_string(app.id), %{ - name: name, - trusted: true, - redirect_uris: url, - scopes: scopes, - website: website - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "id" => ^id, - "name" => ^name, - "redirect_uri" => ^url, - "trusted" => true, - "website" => ^website - } = response - end - - test "without id", %{conn: conn} do - response = - conn - |> patch("/api/pleroma/admin/oauth_app/0") - |> json_response(:bad_request) - - assert response == "" - end - end -end - -# Needed for testing -defmodule Pleroma.Web.Endpoint.NotReal do -end - -defmodule Pleroma.Captcha.NotReal do -end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs new file mode 100644 index 000000000..2c317e0fe --- /dev/null +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -0,0 +1,3707 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do + use Pleroma.Web.ConnCase + use Oban.Testing, repo: Pleroma.Repo + + import ExUnit.CaptureLog + import Mock + import Pleroma.Factory + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.HTML + alias Pleroma.MFA + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.ReportNote + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User + alias Pleroma.UserInviteToken + alias Pleroma.Web + alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.MediaProxy + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + + :ok + end + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "with [:auth, :enforce_oauth_admin_scope_usage]," do + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) + + test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope", + %{admin: admin} do + user = insert(:user) + url = "/api/pleroma/admin/users/#{user.nickname}" + + good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"]) + good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"]) + + bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts"]) + bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"]) + bad_token3 = nil + + for good_token <- [good_token1, good_token2, good_token3] do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, 200) + end + + for good_token <- [good_token1, good_token2, good_token3] do + conn = + build_conn() + |> assign(:user, nil) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + + for bad_token <- [bad_token1, bad_token2, bad_token3] do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, bad_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + end + end + + describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do + setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + + test "GET /api/pleroma/admin/users/:nickname requires " <> + "read:accounts or admin:read:accounts or broader scope", + %{admin: admin} do + user = insert(:user) + url = "/api/pleroma/admin/users/#{user.nickname}" + + good_token1 = insert(:oauth_token, user: admin, scopes: ["admin"]) + good_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + good_token3 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts"]) + good_token4 = insert(:oauth_token, user: admin, scopes: ["read:accounts"]) + good_token5 = insert(:oauth_token, user: admin, scopes: ["read"]) + + good_tokens = [good_token1, good_token2, good_token3, good_token4, good_token5] + + bad_token1 = insert(:oauth_token, user: admin, scopes: ["read:accounts:partial"]) + bad_token2 = insert(:oauth_token, user: admin, scopes: ["admin:read:accounts:partial"]) + bad_token3 = nil + + for good_token <- good_tokens do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, 200) + end + + for good_token <- good_tokens do + conn = + build_conn() + |> assign(:user, nil) + |> assign(:token, good_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + + for bad_token <- [bad_token1, bad_token2, bad_token3] do + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, bad_token) + |> get(url) + + assert json_response(conn, :forbidden) + end + end + end + + describe "DELETE /api/pleroma/admin/users" do + test "single user", %{admin: admin, conn: conn} do + user = insert(:user) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users?nickname=#{user.nickname}") + + ObanHelpers.perform_all() + + assert User.get_by_nickname(user.nickname).deactivated + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deleted users: @#{user.nickname}" + + assert json_response(conn, 200) == [user.nickname] + + assert called(Pleroma.Web.Federator.publish(:_)) + end + end + + test "multiple users", %{admin: admin, conn: conn} do + user_one = insert(:user) + user_two = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users", %{ + nicknames: [user_one.nickname, user_two.nickname] + }) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}" + + response = json_response(conn, 200) + assert response -- [user_one.nickname, user_two.nickname] == [] + end + end + + describe "/api/pleroma/admin/users" do + test "Create", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "lain", + "email" => "lain@example.org", + "password" => "test" + }, + %{ + "nickname" => "lain2", + "email" => "lain2@example.org", + "password" => "test" + } + ] + }) + + response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type")) + assert response == ["success", "success"] + + log_entry = Repo.one(ModerationLog) + + assert ["lain", "lain2"] -- Enum.map(log_entry.data["subjects"], & &1["nickname"]) == [] + end + + test "Cannot create user with existing email", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "lain", + "email" => user.email, + "password" => "test" + } + ] + }) + + assert json_response(conn, 409) == [ + %{ + "code" => 409, + "data" => %{ + "email" => user.email, + "nickname" => "lain" + }, + "error" => "email has already been taken", + "type" => "error" + } + ] + end + + test "Cannot create user with existing nickname", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => user.nickname, + "email" => "someuser@plerama.social", + "password" => "test" + } + ] + }) + + assert json_response(conn, 409) == [ + %{ + "code" => 409, + "data" => %{ + "email" => "someuser@plerama.social", + "nickname" => user.nickname + }, + "error" => "nickname has already been taken", + "type" => "error" + } + ] + end + + test "Multiple user creation works in transaction", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users", %{ + "users" => [ + %{ + "nickname" => "newuser", + "email" => "newuser@pleroma.social", + "password" => "test" + }, + %{ + "nickname" => "lain", + "email" => user.email, + "password" => "test" + } + ] + }) + + assert json_response(conn, 409) == [ + %{ + "code" => 409, + "data" => %{ + "email" => user.email, + "nickname" => "lain" + }, + "error" => "email has already been taken", + "type" => "error" + }, + %{ + "code" => 409, + "data" => %{ + "email" => "newuser@pleroma.social", + "nickname" => "newuser" + }, + "error" => "", + "type" => "error" + } + ] + + assert User.get_by_nickname("newuser") === nil + end + end + + describe "/api/pleroma/admin/users/:nickname" do + test "Show", %{conn: conn} do + user = insert(:user) + + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") + + expected = %{ + "deactivated" => false, + "id" => to_string(user.id), + "local" => true, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + + assert expected == json_response(conn, 200) + end + + test "when the user doesn't exist", %{conn: conn} do + user = build(:user) + + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") + + assert "Not found" == json_response(conn, 404) + end + end + + describe "/api/pleroma/admin/users/follow" do + test "allows to force-follow another user", %{admin: admin, conn: conn} do + user = insert(:user) + follower = insert(:user) + + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users/follow", %{ + "follower" => follower.nickname, + "followed" => user.nickname + }) + + user = User.get_cached_by_id(user.id) + follower = User.get_cached_by_id(follower.id) + + assert User.following?(follower, user) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} made @#{follower.nickname} follow @#{user.nickname}" + end + end + + describe "/api/pleroma/admin/users/unfollow" do + test "allows to force-unfollow another user", %{admin: admin, conn: conn} do + user = insert(:user) + follower = insert(:user) + + User.follow(follower, user) + + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users/unfollow", %{ + "follower" => follower.nickname, + "followed" => user.nickname + }) + + user = User.get_cached_by_id(user.id) + follower = User.get_cached_by_id(follower.id) + + refute User.following?(follower, user) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} made @#{follower.nickname} unfollow @#{user.nickname}" + end + end + + describe "PUT /api/pleroma/admin/users/tag" do + setup %{conn: conn} do + user1 = insert(:user, %{tags: ["x"]}) + user2 = insert(:user, %{tags: ["y"]}) + user3 = insert(:user, %{tags: ["unchanged"]}) + + conn = + conn + |> put_req_header("accept", "application/json") + |> put( + "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=" <> + "#{user2.nickname}&tags[]=foo&tags[]=bar" + ) + + %{conn: conn, user1: user1, user2: user2, user3: user3} + end + + test "it appends specified tags to users with specified nicknames", %{ + conn: conn, + admin: admin, + user1: user1, + user2: user2 + } do + assert json_response(conn, :no_content) + assert User.get_cached_by_id(user1.id).tags == ["x", "foo", "bar"] + assert User.get_cached_by_id(user2.id).tags == ["y", "foo", "bar"] + + log_entry = Repo.one(ModerationLog) + + users = + [user1.nickname, user2.nickname] + |> Enum.map(&"@#{&1}") + |> Enum.join(", ") + + tags = ["foo", "bar"] |> Enum.join(", ") + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} added tags: #{tags} to users: #{users}" + end + + test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do + assert json_response(conn, :no_content) + assert User.get_cached_by_id(user3.id).tags == ["unchanged"] + end + end + + describe "DELETE /api/pleroma/admin/users/tag" do + setup %{conn: conn} do + user1 = insert(:user, %{tags: ["x"]}) + user2 = insert(:user, %{tags: ["y", "z"]}) + user3 = insert(:user, %{tags: ["unchanged"]}) + + conn = + conn + |> put_req_header("accept", "application/json") + |> delete( + "/api/pleroma/admin/users/tag?nicknames[]=#{user1.nickname}&nicknames[]=" <> + "#{user2.nickname}&tags[]=x&tags[]=z" + ) + + %{conn: conn, user1: user1, user2: user2, user3: user3} + end + + test "it removes specified tags from users with specified nicknames", %{ + conn: conn, + admin: admin, + user1: user1, + user2: user2 + } do + assert json_response(conn, :no_content) + assert User.get_cached_by_id(user1.id).tags == [] + assert User.get_cached_by_id(user2.id).tags == ["y"] + + log_entry = Repo.one(ModerationLog) + + users = + [user1.nickname, user2.nickname] + |> Enum.map(&"@#{&1}") + |> Enum.join(", ") + + tags = ["x", "z"] |> Enum.join(", ") + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} removed tags: #{tags} from users: #{users}" + end + + test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do + assert json_response(conn, :no_content) + assert User.get_cached_by_id(user3.id).tags == ["unchanged"] + end + end + + describe "/api/pleroma/admin/users/:nickname/permission_group" do + test "GET is giving user_info", %{admin: admin, conn: conn} do + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/api/pleroma/admin/users/#{admin.nickname}/permission_group/") + + assert json_response(conn, 200) == %{ + "is_admin" => true, + "is_moderator" => false + } + end + + test "/:right POST, can add to a permission group", %{admin: admin, conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin") + + assert json_response(conn, 200) == %{ + "is_admin" => true + } + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} made @#{user.nickname} admin" + end + + test "/:right POST, can add to a permission group (multiple)", %{admin: admin, conn: conn} do + user_one = insert(:user) + user_two = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> post("/api/pleroma/admin/users/permission_group/admin", %{ + nicknames: [user_one.nickname, user_two.nickname] + }) + + assert json_response(conn, 200) == %{"is_admin" => true} + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} made @#{user_one.nickname}, @#{user_two.nickname} admin" + end + + test "/:right DELETE, can remove from a permission group", %{admin: admin, conn: conn} do + user = insert(:user, is_admin: true) + + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users/#{user.nickname}/permission_group/admin") + + assert json_response(conn, 200) == %{"is_admin" => false} + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} revoked admin role from @#{user.nickname}" + end + + test "/:right DELETE, can remove from a permission group (multiple)", %{ + admin: admin, + conn: conn + } do + user_one = insert(:user, is_admin: true) + user_two = insert(:user, is_admin: true) + + conn = + conn + |> put_req_header("accept", "application/json") + |> delete("/api/pleroma/admin/users/permission_group/admin", %{ + nicknames: [user_one.nickname, user_two.nickname] + }) + + assert json_response(conn, 200) == %{"is_admin" => false} + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} revoked admin role from @#{user_one.nickname}, @#{ + user_two.nickname + }" + end + end + + describe "POST /api/pleroma/admin/email_invite, with valid config" do + setup do: clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :invites_enabled], true) + + test "sends invitation and returns 204", %{admin: admin, conn: conn} do + recipient_email = "foo@bar.com" + recipient_name = "J. D." + + conn = + post( + conn, + "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" + ) + + assert json_response(conn, :no_content) + + token_record = List.last(Repo.all(Pleroma.UserInviteToken)) + 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, + recipient_name + ) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {recipient_name, recipient_email}, + html_body: email.html_body + ) + end + + test "it returns 403 if requested by a non-admin" do + non_admin_user = insert(:user) + token = insert(:oauth_token, user: non_admin_user) + + conn = + build_conn() + |> assign(:user, non_admin_user) + |> assign(:token, token) + |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + 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 + setup do: clear_config([:instance, :registrations_open]) + setup do: clear_config([:instance, :invites_enabled]) + + test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do + Config.put([:instance, :registrations_open], false) + Config.put([:instance, :invites_enabled], false) + + 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 to set the `invites_enabled` option to true." + end + + test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :invites_enabled], true) + + 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 to set the `registrations_open` option to false." + end + end + + test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do + user = insert(:user) + + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/api/pleroma/admin/users/#{user.nickname}/password_reset") + + resp = json_response(conn, 200) + + assert Regex.match?(~r/(http:\/\/|https:\/\/)/, resp["link"]) + end + + describe "GET /api/pleroma/admin/users" do + test "renders users array for the first page", %{conn: conn, admin: admin} do + user = insert(:user, local: false, tags: ["foo", "bar"]) + conn = get(conn, "/api/pleroma/admin/users?page=1") + + users = + [ + %{ + "deactivated" => admin.deactivated, + "id" => admin.id, + "nickname" => admin.nickname, + "roles" => %{"admin" => true, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(admin) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(admin.name || admin.nickname), + "confirmation_pending" => false + }, + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => false, + "tags" => ["foo", "bar"], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => users + } + end + + test "pagination works correctly with service users", %{conn: conn} do + service1 = insert(:user, ap_id: Web.base_url() <> "/relay") + service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch") + insert_list(25, :user) + + assert %{"count" => 26, "page_size" => 10, "users" => users1} = + conn + |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users1) == 10 + assert service1 not in [users1] + assert service2 not in [users1] + + assert %{"count" => 26, "page_size" => 10, "users" => users2} = + conn + |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users2) == 10 + assert service1 not in [users2] + assert service2 not in [users2] + + assert %{"count" => 26, "page_size" => 10, "users" => users3} = + conn + |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"}) + |> json_response(200) + + assert Enum.count(users3) == 6 + assert service1 not in [users3] + assert service2 not in [users3] + end + + test "renders empty array for the second page", %{conn: conn} do + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?page=2") + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => [] + } + end + + test "regular search", %{conn: conn} do + user = insert(:user, nickname: "bob") + + conn = get(conn, "/api/pleroma/admin/users?query=bo") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "search by domain", %{conn: conn} do + user = insert(:user, nickname: "nickname@domain.com") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?query=domain.com") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "search by full nickname", %{conn: conn} do + user = insert(:user, nickname: "nickname@domain.com") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?query=nickname@domain.com") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "search by display name", %{conn: conn} do + user = insert(:user, name: "Display name") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?name=display") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "search by email", %{conn: conn} do + user = insert(:user, email: "email@example.com") + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?email=email@example.com") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "regular search with page size", %{conn: conn} do + user = insert(:user, nickname: "aalice") + user2 = insert(:user, nickname: "alice") + + conn1 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=1") + + assert json_response(conn1, 200) == %{ + "count" => 2, + "page_size" => 1, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + + conn2 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=2") + + assert json_response(conn2, 200) == %{ + "count" => 2, + "page_size" => 1, + "users" => [ + %{ + "deactivated" => user2.deactivated, + "id" => user2.id, + "nickname" => user2.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user2) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user2.name || user2.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "only local users" do + admin = insert(:user, is_admin: true, nickname: "john") + token = insert(:oauth_admin_token, user: admin) + user = insert(:user, nickname: "bob") + + insert(:user, nickname: "bobb", local: false) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> get("/api/pleroma/admin/users?query=bo&filters=local") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "only local users with no query", %{conn: conn, admin: old_admin} do + admin = insert(:user, is_admin: true, nickname: "john") + user = insert(:user, nickname: "bob") + + insert(:user, nickname: "bobb", local: false) + + conn = get(conn, "/api/pleroma/admin/users?filters=local") + + users = + [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + }, + %{ + "deactivated" => admin.deactivated, + "id" => admin.id, + "nickname" => admin.nickname, + "roles" => %{"admin" => true, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(admin) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(admin.name || admin.nickname), + "confirmation_pending" => false + }, + %{ + "deactivated" => false, + "id" => old_admin.id, + "local" => true, + "nickname" => old_admin.nickname, + "roles" => %{"admin" => true, "moderator" => false}, + "tags" => [], + "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), + "confirmation_pending" => false + } + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 3, + "page_size" => 50, + "users" => users + } + end + + test "load only admins", %{conn: conn, admin: admin} do + second_admin = insert(:user, is_admin: true) + insert(:user) + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?filters=is_admin") + + users = + [ + %{ + "deactivated" => false, + "id" => admin.id, + "nickname" => admin.nickname, + "roles" => %{"admin" => true, "moderator" => false}, + "local" => admin.local, + "tags" => [], + "avatar" => User.avatar_url(admin) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(admin.name || admin.nickname), + "confirmation_pending" => false + }, + %{ + "deactivated" => false, + "id" => second_admin.id, + "nickname" => second_admin.nickname, + "roles" => %{"admin" => true, "moderator" => false}, + "local" => second_admin.local, + "tags" => [], + "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), + "confirmation_pending" => false + } + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => users + } + end + + test "load only moderators", %{conn: conn} do + moderator = insert(:user, is_moderator: true) + insert(:user) + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?filters=is_moderator") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => false, + "id" => moderator.id, + "nickname" => moderator.nickname, + "roles" => %{"admin" => false, "moderator" => true}, + "local" => moderator.local, + "tags" => [], + "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "load users with tags list", %{conn: conn} do + user1 = insert(:user, tags: ["first"]) + user2 = insert(:user, tags: ["second"]) + insert(:user) + insert(:user) + + conn = get(conn, "/api/pleroma/admin/users?tags[]=first&tags[]=second") + + users = + [ + %{ + "deactivated" => false, + "id" => user1.id, + "nickname" => user1.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => user1.local, + "tags" => ["first"], + "avatar" => User.avatar_url(user1) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user1.name || user1.nickname), + "confirmation_pending" => false + }, + %{ + "deactivated" => false, + "id" => user2.id, + "nickname" => user2.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => user2.local, + "tags" => ["second"], + "avatar" => User.avatar_url(user2) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user2.name || user2.nickname), + "confirmation_pending" => false + } + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 2, + "page_size" => 50, + "users" => users + } + end + + test "it works with multiple filters" do + admin = insert(:user, nickname: "john", is_admin: true) + token = insert(:oauth_admin_token, user: admin) + user = insert(:user, nickname: "bob", local: false, deactivated: true) + + insert(:user, nickname: "ken", local: true, deactivated: true) + insert(:user, nickname: "bobb", local: false, deactivated: false) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> get("/api/pleroma/admin/users?filters=deactivated,external") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => user.local, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + ] + } + end + + test "it omits relay user", %{admin: admin, conn: conn} do + assert %User{} = Relay.get_actor() + + conn = get(conn, "/api/pleroma/admin/users") + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => [ + %{ + "deactivated" => admin.deactivated, + "id" => admin.id, + "nickname" => admin.nickname, + "roles" => %{"admin" => true, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(admin) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(admin.name || admin.nickname), + "confirmation_pending" => false + } + ] + } + end + end + + test "PATCH /api/pleroma/admin/users/activate", %{admin: admin, conn: conn} do + user_one = insert(:user, deactivated: true) + user_two = insert(:user, deactivated: true) + + conn = + patch( + conn, + "/api/pleroma/admin/users/activate", + %{nicknames: [user_one.nickname, user_two.nickname]} + ) + + response = json_response(conn, 200) + assert Enum.map(response["users"], & &1["deactivated"]) == [false, false] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} activated users: @#{user_one.nickname}, @#{user_two.nickname}" + end + + test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do + user_one = insert(:user, deactivated: false) + user_two = insert(:user, deactivated: false) + + conn = + patch( + conn, + "/api/pleroma/admin/users/deactivate", + %{nicknames: [user_one.nickname, user_two.nickname]} + ) + + response = json_response(conn, 200) + assert Enum.map(response["users"], & &1["deactivated"]) == [true, true] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}" + end + + test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do + user = insert(:user) + + conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation") + + assert json_response(conn, 200) == + %{ + "deactivated" => !user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false + } + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deactivated users: @#{user.nickname}" + end + + describe "PUT disable_mfa" do + test "returns 200 and disable 2fa", %{conn: conn} do + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true} + } + ) + + response = + conn + |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname}) + |> json_response(200) + + assert response == user.nickname + mfa_settings = refresh_record(user).multi_factor_authentication_settings + + refute mfa_settings.enabled + refute mfa_settings.totp.confirmed + end + + test "returns 404 if user not found", %{conn: conn} do + response = + conn + |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"}) + |> json_response(404) + + assert response == "Not found" + end + end + + describe "POST /api/pleroma/admin/users/invite_token" do + test "without options", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/invite_token") + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + refute invite.max_use + assert invite.invite_type == "one_time" + end + + test "with expires_at", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/users/invite_token", %{ + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + + refute invite.used + assert invite.expires_at == Date.utc_today() + refute invite.max_use + assert invite.invite_type == "date_limited" + end + + test "with max_use", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + assert invite.max_use == 150 + assert invite.invite_type == "reusable" + end + + test "with max use and expires_at", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/users/invite_token", %{ + "max_use" => 150, + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + assert invite.expires_at == Date.utc_today() + assert invite.max_use == 150 + assert invite.invite_type == "reusable_date_limited" + end + end + + describe "GET /api/pleroma/admin/users/invites" do + test "no invites", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response(conn, 200) == %{"invites" => []} + end + + test "with invite", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response(conn, 200) == %{ + "invites" => [ + %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => false, + "uses" => 0 + } + ] + } + end + end + + describe "POST /api/pleroma/admin/users/revoke_invite" do + test "with token", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) + + assert json_response(conn, 200) == %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => true, + "uses" => 0 + } + end + + test "with invalid token", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) + + assert json_response(conn, :not_found) == "Not found" + end + end + + describe "GET /api/pleroma/admin/reports/:id" do + test "returns report by its id", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + response = + conn + |> get("/api/pleroma/admin/reports/#{report_id}") + |> json_response(:ok) + + assert response["id"] == report_id + end + + test "returns 404 when report id is invalid", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/reports/test") + + assert json_response(conn, :not_found) == "Not found" + end + end + + describe "PATCH /api/pleroma/admin/reports" do + setup do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel very offended", + status_ids: [activity.id] + }) + + %{ + id: report_id, + second_report_id: second_report_id + } + end + + test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do + read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"]) + + response = + conn + |> assign(:token, read_token) + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [%{"state" => "resolved", "id" => id}] + }) + |> json_response(403) + + assert response == %{ + "error" => "Insufficient permissions: admin:write:reports." + } + + conn + |> assign(:token, write_token) + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [%{"state" => "resolved", "id" => id}] + }) + |> json_response(:no_content) + end + + test "mark report as resolved", %{conn: conn, id: id, admin: admin} do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "resolved", "id" => id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + assert activity.data["state"] == "resolved" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated report ##{id} with 'resolved' state" + end + + test "closes report", %{conn: conn, id: id, admin: admin} do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "closed", "id" => id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + assert activity.data["state"] == "closed" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated report ##{id} with 'closed' state" + end + + test "returns 400 when state is unknown", %{conn: conn, id: id} do + conn = + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "test", "id" => id} + ] + }) + + assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" + end + + test "returns 404 when report is not exist", %{conn: conn} do + conn = + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "closed", "id" => "test"} + ] + }) + + assert hd(json_response(conn, :bad_request))["error"] == "not_found" + end + + test "updates state of multiple reports", %{ + conn: conn, + id: id, + admin: admin, + second_report_id: second_report_id + } do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "resolved", "id" => id}, + %{"state" => "closed", "id" => second_report_id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + second_activity = Activity.get_by_id(second_report_id) + assert activity.data["state"] == "resolved" + assert second_activity.data["state"] == "closed" + + [first_log_entry, second_log_entry] = Repo.all(ModerationLog) + + assert ModerationLog.get_log_entry_message(first_log_entry) == + "@#{admin.nickname} updated report ##{id} with 'resolved' state" + + assert ModerationLog.get_log_entry_message(second_log_entry) == + "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" + end + end + + describe "GET /api/pleroma/admin/reports" do + test "returns empty response when no reports created", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/reports") + |> json_response(:ok) + + assert Enum.empty?(response["reports"]) + assert response["total"] == 0 + end + + test "returns reports", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + response = + conn + |> get("/api/pleroma/admin/reports") + |> json_response(:ok) + + [report] = response["reports"] + + assert length(response["reports"]) == 1 + assert report["id"] == report_id + + assert response["total"] == 1 + end + + test "returns reports with specified state", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: first_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I don't like this user" + }) + + CommonAPI.update_report_state(second_report_id, "closed") + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "open" + }) + |> json_response(:ok) + + [open_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert open_report["id"] == first_report_id + + assert response["total"] == 1 + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "closed" + }) + |> json_response(:ok) + + [closed_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert closed_report["id"] == second_report_id + + assert response["total"] == 1 + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "resolved" + }) + |> json_response(:ok) + + assert Enum.empty?(response["reports"]) + assert response["total"] == 0 + end + + test "returns 403 when requested by a non-admin" do + user = insert(:user) + token = insert(:oauth_token, user: user) + + conn = + build_conn() + |> assign(:user, user) + |> assign(:token, token) + |> get("/api/pleroma/admin/reports") + + assert json_response(conn, :forbidden) == + %{"error" => "User is not an admin or OAuth admin scope is not granted."} + end + + test "returns 403 when requested by anonymous" do + conn = get(build_conn(), "/api/pleroma/admin/reports") + + assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} + end + end + + describe "GET /api/pleroma/admin/config" do + setup do: clear_config(:configurable_from_database, true) + + test "when configuration from database is off", %{conn: conn} do + Config.put(:configurable_from_database, false) + conn = get(conn, "/api/pleroma/admin/config") + + assert json_response(conn, 400) == + "To use this endpoint you need to enable configuration from database." + end + + test "with settings only in db", %{conn: conn} do + config1 = insert(:config) + config2 = insert(:config) + + conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) + + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => key1, + "value" => _ + }, + %{ + "group" => ":pleroma", + "key" => key2, + "value" => _ + } + ] + } = json_response(conn, 200) + + assert key1 == config1.key + assert key2 == config2.key + end + + test "db is added to settings that are in db", %{conn: conn} do + _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + [instance_config] = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key == ":instance" + end) + + assert instance_config["db"] == [":name"] + end + + test "merged default setting with db settings", %{conn: conn} do + config1 = insert(:config) + config2 = insert(:config) + + config3 = + insert(:config, + value: ConfigDB.to_binary(k1: :v1, k2: :v2) + ) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert length(configs) > 3 + + received_configs = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key in [config1.key, config2.key, config3.key] + end) + + assert length(received_configs) == 3 + + db_keys = + config3.value + |> ConfigDB.from_binary() + |> Keyword.keys() + |> ConfigDB.convert() + + Enum.each(received_configs, fn %{"value" => value, "db" => db} -> + assert db in [[config1.key], [config2.key], db_keys] + + assert value in [ + ConfigDB.from_binary_with_convert(config1.value), + ConfigDB.from_binary_with_convert(config2.value), + ConfigDB.from_binary_with_convert(config3.value) + ] + end) + end + + test "subkeys with full update right merge", %{conn: conn} do + config1 = + insert(:config, + key: ":emoji", + value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) + ) + + config2 = + insert(:config, + key: ":assets", + value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) + ) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + vals = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key in [config1.key, config2.key] + end) + + emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) + assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) + + emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) + assets_val = ConfigDB.transform_with_out_binary(assets["value"]) + + assert emoji_val[:groups] == [a: 1, b: 2] + assert assets_val[:mascots] == [a: 1, b: 2] + end + end + + test "POST /api/pleroma/admin/config error", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) + + assert json_response(conn, 400) == + "To use this endpoint you need to enable configuration from database." + end + + describe "POST /api/pleroma/admin/config" do + setup do + http = Application.get_env(:pleroma, :http) + + on_exit(fn -> + Application.delete_env(:pleroma, :key1) + Application.delete_env(:pleroma, :key2) + Application.delete_env(:pleroma, :key3) + Application.delete_env(:pleroma, :key4) + Application.delete_env(:pleroma, :keyaa1) + Application.delete_env(:pleroma, :keyaa2) + Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) + Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) + Application.put_env(:pleroma, :http, http) + Application.put_env(:tesla, :adapter, Tesla.Mock) + Restarter.Pleroma.refresh() + end) + end + + setup do: clear_config(:configurable_from_database, true) + + @tag capture_log: true + test "create new config setting in db", %{conn: conn} do + ueberauth = Application.get_env(:ueberauth, Ueberauth) + on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{ + group: ":ueberauth", + key: "Ueberauth", + value: [%{"tuple" => [":consumer_secret", "aaaa"]}] + }, + %{ + group: ":pleroma", + key: ":key2", + value: %{ + ":nested_1" => "nested_value1", + ":nested_2" => [ + %{":nested_22" => "nested_value222"}, + %{":nested_33" => %{":nested_44" => "nested_444"}} + ] + } + }, + %{ + group: ":pleroma", + key: ":key3", + value: [ + %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, + %{"nested_4" => true} + ] + }, + %{ + group: ":pleroma", + key: ":key4", + value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} + }, + %{ + group: ":idna", + key: ":key5", + value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => "value1", + "db" => [":key1"] + }, + %{ + "group" => ":ueberauth", + "key" => "Ueberauth", + "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}], + "db" => [":consumer_secret"] + }, + %{ + "group" => ":pleroma", + "key" => ":key2", + "value" => %{ + ":nested_1" => "nested_value1", + ":nested_2" => [ + %{":nested_22" => "nested_value222"}, + %{":nested_33" => %{":nested_44" => "nested_444"}} + ] + }, + "db" => [":key2"] + }, + %{ + "group" => ":pleroma", + "key" => ":key3", + "value" => [ + %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, + %{"nested_4" => true} + ], + "db" => [":key3"] + }, + %{ + "group" => ":pleroma", + "key" => ":key4", + "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}, + "db" => [":key4"] + }, + %{ + "group" => ":idna", + "key" => ":key5", + "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, + "db" => [":key5"] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == "value1" + + assert Application.get_env(:pleroma, :key2) == %{ + nested_1: "nested_value1", + nested_2: [ + %{nested_22: "nested_value222"}, + %{nested_33: %{nested_44: "nested_444"}} + ] + } + + assert Application.get_env(:pleroma, :key3) == [ + %{"nested_3" => :nested_3, "nested_33" => "nested_33"}, + %{"nested_4" => true} + ] + + assert Application.get_env(:pleroma, :key4) == %{ + "endpoint" => "https://example.com", + nested_5: :upload + } + + assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} + end + + test "save configs setting without explicit key", %{conn: conn} do + level = Application.get_env(:quack, :level) + meta = Application.get_env(:quack, :meta) + webhook_url = Application.get_env(:quack, :webhook_url) + + on_exit(fn -> + Application.put_env(:quack, :level, level) + Application.put_env(:quack, :meta, meta) + Application.put_env(:quack, :webhook_url, webhook_url) + end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":quack", + key: ":level", + value: ":info" + }, + %{ + group: ":quack", + key: ":meta", + value: [":none"] + }, + %{ + group: ":quack", + key: ":webhook_url", + value: "https://hooks.slack.com/services/KEY" + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":quack", + "key" => ":level", + "value" => ":info", + "db" => [":level"] + }, + %{ + "group" => ":quack", + "key" => ":meta", + "value" => [":none"], + "db" => [":meta"] + }, + %{ + "group" => ":quack", + "key" => ":webhook_url", + "value" => "https://hooks.slack.com/services/KEY", + "db" => [":webhook_url"] + } + ] + } + + assert Application.get_env(:quack, :level) == :info + assert Application.get_env(:quack, :meta) == [:none] + assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" + end + + test "saving config with partial update", %{conn: conn} do + config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key2", 2]}, + %{"tuple" => [":key3", 3]} + ], + "db" => [":key1", ":key2", ":key3"] + } + ] + } + end + + test "saving config which need pleroma reboot", %{conn: conn} do + chat = Config.get(:chat) + on_exit(fn -> Config.put(:chat, chat) end) + + assert post( + conn, + "/api/pleroma/admin/config", + %{ + configs: [ + %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + ] + } + ) + |> json_response(200) == %{ + "configs" => [ + %{ + "db" => [":enabled"], + "group" => ":pleroma", + "key" => ":chat", + "value" => [%{"tuple" => [":enabled", true]}] + } + ], + "need_reboot" => true + } + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] + + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] == false + end + + test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do + chat = Config.get(:chat) + on_exit(fn -> Config.put(:chat, chat) end) + + assert post( + conn, + "/api/pleroma/admin/config", + %{ + configs: [ + %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + ] + } + ) + |> json_response(200) == %{ + "configs" => [ + %{ + "db" => [":enabled"], + "group" => ":pleroma", + "key" => ":chat", + "value" => [%{"tuple" => [":enabled", true]}] + } + ], + "need_reboot" => true + } + + assert post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} + ] + }) + |> json_response(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key3", 3]} + ], + "db" => [":key3"] + } + ], + "need_reboot" => true + } + + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] == false + end + + test "saving config with nested merge", %{conn: conn} do + config = + insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [ + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k1", 1]}, + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ], + "db" => [":key1", ":key3", ":key2"] + } + ] + } + end + + test "saving special atoms", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ], + "db" => [":ssl_options"] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == [ + ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] + ] + end + + test "saving full setting if value is in full_key_update list", %{conn: conn} do + backends = Application.get_env(:logger, :backends) + on_exit(fn -> Application.put_env(:logger, :backends, backends) end) + + config = + insert(:config, + group: ":logger", + key: ":backends", + value: :erlang.term_to_binary([]) + ) + + Pleroma.Config.TransferTask.load_and_update_env([], false) + + assert Application.get_env(:logger, :backends) == [] + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [":console"] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":logger", + "key" => ":backends", + "value" => [ + ":console" + ], + "db" => [":backends"] + } + ] + } + + assert Application.get_env(:logger, :backends) == [ + :console + ] + end + + test "saving full setting if value is not keyword", %{conn: conn} do + config = + insert(:config, + group: ":tesla", + key: ":adapter", + value: :erlang.term_to_binary(Tesla.Adapter.Hackey) + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":tesla", + "key" => ":adapter", + "value" => "Tesla.Adapter.Httpc", + "db" => [":adapter"] + } + ] + } + end + + test "update config setting & delete with fallback to default value", %{ + conn: conn, + admin: admin, + token: token + } do + ueberauth = Application.get_env(:ueberauth, Ueberauth) + config1 = insert(:config, key: ":keyaa1") + config2 = insert(:config, key: ":keyaa2") + + config3 = + insert(:config, + group: ":ueberauth", + key: "Ueberauth" + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config1.group, key: config1.key, value: "another_value"}, + %{group: config2.group, key: config2.key, value: "another_value"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => config1.key, + "value" => "another_value", + "db" => [":keyaa1"] + }, + %{ + "group" => ":pleroma", + "key" => config2.key, + "value" => "another_value", + "db" => [":keyaa2"] + } + ] + } + + assert Application.get_env(:pleroma, :keyaa1) == "another_value" + assert Application.get_env(:pleroma, :keyaa2) == "another_value" + assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{group: config2.group, key: config2.key, delete: true}, + %{ + group: ":ueberauth", + key: "Ueberauth", + delete: true + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [] + } + + assert Application.get_env(:ueberauth, Ueberauth) == ueberauth + refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2) + end + + test "common config example", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Captcha.NotReal", + "value" => [ + %{"tuple" => [":enabled", false]}, + %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, + %{"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"]}, + %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} + ] + } + ] + }) + + assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Captcha.NotReal", + "value" => [ + %{"tuple" => [":enabled", false]}, + %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, + %{"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"]}, + %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} + ], + "db" => [ + ":enabled", + ":method", + ":seconds_valid", + ":path", + ":key1", + ":partial_chain", + ":regex1", + ":regex2", + ":regex3", + ":regex4", + ":name" + ] + } + ] + } + end + + test "tuples with more than two values", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Web.Endpoint.NotReal", + "value" => [ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key2", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Web.Endpoint.NotReal", + "value" => [ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key2", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } + ] + ] + } + ], + "db" => [":http"] + } + ] + } + end + + test "settings with nesting map", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key2", "some_val"]}, + %{ + "tuple" => [ + ":key3", + %{ + ":max_options" => 20, + ":max_option_chars" => 200, + ":min_expiration" => 0, + ":max_expiration" => 31_536_000, + "nested" => %{ + ":max_options" => 20, + ":max_option_chars" => 200, + ":min_expiration" => 0, + ":max_expiration" => 31_536_000 + } + } + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key2", "some_val"]}, + %{ + "tuple" => [ + ":key3", + %{ + ":max_expiration" => 31_536_000, + ":max_option_chars" => 200, + ":max_options" => 20, + ":min_expiration" => 0, + "nested" => %{ + ":max_expiration" => 31_536_000, + ":max_option_chars" => 200, + ":max_options" => 20, + ":min_expiration" => 0 + } + } + ] + } + ], + "db" => [":key2", ":key3"] + } + ] + } + end + + test "value as map", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => %{"key" => "some_val"} + } + ] + }) + + assert json_response(conn, 200) == + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => %{"key" => "some_val"}, + "db" => [":key1"] + } + ] + } + end + + test "queues key as atom", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":oban", + "key" => ":queues", + "value" => [ + %{"tuple" => [":federator_incoming", 50]}, + %{"tuple" => [":federator_outgoing", 50]}, + %{"tuple" => [":web_push", 50]}, + %{"tuple" => [":mailer", 10]}, + %{"tuple" => [":transmogrifier", 20]}, + %{"tuple" => [":scheduled_activities", 10]}, + %{"tuple" => [":background", 5]} + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":oban", + "key" => ":queues", + "value" => [ + %{"tuple" => [":federator_incoming", 50]}, + %{"tuple" => [":federator_outgoing", 50]}, + %{"tuple" => [":web_push", 50]}, + %{"tuple" => [":mailer", 10]}, + %{"tuple" => [":transmogrifier", 20]}, + %{"tuple" => [":scheduled_activities", 10]}, + %{"tuple" => [":background", 5]} + ], + "db" => [ + ":federator_incoming", + ":federator_outgoing", + ":web_push", + ":mailer", + ":transmogrifier", + ":scheduled_activities", + ":background" + ] + } + ] + } + end + + test "delete part of settings by atom subkeys", %{conn: conn} do + config = + insert(:config, + key: ":keyaa1", + value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + subkeys: [":subkey1", ":subkey3"], + delete: true + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":keyaa1", + "value" => [%{"tuple" => [":subkey2", "val2"]}], + "db" => [":subkey2"] + } + ] + } + end + + test "proxy tuple localhost", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value + assert ":proxy_url" in db + end + + test "proxy tuple domain", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value + assert ":proxy_url" in db + end + + test "proxy tuple ip", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value + assert ":proxy_url" in db + end + + test "doesn't set keys not in the whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :key1}, + {:pleroma, :key2}, + {:pleroma, Pleroma.Captcha.NotReal}, + {:not_real} + ]) + + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{group: ":pleroma", key: ":key2", value: "value2"}, + %{group: ":pleroma", key: ":key3", value: "value3"}, + %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, + %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, + %{group: ":not_real", key: ":anything", value: "value6"} + ] + }) + + assert Application.get_env(:pleroma, :key1) == "value1" + assert Application.get_env(:pleroma, :key2) == "value2" + assert Application.get_env(:pleroma, :key3) == nil + assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil + assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" + assert Application.get_env(:not_real, :anything) == "value6" + end + end + + describe "GET /api/pleroma/admin/restart" do + setup do: clear_config(:configurable_from_database, true) + + test "pleroma restarts", %{conn: conn} do + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + refute Restarter.Pleroma.need_reboot?() + end + end + + test "need_reboot flag", %{conn: conn} do + assert conn + |> get("/api/pleroma/admin/need_reboot") + |> json_response(200) == %{"need_reboot" => false} + + Restarter.Pleroma.need_reboot() + + assert conn + |> get("/api/pleroma/admin/need_reboot") + |> json_response(200) == %{"need_reboot" => true} + + on_exit(fn -> Restarter.Pleroma.refresh() end) + end + + describe "GET /api/pleroma/admin/users/:nickname/statuses" do + setup do + user = insert(:user) + + date1 = (DateTime.to_unix(DateTime.utc_now()) + 2000) |> DateTime.from_unix!() + date2 = (DateTime.to_unix(DateTime.utc_now()) + 1000) |> DateTime.from_unix!() + date3 = (DateTime.to_unix(DateTime.utc_now()) + 3000) |> DateTime.from_unix!() + + insert(:note_activity, user: user, published: date1) + insert(:note_activity, user: user, published: date2) + insert(:note_activity, user: user, published: date3) + + %{user: user} + end + + test "renders user's statuses", %{conn: conn, user: user} do + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses") + + assert json_response(conn, 200) |> length() == 3 + end + + test "renders user's statuses with a limit", %{conn: conn, user: user} do + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?page_size=2") + + assert json_response(conn, 200) |> length() == 2 + end + + test "doesn't return private statuses by default", %{conn: conn, user: user} do + {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"}) + + {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"}) + + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses") + + assert json_response(conn, 200) |> length() == 4 + end + + test "returns private statuses with godmode on", %{conn: conn, user: user} do + {:ok, _private_status} = CommonAPI.post(user, %{status: "private", visibility: "private"}) + + {:ok, _public_status} = CommonAPI.post(user, %{status: "public", visibility: "public"}) + + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/statuses?godmode=true") + + assert json_response(conn, 200) |> length() == 5 + end + + test "excludes reblogs by default", %{conn: conn, user: user} do + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "."}) + {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, other_user) + + conn_res = get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses") + assert json_response(conn_res, 200) |> length() == 0 + + conn_res = + get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses?with_reblogs=true") + + assert json_response(conn_res, 200) |> length() == 1 + end + end + + describe "GET /api/pleroma/admin/moderation_log" do + setup do + moderator = insert(:user, is_moderator: true) + + %{moderator: moderator} + end + + test "returns the log", %{conn: conn, admin: admin} do + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_follow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.truncate(~N[2017-08-15 15:47:06.597036], :second) + }) + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_unfollow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.truncate(~N[2017-08-16 15:47:06.597036], :second) + }) + + conn = get(conn, "/api/pleroma/admin/moderation_log") + + response = json_response(conn, 200) + [first_entry, second_entry] = response["items"] + + assert response["total"] == 2 + assert first_entry["data"]["action"] == "relay_unfollow" + + assert first_entry["message"] == + "@#{admin.nickname} unfollowed relay: https://example.org/relay" + + assert second_entry["data"]["action"] == "relay_follow" + + assert second_entry["message"] == + "@#{admin.nickname} followed relay: https://example.org/relay" + end + + test "returns the log with pagination", %{conn: conn, admin: admin} do + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_follow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.truncate(~N[2017-08-15 15:47:06.597036], :second) + }) + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_unfollow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.truncate(~N[2017-08-16 15:47:06.597036], :second) + }) + + conn1 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=1") + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 2 + assert response1["items"] |> length() == 1 + assert first_entry["data"]["action"] == "relay_unfollow" + + assert first_entry["message"] == + "@#{admin.nickname} unfollowed relay: https://example.org/relay" + + conn2 = get(conn, "/api/pleroma/admin/moderation_log?page_size=1&page=2") + + response2 = json_response(conn2, 200) + [second_entry] = response2["items"] + + assert response2["total"] == 2 + assert response2["items"] |> length() == 1 + assert second_entry["data"]["action"] == "relay_follow" + + assert second_entry["message"] == + "@#{admin.nickname} followed relay: https://example.org/relay" + end + + test "filters log by date", %{conn: conn, admin: admin} do + first_date = "2017-08-15T15:47:06Z" + second_date = "2017-08-20T15:47:06Z" + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_follow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.from_iso8601!(first_date) + }) + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_unfollow", + target: "https://example.org/relay" + }, + inserted_at: NaiveDateTime.from_iso8601!(second_date) + }) + + conn1 = + get( + conn, + "/api/pleroma/admin/moderation_log?start_date=#{second_date}" + ) + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 1 + assert first_entry["data"]["action"] == "relay_unfollow" + + assert first_entry["message"] == + "@#{admin.nickname} unfollowed relay: https://example.org/relay" + end + + test "returns log filtered by user", %{conn: conn, admin: admin, moderator: moderator} do + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => admin.id, + "nickname" => admin.nickname, + "type" => "user" + }, + action: "relay_follow", + target: "https://example.org/relay" + } + }) + + Repo.insert(%ModerationLog{ + data: %{ + actor: %{ + "id" => moderator.id, + "nickname" => moderator.nickname, + "type" => "user" + }, + action: "relay_unfollow", + target: "https://example.org/relay" + } + }) + + conn1 = get(conn, "/api/pleroma/admin/moderation_log?user_id=#{moderator.id}") + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 1 + assert get_in(first_entry, ["data", "actor", "id"]) == moderator.id + end + + test "returns log filtered by search", %{conn: conn, moderator: moderator} do + ModerationLog.insert_log(%{ + actor: moderator, + action: "relay_follow", + target: "https://example.org/relay" + }) + + ModerationLog.insert_log(%{ + actor: moderator, + action: "relay_unfollow", + target: "https://example.org/relay" + }) + + conn1 = get(conn, "/api/pleroma/admin/moderation_log?search=unfo") + + response1 = json_response(conn1, 200) + [first_entry] = response1["items"] + + assert response1["total"] == 1 + + assert get_in(first_entry, ["data", "message"]) == + "@#{moderator.nickname} unfollowed relay: https://example.org/relay" + end + end + + 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}/credentials", %{ + "password" => "new_password", + "email" => "new_email@example.com", + "name" => "new_name" + }) + + assert json_response(conn, 200) == %{"status" => "success"} + + ObanHelpers.perform_all() + + updated_user = User.get_by_id(user.id) + + 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} 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 + test "sets password_reset_pending to true", %{conn: conn} do + user = insert(:user) + assert user.password_reset_pending == false + + conn = + patch(conn, "/api/pleroma/admin/users/force_password_reset", %{nicknames: [user.nickname]}) + + assert json_response(conn, 204) == "" + + ObanHelpers.perform_all() + + assert User.get_by_id(user.id).password_reset_pending == true + end + end + + describe "relays" do + test "POST /relay", %{conn: conn, admin: admin} do + conn = + post(conn, "/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" + end + + test "GET /relay", %{conn: conn} do + relay_user = Pleroma.Web.ActivityPub.Relay.get_actor() + + ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"] + |> Enum.each(fn ap_id -> + {:ok, user} = User.get_or_fetch_by_ap_id(ap_id) + User.follow(relay_user, user) + end) + + conn = get(conn, "/api/pleroma/admin/relay") + + assert json_response(conn, 200)["relays"] -- ["mastodon.example.org", "mstdn.io"] == [] + end + + test "DELETE /relay", %{conn: conn, admin: admin} do + post(conn, "/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + conn = + delete(conn, "/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" + + [log_entry_one, log_entry_two] = Repo.all(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry_one) == + "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" + + assert ModerationLog.get_log_entry_message(log_entry_two) == + "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin" + end + end + + describe "instances" do + test "GET /instances/:instance/statuses", %{conn: conn} do + user = insert(:user, local: false, nickname: "archaeme@archae.me") + user2 = insert(:user, local: false, nickname: "test@test.com") + insert_pair(:note_activity, user: user) + activity = insert(:note_activity, user: user2) + + ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses") + + response = json_response(ret_conn, 200) + + assert length(response) == 2 + + ret_conn = get(conn, "/api/pleroma/admin/instances/test.com/statuses") + + response = json_response(ret_conn, 200) + + assert length(response) == 1 + + ret_conn = get(conn, "/api/pleroma/admin/instances/nonexistent.com/statuses") + + response = json_response(ret_conn, 200) + + assert Enum.empty?(response) + + CommonAPI.repeat(activity.id, user) + + ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses") + response = json_response(ret_conn, 200) + assert length(response) == 2 + + ret_conn = get(conn, "/api/pleroma/admin/instances/archae.me/statuses?with_reblogs=true") + response = json_response(ret_conn, 200) + assert length(response) == 3 + end + end + + describe "PATCH /confirm_email" do + test "it confirms emails of two users", %{conn: conn, admin: admin} do + [first_user, second_user] = insert_pair(:user, confirmation_pending: true) + + assert first_user.confirmation_pending == true + assert second_user.confirmation_pending == true + + ret_conn = + patch(conn, "/api/pleroma/admin/users/confirm_email", %{ + nicknames: [ + first_user.nickname, + second_user.nickname + ] + }) + + assert ret_conn.status == 200 + + assert first_user.confirmation_pending == true + assert second_user.confirmation_pending == true + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} confirmed email for users: @#{first_user.nickname}, @#{ + second_user.nickname + }" + end + end + + describe "PATCH /resend_confirmation_email" do + test "it resend emails for two users", %{conn: conn, admin: admin} do + [first_user, second_user] = insert_pair(:user, confirmation_pending: true) + + ret_conn = + patch(conn, "/api/pleroma/admin/users/resend_confirmation_email", %{ + nicknames: [ + first_user.nickname, + second_user.nickname + ] + }) + + assert ret_conn.status == 200 + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} re-sent confirmation email for users: @#{first_user.nickname}, @#{ + second_user.nickname + }" + end + end + + describe "POST /reports/:id/notes" do + setup %{conn: conn, admin: admin} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is disgusting!" + }) + + post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is disgusting2!" + }) + + %{ + admin_id: admin.id, + report_id: report_id + } + end + + test "it creates report note", %{admin_id: admin_id, report_id: report_id} do + [note, _] = Repo.all(ReportNote) + + assert %{ + activity_id: ^report_id, + content: "this is disgusting!", + user_id: ^admin_id + } = note + end + + test "it returns reports with notes", %{conn: conn, admin: admin} do + conn = get(conn, "/api/pleroma/admin/reports") + + response = json_response(conn, 200) + notes = hd(response["reports"])["notes"] + [note, _] = notes + + assert note["user"]["nickname"] == admin.nickname + assert note["content"] == "this is disgusting!" + assert note["created_at"] + assert response["total"] == 1 + end + + test "it deletes the note", %{conn: conn, report_id: report_id} do + assert ReportNote |> Repo.all() |> length() == 2 + + [note, _] = Repo.all(ReportNote) + + delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") + + assert ReportNote |> Repo.all() |> length() == 1 + end + end + + describe "GET /api/pleroma/admin/config/descriptions" do + test "structure", %{conn: conn} do + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + assert [child | _others] = json_response(conn, 200) + + assert child["children"] + assert child["key"] + assert String.starts_with?(child["group"], ":") + assert child["description"] + end + + test "filters by database configuration whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :instance}, + {:pleroma, :activitypub}, + {:pleroma, Pleroma.Upload}, + {:esshd} + ]) + + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + children = json_response(conn, 200) + + assert length(children) == 4 + + assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 + + instance = Enum.find(children, fn c -> c["key"] == ":instance" end) + assert instance["children"] + + activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) + assert activitypub["children"] + + web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) + assert web_endpoint["children"] + + esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) + assert esshd["children"] + end + end + + describe "/api/pleroma/admin/stats" do + test "status visibility count", %{conn: conn} do + admin = insert(:user, is_admin: true) + user = insert(:user) + CommonAPI.post(user, %{visibility: "public", status: "hey"}) + CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) + CommonAPI.post(user, %{visibility: "unlisted", status: "hey"}) + + response = + conn + |> assign(:user, admin) + |> get("/api/pleroma/admin/stats") + |> json_response(200) + + assert %{"direct" => 0, "private" => 0, "public" => 1, "unlisted" => 2} = + response["status_visibility"] + end + end + + describe "POST /api/pleroma/admin/oauth_app" do + test "errors", %{conn: conn} do + response = conn |> post("/api/pleroma/admin/oauth_app", %{}) |> json_response(200) + + assert response == %{"name" => "can't be blank", "redirect_uris" => "can't be blank"} + end + + test "success", %{conn: conn} do + base_url = Web.base_url() + app_name = "Trusted app" + + response = + conn + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url + }) + |> json_response(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => false + } = response + end + + test "with trusted", %{conn: conn} do + base_url = Web.base_url() + app_name = "Trusted app" + + response = + conn + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url, + trusted: true + }) + |> json_response(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => true + } = response + end + end + + describe "GET /api/pleroma/admin/oauth_app" do + setup do + app = insert(:oauth_app) + {:ok, app: app} + end + + test "list", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/oauth_app") + |> json_response(200) + + assert %{"apps" => apps, "count" => count, "page_size" => _} = response + + assert length(apps) == count + end + + test "with page size", %{conn: conn} do + insert(:oauth_app) + page_size = 1 + + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{page_size: to_string(page_size)}) + |> json_response(200) + + assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response + + assert length(apps) == page_size + end + + test "search by client name", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{name: app.client_name}) + |> json_response(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "search by client id", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{client_id: app.client_id}) + |> json_response(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "only trusted", %{conn: conn} do + app = insert(:oauth_app, trusted: true) + + response = + conn + |> get("/api/pleroma/admin/oauth_app", %{trusted: true}) + |> json_response(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + end + + describe "DELETE /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + response = + conn + |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) + |> json_response(:no_content) + + assert response == "" + end + + test "with non existance id", %{conn: conn} do + response = + conn + |> delete("/api/pleroma/admin/oauth_app/0") + |> json_response(:bad_request) + + assert response == "" + end + end + + describe "PATCH /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + name = "another name" + url = "https://example.com" + scopes = ["admin"] + id = app.id + website = "http://website.com" + + response = + conn + |> patch("/api/pleroma/admin/oauth_app/" <> to_string(app.id), %{ + name: name, + trusted: true, + redirect_uris: url, + scopes: scopes, + website: website + }) + |> json_response(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "id" => ^id, + "name" => ^name, + "redirect_uri" => ^url, + "trusted" => true, + "website" => ^website + } = response + end + + test "without id", %{conn: conn} do + response = + conn + |> patch("/api/pleroma/admin/oauth_app/0") + |> json_response(:bad_request) + + assert response == "" + end + end +end + +# Needed for testing +defmodule Pleroma.Web.Endpoint.NotReal do +end + +defmodule Pleroma.Captcha.NotReal do +end diff --git a/test/web/admin_api/controllers/status_controller_test.exs b/test/web/admin_api/controllers/status_controller_test.exs new file mode 100644 index 000000000..8ecc78491 --- /dev/null +++ b/test/web/admin_api/controllers/status_controller_test.exs @@ -0,0 +1,185 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.StatusControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/statuses/:id" do + test "not found", %{conn: conn} do + assert conn + |> get("/api/pleroma/admin/statuses/not_found") + |> json_response(:not_found) + end + + test "shows activity", %{conn: conn} do + activity = insert(:note_activity) + + response = + conn + |> get("/api/pleroma/admin/statuses/#{activity.id}") + |> json_response(200) + + assert response["id"] == activity.id + end + end + + describe "PUT /api/pleroma/admin/statuses/:id" do + setup do + activity = insert(:note_activity) + + %{id: activity.id} + end + + test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do + response = + conn + |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "true"}) + |> json_response(:ok) + + assert response["sensitive"] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated status ##{id}, set sensitive: 'true'" + + response = + conn + |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "false"}) + |> json_response(:ok) + + refute response["sensitive"] + end + + test "change visibility flag", %{conn: conn, id: id, admin: admin} do + response = + conn + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "public"}) + |> json_response(:ok) + + assert response["visibility"] == "public" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated status ##{id}, set visibility: 'public'" + + response = + conn + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "private"}) + |> json_response(:ok) + + assert response["visibility"] == "private" + + response = + conn + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "unlisted"}) + |> json_response(:ok) + + assert response["visibility"] == "unlisted" + end + + test "returns 400 when visibility is unknown", %{conn: conn, id: id} do + conn = put(conn, "/api/pleroma/admin/statuses/#{id}", %{visibility: "test"}) + + assert json_response(conn, :bad_request) == "Unsupported visibility" + end + end + + describe "DELETE /api/pleroma/admin/statuses/:id" do + setup do + activity = insert(:note_activity) + + %{id: activity.id} + end + + test "deletes status", %{conn: conn, id: id, admin: admin} do + conn + |> delete("/api/pleroma/admin/statuses/#{id}") + |> json_response(:ok) + + refute Activity.get_by_id(id) + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} deleted status ##{id}" + end + + test "returns 404 when the status does not exist", %{conn: conn} do + conn = delete(conn, "/api/pleroma/admin/statuses/test") + + assert json_response(conn, :not_found) == "Not found" + end + end + + describe "GET /api/pleroma/admin/statuses" do + test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do + blocked = insert(:user) + user = insert(:user) + User.block(admin, blocked) + + {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"}) + + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + {:ok, _} = CommonAPI.post(blocked, %{status: ".", visibility: "public"}) + + response = + conn + |> get("/api/pleroma/admin/statuses") + |> json_response(200) + + refute "private" in Enum.map(response, & &1["visibility"]) + assert length(response) == 3 + end + + test "returns only local statuses with local_only on", %{conn: conn} do + user = insert(:user) + remote_user = insert(:user, local: false, nickname: "archaeme@archae.me") + insert(:note_activity, user: user, local: true) + insert(:note_activity, user: remote_user, local: false) + + response = + conn + |> get("/api/pleroma/admin/statuses?local_only=true") + |> json_response(200) + + assert length(response) == 1 + end + + test "returns private and direct statuses with godmode on", %{conn: conn, admin: admin} do + user = insert(:user) + + {:ok, _} = CommonAPI.post(user, %{status: "@#{admin.nickname}", visibility: "direct"}) + + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + conn = get(conn, "/api/pleroma/admin/statuses?godmode=true") + assert json_response(conn, 200) |> length() == 3 + end + end +end -- cgit v1.2.3 From 4ae2f75c3e5d293c24fac978b1ae10fdfa7a3c00 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 10:27:06 +0000 Subject: Apply suggestion to docs/administration/CLI_tasks/user.md --- docs/administration/CLI_tasks/user.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index 797641898..afeb8d52f 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -117,7 +117,7 @@ mix pleroma.user deactivate NICKNAME ## Deactivate all accounts from an instance and unsubscribe local users on it ```sh tab="OTP" - ./bin/pleroma_ctl user deacitivate_all_from_instance + ./bin/pleroma_ctl user deactivate_all_from_instance ``` ```sh tab="From Source" -- cgit v1.2.3 From d9d425708e094a428fb05127b97480a122c1c616 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 12:43:09 +0200 Subject: SideEffects: Builed out Announce effects. --- lib/pleroma/web/activity_pub/side_effects.ex | 8 ++++- test/web/activity_pub/side_effects_test.exs | 48 +++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index bc0d31c45..66aa1f628 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -30,11 +31,16 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # Tasks this handles: # - Add announce to object # - Set up notification + # - Stream out the announce def handle(%{data: %{"type" => "Announce"}} = object, meta) do announced_object = Object.get_by_ap_id(object.data["object"]) - Utils.add_announce_to_object(object, announced_object) + + if Visibility.is_public?(object) do + Utils.add_announce_to_object(object, announced_object) + end Notification.create_notifications(object) + ActivityPub.stream_out(object) {:ok, object, meta} end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 5dede3957..db8bf2b05 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -172,7 +172,7 @@ test "when activation is required", %{delete: delete, user: user} do {:ok, post} = CommonAPI.post(poster, %{status: "hey"}) {:ok, like} = CommonAPI.favorite(user, post.id) {:ok, reaction} = CommonAPI.react_with_emoji(post.id, user, "👍") - {:ok, announce, _} = CommonAPI.repeat(post.id, user) + {:ok, announce} = CommonAPI.repeat(post.id, user) {:ok, block} = ActivityPub.block(user, poster) User.block(user, poster) @@ -295,23 +295,63 @@ test "creates a notification", %{like: like, poster: poster} do poster = insert(:user) user = insert(:user) {:ok, post} = CommonAPI.post(poster, %{status: "hey"}) + {:ok, private_post} = CommonAPI.post(poster, %{status: "hey", visibility: "private"}) + + {:ok, announce_data, _meta} = Builder.announce(user, post.object, public: true) + + {:ok, private_announce_data, _meta} = + Builder.announce(user, private_post.object, public: false) + + {:ok, relay_announce_data, _meta} = + Builder.announce(Pleroma.Web.ActivityPub.Relay.get_actor(), post.object, public: true) - {:ok, announce_data, _meta} = Builder.announce(user, post.object) {:ok, announce, _meta} = ActivityPub.persist(announce_data, local: true) + {:ok, private_announce, _meta} = ActivityPub.persist(private_announce_data, local: true) + {:ok, relay_announce, _meta} = ActivityPub.persist(relay_announce_data, local: true) - %{announce: announce, user: user, poster: poster} + %{ + announce: announce, + user: user, + poster: poster, + private_announce: private_announce, + relay_announce: relay_announce + } end - test "add the announce to the original object", %{announce: announce, user: user} do + test "adds the announce to the original object", %{announce: announce, user: user} do {:ok, announce, _} = SideEffects.handle(announce) object = Object.get_by_ap_id(announce.data["object"]) assert object.data["announcement_count"] == 1 assert user.ap_id in object.data["announcements"] end + test "does not add the announce to the original object if the announce is private", %{ + private_announce: announce + } do + {:ok, announce, _} = SideEffects.handle(announce) + object = Object.get_by_ap_id(announce.data["object"]) + assert object.data["announcement_count"] == nil + end + + test "does not add the announce to the original object if the actor is a service actor", %{ + relay_announce: announce + } do + {:ok, announce, _} = SideEffects.handle(announce) + object = Object.get_by_ap_id(announce.data["object"]) + assert object.data["announcement_count"] == nil + end + test "creates a notification", %{announce: announce, poster: poster} do {:ok, announce, _} = SideEffects.handle(announce) assert Repo.get_by(Notification, user_id: poster.id, activity_id: announce.id) end + + test "it streams out the announce", %{announce: announce} do + with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough], stream_out: fn _ -> nil end do + {:ok, announce, _} = SideEffects.handle(announce) + + assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(announce)) + end + end end end -- cgit v1.2.3 From 23e248694df988d50e8a07b704a7ac56b8e9b19c Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 13:16:21 +0200 Subject: Announcements: Fix all tests. --- lib/pleroma/web/activity_pub/relay.ex | 9 ++- lib/pleroma/web/activity_pub/side_effects.ex | 5 +- .../mastodon_api/controllers/status_controller.ex | 2 +- test/notification_test.exs | 6 +- test/tasks/user_test.exs | 5 +- test/user_test.exs | 4 +- test/web/activity_pub/activity_pub_test.exs | 85 ++-------------------- test/web/activity_pub/relay_test.exs | 15 ++-- test/web/activity_pub/side_effects_test.exs | 8 -- test/web/activity_pub/transmogrifier_test.exs | 2 +- test/web/activity_pub/utils_test.exs | 2 +- test/web/activity_pub/views/object_view_test.exs | 2 +- test/web/admin_api/admin_api_controller_test.exs | 4 +- test/web/common_api/common_api_test.exs | 5 ++ .../controllers/account_controller_test.exs | 6 +- .../controllers/notification_controller_test.exs | 8 +- .../controllers/status_controller_test.exs | 18 ++--- .../mastodon_api/views/notification_view_test.exs | 2 +- test/web/mastodon_api/views/status_view_test.exs | 4 +- test/web/push/impl_test.exs | 2 +- test/web/streamer/streamer_test.exs | 6 +- 21 files changed, 64 insertions(+), 136 deletions(-) diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index 729c23af7..484178edd 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Web.ActivityPub.Relay do alias Pleroma.Activity - alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.CommonAPI require Logger @relay_nickname "relay" @@ -48,11 +49,11 @@ def unfollow(target_instance) do end end - @spec publish(any()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} + @spec publish(any()) :: {:ok, Activity.t()} | {:error, any()} def publish(%Activity{data: %{"type" => "Create"}} = activity) do with %User{} = user <- get_actor(), - %Object{} = object <- Object.normalize(activity) do - ActivityPub.announce(user, object, nil, true, false) + true <- Visibility.is_public?(activity) do + CommonAPI.repeat(activity.id, user) else error -> format_error(error) end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 66aa1f628..7eae0c52c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -35,9 +34,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do def handle(%{data: %{"type" => "Announce"}} = object, meta) do announced_object = Object.get_by_ap_id(object.data["object"]) - if Visibility.is_public?(object) do - Utils.add_announce_to_object(object, announced_object) - end + Utils.add_announce_to_object(object, announced_object) Notification.create_notifications(object) ActivityPub.stream_out(object) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 9dbf4f33c..83d997abd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -210,7 +210,7 @@ def delete(%{assigns: %{user: user}} = conn, %{id: id}) do @doc "POST /api/v1/statuses/:id/reblog" def reblog(%{assigns: %{user: user}, body_params: params} = conn, %{id: ap_id_or_id}) do - with {:ok, announce, _activity} <- CommonAPI.repeat(ap_id_or_id, user, params), + with {:ok, announce} <- CommonAPI.repeat(ap_id_or_id, user, params), %Activity{} = announce <- Activity.normalize(announce.data) do try_render(conn, "show.json", %{activity: announce, for: user, as: :activity}) end diff --git a/test/notification_test.exs b/test/notification_test.exs index 111ff09f4..3a96721fa 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -648,7 +648,7 @@ test "it does not send notification to mentioned users in announces" do status: "hey @#{other_user.nickname}!" }) - {:ok, activity_two, _} = CommonAPI.repeat(activity_one.id, third_user) + {:ok, activity_two} = CommonAPI.repeat(activity_one.id, third_user) {enabled_receivers, _disabled_receivers} = Notification.get_notified_from_activity(activity_two) @@ -778,7 +778,7 @@ test "repeating an activity results in 1 notification, then 0 if the activity is assert Enum.empty?(Notification.for_user(user)) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, _} = CommonAPI.repeat(activity.id, other_user) assert length(Notification.for_user(user)) == 1 @@ -795,7 +795,7 @@ test "repeating an activity results in 1 notification, then 0 if the activity is assert Enum.empty?(Notification.for_user(user)) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, _} = CommonAPI.repeat(activity.id, other_user) assert length(Notification.for_user(user)) == 1 diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 4aa873f0b..7db48439f 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -91,6 +91,7 @@ test "user is not created" do describe "running rm" do test "user is deleted" do + clear_config([:instance, :federating], true) user = insert(:user) with_mock Pleroma.Web.Federator, @@ -108,8 +109,10 @@ test "user is deleted" do test "a remote user's create activity is deleted when the object has been pruned" do user = insert(:user) - {:ok, post} = CommonAPI.post(user, %{status: "uguu"}) + + clear_config([:instance, :federating], true) + object = Object.normalize(post) Object.prune(object) diff --git a/test/user_test.exs b/test/user_test.exs index 863e0106c..be58c70e1 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -992,7 +992,7 @@ test "works for announces" do user = insert(:user, local: true) {:ok, activity} = CommonAPI.post(actor, %{status: "hello"}) - {:ok, announce, _} = CommonAPI.repeat(activity.id, user) + {:ok, announce} = CommonAPI.repeat(activity.id, user) recipients = User.get_recipients_from_activity(announce) @@ -1147,7 +1147,7 @@ test "it deactivates a user, all follow relationships and all activities", %{use {: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, repeat} = CommonAPI.repeat(activity_two.id, user) {:ok, job} = User.delete(user) {:ok, _user} = ObanHelpers.perform(job) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 77bd07edf..3dcb62873 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -537,7 +537,7 @@ test "doesn't return blocked activities" do assert Enum.member?(activities, activity_one) {:ok, _user_relationship} = User.block(user, %{ap_id: activity_three.data["actor"]}) - {:ok, _announce, %{data: %{"id" => id}}} = CommonAPI.repeat(activity_three.id, booster) + {:ok, %{data: %{"object" => id}}} = CommonAPI.repeat(activity_three.id, booster) %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) activity_three = Activity.get_by_id(activity_three.id) @@ -592,7 +592,7 @@ test "doesn't return announce activities concerning blocked users" do {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"}) - {:ok, activity_three, _} = CommonAPI.repeat(activity_two.id, friend) + {:ok, activity_three} = CommonAPI.repeat(activity_two.id, friend) activities = ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) @@ -618,7 +618,7 @@ test "doesn't return activities from blocked domains" do followed_user = insert(:user) ActivityPub.follow(user, followed_user) - {:ok, repeat_activity, _} = CommonAPI.repeat(activity.id, followed_user) + {:ok, repeat_activity} = CommonAPI.repeat(activity.id, followed_user) activities = ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) @@ -651,7 +651,7 @@ test "does return activities from followed users on blocked domains" do another_user = insert(:user, %{ap_id: "https://#{domain}/@meanie2"}) bad_note = insert(:note, %{data: %{"actor" => another_user.ap_id}}) bad_activity = insert(:note_activity, %{note: bad_note}) - {:ok, repeat_activity, _} = CommonAPI.repeat(bad_activity.id, domain_user) + {:ok, repeat_activity} = CommonAPI.repeat(bad_activity.id, domain_user) activities = ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true}) @@ -699,7 +699,7 @@ test "doesn't return muted activities" do activity_three_actor = User.get_by_ap_id(activity_three.data["actor"]) {:ok, _user_relationships} = User.mute(user, activity_three_actor) - {:ok, _announce, %{data: %{"id" => id}}} = CommonAPI.repeat(activity_three.id, booster) + {:ok, %{data: %{"object" => id}}} = CommonAPI.repeat(activity_three.id, booster) %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) activity_three = Activity.get_by_id(activity_three.id) @@ -749,7 +749,7 @@ test "does include announces on request" do {:ok, user} = User.follow(user, booster) - {:ok, announce, _object} = CommonAPI.repeat(activity_three.id, booster) + {:ok, announce} = CommonAPI.repeat(activity_three.id, booster) [announce_activity] = ActivityPub.fetch_activities([user.ap_id | User.following(user)]) @@ -846,7 +846,7 @@ test "doesn't return reblogs for users for whom reblogs have been muted" do booster = insert(:user) {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, booster) - {:ok, activity, _} = CommonAPI.repeat(activity.id, booster) + {:ok, activity} = CommonAPI.repeat(activity.id, booster) activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) @@ -860,7 +860,7 @@ test "returns reblogs for users for whom reblogs have not been muted" do {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, booster) {:ok, _reblog_mute} = CommonAPI.show_reblogs(user, booster) - {:ok, activity, _} = CommonAPI.repeat(activity.id, booster) + {:ok, activity} = CommonAPI.repeat(activity.id, booster) activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) @@ -868,75 +868,6 @@ test "returns reblogs for users for whom reblogs have not been muted" do end end - describe "announcing an object" do - test "adds an announce activity to the db" do - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - {:ok, announce_activity, object} = ActivityPub.announce(user, object) - assert object.data["announcement_count"] == 1 - assert object.data["announcements"] == [user.ap_id] - - assert announce_activity.data["to"] == [ - User.ap_followers(user), - note_activity.data["actor"] - ] - - assert announce_activity.data["object"] == object.data["id"] - assert announce_activity.data["actor"] == user.ap_id - assert announce_activity.data["context"] == object.data["context"] - end - - test "reverts annouce from object on error" do - note_activity = insert(:note_activity) - object = Object.normalize(note_activity) - user = insert(:user) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.announce(user, object) - end - - reloaded_object = Object.get_by_ap_id(object.data["id"]) - assert reloaded_object == object - refute reloaded_object.data["announcement_count"] - refute reloaded_object.data["announcements"] - end - end - - describe "announcing a private object" do - test "adds an announce activity to the db if the audience is not widened" do - user = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - object = Object.normalize(note_activity) - - {:ok, announce_activity, object} = ActivityPub.announce(user, object, nil, true, false) - - assert announce_activity.data["to"] == [User.ap_followers(user)] - - assert announce_activity.data["object"] == object.data["id"] - assert announce_activity.data["actor"] == user.ap_id - assert announce_activity.data["context"] == object.data["context"] - end - - test "does not add an announce activity to the db if the audience is widened" do - user = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - object = Object.normalize(note_activity) - - assert {:error, _} = ActivityPub.announce(user, object, nil, true, true) - end - - test "does not add an announce activity to the db if the announcer is not the author" do - user = insert(:user) - announcer = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - object = Object.normalize(note_activity) - - assert {:error, _} = ActivityPub.announce(announcer, object, nil, true, false) - end - end - describe "uploading files" do test "copies the file to the configured folder" do file = %Plug.Upload{ diff --git a/test/web/activity_pub/relay_test.exs b/test/web/activity_pub/relay_test.exs index 9e16e39c4..dbee8a0f4 100644 --- a/test/web/activity_pub/relay_test.exs +++ b/test/web/activity_pub/relay_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.RelayTest do use Pleroma.DataCase alias Pleroma.Activity - alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Relay @@ -95,21 +94,20 @@ test "returns error when object is unknown" do end) assert capture_log(fn -> - assert Relay.publish(activity) == {:error, nil} - end) =~ "[error] error: nil" + assert Relay.publish(activity) == {:error, false} + end) =~ "[error] error: false" end test_with_mock "returns announce activity and publish to federate", Pleroma.Web.Federator, [:passthrough], [] do - Pleroma.Config.put([:instance, :federating], true) + clear_config([:instance, :federating], true) service_actor = Relay.get_actor() note = insert(:note_activity) - assert {:ok, %Activity{} = activity, %Object{} = obj} = Relay.publish(note) + assert {:ok, %Activity{} = activity} = Relay.publish(note) assert activity.data["type"] == "Announce" assert activity.data["actor"] == service_actor.ap_id - assert activity.data["object"] == obj.data["id"] assert called(Pleroma.Web.Federator.publish(activity)) end @@ -117,13 +115,12 @@ test "returns error when object is unknown" do Pleroma.Web.Federator, [:passthrough], [] do - Pleroma.Config.put([:instance, :federating], false) + clear_config([:instance, :federating], false) service_actor = Relay.get_actor() note = insert(:note_activity) - assert {:ok, %Activity{} = activity, %Object{} = obj} = Relay.publish(note) + assert {:ok, %Activity{} = activity} = Relay.publish(note) assert activity.data["type"] == "Announce" assert activity.data["actor"] == service_actor.ap_id - assert activity.data["object"] == obj.data["id"] refute called(Pleroma.Web.Federator.publish(activity)) end end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index db8bf2b05..a80104ea7 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -325,14 +325,6 @@ test "adds the announce to the original object", %{announce: announce, user: use assert user.ap_id in object.data["announcements"] end - test "does not add the announce to the original object if the announce is private", %{ - private_announce: announce - } do - {:ok, announce, _} = SideEffects.handle(announce) - object = Object.get_by_ap_id(announce.data["object"]) - assert object.data["announcement_count"] == nil - end - test "does not add the announce to the original object if the actor is a service actor", %{ relay_announce: announce } do diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 81f966ad9..356004d48 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1079,7 +1079,7 @@ test "it inlines private announced objects" do {:ok, activity} = CommonAPI.post(user, %{status: "hey", visibility: "private"}) - {:ok, announce_activity, _} = CommonAPI.repeat(activity.id, user) + {:ok, announce_activity} = CommonAPI.repeat(activity.id, user) {:ok, modified} = Transmogrifier.prepare_outgoing(announce_activity.data) diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index 9e0a0f1c4..15f03f193 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -334,7 +334,7 @@ test "fetches existing announce" do assert object = Object.normalize(note_activity) actor = insert(:user) - {:ok, announce, _object} = ActivityPub.announce(actor, object) + {:ok, announce} = CommonAPI.repeat(note_activity.id, actor) assert Utils.get_existing_announce(actor.ap_id, object) == announce end end diff --git a/test/web/activity_pub/views/object_view_test.exs b/test/web/activity_pub/views/object_view_test.exs index 43f0617f0..f0389845d 100644 --- a/test/web/activity_pub/views/object_view_test.exs +++ b/test/web/activity_pub/views/object_view_test.exs @@ -73,7 +73,7 @@ test "renders an announce activity" do object = Object.normalize(note) user = insert(:user) - {:ok, announce_activity, _} = CommonAPI.repeat(note.id, user) + {:ok, announce_activity} = CommonAPI.repeat(note.id, user) result = ObjectView.render("object.json", %{object: announce_activity}) diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 370d876d0..12cb1afd9 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -148,6 +148,7 @@ test "GET /api/pleroma/admin/users/:nickname requires " <> describe "DELETE /api/pleroma/admin/users" do test "single user", %{admin: admin, conn: conn} do user = insert(:user) + clear_config([:instance, :federating], true) with_mock Pleroma.Web.Federator, publish: fn _ -> nil end do @@ -2944,6 +2945,7 @@ test "proxy tuple ip", %{conn: conn} do assert ":proxy_url" in db end + @tag capture_log: true test "doesn't set keys not in the whitelist", %{conn: conn} do clear_config(:database_config_whitelist, [ {:pleroma, :key1}, @@ -3096,7 +3098,7 @@ test "returns private statuses with godmode on", %{conn: conn, user: user} do test "excludes reblogs by default", %{conn: conn, user: user} do other_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{status: "."}) - {:ok, %Activity{}, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, %Activity{}} = CommonAPI.repeat(activity.id, other_user) conn_res = get(conn, "/api/pleroma/admin/users/#{other_user.nickname}/statuses") assert json_response(conn_res, 200) |> length() == 0 diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index e68a6a7d2..d849fd8f0 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -41,6 +41,8 @@ test "it works with pruned objects" do {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) + clear_config([:instance, :federating], true) + Object.normalize(post, false) |> Object.prune() @@ -59,6 +61,8 @@ test "it allows users to delete their posts" do {:ok, post} = CommonAPI.post(user, %{status: "namu amida butsu"}) + clear_config([:instance, :federating], true) + with_mock Pleroma.Web.Federator, publish: fn _ -> nil end do assert {:ok, delete} = CommonAPI.delete(post.id, user) @@ -440,6 +444,7 @@ test "repeating a status privately" do CommonAPI.repeat(activity.id, user, %{visibility: "private"}) assert Visibility.is_private?(announce_activity) + refute Visibility.visible_for_user?(announce_activity, nil) end test "favoriting a status" do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 280bd6aca..1ce97378d 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -256,7 +256,7 @@ test "respects blocks", %{user: user_one, conn: conn} do User.block(user_one, user_two) {:ok, activity} = CommonAPI.post(user_two, %{status: "User one sux0rz"}) - {:ok, repeat, _} = CommonAPI.repeat(activity.id, user_three) + {:ok, repeat} = CommonAPI.repeat(activity.id, user_three) assert resp = conn @@ -375,7 +375,7 @@ test "gets an users media", %{conn: conn} do test "gets a user's statuses without reblogs", %{user: user, conn: conn} do {:ok, %{id: post_id}} = CommonAPI.post(user, %{status: "HI!!!"}) - {:ok, _, _} = CommonAPI.repeat(post_id, user) + {:ok, _} = CommonAPI.repeat(post_id, user) conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?exclude_reblogs=true") assert [%{"id" => ^post_id}] = json_response_and_validate_schema(conn, 200) @@ -678,7 +678,7 @@ test "following without reblogs" do assert %{"showing_reblogs" => false} = json_response_and_validate_schema(ret_conn, 200) {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) - {:ok, %{id: reblog_id}, _} = CommonAPI.repeat(activity.id, followed) + {:ok, %{id: reblog_id}} = CommonAPI.repeat(activity.id, followed) assert [] == conn diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index 562fc4d8e..e278d61f5 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -280,8 +280,8 @@ test "filters notifications for Announce activities" do {:ok, unlisted_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"}) - {:ok, _, _} = CommonAPI.repeat(public_activity.id, user) - {:ok, _, _} = CommonAPI.repeat(unlisted_activity.id, user) + {:ok, _} = CommonAPI.repeat(public_activity.id, user) + {:ok, _} = CommonAPI.repeat(unlisted_activity.id, user) activity_ids = conn @@ -301,7 +301,7 @@ test "filters notifications using exclude_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(other_user, create_activity.id) - {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) + {:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, other_user) {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) mention_notification_id = get_notification_id_by_activity(mention_activity) @@ -339,7 +339,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(other_user, create_activity.id) - {:ok, reblog_activity, _} = CommonAPI.repeat(create_activity.id, other_user) + {:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, other_user) {:ok, _, _, follow_activity} = CommonAPI.follow(other_user, user) mention_notification_id = get_notification_id_by_activity(mention_activity) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 962e64b03..700c82e4f 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -878,8 +878,8 @@ test "reblogged status for another user" do user3 = insert(:user) {: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) + {:ok, reblog_activity1} = CommonAPI.repeat(activity.id, user1) + {:ok, _} = CommonAPI.repeat(activity.id, user2) conn_res = build_conn() @@ -917,7 +917,7 @@ test "reblogged status for another user" do test "unreblogs and returns the unreblogged status", %{user: user, conn: conn} do activity = insert(:note_activity) - {:ok, _, _} = CommonAPI.repeat(activity.id, user) + {:ok, _} = CommonAPI.repeat(activity.id, user) conn = conn @@ -1427,7 +1427,7 @@ test "requires authentication for private posts", %{user: user} do test "returns users who have reblogged the status", %{conn: conn, activity: activity} do other_user = insert(:user) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, _} = CommonAPI.repeat(activity.id, other_user) response = conn @@ -1458,7 +1458,7 @@ test "does not return users who have reblogged the status but are blocked", %{ other_user = insert(:user) {:ok, _user_relationship} = User.block(user, other_user) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, _} = CommonAPI.repeat(activity.id, other_user) response = conn @@ -1469,12 +1469,12 @@ test "does not return users who have reblogged the status but are blocked", %{ end test "does not return users who have reblogged the status privately", %{ - conn: conn, - activity: activity + conn: conn } do other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{status: "my secret post"}) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user, %{visibility: "private"}) + {:ok, _} = CommonAPI.repeat(activity.id, other_user, %{visibility: "private"}) response = conn @@ -1486,7 +1486,7 @@ test "does not return users who have reblogged the status privately", %{ test "does not fail on an unauthenticated request", %{activity: activity} do other_user = insert(:user) - {:ok, _, _} = CommonAPI.repeat(activity.id, other_user) + {:ok, _} = CommonAPI.repeat(activity.id, other_user) response = build_conn() diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 9839e48fc..f15be1df1 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -78,7 +78,7 @@ test "Reblog notification" do user = insert(:user) another_user = insert(:user) {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) - {:ok, reblog_activity, _object} = CommonAPI.repeat(create_activity.id, another_user) + {:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, another_user) {:ok, [notification]} = Notification.create_notifications(reblog_activity) reblog_activity = Activity.get_by_id(create_activity.id) diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 43e3bdca1..5cbadf0fc 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -442,7 +442,7 @@ test "a reblog" do user = insert(:user) activity = insert(:note_activity) - {:ok, reblog, _} = CommonAPI.repeat(activity.id, user) + {:ok, reblog} = CommonAPI.repeat(activity.id, user) represented = StatusView.render("show.json", %{for: user, activity: reblog}) @@ -600,7 +600,7 @@ test "does not embed a relationship in the account in reposts" do status: "˙˙ɐʎns" }) - {:ok, activity, _object} = CommonAPI.repeat(activity.id, other_user) + {:ok, activity} = CommonAPI.repeat(activity.id, other_user) result = StatusView.render("show.json", %{activity: activity, for: user}) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 2acd0939f..a826b24c9 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -151,7 +151,7 @@ test "renders title and body for announce activity" do "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) - {:ok, announce_activity, _} = CommonAPI.repeat(activity.id, user) + {:ok, announce_activity} = CommonAPI.repeat(activity.id, user) object = Object.normalize(activity) assert Impl.format_body(%{activity: announce_activity}, user, object) == diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 95b7d1420..cb4595bb6 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -106,7 +106,7 @@ test "it streams boosts of the user in the 'user' stream", %{user: user} do other_user = insert(:user) {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) - {:ok, announce, _} = CommonAPI.repeat(activity.id, user) + {:ok, announce} = CommonAPI.repeat(activity.id, user) assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} refute Streamer.filtered_by_user?(user, announce) @@ -427,7 +427,7 @@ test "it filters muted reblogs" do {:ok, create_activity} = CommonAPI.post(user3, %{status: "I'm kawen"}) Streamer.get_topic_and_add_socket("user", user1) - {:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2) + {:ok, announce_activity} = CommonAPI.repeat(create_activity.id, user2) assert_receive {:render_with_user, _, _, ^announce_activity} assert Streamer.filtered_by_user?(user1, announce_activity) end @@ -440,7 +440,7 @@ test "it filters reblog notification for reblog-muted actors" do {:ok, create_activity} = CommonAPI.post(user1, %{status: "I'm kawen"}) Streamer.get_topic_and_add_socket("user", user1) - {:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2) + {:ok, _announce_activity} = CommonAPI.repeat(create_activity.id, user2) assert_receive {:render_with_user, _, "notification.json", notif} assert Streamer.filtered_by_user?(user1, notif) -- cgit v1.2.3 From c76267afb9ba6fa79d949c51d8ff72c75989a4f5 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 13:31:52 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/pipeline.ex | 2 +- test/web/activity_pub/transmogrifier/announce_handling_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 1d6bc2000..0c54c4b23 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Activity + alias Pleroma.Config alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Web.ActivityPub.ActivityPub @@ -11,7 +12,6 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.Federator - alias Pleroma.Config @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} diff --git a/test/web/activity_pub/transmogrifier/announce_handling_test.exs b/test/web/activity_pub/transmogrifier/announce_handling_test.exs index 50bcb307f..e895636b5 100644 --- a/test/web/activity_pub/transmogrifier/announce_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/announce_handling_test.exs @@ -7,8 +7,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.AnnounceHandlingTest do alias Pleroma.Activity alias Pleroma.Object - alias Pleroma.Web.CommonAPI alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI import Pleroma.Factory -- cgit v1.2.3 From cdc6ba8d7bca3660c5c431979eae43231f339d6a Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 13:58:18 +0200 Subject: AnnounceValidator: Check for announcability --- .../object_validators/announce_validator.ex | 32 ++++++++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 29 ++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 082fdea4d..40f861f47 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -5,12 +5,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do use Ecto.Schema + alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.Visibility import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + require Pleroma.Constants + @primary_key false embedded_schema do @@ -52,6 +57,33 @@ def validate_data(data_cng) do |> validate_actor_presence() |> validate_object_presence() |> validate_existing_announce() + |> validate_announcable() + end + + def validate_announcable(cng) do + with actor when is_binary(actor) <- get_field(cng, :actor), + object when is_binary(object) <- get_field(cng, :object), + %User{} = actor <- User.get_cached_by_ap_id(actor), + %Object{} = object <- Object.get_cached_by_ap_id(object), + false <- Visibility.is_public?(object) do + same_actor = object.data["actor"] == actor.ap_id + is_public = Pleroma.Constants.as_public() in (get_field(cng, :to) ++ get_field(cng, :cc)) + + cond do + same_actor && is_public -> + cng + |> add_error(:actor, "can not announce this object publicly") + + !same_actor -> + cng + |> add_error(:actor, "can not announce this object") + + true -> + cng + end + else + _ -> cng + end end def validate_existing_announce(cng) do diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index e24e0f913..84e5edd05 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -342,5 +342,34 @@ test "returns an error if the actor already announced the object", %{ assert {:actor, {"already announced this object", []}} in cng.errors assert {:object, {"already announced by this actor", []}} in cng.errors end + + test "returns an error if the actor can't announce the object", %{ + announcer: announcer, + user: user + } do + {:ok, post_activity} = + CommonAPI.post(user, %{status: "a secret post", visibility: "private"}) + + object = Object.normalize(post_activity, false) + + # Another user can't announce it + {:ok, announce, []} = Builder.announce(announcer, object, public: false) + + {:error, cng} = ObjectValidator.validate(announce, []) + + assert {:actor, {"can not announce this object", []}} in cng.errors + + # The actor of the object can announce it + {:ok, announce, []} = Builder.announce(user, object, public: false) + + assert {:ok, _, _} = ObjectValidator.validate(announce, []) + + # The actor of the object can not announce it publicly + {:ok, announce, []} = Builder.announce(user, object, public: true) + + {:error, cng} = ObjectValidator.validate(announce, []) + + assert {:actor, {"can not announce this object publicly", []}} in cng.errors + end end end -- cgit v1.2.3 From bf1b221f94a53c90cda832e9bfb6c5fa0e6524f2 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 14:12:32 +0200 Subject: Credo fixes for the credo god. --- test/web/activity_pub/object_validator_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 84e5edd05..7953eecf2 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -364,7 +364,7 @@ test "returns an error if the actor can't announce the object", %{ assert {:ok, _, _} = ObjectValidator.validate(announce, []) - # The actor of the object can not announce it publicly + # The actor of the object can not announce it publicly {:ok, announce, []} = Builder.announce(user, object, public: true) {:error, cng} = ObjectValidator.validate(announce, []) -- cgit v1.2.3 From cc0d462e91dd29c834c56b82e02022e1babda369 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 15:08:56 +0200 Subject: Attachments: Have the mediaType on the root, too. --- lib/pleroma/upload.ex | 1 + test/web/activity_pub/object_validator_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 1be1a3a5b..797555bff 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -67,6 +67,7 @@ def store(upload, opts \\ []) do {:ok, %{ "type" => opts.activity_type, + "mediaType" => upload.content_type, "url" => [ %{ "type" => "Link", diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index ed6b84e8e..f9990bd2c 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -25,6 +25,8 @@ test "works with honkerific attachments" do assert {:ok, attachment} = AttachmentValidator.cast_and_validate(attachment) |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "application/octet-stream" end test "it turns mastodon attachments into our attachments" do @@ -48,6 +50,27 @@ test "it turns mastodon attachments into our attachments" do mediaType: "image/jpeg" } ] = attachment.url + + assert attachment.mediaType == "image/jpeg" + end + + test "it handles our own uploads" do + user = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + {:ok, attachment} = + attachment.data + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "image/jpeg" end end -- cgit v1.2.3 From c4a5cead51770f0d54cb77805b7e2bd705f251d9 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 21 May 2020 15:17:39 +0200 Subject: UploadTest: Fix test. --- test/upload_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/upload_test.exs b/test/upload_test.exs index 060a940bb..2abf0edec 100644 --- a/test/upload_test.exs +++ b/test/upload_test.exs @@ -54,6 +54,7 @@ test "it returns file" do %{ "name" => "image.jpg", "type" => "Document", + "mediaType" => "image/jpeg", "url" => [ %{ "href" => "http://localhost:4001/media/post-process-file.jpg", -- cgit v1.2.3 From 45d2c4157fc264dacdca0f17268d3a33f364801f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 21 May 2020 14:03:38 +0400 Subject: Add OpenAPI spec for AdminAPI.StatusController --- .../admin_api/controllers/fallback_controller.ex | 6 +- .../web/admin_api/controllers/status_controller.ex | 59 ++------ .../api_spec/operations/admin/status_operation.ex | 165 +++++++++++++++++++++ .../web/api_spec/operations/status_operation.ex | 2 +- .../controllers/admin_api_controller_test.exs | 24 ++- .../controllers/status_controller_test.exs | 37 +++-- 6 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/admin/status_operation.ex diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex index 9f7bb92ce..82965936d 100644 --- a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -8,13 +8,13 @@ defmodule Pleroma.Web.AdminAPI.FallbackController do def call(conn, {:error, :not_found}) do conn |> put_status(:not_found) - |> json(dgettext("errors", "Not found")) + |> json(%{error: dgettext("errors", "Not found")}) end def call(conn, {:error, reason}) do conn |> put_status(:bad_request) - |> json(reason) + |> json(%{error: reason}) end def call(conn, {:param_cast, _}) do @@ -26,6 +26,6 @@ def call(conn, {:param_cast, _}) do def call(conn, _) do conn |> put_status(:internal_server_error) - |> json(dgettext("errors", "Something went wrong")) + |> json(%{error: dgettext("errors", "Something went wrong")}) end end diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index 1e9763979..08cb9c10b 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -14,8 +14,7 @@ defmodule Pleroma.Web.AdminAPI.StatusController do require Logger - @users_page_size 50 - + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} when action in [:index, :show]) plug( @@ -25,25 +24,22 @@ defmodule Pleroma.Web.AdminAPI.StatusController do action_fallback(Pleroma.Web.AdminAPI.FallbackController) - def index(%{assigns: %{user: _admin}} = conn, params) do - godmode = params["godmode"] == "true" || params["godmode"] == true - local_only = params["local_only"] == "true" || params["local_only"] == true - with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true - {page, page_size} = page_params(params) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.StatusOperation + def index(%{assigns: %{user: _admin}} = conn, params) do activities = ActivityPub.fetch_statuses(nil, %{ - "godmode" => godmode, - "local_only" => local_only, - "limit" => page_size, - "offset" => (page - 1) * page_size, - "exclude_reblogs" => !with_reblogs && "true" + "godmode" => params.godmode, + "local_only" => params.local_only, + "limit" => params.page_size, + "offset" => (params.page - 1) * params.page_size, + "exclude_reblogs" => not params.with_reblogs }) - render(conn, "index.json", %{activities: activities, as: :activity}) + render(conn, "index.json", activities: activities, as: :activity) end - def show(conn, %{"id" => id}) do + def show(conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do conn |> put_view(MastodonAPI.StatusView) @@ -53,20 +49,13 @@ def show(conn, %{"id" => id}) do end end - def update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do - params = - params - |> Map.take(["sensitive", "visibility"]) - |> Map.new(fn {key, value} -> {String.to_existing_atom(key), value} end) - + def update(%{assigns: %{user: admin}, body_params: params} = conn, %{id: id}) do with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do - {:ok, sensitive} = Ecto.Type.cast(:boolean, params[:sensitive]) - ModerationLog.insert_log(%{ action: "status_update", actor: admin, subject: activity, - sensitive: sensitive, + sensitive: params[:sensitive], visibility: params[:visibility] }) @@ -76,7 +65,7 @@ def update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do end end - def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do + def delete(%{assigns: %{user: user}} = conn, %{id: id}) do with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do ModerationLog.insert_log(%{ action: "status_delete", @@ -87,26 +76,4 @@ def delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do json(conn, %{}) end end - - defp page_params(params) do - {get_page(params["page"]), get_page_size(params["page_size"])} - end - - defp get_page(page_string) when is_nil(page_string), do: 1 - - defp get_page(page_string) do - case Integer.parse(page_string) do - {page, _} -> page - :error -> 1 - end - end - - defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size - - defp get_page_size(page_size_string) do - case Integer.parse(page_size_string) do - {page_size, _} -> page_size - :error -> @users_page_size - end - end end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex new file mode 100644 index 000000000..0b138dc79 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -0,0 +1,165 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.StatusOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Statuses"], + operationId: "AdminAPI.StatusController.index", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :godmode, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see private statuses" + ), + Operation.parameter( + :local_only, + :query, + %Schema{type: :boolean, default: false}, + "Excludes remote statuses" + ), + Operation.parameter( + :with_reblogs, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see reblogs" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], + responses: %{ + 200 => + Operation.response("Array of statuses", "application/json", %Schema{ + type: :array, + items: status() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Show Status", + operationId: "AdminAPI.StatusController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Change the scope of an individual reported status", + operationId: "AdminAPI.StatusController.update", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Delete an individual reported status", + operationId: "AdminAPI.StatusController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp status do + %Schema{ + anyOf: [ + Status, + %Schema{ + type: :object, + properties: %{ + account: %Schema{allOf: [Account, admin_account()]} + } + } + ] + } + end + + defp admin_account do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + avatar: %Schema{type: :string}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + deactivated: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :string}, + confirmation_pending: %Schema{type: :string} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + sensitive: %Schema{ + type: :boolean, + description: "Mark status and attached media as sensitive?" + }, + visibility: VisibilityScope + }, + example: %{ + "visibility" => "private", + "sensitive" => "false" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 0682ca6e5..ca9db01e5 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -487,7 +487,7 @@ defp create_request do } end - defp id_param do + def id_param do Operation.parameter(:id, :path, FlakeID, "Status ID", example: "9umDrYheeY451cQnEe", required: true diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 2c317e0fe..a0c11a354 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -350,7 +350,7 @@ test "when the user doesn't exist", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - assert "Not found" == json_response(conn, 404) + assert %{"error" => "Not found"} == json_response(conn, 404) end end @@ -683,7 +683,10 @@ 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 to set the `invites_enabled` option to true." + %{ + "error" => + "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 @@ -693,7 +696,10 @@ 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 to set the `registrations_open` option to false." + %{ + "error" => + "To send invites you need to set the `registrations_open` option to false." + } end end @@ -1307,7 +1313,7 @@ test "returns 404 if user not found", %{conn: conn} do |> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"}) |> json_response(404) - assert response == "Not found" + assert response == %{"error" => "Not found"} end end @@ -1413,7 +1419,7 @@ test "with token", %{conn: conn} do test "with invalid token", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - assert json_response(conn, :not_found) == "Not found" + assert json_response(conn, :not_found) == %{"error" => "Not found"} end end @@ -1440,7 +1446,7 @@ test "returns report by its id", %{conn: conn} do test "returns 404 when report id is invalid", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/reports/test") - assert json_response(conn, :not_found) == "Not found" + assert json_response(conn, :not_found) == %{"error" => "Not found"} end end @@ -1705,7 +1711,9 @@ test "when configuration from database is off", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/config") assert json_response(conn, 400) == - "To use this endpoint you need to enable configuration from database." + %{ + "error" => "To use this endpoint you need to enable configuration from database." + } end test "with settings only in db", %{conn: conn} do @@ -1827,7 +1835,7 @@ test "POST /api/pleroma/admin/config error", %{conn: conn} do conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) assert json_response(conn, 400) == - "To use this endpoint you need to enable configuration from database." + %{"error" => "To use this endpoint you need to enable configuration from database."} end describe "POST /api/pleroma/admin/config" do diff --git a/test/web/admin_api/controllers/status_controller_test.exs b/test/web/admin_api/controllers/status_controller_test.exs index 8ecc78491..124d8dc2e 100644 --- a/test/web/admin_api/controllers/status_controller_test.exs +++ b/test/web/admin_api/controllers/status_controller_test.exs @@ -30,7 +30,7 @@ defmodule Pleroma.Web.AdminAPI.StatusControllerTest do test "not found", %{conn: conn} do assert conn |> get("/api/pleroma/admin/statuses/not_found") - |> json_response(:not_found) + |> json_response_and_validate_schema(:not_found) end test "shows activity", %{conn: conn} do @@ -39,7 +39,7 @@ test "shows activity", %{conn: conn} do response = conn |> get("/api/pleroma/admin/statuses/#{activity.id}") - |> json_response(200) + |> json_response_and_validate_schema(200) assert response["id"] == activity.id end @@ -55,8 +55,9 @@ test "shows activity", %{conn: conn} do test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do response = conn + |> put_req_header("content-type", "application/json") |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "true"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response["sensitive"] @@ -67,8 +68,9 @@ test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do response = conn + |> put_req_header("content-type", "application/json") |> put("/api/pleroma/admin/statuses/#{id}", %{"sensitive" => "false"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) refute response["sensitive"] end @@ -76,8 +78,9 @@ test "toggle sensitive flag", %{conn: conn, id: id, admin: admin} do test "change visibility flag", %{conn: conn, id: id, admin: admin} do response = conn + |> put_req_header("content-type", "application/json") |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "public"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response["visibility"] == "public" @@ -88,23 +91,29 @@ test "change visibility flag", %{conn: conn, id: id, admin: admin} do response = conn + |> put_req_header("content-type", "application/json") |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "private"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response["visibility"] == "private" response = conn + |> put_req_header("content-type", "application/json") |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "unlisted"}) - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response["visibility"] == "unlisted" end test "returns 400 when visibility is unknown", %{conn: conn, id: id} do - conn = put(conn, "/api/pleroma/admin/statuses/#{id}", %{visibility: "test"}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/pleroma/admin/statuses/#{id}", %{visibility: "test"}) - assert json_response(conn, :bad_request) == "Unsupported visibility" + assert %{"error" => "test - Invalid value for enum."} = + json_response_and_validate_schema(conn, :bad_request) end end @@ -118,7 +127,7 @@ test "returns 400 when visibility is unknown", %{conn: conn, id: id} do test "deletes status", %{conn: conn, id: id, admin: admin} do conn |> delete("/api/pleroma/admin/statuses/#{id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) refute Activity.get_by_id(id) @@ -131,7 +140,7 @@ test "deletes status", %{conn: conn, id: id, admin: admin} do test "returns 404 when the status does not exist", %{conn: conn} do conn = delete(conn, "/api/pleroma/admin/statuses/test") - assert json_response(conn, :not_found) == "Not found" + assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} end end @@ -151,7 +160,7 @@ test "returns all public and unlisted statuses", %{conn: conn, admin: admin} do response = conn |> get("/api/pleroma/admin/statuses") - |> json_response(200) + |> json_response_and_validate_schema(200) refute "private" in Enum.map(response, & &1["visibility"]) assert length(response) == 3 @@ -166,7 +175,7 @@ test "returns only local statuses with local_only on", %{conn: conn} do response = conn |> get("/api/pleroma/admin/statuses?local_only=true") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(response) == 1 end @@ -179,7 +188,7 @@ test "returns private and direct statuses with godmode on", %{conn: conn, admin: {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "private"}) {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) conn = get(conn, "/api/pleroma/admin/statuses?godmode=true") - assert json_response(conn, 200) |> length() == 3 + assert json_response_and_validate_schema(conn, 200) |> length() == 3 end end end -- cgit v1.2.3 From bcb549531ff5f90253adc1b8cc2900c348f20175 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 22 May 2020 14:38:28 +0200 Subject: EmojiReactionController: Return more appropriate error. --- lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex | 4 +++- .../web/pleroma_api/controllers/emoji_reaction_controller.ex | 2 ++ .../web/pleroma_api/controllers/emoji_reaction_controller_test.exs | 7 +++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex index 7c08fbaa7..1a49fece0 100644 --- a/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex +++ b/lib/pleroma/web/api_spec/operations/emoji_reaction_operation.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.FlakeID alias Pleroma.Web.ApiSpec.Schemas.Status @@ -46,7 +47,8 @@ def create_operation do security: [%{"oAuth" => ["write:statuses"]}], operationId: "EmojiReactionController.create", responses: %{ - 200 => Operation.response("Status", "application/json", Status) + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Bad Request", "application/json", ApiError) } } end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex index a002912f3..19dcffdf3 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -22,6 +22,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.EmojiReactionOperation + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), %Object{data: %{"reactions" => reactions}} when is_list(reactions) <- diff --git a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index ee66ebf87..e1bb5ebfe 100644 --- a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -33,6 +33,13 @@ test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do assert result["pleroma"]["emoji_reactions"] == [ %{"name" => "☕", "count" => 1, "me" => true} ] + + # Reacting with a non-emoji + assert conn + |> assign(:user, other_user) + |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"])) + |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/x") + |> json_response_and_validate_schema(400) end test "DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do -- cgit v1.2.3 From ca755f9a73649375096850ce7849688f45162de8 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 22 May 2020 16:15:29 +0200 Subject: ActivityPubController: Add Mastodon compatibility route. --- .../web/activity_pub/activity_pub_controller.ex | 5 ++-- lib/pleroma/web/ostatus/ostatus_controller.ex | 2 +- lib/pleroma/web/router.ex | 3 +++ .../activity_pub/activity_pub_controller_test.exs | 29 +++++++++++++++++++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 62ad15d85..5a41dac5c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.Endpoint alias Pleroma.Web.FederatingPlug alias Pleroma.Web.Federator @@ -75,8 +76,8 @@ def user(conn, %{"nickname" => nickname}) do end end - def object(conn, %{"uuid" => uuid}) do - with ap_id <- o_status_url(conn, :object, uuid), + def object(conn, _) do + with ap_id <- Endpoint.url() <> conn.request_path, %Object{} = object <- Object.get_cached_by_ap_id(ap_id), {_, true} <- {:public?, Visibility.is_public?(object)} do conn diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 6971cd9f8..513e69c6e 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -32,7 +32,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do action_fallback(:errors) - def object(%{assigns: %{format: format}} = conn, %{"uuid" => _uuid}) + def object(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do ActivityPubController.call(conn, :object) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index cbe320746..b437e56fb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -556,6 +556,9 @@ defmodule Pleroma.Web.Router do get("/notice/:id", OStatus.OStatusController, :notice) get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) + # Mastodon compat routes + get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object) + get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index c432c90e3..b247163ec 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do use Pleroma.Web.ConnCase use Oban.Testing, repo: Pleroma.Repo - import Pleroma.Factory alias Pleroma.Activity alias Pleroma.Config alias Pleroma.Delivery @@ -19,8 +18,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint alias Pleroma.Workers.ReceiverWorker + import Pleroma.Factory + + require Pleroma.Constants + setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok @@ -168,6 +172,29 @@ test "it requires authentication if instance is NOT federating", %{ end end + describe "mastodon compatibility routes" do + test "it returns a json representation of the object with accept application/json", %{ + conn: conn + } do + {:ok, object} = + %{ + "type" => "Note", + "content" => "hey", + "id" => Endpoint.url() <> "/users/raymoo/statuses/999999999", + "actor" => Endpoint.url() <> "/users/raymoo", + "to" => [Pleroma.Constants.as_public()] + } + |> Object.create() + + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/users/raymoo/statuses/999999999") + + assert json_response(conn, 200) == ObjectView.render("object.json", %{object: object}) + end + end + describe "/objects/:uuid" do test "it returns a json representation of the object with accept application/json", %{ conn: conn -- cgit v1.2.3 From ba106aa9c8d4854c2fe0f6bb02091bb3bd6719d7 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 22 May 2020 18:15:36 +0400 Subject: Fix notifications mark as read API --- .../api_spec/operations/pleroma_notification_operation.ex | 14 ++++++++++---- .../web/pleroma_api/controllers/notification_controller.ex | 4 ++-- .../controllers/notification_controller_test.exs | 11 ++++++++--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex index 636c39a15..b0c8db863 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex @@ -8,6 +8,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaNotificationOperation do alias Pleroma.Web.ApiSpec.NotificationOperation alias Pleroma.Web.ApiSpec.Schemas.ApiError + import Pleroma.Web.ApiSpec.Helpers + def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") apply(__MODULE__, operation, []) @@ -17,10 +19,14 @@ def mark_as_read_operation do %Operation{ tags: ["Notifications"], summary: "Mark notifications as read. Query parameters are mutually exclusive.", - parameters: [ - Operation.parameter(:id, :query, :string, "A single notification ID to read"), - Operation.parameter(:max_id, :query, :string, "Read all notifications up to this id") - ], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer, description: "A single notification ID to read"}, + max_id: %Schema{type: :integer, description: "Read all notifications up to this ID"} + } + }), security: [%{"oAuth" => ["write:notifications"]}], operationId: "PleromaAPI.NotificationController.mark_as_read", responses: %{ diff --git a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex index 0b2f678c5..3ed8bd294 100644 --- a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.PleromaAPI.NotificationController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaNotificationOperation - def mark_as_read(%{assigns: %{user: user}} = conn, %{id: notification_id}) do + def mark_as_read(%{assigns: %{user: user}, body_params: %{id: notification_id}} = conn, _) do with {:ok, notification} <- Notification.read_one(user, notification_id) do render(conn, "show.json", notification: notification, for: user) else @@ -25,7 +25,7 @@ def mark_as_read(%{assigns: %{user: user}} = conn, %{id: notification_id}) do end end - def mark_as_read(%{assigns: %{user: user}} = conn, %{max_id: max_id}) do + def mark_as_read(%{assigns: %{user: user}, body_params: %{max_id: max_id}} = conn, _) do notifications = user |> Notification.set_read_up_to(max_id) diff --git a/test/web/pleroma_api/controllers/notification_controller_test.exs b/test/web/pleroma_api/controllers/notification_controller_test.exs index 7c5ace804..bb4fe6c49 100644 --- a/test/web/pleroma_api/controllers/notification_controller_test.exs +++ b/test/web/pleroma_api/controllers/notification_controller_test.exs @@ -23,7 +23,8 @@ test "it marks a single notification as read", %{user: user1, conn: conn} do response = conn - |> post("/api/v1/pleroma/notifications/read?id=#{notification1.id}") + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/notifications/read", %{id: notification1.id}) |> json_response_and_validate_schema(:ok) assert %{"pleroma" => %{"is_seen" => true}} = response @@ -41,7 +42,8 @@ test "it marks multiple notifications as read", %{user: user1, conn: conn} do [response1, response2] = conn - |> post("/api/v1/pleroma/notifications/read?max_id=#{notification2.id}") + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/notifications/read", %{max_id: notification2.id}) |> json_response_and_validate_schema(:ok) assert %{"pleroma" => %{"is_seen" => true}} = response1 @@ -54,7 +56,10 @@ test "it marks multiple notifications as read", %{user: user1, conn: conn} do test "it returns error when notification not found", %{conn: conn} do response = conn - |> post("/api/v1/pleroma/notifications/read?id=22222222222222") + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/notifications/read", %{ + id: 22_222_222_222_222 + }) |> json_response_and_validate_schema(:bad_request) assert response == %{"error" => "Cannot get notification"} -- cgit v1.2.3 From fabc11bf9ace488582c9621117269b9f56c47277 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 22 May 2020 17:33:19 +0300 Subject: priv/static: Add a warning discouraging admins from modifying the static files directly --- priv/static/READ_THIS_BEFORE_TOUCHING_FILES_HERE | 1 + 1 file changed, 1 insertion(+) create mode 100644 priv/static/READ_THIS_BEFORE_TOUCHING_FILES_HERE diff --git a/priv/static/READ_THIS_BEFORE_TOUCHING_FILES_HERE b/priv/static/READ_THIS_BEFORE_TOUCHING_FILES_HERE new file mode 100644 index 000000000..eb5294eaf --- /dev/null +++ b/priv/static/READ_THIS_BEFORE_TOUCHING_FILES_HERE @@ -0,0 +1 @@ +If you are an instance admin and you want to modify the instace static files, this is probably not the right place to do it. This directory is checked in version control, so don't be surprised if you get merge conflicts after modifying anything here. Please use instance static directory instead, it has the same directory structure and files placed there will override files placed here. See https://docs.pleroma.social/backend/configuration/static_dir/ for more info -- cgit v1.2.3 From 8a4bd9e5d17bd1acb5c5a61b85ac125202a4aaa4 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 22 May 2020 16:47:22 +0200 Subject: OStatusController: Add Mastodon compatibility route for objects. --- lib/pleroma/web/ostatus/ostatus_controller.ex | 4 +-- test/web/ostatus/ostatus_controller_test.exs | 35 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 513e69c6e..b163bfb14 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -37,8 +37,8 @@ def object(%{assigns: %{format: format}} = conn, _params) ActivityPubController.call(conn, :object) end - def object(%{assigns: %{format: format}} = conn, %{"uuid" => uuid}) do - with id <- o_status_url(conn, :object, uuid), + def object(%{assigns: %{format: format}} = conn, _params) do + with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.get_create_by_object_ap_id_with_object(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)} do diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index bb349cb19..266fe2f45 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -10,7 +10,11 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do alias Pleroma.Config alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint + + require Pleroma.Constants setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -19,6 +23,37 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do setup do: clear_config([:instance, :federating], true) + describe "Mastodon compatibility routes" do + setup %{conn: conn} do + conn = put_req_header(conn, "accept", "text/html") + %{conn: conn} + end + + test "redirects to /notice/:id for html format", %{conn: conn} do + {:ok, object} = + %{ + "type" => "Note", + "content" => "hey", + "id" => Endpoint.url() <> "/users/raymoo/statuses/999999999", + "actor" => Endpoint.url() <> "/users/raymoo", + "to" => [Pleroma.Constants.as_public()] + } + |> Object.create() + + {:ok, activity, _} = + %{ + "type" => "Create", + "object" => object.data["id"], + "actor" => object.data["actor"], + "to" => object.data["to"] + } + |> ActivityPub.persist(local: true) + + conn = get(conn, "/users/raymoo/statuses/999999999") + assert redirected_to(conn) == "/notice/#{activity.id}" + end + end + # Note: see ActivityPubControllerTest for JSON format tests describe "GET /objects/:uuid (text/html)" do setup %{conn: conn} do -- cgit v1.2.3 From 355aa3bdc78465a42a9e0b20baaefd4fba04f596 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 22 May 2020 17:06:12 +0200 Subject: ActivityPubController: Add Mastodon activity compat route. --- .../web/activity_pub/activity_pub_controller.ex | 4 +-- lib/pleroma/web/common_api/utils.ex | 4 +++ lib/pleroma/web/ostatus/ostatus_controller.ex | 2 +- lib/pleroma/web/router.ex | 3 +- .../activity_pub/activity_pub_controller_test.exs | 32 ++++++++++++++++++++++ test/web/ostatus/ostatus_controller_test.exs | 1 + 6 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 5a41dac5c..28727d619 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -102,8 +102,8 @@ def track_object_fetch(conn, object_id) do conn end - def activity(conn, %{"uuid" => uuid}) do - with ap_id <- o_status_url(conn, :activity, uuid), + def activity(conn, _params) do + with ap_id <- Endpoint.url() <> conn.request_path, %Activity{} = activity <- Activity.normalize(ap_id), {_, true} <- {:public?, Visibility.is_public?(activity)} do conn diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index b9fa21648..bf9ca7740 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -468,6 +468,8 @@ def maybe_notify_subscribers( |> Enum.map(& &1.ap_id) recipients ++ subscriber_ids + else + _e -> recipients end end @@ -479,6 +481,8 @@ def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = ac |> User.get_followers() |> Enum.map(& &1.ap_id) |> Enum.concat(recipients) + else + _e -> recipients end end diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index b163bfb14..04a4bdeb4 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -54,7 +54,7 @@ def object(%{assigns: %{format: format}} = conn, _params) do end end - def activity(%{assigns: %{format: format}} = conn, %{"uuid" => _uuid}) + def activity(%{assigns: %{format: format}} = conn, _params) when format in ["json", "activity+json"] do ActivityPubController.call(conn, :activity) end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index b437e56fb..08ab3c8bb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -556,8 +556,9 @@ defmodule Pleroma.Web.Router do get("/notice/:id", OStatus.OStatusController, :notice) get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) - # Mastodon compat routes + # Mastodon compatibility routes get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object) + get("/users/:nickname/statuses/:id/activity", OStatus.OStatusController, :activity) get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index b247163ec..dd2a48a61 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do alias Pleroma.Object alias Pleroma.Tests.ObanHelpers alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.UserView @@ -193,6 +194,37 @@ test "it returns a json representation of the object with accept application/jso assert json_response(conn, 200) == ObjectView.render("object.json", %{object: object}) end + + test "it returns a json representation of the activity with accept application/json", %{ + conn: conn + } do + {:ok, object} = + %{ + "type" => "Note", + "content" => "hey", + "id" => Endpoint.url() <> "/users/raymoo/statuses/999999999", + "actor" => Endpoint.url() <> "/users/raymoo", + "to" => [Pleroma.Constants.as_public()] + } + |> Object.create() + + {:ok, activity, _} = + %{ + "id" => object.data["id"] <> "/activity", + "type" => "Create", + "object" => object.data["id"], + "actor" => object.data["actor"], + "to" => object.data["to"] + } + |> ActivityPub.persist(local: true) + + conn = + conn + |> put_req_header("accept", "application/json") + |> get("/users/raymoo/statuses/999999999/activity") + + assert json_response(conn, 200) == ObjectView.render("object.json", %{object: activity}) + end end describe "/objects/:uuid" do diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index 266fe2f45..0f973d5b6 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -42,6 +42,7 @@ test "redirects to /notice/:id for html format", %{conn: conn} do {:ok, activity, _} = %{ + "id" => object.data["id"] <> "/activity", "type" => "Create", "object" => object.data["id"], "actor" => object.data["actor"], -- cgit v1.2.3 From 91c8467582d1b4b5ad12256292e86dc1c54f0234 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 22 May 2020 17:11:59 +0200 Subject: OStatusController: Add Mastodon activity compat route. --- lib/pleroma/web/ostatus/ostatus_controller.ex | 4 ++-- test/web/ostatus/ostatus_controller_test.exs | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index 04a4bdeb4..de1b0b3f0 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -59,8 +59,8 @@ def activity(%{assigns: %{format: format}} = conn, _params) ActivityPubController.call(conn, :activity) end - def activity(%{assigns: %{format: format}} = conn, %{"uuid" => uuid}) do - with id <- o_status_url(conn, :activity, uuid), + def activity(%{assigns: %{format: format}} = conn, _params) do + with id <- Endpoint.url() <> conn.request_path, {_, %Activity{} = activity} <- {:activity, Activity.normalize(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)} do case format do diff --git a/test/web/ostatus/ostatus_controller_test.exs b/test/web/ostatus/ostatus_controller_test.exs index 0f973d5b6..ee498f4b5 100644 --- a/test/web/ostatus/ostatus_controller_test.exs +++ b/test/web/ostatus/ostatus_controller_test.exs @@ -26,10 +26,7 @@ defmodule Pleroma.Web.OStatus.OStatusControllerTest do describe "Mastodon compatibility routes" do setup %{conn: conn} do conn = put_req_header(conn, "accept", "text/html") - %{conn: conn} - end - test "redirects to /notice/:id for html format", %{conn: conn} do {:ok, object} = %{ "type" => "Note", @@ -50,9 +47,21 @@ test "redirects to /notice/:id for html format", %{conn: conn} do } |> ActivityPub.persist(local: true) + %{conn: conn, activity: activity} + end + + test "redirects to /notice/:id for html format", %{conn: conn, activity: activity} do conn = get(conn, "/users/raymoo/statuses/999999999") assert redirected_to(conn) == "/notice/#{activity.id}" end + + test "redirects to /notice/:id for html format for activity", %{ + conn: conn, + activity: activity + } do + conn = get(conn, "/users/raymoo/statuses/999999999/activity") + assert redirected_to(conn) == "/notice/#{activity.id}" + end end # Note: see ActivityPubControllerTest for JSON format tests -- cgit v1.2.3 From 3506e95499133654658c2d653bf636803b61f45f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 22 May 2020 17:13:09 +0200 Subject: Add Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c672275e..76277a90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added +- ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` - Instance: Extend `/api/v1/instance` with Pleroma-specific information. - NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. -- cgit v1.2.3 From cc82229ba70e054acfdea07a195c1b11961ea1bc Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 22 May 2020 18:19:25 +0300 Subject: Add filename_display_max_length config --- config/config.exs | 3 +- lib/pleroma/web/common_api/utils.ex | 8 ++++-- test/web/common_api/common_api_utils_test.exs | 41 +++++++++++++++++++++------ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/config/config.exs b/config/config.exs index 1b11b4fa9..7385fb6c3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -71,7 +71,8 @@ follow_redirect: true, pool: :upload ] - ] + ], + filename_display_max_length: 30 config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads" diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index b9fa21648..7be9d8caa 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -396,10 +396,12 @@ def to_masto_date(date) when is_binary(date) do def to_masto_date(_), do: "" defp shortname(name) do - if String.length(name) < 30 do - name + with max_length when max_length > 0 <- + Pleroma.Config.get([Pleroma.Upload, :filename_display_max_length], 30), + true <- String.length(name) > max_length do + String.slice(name, 0..max_length) <> "…" else - String.slice(name, 0..30) <> "…" + _ -> name end end diff --git a/test/web/common_api/common_api_utils_test.exs b/test/web/common_api/common_api_utils_test.exs index d7d2d10d5..e67c10b93 100644 --- a/test/web/common_api/common_api_utils_test.exs +++ b/test/web/common_api/common_api_utils_test.exs @@ -14,18 +14,41 @@ defmodule Pleroma.Web.CommonAPI.UtilsTest do @public_address "https://www.w3.org/ns/activitystreams#Public" - test "it adds attachment links to a given text and attachment set" do - name = - "Sakura%20Mana%20%E2%80%93%20Turned%20on%20by%20a%20Senior%20OL%20with%20a%20Temptating%20Tight%20Skirt-s%20Full%20Hipline%20and%20Panty%20Shot-%20Beautiful%20Thick%20Thighs-%20and%20Erotic%20Ass-%20-2015-%20--%20Oppaitime%208-28-2017%206-50-33%20PM.png" + describe "add_attachments/2" do + setup do + name = + "Sakura Mana – Turned on by a Senior OL with a Temptating Tight Skirt-s Full Hipline and Panty Shot- Beautiful Thick Thighs- and Erotic Ass- -2015- -- Oppaitime 8-28-2017 6-50-33 PM.png" - attachment = %{ - "url" => [%{"href" => name}] - } + attachment = %{ + "url" => [%{"href" => URI.encode(name)}] + } - res = Utils.add_attachments("", [attachment]) + %{name: name, attachment: attachment} + end + + test "it adds attachment links to a given text and attachment set", %{ + name: name, + attachment: attachment + } do + len = 10 + clear_config([Pleroma.Upload, :filename_display_max_length], len) - assert res == - "
    Sakura Mana – Turned on by a Se…" + expected = + "
    #{String.slice(name, 0..len)}…" + + assert Utils.add_attachments("", [attachment]) == expected + end + + test "doesn't truncate file name if config for truncate is set to 0", %{ + name: name, + attachment: attachment + } do + clear_config([Pleroma.Upload, :filename_display_max_length], 0) + + expected = "
    #{name}" + + assert Utils.add_attachments("", [attachment]) == expected + end end describe "it confirms the password given is the current users password" do -- cgit v1.2.3 From 8eb1dfadca61c68e3470060481b139969708f0ef Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 22 May 2020 18:30:13 +0300 Subject: Update CHANGELOG and docs --- CHANGELOG.md | 3 ++- config/description.exs | 5 +++++ docs/configuration/cheatsheet.md | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c672275e..8692dfef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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. - Configuration: Add `:database_config_whitelist` setting to whitelist settings which can be configured from AdminFE. +- Configuration: `filename_display_max_length` option to set filename truncate limit, if filename display enabled (0 = no limit). - 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. - Mix task to create trusted OAuth App. - Notifications: Added `follow_request` notification type. @@ -47,7 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Healthcheck reporting the number of memory currently used, rather than allocated in total -- `InsertSkeletonsForDeletedUsers` failing on some instances +- `InsertSkeletonsForDeletedUsers` failing on some instances ## [2.0.3] - 2020-05-02 diff --git a/config/description.exs b/config/description.exs index cf7cc297a..807c945e0 100644 --- a/config/description.exs +++ b/config/description.exs @@ -119,6 +119,11 @@ ] } ] + }, + %{ + key: :filename_display_max_length, + type: :integer, + description: "Set max length of a filename to display. 0 = no limit. Default: 30" } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index e8def466e..505acb293 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -498,6 +498,7 @@ the source code is here: https://github.com/koto-bank/kocaptcha. The default end * `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host. * `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. * `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. +* `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30. !!! warning `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. -- cgit v1.2.3 From 5d60b25e690eb21ad2539a10036ba39489f62f97 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 22 May 2020 15:44:10 +0000 Subject: Apply suggestion to lib/pleroma/web/common_api/utils.ex --- lib/pleroma/web/common_api/utils.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 7be9d8caa..1c0d90a2b 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -397,7 +397,7 @@ def to_masto_date(_), do: "" defp shortname(name) do with max_length when max_length > 0 <- - Pleroma.Config.get([Pleroma.Upload, :filename_display_max_length], 30), + Config.get([Pleroma.Upload, :filename_display_max_length], 30), true <- String.length(name) > max_length do String.slice(name, 0..max_length) <> "…" else -- cgit v1.2.3 From d0c26956da160b2fbfd4855ca7fe31eeebe6528d Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 12:46:14 +0200 Subject: User: Don't error out if we want to refresh a user but can't --- lib/pleroma/user.ex | 17 ++++++++---- .../activity_pub/activity_pub_controller_test.exs | 30 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d2eeeb479..842b28c06 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1618,12 +1618,19 @@ def html_filter_policy(_), do: Pleroma.Config.get([:markup, :scrub_policy]) def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) def get_or_fetch_by_ap_id(ap_id) do - user = get_cached_by_ap_id(ap_id) + cached_user = get_cached_by_ap_id(ap_id) - if !is_nil(user) and !needs_update?(user) do - {:ok, user} - else - fetch_by_ap_id(ap_id) + maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id) + + case {cached_user, maybe_fetched_user} do + {_, {:ok, %User{} = user}} -> + {:ok, user} + + {%User{} = user, _} -> + {:ok, user} + + _ -> + {:error, :not_found} end end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index dd2a48a61..24edab41a 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -451,6 +451,36 @@ test "it inserts an incoming activity into the database", %{conn: conn} do assert Activity.get_by_ap_id(data["id"]) end + @tag capture_log: true + test "it inserts an incoming activity into the database" <> + "even if we can't fetch the user but have it in our db", + %{conn: conn} do + user = + insert(:user, + ap_id: "https://mastodon.example.org/users/raymoo", + ap_enabled: true, + local: false, + last_refreshed_at: nil + ) + + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("actor", user.ap_id) + |> put_in(["object", "attridbutedTo"], user.ap_id) + + conn = + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", data) + + assert "ok" == json_response(conn, 200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + assert Activity.get_by_ap_id(data["id"]) + end + test "it clears `unreachable` federation status of the sender", %{conn: conn} do data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() -- cgit v1.2.3 From 0c970a9d44fc0ceddbb52483f6f2fab11243e0ca Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 12:49:38 +0200 Subject: UserTest: Add test for user refreshing. --- test/user_test.exs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/user_test.exs b/test/user_test.exs index 45125f704..3556ef1b4 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -586,6 +586,26 @@ test "updates an existing user, if stale" do refute user.last_refreshed_at == orig_user.last_refreshed_at end + + @tag capture_log: true + test "it returns the old user if stale, but unfetchable" do + a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800) + + orig_user = + insert( + :user, + local: false, + nickname: "admin@mastodon.example.org", + ap_id: "http://mastodon.example.org/users/raymoo", + last_refreshed_at: a_week_ago + ) + + assert orig_user.last_refreshed_at == a_week_ago + + {:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/raymoo") + + assert user.last_refreshed_at == orig_user.last_refreshed_at + end end test "returns an ap_id for a user" do -- cgit v1.2.3 From 3bec0d2e50a0c468508ae884a3e5dc371106501e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 12:59:42 +0200 Subject: Factory: Set users to be ap_enabled by default. --- test/support/factory.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/support/factory.ex b/test/support/factory.ex index d4284831c..6e3676aca 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -34,7 +34,8 @@ def user_factory do last_digest_emailed_at: NaiveDateTime.utc_now(), last_refreshed_at: NaiveDateTime.utc_now(), notification_settings: %Pleroma.User.NotificationSetting{}, - multi_factor_authentication_settings: %Pleroma.MFA.Settings{} + multi_factor_authentication_settings: %Pleroma.MFA.Settings{}, + ap_enabled: true } %{ -- cgit v1.2.3 From aeb0875025cf37ad8cefe43c637f56a5a16f8628 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 13:48:47 +0200 Subject: StealEmojiPolicyTest: Fix flaky test. --- test/web/activity_pub/mrf/steal_emoji_policy_test.exs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs index 8882c8c13..0e7e57c77 100644 --- a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs +++ b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs @@ -14,8 +14,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicyTest do end setup do - clear_config(:mrf_steal_emoji) - emoji_path = Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") File.rm_rf!(emoji_path) File.mkdir!(emoji_path) @@ -53,8 +51,8 @@ test "Steals emoji on unknown shortcode from allowed remote host" do } } - Config.put([:mrf_steal_emoji, :hosts], ["example.org"]) - Config.put([:mrf_steal_emoji, :size_limit], 284_468) + clear_config([:mrf_steal_emoji, :hosts], ["example.org"]) + clear_config([:mrf_steal_emoji, :size_limit], 284_468) assert {:ok, message} == StealEmojiPolicy.filter(message) -- cgit v1.2.3 From 2dff37604192b086ede4a00d68b886dbec93f3c9 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 13:48:47 +0200 Subject: StealEmojiPolicyTest: Fix flaky test. --- test/web/activity_pub/mrf/steal_emoji_policy_test.exs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs index 8882c8c13..0e7e57c77 100644 --- a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs +++ b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs @@ -14,8 +14,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicyTest do end setup do - clear_config(:mrf_steal_emoji) - emoji_path = Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") File.rm_rf!(emoji_path) File.mkdir!(emoji_path) @@ -53,8 +51,8 @@ test "Steals emoji on unknown shortcode from allowed remote host" do } } - Config.put([:mrf_steal_emoji, :hosts], ["example.org"]) - Config.put([:mrf_steal_emoji, :size_limit], 284_468) + clear_config([:mrf_steal_emoji, :hosts], ["example.org"]) + clear_config([:mrf_steal_emoji, :size_limit], 284_468) assert {:ok, message} == StealEmojiPolicy.filter(message) -- cgit v1.2.3 From 5d5db7e5b7f722f7735798f9b9ed8069091726de Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 14:00:18 +0200 Subject: StealEmojiPolicyTest: Clean up. --- test/web/activity_pub/mrf/steal_emoji_policy_test.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs index 0e7e57c77..3f8222736 100644 --- a/test/web/activity_pub/mrf/steal_emoji_policy_test.exs +++ b/test/web/activity_pub/mrf/steal_emoji_policy_test.exs @@ -19,6 +19,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicyTest do File.mkdir!(emoji_path) Pleroma.Emoji.reload() + + on_exit(fn -> + File.rm_rf!(emoji_path) + end) + + :ok end test "does nothing by default" do -- cgit v1.2.3 From cbcd592300673582e38d0bf539dcdb9a2c1985a1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 22 May 2020 17:52:26 +0400 Subject: Add OpenAPI spec for AdminAPI.RelayController --- .../admin_api/controllers/admin_api_controller.ex | 48 +---------- .../web/admin_api/controllers/relay_controller.ex | 67 ++++++++++++++++ .../api_spec/operations/admin/relay_operation.ex | 83 +++++++++++++++++++ lib/pleroma/web/router.ex | 6 +- .../controllers/admin_api_controller_test.exs | 51 ------------ .../controllers/relay_controller_test.exs | 92 ++++++++++++++++++++++ 6 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/relay_controller.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/relay_operation.ex create mode 100644 test/web/admin_api/controllers/relay_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..b73701f5e 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -20,7 +20,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline - alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView @@ -80,7 +79,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do plug( OAuthScopesPlug, %{scopes: ["write:follows"], admin: true} - when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] + when action in [:user_follow, :user_unfollow] ) plug( @@ -108,7 +107,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :config_show, :list_log, :stats, - :relay_list, :config_descriptions, :need_reboot ] @@ -531,50 +529,6 @@ def right_delete(%{assigns: %{user: %{nickname: nickname}}} = conn, %{"nickname" render_error(conn, :forbidden, "You can't revoke your own admin status.") end - def relay_list(conn, _params) do - with {:ok, list} <- Relay.list() do - json(conn, %{relays: list}) - else - _ -> - conn - |> put_status(500) - end - end - - def relay_follow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do - with {:ok, _message} <- Relay.follow(target) do - ModerationLog.insert_log(%{ - action: "relay_follow", - actor: admin, - target: target - }) - - json(conn, target) - else - _ -> - conn - |> put_status(500) - |> json(target) - end - end - - def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do - with {:ok, _message} <- Relay.unfollow(target) do - ModerationLog.insert_log(%{ - action: "relay_unfollow", - actor: admin, - target: target - }) - - json(conn, target) - else - _ -> - conn - |> put_status(500) - |> json(target) - end - end - @doc "Sends registration invite via email" def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, diff --git a/lib/pleroma/web/admin_api/controllers/relay_controller.ex b/lib/pleroma/web/admin_api/controllers/relay_controller.ex new file mode 100644 index 000000000..cf9f3a14b --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/relay_controller.ex @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RelayController do + use Pleroma.Web, :controller + + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ActivityPub.Relay + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["write:follows"], admin: true} + when action in [:follow, :unfollow] + ) + + plug(OAuthScopesPlug, %{scopes: ["read"], admin: true} when action == :index) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.RelayOperation + + def index(conn, _params) do + with {:ok, list} <- Relay.list() do + json(conn, %{relays: list}) + end + end + + def follow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, _) do + with {:ok, _message} <- Relay.follow(target) do + ModerationLog.insert_log(%{ + action: "relay_follow", + actor: admin, + target: target + }) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end + + def unfollow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, _) do + with {:ok, _message} <- Relay.unfollow(target) do + ModerationLog.insert_log(%{ + action: "relay_unfollow", + actor: admin, + target: target + }) + + json(conn, target) + else + _ -> + conn + |> put_status(500) + |> json(target) + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex new file mode 100644 index 000000000..7672cb467 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex @@ -0,0 +1,83 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.RelayOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Relays"], + summary: "List Relays", + operationId: "AdminAPI.RelayController.index", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + relays: %Schema{ + type: :array, + items: %Schema{type: :string}, + example: ["lain.com", "mstdn.io"] + } + } + }) + } + } + end + + def follow_operation do + %Operation{ + tags: ["Admin", "Relays"], + summary: "Follow a Relay", + operationId: "AdminAPI.RelayController.follow", + security: [%{"oAuth" => ["write:follows"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri} + } + }), + responses: %{ + 200 => + Operation.response("Status", "application/json", %Schema{ + type: :string, + example: "http://mastodon.example.org/users/admin" + }) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["Admin", "Relays"], + summary: "Unfollow a Relay", + operationId: "AdminAPI.RelayController.unfollow", + security: [%{"oAuth" => ["write:follows"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri} + } + }), + responses: %{ + 200 => + Operation.response("Status", "application/json", %Schema{ + type: :string, + example: "http://mastodon.example.org/users/admin" + }) + } + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..269bbabde 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -160,9 +160,9 @@ defmodule Pleroma.Web.Router do :right_delete_multiple ) - get("/relay", AdminAPIController, :relay_list) - post("/relay", AdminAPIController, :relay_follow) - delete("/relay", AdminAPIController, :relay_unfollow) + get("/relay", RelayController, :index) + post("/relay", RelayController, :follow) + delete("/relay", RelayController, :unfollow) post("/users/invite_token", AdminAPIController, :create_invite_token) get("/users/invites", AdminAPIController, :invites) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..82825473c 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -3254,57 +3254,6 @@ test "sets password_reset_pending to true", %{conn: conn} do end end - describe "relays" do - test "POST /relay", %{conn: conn, admin: admin} do - conn = - post(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" - end - - test "GET /relay", %{conn: conn} do - relay_user = Pleroma.Web.ActivityPub.Relay.get_actor() - - ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"] - |> Enum.each(fn ap_id -> - {:ok, user} = User.get_or_fetch_by_ap_id(ap_id) - User.follow(relay_user, user) - end) - - conn = get(conn, "/api/pleroma/admin/relay") - - assert json_response(conn, 200)["relays"] -- ["mastodon.example.org", "mstdn.io"] == [] - end - - test "DELETE /relay", %{conn: conn, admin: admin} do - post(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - conn = - delete(conn, "/api/pleroma/admin/relay", %{ - relay_url: "http://mastodon.example.org/users/admin" - }) - - assert json_response(conn, 200) == "http://mastodon.example.org/users/admin" - - [log_entry_one, log_entry_two] = Repo.all(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry_one) == - "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" - - assert ModerationLog.get_log_entry_message(log_entry_two) == - "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin" - end - end - describe "instances" do test "GET /instances/:instance/statuses", %{conn: conn} do user = insert(:user, local: false, nickname: "archaeme@archae.me") diff --git a/test/web/admin_api/controllers/relay_controller_test.exs b/test/web/admin_api/controllers/relay_controller_test.exs new file mode 100644 index 000000000..64086adc5 --- /dev/null +++ b/test/web/admin_api/controllers/relay_controller_test.exs @@ -0,0 +1,92 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RelayControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.User + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + + :ok + end + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "relays" do + test "POST /relay", %{conn: conn, admin: admin} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + assert json_response_and_validate_schema(conn, 200) == + "http://mastodon.example.org/users/admin" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" + end + + test "GET /relay", %{conn: conn} do + relay_user = Pleroma.Web.ActivityPub.Relay.get_actor() + + ["http://mastodon.example.org/users/admin", "https://mstdn.io/users/mayuutann"] + |> Enum.each(fn ap_id -> + {:ok, user} = User.get_or_fetch_by_ap_id(ap_id) + User.follow(relay_user, user) + end) + + conn = get(conn, "/api/pleroma/admin/relay") + + assert json_response_and_validate_schema(conn, 200)["relays"] -- + ["mastodon.example.org", "mstdn.io"] == [] + end + + test "DELETE /relay", %{conn: conn, admin: admin} do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/pleroma/admin/relay", %{ + relay_url: "http://mastodon.example.org/users/admin" + }) + + assert json_response_and_validate_schema(conn, 200) == + "http://mastodon.example.org/users/admin" + + [log_entry_one, log_entry_two] = Repo.all(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry_one) == + "@#{admin.nickname} followed relay: http://mastodon.example.org/users/admin" + + assert ModerationLog.get_log_entry_message(log_entry_two) == + "@#{admin.nickname} unfollowed relay: http://mastodon.example.org/users/admin" + end + end +end -- cgit v1.2.3 From 5fef40520819bea1effab5ed4937613d8896a453 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 15:06:35 +0200 Subject: User: Change signature of get_users_from_set --- lib/pleroma/conversation.ex | 2 +- lib/pleroma/notification.ex | 3 ++- lib/pleroma/user.ex | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/conversation.ex b/lib/pleroma/conversation.ex index 37d455cfc..e76eb0087 100644 --- a/lib/pleroma/conversation.ex +++ b/lib/pleroma/conversation.ex @@ -63,7 +63,7 @@ def create_or_bump_for(activity, opts \\ []) do ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"] do {:ok, conversation} = create_for_ap_id(ap_id) - users = User.get_users_from_set(activity.recipients, false) + users = User.get_users_from_set(activity.recipients, local_only: false) participations = Enum.map(users, fn user -> diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 8aa9ed2d4..557961e94 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -370,7 +370,8 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity) - potential_receivers = User.get_users_from_set(potential_receiver_ap_ids, local_only) + potential_receivers = + User.get_users_from_set(potential_receiver_ap_ids, local_only: local_only) notification_enabled_ap_ids = potential_receiver_ap_ids diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d2eeeb479..f57cd3e74 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1208,8 +1208,9 @@ def increment_unread_conversation_count(conversation, %User{local: true} = user) def increment_unread_conversation_count(_, user), do: {:ok, user} - @spec get_users_from_set([String.t()], boolean()) :: [User.t()] - def get_users_from_set(ap_ids, local_only \\ true) do + @spec get_users_from_set([String.t()], keyword()) :: [User.t()] + def get_users_from_set(ap_ids, opts \\ []) do + local_only = Keyword.get(opts, :local_only, true) criteria = %{ap_id: ap_ids, deactivated: false} criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria -- cgit v1.2.3 From 6bd7070b00a8d0ac64292f4c7152b71bee5f6b69 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 15:08:43 +0200 Subject: Transmogrifier: Use a simpler way to get mentions. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 10 +++++--- test/web/activity_pub/transmogrifier_test.exs | 33 +++++++++++++++----------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index d594c64f4..8443c284c 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -1045,10 +1045,14 @@ def add_hashtags(object) do Map.put(object, "tag", tags) end + # TODO These should be added on our side on insertion, it doesn't make much + # sense to regenerate these all the time def add_mention_tags(object) do - {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) + to = object["to"] || [] + cc = object["cc"] || [] + mentioned = User.get_users_from_set(to ++ cc, local_only: false) + + mentions = Enum.map(mentioned, &build_mention_tag/1) tags = object["tag"] || [] Map.put(object, "tag", tags ++ mentions) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 356004d48..94d8552e8 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1094,23 +1094,28 @@ test "it turns mentions into tags" do {:ok, activity} = CommonAPI.post(user, %{status: "hey, @#{other_user.nickname}, how are ya? #2hu"}) - {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) - object = modified["object"] + with_mock Pleroma.Notification, + get_notified_from_activity: fn _, _ -> [] end do + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) - expected_mention = %{ - "href" => other_user.ap_id, - "name" => "@#{other_user.nickname}", - "type" => "Mention" - } + object = modified["object"] - expected_tag = %{ - "href" => Pleroma.Web.Endpoint.url() <> "/tags/2hu", - "type" => "Hashtag", - "name" => "#2hu" - } + expected_mention = %{ + "href" => other_user.ap_id, + "name" => "@#{other_user.nickname}", + "type" => "Mention" + } - assert Enum.member?(object["tag"], expected_tag) - assert Enum.member?(object["tag"], expected_mention) + expected_tag = %{ + "href" => Pleroma.Web.Endpoint.url() <> "/tags/2hu", + "type" => "Hashtag", + "name" => "#2hu" + } + + refute called(Pleroma.Notification.get_notified_from_activity(:_, :_)) + assert Enum.member?(object["tag"], expected_tag) + assert Enum.member?(object["tag"], expected_mention) + end end test "it adds the sensitive property" do -- cgit v1.2.3 From 6a85fe1f9d0bfe7aee042671a86c9e58ae2d102b Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 15:53:14 +0200 Subject: Docs: Document reasonable Postgres settings. --- docs/configuration/postgresql.md | 31 +++++++++++++++++++++++++++++++ docs/installation/otp_en.md | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docs/configuration/postgresql.md diff --git a/docs/configuration/postgresql.md b/docs/configuration/postgresql.md new file mode 100644 index 000000000..068f133a9 --- /dev/null +++ b/docs/configuration/postgresql.md @@ -0,0 +1,31 @@ +# Optimizing your Postgresql performance + +Pleroma performance depends to a large extent on good database performance. The default Postgresql settings are mostly fine, but often you can get better performance by changing a few settings. + +You can use [PGTune](https://pgtune.leopard.in.ua) to get recommendations for your setup. If you do, set the "Number of Connections" field to 20, as Pleroma will only use 10 concurrent connections anyway. If you don't, it will give you advice that might even hurt your performance. + +We also recommend not using the "Network Storage" option. + +## Example configurations + +Here are some configuration suggestions for Postgresql 10+. + +### 1GB RAM, 1 CPU +``` +shared_buffers = 256MB +effective_cache_size = 768MB +maintenance_work_mem = 64MB +work_mem = 13107kB +``` + +### 2GB RAM, 2 CPU +``` +shared_buffers = 512MB +effective_cache_size = 1536MB +maintenance_work_mem = 128MB +work_mem = 26214kB +max_worker_processes = 2 +max_parallel_workers_per_gather = 1 +max_parallel_workers = 2 +``` + diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index fb99af699..b627bbb7a 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -63,7 +63,7 @@ apt install postgresql-11-rum ``` #### (Optional) Performance configuration -For optimal performance, you may use [PGTune](https://pgtune.leopard.in.ua), don't forget to restart postgresql after editing the configuration +Check out our Postgresql document for a guide on optimizing Postgresql performance settings. ```sh tab="Alpine" rc-service postgresql restart -- cgit v1.2.3 From dbd07d29a358a446d87078d60b993a59b757ad1d Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 17:27:45 +0200 Subject: Streamer: Don't crash on streaming chat notifications --- lib/pleroma/web/common_api/common_api.ex | 9 +++++---- test/web/streamer/streamer_test.exs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c08edbc5f..764fa4f4f 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -467,12 +467,13 @@ def remove_mute(user, activity) do {:ok, activity} end - def thread_muted?(%{id: nil} = _user, _activity), do: false - - def thread_muted?(user, activity) do - ThreadMute.exists?(user.id, activity.data["context"]) + def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) + when is_binary("context") do + ThreadMute.exists?(user_id, context) end + def thread_muted?(_, _), do: false + def report(user, data) do with {:ok, account} <- get_reported_account(data.account_id), {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index cb4595bb6..115ba4703 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -126,6 +126,21 @@ test "it sends notify to in the 'user:notification' stream", %{user: user, notif refute Streamer.filtered_by_user?(user, notify) end + test "it sends chat message notifications to the 'user:notification' stream", %{user: user} do + other_user = insert(:user) + + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + + notify = + Repo.get_by(Pleroma.Notification, user_id: user.id, activity_id: create_activity.id) + |> Repo.preload(:activity) + + Streamer.get_topic_and_add_socket("user:notification", user) + Streamer.stream("user:notification", notify) + assert_receive {:render_with_user, _, _, ^notify} + refute Streamer.filtered_by_user?(user, notify) + end + test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{ user: user } do -- cgit v1.2.3 From f7cb3f4cfc7eaf6ff67e27a7d6449e23e09a501e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 17:11:35 +0000 Subject: Apply suggestion to docs/installation/otp_en.md --- docs/installation/otp_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index b627bbb7a..6f9749ef1 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -63,7 +63,7 @@ apt install postgresql-11-rum ``` #### (Optional) Performance configuration -Check out our Postgresql document for a guide on optimizing Postgresql performance settings. +It is encouraged to check [Optimizing your Postgresql performance](../configuration/postgresql.md) document, for tips on PostgreSQL tuning. ```sh tab="Alpine" rc-service postgresql restart -- cgit v1.2.3 From af3568a6d99cbd73d1e685d7d2f57292ef951f43 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 25 May 2020 19:26:07 +0200 Subject: Docs: sql -> SQL --- docs/configuration/postgresql.md | 6 +++--- docs/installation/otp_en.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration/postgresql.md b/docs/configuration/postgresql.md index 068f133a9..6983fb459 100644 --- a/docs/configuration/postgresql.md +++ b/docs/configuration/postgresql.md @@ -1,6 +1,6 @@ -# Optimizing your Postgresql performance +# Optimizing your PostgreSQL performance -Pleroma performance depends to a large extent on good database performance. The default Postgresql settings are mostly fine, but often you can get better performance by changing a few settings. +Pleroma performance depends to a large extent on good database performance. The default PostgreSQL settings are mostly fine, but often you can get better performance by changing a few settings. You can use [PGTune](https://pgtune.leopard.in.ua) to get recommendations for your setup. If you do, set the "Number of Connections" field to 20, as Pleroma will only use 10 concurrent connections anyway. If you don't, it will give you advice that might even hurt your performance. @@ -8,7 +8,7 @@ We also recommend not using the "Network Storage" option. ## Example configurations -Here are some configuration suggestions for Postgresql 10+. +Here are some configuration suggestions for PostgreSQL 10+. ### 1GB RAM, 1 CPU ``` diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 6f9749ef1..86135cd20 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -63,7 +63,7 @@ apt install postgresql-11-rum ``` #### (Optional) Performance configuration -It is encouraged to check [Optimizing your Postgresql performance](../configuration/postgresql.md) document, for tips on PostgreSQL tuning. +It is encouraged to check [Optimizing your PostgreSQL performance](../configuration/postgresql.md) document, for tips on PostgreSQL tuning. ```sh tab="Alpine" rc-service postgresql restart -- cgit v1.2.3 From 0ba1f2631a09cc0a40f8a0bc2f81ff2c83beedfb Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 25 May 2020 22:02:22 +0400 Subject: Add OpenAPI spec for AdminAPI.OAuthAppContoller --- .../admin_api/controllers/admin_api_controller.ex | 83 -------- .../admin_api/controllers/oauth_app_controller.ex | 87 ++++++++ .../operations/admin/oauth_app_operation.ex | 215 ++++++++++++++++++++ lib/pleroma/web/oauth/app.ex | 29 +-- lib/pleroma/web/router.ex | 8 +- .../controllers/admin_api_controller_test.exs | 185 ----------------- .../controllers/oauth_app_controller_test.exs | 220 +++++++++++++++++++++ 7 files changed, 541 insertions(+), 286 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex create mode 100644 test/web/admin_api/controllers/oauth_app_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..4f10bd947 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -32,8 +32,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint alias Pleroma.Web.MastodonAPI - alias Pleroma.Web.MastodonAPI.AppView - alias Pleroma.Web.OAuth.App alias Pleroma.Web.Router require Logger @@ -122,10 +120,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :config_update, :resend_confirmation_email, :confirm_email, - :oauth_app_create, - :oauth_app_list, - :oauth_app_update, - :oauth_app_delete, :reload_emoji ] ) @@ -995,83 +989,6 @@ def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" = conn |> json("") end - def oauth_app_create(conn, params) do - params = - if params["name"] do - Map.put(params, "client_name", params["name"]) - else - params - end - - result = - case App.create(params) do - {:ok, app} -> - AppView.render("show.json", %{app: app, admin: true}) - - {:error, changeset} -> - App.errors(changeset) - end - - json(conn, result) - end - - def oauth_app_update(conn, params) do - params = - if params["name"] do - Map.put(params, "client_name", params["name"]) - else - params - end - - with {:ok, app} <- App.update(params) do - json(conn, AppView.render("show.json", %{app: app, admin: true})) - else - {:error, changeset} -> - json(conn, App.errors(changeset)) - - nil -> - json_response(conn, :bad_request, "") - end - end - - def oauth_app_list(conn, params) do - {page, page_size} = page_params(params) - - search_params = %{ - client_name: params["name"], - client_id: params["client_id"], - page: page, - page_size: page_size - } - - search_params = - if Map.has_key?(params, "trusted") do - Map.put(search_params, :trusted, params["trusted"]) - else - search_params - end - - with {:ok, apps, count} <- App.search(search_params) do - json( - conn, - AppView.render("index.json", - apps: apps, - count: count, - page_size: page_size, - admin: true - ) - ) - end - end - - def oauth_app_delete(conn, params) do - with {:ok, _app} <- App.destroy(params["id"]) do - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - def stats(conn, _) do count = Stats.get_status_visibility_count() diff --git a/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex new file mode 100644 index 000000000..04e629fc1 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex @@ -0,0 +1,87 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.OAuth.App + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(:put_view, Pleroma.Web.MastodonAPI.AppView) + + plug( + OAuthScopesPlug, + %{scopes: ["write"], admin: true} + when action in [:create, :index, :update, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.OAuthAppOperation + + def index(conn, params) do + search_params = + params + |> Map.take([:client_id, :page, :page_size, :trusted]) + |> Map.put(:client_name, params[:name]) + + with {:ok, apps, count} <- App.search(search_params) do + render(conn, "index.json", + apps: apps, + count: count, + page_size: params.page_size, + admin: true + ) + end + end + + def create(%{body_params: params} = conn, _) do + params = + if params[:name] do + Map.put(params, :client_name, params[:name]) + else + params + end + + case App.create(params) do + {:ok, app} -> + render(conn, "show.json", app: app, admin: true) + + {:error, changeset} -> + json(conn, App.errors(changeset)) + end + end + + def update(%{body_params: params} = conn, %{id: id}) do + params = + if params[:name] do + Map.put(params, :client_name, params.name) + else + params + end + + with {:ok, app} <- App.update(id, params) do + render(conn, "show.json", app: app, admin: true) + else + {:error, changeset} -> + json(conn, App.errors(changeset)) + + nil -> + json_response(conn, :bad_request, "") + end + end + + def delete(conn, params) do + with {:ok, _app} <- App.destroy(params.id) do + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex new file mode 100644 index 000000000..fbc9f80d7 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex @@ -0,0 +1,215 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.OAuthAppOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + summary: "List OAuth apps", + tags: ["Admin", "oAuth Apps"], + operationId: "AdminAPI.OAuthAppController.index", + security: [%{"oAuth" => ["write"]}], + parameters: [ + Operation.parameter(:name, :query, %Schema{type: :string}, "App name"), + Operation.parameter(:client_id, :query, %Schema{type: :string}, "Client ID"), + Operation.parameter(:page, :query, %Schema{type: :integer, default: 1}, "Page"), + Operation.parameter( + :trusted, + :query, + %Schema{type: :boolean, default: false}, + "Trusted apps" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of apps to return" + ) + ], + responses: %{ + 200 => + Operation.response("List of apps", "application/json", %Schema{ + type: :object, + properties: %{ + apps: %Schema{type: :array, items: oauth_app()}, + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer} + }, + example: %{ + "apps" => [ + %{ + "id" => 1, + "name" => "App name", + "client_id" => "yHoDSiWYp5mPV6AfsaVOWjdOyt5PhWRiafi6MRd1lSk", + "client_secret" => "nLmis486Vqrv2o65eM9mLQx_m_4gH-Q6PcDpGIMl6FY", + "redirect_uri" => "https://example.com/oauth-callback", + "website" => "https://example.com", + "trusted" => true + } + ], + "count" => 1, + "page_size" => 50 + } + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["Admin", "oAuth Apps"], + summary: "Create OAuth App", + operationId: "AdminAPI.OAuthAppController.create", + requestBody: request_body("Parameters", create_request()), + security: [%{"oAuth" => ["write"]}], + responses: %{ + 200 => Operation.response("App", "application/json", oauth_app()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "oAuth Apps"], + summary: "Update OAuth App", + operationId: "AdminAPI.OAuthAppController.update", + parameters: [id_param()], + security: [%{"oAuth" => ["write"]}], + requestBody: request_body("Parameters", update_request()), + responses: %{ + 200 => Operation.response("App", "application/json", oauth_app()), + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + oneOf: [ApiError, %Schema{type: :string}] + }) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "oAuth Apps"], + summary: "Delete OAuth App", + operationId: "AdminAPI.OAuthAppController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write"]}], + responses: %{ + 204 => no_content_response(), + 400 => no_content_response() + } + } + end + + defp create_request do + %Schema{ + title: "oAuthAppCreateRequest", + type: :object, + required: [:name, :redirect_uris], + properties: %{ + name: %Schema{type: :string, description: "Application Name"}, + scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, + 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." + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of the app" + }, + trusted: %Schema{ + type: :boolean, + nullable: true, + default: false, + description: "Is the app trusted?" + } + }, + example: %{ + "name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/", + "scopes" => ["read", "write"], + "trusted" => true + } + } + end + + defp update_request do + %Schema{ + title: "oAuthAppUpdateRequest", + type: :object, + properties: %{ + name: %Schema{type: :string, description: "Application Name"}, + scopes: %Schema{type: :array, items: %Schema{type: :string}, description: "oAuth scopes"}, + 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." + }, + website: %Schema{ + type: :string, + nullable: true, + description: "A URL to the homepage of the app" + }, + trusted: %Schema{ + type: :boolean, + nullable: true, + default: false, + description: "Is the app trusted?" + } + }, + example: %{ + "name" => "My App", + "redirect_uris" => "https://myapp.com/auth/callback", + "website" => "https://myapp.com/", + "scopes" => ["read", "write"], + "trusted" => true + } + } + end + + defp oauth_app do + %Schema{ + title: "oAuthApp", + type: :object, + properties: %{ + id: %Schema{type: :integer}, + name: %Schema{type: :string}, + client_id: %Schema{type: :string}, + client_secret: %Schema{type: :string}, + redirect_uri: %Schema{type: :string}, + website: %Schema{type: :string, nullable: true}, + trusted: %Schema{type: :boolean} + }, + example: %{ + "id" => 123, + "name" => "My App", + "client_id" => "TWhM-tNSuncnqN7DBJmoyeLnk6K3iJJ71KKXxgL1hPM", + "client_secret" => "ZEaFUFmF0umgBX1qKJDjaU99Q31lDkOU8NutzTOoliw", + "redirect_uri" => "https://myapp.com/oauth-callback", + "website" => "https://myapp.com/", + "trusted" => false + } + } + end + + def id_param do + Operation.parameter(:id, :path, :integer, "App ID", + example: 1337, + required: true + ) + end +end diff --git a/lib/pleroma/web/oauth/app.ex b/lib/pleroma/web/oauth/app.ex index 6a6d5f2e2..df99472e1 100644 --- a/lib/pleroma/web/oauth/app.ex +++ b/lib/pleroma/web/oauth/app.ex @@ -25,12 +25,12 @@ defmodule Pleroma.Web.OAuth.App do timestamps() end - @spec changeset(App.t(), map()) :: Ecto.Changeset.t() + @spec changeset(t(), map()) :: Ecto.Changeset.t() def changeset(struct, params) do cast(struct, params, [:client_name, :redirect_uris, :scopes, :website, :trusted]) end - @spec register_changeset(App.t(), map()) :: Ecto.Changeset.t() + @spec register_changeset(t(), map()) :: Ecto.Changeset.t() def register_changeset(struct, params \\ %{}) do changeset = struct @@ -52,18 +52,19 @@ def register_changeset(struct, params \\ %{}) do end end - @spec create(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def create(params) do - with changeset <- __MODULE__.register_changeset(%__MODULE__{}, params) do - Repo.insert(changeset) - end + %__MODULE__{} + |> register_changeset(params) + |> Repo.insert() end - @spec update(map()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} - def update(params) do - with %__MODULE__{} = app <- Repo.get(__MODULE__, params["id"]), - changeset <- changeset(app, params) do - Repo.update(changeset) + @spec update(pos_integer(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def update(id, params) do + with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do + app + |> changeset(params) + |> Repo.update() end end @@ -71,7 +72,7 @@ def update(params) do Gets app by attrs or create new with attrs. And updates the scopes if need. """ - @spec get_or_make(map(), list(String.t())) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + @spec get_or_make(map(), list(String.t())) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def get_or_make(attrs, scopes) do with %__MODULE__{} = app <- Repo.get_by(__MODULE__, attrs) do update_scopes(app, scopes) @@ -92,7 +93,7 @@ defp update_scopes(%__MODULE__{} = app, scopes) do |> Repo.update() end - @spec search(map()) :: {:ok, [App.t()], non_neg_integer()} + @spec search(map()) :: {:ok, [t()], non_neg_integer()} def search(params) do query = from(a in __MODULE__) @@ -128,7 +129,7 @@ def search(params) do {:ok, Repo.all(query), count} end - @spec destroy(pos_integer()) :: {:ok, App.t()} | {:error, Ecto.Changeset.t()} + @spec destroy(pos_integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} def destroy(id) do with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do Repo.delete(app) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..46f03cdfd 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -205,10 +205,10 @@ defmodule Pleroma.Web.Router do post("/reload_emoji", AdminAPIController, :reload_emoji) get("/stats", AdminAPIController, :stats) - get("/oauth_app", AdminAPIController, :oauth_app_list) - post("/oauth_app", AdminAPIController, :oauth_app_create) - patch("/oauth_app/:id", AdminAPIController, :oauth_app_update) - delete("/oauth_app/:id", AdminAPIController, :oauth_app_delete) + get("/oauth_app", OAuthAppController, :index) + post("/oauth_app", OAuthAppController, :create) + patch("/oauth_app/:id", OAuthAppController, :update) + delete("/oauth_app/:id", OAuthAppController, :delete) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..f704cdd3a 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -3522,191 +3522,6 @@ test "status visibility count", %{conn: conn} do response["status_visibility"] end end - - describe "POST /api/pleroma/admin/oauth_app" do - test "errors", %{conn: conn} do - response = conn |> post("/api/pleroma/admin/oauth_app", %{}) |> json_response(200) - - assert response == %{"name" => "can't be blank", "redirect_uris" => "can't be blank"} - end - - test "success", %{conn: conn} do - base_url = Web.base_url() - app_name = "Trusted app" - - response = - conn - |> post("/api/pleroma/admin/oauth_app", %{ - name: app_name, - redirect_uris: base_url - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "name" => ^app_name, - "redirect_uri" => ^base_url, - "trusted" => false - } = response - end - - test "with trusted", %{conn: conn} do - base_url = Web.base_url() - app_name = "Trusted app" - - response = - conn - |> post("/api/pleroma/admin/oauth_app", %{ - name: app_name, - redirect_uris: base_url, - trusted: true - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "name" => ^app_name, - "redirect_uri" => ^base_url, - "trusted" => true - } = response - end - end - - describe "GET /api/pleroma/admin/oauth_app" do - setup do - app = insert(:oauth_app) - {:ok, app: app} - end - - test "list", %{conn: conn} do - response = - conn - |> get("/api/pleroma/admin/oauth_app") - |> json_response(200) - - assert %{"apps" => apps, "count" => count, "page_size" => _} = response - - assert length(apps) == count - end - - test "with page size", %{conn: conn} do - insert(:oauth_app) - page_size = 1 - - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{page_size: to_string(page_size)}) - |> json_response(200) - - assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response - - assert length(apps) == page_size - end - - test "search by client name", %{conn: conn, app: app} do - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{name: app.client_name}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - - test "search by client id", %{conn: conn, app: app} do - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{client_id: app.client_id}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - - test "only trusted", %{conn: conn} do - app = insert(:oauth_app, trusted: true) - - response = - conn - |> get("/api/pleroma/admin/oauth_app", %{trusted: true}) - |> json_response(200) - - assert %{"apps" => [returned], "count" => _, "page_size" => _} = response - - assert returned["client_id"] == app.client_id - assert returned["name"] == app.client_name - end - end - - describe "DELETE /api/pleroma/admin/oauth_app/:id" do - test "with id", %{conn: conn} do - app = insert(:oauth_app) - - response = - conn - |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) - |> json_response(:no_content) - - assert response == "" - end - - test "with non existance id", %{conn: conn} do - response = - conn - |> delete("/api/pleroma/admin/oauth_app/0") - |> json_response(:bad_request) - - assert response == "" - end - end - - describe "PATCH /api/pleroma/admin/oauth_app/:id" do - test "with id", %{conn: conn} do - app = insert(:oauth_app) - - name = "another name" - url = "https://example.com" - scopes = ["admin"] - id = app.id - website = "http://website.com" - - response = - conn - |> patch("/api/pleroma/admin/oauth_app/" <> to_string(app.id), %{ - name: name, - trusted: true, - redirect_uris: url, - scopes: scopes, - website: website - }) - |> json_response(200) - - assert %{ - "client_id" => _, - "client_secret" => _, - "id" => ^id, - "name" => ^name, - "redirect_uri" => ^url, - "trusted" => true, - "website" => ^website - } = response - end - - test "without id", %{conn: conn} do - response = - conn - |> patch("/api/pleroma/admin/oauth_app/0") - |> json_response(:bad_request) - - assert response == "" - end - end end # Needed for testing diff --git a/test/web/admin_api/controllers/oauth_app_controller_test.exs b/test/web/admin_api/controllers/oauth_app_controller_test.exs new file mode 100644 index 000000000..ed7c4172c --- /dev/null +++ b/test/web/admin_api/controllers/oauth_app_controller_test.exs @@ -0,0 +1,220 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppControllerTest do + use Pleroma.Web.ConnCase, async: true + use Oban.Testing, repo: Pleroma.Repo + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.Web + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "POST /api/pleroma/admin/oauth_app" do + test "errors", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{}) + |> json_response_and_validate_schema(400) + + assert %{ + "error" => "Missing field: name. Missing field: redirect_uris." + } = response + end + + test "success", %{conn: conn} do + base_url = Web.base_url() + app_name = "Trusted app" + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => false + } = response + end + + test "with trusted", %{conn: conn} do + base_url = Web.base_url() + app_name = "Trusted app" + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/oauth_app", %{ + name: app_name, + redirect_uris: base_url, + trusted: true + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "name" => ^app_name, + "redirect_uri" => ^base_url, + "trusted" => true + } = response + end + end + + describe "GET /api/pleroma/admin/oauth_app" do + setup do + app = insert(:oauth_app) + {:ok, app: app} + end + + test "list", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/oauth_app") + |> json_response_and_validate_schema(200) + + assert %{"apps" => apps, "count" => count, "page_size" => _} = response + + assert length(apps) == count + end + + test "with page size", %{conn: conn} do + insert(:oauth_app) + page_size = 1 + + response = + conn + |> get("/api/pleroma/admin/oauth_app?page_size=#{page_size}") + |> json_response_and_validate_schema(200) + + assert %{"apps" => apps, "count" => _, "page_size" => ^page_size} = response + + assert length(apps) == page_size + end + + test "search by client name", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app?name=#{app.client_name}") + |> json_response_and_validate_schema(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "search by client id", %{conn: conn, app: app} do + response = + conn + |> get("/api/pleroma/admin/oauth_app?client_id=#{app.client_id}") + |> json_response_and_validate_schema(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + + test "only trusted", %{conn: conn} do + app = insert(:oauth_app, trusted: true) + + response = + conn + |> get("/api/pleroma/admin/oauth_app?trusted=true") + |> json_response_and_validate_schema(200) + + assert %{"apps" => [returned], "count" => _, "page_size" => _} = response + + assert returned["client_id"] == app.client_id + assert returned["name"] == app.client_name + end + end + + describe "DELETE /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + response = + conn + |> delete("/api/pleroma/admin/oauth_app/" <> to_string(app.id)) + |> json_response_and_validate_schema(:no_content) + + assert response == "" + end + + test "with non existance id", %{conn: conn} do + response = + conn + |> delete("/api/pleroma/admin/oauth_app/0") + |> json_response_and_validate_schema(:bad_request) + + assert response == "" + end + end + + describe "PATCH /api/pleroma/admin/oauth_app/:id" do + test "with id", %{conn: conn} do + app = insert(:oauth_app) + + name = "another name" + url = "https://example.com" + scopes = ["admin"] + id = app.id + website = "http://website.com" + + response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/oauth_app/#{id}", %{ + name: name, + trusted: true, + redirect_uris: url, + scopes: scopes, + website: website + }) + |> json_response_and_validate_schema(200) + + assert %{ + "client_id" => _, + "client_secret" => _, + "id" => ^id, + "name" => ^name, + "redirect_uri" => ^url, + "trusted" => true, + "website" => ^website + } = response + end + + test "without id", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/oauth_app/0") + |> json_response_and_validate_schema(:bad_request) + + assert response == "" + end + end +end -- cgit v1.2.3 From e32b7ae044be284c4b0d596d2effd1c7801873b3 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 25 May 2020 23:01:37 +0400 Subject: Skip failing `:crypt` test on mac --- test/plugs/authentication_plug_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugs/authentication_plug_test.exs b/test/plugs/authentication_plug_test.exs index 3c70c1747..777ae15ae 100644 --- a/test/plugs/authentication_plug_test.exs +++ b/test/plugs/authentication_plug_test.exs @@ -68,6 +68,7 @@ test "with a bcrypt hash, it updates to a pkbdf2 hash", %{conn: conn} do assert "$pbkdf2" <> _ = user.password_hash end + @tag :skip_on_mac test "with a crypt hash, it updates to a pkbdf2 hash", %{conn: conn} do user = insert(:user, -- cgit v1.2.3 From d7a57004ef975e2cf02facb9d80cff287a5d6d3b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Mon, 25 May 2020 23:27:47 +0300 Subject: [#1501] Made user feed contain public and unlisted activities. --- lib/pleroma/web/activity_pub/activity_pub.ex | 25 +++++++++++++++++++------ lib/pleroma/web/feed/user_controller.ex | 2 +- test/web/feed/user_controller_test.exs | 26 +++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 2cea55285..0fe71694a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -538,14 +538,27 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do |> Repo.one() end - @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] - def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] + def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do opts = Map.drop(opts, ["user"]) - [Constants.as_public()] - |> fetch_activities_query(opts) - |> restrict_unlisted() - |> Pagination.fetch_paginated(opts, pagination) + query = fetch_activities_query([Constants.as_public()], opts) + + query = + if opts["restrict_unlisted"] do + restrict_unlisted(query) + else + query + end + + Pagination.fetch_paginated(query, opts, pagination) + end + + @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] + def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do + opts + |> Map.put("restrict_unlisted", true) + |> fetch_public_or_unlisted_activities(pagination) end @valid_visibilities ~w[direct unlisted public private] diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 1b72e23dc..5a6fc9de0 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -56,7 +56,7 @@ def feed(conn, %{"nickname" => nickname} = params) do "actor_id" => user.ap_id } |> put_if_exist("max_id", params["max_id"]) - |> ActivityPub.fetch_public_activities() + |> ActivityPub.fetch_public_or_unlisted_activities() conn |> put_resp_content_type("application/#{format}+xml") diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index 05ad427c2..fa2ed1ea5 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -11,13 +11,14 @@ defmodule Pleroma.Web.Feed.UserControllerTest do alias Pleroma.Config alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.CommonAPI setup do: clear_config([:instance, :federating], true) describe "feed" do setup do: clear_config([:feed]) - test "gets a feed", %{conn: conn} do + test "gets an atom feed", %{conn: conn} do Config.put( [:feed, :post_title], %{max_length: 10, omission: "..."} @@ -157,6 +158,29 @@ test "returns 404 for a missing feed", %{conn: conn} do assert response(conn, 404) end + + test "returns feed with public and unlisted activities", %{conn: conn} do + user = insert(:user) + + {:ok, _} = CommonAPI.post(user, %{status: "public", visibility: "public"}) + {:ok, _} = CommonAPI.post(user, %{status: "direct", visibility: "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "unlisted", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: "private", visibility: "private"}) + + resp = + conn + |> put_req_header("accept", "application/atom+xml") + |> get(user_feed_path(conn, :feed, user.nickname)) + |> response(200) + + activity_titles = + resp + |> SweetXml.parse() + |> SweetXml.xpath(~x"//entry/title/text()"l) + |> Enum.sort() + + assert activity_titles == ['public', 'unlisted'] + end end # Note: see ActivityPubControllerTest for JSON format tests -- cgit v1.2.3 From 8f08384d8058f61753c28d37c90b47a2886f348c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 18 May 2020 10:09:21 +0300 Subject: another view for account in admin-fe status_show --- lib/pleroma/web/admin_api/controllers/status_controller.ex | 2 +- test/web/admin_api/controllers/status_controller_test.exs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index 08cb9c10b..c91fbc771 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -42,7 +42,7 @@ def index(%{assigns: %{user: _admin}} = conn, params) do def show(conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do conn - |> put_view(MastodonAPI.StatusView) + |> put_view(Pleroma.Web.AdminAPI.StatusView) |> render("show.json", %{activity: activity}) else nil -> {:error, :not_found} diff --git a/test/web/admin_api/controllers/status_controller_test.exs b/test/web/admin_api/controllers/status_controller_test.exs index 124d8dc2e..eff78fb0a 100644 --- a/test/web/admin_api/controllers/status_controller_test.exs +++ b/test/web/admin_api/controllers/status_controller_test.exs @@ -42,6 +42,14 @@ test "shows activity", %{conn: conn} do |> json_response_and_validate_schema(200) assert response["id"] == activity.id + + account = response["account"] + actor = User.get_by_ap_id(activity.actor) + + assert account["id"] == actor.id + assert account["nickname"] == actor.nickname + assert account["deactivated"] == actor.deactivated + assert account["confirmation_pending"] == actor.confirmation_pending end end -- cgit v1.2.3 From 95ebfb9190e6e7d446213ca57e8c99aa3116ed0a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 13:13:39 +0400 Subject: Move invite actions to AdminAPI.InviteTokenController --- .../admin_api/controllers/admin_api_controller.ex | 72 ------ .../controllers/invite_token_controller.ex | 88 ++++++++ .../operations/admin/invite_token_operation.ex | 165 ++++++++++++++ lib/pleroma/web/router.ex | 8 +- .../controllers/admin_api_controller_test.exs | 223 ------------------- .../controllers/invite_token_controller_test.exs | 247 +++++++++++++++++++++ 6 files changed, 504 insertions(+), 299 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/invite_token_controller.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex create mode 100644 test/web/admin_api/controllers/invite_token_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..95582b008 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -16,7 +16,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.ReportNote alias Pleroma.Stats alias Pleroma.User - alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline @@ -69,14 +68,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do ] ) - plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :invites) - - plug( - OAuthScopesPlug, - %{scopes: ["write:invites"], admin: true} - when action in [:create_invite_token, :revoke_invite, :email_invite] - ) - plug( OAuthScopesPlug, %{scopes: ["write:follows"], admin: true} @@ -575,69 +566,6 @@ def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) end end - @doc "Sends registration invite via email" - def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do - 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( - user, - invite_token, - email, - params["name"] - ), - {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do - json_response(conn, :no_content, "") - else - {:registrations_open, _} -> - {:error, "To send invites you need to set the `registrations_open` option to false."} - - {:invites_enabled, _} -> - {:error, "To send invites you need to set the `invites_enabled` option to true."} - end - end - - @doc "Create an account registration invite token" - def create_invite_token(conn, params) do - opts = %{} - - opts = - if params["max_use"], - do: Map.put(opts, :max_use, params["max_use"]), - else: opts - - opts = - if params["expires_at"], - do: Map.put(opts, :expires_at, params["expires_at"]), - else: opts - - {:ok, invite} = UserInviteToken.create_invite(opts) - - json(conn, AccountView.render("invite.json", %{invite: invite})) - end - - @doc "Get list of created invites" - def invites(conn, _params) do - invites = UserInviteToken.list_invites() - - conn - |> put_view(AccountView) - |> render("invites.json", %{invites: invites}) - end - - @doc "Revokes invite by token" - def revoke_invite(conn, %{"token" => token}) do - with {:ok, invite} <- UserInviteToken.find_by_token(token), - {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do - conn - |> put_view(AccountView) - |> render("invite.json", %{invite: updated_invite}) - else - nil -> {:error, :not_found} - end - end - @doc "Get a password reset token (base64 string) for given nickname" def get_password_reset(conn, %{"nickname" => nickname}) do (%User{local: true} = user) = User.get_cached_by_nickname(nickname) diff --git a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex new file mode 100644 index 000000000..a0291e9c3 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex @@ -0,0 +1,88 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteTokenController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Config + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.UserInviteToken + alias Pleroma.Web.AdminAPI.AccountView + + require Logger + + plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["write:invites"], admin: true} when action in [:create, :revoke, :email] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + @doc "Get list of created invites" + def index(conn, _params) do + invites = UserInviteToken.list_invites() + + conn + |> put_view(AccountView) + |> render("invites.json", %{invites: invites}) + end + + @doc "Create an account registration invite token" + def create(conn, params) do + opts = %{} + + opts = + if params["max_use"], + do: Map.put(opts, :max_use, params["max_use"]), + else: opts + + opts = + if params["expires_at"], + do: Map.put(opts, :expires_at, params["expires_at"]), + else: opts + + {:ok, invite} = UserInviteToken.create_invite(opts) + + json(conn, AccountView.render("invite.json", %{invite: invite})) + end + + @doc "Revokes invite by token" + def revoke(conn, %{"token" => token}) do + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do + conn + |> put_view(AccountView) + |> render("invite.json", %{invite: updated_invite}) + else + nil -> {:error, :not_found} + end + end + + @doc "Sends registration invite via email" + def email(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do + 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( + user, + invite_token, + email, + params["name"] + ), + {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do + json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + {:error, "To send invites you need to set the `registrations_open` option to false."} + + {:invites_enabled, _} -> + {:error, "To send invites you need to set the `invites_enabled` option to true."} + end + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex new file mode 100644 index 000000000..09a7735d1 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex @@ -0,0 +1,165 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.InviteTokenOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope + + import Pleroma.Web.ApiSpec.Helpers + import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Statuses"], + operationId: "AdminAPI.StatusController.index", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + Operation.parameter( + :godmode, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see private statuses" + ), + Operation.parameter( + :local_only, + :query, + %Schema{type: :boolean, default: false}, + "Excludes remote statuses" + ), + Operation.parameter( + :with_reblogs, + :query, + %Schema{type: :boolean, default: false}, + "Allows to see reblogs" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], + responses: %{ + 200 => + Operation.response("Array of statuses", "application/json", %Schema{ + type: :array, + items: status() + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Show Status", + operationId: "AdminAPI.StatusController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Change the scope of an individual reported status", + operationId: "AdminAPI.StatusController.update", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Status", "application/json", Status), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "Statuses"], + summary: "Delete an individual reported status", + operationId: "AdminAPI.StatusController.delete", + parameters: [id_param()], + security: [%{"oAuth" => ["write:statuses"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp status do + %Schema{ + anyOf: [ + Status, + %Schema{ + type: :object, + properties: %{ + account: %Schema{allOf: [Account, admin_account()]} + } + } + ] + } + end + + defp admin_account do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + avatar: %Schema{type: :string}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + deactivated: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :string}, + confirmation_pending: %Schema{type: :string} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + sensitive: %Schema{ + type: :boolean, + description: "Mark status and attached media as sensitive?" + }, + visibility: VisibilityScope + }, + example: %{ + "visibility" => "private", + "sensitive" => "false" + } + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..fe36f0189 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -164,10 +164,10 @@ defmodule Pleroma.Web.Router do post("/relay", AdminAPIController, :relay_follow) delete("/relay", AdminAPIController, :relay_unfollow) - post("/users/invite_token", AdminAPIController, :create_invite_token) - get("/users/invites", AdminAPIController, :invites) - post("/users/revoke_invite", AdminAPIController, :revoke_invite) - post("/users/email_invite", AdminAPIController, :email_invite) + post("/users/invite_token", InviteTokenController, :create) + get("/users/invites", InviteTokenController, :index) + post("/users/revoke_invite", InviteTokenController, :revoke) + post("/users/email_invite", InviteTokenController, :email) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..f7e163f57 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -20,7 +20,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.ReportNote alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.UserInviteToken alias Pleroma.Web alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI @@ -588,122 +587,6 @@ test "/:right DELETE, can remove from a permission group (multiple)", %{ end end - describe "POST /api/pleroma/admin/email_invite, with valid config" do - setup do: clear_config([:instance, :registrations_open], false) - setup do: clear_config([:instance, :invites_enabled], true) - - test "sends invitation and returns 204", %{admin: admin, conn: conn} do - recipient_email = "foo@bar.com" - recipient_name = "J. D." - - conn = - post( - conn, - "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" - ) - - assert json_response(conn, :no_content) - - token_record = List.last(Repo.all(Pleroma.UserInviteToken)) - 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, - recipient_name - ) - - Swoosh.TestAssertions.assert_email_sent( - from: {instance_name, notify_email}, - to: {recipient_name, recipient_email}, - html_body: email.html_body - ) - end - - test "it returns 403 if requested by a non-admin" do - non_admin_user = insert(:user) - token = insert(:oauth_token, user: non_admin_user) - - conn = - build_conn() - |> assign(:user, non_admin_user) - |> assign(:token, token) - |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - 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 - setup do: clear_config([:instance, :registrations_open]) - setup do: clear_config([:instance, :invites_enabled]) - - test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do - Config.put([:instance, :registrations_open], false) - Config.put([:instance, :invites_enabled], false) - - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - assert json_response(conn, :bad_request) == - %{ - "error" => - "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 - Config.put([:instance, :registrations_open], true) - Config.put([:instance, :invites_enabled], true) - - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") - - assert json_response(conn, :bad_request) == - %{ - "error" => - "To send invites you need to set the `registrations_open` option to false." - } - end - end - test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do user = insert(:user) @@ -1318,112 +1201,6 @@ test "returns 404 if user not found", %{conn: conn} do end end - describe "POST /api/pleroma/admin/users/invite_token" do - test "without options", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token") - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - refute invite.max_use - assert invite.invite_type == "one_time" - end - - test "with expires_at", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - - refute invite.used - assert invite.expires_at == Date.utc_today() - refute invite.max_use - assert invite.invite_type == "date_limited" - end - - test "with max_use", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - assert invite.max_use == 150 - assert invite.invite_type == "reusable" - end - - test "with max use and expires_at", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ - "max_use" => 150, - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - assert invite.expires_at == Date.utc_today() - assert invite.max_use == 150 - assert invite.invite_type == "reusable_date_limited" - end - end - - describe "GET /api/pleroma/admin/users/invites" do - test "no invites", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response(conn, 200) == %{"invites" => []} - end - - test "with invite", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response(conn, 200) == %{ - "invites" => [ - %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => false, - "uses" => 0 - } - ] - } - end - end - - describe "POST /api/pleroma/admin/users/revoke_invite" do - test "with token", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) - - assert json_response(conn, 200) == %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => true, - "uses" => 0 - } - end - - test "with invalid token", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - - assert json_response(conn, :not_found) == %{"error" => "Not found"} - end - end - describe "GET /api/pleroma/admin/reports/:id" do test "returns report by its id", %{conn: conn} do [reporter, target_user] = insert_pair(:user) diff --git a/test/web/admin_api/controllers/invite_token_controller_test.exs b/test/web/admin_api/controllers/invite_token_controller_test.exs new file mode 100644 index 000000000..eb57b4d44 --- /dev/null +++ b/test/web/admin_api/controllers/invite_token_controller_test.exs @@ -0,0 +1,247 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteTokenControllerTest do + use Pleroma.Web.ConnCase, async: true + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.UserInviteToken + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "POST /api/pleroma/admin/users/email_invite, with valid config" do + setup do: clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :invites_enabled], true) + + test "sends invitation and returns 204", %{admin: admin, conn: conn} do + recipient_email = "foo@bar.com" + recipient_name = "J. D." + + conn = + post( + conn, + "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" + ) + + assert json_response(conn, :no_content) + + token_record = List.last(Repo.all(Pleroma.UserInviteToken)) + 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, + recipient_name + ) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {recipient_name, recipient_email}, + html_body: email.html_body + ) + end + + test "it returns 403 if requested by a non-admin" do + non_admin_user = insert(:user) + token = insert(:oauth_token, user: non_admin_user) + + conn = + build_conn() + |> assign(:user, non_admin_user) + |> assign(:token, token) + |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + 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 + setup do: clear_config([:instance, :registrations_open]) + setup do: clear_config([:instance, :invites_enabled]) + + test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do + Config.put([:instance, :registrations_open], false) + Config.put([:instance, :invites_enabled], false) + + conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + assert json_response(conn, :bad_request) == + %{ + "error" => + "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 + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :invites_enabled], true) + + conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + + assert json_response(conn, :bad_request) == + %{ + "error" => + "To send invites you need to set the `registrations_open` option to false." + } + end + end + + describe "POST /api/pleroma/admin/users/invite_token" do + test "without options", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/invite_token") + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + refute invite.max_use + assert invite.invite_type == "one_time" + end + + test "with expires_at", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/users/invite_token", %{ + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + + refute invite.used + assert invite.expires_at == Date.utc_today() + refute invite.max_use + assert invite.invite_type == "date_limited" + end + + test "with max_use", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + assert invite.max_use == 150 + assert invite.invite_type == "reusable" + end + + test "with max use and expires_at", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/users/invite_token", %{ + "max_use" => 150, + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + assert invite.expires_at == Date.utc_today() + assert invite.max_use == 150 + assert invite.invite_type == "reusable_date_limited" + end + end + + describe "GET /api/pleroma/admin/users/invites" do + test "no invites", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response(conn, 200) == %{"invites" => []} + end + + test "with invite", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response(conn, 200) == %{ + "invites" => [ + %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => false, + "uses" => 0 + } + ] + } + end + end + + describe "POST /api/pleroma/admin/users/revoke_invite" do + test "with token", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) + + assert json_response(conn, 200) == %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => true, + "uses" => 0 + } + end + + test "with invalid token", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) + + assert json_response(conn, :not_found) == %{"error" => "Not found"} + end + end +end -- cgit v1.2.3 From 2a4f965191af6ec6ab953569898acff55bd1502b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 15:02:51 +0400 Subject: Add OpenAPI spec for AdminAPI.InviteTokenController --- .../controllers/invite_token_controller.ex | 25 +-- .../operations/admin/invite_token_operation.ex | 209 ++++++++++----------- .../controllers/invite_token_controller_test.exs | 84 ++++++--- 3 files changed, 163 insertions(+), 155 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex index a0291e9c3..a09966e5c 100644 --- a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.AdminAPI.InviteTokenController do require Logger + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :index) plug( @@ -23,6 +24,8 @@ defmodule Pleroma.Web.AdminAPI.InviteTokenController do action_fallback(Pleroma.Web.AdminAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteTokenOperation + @doc "Get list of created invites" def index(conn, _params) do invites = UserInviteToken.list_invites() @@ -33,26 +36,14 @@ def index(conn, _params) do end @doc "Create an account registration invite token" - def create(conn, params) do - opts = %{} - - opts = - if params["max_use"], - do: Map.put(opts, :max_use, params["max_use"]), - else: opts - - opts = - if params["expires_at"], - do: Map.put(opts, :expires_at, params["expires_at"]), - else: opts - - {:ok, invite} = UserInviteToken.create_invite(opts) + def create(%{body_params: params} = conn, _) do + {:ok, invite} = UserInviteToken.create_invite(params) json(conn, AccountView.render("invite.json", %{invite: invite})) end @doc "Revokes invite by token" - def revoke(conn, %{"token" => token}) do + def revoke(%{body_params: %{token: token}} = conn, _) do with {:ok, invite} <- UserInviteToken.find_by_token(token), {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do conn @@ -64,7 +55,7 @@ def revoke(conn, %{"token" => token}) do end @doc "Sends registration invite via email" - def email(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do + def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = conn, _) do with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, {:ok, invite_token} <- UserInviteToken.create_invite(), @@ -73,7 +64,7 @@ def email(%{assigns: %{user: user}} = conn, %{"email" => email} = params) do user, invite_token, email, - params["name"] + params[:name] ), {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do json_response(conn, :no_content, "") diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex index 09a7735d1..0f7403f26 100644 --- a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex @@ -5,14 +5,9 @@ defmodule Pleroma.Web.ApiSpec.Admin.InviteTokenOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.ApiError - alias Pleroma.Web.ApiSpec.Schemas.FlakeID - alias Pleroma.Web.ApiSpec.Schemas.Status - alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope import Pleroma.Web.ApiSpec.Helpers - import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0] def open_api_operation(action) do operation = String.to_existing_atom("#{action}_operation") @@ -21,144 +16,132 @@ def open_api_operation(action) do def index_operation do %Operation{ - tags: ["Admin", "Statuses"], - operationId: "AdminAPI.StatusController.index", - security: [%{"oAuth" => ["read:statuses"]}], - parameters: [ - Operation.parameter( - :godmode, - :query, - %Schema{type: :boolean, default: false}, - "Allows to see private statuses" - ), - Operation.parameter( - :local_only, - :query, - %Schema{type: :boolean, default: false}, - "Excludes remote statuses" - ), - Operation.parameter( - :with_reblogs, - :query, - %Schema{type: :boolean, default: false}, - "Allows to see reblogs" - ), - Operation.parameter( - :page, - :query, - %Schema{type: :integer, default: 1}, - "Page" - ), - Operation.parameter( - :page_size, - :query, - %Schema{type: :integer, default: 50}, - "Number of statuses to return" - ) - ], + tags: ["Admin", "Invites"], + summary: "Get a list of generated invites", + operationId: "AdminAPI.InviteTokenController.index", + security: [%{"oAuth" => ["read:invites"]}], responses: %{ 200 => - Operation.response("Array of statuses", "application/json", %Schema{ - type: :array, - items: status() + Operation.response("Intites", "application/json", %Schema{ + type: :object, + properties: %{ + invites: %Schema{type: :array, items: invite()} + }, + example: %{ + "invites" => [ + %{ + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" + } + ] + } }) } } end - def show_operation do + def create_operation do %Operation{ - tags: ["Admin", "Statuses"], - summary: "Show Status", - operationId: "AdminAPI.StatusController.show", - parameters: [id_param()], - security: [%{"oAuth" => ["read:statuses"]}], + tags: ["Admin", "Invites"], + summary: "Create an account registration invite token", + operationId: "AdminAPI.InviteTokenController.create", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + max_use: %Schema{type: :integer}, + expires_at: %Schema{type: :string, format: :date, example: "2020-04-20"} + } + }), responses: %{ - 200 => Operation.response("Status", "application/json", Status), - 404 => Operation.response("Not Found", "application/json", ApiError) + 200 => Operation.response("Invite", "application/json", invite()) } } end - def update_operation do + def revoke_operation do %Operation{ - tags: ["Admin", "Statuses"], - summary: "Change the scope of an individual reported status", - operationId: "AdminAPI.StatusController.update", - parameters: [id_param()], - security: [%{"oAuth" => ["write:statuses"]}], - requestBody: request_body("Parameters", update_request(), required: true), + tags: ["Admin", "Invites"], + summary: "Revoke invite by token", + operationId: "AdminAPI.InviteTokenController.revoke", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:token], + properties: %{ + token: %Schema{type: :string} + } + }, + required: true + ), responses: %{ - 200 => Operation.response("Status", "application/json", Status), - 400 => Operation.response("Error", "application/json", ApiError) + 200 => Operation.response("Invite", "application/json", invite()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) } } end - def delete_operation do + def email_operation do %Operation{ - tags: ["Admin", "Statuses"], - summary: "Delete an individual reported status", - operationId: "AdminAPI.StatusController.delete", - parameters: [id_param()], - security: [%{"oAuth" => ["write:statuses"]}], + tags: ["Admin", "Invites"], + summary: "Sends registration invite via email", + operationId: "AdminAPI.InviteTokenController.email", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:email], + properties: %{ + email: %Schema{type: :string, format: :email}, + name: %Schema{type: :string} + } + }, + required: true + ), responses: %{ - 200 => empty_object_response(), - 404 => Operation.response("Not Found", "application/json", ApiError) + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) } } end - defp status do - %Schema{ - anyOf: [ - Status, - %Schema{ - type: :object, - properties: %{ - account: %Schema{allOf: [Account, admin_account()]} - } - } - ] - } - end - - defp admin_account do + defp invite do %Schema{ + title: "Invite", type: :object, properties: %{ - id: FlakeID, - avatar: %Schema{type: :string}, - nickname: %Schema{type: :string}, - display_name: %Schema{type: :string}, - deactivated: %Schema{type: :boolean}, - local: %Schema{type: :boolean}, - roles: %Schema{ - type: :object, - properties: %{ - admin: %Schema{type: :boolean}, - moderator: %Schema{type: :boolean} - } - }, - tags: %Schema{type: :string}, - confirmation_pending: %Schema{type: :string} - } - } - end - - defp update_request do - %Schema{ - type: :object, - properties: %{ - sensitive: %Schema{ - type: :boolean, - description: "Mark status and attached media as sensitive?" - }, - visibility: VisibilityScope + id: %Schema{type: :integer}, + token: %Schema{type: :string}, + used: %Schema{type: :boolean}, + expires_at: %Schema{type: :string, format: :date, nullable: true}, + uses: %Schema{type: :integer}, + max_use: %Schema{type: :integer, nullable: true}, + invite_type: %Schema{ + type: :string, + enum: ["one_time", "reusable", "date_limited", "reusable_date_limited"] + } }, example: %{ - "visibility" => "private", - "sensitive" => "false" + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" } } end diff --git a/test/web/admin_api/controllers/invite_token_controller_test.exs b/test/web/admin_api/controllers/invite_token_controller_test.exs index eb57b4d44..cb486f4d1 100644 --- a/test/web/admin_api/controllers/invite_token_controller_test.exs +++ b/test/web/admin_api/controllers/invite_token_controller_test.exs @@ -32,12 +32,14 @@ test "sends invitation and returns 204", %{admin: admin, conn: conn} do recipient_name = "J. D." conn = - post( - conn, - "/api/pleroma/admin/users/email_invite?email=#{recipient_email}&name=#{recipient_name}" - ) + conn + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: recipient_email, + name: recipient_name + }) - assert json_response(conn, :no_content) + assert json_response_and_validate_schema(conn, :no_content) token_record = List.last(Repo.all(Pleroma.UserInviteToken)) assert token_record @@ -69,7 +71,11 @@ test "it returns 403 if requested by a non-admin" do build_conn() |> assign(:user, non_admin_user) |> assign(:token, token) - |> post("/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) assert json_response(conn, :forbidden) end @@ -80,7 +86,7 @@ test "email with +", %{conn: conn, admin: admin} do 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) + |> json_response_and_validate_schema(:no_content) token_record = Pleroma.UserInviteToken @@ -116,9 +122,15 @@ test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do Config.put([:instance, :registrations_open], false) Config.put([:instance, :invites_enabled], false) - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) - assert json_response(conn, :bad_request) == + assert json_response_and_validate_schema(conn, :bad_request) == %{ "error" => "To send invites you need to set the `invites_enabled` option to true." @@ -129,9 +141,15 @@ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do Config.put([:instance, :registrations_open], true) Config.put([:instance, :invites_enabled], true) - conn = post(conn, "/api/pleroma/admin/users/email_invite?email=foo@bar.com&name=JD") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) - assert json_response(conn, :bad_request) == + assert json_response_and_validate_schema(conn, :bad_request) == %{ "error" => "To send invites you need to set the `registrations_open` option to false." @@ -141,9 +159,12 @@ test "it returns 500 if `registrations_open` is enabled", %{conn: conn} do describe "POST /api/pleroma/admin/users/invite_token" do test "without options", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token") + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token") - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used refute invite.expires_at @@ -153,11 +174,13 @@ test "without options", %{conn: conn} do test "with expires_at", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{ "expires_at" => Date.to_string(Date.utc_today()) }) - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used @@ -167,9 +190,12 @@ test "with expires_at", %{conn: conn} do end test "with max_use", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used refute invite.expires_at @@ -179,12 +205,14 @@ test "with max_use", %{conn: conn} do test "with max use and expires_at", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/users/invite_token", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{ "max_use" => 150, "expires_at" => Date.to_string(Date.utc_today()) }) - invite_json = json_response(conn, 200) + invite_json = json_response_and_validate_schema(conn, 200) invite = UserInviteToken.find_by_token!(invite_json["token"]) refute invite.used assert invite.expires_at == Date.utc_today() @@ -197,7 +225,7 @@ test "with max use and expires_at", %{conn: conn} do test "no invites", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/invites") - assert json_response(conn, 200) == %{"invites" => []} + assert json_response_and_validate_schema(conn, 200) == %{"invites" => []} end test "with invite", %{conn: conn} do @@ -205,7 +233,7 @@ test "with invite", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/invites") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "invites" => [ %{ "expires_at" => nil, @@ -225,9 +253,12 @@ test "with invite", %{conn: conn} do test "with token", %{conn: conn} do {:ok, invite} = UserInviteToken.create_invite() - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "expires_at" => nil, "id" => invite.id, "invite_type" => "one_time", @@ -239,9 +270,12 @@ test "with token", %{conn: conn} do end test "with invalid token", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - assert json_response(conn, :not_found) == %{"error" => "Not found"} + assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} end end end -- cgit v1.2.3 From fca48154a23c0b38d514b2bc4d49a74274e02a8f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 15:21:33 +0400 Subject: Add AdminAPI.InviteView --- .../web/admin_api/controllers/invite_controller.ex | 78 ++++++ .../controllers/invite_token_controller.ex | 79 ------ lib/pleroma/web/admin_api/views/account_view.ex | 18 -- lib/pleroma/web/admin_api/views/invite_view.ex | 25 ++ .../api_spec/operations/admin/invite_operation.ex | 148 +++++++++++ .../operations/admin/invite_token_operation.ex | 148 ----------- lib/pleroma/web/router.ex | 8 +- .../controllers/invite_controller_test.exs | 281 +++++++++++++++++++++ .../controllers/invite_token_controller_test.exs | 281 --------------------- 9 files changed, 536 insertions(+), 530 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/invite_controller.ex delete mode 100644 lib/pleroma/web/admin_api/controllers/invite_token_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/invite_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/invite_operation.ex delete mode 100644 lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex create mode 100644 test/web/admin_api/controllers/invite_controller_test.exs delete mode 100644 test/web/admin_api/controllers/invite_token_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/invite_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_controller.ex new file mode 100644 index 000000000..7d169b8d2 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/invite_controller.ex @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Config + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.UserInviteToken + + require Logger + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :index) + + plug( + OAuthScopesPlug, + %{scopes: ["write:invites"], admin: true} when action in [:create, :revoke, :email] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteOperation + + @doc "Get list of created invites" + def index(conn, _params) do + invites = UserInviteToken.list_invites() + + render(conn, "index.json", invites: invites) + end + + @doc "Create an account registration invite token" + def create(%{body_params: params} = conn, _) do + {:ok, invite} = UserInviteToken.create_invite(params) + + render(conn, "show.json", invite: invite) + end + + @doc "Revokes invite by token" + def revoke(%{body_params: %{token: token}} = conn, _) do + with {:ok, invite} <- UserInviteToken.find_by_token(token), + {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do + render(conn, "show.json", invite: updated_invite) + else + nil -> {:error, :not_found} + error -> error + end + end + + @doc "Sends registration invite via email" + def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = conn, _) do + with {_, false} <- {:registrations_open, Config.get([:instance, :registrations_open])}, + {_, true} <- {:invites_enabled, Config.get([:instance, :invites_enabled])}, + {:ok, invite_token} <- UserInviteToken.create_invite(), + {:ok, _} <- + user + |> Pleroma.Emails.UserEmail.user_invitation_email( + invite_token, + email, + params[:name] + ) + |> Pleroma.Emails.Mailer.deliver() do + json_response(conn, :no_content, "") + else + {:registrations_open, _} -> + {:error, "To send invites you need to set the `registrations_open` option to false."} + + {:invites_enabled, _} -> + {:error, "To send invites you need to set the `invites_enabled` option to true."} + + {:error, error} -> + {:error, error} + end + end +end diff --git a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex b/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex deleted file mode 100644 index a09966e5c..000000000 --- a/lib/pleroma/web/admin_api/controllers/invite_token_controller.ex +++ /dev/null @@ -1,79 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.AdminAPI.InviteTokenController do - use Pleroma.Web, :controller - - import Pleroma.Web.ControllerHelper, only: [json_response: 3] - - alias Pleroma.Config - alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.UserInviteToken - alias Pleroma.Web.AdminAPI.AccountView - - require Logger - - plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(OAuthScopesPlug, %{scopes: ["read:invites"], admin: true} when action == :index) - - plug( - OAuthScopesPlug, - %{scopes: ["write:invites"], admin: true} when action in [:create, :revoke, :email] - ) - - action_fallback(Pleroma.Web.AdminAPI.FallbackController) - - defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.InviteTokenOperation - - @doc "Get list of created invites" - def index(conn, _params) do - invites = UserInviteToken.list_invites() - - conn - |> put_view(AccountView) - |> render("invites.json", %{invites: invites}) - end - - @doc "Create an account registration invite token" - def create(%{body_params: params} = conn, _) do - {:ok, invite} = UserInviteToken.create_invite(params) - - json(conn, AccountView.render("invite.json", %{invite: invite})) - end - - @doc "Revokes invite by token" - def revoke(%{body_params: %{token: token}} = conn, _) do - with {:ok, invite} <- UserInviteToken.find_by_token(token), - {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) do - conn - |> put_view(AccountView) - |> render("invite.json", %{invite: updated_invite}) - else - nil -> {:error, :not_found} - end - end - - @doc "Sends registration invite via email" - def email(%{assigns: %{user: user}, body_params: %{email: email} = params} = conn, _) do - 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( - user, - invite_token, - email, - params[:name] - ), - {:ok, _} <- Pleroma.Emails.Mailer.deliver(email) do - json_response(conn, :no_content, "") - else - {:registrations_open, _} -> - {:error, "To send invites you need to set the `registrations_open` option to false."} - - {:invites_enabled, _} -> - {:error, "To send invites you need to set the `invites_enabled` option to true."} - end - end -end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 46dadb5ee..120159527 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -80,24 +80,6 @@ def render("show.json", %{user: user}) do } end - def render("invite.json", %{invite: invite}) do - %{ - "id" => invite.id, - "token" => invite.token, - "used" => invite.used, - "expires_at" => invite.expires_at, - "uses" => invite.uses, - "max_use" => invite.max_use, - "invite_type" => invite.invite_type - } - end - - def render("invites.json", %{invites: invites}) do - %{ - invites: render_many(invites, AccountView, "invite.json", as: :invite) - } - end - def render("created.json", %{user: user}) do %{ type: "success", diff --git a/lib/pleroma/web/admin_api/views/invite_view.ex b/lib/pleroma/web/admin_api/views/invite_view.ex new file mode 100644 index 000000000..f93cb6916 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/invite_view.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.AdminAPI.InviteView do + use Pleroma.Web, :view + + def render("index.json", %{invites: invites}) do + %{ + invites: render_many(invites, __MODULE__, "show.json", as: :invite) + } + end + + def render("show.json", %{invite: invite}) do + %{ + "id" => invite.id, + "token" => invite.token, + "used" => invite.used, + "expires_at" => invite.expires_at, + "uses" => invite.uses, + "max_use" => invite.max_use, + "invite_type" => invite.invite_type + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex new file mode 100644 index 000000000..4ae44fff6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex @@ -0,0 +1,148 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.InviteOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Invites"], + summary: "Get a list of generated invites", + operationId: "AdminAPI.InviteController.index", + security: [%{"oAuth" => ["read:invites"]}], + responses: %{ + 200 => + Operation.response("Intites", "application/json", %Schema{ + type: :object, + properties: %{ + invites: %Schema{type: :array, items: invite()} + }, + example: %{ + "invites" => [ + %{ + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" + } + ] + } + }) + } + } + end + + def create_operation do + %Operation{ + tags: ["Admin", "Invites"], + summary: "Create an account registration invite token", + operationId: "AdminAPI.InviteController.create", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + max_use: %Schema{type: :integer}, + expires_at: %Schema{type: :string, format: :date, example: "2020-04-20"} + } + }), + responses: %{ + 200 => Operation.response("Invite", "application/json", invite()) + } + } + end + + def revoke_operation do + %Operation{ + tags: ["Admin", "Invites"], + summary: "Revoke invite by token", + operationId: "AdminAPI.InviteController.revoke", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:token], + properties: %{ + token: %Schema{type: :string} + } + }, + required: true + ), + responses: %{ + 200 => Operation.response("Invite", "application/json", invite()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def email_operation do + %Operation{ + tags: ["Admin", "Invites"], + summary: "Sends registration invite via email", + operationId: "AdminAPI.InviteController.email", + security: [%{"oAuth" => ["write:invites"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:email], + properties: %{ + email: %Schema{type: :string, format: :email}, + name: %Schema{type: :string} + } + }, + required: true + ), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp invite do + %Schema{ + title: "Invite", + type: :object, + properties: %{ + id: %Schema{type: :integer}, + token: %Schema{type: :string}, + used: %Schema{type: :boolean}, + expires_at: %Schema{type: :string, format: :date, nullable: true}, + uses: %Schema{type: :integer}, + max_use: %Schema{type: :integer, nullable: true}, + invite_type: %Schema{ + type: :string, + enum: ["one_time", "reusable", "date_limited", "reusable_date_limited"] + } + }, + example: %{ + "id" => 123, + "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", + "used" => true, + "expires_at" => nil, + "uses" => 0, + "max_use" => nil, + "invite_type" => "one_time" + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex deleted file mode 100644 index 0f7403f26..000000000 --- a/lib/pleroma/web/api_spec/operations/admin/invite_token_operation.ex +++ /dev/null @@ -1,148 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ApiSpec.Admin.InviteTokenOperation do - alias OpenApiSpex.Operation - alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.Schemas.ApiError - - import Pleroma.Web.ApiSpec.Helpers - - def open_api_operation(action) do - operation = String.to_existing_atom("#{action}_operation") - apply(__MODULE__, operation, []) - end - - def index_operation do - %Operation{ - tags: ["Admin", "Invites"], - summary: "Get a list of generated invites", - operationId: "AdminAPI.InviteTokenController.index", - security: [%{"oAuth" => ["read:invites"]}], - responses: %{ - 200 => - Operation.response("Intites", "application/json", %Schema{ - type: :object, - properties: %{ - invites: %Schema{type: :array, items: invite()} - }, - example: %{ - "invites" => [ - %{ - "id" => 123, - "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", - "used" => true, - "expires_at" => nil, - "uses" => 0, - "max_use" => nil, - "invite_type" => "one_time" - } - ] - } - }) - } - } - end - - def create_operation do - %Operation{ - tags: ["Admin", "Invites"], - summary: "Create an account registration invite token", - operationId: "AdminAPI.InviteTokenController.create", - security: [%{"oAuth" => ["write:invites"]}], - requestBody: - request_body("Parameters", %Schema{ - type: :object, - properties: %{ - max_use: %Schema{type: :integer}, - expires_at: %Schema{type: :string, format: :date, example: "2020-04-20"} - } - }), - responses: %{ - 200 => Operation.response("Invite", "application/json", invite()) - } - } - end - - def revoke_operation do - %Operation{ - tags: ["Admin", "Invites"], - summary: "Revoke invite by token", - operationId: "AdminAPI.InviteTokenController.revoke", - security: [%{"oAuth" => ["write:invites"]}], - requestBody: - request_body( - "Parameters", - %Schema{ - type: :object, - required: [:token], - properties: %{ - token: %Schema{type: :string} - } - }, - required: true - ), - responses: %{ - 200 => Operation.response("Invite", "application/json", invite()), - 400 => Operation.response("Bad Request", "application/json", ApiError), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - - def email_operation do - %Operation{ - tags: ["Admin", "Invites"], - summary: "Sends registration invite via email", - operationId: "AdminAPI.InviteTokenController.email", - security: [%{"oAuth" => ["write:invites"]}], - requestBody: - request_body( - "Parameters", - %Schema{ - type: :object, - required: [:email], - properties: %{ - email: %Schema{type: :string, format: :email}, - name: %Schema{type: :string} - } - }, - required: true - ), - responses: %{ - 204 => no_content_response(), - 400 => Operation.response("Bad Request", "application/json", ApiError), - 403 => Operation.response("Forbidden", "application/json", ApiError) - } - } - end - - defp invite do - %Schema{ - title: "Invite", - type: :object, - properties: %{ - id: %Schema{type: :integer}, - token: %Schema{type: :string}, - used: %Schema{type: :boolean}, - expires_at: %Schema{type: :string, format: :date, nullable: true}, - uses: %Schema{type: :integer}, - max_use: %Schema{type: :integer, nullable: true}, - invite_type: %Schema{ - type: :string, - enum: ["one_time", "reusable", "date_limited", "reusable_date_limited"] - } - }, - example: %{ - "id" => 123, - "token" => "kSQtDj_GNy2NZsL9AQDFIsHN5qdbguB6qRg3WHw6K1U=", - "used" => true, - "expires_at" => nil, - "uses" => 0, - "max_use" => nil, - "invite_type" => "one_time" - } - } - end -end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index fe36f0189..9b7c7ee3d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -164,10 +164,10 @@ defmodule Pleroma.Web.Router do post("/relay", AdminAPIController, :relay_follow) delete("/relay", AdminAPIController, :relay_unfollow) - post("/users/invite_token", InviteTokenController, :create) - get("/users/invites", InviteTokenController, :index) - post("/users/revoke_invite", InviteTokenController, :revoke) - post("/users/email_invite", InviteTokenController, :email) + post("/users/invite_token", InviteController, :create) + get("/users/invites", InviteController, :index) + post("/users/revoke_invite", InviteController, :revoke) + post("/users/email_invite", InviteController, :email) get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) patch("/users/force_password_reset", AdminAPIController, :force_password_reset) diff --git a/test/web/admin_api/controllers/invite_controller_test.exs b/test/web/admin_api/controllers/invite_controller_test.exs new file mode 100644 index 000000000..ab186c5e7 --- /dev/null +++ b/test/web/admin_api/controllers/invite_controller_test.exs @@ -0,0 +1,281 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.InviteControllerTest do + use Pleroma.Web.ConnCase, async: true + + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.UserInviteToken + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "POST /api/pleroma/admin/users/email_invite, with valid config" do + setup do: clear_config([:instance, :registrations_open], false) + setup do: clear_config([:instance, :invites_enabled], true) + + test "sends invitation and returns 204", %{admin: admin, conn: conn} do + recipient_email = "foo@bar.com" + recipient_name = "J. D." + + conn = + conn + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: recipient_email, + name: recipient_name + }) + + assert json_response_and_validate_schema(conn, :no_content) + + token_record = List.last(Repo.all(Pleroma.UserInviteToken)) + 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, + recipient_name + ) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {recipient_name, recipient_email}, + html_body: email.html_body + ) + end + + test "it returns 403 if requested by a non-admin" do + non_admin_user = insert(:user) + token = insert(:oauth_token, user: non_admin_user) + + conn = + build_conn() + |> assign(:user, non_admin_user) + |> assign(:token, token) + |> put_req_header("content-type", "application/json;charset=utf-8") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) + + 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_and_validate_schema(: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 + setup do: clear_config([:instance, :registrations_open]) + setup do: clear_config([:instance, :invites_enabled]) + + test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do + Config.put([:instance, :registrations_open], false) + Config.put([:instance, :invites_enabled], false) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) + + assert json_response_and_validate_schema(conn, :bad_request) == + %{ + "error" => + "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 + Config.put([:instance, :registrations_open], true) + Config.put([:instance, :invites_enabled], true) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/email_invite", %{ + email: "foo@bar.com", + name: "JD" + }) + + assert json_response_and_validate_schema(conn, :bad_request) == + %{ + "error" => + "To send invites you need to set the `registrations_open` option to false." + } + end + end + + describe "POST /api/pleroma/admin/users/invite_token" do + test "without options", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token") + + invite_json = json_response_and_validate_schema(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + refute invite.max_use + assert invite.invite_type == "one_time" + end + + test "with expires_at", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{ + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response_and_validate_schema(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + + refute invite.used + assert invite.expires_at == Date.utc_today() + refute invite.max_use + assert invite.invite_type == "date_limited" + end + + test "with max_use", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) + + invite_json = json_response_and_validate_schema(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + refute invite.expires_at + assert invite.max_use == 150 + assert invite.invite_type == "reusable" + end + + test "with max use and expires_at", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/invite_token", %{ + "max_use" => 150, + "expires_at" => Date.to_string(Date.utc_today()) + }) + + invite_json = json_response_and_validate_schema(conn, 200) + invite = UserInviteToken.find_by_token!(invite_json["token"]) + refute invite.used + assert invite.expires_at == Date.utc_today() + assert invite.max_use == 150 + assert invite.invite_type == "reusable_date_limited" + end + end + + describe "GET /api/pleroma/admin/users/invites" do + test "no invites", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response_and_validate_schema(conn, 200) == %{"invites" => []} + end + + test "with invite", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = get(conn, "/api/pleroma/admin/users/invites") + + assert json_response_and_validate_schema(conn, 200) == %{ + "invites" => [ + %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => false, + "uses" => 0 + } + ] + } + end + end + + describe "POST /api/pleroma/admin/users/revoke_invite" do + test "with token", %{conn: conn} do + {:ok, invite} = UserInviteToken.create_invite() + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) + + assert json_response_and_validate_schema(conn, 200) == %{ + "expires_at" => nil, + "id" => invite.id, + "invite_type" => "one_time", + "max_use" => nil, + "token" => invite.token, + "used" => true, + "uses" => 0 + } + end + + test "with invalid token", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) + + assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} + end + end +end diff --git a/test/web/admin_api/controllers/invite_token_controller_test.exs b/test/web/admin_api/controllers/invite_token_controller_test.exs deleted file mode 100644 index cb486f4d1..000000000 --- a/test/web/admin_api/controllers/invite_token_controller_test.exs +++ /dev/null @@ -1,281 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.AdminAPI.InviteTokenControllerTest do - use Pleroma.Web.ConnCase, async: true - - import Pleroma.Factory - - alias Pleroma.Config - alias Pleroma.Repo - alias Pleroma.UserInviteToken - - setup do - admin = insert(:user, is_admin: true) - token = insert(:oauth_admin_token, user: admin) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - - {:ok, %{admin: admin, token: token, conn: conn}} - end - - describe "POST /api/pleroma/admin/users/email_invite, with valid config" do - setup do: clear_config([:instance, :registrations_open], false) - setup do: clear_config([:instance, :invites_enabled], true) - - test "sends invitation and returns 204", %{admin: admin, conn: conn} do - recipient_email = "foo@bar.com" - recipient_name = "J. D." - - conn = - conn - |> put_req_header("content-type", "application/json;charset=utf-8") - |> post("/api/pleroma/admin/users/email_invite", %{ - email: recipient_email, - name: recipient_name - }) - - assert json_response_and_validate_schema(conn, :no_content) - - token_record = List.last(Repo.all(Pleroma.UserInviteToken)) - 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, - recipient_name - ) - - Swoosh.TestAssertions.assert_email_sent( - from: {instance_name, notify_email}, - to: {recipient_name, recipient_email}, - html_body: email.html_body - ) - end - - test "it returns 403 if requested by a non-admin" do - non_admin_user = insert(:user) - token = insert(:oauth_token, user: non_admin_user) - - conn = - build_conn() - |> assign(:user, non_admin_user) - |> assign(:token, token) - |> put_req_header("content-type", "application/json;charset=utf-8") - |> post("/api/pleroma/admin/users/email_invite", %{ - email: "foo@bar.com", - name: "JD" - }) - - 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_and_validate_schema(: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 - setup do: clear_config([:instance, :registrations_open]) - setup do: clear_config([:instance, :invites_enabled]) - - test "it returns 500 if `invites_enabled` is not enabled", %{conn: conn} do - Config.put([:instance, :registrations_open], false) - Config.put([:instance, :invites_enabled], false) - - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/email_invite", %{ - email: "foo@bar.com", - name: "JD" - }) - - assert json_response_and_validate_schema(conn, :bad_request) == - %{ - "error" => - "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 - Config.put([:instance, :registrations_open], true) - Config.put([:instance, :invites_enabled], true) - - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/email_invite", %{ - email: "foo@bar.com", - name: "JD" - }) - - assert json_response_and_validate_schema(conn, :bad_request) == - %{ - "error" => - "To send invites you need to set the `registrations_open` option to false." - } - end - end - - describe "POST /api/pleroma/admin/users/invite_token" do - test "without options", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/invite_token") - - invite_json = json_response_and_validate_schema(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - refute invite.max_use - assert invite.invite_type == "one_time" - end - - test "with expires_at", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/invite_token", %{ - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response_and_validate_schema(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - - refute invite.used - assert invite.expires_at == Date.utc_today() - refute invite.max_use - assert invite.invite_type == "date_limited" - end - - test "with max_use", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/invite_token", %{"max_use" => 150}) - - invite_json = json_response_and_validate_schema(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - refute invite.expires_at - assert invite.max_use == 150 - assert invite.invite_type == "reusable" - end - - test "with max use and expires_at", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/invite_token", %{ - "max_use" => 150, - "expires_at" => Date.to_string(Date.utc_today()) - }) - - invite_json = json_response_and_validate_schema(conn, 200) - invite = UserInviteToken.find_by_token!(invite_json["token"]) - refute invite.used - assert invite.expires_at == Date.utc_today() - assert invite.max_use == 150 - assert invite.invite_type == "reusable_date_limited" - end - end - - describe "GET /api/pleroma/admin/users/invites" do - test "no invites", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response_and_validate_schema(conn, 200) == %{"invites" => []} - end - - test "with invite", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = get(conn, "/api/pleroma/admin/users/invites") - - assert json_response_and_validate_schema(conn, 200) == %{ - "invites" => [ - %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => false, - "uses" => 0 - } - ] - } - end - end - - describe "POST /api/pleroma/admin/users/revoke_invite" do - test "with token", %{conn: conn} do - {:ok, invite} = UserInviteToken.create_invite() - - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => invite.token}) - - assert json_response_and_validate_schema(conn, 200) == %{ - "expires_at" => nil, - "id" => invite.id, - "invite_type" => "one_time", - "max_use" => nil, - "token" => invite.token, - "used" => true, - "uses" => 0 - } - end - - test "with invalid token", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/users/revoke_invite", %{"token" => "foo"}) - - assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} - end - end -end -- cgit v1.2.3 From 51bc6674f6a9b6794ba981052a1e432915beaef7 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 26 May 2020 13:45:54 +0200 Subject: Mastodon API Controllers: Use the correct params for rate limiting. --- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/status_controller.ex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 75512442d..47649d41d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -81,7 +81,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( RateLimiter, - [name: :relation_id_action, params: ["id", "uri"]] when action in @relationship_actions + [name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions ) plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 83d997abd..f20157a5f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -84,13 +84,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do plug( RateLimiter, - [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]] + [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: [:id]] when action in ~w(reblog unreblog)a ) plug( RateLimiter, - [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]] + [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: [:id]] when action in ~w(favourite unfavourite)a ) -- cgit v1.2.3 From 2069ec5006b9142b784dc6ab8b190838481dfe5b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 26 May 2020 16:11:42 +0400 Subject: Fix Oban warnings --- lib/pleroma/workers/cron/clear_oauth_token_worker.ex | 2 ++ lib/pleroma/workers/cron/digest_emails_worker.ex | 2 ++ lib/pleroma/workers/cron/new_users_digest_worker.ex | 4 ++++ lib/pleroma/workers/cron/purge_expired_activities_worker.ex | 2 ++ 4 files changed, 10 insertions(+) diff --git a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex index 341eff054..a4c3b9516 100644 --- a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex +++ b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Workers.Cron.ClearOauthTokenWorker do def perform(_opts, _job) do if Config.get([:oauth2, :clean_expired_tokens], false) do Token.delete_expired_tokens() + else + :ok end end end diff --git a/lib/pleroma/workers/cron/digest_emails_worker.ex b/lib/pleroma/workers/cron/digest_emails_worker.ex index dd13c3b17..7f09ff3cf 100644 --- a/lib/pleroma/workers/cron/digest_emails_worker.ex +++ b/lib/pleroma/workers/cron/digest_emails_worker.ex @@ -37,6 +37,8 @@ def perform(_opts, _job) do ) |> Repo.all() |> send_emails + else + :ok end end diff --git a/lib/pleroma/workers/cron/new_users_digest_worker.ex b/lib/pleroma/workers/cron/new_users_digest_worker.ex index 9bd0a5621..5c816b3fe 100644 --- a/lib/pleroma/workers/cron/new_users_digest_worker.ex +++ b/lib/pleroma/workers/cron/new_users_digest_worker.ex @@ -55,7 +55,11 @@ def perform(_args, _job) do |> Repo.all() |> Enum.map(&Pleroma.Emails.NewUsersDigestEmail.new_users(&1, users_and_statuses)) |> Enum.each(&Pleroma.Emails.Mailer.deliver/1) + else + :ok end + else + :ok end end end diff --git a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex index b8953dd7f..84b3b84de 100644 --- a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex +++ b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex @@ -23,6 +23,8 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker do def perform(_opts, _job) do if Config.get([ActivityExpiration, :enabled]) do Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) + else + :ok end end -- cgit v1.2.3 From 337ca33e5e4a84885eee3abd2de529663c27f1f1 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 26 May 2020 16:00:56 +0200 Subject: Config: Restore old new background image Became lost in a settings restructure --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 7385fb6c3..d15998715 100644 --- a/config/config.exs +++ b/config/config.exs @@ -274,7 +274,7 @@ config :pleroma, :frontend_configurations, pleroma_fe: %{ alwaysShowSubjectInput: true, - background: "/static/aurora_borealis.jpg", + background: "/images/city.jpg", collapseMessageWithSubject: false, disableChat: false, greentext: false, -- cgit v1.2.3 From acba7043be4256976b4026e1b331c38842ec0e86 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 26 May 2020 16:46:57 +0200 Subject: Migrations: Add index on client_id and client_secret for apps. Greatly speeds up app lookup. --- priv/repo/migrations/20200526144426_add_apps_indexes.exs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 priv/repo/migrations/20200526144426_add_apps_indexes.exs diff --git a/priv/repo/migrations/20200526144426_add_apps_indexes.exs b/priv/repo/migrations/20200526144426_add_apps_indexes.exs new file mode 100644 index 000000000..5cb6a0473 --- /dev/null +++ b/priv/repo/migrations/20200526144426_add_apps_indexes.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddAppsIndexes do + use Ecto.Migration + + def change do + create(index(:apps, [:client_id, :client_secret])) + end +end -- cgit v1.2.3 From d8d99fd4cf56b4e3adb17c75062a08ec3fdebb89 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 26 May 2020 17:46:16 +0200 Subject: Activity.Queries: Use correct actor restriction. --- lib/pleroma/activity/queries.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index a34c20343..c99aae44b 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -24,10 +24,7 @@ def by_ap_id(query \\ Activity, ap_id) do @spec by_actor(query, String.t()) :: query def by_actor(query \\ Activity, actor) do - from( - activity in query, - where: fragment("(?)->>'actor' = ?", activity.data, ^actor) - ) + from(a in query, where: a.actor == ^actor) end @spec by_author(query, User.t()) :: query -- cgit v1.2.3 From 3249141588c8f73f1958f782041798fbde05e69f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 27 May 2020 09:42:28 +0300 Subject: validate actor type --- docs/API/admin_api.md | 18 ++++++++++- lib/pleroma/user.ex | 5 ++-- .../admin_api/controllers/admin_api_controller.ex | 13 +++++--- .../controllers/admin_api_controller_test.exs | 35 +++++++++++++++++++--- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index c455047cc..639c3224d 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -511,7 +511,23 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - `discoverable` - `actor_type` -- Response: none (code `200`) +- Response: + +```json +{"status": "success"} +``` + +```json +{"errors": + {"actor_type": "is invalid"}, + {"email": "has invalid format"}, + ... + } +``` + +```json +{"error": "Unable to update user."} +``` ## `GET /api/pleroma/admin/reports` diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 842b28c06..2684e1139 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -538,9 +538,10 @@ def update_as_admin_changeset(struct, params) do |> delete_change(:also_known_as) |> unique_constraint(:email) |> validate_format(:email, @email_regex) + |> validate_inclusion(:actor_type, ["Person", "Service"]) end - @spec update_as_admin(%User{}, map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + @spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()} def update_as_admin(user, params) do params = Map.put(params, "password_confirmation", params["password"]) changeset = update_as_admin_changeset(user, params) @@ -561,7 +562,7 @@ def password_update_changeset(struct, params) do |> put_change(:password_reset_pending, false) end - @spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + @spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()} def reset_password(%User{} = user, params) do reset_password(user, user, params) end diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6b1d64a2e..6aedccec6 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -693,7 +693,7 @@ def update_user_credentials( %{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params ) do - with {_, user} <- {:user, User.get_cached_by_nickname(nickname)}, + with {_, %User{} = user} <- {:user, User.get_cached_by_nickname(nickname)}, {:ok, _user} <- User.update_as_admin(user, params) do ModerationLog.insert_log(%{ @@ -715,11 +715,16 @@ def update_user_credentials( json(conn, %{status: "success"}) else {:error, changeset} -> - {_, {error, _}} = Enum.at(changeset.errors, 0) - json(conn, %{error: "New password #{error}."}) + errors = + Enum.reduce(changeset.errors, %{}, fn + {key, {error, _}}, acc -> + Map.put(acc, key, error) + end) + + json(conn, %{errors: errors}) _ -> - json(conn, %{error: "Unable to change password."}) + json(conn, %{error: "Unable to update user."}) end end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 321840a8c..ead840186 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -3191,8 +3191,12 @@ test "returns 403 if requested by a non-admin" do end describe "PATCH /users/:nickname/credentials" do - test "changes password and email", %{conn: conn, admin: admin} do + setup do user = insert(:user) + [user: user] + end + + test "changes password and email", %{conn: conn, admin: admin, user: user} do assert user.password_reset_pending == false conn = @@ -3222,9 +3226,7 @@ test "changes password and email", %{conn: conn, admin: admin} do "@#{admin.nickname} forced password reset for users: @#{user.nickname}" end - test "returns 403 if requested by a non-admin" do - user = insert(:user) - + test "returns 403 if requested by a non-admin", %{user: user} do conn = build_conn() |> assign(:user, user) @@ -3236,6 +3238,31 @@ test "returns 403 if requested by a non-admin" do assert json_response(conn, :forbidden) end + + test "changes actor type from permitted list", %{conn: conn, user: user} do + assert user.actor_type == "Person" + + assert patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{ + "actor_type" => "Service" + }) + |> json_response(200) == %{"status" => "success"} + + updated_user = User.get_by_id(user.id) + + assert updated_user.actor_type == "Service" + + assert patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{ + "actor_type" => "Application" + }) + |> json_response(200) == %{"errors" => %{"actor_type" => "is invalid"}} + end + + test "update non existing user", %{conn: conn} do + assert patch(conn, "/api/pleroma/admin/users/non-existing/credentials", %{ + "password" => "new_password" + }) + |> json_response(200) == %{"error" => "Unable to update user."} + end end describe "PATCH /users/:nickname/force_password_reset" do -- cgit v1.2.3 From 7e13200869a41647f25bdcf416ecd36ff09219bc Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 27 May 2020 09:46:12 +0200 Subject: ActivityPub: Change ordering to `nulls last` in favorites query This makes it use our existing index and speeds up the query. --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0fe71694a..b8a2873d8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1158,7 +1158,7 @@ def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do |> Activity.with_joined_object() |> Object.with_joined_activity() |> select([_like, object, activity], %{activity | object: object}) - |> order_by([like, _, _], desc: like.id) + |> order_by([like, _, _], desc_nulls_last: like.id) |> Pagination.fetch_paginated( Map.merge(params, %{"skip_order" => true}), pagination, -- cgit v1.2.3 From b8e029b5ea33c9267ac26ab7ba598f1cd7be46c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 27 May 2020 12:41:06 +0200 Subject: Notification: Actually preload objects. --- lib/pleroma/notification.ex | 15 +++------------ test/notification_test.exs | 11 +++++++---- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 8aa9ed2d4..fb16ec896 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -92,8 +92,9 @@ def for_user_query(user, opts \\ %{}) do |> join(:left, [n, a], object in Object, on: fragment( - "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", + "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')", object.data, + a.data, a.data ) ) @@ -224,18 +225,8 @@ def set_read_up_to(%{id: user_id} = user, id) do |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() - Notification + for_user_query(user) |> where([n], n.id in ^notification_ids) - |> join(:inner, [n], activity in assoc(n, :activity)) - |> join(:left, [n, a], object in Object, - on: - fragment( - "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", - object.data, - a.data - ) - ) - |> preload([n, a, o], activity: {a, object: o}) |> Repo.all() end diff --git a/test/notification_test.exs b/test/notification_test.exs index 3a96721fa..37c255fee 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -454,8 +454,7 @@ test "it sets all notifications as read up to a specified notification ID" do status: "hey again @#{other_user.nickname}!" }) - [n2, n1] = notifs = Notification.for_user(other_user) - assert length(notifs) == 2 + [n2, n1] = Notification.for_user(other_user) assert n2.id > n1.id @@ -464,7 +463,9 @@ test "it sets all notifications as read up to a specified notification ID" do status: "hey yet again @#{other_user.nickname}!" }) - Notification.set_read_up_to(other_user, n2.id) + [_, read_notification] = Notification.set_read_up_to(other_user, n2.id) + + assert read_notification.activity.object [n3, n2, n1] = Notification.for_user(other_user) @@ -972,7 +973,9 @@ test "it returns notifications for muted user without notifications" do {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) - assert length(Notification.for_user(user)) == 1 + [notification] = Notification.for_user(user) + + assert notification.activity.object end test "it doesn't return notifications for muted user with notifications" do -- cgit v1.2.3 From c6290be682bd12b1772153d421f36e5ddb9d664b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 27 May 2020 14:42:21 +0400 Subject: Fix typo --- lib/pleroma/web/api_spec/operations/admin/invite_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex index 4ae44fff6..d3af9db49 100644 --- a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex @@ -22,7 +22,7 @@ def index_operation do security: [%{"oAuth" => ["read:invites"]}], responses: %{ 200 => - Operation.response("Intites", "application/json", %Schema{ + Operation.response("Invites", "application/json", %Schema{ type: :object, properties: %{ invites: %Schema{type: :array, items: invite()} -- cgit v1.2.3 From 047a11c48f2bc88b6b278b6a5acd94807c7e5138 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 27 May 2020 10:55:42 +0000 Subject: Apply suggestion to lib/pleroma/web/admin_api/controllers/admin_api_controller.ex --- lib/pleroma/web/admin_api/controllers/admin_api_controller.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 6aedccec6..783203c07 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -715,11 +715,7 @@ def update_user_credentials( json(conn, %{status: "success"}) else {:error, changeset} -> - errors = - Enum.reduce(changeset.errors, %{}, fn - {key, {error, _}}, acc -> - Map.put(acc, key, error) - end) + errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end) json(conn, %{errors: errors}) -- cgit v1.2.3 From 73f222d76a03e7bfad1aae80e0dc9d2777a94f3e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 27 May 2020 12:56:15 +0200 Subject: Migrations: Make user_id index on notifications better for query. --- .../migrations/20200527104138_change_notification_user_index.exs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 priv/repo/migrations/20200527104138_change_notification_user_index.exs diff --git a/priv/repo/migrations/20200527104138_change_notification_user_index.exs b/priv/repo/migrations/20200527104138_change_notification_user_index.exs new file mode 100644 index 000000000..4dcfe6de9 --- /dev/null +++ b/priv/repo/migrations/20200527104138_change_notification_user_index.exs @@ -0,0 +1,8 @@ +defmodule Pleroma.Repo.Migrations.ChangeNotificationUserIndex do + use Ecto.Migration + + def change do + drop_if_exists(index(:notifications, [:user_id])) + create_if_not_exists(index(:notifications, [:user_id, "id desc nulls last"])) + end +end -- cgit v1.2.3 From 48fd9be65ae2c25e170e494720a07c126e80e2f6 Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 26 May 2020 09:47:03 +0000 Subject: Exclude post actor from to of relay announce --- lib/pleroma/web/activity_pub/builder.ex | 16 +++++++++++----- test/web/activity_pub/relay_test.exs | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 7ece764f5..51b74414a 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -85,15 +86,20 @@ def like(actor, object) do end end + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} def announce(actor, object, options \\ []) do public? = Keyword.get(options, :public, false) - to = [actor.follower_address, object.data["actor"]] to = - if public? do - [Pleroma.Constants.as_public() | to] - else - to + cond do + actor.ap_id == Relay.relay_ap_id() -> + [actor.follower_address] + + public? -> + [actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()] + + true -> + [actor.follower_address, object.data["actor"]] end {:ok, diff --git a/test/web/activity_pub/relay_test.exs b/test/web/activity_pub/relay_test.exs index dbee8a0f4..b3b573c9b 100644 --- a/test/web/activity_pub/relay_test.exs +++ b/test/web/activity_pub/relay_test.exs @@ -108,6 +108,7 @@ test "returns error when object is unknown" do assert {:ok, %Activity{} = activity} = Relay.publish(note) assert activity.data["type"] == "Announce" assert activity.data["actor"] == service_actor.ap_id + assert activity.data["to"] == [service_actor.follower_address] assert called(Pleroma.Web.Federator.publish(activity)) end -- cgit v1.2.3 From 78c46fb7ba2aa9e9842d3c7d8331488fd10a3b9d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 27 May 2020 19:34:56 +0300 Subject: MediaProxy test: use config macros instead of directly putting values They were not properly cleaned later and caused trouble for another tests --- test/web/media_proxy/media_proxy_test.exs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs index 69c2d5dae..69d2a71a6 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -124,15 +124,7 @@ test "encoded url are tried to match for proxy as `conn.request_path` encodes th end test "uses the configured base_url" do - base_url = Pleroma.Config.get([:media_proxy, :base_url]) - - if base_url do - on_exit(fn -> - Pleroma.Config.put([:media_proxy, :base_url], base_url) - end) - end - - Pleroma.Config.put([:media_proxy, :base_url], "https://cache.pleroma.social") + clear_config([:media_proxy, :base_url], "https://cache.pleroma.social") url = "https://pleroma.soykaf.com/static/logo.png" encoded = url(url) @@ -213,8 +205,8 @@ test "mediaproxy whitelist" do end test "does not change whitelisted urls" do - Pleroma.Config.put([:media_proxy, :whitelist], ["mycdn.akamai.com"]) - Pleroma.Config.put([:media_proxy, :base_url], "https://cache.pleroma.social") + clear_config([:media_proxy, :whitelist], ["mycdn.akamai.com"]) + clear_config([:media_proxy, :base_url], "https://cache.pleroma.social") media_url = "https://mycdn.akamai.com" -- cgit v1.2.3 From 8f6d428880721d4b0151991e7943706b70ab8005 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 27 May 2020 19:35:35 +0300 Subject: AccountView: Use mediaproxy URLs for emojis Also use atom keys in emoji maps instead of binaries Closes #1810 --- lib/pleroma/web/mastodon_api/views/account_view.ex | 12 ++++---- test/web/mastodon_api/views/account_view_test.exs | 35 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 45fffaad2..04c419d2f 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -182,12 +182,14 @@ defp do_render("show.json", %{user: user} = opts) do bot = user.actor_type in ["Application", "Service"] emojis = - Enum.map(user.emoji, fn {shortcode, url} -> + Enum.map(user.emoji, fn {shortcode, raw_url} -> + url = MediaProxy.url(raw_url) + %{ - "shortcode" => shortcode, - "url" => url, - "static_url" => url, - "visible_in_picker" => false + shortcode: shortcode, + url: url, + static_url: url, + visible_in_picker: false } end) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 487ec26c2..f91333e5c 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -54,10 +54,10 @@ test "Represent a user account" do header_static: "http://localhost:4001/images/banner.png", emojis: [ %{ - "static_url" => "/file.png", - "url" => "/file.png", - "shortcode" => "karjalanpiirakka", - "visible_in_picker" => false + static_url: "/file.png", + url: "/file.png", + shortcode: "karjalanpiirakka", + visible_in_picker: false } ], fields: [], @@ -491,4 +491,31 @@ test "shows non-zero when historical unapproved requests are present" do AccountView.render("show.json", %{user: user, for: user}) end end + + test "uses mediaproxy urls when it's enabled" do + clear_config([:media_proxy, :enabled], true) + + user = + insert(:user, + avatar: %{"url" => [%{"href" => "https://evil.website/avatar.png"}]}, + banner: %{"url" => [%{"href" => "https://evil.website/banner.png"}]}, + emoji: %{"joker_smile" => "https://evil.website/society.png"} + ) + + AccountView.render("show.json", %{user: user}) + |> Enum.all?(fn + {key, url} when key in [:avatar, :avatar_static, :header, :header_static] -> + String.starts_with?(url, Pleroma.Web.base_url()) + + {:emojis, emojis} -> + Enum.all?(emojis, fn %{url: url, static_url: static_url} -> + String.starts_with?(url, Pleroma.Web.base_url()) && + String.starts_with?(static_url, Pleroma.Web.base_url()) + end) + + _ -> + true + end) + |> assert() + end end -- cgit v1.2.3 From 455a402c8a967b3a234c836b0574c4f011860d43 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 27 May 2020 20:27:30 +0300 Subject: HTTP Security plug: rewrite &csp_string/0 - Directives are now separated with ";" instead of " ;", according to https://www.w3.org/TR/CSP2/#policy-parsing the space is optional - Use an IO list, which at the end gets converted to a binary as opposed to ++ing a bunch of arrays with binaries together and joining them to a string. I doubt it gives any significant real world advantage, but the code is cleaner and now I can sleep at night. - The static part of csp is pre-joined to a single binary at compile time. Same reasoning as the last point. --- lib/pleroma/plugs/http_security_plug.ex | 52 +++++++++++++++++++-------------- test/plugs/http_security_plug_test.exs | 2 +- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 6462797b6..f9aff2fab 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -31,7 +31,7 @@ defp headers do {"x-content-type-options", "nosniff"}, {"referrer-policy", referrer_policy}, {"x-download-options", "noopen"}, - {"content-security-policy", csp_string() <> ";"} + {"content-security-policy", csp_string()} ] if report_uri do @@ -43,23 +43,35 @@ defp headers do ] } - headers ++ [{"reply-to", Jason.encode!(report_group)}] + [{"reply-to", Jason.encode!(report_group)} | headers] else headers end end + @csp_start [ + "default-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self'", + "manifest-src 'self'" + ] + |> Enum.join(";") + |> Kernel.<>(";") + |> List.wrap() + defp csp_string do scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme] static_url = Pleroma.Web.Endpoint.static_url() websocket_url = Pleroma.Web.Endpoint.websocket_url() report_uri = Config.get([:http_security, :report_uri]) - connect_src = "connect-src 'self' #{static_url} #{websocket_url}" + connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] connect_src = if Pleroma.Config.get(:env) == :dev do - connect_src <> " http://localhost:3035/" + [connect_src," http://localhost:3035/"] else connect_src end @@ -71,26 +83,22 @@ defp csp_string do "script-src 'self'" end - main_part = [ - "default-src 'none'", - "base-uri 'self'", - "frame-ancestors 'none'", - "img-src 'self' data: blob: https:", - "media-src 'self' https:", - "style-src 'self' 'unsafe-inline'", - "font-src 'self'", - "manifest-src 'self'", - connect_src, - script_src - ] - - report = if report_uri, do: ["report-uri #{report_uri}; report-to csp-endpoint"], else: [] + report = if report_uri, do: ["report-uri ", report_uri, ";report-to csp-endpoint"] + insecure = if scheme == "https", do: "upgrade-insecure-requests" + + @csp_start + |> add_csp_param("img-src 'self' data: blob: https:") + |> add_csp_param("media-src 'self' https:") + |> add_csp_param(connect_src) + |> add_csp_param(script_src) + |> add_csp_param(insecure) + |> add_csp_param(report) + |> :erlang.iolist_to_binary() + end - insecure = if scheme == "https", do: ["upgrade-insecure-requests"], else: [] + defp add_csp_param(csp_iodata, nil), do: csp_iodata - (main_part ++ report ++ insecure) - |> Enum.join("; ") - end + defp add_csp_param(csp_iodata, param), do: [[param, ?;] | csp_iodata] def warn_if_disabled do unless Config.get([:http_security, :enabled]) do diff --git a/test/plugs/http_security_plug_test.exs b/test/plugs/http_security_plug_test.exs index 84e4c274f..63b4d3f31 100644 --- a/test/plugs/http_security_plug_test.exs +++ b/test/plugs/http_security_plug_test.exs @@ -67,7 +67,7 @@ test "it sends `report-to` & `report-uri` CSP response headers" do [csp] = Conn.get_resp_header(conn, "content-security-policy") - assert csp =~ ~r|report-uri https://endpoint.com; report-to csp-endpoint;| + assert csp =~ ~r|report-uri https://endpoint.com;report-to csp-endpoint;| [reply_to] = Conn.get_resp_header(conn, "reply-to") -- cgit v1.2.3 From 29ff6d414ba096e74e04264af895abcabcf580b4 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 27 May 2020 21:01:36 +0300 Subject: HTTP security plug: Harden img-src and media-src when MediaProxy is enabled --- lib/pleroma/plugs/http_security_plug.ex | 41 ++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index f9aff2fab..df38d5022 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -67,11 +67,23 @@ defp csp_string do websocket_url = Pleroma.Web.Endpoint.websocket_url() report_uri = Config.get([:http_security, :report_uri]) + img_src = "img-src 'self' data: blob:" + media_src = "media-src 'self'" + + {img_src, media_src} = + if Config.get([:media_proxy, :enabled]) && + !Config.get([:media_proxy, :proxy_opts, :redirect_on_failure]) do + sources = get_proxy_and_attachment_sources() + {[img_src, sources], [media_src, sources]} + else + {img_src, media_src} + end + connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] connect_src = if Pleroma.Config.get(:env) == :dev do - [connect_src," http://localhost:3035/"] + [connect_src, " http://localhost:3035/"] else connect_src end @@ -87,8 +99,8 @@ defp csp_string do insecure = if scheme == "https", do: "upgrade-insecure-requests" @csp_start - |> add_csp_param("img-src 'self' data: blob: https:") - |> add_csp_param("media-src 'self' https:") + |> add_csp_param(img_src) + |> add_csp_param(media_src) |> add_csp_param(connect_src) |> add_csp_param(script_src) |> add_csp_param(insecure) @@ -96,6 +108,29 @@ defp csp_string do |> :erlang.iolist_to_binary() end + defp get_proxy_and_attachment_sources do + media_proxy_whitelist = + Enum.reduce(Config.get([:media_proxy, :whitelist]), [], fn host, acc -> + add_source(acc, host) + end) + + upload_base_url = + if Config.get([Pleroma.Upload, :base_url]), + do: URI.parse(Config.get([Pleroma.Upload, :base_url])).host + + s3_endpoint = + if Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.S3, + do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host + + [] + |> add_source(upload_base_url) + |> add_source(s3_endpoint) + |> add_source(media_proxy_whitelist) + end + + defp add_source(iodata, nil), do: iodata + defp add_source(iodata, source), do: [[?\s, source] | iodata] + defp add_csp_param(csp_iodata, nil), do: csp_iodata defp add_csp_param(csp_iodata, param), do: [[param, ?;] | csp_iodata] -- cgit v1.2.3 From d28b9708d2713984a8852152deec5fa467be4862 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 13:50:24 -0500 Subject: Update AdminFE build, re-enables emoji packs --- priv/static/adminfe/chunk-3384.2278f87c.css | Bin 5550 -> 0 bytes priv/static/adminfe/chunk-3384.d50ed383.css | Bin 0 -> 5605 bytes priv/static/adminfe/chunk-4011.c4799067.css | Bin 23982 -> 0 bytes priv/static/adminfe/chunk-7e30.f2b9674a.css | Bin 0 -> 23982 bytes priv/static/adminfe/chunk-e458.6c0703cb.css | Bin 0 -> 3863 bytes priv/static/adminfe/chunk-e458.f88bafea.css | Bin 3156 -> 0 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.0146039c.js | Bin 0 -> 190274 bytes priv/static/adminfe/static/js/app.0146039c.js.map | Bin 0 -> 421137 bytes priv/static/adminfe/static/js/app.203f69f8.js | Bin 187722 -> 0 bytes priv/static/adminfe/static/js/app.203f69f8.js.map | Bin 416278 -> 0 bytes priv/static/adminfe/static/js/chunk-3384.458ffaf1.js | Bin 23953 -> 0 bytes .../adminfe/static/js/chunk-3384.458ffaf1.js.map | Bin 85906 -> 0 bytes priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js | Bin 0 -> 24591 bytes .../adminfe/static/js/chunk-3384.b2ebeeca.js.map | Bin 0 -> 86706 bytes priv/static/adminfe/static/js/chunk-4011.67fb1692.js | Bin 117521 -> 0 bytes .../adminfe/static/js/chunk-4011.67fb1692.js.map | Bin 397967 -> 0 bytes priv/static/adminfe/static/js/chunk-7e30.ec42e302.js | Bin 0 -> 119434 bytes .../adminfe/static/js/chunk-7e30.ec42e302.js.map | Bin 0 -> 403603 bytes priv/static/adminfe/static/js/chunk-e458.4e5aad44.js | Bin 16756 -> 0 bytes .../adminfe/static/js/chunk-e458.4e5aad44.js.map | Bin 55666 -> 0 bytes priv/static/adminfe/static/js/chunk-e458.bb460d81.js | Bin 0 -> 17199 bytes .../adminfe/static/js/chunk-e458.bb460d81.js.map | Bin 0 -> 57478 bytes priv/static/adminfe/static/js/runtime.1b4f6ce0.js | Bin 4032 -> 0 bytes priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map | Bin 16879 -> 0 bytes priv/static/adminfe/static/js/runtime.b08eb412.js | Bin 0 -> 4032 bytes priv/static/adminfe/static/js/runtime.b08eb412.js.map | Bin 0 -> 16879 bytes 27 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 priv/static/adminfe/chunk-3384.2278f87c.css create mode 100644 priv/static/adminfe/chunk-3384.d50ed383.css delete mode 100644 priv/static/adminfe/chunk-4011.c4799067.css create mode 100644 priv/static/adminfe/chunk-7e30.f2b9674a.css create mode 100644 priv/static/adminfe/chunk-e458.6c0703cb.css delete mode 100644 priv/static/adminfe/chunk-e458.f88bafea.css create mode 100644 priv/static/adminfe/static/js/app.0146039c.js create mode 100644 priv/static/adminfe/static/js/app.0146039c.js.map delete mode 100644 priv/static/adminfe/static/js/app.203f69f8.js delete mode 100644 priv/static/adminfe/static/js/app.203f69f8.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-3384.458ffaf1.js delete mode 100644 priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map create mode 100644 priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js create mode 100644 priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-4011.67fb1692.js delete mode 100644 priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7e30.ec42e302.js create mode 100644 priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-e458.4e5aad44.js delete mode 100644 priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map create mode 100644 priv/static/adminfe/static/js/chunk-e458.bb460d81.js create mode 100644 priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.1b4f6ce0.js delete mode 100644 priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map create mode 100644 priv/static/adminfe/static/js/runtime.b08eb412.js create mode 100644 priv/static/adminfe/static/js/runtime.b08eb412.js.map diff --git a/priv/static/adminfe/chunk-3384.2278f87c.css b/priv/static/adminfe/chunk-3384.2278f87c.css deleted file mode 100644 index 96e3273eb..000000000 Binary files a/priv/static/adminfe/chunk-3384.2278f87c.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-3384.d50ed383.css b/priv/static/adminfe/chunk-3384.d50ed383.css new file mode 100644 index 000000000..70ae2a26b Binary files /dev/null and b/priv/static/adminfe/chunk-3384.d50ed383.css differ diff --git a/priv/static/adminfe/chunk-4011.c4799067.css b/priv/static/adminfe/chunk-4011.c4799067.css deleted file mode 100644 index 1fb099c0c..000000000 Binary files a/priv/static/adminfe/chunk-4011.c4799067.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-7e30.f2b9674a.css b/priv/static/adminfe/chunk-7e30.f2b9674a.css new file mode 100644 index 000000000..a4a56712e Binary files /dev/null and b/priv/static/adminfe/chunk-7e30.f2b9674a.css differ diff --git a/priv/static/adminfe/chunk-e458.6c0703cb.css b/priv/static/adminfe/chunk-e458.6c0703cb.css new file mode 100644 index 000000000..6d2a5d996 Binary files /dev/null and b/priv/static/adminfe/chunk-e458.6c0703cb.css differ diff --git a/priv/static/adminfe/chunk-e458.f88bafea.css b/priv/static/adminfe/chunk-e458.f88bafea.css deleted file mode 100644 index 085bdf076..000000000 Binary files a/priv/static/adminfe/chunk-e458.f88bafea.css and /dev/null differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index a236dd0f7..73e680115 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.0146039c.js b/priv/static/adminfe/static/js/app.0146039c.js new file mode 100644 index 000000000..ab08475ad Binary files /dev/null and b/priv/static/adminfe/static/js/app.0146039c.js differ diff --git a/priv/static/adminfe/static/js/app.0146039c.js.map b/priv/static/adminfe/static/js/app.0146039c.js.map new file mode 100644 index 000000000..178715dc6 Binary files /dev/null and b/priv/static/adminfe/static/js/app.0146039c.js.map differ diff --git a/priv/static/adminfe/static/js/app.203f69f8.js b/priv/static/adminfe/static/js/app.203f69f8.js deleted file mode 100644 index d06fdf71d..000000000 Binary files a/priv/static/adminfe/static/js/app.203f69f8.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.203f69f8.js.map b/priv/static/adminfe/static/js/app.203f69f8.js.map deleted file mode 100644 index eb78cd464..000000000 Binary files a/priv/static/adminfe/static/js/app.203f69f8.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js b/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js deleted file mode 100644 index eb2b55d37..000000000 Binary files a/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map b/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map deleted file mode 100644 index 0bb577aab..000000000 Binary files a/priv/static/adminfe/static/js/chunk-3384.458ffaf1.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js new file mode 100644 index 000000000..6a161a0c6 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js differ diff --git a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map new file mode 100644 index 000000000..b08db9d6e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-4011.67fb1692.js b/priv/static/adminfe/static/js/chunk-4011.67fb1692.js deleted file mode 100644 index 775ed26f1..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4011.67fb1692.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map b/priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map deleted file mode 100644 index 6df398cbc..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4011.67fb1692.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js new file mode 100644 index 000000000..0a0e1ca34 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js differ diff --git a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map new file mode 100644 index 000000000..bc47158ea Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js b/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js deleted file mode 100644 index a02c83110..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map b/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map deleted file mode 100644 index e623af23d..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e458.4e5aad44.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js new file mode 100644 index 000000000..a08717166 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js differ diff --git a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map new file mode 100644 index 000000000..89f05fb99 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.1b4f6ce0.js b/priv/static/adminfe/static/js/runtime.1b4f6ce0.js deleted file mode 100644 index 6558531ba..000000000 Binary files a/priv/static/adminfe/static/js/runtime.1b4f6ce0.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map b/priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map deleted file mode 100644 index 9295ac636..000000000 Binary files a/priv/static/adminfe/static/js/runtime.1b4f6ce0.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.b08eb412.js b/priv/static/adminfe/static/js/runtime.b08eb412.js new file mode 100644 index 000000000..2a9a4e0db Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.b08eb412.js differ diff --git a/priv/static/adminfe/static/js/runtime.b08eb412.js.map b/priv/static/adminfe/static/js/runtime.b08eb412.js.map new file mode 100644 index 000000000..62f70ee3e Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.b08eb412.js.map differ -- cgit v1.2.3 From 95f6240889c216feaffe55d928e0a4d5ff634119 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 14:34:37 -0500 Subject: Fix minor spelling error --- lib/pleroma/emoji/pack.ex | 2 +- lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index eb7d598c6..14a5185be 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -499,7 +499,7 @@ defp download_archive(url, sha) do if Base.decode16!(sha) == :crypto.hash(:sha256, archive) do {:ok, archive} else - {:error, :imvalid_checksum} + {:error, :invalid_checksum} end end end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 2c53dcde1..d1efdeb5d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -106,7 +106,7 @@ def download(%{body_params: %{url: url, name: name} = params} = conn, _) do |> put_status(:internal_server_error) |> json(%{error: "The requested instance does not support sharing emoji packs"}) - {:error, :imvalid_checksum} -> + {:error, :invalid_checksum} -> conn |> put_status(:internal_server_error) |> json(%{error: "SHA256 for the pack doesn't match the one sent by the server"}) -- cgit v1.2.3 From a2f57bd82b1b495a754516231b56e53ae41c6b69 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 16:27:07 -0500 Subject: Permit easy access to vaccum full and analyze via a mix task --- lib/mix/tasks/pleroma/database.ex | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 778de162f..c4f343f04 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -135,4 +135,30 @@ def run(["fix_likes_collections"]) do end) |> Stream.run() end + + def run(["vacuum", args]) do + start_pleroma() + + case args do + "analyze" -> + Logger.info("Runnning VACUUM ANALYZE.") + Repo.query!( + "vacuum analyze;", + [], + timeout: :infinity + ) + + "full" -> + Logger.info("Runnning VACUUM FULL. This could take a while.") + + Repo.query!( + "vacuum full;", + [], + timeout: :infinity + ) + + _ -> + Logger.error("Error: invalid vacuum argument.") + end + end end -- cgit v1.2.3 From 73ca57e4f1620ddaf167c368f48a0096b2096a96 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 16:27:29 -0500 Subject: Make it obvious a full vacuum can take a while --- lib/mix/tasks/pleroma/database.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index c4f343f04..1fdafcc88 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -34,7 +34,7 @@ def run(["remove_embedded_objects" | args]) do ) if Keyword.get(options, :vacuum) do - Logger.info("Runnning VACUUM FULL") + Logger.info("Runnning VACUUM FULL. This could take a while.") Repo.query!( "vacuum full;", @@ -94,7 +94,7 @@ def run(["prune_objects" | args]) do |> Repo.delete_all(timeout: :infinity) if Keyword.get(options, :vacuum) do - Logger.info("Runnning VACUUM FULL") + Logger.info("Runnning VACUUM FULL. This could take a while.") Repo.query!( "vacuum full;", -- cgit v1.2.3 From 0d57e066260234fb582a63870cbae7517e7b6246 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 16:31:37 -0500 Subject: Make clearer that this is time and resource consuming --- lib/mix/tasks/pleroma/database.ex | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 1fdafcc88..2f1f33469 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -34,7 +34,11 @@ def run(["remove_embedded_objects" | args]) do ) if Keyword.get(options, :vacuum) do - Logger.info("Runnning VACUUM FULL. This could take a while.") + Logger.info("Runnning VACUUM FULL.") + + Logger.warn( + "Re-packing your entire database may take a while and will consume extra disk space during the process." + ) Repo.query!( "vacuum full;", @@ -94,7 +98,11 @@ def run(["prune_objects" | args]) do |> Repo.delete_all(timeout: :infinity) if Keyword.get(options, :vacuum) do - Logger.info("Runnning VACUUM FULL. This could take a while.") + Logger.info("Runnning VACUUM FULL.") + + Logger.warn( + "Re-packing your entire database may take a while and will consume extra disk space during the process." + ) Repo.query!( "vacuum full;", @@ -142,6 +150,7 @@ def run(["vacuum", args]) do case args do "analyze" -> Logger.info("Runnning VACUUM ANALYZE.") + Repo.query!( "vacuum analyze;", [], @@ -149,7 +158,11 @@ def run(["vacuum", args]) do ) "full" -> - Logger.info("Runnning VACUUM FULL. This could take a while.") + Logger.info("Runnning VACUUM FULL.") + + Logger.warn( + "Re-packing your entire database may take a while and will consume extra disk space during the process." + ) Repo.query!( "vacuum full;", -- cgit v1.2.3 From 30f96b19c1850d0dd534edbe66ce19a1c8198729 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 16:40:51 -0500 Subject: Abstract out the database maintenance. I'd like to use this from AdminFE too. --- lib/mix/tasks/pleroma/database.ex | 52 +++------------------------------------ lib/pleroma/maintenance.ex | 37 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 48 deletions(-) create mode 100644 lib/pleroma/maintenance.ex diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 2f1f33469..7049293d9 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -5,6 +5,7 @@ defmodule Mix.Tasks.Pleroma.Database do alias Pleroma.Conversation alias Pleroma.Object + alias Pleroma.Maintenance alias Pleroma.Repo alias Pleroma.User require Logger @@ -34,17 +35,7 @@ def run(["remove_embedded_objects" | args]) do ) if Keyword.get(options, :vacuum) do - Logger.info("Runnning VACUUM FULL.") - - Logger.warn( - "Re-packing your entire database may take a while and will consume extra disk space during the process." - ) - - Repo.query!( - "vacuum full;", - [], - timeout: :infinity - ) + Maintenance.vacuum("full") end end @@ -98,17 +89,7 @@ def run(["prune_objects" | args]) do |> Repo.delete_all(timeout: :infinity) if Keyword.get(options, :vacuum) do - Logger.info("Runnning VACUUM FULL.") - - Logger.warn( - "Re-packing your entire database may take a while and will consume extra disk space during the process." - ) - - Repo.query!( - "vacuum full;", - [], - timeout: :infinity - ) + Maintenance.vacuum("full") end end @@ -147,31 +128,6 @@ def run(["fix_likes_collections"]) do def run(["vacuum", args]) do start_pleroma() - case args do - "analyze" -> - Logger.info("Runnning VACUUM ANALYZE.") - - Repo.query!( - "vacuum analyze;", - [], - timeout: :infinity - ) - - "full" -> - Logger.info("Runnning VACUUM FULL.") - - Logger.warn( - "Re-packing your entire database may take a while and will consume extra disk space during the process." - ) - - Repo.query!( - "vacuum full;", - [], - timeout: :infinity - ) - - _ -> - Logger.error("Error: invalid vacuum argument.") - end + Maintenance.vacuum(args) end end diff --git a/lib/pleroma/maintenance.ex b/lib/pleroma/maintenance.ex new file mode 100644 index 000000000..326c17825 --- /dev/null +++ b/lib/pleroma/maintenance.ex @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Maintenance do + alias Pleroma.Repo + require Logger + + def vacuum(args) do + case args do + "analyze" -> + Logger.info("Runnning VACUUM ANALYZE.") + + Repo.query!( + "vacuum analyze;", + [], + timeout: :infinity + ) + + "full" -> + Logger.info("Runnning VACUUM FULL.") + + Logger.warn( + "Re-packing your entire database may take a while and will consume extra disk space during the process." + ) + + Repo.query!( + "vacuum full;", + [], + timeout: :infinity + ) + + _ -> + Logger.error("Error: invalid vacuum argument.") + end + end +end -- cgit v1.2.3 From 92fba24c743a5d2d9ed78df7499fd3123a6ad6ac Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 27 May 2020 17:17:06 -0500 Subject: Alpha sort --- lib/mix/tasks/pleroma/database.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 7049293d9..82e2abdcb 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -4,8 +4,8 @@ defmodule Mix.Tasks.Pleroma.Database do alias Pleroma.Conversation - alias Pleroma.Object alias Pleroma.Maintenance + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User require Logger -- cgit v1.2.3 From 9eea80002673eb1359a2d4369c65a89c42c2f707 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 28 May 2020 10:16:09 -0500 Subject: Refactor notification settings --- CHANGELOG.md | 3 +++ docs/API/pleroma_api.md | 7 +++--- lib/pleroma/notification.ex | 29 +++++++---------------- lib/pleroma/user/notification_setting.ex | 14 +++++------ lib/pleroma/web/api_spec/schemas/account.ex | 14 +++++------ test/notification_test.exs | 17 ++++--------- test/web/mastodon_api/views/account_view_test.exs | 7 +++--- test/web/twitter_api/util_controller_test.exs | 16 ++++++------- 8 files changed, 41 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dabc2a85a..fba236608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    API Changes - **Breaking:** Emoji API: changed methods and renamed routes. +- **Breaking:** Notification Settings API for suppressing notification + now supports the following controls: `from_followers`, `from_following`, + and `from_strangers`.
    ### Removed diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 70d4755b7..2cb0792db 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -287,10 +287,9 @@ See [Admin-API](admin_api.md) * Method `PUT` * Authentication: required * Params: - * `followers`: BOOLEAN field, receives notifications from followers - * `follows`: BOOLEAN field, receives notifications from people the user follows - * `remote`: BOOLEAN field, receives notifications from people on remote instances - * `local`: BOOLEAN field, receives notifications from people on the local instance + * `from_followers`: BOOLEAN field, receives notifications from followers + * `from_following`: BOOLEAN field, receives notifications from people the user follows + * `from_strangers`: BOOLEAN field, receives notifications from people without an established relationship * `privacy_option`: BOOLEAN field. When set to true, it removes the contents of a message from the push notification. * Response: JSON. Returns `{"status": "success"}` if the update was successful, otherwise returns `{"error": "error_msg"}` diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 7eca55ac9..ca556f0bb 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -459,10 +459,9 @@ def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do def skip?(%Activity{} = activity, %User{} = user) do [ :self, - :followers, - :follows, - :non_followers, - :non_follows, + :from_followers, + :from_following, + :from_strangers, :recently_followed ] |> Enum.find(&skip?(&1, activity, user)) @@ -476,9 +475,9 @@ def skip?(:self, %Activity{} = activity, %User{} = user) do end def skip?( - :followers, + :from_followers, %Activity{} = activity, - %User{notification_settings: %{followers: false}} = user + %User{notification_settings: %{from_followers: false}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) @@ -486,9 +485,9 @@ def skip?( end def skip?( - :non_followers, + :from_strangers, %Activity{} = activity, - %User{notification_settings: %{non_followers: false}} = user + %User{notification_settings: %{from_strangers: false}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) @@ -496,25 +495,15 @@ def skip?( end def skip?( - :follows, + :from_following, %Activity{} = activity, - %User{notification_settings: %{follows: false}} = user + %User{notification_settings: %{from_following: false}} = user ) do actor = activity.data["actor"] followed = User.get_cached_by_ap_id(actor) User.following?(user, followed) end - def skip?( - :non_follows, - %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 - # 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"] diff --git a/lib/pleroma/user/notification_setting.ex b/lib/pleroma/user/notification_setting.ex index 4bd55e139..e47ac4cab 100644 --- a/lib/pleroma/user/notification_setting.ex +++ b/lib/pleroma/user/notification_setting.ex @@ -10,20 +10,18 @@ defmodule Pleroma.User.NotificationSetting do @primary_key false embedded_schema do - field(:followers, :boolean, default: true) - field(:follows, :boolean, default: true) - field(:non_follows, :boolean, default: true) - field(:non_followers, :boolean, default: true) + field(:from_followers, :boolean, default: true) + field(:from_following, :boolean, default: true) + field(:from_strangers, :boolean, default: true) field(:privacy_option, :boolean, default: false) end def changeset(schema, params) do schema |> cast(prepare_attrs(params), [ - :followers, - :follows, - :non_follows, - :non_followers, + :from_followers, + :from_following, + :from_strangers, :privacy_option ]) end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index d54e2158d..ed90ef3db 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -57,10 +57,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do notification_settings: %Schema{ type: :object, properties: %{ - followers: %Schema{type: :boolean}, - follows: %Schema{type: :boolean}, - non_followers: %Schema{type: :boolean}, - non_follows: %Schema{type: :boolean}, + from_followers: %Schema{type: :boolean}, + from_following: %Schema{type: :boolean}, + from_strangers: %Schema{type: :boolean}, privacy_option: %Schema{type: :boolean} } }, @@ -123,10 +122,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "unread_conversation_count" => 0, "tags" => [], "notification_settings" => %{ - "followers" => true, - "follows" => true, - "non_followers" => true, - "non_follows" => true, + "from_followers" => true, + "from_following" => true, + "from_strangers" => true, "privacy_option" => false }, "relationship" => %{ diff --git a/test/notification_test.exs b/test/notification_test.exs index 37c255fee..fd59aceb5 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -237,19 +237,19 @@ test "it disables notifications from followers" do follower = insert(:user) followed = - insert(:user, notification_settings: %Pleroma.User.NotificationSetting{followers: false}) + insert(:user, notification_settings: %Pleroma.User.NotificationSetting{from_followers: false}) User.follow(follower, followed) {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) refute Notification.create_notification(activity, followed) end - test "it disables notifications from non-followers" do + test "it disables notifications from strangers" do follower = insert(:user) followed = insert(:user, - notification_settings: %Pleroma.User.NotificationSetting{non_followers: false} + notification_settings: %Pleroma.User.NotificationSetting{from_strangers: false} ) {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) @@ -258,7 +258,7 @@ test "it disables notifications from non-followers" do test "it disables notifications from people the user follows" do follower = - insert(:user, notification_settings: %Pleroma.User.NotificationSetting{follows: false}) + insert(:user, notification_settings: %Pleroma.User.NotificationSetting{from_following: false}) followed = insert(:user) User.follow(follower, followed) @@ -267,15 +267,6 @@ test "it disables notifications from people the user follows" do refute Notification.create_notification(activity, follower) end - test "it disables notifications from people the user does not follow" do - follower = - insert(:user, notification_settings: %Pleroma.User.NotificationSetting{non_follows: false}) - - followed = insert(:user) - {:ok, activity} = CommonAPI.post(followed, %{status: "hey @#{follower.nickname}"}) - refute Notification.create_notification(activity, follower) - end - test "it doesn't create a notification for user if he is the activity author" do activity = insert(:note_activity) author = User.get_cached_by_ap_id(activity.data["actor"]) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 487ec26c2..2e01689ff 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -94,10 +94,9 @@ test "Represent the user account for the account owner" do user = insert(:user) notification_settings = %{ - followers: true, - follows: true, - non_followers: true, - non_follows: true, + from_followers: true, + from_following: true, + from_strangers: true, privacy_option: false } diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index ad919d341..1133107f4 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -191,7 +191,7 @@ test "it imports blocks with different nickname variations", %{conn: conn} do test "it updates notification settings", %{user: user, conn: conn} do conn |> put("/api/pleroma/notification_settings", %{ - "followers" => false, + "from_followers" => false, "bar" => 1 }) |> json_response(:ok) @@ -199,10 +199,9 @@ test "it updates notification settings", %{user: user, conn: conn} do user = refresh_record(user) assert %Pleroma.User.NotificationSetting{ - followers: false, - follows: true, - non_follows: true, - non_followers: true, + from_followers: false, + from_following: true, + from_strangers: true, privacy_option: false } == user.notification_settings end @@ -215,10 +214,9 @@ test "it updates notification privacy option", %{user: user, conn: conn} do user = refresh_record(user) assert %Pleroma.User.NotificationSetting{ - followers: true, - follows: true, - non_follows: true, - non_followers: true, + from_followers: true, + from_following: true, + from_strangers: true, privacy_option: true } == user.notification_settings end -- cgit v1.2.3 From 800e62405855af673328278ce08e9b1c5cb0602f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 28 May 2020 19:32:56 +0400 Subject: Update installation guides --- docs/installation/debian_based_en.md | 4 ++-- docs/installation/debian_based_jp.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index 62d8733f7..2c20d521a 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -38,8 +38,8 @@ sudo apt install git build-essential postgresql postgresql-contrib * Download and add the Erlang repository: ```shell -wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb -sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb +wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb +sudo dpkg -i /tmp/erlang-solutions_2.0_all.deb ``` * Install Elixir and Erlang: diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index a3c4621d8..1e5a9be91 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -40,8 +40,8 @@ sudo apt install git build-essential postgresql postgresql-contrib * Erlangのリポジトリをダウンロードおよびインストールします。 ``` -wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb -sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb +wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb +sudo dpkg -i /tmp/erlang-solutions_2.0_all.deb ``` * ElixirとErlangをインストールします、 -- cgit v1.2.3 From ae05792d2a825dbb7d53a7f5a079548ae8310c63 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 28 May 2020 19:41:34 +0300 Subject: get-packs for local generated pack --- lib/mix/tasks/pleroma/emoji.ex | 38 +++++++++++++++------------ test/instance_static/local_pack/files.json | 3 +++ test/instance_static/local_pack/manifest.json | 10 +++++++ test/tasks/emoji_test.exs | 13 +++++++++ 4 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 test/instance_static/local_pack/files.json create mode 100644 test/instance_static/local_pack/manifest.json diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index cdffa88b2..29a5fa99c 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -15,7 +15,7 @@ def run(["ls-packs" | args]) do {options, [], []} = parse_global_opts(args) url_or_path = options[:manifest] || default_manifest() - manifest = fetch_manifest(url_or_path) + manifest = fetch_and_decode(url_or_path) Enum.each(manifest, fn {name, info} -> to_print = [ @@ -42,12 +42,12 @@ def run(["get-packs" | args]) do url_or_path = options[:manifest] || default_manifest() - manifest = fetch_manifest(url_or_path) + manifest = fetch_and_decode(url_or_path) for pack_name <- pack_names do if Map.has_key?(manifest, pack_name) do pack = manifest[pack_name] - src_url = pack["src"] + src = pack["src"] IO.puts( IO.ANSI.format([ @@ -57,11 +57,11 @@ def run(["get-packs" | args]) do :normal, " from ", :underline, - src_url + src ]) ) - binary_archive = Tesla.get!(client(), src_url).body + {:ok, binary_archive} = fetch(src) archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() sha_status_text = ["SHA256 of ", :bright, pack_name, :normal, " source file is ", :bright] @@ -74,8 +74,8 @@ def run(["get-packs" | args]) do raise "Bad SHA256 for #{pack_name}" end - # The url specified in files should be in the same directory - files_url = + # The location specified in files should be in the same directory + files_loc = url_or_path |> Path.dirname() |> Path.join(pack["files"]) @@ -88,11 +88,11 @@ def run(["get-packs" | args]) do :normal, " from ", :underline, - files_url + files_loc ]) ) - files = Tesla.get!(client(), files_url).body |> Jason.decode!() + files = fetch_and_decode(files_loc) IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name])) @@ -237,16 +237,20 @@ def run(["gen-pack" | args]) do end end - defp fetch_manifest(from) do - Jason.decode!( - if String.starts_with?(from, "http") do - Tesla.get!(client(), from).body - else - File.read!(from) - end - ) + defp fetch_and_decode(from) do + with {:ok, json} <- fetch(from) do + Jason.decode!(json) + end end + defp fetch("http" <> _ = from) do + with {:ok, %{body: body}} <- Tesla.get(client(), from) do + {:ok, body} + end + end + + defp fetch(path), do: File.read(path) + defp parse_global_opts(args) do OptionParser.parse( args, diff --git a/test/instance_static/local_pack/files.json b/test/instance_static/local_pack/files.json new file mode 100644 index 000000000..279770998 --- /dev/null +++ b/test/instance_static/local_pack/files.json @@ -0,0 +1,3 @@ +{ + "blank": "blank.png" +} \ No newline at end of file diff --git a/test/instance_static/local_pack/manifest.json b/test/instance_static/local_pack/manifest.json new file mode 100644 index 000000000..01067042f --- /dev/null +++ b/test/instance_static/local_pack/manifest.json @@ -0,0 +1,10 @@ +{ + "local": { + "src_sha256": "384025A1AC6314473863A11AC7AB38A12C01B851A3F82359B89B4D4211D3291D", + "src": "test/fixtures/emoji/packs/blank.png.zip", + "license": "Apache 2.0", + "homepage": "https://example.com", + "files": "files.json", + "description": "Some local pack" + } +} \ No newline at end of file diff --git a/test/tasks/emoji_test.exs b/test/tasks/emoji_test.exs index f5de3ef0e..499f098c2 100644 --- a/test/tasks/emoji_test.exs +++ b/test/tasks/emoji_test.exs @@ -73,6 +73,19 @@ test "download pack from default manifest" do on_exit(fn -> File.rm_rf!("test/instance_static/emoji/finmoji") end) end + test "install local emoji pack" do + assert capture_io(fn -> + Emoji.run([ + "get-packs", + "local", + "--manifest", + "test/instance_static/local_pack/manifest.json" + ]) + end) =~ "Writing pack.json for" + + on_exit(fn -> File.rm_rf!("test/instance_static/emoji/local") end) + end + test "pack not found" do mock(fn %{ -- cgit v1.2.3 From d4a18d44feb4ae67f6476b30fac96c0e6aa511dd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 May 2020 00:49:49 -0500 Subject: Update default instance description --- config/config.exs | 2 +- lib/pleroma/web/api_spec/operations/instance_operation.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index d15998715..3729526ea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -183,7 +183,7 @@ name: "Pleroma", email: "example@example.com", notify_email: "noreply@example.com", - description: "A Pleroma instance, an alternative fediverse server", + description: "Pleroma: An efficient and flexible fediverse server", background_image: "/images/city.jpg", limit: 5_000, chat_limit: 5_000, diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index d5c335d0c..bf39ae643 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -137,7 +137,7 @@ defp instance do "background_upload_limit" => 4_000_000, "background_image" => "/static/image.png", "banner_upload_limit" => 4_000_000, - "description" => "A Pleroma instance, an alternative fediverse server", + "description" => "Pleroma: An efficient and flexible fediverse server", "email" => "lain@lain.com", "languages" => ["en"], "max_toot_chars" => 5000, -- cgit v1.2.3 From 4c82f657c5aaba6aacbc8ef927c1727509195839 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 28 May 2020 13:22:28 -0500 Subject: Formatting --- test/notification_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index fd59aceb5..a1a7cee2a 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -237,7 +237,9 @@ test "it disables notifications from followers" do follower = insert(:user) followed = - insert(:user, notification_settings: %Pleroma.User.NotificationSetting{from_followers: false}) + insert(:user, + notification_settings: %Pleroma.User.NotificationSetting{from_followers: false} + ) User.follow(follower, followed) {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) @@ -258,7 +260,9 @@ test "it disables notifications from strangers" do test "it disables notifications from people the user follows" do follower = - insert(:user, notification_settings: %Pleroma.User.NotificationSetting{from_following: false}) + insert(:user, + notification_settings: %Pleroma.User.NotificationSetting{from_following: false} + ) followed = insert(:user) User.follow(follower, followed) -- cgit v1.2.3 From d1ee3527ef8062c34e222a1c7084c207b80fe4db Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 28 May 2020 22:23:15 +0400 Subject: Move config actions to AdminAPI.ConfigController --- .../admin_api/controllers/admin_api_controller.ex | 127 -- .../web/admin_api/controllers/config_controller.ex | 150 +++ lib/pleroma/web/router.ex | 6 +- .../controllers/admin_api_controller_test.exs | 1220 ------------------- .../controllers/config_controller_test.exs | 1244 ++++++++++++++++++++ 5 files changed, 1397 insertions(+), 1350 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/config_controller.ex create mode 100644 test/web/admin_api/controllers/config_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 783203c07..52900026f 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Activity alias Pleroma.Config - alias Pleroma.ConfigDB alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug @@ -24,7 +23,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView - alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView @@ -38,7 +36,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do require Logger - @descriptions Pleroma.Docs.JSON.compile() @users_page_size 50 plug( @@ -105,11 +102,9 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do OAuthScopesPlug, %{scopes: ["read"], admin: true} when action in [ - :config_show, :list_log, :stats, :relay_list, - :config_descriptions, :need_reboot ] ) @@ -119,7 +114,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do %{scopes: ["write"], admin: true} when action in [ :restart, - :config_update, :resend_confirmation_email, :confirm_email, :oauth_app_create, @@ -821,105 +815,6 @@ def list_log(conn, params) do |> render("index.json", %{log: log}) end - def config_descriptions(conn, _params) do - descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) - - json(conn, descriptions) - end - - def config_show(conn, %{"only_db" => true}) do - with :ok <- configurable_from_database() do - configs = Pleroma.Repo.all(ConfigDB) - - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: configs}) - end - end - - def config_show(conn, _params) do - with :ok <- configurable_from_database() do - configs = ConfigDB.get_all_as_keyword() - - merged = - Config.Holder.default_config() - |> ConfigDB.merge(configs) - |> Enum.map(fn {group, values} -> - Enum.map(values, fn {key, value} -> - db = - if configs[group][key] do - ConfigDB.get_db_keys(configs[group][key], key) - end - - db_value = configs[group][key] - - merged_value = - if !is_nil(db_value) and Keyword.keyword?(db_value) and - ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do - ConfigDB.merge_group(group, key, value, db_value) - else - value - end - - setting = %{ - group: ConfigDB.convert(group), - key: ConfigDB.convert(key), - value: ConfigDB.convert(merged_value) - } - - if db, do: Map.put(setting, :db, db), else: setting - end) - end) - |> List.flatten() - - json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) - end - end - - def config_update(conn, %{"configs" => configs}) do - with :ok <- configurable_from_database() do - {_errors, results} = - configs - |> Enum.filter(&whitelisted_config?/1) - |> Enum.map(fn - %{"group" => group, "key" => key, "delete" => true} = params -> - ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) - - %{"group" => group, "key" => key, "value" => value} -> - ConfigDB.update_or_create(%{group: group, key: key, value: value}) - end) - |> Enum.split_with(fn result -> elem(result, 0) == :error end) - - {deleted, updated} = - results - |> Enum.map(fn {:ok, config} -> - Map.put(config, :db, ConfigDB.get_db_keys(config)) - end) - |> Enum.split_with(fn config -> - Ecto.get_meta(config, :state) == :deleted - end) - - Config.TransferTask.load_and_update_env(deleted, false) - - if !Restarter.Pleroma.need_reboot?() do - changed_reboot_settings? = - (updated ++ deleted) - |> Enum.any?(fn config -> - group = ConfigDB.from_string(config.group) - key = ConfigDB.from_string(config.key) - value = ConfigDB.from_binary(config.value) - Config.TransferTask.pleroma_need_restart?(group, key, value) - end) - - if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() - end - - conn - |> put_view(ConfigView) - |> render("index.json", %{configs: updated, need_reboot: Restarter.Pleroma.need_reboot?()}) - end - end - def restart(conn, _params) do with :ok <- configurable_from_database() do Restarter.Pleroma.restart(Config.get(:env), 50) @@ -940,28 +835,6 @@ defp configurable_from_database do end end - defp whitelisted_config?(group, key) do - if whitelisted_configs = Config.get(:database_config_whitelist) do - Enum.any?(whitelisted_configs, fn - {whitelisted_group} -> - group == inspect(whitelisted_group) - - {whitelisted_group, whitelisted_key} -> - group == inspect(whitelisted_group) && key == inspect(whitelisted_key) - end) - else - true - end - end - - defp whitelisted_config?(%{"group" => group, "key" => key}) do - whitelisted_config?(group, key) - end - - defp whitelisted_config?(%{:group => group} = config) do - whitelisted_config?(group, config[:key]) - end - def reload_emoji(conn, _params) do Pleroma.Emoji.reload() diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex new file mode 100644 index 000000000..742980976 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -0,0 +1,150 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ConfigController do + use Pleroma.Web, :controller + + alias Pleroma.Config + alias Pleroma.ConfigDB + alias Pleroma.Plugs.OAuthScopesPlug + + @descriptions Pleroma.Docs.JSON.compile() + + plug( + OAuthScopesPlug, + %{scopes: ["read"], admin: true} + when action in [:show, :descriptions] + ) + + plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + def descriptions(conn, _params) do + descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) + + json(conn, descriptions) + end + + def show(conn, %{"only_db" => true}) do + with :ok <- configurable_from_database() do + configs = Pleroma.Repo.all(ConfigDB) + render(conn, "index.json", %{configs: configs}) + end + end + + def show(conn, _params) do + with :ok <- configurable_from_database() do + configs = ConfigDB.get_all_as_keyword() + + merged = + Config.Holder.default_config() + |> ConfigDB.merge(configs) + |> Enum.map(fn {group, values} -> + Enum.map(values, fn {key, value} -> + db = + if configs[group][key] do + ConfigDB.get_db_keys(configs[group][key], key) + end + + db_value = configs[group][key] + + merged_value = + if not is_nil(db_value) and Keyword.keyword?(db_value) and + ConfigDB.sub_key_full_update?(group, key, Keyword.keys(db_value)) do + ConfigDB.merge_group(group, key, value, db_value) + else + value + end + + setting = %{ + group: ConfigDB.convert(group), + key: ConfigDB.convert(key), + value: ConfigDB.convert(merged_value) + } + + if db, do: Map.put(setting, :db, db), else: setting + end) + end) + |> List.flatten() + + json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) + end + end + + def update(conn, %{"configs" => configs}) do + with :ok <- configurable_from_database() do + results = + configs + |> Enum.filter(&whitelisted_config?/1) + |> Enum.map(fn + %{"group" => group, "key" => key, "delete" => true} = params -> + ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) + + %{"group" => group, "key" => key, "value" => value} -> + ConfigDB.update_or_create(%{group: group, key: key, value: value}) + end) + |> Enum.reject(fn {result, _} -> result == :error end) + + {deleted, updated} = + results + |> Enum.map(fn {:ok, config} -> + Map.put(config, :db, ConfigDB.get_db_keys(config)) + end) + |> Enum.split_with(fn config -> + Ecto.get_meta(config, :state) == :deleted + end) + + Config.TransferTask.load_and_update_env(deleted, false) + + if not Restarter.Pleroma.need_reboot?() do + changed_reboot_settings? = + (updated ++ deleted) + |> Enum.any?(fn config -> + group = ConfigDB.from_string(config.group) + key = ConfigDB.from_string(config.key) + value = ConfigDB.from_binary(config.value) + Config.TransferTask.pleroma_need_restart?(group, key, value) + end) + + if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() + end + + render(conn, "index.json", %{ + configs: updated, + need_reboot: Restarter.Pleroma.need_reboot?() + }) + end + end + + defp configurable_from_database do + if Config.get(:configurable_from_database) do + :ok + else + {:error, "To use this endpoint you need to enable configuration from database."} + end + end + + defp whitelisted_config?(group, key) do + if whitelisted_configs = Config.get(:database_config_whitelist) do + Enum.any?(whitelisted_configs, fn + {whitelisted_group} -> + group == inspect(whitelisted_group) + + {whitelisted_group, whitelisted_key} -> + group == inspect(whitelisted_group) && key == inspect(whitelisted_key) + end) + else + true + end + end + + defp whitelisted_config?(%{"group" => group, "key" => key}) do + whitelisted_config?(group, key) + end + + defp whitelisted_config?(%{:group => group} = config) do + whitelisted_config?(group, config[:key]) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..b683a4ff3 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -194,9 +194,9 @@ defmodule Pleroma.Web.Router do delete("/statuses/:id", StatusController, :delete) get("/statuses", StatusController, :index) - get("/config", AdminAPIController, :config_show) - post("/config", AdminAPIController, :config_update) - get("/config/descriptions", AdminAPIController, :config_descriptions) + get("/config", ConfigController, :show) + post("/config", ConfigController, :update) + get("/config/descriptions", ConfigController, :descriptions) get("/need_reboot", AdminAPIController, :need_reboot) get("/restart", AdminAPIController, :restart) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index ead840186..bd44ffed3 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -12,7 +12,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.Activity alias Pleroma.Config - alias Pleroma.ConfigDB alias Pleroma.HTML alias Pleroma.MFA alias Pleroma.ModerationLog @@ -1704,1175 +1703,6 @@ test "returns 403 when requested by anonymous" do end end - describe "GET /api/pleroma/admin/config" do - setup do: clear_config(:configurable_from_database, true) - - test "when configuration from database is off", %{conn: conn} do - Config.put(:configurable_from_database, false) - conn = get(conn, "/api/pleroma/admin/config") - - assert json_response(conn, 400) == - %{ - "error" => "To use this endpoint you need to enable configuration from database." - } - end - - test "with settings only in db", %{conn: conn} do - config1 = insert(:config) - config2 = insert(:config) - - conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) - - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => key1, - "value" => _ - }, - %{ - "group" => ":pleroma", - "key" => key2, - "value" => _ - } - ] - } = json_response(conn, 200) - - assert key1 == config1.key - assert key2 == config2.key - end - - test "db is added to settings that are in db", %{conn: conn} do - _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - [instance_config] = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key == ":instance" - end) - - assert instance_config["db"] == [":name"] - end - - test "merged default setting with db settings", %{conn: conn} do - config1 = insert(:config) - config2 = insert(:config) - - config3 = - insert(:config, - value: ConfigDB.to_binary(k1: :v1, k2: :v2) - ) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert length(configs) > 3 - - received_configs = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key, config3.key] - end) - - assert length(received_configs) == 3 - - db_keys = - config3.value - |> ConfigDB.from_binary() - |> Keyword.keys() - |> ConfigDB.convert() - - Enum.each(received_configs, fn %{"value" => value, "db" => db} -> - assert db in [[config1.key], [config2.key], db_keys] - - assert value in [ - ConfigDB.from_binary_with_convert(config1.value), - ConfigDB.from_binary_with_convert(config2.value), - ConfigDB.from_binary_with_convert(config3.value) - ] - end) - end - - test "subkeys with full update right merge", %{conn: conn} do - config1 = - insert(:config, - key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) - ) - - config2 = - insert(:config, - key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) - ) - - %{"configs" => configs} = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - vals = - Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key] - end) - - emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) - assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) - - emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) - assets_val = ConfigDB.transform_with_out_binary(assets["value"]) - - assert emoji_val[:groups] == [a: 1, b: 2] - assert assets_val[:mascots] == [a: 1, b: 2] - end - end - - test "POST /api/pleroma/admin/config error", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) - - assert json_response(conn, 400) == - %{"error" => "To use this endpoint you need to enable configuration from database."} - end - - describe "POST /api/pleroma/admin/config" do - setup do - http = Application.get_env(:pleroma, :http) - - on_exit(fn -> - Application.delete_env(:pleroma, :key1) - Application.delete_env(:pleroma, :key2) - Application.delete_env(:pleroma, :key3) - Application.delete_env(:pleroma, :key4) - Application.delete_env(:pleroma, :keyaa1) - Application.delete_env(:pleroma, :keyaa2) - Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) - Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) - Application.put_env(:pleroma, :http, http) - Application.put_env(:tesla, :adapter, Tesla.Mock) - Restarter.Pleroma.refresh() - end) - end - - setup do: clear_config(:configurable_from_database, true) - - @tag capture_log: true - test "create new config setting in db", %{conn: conn} do - ueberauth = Application.get_env(:ueberauth, Ueberauth) - on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: "value1"}, - %{ - group: ":ueberauth", - key: "Ueberauth", - value: [%{"tuple" => [":consumer_secret", "aaaa"]}] - }, - %{ - group: ":pleroma", - key: ":key2", - value: %{ - ":nested_1" => "nested_value1", - ":nested_2" => [ - %{":nested_22" => "nested_value222"}, - %{":nested_33" => %{":nested_44" => "nested_444"}} - ] - } - }, - %{ - group: ":pleroma", - key: ":key3", - value: [ - %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, - %{"nested_4" => true} - ] - }, - %{ - group: ":pleroma", - key: ":key4", - value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} - }, - %{ - group: ":idna", - key: ":key5", - value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => "value1", - "db" => [":key1"] - }, - %{ - "group" => ":ueberauth", - "key" => "Ueberauth", - "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}], - "db" => [":consumer_secret"] - }, - %{ - "group" => ":pleroma", - "key" => ":key2", - "value" => %{ - ":nested_1" => "nested_value1", - ":nested_2" => [ - %{":nested_22" => "nested_value222"}, - %{":nested_33" => %{":nested_44" => "nested_444"}} - ] - }, - "db" => [":key2"] - }, - %{ - "group" => ":pleroma", - "key" => ":key3", - "value" => [ - %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, - %{"nested_4" => true} - ], - "db" => [":key3"] - }, - %{ - "group" => ":pleroma", - "key" => ":key4", - "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}, - "db" => [":key4"] - }, - %{ - "group" => ":idna", - "key" => ":key5", - "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, - "db" => [":key5"] - } - ] - } - - assert Application.get_env(:pleroma, :key1) == "value1" - - assert Application.get_env(:pleroma, :key2) == %{ - nested_1: "nested_value1", - nested_2: [ - %{nested_22: "nested_value222"}, - %{nested_33: %{nested_44: "nested_444"}} - ] - } - - assert Application.get_env(:pleroma, :key3) == [ - %{"nested_3" => :nested_3, "nested_33" => "nested_33"}, - %{"nested_4" => true} - ] - - assert Application.get_env(:pleroma, :key4) == %{ - "endpoint" => "https://example.com", - nested_5: :upload - } - - assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} - end - - test "save configs setting without explicit key", %{conn: conn} do - level = Application.get_env(:quack, :level) - meta = Application.get_env(:quack, :meta) - webhook_url = Application.get_env(:quack, :webhook_url) - - on_exit(fn -> - Application.put_env(:quack, :level, level) - Application.put_env(:quack, :meta, meta) - Application.put_env(:quack, :webhook_url, webhook_url) - end) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":quack", - key: ":level", - value: ":info" - }, - %{ - group: ":quack", - key: ":meta", - value: [":none"] - }, - %{ - group: ":quack", - key: ":webhook_url", - value: "https://hooks.slack.com/services/KEY" - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":quack", - "key" => ":level", - "value" => ":info", - "db" => [":level"] - }, - %{ - "group" => ":quack", - "key" => ":meta", - "value" => [":none"], - "db" => [":meta"] - }, - %{ - "group" => ":quack", - "key" => ":webhook_url", - "value" => "https://hooks.slack.com/services/KEY", - "db" => [":webhook_url"] - } - ] - } - - assert Application.get_env(:quack, :level) == :info - assert Application.get_env(:quack, :meta) == [:none] - assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" - end - - test "saving config with partial update", %{conn: conn} do - config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key1", 1]}, - %{"tuple" => [":key2", 2]}, - %{"tuple" => [":key3", 3]} - ], - "db" => [":key1", ":key2", ":key3"] - } - ] - } - end - - test "saving config which need pleroma reboot", %{conn: conn} do - chat = Config.get(:chat) - on_exit(fn -> Config.put(:chat, chat) end) - - assert post( - conn, - "/api/pleroma/admin/config", - %{ - configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} - ] - } - ) - |> json_response(200) == %{ - "configs" => [ - %{ - "db" => [":enabled"], - "group" => ":pleroma", - "key" => ":chat", - "value" => [%{"tuple" => [":enabled", true]}] - } - ], - "need_reboot" => true - } - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] - - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] == false - end - - test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do - chat = Config.get(:chat) - on_exit(fn -> Config.put(:chat, chat) end) - - assert post( - conn, - "/api/pleroma/admin/config", - %{ - configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} - ] - } - ) - |> json_response(200) == %{ - "configs" => [ - %{ - "db" => [":enabled"], - "group" => ":pleroma", - "key" => ":chat", - "value" => [%{"tuple" => [":enabled", true]}] - } - ], - "need_reboot" => true - } - - assert post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} - ] - }) - |> json_response(200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key3", 3]} - ], - "db" => [":key3"] - } - ], - "need_reboot" => true - } - - capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} - end) =~ "pleroma restarted" - - configs = - conn - |> get("/api/pleroma/admin/config") - |> json_response(200) - - assert configs["need_reboot"] == false - end - - test "saving config with nested merge", %{conn: conn} do - config = - insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - value: [ - %{"tuple" => [":key3", 3]}, - %{ - "tuple" => [ - ":key2", - [ - %{"tuple" => [":k2", 1]}, - %{"tuple" => [":k3", 3]} - ] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key1", 1]}, - %{"tuple" => [":key3", 3]}, - %{ - "tuple" => [ - ":key2", - [ - %{"tuple" => [":k1", 1]}, - %{"tuple" => [":k2", 1]}, - %{"tuple" => [":k3", 3]} - ] - ] - } - ], - "db" => [":key1", ":key3", ":key2"] - } - ] - } - end - - test "saving special atoms", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{ - "tuple" => [ - ":ssl_options", - [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{ - "tuple" => [ - ":ssl_options", - [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] - ] - } - ], - "db" => [":ssl_options"] - } - ] - } - - assert Application.get_env(:pleroma, :key1) == [ - ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] - ] - end - - test "saving full setting if value is in full_key_update list", %{conn: conn} do - backends = Application.get_env(:logger, :backends) - on_exit(fn -> Application.put_env(:logger, :backends, backends) end) - - config = - insert(:config, - group: ":logger", - key: ":backends", - value: :erlang.term_to_binary([]) - ) - - Pleroma.Config.TransferTask.load_and_update_env([], false) - - assert Application.get_env(:logger, :backends) == [] - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - value: [":console"] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":logger", - "key" => ":backends", - "value" => [ - ":console" - ], - "db" => [":backends"] - } - ] - } - - assert Application.get_env(:logger, :backends) == [ - :console - ] - end - - test "saving full setting if value is not keyword", %{conn: conn} do - config = - insert(:config, - group: ":tesla", - key: ":adapter", - value: :erlang.term_to_binary(Tesla.Adapter.Hackey) - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":tesla", - "key" => ":adapter", - "value" => "Tesla.Adapter.Httpc", - "db" => [":adapter"] - } - ] - } - end - - test "update config setting & delete with fallback to default value", %{ - conn: conn, - admin: admin, - token: token - } do - ueberauth = Application.get_env(:ueberauth, Ueberauth) - config1 = insert(:config, key: ":keyaa1") - config2 = insert(:config, key: ":keyaa2") - - config3 = - insert(:config, - group: ":ueberauth", - key: "Ueberauth" - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: config1.group, key: config1.key, value: "another_value"}, - %{group: config2.group, key: config2.key, value: "another_value"} - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => config1.key, - "value" => "another_value", - "db" => [":keyaa1"] - }, - %{ - "group" => ":pleroma", - "key" => config2.key, - "value" => "another_value", - "db" => [":keyaa2"] - } - ] - } - - assert Application.get_env(:pleroma, :keyaa1) == "another_value" - assert Application.get_env(:pleroma, :keyaa2) == "another_value" - assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) - - conn = - build_conn() - |> assign(:user, admin) - |> assign(:token, token) - |> post("/api/pleroma/admin/config", %{ - configs: [ - %{group: config2.group, key: config2.key, delete: true}, - %{ - group: ":ueberauth", - key: "Ueberauth", - delete: true - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [] - } - - assert Application.get_env(:ueberauth, Ueberauth) == ueberauth - refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2) - end - - test "common config example", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Captcha.NotReal", - "value" => [ - %{"tuple" => [":enabled", false]}, - %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, - %{"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"]}, - %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, - %{"tuple" => [":name", "Pleroma"]} - ] - } - ] - }) - - assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Captcha.NotReal", - "value" => [ - %{"tuple" => [":enabled", false]}, - %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, - %{"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"]}, - %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, - %{"tuple" => [":name", "Pleroma"]} - ], - "db" => [ - ":enabled", - ":method", - ":seconds_valid", - ":path", - ":key1", - ":partial_chain", - ":regex1", - ":regex2", - ":regex3", - ":regex4", - ":name" - ] - } - ] - } - end - - test "tuples with more than two values", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Web.Endpoint.NotReal", - "value" => [ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key2", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => "Pleroma.Web.Endpoint.NotReal", - "value" => [ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key2", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ], - "db" => [":http"] - } - ] - } - end - - test "settings with nesting map", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key2", "some_val"]}, - %{ - "tuple" => [ - ":key3", - %{ - ":max_options" => 20, - ":max_option_chars" => 200, - ":min_expiration" => 0, - ":max_expiration" => 31_536_000, - "nested" => %{ - ":max_options" => 20, - ":max_option_chars" => 200, - ":min_expiration" => 0, - ":max_expiration" => 31_536_000 - } - } - ] - } - ] - } - ] - }) - - assert json_response(conn, 200) == - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => [ - %{"tuple" => [":key2", "some_val"]}, - %{ - "tuple" => [ - ":key3", - %{ - ":max_expiration" => 31_536_000, - ":max_option_chars" => 200, - ":max_options" => 20, - ":min_expiration" => 0, - "nested" => %{ - ":max_expiration" => 31_536_000, - ":max_option_chars" => 200, - ":max_options" => 20, - ":min_expiration" => 0 - } - } - ] - } - ], - "db" => [":key2", ":key3"] - } - ] - } - end - - test "value as map", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => %{"key" => "some_val"} - } - ] - }) - - assert json_response(conn, 200) == - %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":key1", - "value" => %{"key" => "some_val"}, - "db" => [":key1"] - } - ] - } - end - - test "queues key as atom", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - "group" => ":oban", - "key" => ":queues", - "value" => [ - %{"tuple" => [":federator_incoming", 50]}, - %{"tuple" => [":federator_outgoing", 50]}, - %{"tuple" => [":web_push", 50]}, - %{"tuple" => [":mailer", 10]}, - %{"tuple" => [":transmogrifier", 20]}, - %{"tuple" => [":scheduled_activities", 10]}, - %{"tuple" => [":background", 5]} - ] - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":oban", - "key" => ":queues", - "value" => [ - %{"tuple" => [":federator_incoming", 50]}, - %{"tuple" => [":federator_outgoing", 50]}, - %{"tuple" => [":web_push", 50]}, - %{"tuple" => [":mailer", 10]}, - %{"tuple" => [":transmogrifier", 20]}, - %{"tuple" => [":scheduled_activities", 10]}, - %{"tuple" => [":background", 5]} - ], - "db" => [ - ":federator_incoming", - ":federator_outgoing", - ":web_push", - ":mailer", - ":transmogrifier", - ":scheduled_activities", - ":background" - ] - } - ] - } - end - - test "delete part of settings by atom subkeys", %{conn: conn} do - config = - insert(:config, - key: ":keyaa1", - value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") - ) - - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: config.group, - key: config.key, - subkeys: [":subkey1", ":subkey3"], - delete: true - } - ] - }) - - assert json_response(conn, 200) == %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":keyaa1", - "value" => [%{"tuple" => [":subkey2", "val2"]}], - "db" => [":subkey2"] - } - ] - } - end - - test "proxy tuple localhost", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value - assert ":proxy_url" in db - end - - test "proxy tuple domain", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value - assert ":proxy_url" in db - end - - test "proxy tuple ip", %{conn: conn} do - conn = - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{ - group: ":pleroma", - key: ":http", - value: [ - %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} - ] - } - ] - }) - - assert %{ - "configs" => [ - %{ - "group" => ":pleroma", - "key" => ":http", - "value" => value, - "db" => db - } - ] - } = json_response(conn, 200) - - assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value - assert ":proxy_url" in db - end - - @tag capture_log: true - test "doesn't set keys not in the whitelist", %{conn: conn} do - clear_config(:database_config_whitelist, [ - {:pleroma, :key1}, - {:pleroma, :key2}, - {:pleroma, Pleroma.Captcha.NotReal}, - {:not_real} - ]) - - post(conn, "/api/pleroma/admin/config", %{ - configs: [ - %{group: ":pleroma", key: ":key1", value: "value1"}, - %{group: ":pleroma", key: ":key2", value: "value2"}, - %{group: ":pleroma", key: ":key3", value: "value3"}, - %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, - %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, - %{group: ":not_real", key: ":anything", value: "value6"} - ] - }) - - assert Application.get_env(:pleroma, :key1) == "value1" - assert Application.get_env(:pleroma, :key2) == "value2" - assert Application.get_env(:pleroma, :key3) == nil - assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil - assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" - assert Application.get_env(:not_real, :anything) == "value6" - end - end - describe "GET /api/pleroma/admin/restart" do setup do: clear_config(:configurable_from_database, true) @@ -3481,56 +2311,6 @@ test "it deletes the note", %{conn: conn, report_id: report_id} do end end - describe "GET /api/pleroma/admin/config/descriptions" do - test "structure", %{conn: conn} do - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") - - assert [child | _others] = json_response(conn, 200) - - assert child["children"] - assert child["key"] - assert String.starts_with?(child["group"], ":") - assert child["description"] - end - - test "filters by database configuration whitelist", %{conn: conn} do - clear_config(:database_config_whitelist, [ - {:pleroma, :instance}, - {:pleroma, :activitypub}, - {:pleroma, Pleroma.Upload}, - {:esshd} - ]) - - admin = insert(:user, is_admin: true) - - conn = - assign(conn, :user, admin) - |> get("/api/pleroma/admin/config/descriptions") - - children = json_response(conn, 200) - - assert length(children) == 4 - - assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 - - instance = Enum.find(children, fn c -> c["key"] == ":instance" end) - assert instance["children"] - - activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) - assert activitypub["children"] - - web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) - assert web_endpoint["children"] - - esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) - assert esshd["children"] - end - end - describe "/api/pleroma/admin/stats" do test "status visibility count", %{conn: conn} do admin = insert(:user, is_admin: true) diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs new file mode 100644 index 000000000..9bc6fd91c --- /dev/null +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -0,0 +1,1244 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ConfigControllerTest do + use Pleroma.Web.ConnCase, async: true + + import ExUnit.CaptureLog + import Pleroma.Factory + + alias Pleroma.Config + alias Pleroma.ConfigDB + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/config" do + setup do: clear_config(:configurable_from_database, true) + + test "when configuration from database is off", %{conn: conn} do + Config.put(:configurable_from_database, false) + conn = get(conn, "/api/pleroma/admin/config") + + assert json_response(conn, 400) == + %{ + "error" => "To use this endpoint you need to enable configuration from database." + } + end + + test "with settings only in db", %{conn: conn} do + config1 = insert(:config) + config2 = insert(:config) + + conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) + + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => key1, + "value" => _ + }, + %{ + "group" => ":pleroma", + "key" => key2, + "value" => _ + } + ] + } = json_response(conn, 200) + + assert key1 == config1.key + assert key2 == config2.key + end + + test "db is added to settings that are in db", %{conn: conn} do + _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + [instance_config] = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key == ":instance" + end) + + assert instance_config["db"] == [":name"] + end + + test "merged default setting with db settings", %{conn: conn} do + config1 = insert(:config) + config2 = insert(:config) + + config3 = + insert(:config, + value: ConfigDB.to_binary(k1: :v1, k2: :v2) + ) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert length(configs) > 3 + + received_configs = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key in [config1.key, config2.key, config3.key] + end) + + assert length(received_configs) == 3 + + db_keys = + config3.value + |> ConfigDB.from_binary() + |> Keyword.keys() + |> ConfigDB.convert() + + Enum.each(received_configs, fn %{"value" => value, "db" => db} -> + assert db in [[config1.key], [config2.key], db_keys] + + assert value in [ + ConfigDB.from_binary_with_convert(config1.value), + ConfigDB.from_binary_with_convert(config2.value), + ConfigDB.from_binary_with_convert(config3.value) + ] + end) + end + + test "subkeys with full update right merge", %{conn: conn} do + config1 = + insert(:config, + key: ":emoji", + value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) + ) + + config2 = + insert(:config, + key: ":assets", + value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) + ) + + %{"configs" => configs} = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + vals = + Enum.filter(configs, fn %{"group" => group, "key" => key} -> + group == ":pleroma" and key in [config1.key, config2.key] + end) + + emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) + assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) + + emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) + assets_val = ConfigDB.transform_with_out_binary(assets["value"]) + + assert emoji_val[:groups] == [a: 1, b: 2] + assert assets_val[:mascots] == [a: 1, b: 2] + end + end + + test "POST /api/pleroma/admin/config error", %{conn: conn} do + conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) + + assert json_response(conn, 400) == + %{"error" => "To use this endpoint you need to enable configuration from database."} + end + + describe "POST /api/pleroma/admin/config" do + setup do + http = Application.get_env(:pleroma, :http) + + on_exit(fn -> + Application.delete_env(:pleroma, :key1) + Application.delete_env(:pleroma, :key2) + Application.delete_env(:pleroma, :key3) + Application.delete_env(:pleroma, :key4) + Application.delete_env(:pleroma, :keyaa1) + Application.delete_env(:pleroma, :keyaa2) + Application.delete_env(:pleroma, Pleroma.Web.Endpoint.NotReal) + Application.delete_env(:pleroma, Pleroma.Captcha.NotReal) + Application.put_env(:pleroma, :http, http) + Application.put_env(:tesla, :adapter, Tesla.Mock) + Restarter.Pleroma.refresh() + end) + end + + setup do: clear_config(:configurable_from_database, true) + + @tag capture_log: true + test "create new config setting in db", %{conn: conn} do + ueberauth = Application.get_env(:ueberauth, Ueberauth) + on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{ + group: ":ueberauth", + key: "Ueberauth", + value: [%{"tuple" => [":consumer_secret", "aaaa"]}] + }, + %{ + group: ":pleroma", + key: ":key2", + value: %{ + ":nested_1" => "nested_value1", + ":nested_2" => [ + %{":nested_22" => "nested_value222"}, + %{":nested_33" => %{":nested_44" => "nested_444"}} + ] + } + }, + %{ + group: ":pleroma", + key: ":key3", + value: [ + %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, + %{"nested_4" => true} + ] + }, + %{ + group: ":pleroma", + key: ":key4", + value: %{":nested_5" => ":upload", "endpoint" => "https://example.com"} + }, + %{ + group: ":idna", + key: ":key5", + value: %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]} + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => "value1", + "db" => [":key1"] + }, + %{ + "group" => ":ueberauth", + "key" => "Ueberauth", + "value" => [%{"tuple" => [":consumer_secret", "aaaa"]}], + "db" => [":consumer_secret"] + }, + %{ + "group" => ":pleroma", + "key" => ":key2", + "value" => %{ + ":nested_1" => "nested_value1", + ":nested_2" => [ + %{":nested_22" => "nested_value222"}, + %{":nested_33" => %{":nested_44" => "nested_444"}} + ] + }, + "db" => [":key2"] + }, + %{ + "group" => ":pleroma", + "key" => ":key3", + "value" => [ + %{"nested_3" => ":nested_3", "nested_33" => "nested_33"}, + %{"nested_4" => true} + ], + "db" => [":key3"] + }, + %{ + "group" => ":pleroma", + "key" => ":key4", + "value" => %{"endpoint" => "https://example.com", ":nested_5" => ":upload"}, + "db" => [":key4"] + }, + %{ + "group" => ":idna", + "key" => ":key5", + "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, + "db" => [":key5"] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == "value1" + + assert Application.get_env(:pleroma, :key2) == %{ + nested_1: "nested_value1", + nested_2: [ + %{nested_22: "nested_value222"}, + %{nested_33: %{nested_44: "nested_444"}} + ] + } + + assert Application.get_env(:pleroma, :key3) == [ + %{"nested_3" => :nested_3, "nested_33" => "nested_33"}, + %{"nested_4" => true} + ] + + assert Application.get_env(:pleroma, :key4) == %{ + "endpoint" => "https://example.com", + nested_5: :upload + } + + assert Application.get_env(:idna, :key5) == {"string", Pleroma.Captcha.NotReal, []} + end + + test "save configs setting without explicit key", %{conn: conn} do + level = Application.get_env(:quack, :level) + meta = Application.get_env(:quack, :meta) + webhook_url = Application.get_env(:quack, :webhook_url) + + on_exit(fn -> + Application.put_env(:quack, :level, level) + Application.put_env(:quack, :meta, meta) + Application.put_env(:quack, :webhook_url, webhook_url) + end) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":quack", + key: ":level", + value: ":info" + }, + %{ + group: ":quack", + key: ":meta", + value: [":none"] + }, + %{ + group: ":quack", + key: ":webhook_url", + value: "https://hooks.slack.com/services/KEY" + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":quack", + "key" => ":level", + "value" => ":info", + "db" => [":level"] + }, + %{ + "group" => ":quack", + "key" => ":meta", + "value" => [":none"], + "db" => [":meta"] + }, + %{ + "group" => ":quack", + "key" => ":webhook_url", + "value" => "https://hooks.slack.com/services/KEY", + "db" => [":webhook_url"] + } + ] + } + + assert Application.get_env(:quack, :level) == :info + assert Application.get_env(:quack, :meta) == [:none] + assert Application.get_env(:quack, :webhook_url) == "https://hooks.slack.com/services/KEY" + end + + test "saving config with partial update", %{conn: conn} do + config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key2", 2]}, + %{"tuple" => [":key3", 3]} + ], + "db" => [":key1", ":key2", ":key3"] + } + ] + } + end + + test "saving config which need pleroma reboot", %{conn: conn} do + chat = Config.get(:chat) + on_exit(fn -> Config.put(:chat, chat) end) + + assert post( + conn, + "/api/pleroma/admin/config", + %{ + configs: [ + %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + ] + } + ) + |> json_response(200) == %{ + "configs" => [ + %{ + "db" => [":enabled"], + "group" => ":pleroma", + "key" => ":chat", + "value" => [%{"tuple" => [":enabled", true]}] + } + ], + "need_reboot" => true + } + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] + + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] == false + end + + test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do + chat = Config.get(:chat) + on_exit(fn -> Config.put(:chat, chat) end) + + assert post( + conn, + "/api/pleroma/admin/config", + %{ + configs: [ + %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + ] + } + ) + |> json_response(200) == %{ + "configs" => [ + %{ + "db" => [":enabled"], + "group" => ":pleroma", + "key" => ":chat", + "value" => [%{"tuple" => [":enabled", true]}] + } + ], + "need_reboot" => true + } + + assert post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} + ] + }) + |> json_response(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key3", 3]} + ], + "db" => [":key3"] + } + ], + "need_reboot" => true + } + + capture_log(fn -> + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + end) =~ "pleroma restarted" + + configs = + conn + |> get("/api/pleroma/admin/config") + |> json_response(200) + + assert configs["need_reboot"] == false + end + + test "saving config with nested merge", %{conn: conn} do + config = + insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [ + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key1", 1]}, + %{"tuple" => [":key3", 3]}, + %{ + "tuple" => [ + ":key2", + [ + %{"tuple" => [":k1", 1]}, + %{"tuple" => [":k2", 1]}, + %{"tuple" => [":k3", 3]} + ] + ] + } + ], + "db" => [":key1", ":key3", ":key2"] + } + ] + } + end + + test "saving special atoms", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{ + "tuple" => [ + ":ssl_options", + [%{"tuple" => [":versions", [":tlsv1", ":tlsv1.1", ":tlsv1.2"]]}] + ] + } + ], + "db" => [":ssl_options"] + } + ] + } + + assert Application.get_env(:pleroma, :key1) == [ + ssl_options: [versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"]] + ] + end + + test "saving full setting if value is in full_key_update list", %{conn: conn} do + backends = Application.get_env(:logger, :backends) + on_exit(fn -> Application.put_env(:logger, :backends, backends) end) + + config = + insert(:config, + group: ":logger", + key: ":backends", + value: :erlang.term_to_binary([]) + ) + + Pleroma.Config.TransferTask.load_and_update_env([], false) + + assert Application.get_env(:logger, :backends) == [] + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + value: [":console"] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":logger", + "key" => ":backends", + "value" => [ + ":console" + ], + "db" => [":backends"] + } + ] + } + + assert Application.get_env(:logger, :backends) == [ + :console + ] + end + + test "saving full setting if value is not keyword", %{conn: conn} do + config = + insert(:config, + group: ":tesla", + key: ":adapter", + value: :erlang.term_to_binary(Tesla.Adapter.Hackey) + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":tesla", + "key" => ":adapter", + "value" => "Tesla.Adapter.Httpc", + "db" => [":adapter"] + } + ] + } + end + + test "update config setting & delete with fallback to default value", %{ + conn: conn, + admin: admin, + token: token + } do + ueberauth = Application.get_env(:ueberauth, Ueberauth) + config1 = insert(:config, key: ":keyaa1") + config2 = insert(:config, key: ":keyaa2") + + config3 = + insert(:config, + group: ":ueberauth", + key: "Ueberauth" + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: config1.group, key: config1.key, value: "another_value"}, + %{group: config2.group, key: config2.key, value: "another_value"} + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => config1.key, + "value" => "another_value", + "db" => [":keyaa1"] + }, + %{ + "group" => ":pleroma", + "key" => config2.key, + "value" => "another_value", + "db" => [":keyaa2"] + } + ] + } + + assert Application.get_env(:pleroma, :keyaa1) == "another_value" + assert Application.get_env(:pleroma, :keyaa2) == "another_value" + assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{group: config2.group, key: config2.key, delete: true}, + %{ + group: ":ueberauth", + key: "Ueberauth", + delete: true + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [] + } + + assert Application.get_env(:ueberauth, Ueberauth) == ueberauth + refute Keyword.has_key?(Application.get_all_env(:pleroma), :keyaa2) + end + + test "common config example", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Captcha.NotReal", + "value" => [ + %{"tuple" => [":enabled", false]}, + %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, + %{"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"]}, + %{"tuple" => [":regex4", "~r/https:\/\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} + ] + } + ] + }) + + assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Captcha.NotReal", + "value" => [ + %{"tuple" => [":enabled", false]}, + %{"tuple" => [":method", "Pleroma.Captcha.Kocaptcha"]}, + %{"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"]}, + %{"tuple" => [":regex4", "~r/https:\\/\\/example.com/s"]}, + %{"tuple" => [":name", "Pleroma"]} + ], + "db" => [ + ":enabled", + ":method", + ":seconds_valid", + ":path", + ":key1", + ":partial_chain", + ":regex1", + ":regex2", + ":regex3", + ":regex4", + ":name" + ] + } + ] + } + end + + test "tuples with more than two values", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Web.Endpoint.NotReal", + "value" => [ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key2", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } + ] + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Web.Endpoint.NotReal", + "value" => [ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key2", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } + ] + ] + } + ], + "db" => [":http"] + } + ] + } + end + + test "settings with nesting map", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key2", "some_val"]}, + %{ + "tuple" => [ + ":key3", + %{ + ":max_options" => 20, + ":max_option_chars" => 200, + ":min_expiration" => 0, + ":max_expiration" => 31_536_000, + "nested" => %{ + ":max_options" => 20, + ":max_option_chars" => 200, + ":min_expiration" => 0, + ":max_expiration" => 31_536_000 + } + } + ] + } + ] + } + ] + }) + + assert json_response(conn, 200) == + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => [ + %{"tuple" => [":key2", "some_val"]}, + %{ + "tuple" => [ + ":key3", + %{ + ":max_expiration" => 31_536_000, + ":max_option_chars" => 200, + ":max_options" => 20, + ":min_expiration" => 0, + "nested" => %{ + ":max_expiration" => 31_536_000, + ":max_option_chars" => 200, + ":max_options" => 20, + ":min_expiration" => 0 + } + } + ] + } + ], + "db" => [":key2", ":key3"] + } + ] + } + end + + test "value as map", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => %{"key" => "some_val"} + } + ] + }) + + assert json_response(conn, 200) == + %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":key1", + "value" => %{"key" => "some_val"}, + "db" => [":key1"] + } + ] + } + end + + test "queues key as atom", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + "group" => ":oban", + "key" => ":queues", + "value" => [ + %{"tuple" => [":federator_incoming", 50]}, + %{"tuple" => [":federator_outgoing", 50]}, + %{"tuple" => [":web_push", 50]}, + %{"tuple" => [":mailer", 10]}, + %{"tuple" => [":transmogrifier", 20]}, + %{"tuple" => [":scheduled_activities", 10]}, + %{"tuple" => [":background", 5]} + ] + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":oban", + "key" => ":queues", + "value" => [ + %{"tuple" => [":federator_incoming", 50]}, + %{"tuple" => [":federator_outgoing", 50]}, + %{"tuple" => [":web_push", 50]}, + %{"tuple" => [":mailer", 10]}, + %{"tuple" => [":transmogrifier", 20]}, + %{"tuple" => [":scheduled_activities", 10]}, + %{"tuple" => [":background", 5]} + ], + "db" => [ + ":federator_incoming", + ":federator_outgoing", + ":web_push", + ":mailer", + ":transmogrifier", + ":scheduled_activities", + ":background" + ] + } + ] + } + end + + test "delete part of settings by atom subkeys", %{conn: conn} do + config = + insert(:config, + key: ":keyaa1", + value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") + ) + + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: config.group, + key: config.key, + subkeys: [":subkey1", ":subkey3"], + delete: true + } + ] + }) + + assert json_response(conn, 200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":keyaa1", + "value" => [%{"tuple" => [":subkey2", "val2"]}], + "db" => [":subkey2"] + } + ] + } + end + + test "proxy tuple localhost", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value + assert ":proxy_url" in db + end + + test "proxy tuple domain", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value + assert ":proxy_url" in db + end + + test "proxy tuple ip", %{conn: conn} do + conn = + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: ":http", + value: [ + %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} + ] + } + ] + }) + + assert %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => ":http", + "value" => value, + "db" => db + } + ] + } = json_response(conn, 200) + + assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value + assert ":proxy_url" in db + end + + @tag capture_log: true + test "doesn't set keys not in the whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :key1}, + {:pleroma, :key2}, + {:pleroma, Pleroma.Captcha.NotReal}, + {:not_real} + ]) + + post(conn, "/api/pleroma/admin/config", %{ + configs: [ + %{group: ":pleroma", key: ":key1", value: "value1"}, + %{group: ":pleroma", key: ":key2", value: "value2"}, + %{group: ":pleroma", key: ":key3", value: "value3"}, + %{group: ":pleroma", key: "Pleroma.Web.Endpoint.NotReal", value: "value4"}, + %{group: ":pleroma", key: "Pleroma.Captcha.NotReal", value: "value5"}, + %{group: ":not_real", key: ":anything", value: "value6"} + ] + }) + + assert Application.get_env(:pleroma, :key1) == "value1" + assert Application.get_env(:pleroma, :key2) == "value2" + assert Application.get_env(:pleroma, :key3) == nil + assert Application.get_env(:pleroma, Pleroma.Web.Endpoint.NotReal) == nil + assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" + assert Application.get_env(:not_real, :anything) == "value6" + end + end + + describe "GET /api/pleroma/admin/config/descriptions" do + test "structure", %{conn: conn} do + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + assert [child | _others] = json_response(conn, 200) + + assert child["children"] + assert child["key"] + assert String.starts_with?(child["group"], ":") + assert child["description"] + end + + test "filters by database configuration whitelist", %{conn: conn} do + clear_config(:database_config_whitelist, [ + {:pleroma, :instance}, + {:pleroma, :activitypub}, + {:pleroma, Pleroma.Upload}, + {:esshd} + ]) + + admin = insert(:user, is_admin: true) + + conn = + assign(conn, :user, admin) + |> get("/api/pleroma/admin/config/descriptions") + + children = json_response(conn, 200) + + assert length(children) == 4 + + assert Enum.count(children, fn c -> c["group"] == ":pleroma" end) == 3 + + instance = Enum.find(children, fn c -> c["key"] == ":instance" end) + assert instance["children"] + + activitypub = Enum.find(children, fn c -> c["key"] == ":activitypub" end) + assert activitypub["children"] + + web_endpoint = Enum.find(children, fn c -> c["key"] == "Pleroma.Upload" end) + assert web_endpoint["children"] + + esshd = Enum.find(children, fn c -> c["group"] == ":esshd" end) + assert esshd["children"] + end + end +end -- cgit v1.2.3 From 06f20e918129b1f434783b64d59b5ae6b4b4ed51 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 28 May 2020 23:11:12 +0400 Subject: Add OpenApi spec to AdminAPI.ConfigController --- .../web/admin_api/controllers/config_controller.ex | 21 +-- .../api_spec/operations/admin/config_operation.ex | 142 ++++++++++++++++++ .../controllers/config_controller_test.exs | 164 +++++++++++++-------- 3 files changed, 259 insertions(+), 68 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/admin/config_operation.ex diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index 742980976..e221d9418 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -11,23 +11,26 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do @descriptions Pleroma.Docs.JSON.compile() + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) + plug( OAuthScopesPlug, %{scopes: ["read"], admin: true} when action in [:show, :descriptions] ) - plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) - action_fallback(Pleroma.Web.AdminAPI.FallbackController) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ConfigOperation + def descriptions(conn, _params) do descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) json(conn, descriptions) end - def show(conn, %{"only_db" => true}) do + def show(conn, %{only_db: true}) do with :ok <- configurable_from_database() do configs = Pleroma.Repo.all(ConfigDB) render(conn, "index.json", %{configs: configs}) @@ -73,16 +76,16 @@ def show(conn, _params) do end end - def update(conn, %{"configs" => configs}) do + def update(%{body_params: %{configs: configs}} = conn, _) do with :ok <- configurable_from_database() do results = configs |> Enum.filter(&whitelisted_config?/1) |> Enum.map(fn - %{"group" => group, "key" => key, "delete" => true} = params -> - ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) + %{group: group, key: key, delete: true} = params -> + ConfigDB.delete(%{group: group, key: key, subkeys: params[:subkeys]}) - %{"group" => group, "key" => key, "value" => value} -> + %{group: group, key: key, value: value} -> ConfigDB.update_or_create(%{group: group, key: key, value: value}) end) |> Enum.reject(fn {result, _} -> result == :error end) @@ -140,11 +143,11 @@ defp whitelisted_config?(group, key) do end end - defp whitelisted_config?(%{"group" => group, "key" => key}) do + defp whitelisted_config?(%{group: group, key: key}) do whitelisted_config?(group, key) end - defp whitelisted_config?(%{:group => group} = config) do + defp whitelisted_config?(%{group: group} = config) do whitelisted_config?(group, config[:key]) end end diff --git a/lib/pleroma/web/api_spec/operations/admin/config_operation.ex b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex new file mode 100644 index 000000000..7b38a2ef4 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ConfigOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Admin", "Config"], + summary: "Get list of merged default settings with saved in database", + operationId: "AdminAPI.ConfigController.show", + parameters: [ + Operation.parameter( + :only_db, + :query, + %Schema{type: :boolean, default: false}, + "Get only saved in database settings" + ) + ], + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => Operation.response("Config", "application/json", config_response()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Config"], + summary: "Update config settings", + operationId: "AdminAPI.ConfigController.update", + security: [%{"oAuth" => ["write"]}], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + configs: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + value: any(), + delete: %Schema{type: :boolean}, + subkeys: %Schema{type: :array, items: %Schema{type: :string}} + } + } + } + } + }), + responses: %{ + 200 => Operation.response("Config", "application/json", config_response()), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + def descriptions_operation do + %Operation{ + tags: ["Admin", "Config"], + summary: "Get JSON with config descriptions.", + operationId: "AdminAPI.ConfigController.descriptions", + security: [%{"oAuth" => ["read"]}], + responses: %{ + 200 => + Operation.response("Config Descriptions", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + type: %Schema{oneOf: [%Schema{type: :string}, %Schema{type: :array}]}, + description: %Schema{type: :string}, + children: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + key: %Schema{type: :string}, + type: %Schema{oneOf: [%Schema{type: :string}, %Schema{type: :array}]}, + description: %Schema{type: :string}, + suggestions: %Schema{type: :array} + } + } + } + } + } + }), + 400 => Operation.response("Bad Request", "application/json", ApiError) + } + } + end + + defp any do + %Schema{ + oneOf: [ + %Schema{type: :array}, + %Schema{type: :object}, + %Schema{type: :string}, + %Schema{type: :integer}, + %Schema{type: :boolean} + ] + } + end + + defp config_response do + %Schema{ + type: :object, + properties: %{ + configs: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + group: %Schema{type: :string}, + key: %Schema{type: :string}, + value: any() + } + } + }, + need_reboot: %Schema{ + type: :boolean, + description: + "If `need_reboot` is `true`, instance must be restarted, so reboot time settings can take effect" + } + } + } + end +end diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs index 9bc6fd91c..780de8d18 100644 --- a/test/web/admin_api/controllers/config_controller_test.exs +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -30,7 +30,7 @@ test "when configuration from database is off", %{conn: conn} do Config.put(:configurable_from_database, false) conn = get(conn, "/api/pleroma/admin/config") - assert json_response(conn, 400) == + assert json_response_and_validate_schema(conn, 400) == %{ "error" => "To use this endpoint you need to enable configuration from database." } @@ -40,7 +40,7 @@ test "with settings only in db", %{conn: conn} do config1 = insert(:config) config2 = insert(:config) - conn = get(conn, "/api/pleroma/admin/config", %{"only_db" => true}) + conn = get(conn, "/api/pleroma/admin/config?only_db=true") %{ "configs" => [ @@ -55,7 +55,7 @@ test "with settings only in db", %{conn: conn} do "value" => _ } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert key1 == config1.key assert key2 == config2.key @@ -67,7 +67,7 @@ test "db is added to settings that are in db", %{conn: conn} do %{"configs" => configs} = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) [instance_config] = Enum.filter(configs, fn %{"group" => group, "key" => key} -> @@ -89,7 +89,7 @@ test "merged default setting with db settings", %{conn: conn} do %{"configs" => configs} = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert length(configs) > 3 @@ -133,7 +133,7 @@ test "subkeys with full update right merge", %{conn: conn} do %{"configs" => configs} = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) vals = Enum.filter(configs, fn %{"group" => group, "key" => key} -> @@ -152,9 +152,12 @@ test "subkeys with full update right merge", %{conn: conn} do end test "POST /api/pleroma/admin/config error", %{conn: conn} do - conn = post(conn, "/api/pleroma/admin/config", %{"configs" => []}) + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => []}) - assert json_response(conn, 400) == + assert json_response_and_validate_schema(conn, 400) == %{"error" => "To use this endpoint you need to enable configuration from database."} end @@ -185,7 +188,9 @@ test "create new config setting in db", %{conn: conn} do on_exit(fn -> Application.put_env(:ueberauth, Ueberauth, ueberauth) end) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: ":pleroma", key: ":key1", value: "value1"}, %{ @@ -225,7 +230,7 @@ test "create new config setting in db", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -310,7 +315,9 @@ test "save configs setting without explicit key", %{conn: conn} do end) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":quack", @@ -330,7 +337,7 @@ test "save configs setting without explicit key", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":quack", @@ -362,13 +369,15 @@ test "saving config with partial update", %{conn: conn} do config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -388,8 +397,9 @@ test "saving config which need pleroma reboot", %{conn: conn} do chat = Config.get(:chat) on_exit(fn -> Config.put(:chat, chat) end) - assert post( - conn, + assert conn + |> put_req_header("content-type", "application/json") + |> post( "/api/pleroma/admin/config", %{ configs: [ @@ -397,7 +407,7 @@ test "saving config which need pleroma reboot", %{conn: conn} do ] } ) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "configs" => [ %{ "db" => [":enabled"], @@ -412,18 +422,19 @@ test "saving config which need pleroma reboot", %{conn: conn} do configs = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert configs["need_reboot"] capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == + %{} end) =~ "pleroma restarted" configs = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert configs["need_reboot"] == false end @@ -432,8 +443,9 @@ test "update setting which need reboot, don't change reboot flag until reboot", chat = Config.get(:chat) on_exit(fn -> Config.put(:chat, chat) end) - assert post( - conn, + assert conn + |> put_req_header("content-type", "application/json") + |> post( "/api/pleroma/admin/config", %{ configs: [ @@ -441,7 +453,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", ] } ) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "configs" => [ %{ "db" => [":enabled"], @@ -453,12 +465,14 @@ test "update setting which need reboot, don't change reboot flag until reboot", "need_reboot" => true } - assert post(conn, "/api/pleroma/admin/config", %{ + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} ] }) - |> json_response(200) == %{ + |> json_response_and_validate_schema(200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -473,13 +487,14 @@ test "update setting which need reboot, don't change reboot flag until reboot", } capture_log(fn -> - assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == %{} + assert conn |> get("/api/pleroma/admin/restart") |> json_response(200) == + %{} end) =~ "pleroma restarted" configs = conn |> get("/api/pleroma/admin/config") - |> json_response(200) + |> json_response_and_validate_schema(200) assert configs["need_reboot"] == false end @@ -489,7 +504,9 @@ test "saving config with nested merge", %{conn: conn} do insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, @@ -510,7 +527,7 @@ test "saving config with nested merge", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -537,7 +554,9 @@ test "saving config with nested merge", %{conn: conn} do test "saving special atoms", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ "configs" => [ %{ "group" => ":pleroma", @@ -554,7 +573,7 @@ test "saving special atoms", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -593,7 +612,9 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do assert Application.get_env(:logger, :backends) == [] conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, @@ -603,7 +624,7 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":logger", @@ -630,13 +651,15 @@ test "saving full setting if value is not keyword", %{conn: conn} do ) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":tesla", @@ -664,14 +687,16 @@ test "update config setting & delete with fallback to default value", %{ ) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config1.group, key: config1.key, value: "another_value"}, %{group: config2.group, key: config2.key, value: "another_value"} ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -696,6 +721,7 @@ test "update config setting & delete with fallback to default value", %{ build_conn() |> assign(:user, admin) |> assign(:token, token) + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ %{group: config2.group, key: config2.key, delete: true}, @@ -707,7 +733,7 @@ test "update config setting & delete with fallback to default value", %{ ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [] } @@ -717,7 +743,9 @@ test "update config setting & delete with fallback to default value", %{ test "common config example", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -741,7 +769,7 @@ test "common config example", %{conn: conn} do assert Config.get([Pleroma.Captcha.NotReal, :name]) == "Pleroma" - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -779,7 +807,9 @@ test "common config example", %{conn: conn} do test "tuples with more than two values", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -843,7 +873,7 @@ test "tuples with more than two values", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -911,7 +941,9 @@ test "tuples with more than two values", %{conn: conn} do test "settings with nesting map", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -940,7 +972,7 @@ test "settings with nesting map", %{conn: conn} do ] }) - assert json_response(conn, 200) == + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ @@ -974,7 +1006,9 @@ test "settings with nesting map", %{conn: conn} do test "value as map", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":pleroma", @@ -984,7 +1018,7 @@ test "value as map", %{conn: conn} do ] }) - assert json_response(conn, 200) == + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ @@ -999,7 +1033,9 @@ test "value as map", %{conn: conn} do test "queues key as atom", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ "group" => ":oban", @@ -1017,7 +1053,7 @@ test "queues key as atom", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":oban", @@ -1053,7 +1089,9 @@ test "delete part of settings by atom subkeys", %{conn: conn} do ) conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: config.group, @@ -1064,7 +1102,7 @@ test "delete part of settings by atom subkeys", %{conn: conn} do ] }) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "configs" => [ %{ "group" => ":pleroma", @@ -1078,7 +1116,9 @@ test "delete part of settings by atom subkeys", %{conn: conn} do test "proxy tuple localhost", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":pleroma", @@ -1099,7 +1139,7 @@ test "proxy tuple localhost", %{conn: conn} do "db" => db } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}]} in value assert ":proxy_url" in db @@ -1107,7 +1147,9 @@ test "proxy tuple localhost", %{conn: conn} do test "proxy tuple domain", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":pleroma", @@ -1128,7 +1170,7 @@ test "proxy tuple domain", %{conn: conn} do "db" => db } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}]} in value assert ":proxy_url" in db @@ -1136,7 +1178,9 @@ test "proxy tuple domain", %{conn: conn} do test "proxy tuple ip", %{conn: conn} do conn = - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{ group: ":pleroma", @@ -1157,7 +1201,7 @@ test "proxy tuple ip", %{conn: conn} do "db" => db } ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) assert %{"tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}]} in value assert ":proxy_url" in db @@ -1172,7 +1216,9 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do {:not_real} ]) - post(conn, "/api/pleroma/admin/config", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ configs: [ %{group: ":pleroma", key: ":key1", value: "value1"}, %{group: ":pleroma", key: ":key2", value: "value2"}, @@ -1200,7 +1246,7 @@ test "structure", %{conn: conn} do assign(conn, :user, admin) |> get("/api/pleroma/admin/config/descriptions") - assert [child | _others] = json_response(conn, 200) + assert [child | _others] = json_response_and_validate_schema(conn, 200) assert child["children"] assert child["key"] @@ -1222,7 +1268,7 @@ test "filters by database configuration whitelist", %{conn: conn} do assign(conn, :user, admin) |> get("/api/pleroma/admin/config/descriptions") - children = json_response(conn, 200) + children = json_response_and_validate_schema(conn, 200) assert length(children) == 4 -- cgit v1.2.3 From d4b20c96c4030ebb5eb908dc6efcf45be7a8355d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 28 May 2020 15:34:11 -0500 Subject: Migrate old notification settings to new variants --- ...28160439_users_update_notification_settings.exs | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 priv/repo/migrations/20200528160439_users_update_notification_settings.exs diff --git a/priv/repo/migrations/20200528160439_users_update_notification_settings.exs b/priv/repo/migrations/20200528160439_users_update_notification_settings.exs new file mode 100644 index 000000000..561f7a2c4 --- /dev/null +++ b/priv/repo/migrations/20200528160439_users_update_notification_settings.exs @@ -0,0 +1,43 @@ +defmodule Pleroma.Repo.Migrations.UsersUpdateNotificationSettings do + use Ecto.Migration + + def up do + execute( + "UPDATE users SET notification_settings = notification_settings - 'followers' || jsonb_build_object('from_followers', notification_settings->'followers') +where notification_settings ? 'followers' +and local" + ) + + execute( + "UPDATE users SET notification_settings = notification_settings - 'follows' || jsonb_build_object('from_following', notification_settings->'follows') +where notification_settings ? 'follows' +and local" + ) + + execute( + "UPDATE users SET notification_settings = notification_settings - 'non_followers' || jsonb_build_object('from_strangers', notification_settings->'non_followers') +where notification_settings ? 'non_followers' +and local" + ) + end + + def down do + execute( + "UPDATE users SET notification_settings = notification_settings - 'from_followers' || jsonb_build_object('followers', notification_settings->'from_followers') +where notification_settings ? 'from_followers' +and local" + ) + + execute( + "UPDATE users SET notification_settings = notification_settings - 'from_following' || jsonb_build_object('follows', notification_settings->'from_following') +where notification_settings ? 'from_following' +and local" + ) + + execute( + "UPDATE users SET notification_settings = notification_settings - 'from_strangers' || jsonb_build_object('non_follows', notification_settings->'from_strangers') +where notification_settings ? 'from_strangers' +and local" + ) + end +end -- cgit v1.2.3 From 394258d548d20d1bea50166bc31f8e48462080dd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 28 May 2020 16:10:06 -0500 Subject: Docs: Attachement limitations in MastoAPI differences --- docs/API/differences_in_mastoapi_responses.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index e65fd5da4..434ade9a4 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -6,10 +6,6 @@ A Pleroma instance can be identified by " (compatible; Pleroma Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings -## Attachment cap - -Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting. - ## Timelines Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. @@ -32,12 +28,20 @@ Has these additional fields under the `pleroma` object: - `thread_muted`: true if the thread the post belongs to is muted - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. -## Attachments +## Media Attachments Has these additional fields under the `pleroma` object: - `mime_type`: mime type of the attachment. +### Attachment cap + +Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting. + +### Limitations + +Pleroma does not process remote images and therefore cannot include fields such as `meta` and `blurhash`. It does not support focal points or aspect ratios. The frontend is expected to handle it. + ## Accounts The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc. -- cgit v1.2.3 From 27180611dfffd064e65793f90c67dc16fff8ecc2 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 29 May 2020 12:32:48 +0300 Subject: HTTP Security plug: make starting csp string generation more readable --- lib/pleroma/plugs/http_security_plug.ex | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index df38d5022..2208d1d6c 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -49,17 +49,16 @@ defp headers do end end - @csp_start [ - "default-src 'none'", - "base-uri 'self'", - "frame-ancestors 'none'", - "style-src 'self' 'unsafe-inline'", - "font-src 'self'", - "manifest-src 'self'" - ] - |> Enum.join(";") - |> Kernel.<>(";") - |> List.wrap() + static_csp_rules = [ + "default-src 'none'", + "base-uri 'self'", + "frame-ancestors 'none'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self'", + "manifest-src 'self'" + ] + + @csp_start [Enum.join(static_csp_rules, ";") <> ";"] defp csp_string do scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme] -- cgit v1.2.3 From 9df5b1e6ae8357942ef85563eebaf583f1dbc19a Mon Sep 17 00:00:00 2001 From: kPherox Date: Tue, 26 May 2020 11:32:05 +0000 Subject: Don't make relay announce notification --- lib/pleroma/web/activity_pub/side_effects.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 7eae0c52c..60ab8733d 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -36,8 +37,10 @@ def handle(%{data: %{"type" => "Announce"}} = object, meta) do Utils.add_announce_to_object(object, announced_object) - Notification.create_notifications(object) - ActivityPub.stream_out(object) + if object.data["actor"] != Relay.relay_ap_id() do + Notification.create_notifications(object) + ActivityPub.stream_out(object) + end {:ok, object, meta} end -- cgit v1.2.3 From 228ff3760efb62d4452b3025fa9e78fed164655e Mon Sep 17 00:00:00 2001 From: kPherox Date: Wed, 27 May 2020 05:24:36 +0000 Subject: Use `User.is_internal_user?` instead --- lib/pleroma/web/activity_pub/side_effects.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 60ab8733d..fb6275450 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Utils def handle(object, meta \\ []) @@ -34,10 +33,11 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Stream out the announce def handle(%{data: %{"type" => "Announce"}} = object, meta) do announced_object = Object.get_by_ap_id(object.data["object"]) + user = User.get_cached_by_ap_id(object.data["actor"]) Utils.add_announce_to_object(object, announced_object) - if object.data["actor"] != Relay.relay_ap_id() do + if !User.is_internal_user?(user) do Notification.create_notifications(object) ActivityPub.stream_out(object) end -- cgit v1.2.3 From c86a88edec75223f650faa2bb442c09aa95ad694 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 15:24:41 +0200 Subject: Streamer: Add a chat message stream. --- lib/pleroma/web/streamer/streamer.ex | 23 ++++++++++++++++++++++- lib/pleroma/web/views/streamer_view.ex | 19 +++++++++++++++++++ test/web/streamer/streamer_test.exs | 24 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 49a400df7..331490a78 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility @@ -22,7 +23,7 @@ defmodule Pleroma.Web.Streamer do def registry, do: @registry @public_streams ["public", "public:local", "public:media", "public:local:media"] - @user_streams ["user", "user:notification", "direct"] + @user_streams ["user", "user:notification", "direct", "user:pleroma_chat"] @doc "Expands and authorizes a stream, and registers the process for streaming." @spec get_topic_and_add_socket(stream :: String.t(), User.t() | nil, Map.t() | nil) :: @@ -200,6 +201,26 @@ defp do_stream(topic, %Notification{} = item) end) end + defp do_stream(topic, %{data: %{"type" => "ChatMessage"}} = object) + when topic in ["user", "user:pleroma_chat"] do + recipients = [object.data["actor"] | object.data["to"]] + + topics = + %{ap_id: recipients, local: true} + |> Pleroma.User.Query.build() + |> Repo.all() + |> Enum.map(fn %{id: id} = user -> {user, "#{topic}:#{id}"} end) + + Enum.each(topics, fn {user, topic} -> + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _auth} -> + text = StreamerView.render("chat_update.json", object, user, recipients) + send(pid, {:text, text}) + end) + end) + end) + end + defp do_stream("user", item) do Logger.debug("Trying to push to users") diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 237b29ded..949e2ed37 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -6,11 +6,30 @@ defmodule Pleroma.Web.StreamerView do use Pleroma.Web, :view alias Pleroma.Activity + alias Pleroma.Chat alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.MastodonAPI.NotificationView + def render("chat_update.json", object, user, recipients) do + chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) + + representation = + Pleroma.Web.PleromaAPI.ChatMessageView.render( + "show.json", + %{object: object, chat: chat} + ) + + %{ + event: "pleroma:chat_update", + payload: + representation + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("update.json", %Activity{} = activity, %User{} = user) do %{ event: "update", diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 115ba4703..ffbff35ca 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -9,9 +9,11 @@ defmodule Pleroma.Web.StreamerTest do alias Pleroma.Conversation.Participation alias Pleroma.List + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Streamer + alias Pleroma.Web.StreamerView @moduletag needs_streamer: true, capture_log: true @@ -126,6 +128,28 @@ test "it sends notify to in the 'user:notification' stream", %{user: user, notif refute Streamer.filtered_by_user?(user, notify) end + test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} do + other_user = insert(:user) + + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + object = Object.normalize(create_activity, false) + Streamer.get_topic_and_add_socket("user:pleroma_chat", user) + Streamer.stream("user:pleroma_chat", object) + text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + assert_receive {:text, ^text} + end + + test "it sends chat messages to the 'user' stream", %{user: user} do + other_user = insert(:user) + + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + object = Object.normalize(create_activity, false) + Streamer.get_topic_and_add_socket("user", user) + Streamer.stream("user", object) + text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + assert_receive {:text, ^text} + end + test "it sends chat message notifications to the 'user:notification' stream", %{user: user} do other_user = insert(:user) -- cgit v1.2.3 From 863c02b25d1c6128fab88c33d2c4c3565a6c378f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 15:44:03 +0200 Subject: SideEffects: Stream out chat messages. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ test/web/activity_pub/side_effects_test.exs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index f0f0659c2..a4de8691e 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.Streamer def handle(object, meta \\ []) @@ -126,6 +127,7 @@ def handle(object, meta) do def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + Streamer.stream(["user", "user:pleroma_chat"], object) actor = User.get_cached_by_ap_id(object.data["actor"]) recipient = User.get_cached_by_ap_id(hd(object.data["to"])) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index fb4411c07..210ba6ef0 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -309,6 +309,27 @@ test "notifies the recipient" do assert Repo.get_by(Notification, user_id: recipient.id, activity_id: create_activity.id) end + test "it streams the created ChatMessage" do + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + with_mock Pleroma.Web.Streamer, [], stream: fn _, _ -> nil end do + {:ok, _create_activity, _meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + object = Object.normalize(create_activity, false) + + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], object)) + end + end + test "it creates a Chat for the local users and bumps the unread count, except for the author" do author = insert(:user, local: true) recipient = insert(:user, local: true) -- cgit v1.2.3 From 767ce8b8030562935ccd9f7c3d9ed83af0735db0 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:02:45 +0200 Subject: StreamerView: Actually send Chats, not ChatMessages. --- lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- lib/pleroma/web/views/streamer_view.ex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 08d5110c3..223b64987 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = Chat.last_message_for_chat(chat) + last_message = opts[:message] || Chat.last_message_for_chat(chat) %{ id: chat.id |> to_string(), diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 949e2ed37..5e953d770 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -16,9 +16,9 @@ def render("chat_update.json", object, user, recipients) do chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) representation = - Pleroma.Web.PleromaAPI.ChatMessageView.render( + Pleroma.Web.PleromaAPI.ChatView.render( "show.json", - %{object: object, chat: chat} + %{message: object, chat: chat} ) %{ -- cgit v1.2.3 From b08baf905b09ac49ed908eff8b43593d890612dd Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:03:55 +0200 Subject: Docs: Document streaming differences --- docs/API/differences_in_mastoapi_responses.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index e65fd5da4..a9d1f2f38 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -226,3 +226,7 @@ Has theses additional parameters (which are the same as in Pleroma-API): Has these additional fields under the `pleroma` object: - `unread_count`: contains number unread notifications + +## Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. -- cgit v1.2.3 From 3898dd69a69d3af9793f2e1d442b409c84b319a8 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:05:02 +0200 Subject: SideEffects: Ensure a chat is present before streaming something out. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a4de8691e..02296b210 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -127,7 +127,6 @@ def handle(object, meta) do def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do - Streamer.stream(["user", "user:pleroma_chat"], object) actor = User.get_cached_by_ap_id(object.data["actor"]) recipient = User.get_cached_by_ap_id(hd(object.data["to"])) @@ -142,6 +141,7 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do end end) + Streamer.stream(["user", "user:pleroma_chat"], object) {:ok, object, meta} end end -- cgit v1.2.3 From 32431ad1ee88d260b720fab05fce76eb75bfe107 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 29 May 2020 16:07:40 +0200 Subject: Docs: Also add the streaming docs to the Chat api doc. --- docs/API/chats.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 2e415e4da..2eca5adf6 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -220,3 +220,7 @@ There's a new `pleroma:chat_mention` notification, which has this form: "created_at": "somedate" } ``` + +### Streaming + +There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. -- cgit v1.2.3 From b3b367b894d1605202625310e7d8b1ed6ed5eb13 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 7 May 2020 21:52:45 +0200 Subject: Bugfix: Reuse Controller.Helper pagination for APC2S --- .../web/activity_pub/activity_pub_controller.ex | 3 ++ lib/pleroma/web/activity_pub/views/user_view.ex | 34 +++++--------- lib/pleroma/web/controller_helper.ex | 52 +++++++++++++--------- .../controllers/timeline_controller.ex | 4 +- .../activity_pub/activity_pub_controller_test.exs | 50 ++++++++++++++++++++- test/web/activity_pub/views/user_view_test.exs | 31 ------------- 6 files changed, 96 insertions(+), 78 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 28727d619..b624d4255 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.ControllerHelper alias Pleroma.Web.Endpoint alias Pleroma.Web.FederatingPlug alias Pleroma.Web.Federator @@ -251,6 +252,7 @@ def outbox( |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), iri: "#{user.ap_id}/outbox" }) end @@ -368,6 +370,7 @@ def read_inbox( |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), iri: "#{user.ap_id}/inbox" }) end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 34590b16d..4a02b09a1 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -213,34 +213,24 @@ def render("activity_collection.json", %{iri: iri}) do |> Map.merge(Utils.make_json_ld_header()) end - def render("activity_collection_page.json", %{activities: activities, iri: iri}) do - # this is sorted chronologically, so first activity is the newest (max) - {max_id, min_id, collection} = - if length(activities) > 0 do - { - Enum.at(activities, 0).id, - Enum.at(Enum.reverse(activities), 0).id, - Enum.map(activities, fn act -> - {:ok, data} = Transmogrifier.prepare_outgoing(act.data) - data - end) - } - else - { - 0, - 0, - [] - } - end + def render("activity_collection_page.json", %{ + activities: activities, + iri: iri, + pagination: pagination + }) do + collection = + Enum.map(activities, fn activity -> + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + data + end) %{ - "id" => "#{iri}?max_id=#{max_id}&page=true", "type" => "OrderedCollectionPage", "partOf" => iri, - "orderedItems" => collection, - "next" => "#{iri}?max_id=#{min_id}&page=true" + "orderedItems" => collection } |> Map.merge(Utils.make_json_ld_header()) + |> Map.merge(pagination) end defp maybe_put_total_items(map, false, _total), do: map diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5a1316a5f..2d35bb56c 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller + alias Pleroma.Pagination + # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] @@ -46,6 +48,16 @@ def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _activities, do: conn def add_link_headers(conn, activities, extra_params) do + case get_pagination_fields(conn, activities, extra_params) do + %{"next" => next_url, "prev" => prev_url} -> + put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") + + _ -> + conn + end + end + + def get_pagination_fields(conn, activities, extra_params \\ %{}) do case List.last(activities) do %{id: max_id} -> params = @@ -54,29 +66,29 @@ def add_link_headers(conn, activities, extra_params) do |> Map.drop(["since_id", "max_id", "min_id"]) |> Map.merge(extra_params) - limit = - params - |> Map.get("limit", "20") - |> String.to_integer() - min_id = - if length(activities) <= limit do - activities - |> List.first() - |> Map.get(:id) - else - activities - |> Enum.at(limit * -1) - |> Map.get(:id) - end - - next_url = current_url(conn, Map.merge(params, %{max_id: max_id})) - prev_url = current_url(conn, Map.merge(params, %{min_id: min_id})) - - put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"") + activities + |> List.first() + |> Map.get(:id) + + fields = %{ + "next" => current_url(conn, Map.put(params, :max_id, max_id)), + "prev" => current_url(conn, Map.put(params, :min_id, min_id)) + } + + # Generating an `id` without already present pagination keys would + # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` + # instead of the `q.id > ^min_id` and `q.id < ^max_id`. + # This is because we only have ids present inside of the page, while + # `min_id`, `since_id` and `max_id` requires to know one outside of it. + if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do + Map.put(fields, "id", current_url(conn, conn.params)) + else + fields + end _ -> - conn + %{} end end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 958567510..c852082a5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -51,10 +51,8 @@ def home(%{assigns: %{user: user}} = conn, params) do |> Map.put("reply_filtering_user", user) |> Map.put("user", user) - recipients = [user.ap_id | User.following(user)] - activities = - recipients + [user.ap_id | User.following(user)] |> ActivityPub.fetch_activities(params) |> Enum.reverse() diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 24edab41a..3f48553c9 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -804,17 +804,63 @@ test "it requires authentication", %{conn: conn} do end describe "GET /users/:nickname/outbox" do + test "it paginates correctly", %{conn: conn} do + user = insert(:user) + conn = assign(conn, :user, user) + outbox_endpoint = user.ap_id <> "/outbox" + + _posts = + for i <- 0..15 do + {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) + activity + end + + result = + conn + |> put_req_header("accept", "application/activity+json") + |> get(outbox_endpoint <> "?page=true") + |> json_response(200) + + result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end) + assert length(result["orderedItems"]) == 10 + assert length(result_ids) == 10 + assert result["next"] + assert String.starts_with?(result["next"], outbox_endpoint) + + result_next = + conn + |> put_req_header("accept", "application/activity+json") + |> get(result["next"]) + |> json_response(200) + + result_next_ids = Enum.map(result_next["orderedItems"], fn x -> x["id"] end) + assert length(result_next["orderedItems"]) == 6 + assert length(result_next_ids) == 6 + refute Enum.find(result_next_ids, fn x -> x in result_ids end) + refute Enum.find(result_ids, fn x -> x in result_next_ids end) + assert String.starts_with?(result["id"], outbox_endpoint) + + result_next_again = + conn + |> put_req_header("accept", "application/activity+json") + |> get(result_next["id"]) + |> json_response(200) + + assert result_next == result_next_again + end + test "it returns 200 even if there're no activities", %{conn: conn} do user = insert(:user) + outbox_endpoint = user.ap_id <> "/outbox" conn = conn |> assign(:user, user) |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox") + |> get(outbox_endpoint) result = json_response(conn, 200) - assert user.ap_id <> "/outbox" == result["id"] + assert outbox_endpoint == result["id"] end test "it returns a note activity in a collection", %{conn: conn} do diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index 20b0f223c..bec15a996 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -158,35 +158,4 @@ test "sets correct totalItems when follows are hidden but the follow counter is assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user}) end end - - test "activity collection page aginates correctly" do - user = insert(:user) - - posts = - for i <- 0..25 do - {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) - activity - end - - # outbox sorts chronologically, newest first, with ten per page - posts = Enum.reverse(posts) - - %{"next" => next_url} = - UserView.render("activity_collection_page.json", %{ - iri: "#{user.ap_id}/outbox", - activities: Enum.take(posts, 10) - }) - - next_id = Enum.at(posts, 9).id - assert next_url =~ next_id - - %{"next" => next_url} = - UserView.render("activity_collection_page.json", %{ - iri: "#{user.ap_id}/outbox", - activities: Enum.take(Enum.drop(posts, 10), 10) - }) - - next_id = Enum.at(posts, 19).id - assert next_url =~ next_id - end end -- cgit v1.2.3 From 2c18830d0dbd7f63cd20dcf5167254fede538930 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 8 May 2020 03:08:11 +0200 Subject: Bugfix: router: allow basic_auth for outbox --- lib/pleroma/web/router.ex | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..d65af23d9 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -571,13 +571,6 @@ defmodule Pleroma.Web.Router do get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end - scope "/", Pleroma.Web.ActivityPub do - # XXX: not really ostatus - pipe_through(:ostatus) - - get("/users/:nickname/outbox", ActivityPubController, :outbox) - end - pipeline :ap_service_actor do plug(:accepts, ["activity+json", "json"]) end @@ -602,6 +595,7 @@ defmodule Pleroma.Web.Router do get("/api/ap/whoami", ActivityPubController, :whoami) get("/users/:nickname/inbox", ActivityPubController, :read_inbox) + get("/users/:nickname/outbox", ActivityPubController, :outbox) post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) -- cgit v1.2.3 From a43b435c0ad8a1198241fbd18e1a5f1be830f4b5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 8 May 2020 03:05:56 +0200 Subject: AP C2S: allow limit & order on outbox & read_inbox --- .../web/activity_pub/activity_pub_controller.ex | 45 ++++++++++------------ lib/pleroma/web/controller_helper.ex | 2 +- .../activity_pub/activity_pub_controller_test.exs | 6 +-- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index b624d4255..5b8441384 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -231,28 +231,22 @@ def outbox( when page? in [true, "true"] do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do - activities = - if params["max_id"] do - ActivityPub.fetch_user_activities(user, for_user, %{ - "max_id" => params["max_id"], - # This is a hack because postgres generates inefficient queries when filtering by - # 'Answer', poll votes will be hidden by the visibility filter in this case anyway - "include_poll_votes" => true, - "limit" => 10 - }) - else - ActivityPub.fetch_user_activities(user, for_user, %{ - "limit" => 10, - "include_poll_votes" => true - }) - end + # "include_poll_votes" is a hack because postgres generates inefficient + # queries when filtering by 'Answer', poll votes will be hidden by the + # visibility filter in this case anyway + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("include_poll_votes", true) + + activities = ActivityPub.fetch_user_activities(user, for_user, params) conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), + pagination: ControllerHelper.get_pagination_fields(conn, activities), iri: "#{user.ap_id}/outbox" }) end @@ -355,22 +349,23 @@ def read_inbox( %{"nickname" => nickname, "page" => page?} = params ) when page? in [true, "true"] do + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("blocking_user", user) + |> Map.put("user", user) + activities = - if params["max_id"] do - ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{ - "max_id" => params["max_id"], - "limit" => 10 - }) - else - ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{"limit" => 10}) - end + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() conn |> put_resp_content_type("application/activity+json") |> put_view(UserView) |> render("activity_collection_page.json", %{ activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities, %{"limit" => "10"}), + pagination: ControllerHelper.get_pagination_fields(conn, activities), iri: "#{user.ap_id}/inbox" }) end diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 2d35bb56c..9e5444817 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -63,8 +63,8 @@ def get_pagination_fields(conn, activities, extra_params \\ %{}) do params = conn.params |> Map.drop(Map.keys(conn.path_params)) - |> Map.drop(["since_id", "max_id", "min_id"]) |> Map.merge(extra_params) + |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) min_id = activities diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 3f48553c9..e490a5744 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -810,7 +810,7 @@ test "it paginates correctly", %{conn: conn} do outbox_endpoint = user.ap_id <> "/outbox" _posts = - for i <- 0..15 do + for i <- 0..25 do {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) activity end @@ -822,8 +822,8 @@ test "it paginates correctly", %{conn: conn} do |> json_response(200) result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end) - assert length(result["orderedItems"]) == 10 - assert length(result_ids) == 10 + assert length(result["orderedItems"]) == 20 + assert length(result_ids) == 20 assert result["next"] assert String.starts_with?(result["next"], outbox_endpoint) -- cgit v1.2.3 From 1b586ff3aece21d277e40f95cc5c60fc15818a87 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 29 May 2020 10:17:06 -0500 Subject: Document new database vacuum tasks --- docs/administration/CLI_tasks/database.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/administration/CLI_tasks/database.md b/docs/administration/CLI_tasks/database.md index ff400c8ed..647f6f274 100644 --- a/docs/administration/CLI_tasks/database.md +++ b/docs/administration/CLI_tasks/database.md @@ -69,3 +69,32 @@ mix pleroma.database update_users_following_followers_counts ```sh tab="From Source" mix pleroma.database fix_likes_collections ``` + +## Vacuum the database + +### Analyze + +Running an `analyze` vacuum job can improve performance by updating statistics used by the query planner. **It is safe to cancel this.** + +```sh tab="OTP" +./bin/pleroma_ctl database vacuum analyze +``` + +```sh tab="From Source" +mix pleroma.database vacuum analyze +``` + +### Full + +Running a `full` vacuum job rebuilds your entire database by reading all of the data and rewriting it into smaller +and more compact files with an optimized layout. This process will take a long time and use additional disk space as +it builds the files side-by-side the existing database files. It can make your database faster and use less disk space, +but should only be run if necessary. **It is safe to cancel this.** + +```sh tab="OTP" +./bin/pleroma_ctl database vacuum full +``` + +```sh tab="From Source" +mix pleroma.database vacuum full +``` \ No newline at end of file -- cgit v1.2.3 From da1e31fae3f7a7e0063c3a6fb4315e1578d72daa Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 29 May 2020 17:17:02 +0200 Subject: http_security_plug.ex: Fix non-proxied media --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 2208d1d6c..4b926e867 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -75,7 +75,7 @@ defp csp_string do sources = get_proxy_and_attachment_sources() {[img_src, sources], [media_src, sources]} else - {img_src, media_src} + {img_src <> " https:", media_src <> " https:"} end connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] -- cgit v1.2.3 From de0e2628391ca039ac0d029c251136d53b6f8e63 Mon Sep 17 00:00:00 2001 From: kPherox Date: Mon, 25 May 2020 23:21:43 +0900 Subject: Fix argument error in streamer `Repo.exists` can't use `nil` as it is unsafe. Use parent object instead of activity because currently Announce activity's context is null. --- lib/pleroma/web/streamer/streamer.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 49a400df7..0cf41189b 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -136,7 +136,7 @@ def filtered_by_user?(%User{} = user, %Activity{} = item) do false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host), false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host), true <- thread_containment(item, user), - false <- CommonAPI.thread_muted?(user, item) do + false <- CommonAPI.thread_muted?(user, parent) do false else _ -> true -- cgit v1.2.3 From 9ca978494fee4be96ec9b6b93e74afe08dd05fcc Mon Sep 17 00:00:00 2001 From: kPherox Date: Fri, 29 May 2020 21:08:09 +0900 Subject: Add test for stream boosts of mastodon user --- test/web/streamer/streamer_test.exs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index cb4595bb6..4cf640ce8 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -112,6 +112,25 @@ test "it streams boosts of the user in the 'user' stream", %{user: user} do refute Streamer.filtered_by_user?(user, announce) end + test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do + Streamer.get_topic_and_add_socket("user", user) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) + + data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("actor", user.ap_id) + + {:ok, %Pleroma.Activity{data: data, local: false} = announce} = + Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data) + + assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} + refute Streamer.filtered_by_user?(user, announce) + end + test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do Streamer.get_topic_and_add_socket("user", user) Streamer.stream("user", notify) -- cgit v1.2.3 From d38f28870e7ba1c8c1b315d52e68a83fb1a68b6d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 29 May 2020 10:33:31 -0500 Subject: Add blob: to connect-src CSP --- CHANGELOG.md | 1 + lib/pleroma/plugs/http_security_plug.ex | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dabc2a85a..839bf90ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix follower/blocks import when nicknames starts with @ - Filtering of push notifications on activities from blocked domains - Resolving Peertube accounts with Webfinger +- `blob:` urls not being allowed by connect-src CSP ## [Unreleased (patch)] diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 2208d1d6c..41e3a31f4 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -78,7 +78,7 @@ defp csp_string do {img_src, media_src} end - connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] + connect_src = ["connect-src 'self' blob: ", static_url, ?\s, websocket_url] connect_src = if Pleroma.Config.get(:env) == :dev do -- cgit v1.2.3 From d67b302810c53d92ace7c347c77eecc10be6bcd6 Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 12 May 2020 11:08:00 -0400 Subject: preload data into index.html --- config/config.exs | 25 ++--- lib/pleroma/web/fallback_redirect_controller.ex | 82 ++++++++++----- lib/pleroma/web/nodeinfo/nodeinfo.ex | 130 ++++++++++++++++++++++++ lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 114 +++------------------ lib/pleroma/web/preload.ex | 30 ++++++ lib/pleroma/web/preload/instance.ex | 49 +++++++++ lib/pleroma/web/preload/provider.ex | 7 ++ lib/pleroma/web/preload/timelines.ex | 42 ++++++++ lib/pleroma/web/preload/user.ex | 25 +++++ lib/pleroma/web/router.ex | 2 +- test/plugs/instance_static_test.exs | 2 +- test/web/fallback_test.exs | 38 ++++--- test/web/preload/instance_test.exs | 37 +++++++ test/web/preload/timeline_test.exs | 74 ++++++++++++++ test/web/preload/user_test.exs | 33 ++++++ 15 files changed, 533 insertions(+), 157 deletions(-) create mode 100644 lib/pleroma/web/nodeinfo/nodeinfo.ex create mode 100644 lib/pleroma/web/preload.ex create mode 100644 lib/pleroma/web/preload/instance.ex create mode 100644 lib/pleroma/web/preload/provider.ex create mode 100644 lib/pleroma/web/preload/timelines.ex create mode 100644 lib/pleroma/web/preload/user.ex create mode 100644 test/web/preload/instance_test.exs create mode 100644 test/web/preload/timeline_test.exs create mode 100644 test/web/preload/user_test.exs diff --git a/config/config.exs b/config/config.exs index d15998715..1539b15c6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -240,18 +240,7 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false, - multi_factor_authentication: [ - totp: [ - # digits 6 or 8 - digits: 6, - period: 30 - ], - backup_codes: [ - number: 5, - length: 16 - ] - ] + cleanup_attachments: false config :pleroma, :feed, post_title: %{ @@ -360,8 +349,7 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [], - reject_deletes: [] + banner_removal: [] config :pleroma, :mrf_keyword, reject: [], @@ -427,6 +415,13 @@ ], unfurl_nsfw: false +config :pleroma, Pleroma.Web.Preload, + providers: [ + Pleroma.Web.Preload.Providers.Instance, + Pleroma.Web.Preload.Providers.User, + Pleroma.Web.Preload.Providers.Timelines + ] + config :pleroma, :http_security, enabled: true, sts: false, @@ -681,8 +676,6 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} -config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 0d9d578fc..932fb8d7e 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,11 +4,10 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller - require Logger - alias Pleroma.User alias Pleroma.Web.Metadata + alias Pleroma.Web.Preload def api_not_implemented(conn, _params) do conn @@ -16,16 +15,7 @@ def api_not_implemented(conn, _params) do |> json(%{error: "Not implemented"}) end - def redirector(conn, _params, code \\ 200) - - # redirect to admin section - # /pleroma/admin -> /pleroma/admin/ - # - def redirector(conn, %{"path" => ["pleroma", "admin"]} = _, _code) do - redirect(conn, to: "/pleroma/admin/") - end - - def redirector(conn, _params, code) do + def redirector(conn, _params, code \\ 200) do conn |> put_resp_content_type("text/html") |> send_file(code, index_file_path()) @@ -43,28 +33,34 @@ def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} def redirector_with_meta(conn, params) do {:ok, index_content} = File.read(index_file_path()) - tags = - try do - Metadata.build_tags(params) - rescue - e -> - Logger.error( - "Metadata rendering for #{conn.request_path} failed.\n" <> - Exception.format(:error, e, __STACKTRACE__) - ) - - "" - end + tags = build_tags(conn, params) + preloads = preload_data(conn, params) - response = String.replace(index_content, "", tags) + response = + index_content + |> String.replace("", tags) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") |> send_resp(200, response) end - def index_file_path do - Pleroma.Plugs.InstanceStatic.file_path("index.html") + def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do + redirect(conn, to: "/pleroma/admin/") + end + + def redirector_with_preload(conn, params) do + {:ok, index_content} = File.read(index_file_path()) + preloads = preload_data(conn, params) + + response = + index_content + |> String.replace("", preloads) + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, response) end def registration_page(conn, params) do @@ -76,4 +72,36 @@ def empty(conn, _params) do |> put_status(204) |> text("") end + + defp index_file_path do + Pleroma.Plugs.InstanceStatic.file_path("index.html") + end + + defp build_tags(conn, params) do + try do + Metadata.build_tags(params) + rescue + e -> + Logger.error( + "Metadata rendering for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end + + defp preload_data(conn, params) do + try do + Preload.build_tags(conn, params) + rescue + e -> + Logger.error( + "Preloading for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex new file mode 100644 index 000000000..d26b7c938 --- /dev/null +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -0,0 +1,130 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Nodeinfo.Nodeinfo do + alias Pleroma.Config + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.Federator.Publisher + + # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field + # under software. + def get_nodeinfo("2.0") do + stats = Stats.get_stats() + + quarantined = Config.get([:instance, :quarantined_instances], []) + + staff_accounts = + User.all_superusers() + |> Enum.map(fn u -> u.ap_id end) + + federation_response = + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + + features = + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions" + ] + |> Enum.filter(& &1) + + %{ + version: "2.0", + software: %{ + name: Pleroma.Application.name() |> String.downcase(), + version: Pleroma.Application.version() + }, + protocols: Publisher.gather_nodeinfo_protocol_names(), + services: %{ + inbound: [], + outbound: [] + }, + openRegistrations: Config.get([:instance, :registrations_open]), + usage: %{ + users: %{ + total: Map.get(stats, :user_count, 0) + }, + localPosts: Map.get(stats, :status_count, 0) + }, + metadata: %{ + nodeName: Config.get([:instance, :name]), + nodeDescription: Config.get([:instance, :description]), + private: !Config.get([:instance, :public], true), + suggestions: %{ + enabled: false + }, + staffAccounts: staff_accounts, + federation: federation_response, + pollLimits: Config.get([:instance, :poll_limits]), + postFormats: Config.get([:instance, :allowed_post_formats]), + uploadLimits: %{ + general: Config.get([:instance, :upload_limit]), + avatar: Config.get([:instance, :avatar_upload_limit]), + banner: Config.get([:instance, :banner_upload_limit]), + background: Config.get([:instance, :background_upload_limit]) + }, + fieldsLimits: %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + }, + accountActivationRequired: Config.get([:instance, :account_activation_required], false), + invitesEnabled: Config.get([:instance, :invites_enabled], false), + mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), + features: features, + restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) + } + } + end + + def get_nodeinfo("2.1") do + raw_response = get_nodeinfo("2.0") + + updated_software = + raw_response + |> Map.get(:software) + |> Map.put(:repository, Pleroma.Application.repository()) + + raw_response + |> Map.put(:software, updated_software) + |> Map.put(:version, "2.1") + end + + def get_nodeinfo(_version) do + {:error, :missing} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 721b599d4..8c7a9e565 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -5,12 +5,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do use Pleroma.Web, :controller - alias Pleroma.Config - alias Pleroma.Stats - alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.Federator.Publisher - alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo def schemas(conn, _params) do response = %{ @@ -29,102 +25,20 @@ def schemas(conn, _params) do json(conn, response) end - # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field - # under software. - def raw_nodeinfo do - stats = Stats.get_stats() - - staff_accounts = - User.all_superusers() - |> Enum.map(fn u -> u.ap_id end) - - features = InstanceView.features() - federation = InstanceView.federation() - - %{ - version: "2.0", - software: %{ - name: Pleroma.Application.name() |> String.downcase(), - version: Pleroma.Application.version() - }, - protocols: Publisher.gather_nodeinfo_protocol_names(), - services: %{ - inbound: [], - outbound: [] - }, - openRegistrations: Config.get([:instance, :registrations_open]), - usage: %{ - users: %{ - total: Map.get(stats, :user_count, 0) - }, - localPosts: Map.get(stats, :status_count, 0) - }, - metadata: %{ - nodeName: Config.get([:instance, :name]), - nodeDescription: Config.get([:instance, :description]), - private: !Config.get([:instance, :public], true), - suggestions: %{ - enabled: false - }, - staffAccounts: staff_accounts, - federation: federation, - pollLimits: Config.get([:instance, :poll_limits]), - postFormats: Config.get([:instance, :allowed_post_formats]), - uploadLimits: %{ - general: Config.get([:instance, :upload_limit]), - avatar: Config.get([:instance, :avatar_upload_limit]), - banner: Config.get([:instance, :banner_upload_limit]), - background: Config.get([:instance, :background_upload_limit]) - }, - fieldsLimits: %{ - maxFields: Config.get([:instance, :max_account_fields]), - maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), - nameLength: Config.get([:instance, :account_field_name_length]), - valueLength: Config.get([:instance, :account_field_value_length]) - }, - accountActivationRequired: Config.get([:instance, :account_activation_required], false), - invitesEnabled: Config.get([:instance, :invites_enabled], false), - mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), - features: features, - restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) - } - } - end - # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json - def nodeinfo(conn, %{"version" => "2.0"}) do - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" - ) - |> json(raw_nodeinfo()) - end - - def nodeinfo(conn, %{"version" => "2.1"}) do - raw_response = raw_nodeinfo() - - updated_software = - raw_response - |> Map.get(:software) - |> Map.put(:repository, Pleroma.Application.repository()) - - response = - raw_response - |> Map.put(:software, updated_software) - |> Map.put(:version, "2.1") - - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8" - ) - |> json(response) - end - - def nodeinfo(conn, _) do - render_error(conn, :not_found, "Nodeinfo schema version not handled") + def nodeinfo(conn, %{"version" => version}) do + case Nodeinfo.get_nodeinfo(version) do + {:error, :missing} -> + render_error(conn, :not_found, "Nodeinfo schema version not handled") + + node_info -> + conn + |> put_resp_header( + "content-type", + "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + ) + |> json(node_info) + end end end diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex new file mode 100644 index 000000000..c2211c597 --- /dev/null +++ b/lib/pleroma/web/preload.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.Preload do + alias Phoenix.HTML + require Logger + + def build_tags(_conn, params) do + preload_data = + Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> + Map.merge(acc, parser.generate_terms(params)) + end) + + rendered_html = + preload_data + |> Jason.encode!() + |> build_script_tag() + |> HTML.safe_to_string() + + rendered_html + end + + def build_script_tag(content) do + HTML.Tag.content_tag(:script, HTML.raw(content), + id: "initial-results", + type: "application/json" + ) + end +end diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex new file mode 100644 index 000000000..0b6fd3313 --- /dev/null +++ b/lib/pleroma/web/preload/instance.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Instance do + alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @instance_url :"/api/v1/instance" + @panel_url :"/instance/panel.html" + @nodeinfo_url :"/nodeinfo/2.0" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_info_tag() + |> build_panel_tag() + |> build_nodeinfo_tag() + end + + defp build_info_tag(acc) do + info_data = InstanceView.render("show.json", %{}) + + Map.put(acc, @instance_url, info_data) + end + + defp build_panel_tag(acc) do + instance_path = Path.join(:code.priv_dir(:pleroma), "static/instance/panel.html") + + if File.exists?(instance_path) do + panel_data = File.read!(instance_path) + Map.put(acc, @panel_url, panel_data) + else + acc + end + end + + defp build_nodeinfo_tag(acc) do + case Nodeinfo.get_nodeinfo("2.0") do + {:error, _} -> + acc + + nodeinfo_data -> + Map.put(acc, @nodeinfo_url, nodeinfo_data) + end + end +end diff --git a/lib/pleroma/web/preload/provider.ex b/lib/pleroma/web/preload/provider.ex new file mode 100644 index 000000000..7ef595a34 --- /dev/null +++ b/lib/pleroma/web/preload/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Provider do + @callback generate_terms(map()) :: map() +end diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex new file mode 100644 index 000000000..dbd7db407 --- /dev/null +++ b/lib/pleroma/web/preload/timelines.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Timelines do + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @public_url :"/api/v1/timelines/public" + + @impl Provider + def generate_terms(_params) do + build_public_tag(%{}) + end + + def build_public_tag(acc) do + if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do + acc + else + Map.put(acc, @public_url, public_timeline(nil)) + end + end + + defp public_timeline(user) do + activities = + create_timeline_params(user) + |> Map.put("local_only", false) + |> ActivityPub.fetch_public_activities() + + StatusView.render("index.json", activities: activities, for: user, as: :activity) + end + + defp create_timeline_params(user) do + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + end +end diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex new file mode 100644 index 000000000..3a244845b --- /dev/null +++ b/lib/pleroma/web/preload/user.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.Preload.Providers.User do + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @account_url :"/api/v1/accounts" + + @impl Provider + def generate_terms(%{user: user}) do + build_accounts_tag(%{}, user) + end + + def generate_terms(_params), do: %{} + + def build_accounts_tag(acc, nil), do: acc + + def build_accounts_tag(acc, user) do + account_data = AccountView.render("show.json", %{user: user, for: user}) + Map.put(acc, @account_url, account_data) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index e493a4153..a1ef2633d 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -716,7 +716,7 @@ defmodule Pleroma.Web.Router do get("/registration/:token", RedirectController, :registration_page) get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) get("/api*path", RedirectController, :api_not_implemented) - get("/*path", RedirectController, :redirector) + get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) end diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index b8f070d6a..be2613ad0 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do test "overrides index" do bundled_index = get(build_conn(), "/") - assert html_response(bundled_index, 200) == File.read!("priv/static/index.html") + refute html_response(bundled_index, 200) == "hello world" File.write!(@dir <> "/index.html", "hello world") diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3919ef93a..3b7a51d5e 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -6,22 +6,36 @@ defmodule Pleroma.Web.FallbackTest do use Pleroma.Web.ConnCase import Pleroma.Factory - test "GET /registration/:token", %{conn: conn} do - assert conn - |> get("/registration/foo") - |> html_response(200) =~ "" + describe "neither preloaded data nor metadata attached to" do + test "GET /registration/:token", %{conn: conn} do + response = get(conn, "/registration/foo") + + assert html_response(response, 200) =~ "" + assert html_response(response, 200) =~ "" + end end - test "GET /:maybe_nickname_or_id", %{conn: conn} do - user = insert(:user) + describe "preloaded data and metadata attached to" do + test "GET /:maybe_nickname_or_id", %{conn: conn} do + user = insert(:user) + user_missing = get(conn, "/foo") + user_present = get(conn, "/#{user.nickname}") - assert conn - |> get("/foo") - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" - refute conn - |> get("/" <> user.nickname) - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" + end + end + + describe "preloaded data only attached to" do + test "GET /*path", %{conn: conn} do + public_page = get(conn, "/main/public") + + assert html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + end end test "GET /api*path", %{conn: conn} do diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs new file mode 100644 index 000000000..52f9bab3b --- /dev/null +++ b/test/web/preload/instance_test.exs @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.InstanceTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.Instance + + setup do: {:ok, Instance.generate_terms(nil)} + + test "it renders the info", %{"/api/v1/instance": info} do + assert %{ + description: description, + email: "admin@example.com", + registrations: true + } = info + + assert String.equivalent?(description, "A Pleroma instance, an alternative fediverse server") + end + + test "it renders the panel", %{"/instance/panel.html": panel} do + assert String.contains?( + panel, + "

    Welcome to Pleroma!

    " + ) + end + + test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do + %{ + metadata: metadata, + version: "2.0" + } = nodeinfo + + assert metadata.private == false + assert metadata.suggestions == %{enabled: false} + end +end diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs new file mode 100644 index 000000000..00b10d0ab --- /dev/null +++ b/test/web/preload/timeline_test.exs @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.TimelineTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Preload.Providers.Timelines + + @public_url :"/api/v1/timelines/public" + + describe "unauthenticated timeliness when restricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + :ok + end + + test "return nothing" do + tl_data = Timelines.generate_terms(%{}) + + refute Map.has_key?(tl_data, "/api/v1/timelines/public") + end + end + + describe "unauthenticated timeliness when unrestricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{ + local: false, + federated: false + }) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + {:ok, user: insert(:user)} + end + + test "returns the timeline when not restricted" do + assert Timelines.generate_terms(%{}) + |> Map.has_key?(@public_url) + end + + test "returns public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 3 + end + + test "does not return non-public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 1 + end + end +end diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs new file mode 100644 index 000000000..99232cdfa --- /dev/null +++ b/test/web/preload/user_test.exs @@ -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.Preload.Providers.UserTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.Preload.Providers.User + + describe "returns empty when user doesn't exist" do + test "nil user specified" do + refute User.generate_terms(%{user: nil}) + |> Map.has_key?("/api/v1/accounts") + end + + test "missing user specified" do + refute User.generate_terms(%{user: :not_a_user}) + |> Map.has_key?("/api/v1/accounts") + end + end + + describe "specified user exists" do + setup do + user = insert(:user) + + {:ok, User.generate_terms(%{user: user})} + end + + test "account is rendered", %{"/api/v1/accounts": accounts} do + assert %{acct: user, username: user} = accounts + end + end +end -- cgit v1.2.3 From c181e555db4a90f770418af67b1073ec958adb4d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 29 May 2020 22:03:14 +0300 Subject: [#1794] Improvements to hashtags extraction from search query. --- .../mastodon_api/controllers/search_controller.ex | 40 +++++++++++++++++----- .../controllers/search_controller_test.exs | 36 ++++++++++++++++++- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 77e2224e4..23fe378a6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -113,22 +113,44 @@ defp resource_search(:v2, "hashtags", query, _options) do query |> prepare_tags() |> Enum.map(fn tag -> - tag = String.trim_leading(tag, "#") %{name: tag, url: tags_path <> tag} end) end defp resource_search(:v1, "hashtags", query, _options) do - query - |> prepare_tags() - |> Enum.map(fn tag -> String.trim_leading(tag, "#") end) + prepare_tags(query) end - defp prepare_tags(query) do - query - |> String.split() - |> Enum.uniq() - |> Enum.filter(fn tag -> String.starts_with?(tag, "#") end) + defp prepare_tags(query, add_joined_tag \\ true) do + tags = + query + |> String.split(~r/[^#\w]+/, trim: true) + |> Enum.uniq_by(&String.downcase/1) + + explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end) + + tags = + if Enum.any?(explicit_tags) do + explicit_tags + else + tags + end + + tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end) + + if Enum.empty?(explicit_tags) && add_joined_tag do + tags + |> Kernel.++([joined_tag(tags)]) + |> Enum.uniq_by(&String.downcase/1) + else + tags + end + end + + defp joined_tag(tags) do + tags + |> Enum.map(fn tag -> String.capitalize(tag) end) + |> Enum.join() end defp with_fallback(f, fallback \\ []) do diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 7d0cafccc..498290377 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -75,6 +75,40 @@ test "search", %{conn: conn} do assert status["id"] == to_string(activity.id) end + test "constructs hashtags from search query", %{conn: conn} do + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "some text with #explicit #hashtags"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "explicit", "url" => "#{Web.base_url()}/tag/explicit"}, + %{"name" => "hashtags", "url" => "#{Web.base_url()}/tag/hashtags"} + ] + + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "john doe JOHN DOE"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "john", "url" => "#{Web.base_url()}/tag/john"}, + %{"name" => "doe", "url" => "#{Web.base_url()}/tag/doe"}, + %{"name" => "JohnDoe", "url" => "#{Web.base_url()}/tag/JohnDoe"} + ] + + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "accident-prone"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "accident", "url" => "#{Web.base_url()}/tag/accident"}, + %{"name" => "prone", "url" => "#{Web.base_url()}/tag/prone"}, + %{"name" => "AccidentProne", "url" => "#{Web.base_url()}/tag/AccidentProne"} + ] + end + test "excludes a blocked users from search results", %{conn: conn} do user = insert(:user) user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"}) @@ -179,7 +213,7 @@ test "search", %{conn: conn} do [account | _] = results["accounts"] assert account["id"] == to_string(user_three.id) - assert results["hashtags"] == [] + assert results["hashtags"] == ["2hu"] [status] = results["statuses"] assert status["id"] == to_string(activity.id) -- cgit v1.2.3 From 0a83af330b7f33601848bca79bd1651b45eaea87 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 29 May 2020 23:05:03 +0300 Subject: fix unused var warning --- test/web/streamer/streamer_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 4cf640ce8..3f012259a 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -124,7 +124,7 @@ test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do |> Map.put("object", activity.data["object"]) |> Map.put("actor", user.ap_id) - {:ok, %Pleroma.Activity{data: data, local: false} = announce} = + {:ok, %Pleroma.Activity{data: _data, local: false} = announce} = Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data) assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} -- cgit v1.2.3 From 109af93227f65d308641e345c68c3884addb0181 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 29 May 2020 21:15:07 +0000 Subject: Apply suggestion to lib/pleroma/plugs/http_security_plug.ex --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 4b926e867..589072535 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -75,7 +75,7 @@ defp csp_string do sources = get_proxy_and_attachment_sources() {[img_src, sources], [media_src, sources]} else - {img_src <> " https:", media_src <> " https:"} + {[img_src, " https:"], [media_src, " https:"]} end connect_src = ["connect-src 'self' ", static_url, ?\s, websocket_url] -- cgit v1.2.3 From d2a1975e565e2e83859a607af29320226877cc4d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 30 May 2020 00:18:17 +0300 Subject: mix.lock: update hackney to 1.16.0 Closes #1612 --- mix.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mix.lock b/mix.lock index 470b401a3..5383c2c6e 100644 --- a/mix.lock +++ b/mix.lock @@ -12,7 +12,7 @@ "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"}, + "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, @@ -50,12 +50,12 @@ "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, "gun": {:git, "https://github.com/ninenines/gun.git", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, - "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, + "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [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]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, - "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, + "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, @@ -102,7 +102,7 @@ "recon": {:hex, :recon, "2.5.0", "2f7fcbec2c35034bade2f9717f77059dc54eb4e929a3049ca7ba6775c0bd66cd", [:mix, :rebar3], [], "hexpm", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "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.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, @@ -112,7 +112,7 @@ "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"}, "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "db9fbfb5ac707bc4f85a297758406340bf0358b4af737a88113c1a9eee120ac7"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "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", []}, -- cgit v1.2.3 From 24f40b8a26f95ee7f50b6023176d361660ceb35c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 30 May 2020 10:29:08 +0300 Subject: [#1794] Fixed search query splitting regex to deal with Unicode. Adjusted a test. --- lib/pleroma/web/mastodon_api/controllers/search_controller.ex | 2 +- test/web/mastodon_api/controllers/search_controller_test.exs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 23fe378a6..8840fc19c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -124,7 +124,7 @@ defp resource_search(:v1, "hashtags", query, _options) do defp prepare_tags(query, add_joined_tag \\ true) do tags = query - |> String.split(~r/[^#\w]+/, trim: true) + |> String.split(~r/[^#\w]+/u, trim: true) |> Enum.uniq_by(&String.downcase/1) explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end) diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 498290377..84d46895e 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -71,6 +71,10 @@ test "search", %{conn: conn} do get(conn, "/api/v2/search?q=天子") |> json_response_and_validate_schema(200) + assert results["hashtags"] == [ + %{"name" => "天子", "url" => "#{Web.base_url()}/tag/天子"} + ] + [status] = results["statuses"] assert status["id"] == to_string(activity.id) end -- cgit v1.2.3 From 6d4b80822b15f5958518f4c6006862fb1f92354a Mon Sep 17 00:00:00 2001 From: Steven Fuchs Date: Sat, 30 May 2020 10:02:37 +0000 Subject: Conversation pagination --- .../controllers/conversation_controller.ex | 17 ++ .../controllers/conversation_controller_test.exs | 183 +++++++++++---------- 2 files changed, 109 insertions(+), 91 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index f35ec3596..69f0e3846 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do + params = stringify_pagination_params(params) participations = Participation.for_user_with_last_activity_id(user, params) conn @@ -36,4 +37,20 @@ def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do render(conn, "participation.json", participation: participation, for: user) end end + + defp stringify_pagination_params(params) do + atom_keys = + Pleroma.Pagination.page_keys() + |> Enum.map(&String.to_atom(&1)) + + str_keys = + params + |> Map.take(atom_keys) + |> Enum.map(fn {key, value} -> {to_string(key), value} end) + |> Enum.into(%{}) + + params + |> Map.delete(atom_keys) + |> Map.merge(str_keys) + end end diff --git a/test/web/mastodon_api/controllers/conversation_controller_test.exs b/test/web/mastodon_api/controllers/conversation_controller_test.exs index 693ba51e5..3e21e6bf1 100644 --- a/test/web/mastodon_api/controllers/conversation_controller_test.exs +++ b/test/web/mastodon_api/controllers/conversation_controller_test.exs @@ -12,84 +12,88 @@ defmodule Pleroma.Web.MastodonAPI.ConversationControllerTest do setup do: oauth_access(["read:statuses"]) - test "returns a list of conversations", %{user: user_one, conn: conn} do - user_two = insert(:user) - user_three = insert(:user) - - {:ok, user_two} = User.follow(user_two, user_one) - - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - visibility: "direct" - }) - - assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 - - {:ok, _follower_only} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}!", - visibility: "private" - }) - - res_conn = get(conn, "/api/v1/conversations") - - assert response = json_response_and_validate_schema(res_conn, 200) - - assert [ - %{ - "id" => res_id, - "accounts" => res_accounts, - "last_status" => res_last_status, - "unread" => unread - } - ] = response - - account_ids = Enum.map(res_accounts, & &1["id"]) - assert length(res_accounts) == 2 - assert user_two.id in account_ids - assert user_three.id in account_ids - assert is_binary(res_id) - assert unread == false - assert res_last_status["id"] == direct.id - assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 + describe "returns a list of conversations" do + setup(%{user: user_one, conn: conn}) do + user_two = insert(:user) + user_three = insert(:user) + + {:ok, user_two} = User.follow(user_two, user_one) + + {:ok, %{user: user_one, user_two: user_two, user_three: user_three, conn: conn}} + end + + test "returns correct conversations", %{ + user: user_one, + user_two: user_two, + user_three: user_three, + conn: conn + } do + assert User.get_cached_by_id(user_two.id).unread_conversation_count == 0 + {:ok, direct} = create_direct_message(user_one, [user_two, user_three]) + + assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 + + {:ok, _follower_only} = + CommonAPI.post(user_one, %{ + status: "Hi @#{user_two.nickname}!", + visibility: "private" + }) + + res_conn = get(conn, "/api/v1/conversations") + + assert response = json_response_and_validate_schema(res_conn, 200) + + assert [ + %{ + "id" => res_id, + "accounts" => res_accounts, + "last_status" => res_last_status, + "unread" => unread + } + ] = response + + account_ids = Enum.map(res_accounts, & &1["id"]) + assert length(res_accounts) == 2 + assert user_two.id in account_ids + assert user_three.id in account_ids + assert is_binary(res_id) + assert unread == false + assert res_last_status["id"] == direct.id + assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 + end + + test "observes limit params", %{ + user: user_one, + user_two: user_two, + user_three: user_three, + conn: conn + } do + {:ok, _} = create_direct_message(user_one, [user_two, user_three]) + {:ok, _} = create_direct_message(user_two, [user_one, user_three]) + {:ok, _} = create_direct_message(user_three, [user_two, user_one]) + + res_conn = get(conn, "/api/v1/conversations?limit=1") + + assert response = json_response_and_validate_schema(res_conn, 200) + + assert Enum.count(response) == 1 + + res_conn = get(conn, "/api/v1/conversations?limit=2") + + assert response = json_response_and_validate_schema(res_conn, 200) + + assert Enum.count(response) == 2 + end end test "filters conversations by recipients", %{user: user_one, conn: conn} do user_two = insert(:user) user_three = insert(:user) - - {:ok, direct1} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}!", - visibility: "direct" - }) - - {:ok, _direct2} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_three.nickname}!", - visibility: "direct" - }) - - {:ok, direct3} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}, @#{user_three.nickname}!", - visibility: "direct" - }) - - {:ok, _direct4} = - CommonAPI.post(user_two, %{ - status: "Hi @#{user_three.nickname}!", - visibility: "direct" - }) - - {:ok, direct5} = - CommonAPI.post(user_two, %{ - status: "Hi @#{user_one.nickname}!", - visibility: "direct" - }) + {:ok, direct1} = create_direct_message(user_one, [user_two]) + {:ok, _direct2} = create_direct_message(user_one, [user_three]) + {:ok, direct3} = create_direct_message(user_one, [user_two, user_three]) + {:ok, _direct4} = create_direct_message(user_two, [user_three]) + {:ok, direct5} = create_direct_message(user_two, [user_one]) assert [conversation1, conversation2] = conn @@ -109,12 +113,7 @@ test "filters conversations by recipients", %{user: user_one, conn: conn} do test "updates the last_status on reply", %{user: user_one, conn: conn} do user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}", - visibility: "direct" - }) + {:ok, direct} = create_direct_message(user_one, [user_two]) {:ok, direct_reply} = CommonAPI.post(user_two, %{ @@ -133,12 +132,7 @@ test "updates the last_status on reply", %{user: user_one, conn: conn} do test "the user marks a conversation as read", %{user: user_one, conn: conn} do user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}", - visibility: "direct" - }) + {:ok, direct} = create_direct_message(user_one, [user_two]) assert User.get_cached_by_id(user_one.id).unread_conversation_count == 0 assert User.get_cached_by_id(user_two.id).unread_conversation_count == 1 @@ -194,15 +188,22 @@ test "the user marks a conversation as read", %{user: user_one, conn: conn} do test "(vanilla) Mastodon frontend behaviour", %{user: user_one, conn: conn} do user_two = insert(:user) - - {:ok, direct} = - CommonAPI.post(user_one, %{ - status: "Hi @#{user_two.nickname}!", - visibility: "direct" - }) + {:ok, direct} = create_direct_message(user_one, [user_two]) res_conn = get(conn, "/api/v1/statuses/#{direct.id}/context") assert %{"ancestors" => [], "descendants" => []} == json_response(res_conn, 200) end + + defp create_direct_message(sender, recips) do + hellos = + recips + |> Enum.map(fn s -> "@#{s.nickname}" end) + |> Enum.join(", ") + + CommonAPI.post(sender, %{ + status: "Hi #{hellos}!", + visibility: "direct" + }) + end end -- cgit v1.2.3 From 2c9465cc51160546ae054d1a1912fbb8e9add8e8 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 30 May 2020 12:17:18 +0200 Subject: SafeText: Let through basic html. --- .../web/activity_pub/object_validators/types/safe_text.ex | 2 +- test/web/activity_pub/object_validator_test.exs | 14 ++++++++++++++ .../object_validators/types/safe_text_test.exs | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex index 822e8d2c1..95c948123 100644 --- a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex +++ b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText do def type, do: :string def cast(str) when is_binary(str) do - {:ok, HTML.strip_tags(str)} + {:ok, HTML.filter_tags(str)} end def cast(_), do: :error diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 929fdbc9b..31224abe0 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -113,6 +113,20 @@ test "it is invalid if the object data has a different `to` or `actor` field" do %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} end + test "let's through some basic html", %{user: user, recipient: recipient} do + {:ok, valid_chat_message, _} = + Builder.chat_message( + user, + recipient.ap_id, + "hey example " + ) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["content"] == + "hey example alert('uguu')" + end + test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs index 59ed0a1fe..d4a574554 100644 --- a/test/web/activity_pub/object_validators/types/safe_text_test.exs +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -17,6 +17,13 @@ test "it removes html tags from text" do assert {:ok, "hey look xss alert('foo')"} == SafeText.cast(text) end + test "it keeps basic html tags" do + text = "hey look xss " + + assert {:ok, "hey look xss alert('foo')"} == + SafeText.cast(text) + end + test "errors for non-text" do assert :error == SafeText.cast(1) end -- cgit v1.2.3 From 8bdf18d7c10f0e740b2f5e0fa5063c522b8b3872 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 30 May 2020 12:30:31 +0200 Subject: CommonAPI: Linkify chat messages. --- lib/pleroma/web/common_api/common_api.ex | 7 ++++++- test/web/common_api/common_api_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 764fa4f4f..173353aa5 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -50,7 +50,12 @@ def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) defp format_chat_content(nil), do: nil defp format_chat_content(content) do - content |> Formatter.html_escape("text/plain") + {text, _, _} = + content + |> Formatter.html_escape("text/plain") + |> Formatter.linkify() + + text end defp validate_chat_content_length(_, true), do: :ok diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 9e129e5a7..41c6909de 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -50,6 +50,29 @@ test "it posts a chat message without content but with an attachment" do assert activity end + test "it linkifies" do + author = insert(:user) + recipient = insert(:user) + + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "https://example.org is the site of @#{other_user.nickname} #2hu" + ) + + assert other_user.ap_id not in activity.recipients + + object = Object.normalize(activity, false) + + assert object.data["content"] == + "https://example.org is the site of @#{other_user.nickname} #2hu" + end + test "it posts a chat message" do author = insert(:user) recipient = insert(:user) -- cgit v1.2.3 From 0cb7b0ea8477bdd7af2e5e9071843be5b8623dff Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 30 May 2020 13:59:04 +0300 Subject: hackney adapter helper: support tlsv1.3 and remove custom opts - partitial_chain is no longer exported, but it seems to be the default anyway. - The bug that caused sni to not be sent automatically seems to be fixed - https://github.com/benoitc/hackney/issues/612 --- lib/pleroma/http/adapter_helper/hackney.ex | 17 +---------------- test/http/adapter_helper/hackney_test.exs | 12 ------------ 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index dcb4cac71..3972a03a9 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -22,22 +22,7 @@ def options(connection_opts \\ [], %URI{} = uri) do |> Pleroma.HTTP.AdapterHelper.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 + defp add_scheme_opts(opts, _), do: opts def after_request(_), do: :ok end diff --git a/test/http/adapter_helper/hackney_test.exs b/test/http/adapter_helper/hackney_test.exs index 3f7e708e0..f2361ff0b 100644 --- a/test/http/adapter_helper/hackney_test.exs +++ b/test/http/adapter_helper/hackney_test.exs @@ -31,17 +31,5 @@ test "respect connection opts and no proxy", %{uri: uri} do 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 -- cgit v1.2.3 From b973d0b2f0809e7a96c39f6eef1d86050c9d421b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 30 May 2020 16:47:09 +0300 Subject: Fix config setting to not affect other tests --- test/user_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/user_test.exs b/test/user_test.exs index 3556ef1b4..6b344158d 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1802,7 +1802,7 @@ test "avatar fallback" do user = insert(:user) assert User.avatar_url(user) =~ "/images/avi.png" - Pleroma.Config.put([:assets, :default_user_avatar], "avatar.png") + clear_config([:assets, :default_user_avatar], "avatar.png") user = User.get_cached_by_nickname_or_id(user.nickname) assert User.avatar_url(user) =~ "avatar.png" -- cgit v1.2.3 From 954acdda2072cac343409b3d17d831b86ac6a18c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 29 May 2020 15:16:44 -0500 Subject: Add `account_activation_required` to /api/v1/instance --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 6a630eafa..bb7bd2a9f 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -36,6 +36,7 @@ def render("show.json", _) do background_image: Keyword.get(instance, :background_image), pleroma: %{ metadata: %{ + account_activation_required: Keyword.get(instance, :account_activation_required), features: features(), federation: federation() }, -- cgit v1.2.3 From 9460983032257022ff29c063901f6b714e4fbf59 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 13:03:22 +0200 Subject: AccountController: Federate user account changes. Hotfixy commit, will be moved to the pipeline. --- .../mastodon_api/controllers/account_controller.ex | 23 +++++++++++-- .../account_controller/update_credentials_test.exs | 38 +++++++++++++--------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 47649d41d..97295a52f 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -139,9 +139,7 @@ def verify_credentials(%{assigns: %{user: user}} = conn, _) do end @doc "PATCH /api/v1/accounts/update_credentials" - def update_credentials(%{assigns: %{user: original_user}, body_params: params} = conn, _params) do - user = original_user - + def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do params = params |> Enum.filter(fn {_, value} -> not is_nil(value) end) @@ -183,12 +181,31 @@ def update_credentials(%{assigns: %{user: original_user}, body_params: params} = changeset = User.update_changeset(user, user_params) with {:ok, user} <- User.update_and_set_cache(changeset) do + user + |> build_update_activity_params() + |> ActivityPub.update() + render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) else _e -> render_error(conn, :forbidden, "Invalid request") end end + # Hotfix, handling will be redone with the pipeline + defp build_update_activity_params(user) do + object = + Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) + |> Map.delete("@context") + + %{ + local: true, + to: [user.follower_address], + cc: [], + object: object, + actor: user.ap_id + } + end + defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do with true <- is_map(params), true <- Map.has_key?(params, params_field), 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 696228203..7c420985d 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 @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController.UpdateCredentialsTest do use Pleroma.Web.ConnCase + import Mock import Pleroma.Factory setup do: clear_config([:instance, :max_account_fields]) @@ -52,24 +53,31 @@ test "sets user settings in a generic way", %{conn: conn} do user = Repo.get(User, user_data["id"]) - res_conn = - conn - |> assign(:user, user) - |> patch("/api/v1/accounts/update_credentials", %{ - "pleroma_settings_store" => %{ - masto_fe: %{ - theme: "blub" + clear_config([:instance, :federating], true) + + with_mock Pleroma.Web.Federator, + publish: fn _activity -> :ok end do + res_conn = + conn + |> assign(:user, user) + |> patch("/api/v1/accounts/update_credentials", %{ + "pleroma_settings_store" => %{ + masto_fe: %{ + theme: "blub" + } } - } - }) + }) - assert user_data = json_response_and_validate_schema(res_conn, 200) + assert user_data = json_response_and_validate_schema(res_conn, 200) - assert user_data["pleroma"]["settings_store"] == - %{ - "pleroma_fe" => %{"theme" => "bla"}, - "masto_fe" => %{"theme" => "blub"} - } + assert user_data["pleroma"]["settings_store"] == + %{ + "pleroma_fe" => %{"theme" => "bla"}, + "masto_fe" => %{"theme" => "blub"} + } + + assert_called(Pleroma.Web.Federator.publish(:_)) + end end test "updates the user's bio", %{conn: conn} do -- cgit v1.2.3 From d4d4b92f758979fbc22cd56a9f30435df5c40ab6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 13:17:56 +0200 Subject: TimelineController: Only return `Create` in public timelines. --- .../web/mastodon_api/controllers/timeline_controller.ex | 2 +- .../web/mastodon_api/controllers/timeline_controller_test.exs | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 958567510..f67f75430 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -111,7 +111,7 @@ def public(%{assigns: %{user: user}} = conn, params) do else activities = params - |> Map.put("type", ["Create", "Announce"]) + |> Map.put("type", ["Create"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 2375ac8e8..65b4079fe 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -60,9 +60,9 @@ test "the home timeline when the direct messages are excluded", %{user: user, co describe "public" do @tag capture_log: true test "the public timeline", %{conn: conn} do - following = insert(:user) + user = insert(:user) - {:ok, _activity} = CommonAPI.post(following, %{status: "test"}) + {:ok, activity} = CommonAPI.post(user, %{status: "test"}) _activity = insert(:note_activity, local: false) @@ -77,6 +77,13 @@ test "the public timeline", %{conn: conn} do conn = get(build_conn(), "/api/v1/timelines/public?local=1") assert [%{"content" => "test"}] = json_response_and_validate_schema(conn, :ok) + + # does not contain repeats + {:ok, _} = CommonAPI.repeat(activity.id, user) + + conn = get(build_conn(), "/api/v1/timelines/public?local=true") + + assert [_] = json_response_and_validate_schema(conn, :ok) end test "the public timeline includes only public statuses for an authenticated user" do -- cgit v1.2.3 From ac31f687c0fbe06251257acb72b67146b472d22f Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 13:35:39 +0200 Subject: Config: Default to Hackney again Gun needs some server setting changes (files) and has problems with OTP 23 (wildcards), so use Hackney as a default again for now. --- config/config.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index d15998715..9a9fbb436 100644 --- a/config/config.exs +++ b/config/config.exs @@ -171,7 +171,8 @@ "application/ld+json" => ["activity+json"] } -config :tesla, adapter: Tesla.Adapter.Gun +config :tesla, adapter: Tesla.Adapter.Hackney + # Configures http settings, upstream proxy etc. config :pleroma, :http, proxy_url: nil, -- cgit v1.2.3 From af9090238e1f71e6b081fbd09c09a5975d2ed99e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 1 Jun 2020 15:14:22 +0200 Subject: CommonAPI: Newlines -> br for chat messages. --- lib/pleroma/web/common_api/common_api.ex | 3 +++ test/web/common_api/common_api_test.exs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 173353aa5..e0987b1a7 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -54,6 +54,9 @@ defp format_chat_content(content) do content |> Formatter.html_escape("text/plain") |> Formatter.linkify() + |> (fn {text, mentions, tags} -> + {String.replace(text, ~r/\r?\n/, "
    "), mentions, tags} + end).() text end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 41c6909de..611a9ae66 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -50,6 +50,26 @@ test "it posts a chat message without content but with an attachment" do assert activity end + test "it adds html newlines" do + author = insert(:user) + recipient = insert(:user) + + other_user = insert(:user) + + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + "uguu\nuguuu" + ) + + assert other_user.ap_id not in activity.recipients + + object = Object.normalize(activity, false) + + assert object.data["content"] == "uguu
    uguuu" + end + test "it linkifies" do author = insert(:user) recipient = insert(:user) -- cgit v1.2.3 From 7e6ec778d965419ed4083428d4d39b2a689f7619 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 20 May 2020 17:45:06 +0300 Subject: exclude replies on blocked domains --- benchmarks/load_testing/activities.ex | 361 +++++++++++---------- benchmarks/load_testing/fetcher.ex | 71 ++++ benchmarks/load_testing/users.ex | 22 +- benchmarks/mix/tasks/pleroma/benchmarks/tags.ex | 38 ++- lib/pleroma/web/activity_pub/activity_pub.ex | 27 ++ .../web/api_spec/operations/timeline_operation.ex | 7 + .../controllers/timeline_controller.ex | 13 +- ...recipients_contain_blocked_domains_function.exs | 33 ++ .../controllers/timeline_controller_test.exs | 68 ++++ 9 files changed, 467 insertions(+), 173 deletions(-) create mode 100644 priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index ff0d481a8..074ded457 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -22,8 +22,21 @@ defmodule Pleroma.LoadTesting.Activities do @max_concurrency 10 @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) + @types [ + :simple, + :emoji, + :mentions, + :hell_thread, + :attachment, + :tag, + :like, + :reblog, + :simple_thread + ] + @groups [:friends_local, :friends_remote, :non_friends_local, :non_friends_local] + @remote_groups [:friends_remote, :non_friends_remote] + @friends_groups [:friends_local, :friends_remote] + @non_friends_groups [:non_friends_local, :non_friends_remote] @spec generate(User.t(), keyword()) :: :ok def generate(user, opts \\ []) do @@ -34,33 +47,24 @@ def generate(user, opts \\ []) do opts = Keyword.merge(@defaults, opts) - friends = - user - |> Users.get_users(limit: opts[:friends_used], local: :local, friends?: true) - |> Enum.shuffle() + users = Users.prepare_users(user, opts) - non_friends = - user - |> Users.get_users(limit: opts[:non_friends_used], local: :local, friends?: false) - |> Enum.shuffle() + {:ok, _} = Agent.start_link(fn -> users[:non_friends_remote] end, name: :non_friends_remote) task_data = for visibility <- @visibility, type <- @types, - group <- @groups, + group <- [:user | @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) + generate_long_thread("public", users, opts) end private_long_thread = fn -> - generate_long_thread("private", user, friends_thread, non_friends_thread, opts) + generate_long_thread("private", users, opts) end iterations = opts[:iterations] @@ -73,10 +77,10 @@ def generate(user, opts \\ []) do 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(users, Enum.shuffle(task_data), opts) _ -> - generate_activities(user, friends, non_friends, Enum.shuffle(task_data), opts) + generate_activities(users, Enum.shuffle(task_data), opts) end ) end) @@ -127,16 +131,16 @@ def generate_tagged_activities(opts \\ []) do end) end - defp generate_long_thread(visibility, user, friends, non_friends, _opts) do + defp generate_long_thread(visibility, users, _opts) do group = if visibility == "public", - do: "friends", - else: "user" + do: :friends_local, + else: :user tasks = get_reply_tasks(visibility, group) |> Stream.cycle() |> Enum.take(50) {:ok, activity} = - CommonAPI.post(user, %{ + CommonAPI.post(users[:user], %{ status: "Start of #{visibility} long thread", visibility: visibility }) @@ -150,31 +154,28 @@ defp generate_long_thread(visibility, user, friends, non_friends, _opts) do 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) + acc = {activity.id, ["@" <> users[:user].nickname, "reply to long thread"]} + insert_replies_for_long_thread(tasks, visibility, users, acc) IO.puts("Generating #{visibility} long thread ended\n") end - defp insert_replies_for_long_thread(tasks, visibility, user, friends, non_friends, acc) do + defp insert_replies_for_long_thread(tasks, visibility, users, 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} -> + :user, {id, data} -> + user = users[:user] insert_reply(user, List.delete(data, "@" <> user.nickname), id, visibility) + + group, {id, data} -> + replier = Enum.random(users[group]) + insert_reply(replier, List.delete(data, "@" <> replier.nickname), id, visibility) end) end - defp generate_activities(user, friends, non_friends, task_data, opts) do + defp generate_activities(users, task_data, opts) do Task.async_stream( task_data, fn {visibility, type, group} -> - insert_activity(type, visibility, group, user, friends, non_friends, opts) + insert_activity(type, visibility, group, users, opts) end, max_concurrency: @max_concurrency, timeout: 30_000 @@ -182,67 +183,104 @@ defp generate_activities(user, friends, non_friends, task_data, opts) do |> Stream.run() end - defp insert_activity("simple", visibility, group, user, friends, non_friends, _opts) do - {:ok, _activity} = + defp insert_local_activity(visibility, group, users, status) do + {:ok, _} = group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{status: "Simple status", visibility: visibility}) + |> get_actor(users) + |> CommonAPI.post(%{status: 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 - }) + defp insert_remote_activity(visibility, group, users, status) do + actor = get_actor(group, users) + {act_data, obj_data} = prepare_activity_data(actor, visibility, users[:user]) + {activity_data, object_data} = other_data(actor, status) + + activity_data + |> Map.merge(act_data) + |> Map.put("object", Map.merge(object_data, obj_data)) + |> Pleroma.Web.ActivityPub.ActivityPub.insert(false) end - defp insert_activity("mentions", visibility, group, user, friends, non_friends, _opts) do + defp user_mentions(users) do user_mentions = - get_random_mentions(friends, Enum.random(0..3)) ++ - get_random_mentions(non_friends, Enum.random(0..3)) + Enum.reduce( + @groups, + [], + fn group, acc -> + acc ++ get_random_mentions(users[group], Enum.random(0..2)) + end + ) - user_mentions = - if Enum.random([true, false]), - do: ["@" <> user.nickname | user_mentions], - else: user_mentions + if Enum.random([true, false]), + do: ["@" <> users[:user].nickname | user_mentions], + else: user_mentions + end - {:ok, _activity} = - group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{ - status: Enum.join(user_mentions, ", ") <> " simple status with mentions", - visibility: visibility - }) + defp hell_thread_mentions(users) do + with {:ok, nil} <- Cachex.get(:user_cache, "hell_thread_mentions") do + cached = + @groups + |> Enum.reduce([users[:user]], fn group, acc -> + acc ++ Enum.take(users[group], 5) + end) + |> Enum.map(&"@#{&1.nickname}") + |> Enum.join(", ") + + Cachex.put(:user_cache, "hell_thread_mentions", cached) + cached + else + {:ok, cached} -> cached + end 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(", ") + defp insert_activity(:simple, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status") + end - Cachex.put(:user_cache, "hell_thread_mentions", cached) - cached - else - {:ok, cached} -> cached - end + defp insert_activity(:simple, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status") + end - {:ok, _activity} = - group - |> get_actor(user, friends, non_friends) - |> CommonAPI.post(%{ - status: mentions <> " hell thread status", - visibility: visibility - }) + defp insert_activity(:emoji, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status with emoji :firefox:") + end + + defp insert_activity(:emoji, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status with emoji :firefox:") + end + + defp insert_activity(:mentions, visibility, group, users, _opts) + when group in @remote_groups do + mentions = user_mentions(users) + + status = Enum.join(mentions, ", ") <> " remote status with mentions" + + insert_remote_activity(visibility, group, users, status) + end + + defp insert_activity(:mentions, visibility, group, users, _opts) do + mentions = user_mentions(users) + + status = Enum.join(mentions, ", ") <> " simple status with mentions" + insert_remote_activity(visibility, group, users, status) + end + + defp insert_activity(:hell_thread, visibility, group, users, _) + when group in @remote_groups do + mentions = hell_thread_mentions(users) + insert_remote_activity(visibility, group, users, mentions <> " remote hell thread status") + end + + defp insert_activity(:hell_thread, visibility, group, users, _opts) do + mentions = hell_thread_mentions(users) + + insert_local_activity(visibility, group, users, mentions <> " hell thread status") end - defp insert_activity("attachment", visibility, group, user, friends, non_friends, _opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:attachment, visibility, group, users, _opts) do + actor = get_actor(group, users) obj_data = %{ "actor" => actor.ap_id, @@ -268,67 +306,54 @@ defp insert_activity("attachment", visibility, group, user, friends, non_friends }) 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}) + defp insert_activity(:tag, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Status with #tag") end - defp insert_activity("like", visibility, group, user, friends, non_friends, opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:like, visibility, group, users, opts) do + actor = get_actor(group, users) with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), {:ok, _activity} <- CommonAPI.favorite(actor, activity_id) do :ok else {:error, _} -> - insert_activity("like", visibility, group, user, friends, non_friends, opts) + insert_activity(:like, visibility, group, users, opts) nil -> Process.sleep(15) - insert_activity("like", visibility, group, user, friends, non_friends, opts) + insert_activity(:like, visibility, group, users, opts) end end - defp insert_activity("reblog", visibility, group, user, friends, non_friends, opts) do - actor = get_actor(group, user, friends, non_friends) + defp insert_activity(:reblog, visibility, group, users, opts) do + actor = get_actor(group, users) with activity_id when not is_nil(activity_id) <- get_random_create_activity_id(), - {:ok, _activity, _object} <- CommonAPI.repeat(activity_id, actor) do + {:ok, _activity} <- CommonAPI.repeat(activity_id, actor) do :ok else {:error, _} -> - insert_activity("reblog", visibility, group, user, friends, non_friends, opts) + insert_activity(:reblog, visibility, group, users, opts) nil -> Process.sleep(15) - insert_activity("reblog", visibility, group, user, friends, non_friends, opts) + insert_activity(:reblog, visibility, group, users, 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: visibility}) - - 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) + defp insert_activity(:simple_thread, "direct", group, users, _opts) do + actor = get_actor(group, users) tasks = get_reply_tasks("direct", group) list = case group do - "non_friends" -> - Enum.take(non_friends, 3) + :user -> + group = Enum.random(@friends_groups) + Enum.take(users[group], 3) _ -> - Enum.take(friends, 3) + Enum.take(users[group], 3) end data = Enum.map(list, &("@" <> &1.nickname)) @@ -339,40 +364,30 @@ defp insert_activity("simple_thread", "direct", group, user, friends, non_friend visibility: "direct" }) - acc = {activity.id, ["@" <> user.nickname | data] ++ ["reply to status"]} - insert_direct_replies(tasks, user, list, acc) + acc = {activity.id, ["@" <> users[:user].nickname | data] ++ ["reply to status"]} + insert_direct_replies(tasks, users[: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) + defp insert_activity(:simple_thread, visibility, group, users, _opts) do + actor = get_actor(group, users) + tasks = get_reply_tasks(visibility, group) - {act_data, obj_data} = prepare_activity_data(actor, visibility, user) - {activity_data, object_data} = other_data(actor) + {:ok, activity} = + CommonAPI.post(users[:user], %{status: "Simple status", visibility: visibility}) - activity_data - |> Map.merge(act_data) - |> Map.put("object", Map.merge(object_data, obj_data)) - |> Pleroma.Web.ActivityPub.ActivityPub.insert(false) + acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} + insert_replies(tasks, visibility, users, acc) 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 get_actor(:user, %{user: user}), do: user + defp get_actor(group, users), do: Enum.random(users[group]) - defp other_data(actor) do + defp other_data(actor, content) 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()}" + context_id = "https://#{host}/contexts/#{UUID.generate()}" + activity_id = "https://#{host}/activities/#{UUID.generate()}" + object_id = "https://#{host}/objects/#{UUID.generate()}" activity_data = %{ "actor" => actor.ap_id, @@ -389,7 +404,7 @@ defp other_data(actor) do "attributedTo" => actor.ap_id, "bcc" => [], "bto" => [], - "content" => "Remote post", + "content" => content, "context" => context_id, "conversation" => context_id, "emoji" => %{}, @@ -475,51 +490,65 @@ defp prepare_activity_data(_actor, "direct", mention) do {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("public", :user) do + [:friends_local, :friends_remote, :non_friends_local, :non_friends_remote, :user] + end + + defp get_reply_tasks("public", group) when group in @friends_groups do + [:non_friends_local, :non_friends_remote, :user, :friends_local, :friends_remote] + end - defp get_reply_tasks(visibility, "user") when visibility in ["unlisted", "private"], - do: ~w(friend user friend) + defp get_reply_tasks("public", group) when group in @non_friends_groups do + [:user, :friends_local, :friends_remote, :non_friends_local, :non_friends_remote] + end - defp get_reply_tasks(visibility, "friends") when visibility in ["unlisted", "private"], - do: ~w(user friend user) + defp get_reply_tasks(visibility, :user) when visibility in ["unlisted", "private"] do + [:friends_local, :friends_remote, :user, :friends_local, :friends_remote] + end - defp get_reply_tasks(visibility, "non_friends") when visibility in ["unlisted", "private"], - do: [] + defp get_reply_tasks(visibility, group) + when visibility in ["unlisted", "private"] and group in @friends_groups do + [:user, :friends_remote, :friends_local, :user] + end - 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 get_reply_tasks(visibility, group) + when visibility in ["unlisted", "private"] and + group in @non_friends_groups, + do: [] - 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) + defp get_reply_tasks("direct", :user), do: [:friends_local, :user, :friends_remote] - "non_friend", {id, data} -> - non_friend = Enum.random(non_friends) - insert_reply(non_friend, data, id, visibility) + defp get_reply_tasks("direct", group) when group in @friends_groups, + do: [:user, group, :user] - "user", {id, data} -> - insert_reply(user, data, id, visibility) + defp get_reply_tasks("direct", group) when group in @non_friends_groups do + [:user, :non_friends_remote, :user, :non_friends_local] + end + + defp insert_replies(tasks, visibility, users, acc) do + Enum.reduce(tasks, acc, fn + :user, {id, data} -> + insert_reply(users[:user], data, id, visibility) + + group, {id, data} -> + replier = Enum.random(users[group]) + insert_reply(replier, 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"] -> + :user, {id, data} -> + {reply_id, _} = insert_reply(user, List.delete(data, "@" <> user.nickname), id, "direct") + {reply_id, data} + + _, {id, data} -> 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 diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 0de4924bc..b278faf9f 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -36,6 +36,7 @@ defp fetch_timelines(user) do fetch_home_timeline(user) fetch_direct_timeline(user) fetch_public_timeline(user) + fetch_public_timeline(user, :with_blocks) fetch_public_timeline(user, :local) fetch_public_timeline(user, :tag) fetch_notifications(user) @@ -227,6 +228,76 @@ defp fetch_public_timeline(user, :only_media) do fetch_public_timeline(opts, "public timeline only media") end + # TODO: remove using `:method` after benchmarks + defp fetch_public_timeline(user, :with_blocks) do + opts = opts_for_public_timeline(user) + + remote_non_friends = Agent.get(:non_friends_remote, & &1) + + Benchee.run( + %{ + "public timeline without blocks" => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "old filtering" => Map.delete(opts, :method), + "with psql fun" => Map.put(opts, :method, :fun), + "with unnest" => Map.put(opts, :method, :unnest) + } + ) + + Enum.each(remote_non_friends, fn non_friend -> + {:ok, _} = User.block(user, non_friend) + end) + + user = User.get_by_id(user.id) + + opts = Map.put(opts, "blocking_user", user) + + Benchee.run( + %{ + "public timeline with user block" => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "old filtering" => Map.delete(opts, :method), + "with psql fun" => Map.put(opts, :method, :fun), + "with unnest" => Map.put(opts, :method, :unnest) + } + ) + + domains = + Enum.reduce(remote_non_friends, [], fn non_friend, domains -> + {:ok, _user} = User.unblock(user, non_friend) + %{host: host} = URI.parse(non_friend.ap_id) + [host | domains] + end) + + domains = Enum.uniq(domains) + + Enum.each(domains, fn domain -> + {:ok, _} = User.block_domain(user, domain) + end) + + user = User.get_by_id(user.id) + opts = Map.put(opts, "blocking_user", user) + + Benchee.run( + %{ + "public timeline with domain block" => fn opts -> + ActivityPub.fetch_public_activities(opts) + end + }, + inputs: %{ + "old filtering" => Map.delete(opts, :method), + "with psql fun" => Map.put(opts, :method, :fun), + "with unnest" => Map.put(opts, :method, :unnest) + } + ) + end + defp fetch_public_timeline(opts, title) when is_binary(title) do first_page_last = ActivityPub.fetch_public_activities(opts) |> List.last() diff --git a/benchmarks/load_testing/users.ex b/benchmarks/load_testing/users.ex index e4d0b22ff..6cf3958c1 100644 --- a/benchmarks/load_testing/users.ex +++ b/benchmarks/load_testing/users.ex @@ -27,7 +27,7 @@ def generate(opts \\ []) do make_friends(main_user, opts[:friends]) - Repo.get(User, main_user.id) + User.get_by_id(main_user.id) end def generate_users(max) do @@ -166,4 +166,24 @@ defp run_stream(users, main_user) do ) |> Stream.run() end + + @spec prepare_users(User.t(), keyword()) :: map() + def prepare_users(user, opts) do + friends_limit = opts[:friends_used] + non_friends_limit = opts[:non_friends_used] + + %{ + user: user, + friends_local: fetch_users(user, friends_limit, :local, true), + friends_remote: fetch_users(user, friends_limit, :external, true), + non_friends_local: fetch_users(user, non_friends_limit, :local, false), + non_friends_remote: fetch_users(user, non_friends_limit, :external, false) + } + end + + defp fetch_users(user, limit, local, friends?) do + user + |> get_users(limit: limit, local: local, friends?: friends?) + |> Enum.shuffle() + end end diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex index 657403202..1162b2e06 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex @@ -5,7 +5,6 @@ defmodule Mix.Tasks.Pleroma.Benchmarks.Tags do import Ecto.Query alias Pleroma.Repo - alias Pleroma.Web.MastodonAPI.TimelineController def run(_args) do Mix.Pleroma.start_pleroma() @@ -37,7 +36,7 @@ def run(_args) do Benchee.run( %{ "Hashtag fetching, any" => fn tags -> - TimelineController.hashtag_fetching( + hashtag_fetching( %{ "any" => tags }, @@ -47,7 +46,7 @@ def run(_args) do end, # Will always return zero results because no overlapping hashtags are generated. "Hashtag fetching, all" => fn tags -> - TimelineController.hashtag_fetching( + hashtag_fetching( %{ "all" => tags }, @@ -67,7 +66,7 @@ def run(_args) do Benchee.run( %{ "Hashtag fetching" => fn tag -> - TimelineController.hashtag_fetching( + hashtag_fetching( %{ "tag" => tag }, @@ -80,4 +79,35 @@ def run(_args) do time: 5 ) end + + defp hashtag_fetching(params, user, local_only) do + tags = + [params["tag"], params["any"]] + |> List.flatten() + |> Enum.uniq() + |> Enum.filter(& &1) + |> Enum.map(&String.downcase(&1)) + + tag_all = + params + |> Map.get("all", []) + |> Enum.map(&String.downcase(&1)) + + tag_reject = + params + |> Map.get("none", []) + |> Enum.map(&String.downcase(&1)) + + _activities = + params + |> Map.put("type", "Create") + |> Map.put("local_only", local_only) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + |> Map.put("tag", tags) + |> Map.put("tag_all", tag_all) + |> Map.put("tag_reject", tag_reject) + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() + end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b8a2873d8..e7958f7a8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -932,6 +932,33 @@ defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do query = if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query) + # TODO: update after benchmarks + query = + case opts[:method] do + :fun -> + from(a in query, + where: + fragment( + "recipients_contain_blocked_domains(?, ?) = false", + a.recipients, + ^domain_blocks + ) + ) + + :unnest -> + from(a in query, + where: + fragment( + "NOT ? && (SELECT ARRAY(SELECT split_part(UNNEST(?), '/', 3)))", + ^domain_blocks, + a.recipients + ) + ) + + _ -> + query + end + from( [activity, object: o] in query, where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids), diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 8e19bace7..375b441a1 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -62,6 +62,13 @@ def public_operation do only_media_param(), with_muted_param(), exclude_visibilities_param(), + # TODO: remove after benchmarks + Operation.parameter( + :method, + :query, + %Schema{type: :string}, + "Temp parameter" + ), reply_visibility_param() | pagination_params() ], operationId: "TimelineController.public", diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 958567510..1734df4b5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -109,14 +109,23 @@ def public(%{assigns: %{user: user}} = conn, params) do if restrict? and is_nil(user) do render_error(conn, :unauthorized, "authorization required for timeline view") else - activities = + # TODO: return back after benchmarks + params = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_filtering_user", user) - |> ActivityPub.fetch_public_activities() + + params = + if params["method"] do + Map.put(params, :method, String.to_existing_atom(params["method"])) + else + params + end + + activities = ActivityPub.fetch_public_activities(params) conn |> add_link_headers(activities, %{"local" => local_only}) diff --git a/priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs b/priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs new file mode 100644 index 000000000..14e873125 --- /dev/null +++ b/priv/repo/migrations/20200520155351_add_recipients_contain_blocked_domains_function.exs @@ -0,0 +1,33 @@ +defmodule Pleroma.Repo.Migrations.AddRecipientsContainBlockedDomainsFunction do + use Ecto.Migration + @disable_ddl_transaction true + + def up do + statement = """ + CREATE OR REPLACE FUNCTION recipients_contain_blocked_domains(recipients varchar[], blocked_domains varchar[]) RETURNS boolean AS $$ + DECLARE + recipient_domain varchar; + recipient varchar; + BEGIN + FOREACH recipient IN ARRAY recipients LOOP + recipient_domain = split_part(recipient, '/', 3)::varchar; + + IF recipient_domain = ANY(blocked_domains) THEN + RETURN TRUE; + END IF; + END LOOP; + + RETURN FALSE; + END; + $$ LANGUAGE plpgsql; + """ + + execute(statement) + end + + def down do + execute( + "drop function if exists recipients_contain_blocked_domains(recipients varchar[], blocked_domains varchar[])" + ) + end +end diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 2375ac8e8..3474c0cf9 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -90,6 +90,74 @@ test "the public timeline includes only public statuses for an authenticated use res_conn = get(conn, "/api/v1/timelines/public") assert length(json_response_and_validate_schema(res_conn, 200)) == 1 end + + test "doesn't return replies if follower is posting with blocked user" do + %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) + [blockee, friend] = insert_list(2, :user) + {:ok, blocker} = User.follow(blocker, friend) + {:ok, _} = User.block(blocker, blockee) + + conn = assign(conn, :user, blocker) + + {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, reply_from_blockee} = + CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) + + {:ok, _reply_from_friend} = + CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) + + res_conn = get(conn, "/api/v1/timelines/public") + [%{"id" => ^activity_id}] = json_response_and_validate_schema(res_conn, 200) + end + + # TODO: update after benchmarks + test "doesn't return replies if follow is posting with users from blocked domain" do + %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) + friend = insert(:user) + blockee = insert(:user, ap_id: "https://example.com/users/blocked") + {:ok, blocker} = User.follow(blocker, friend) + {:ok, blocker} = User.block_domain(blocker, "example.com") + + conn = assign(conn, :user, blocker) + + {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, reply_from_blockee} = + CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) + + {:ok, _reply_from_friend} = + CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) + + res_conn = get(conn, "/api/v1/timelines/public?method=fun") + + activities = json_response_and_validate_schema(res_conn, 200) + [%{"id" => ^activity_id}] = activities + end + + # TODO: update after benchmarks + test "doesn't return replies if follow is posting with users from blocked domain with unnest param" do + %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) + friend = insert(:user) + blockee = insert(:user, ap_id: "https://example.com/users/blocked") + {:ok, blocker} = User.follow(blocker, friend) + {:ok, blocker} = User.block_domain(blocker, "example.com") + + conn = assign(conn, :user, blocker) + + {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, reply_from_blockee} = + CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) + + {:ok, _reply_from_friend} = + CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) + + res_conn = get(conn, "/api/v1/timelines/public?method=unnest") + + activities = json_response_and_validate_schema(res_conn, 200) + [%{"id" => ^activity_id}] = activities + end end defp local_and_remote_activities do -- cgit v1.2.3 From 19f468c5bc230d6790b00aa87e509a07e709aaa7 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 2 Jun 2020 08:50:24 +0300 Subject: replies filtering for blocked domains --- benchmarks/load_testing/fetcher.ex | 30 ++++---------------- lib/pleroma/web/activity_pub/activity_pub.ex | 33 ++++------------------ .../web/api_spec/operations/timeline_operation.ex | 7 ----- .../controllers/timeline_controller.ex | 13 ++------- .../controllers/timeline_controller_test.exs | 27 +----------------- 5 files changed, 15 insertions(+), 95 deletions(-) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index b278faf9f..22a06e472 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -228,24 +228,16 @@ defp fetch_public_timeline(user, :only_media) do fetch_public_timeline(opts, "public timeline only media") end - # TODO: remove using `:method` after benchmarks defp fetch_public_timeline(user, :with_blocks) do opts = opts_for_public_timeline(user) remote_non_friends = Agent.get(:non_friends_remote, & &1) - Benchee.run( - %{ - "public timeline without blocks" => fn opts -> - ActivityPub.fetch_public_activities(opts) - end - }, - inputs: %{ - "old filtering" => Map.delete(opts, :method), - "with psql fun" => Map.put(opts, :method, :fun), - "with unnest" => Map.put(opts, :method, :unnest) - } - ) + Benchee.run(%{ + "public timeline without blocks" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) Enum.each(remote_non_friends, fn non_friend -> {:ok, _} = User.block(user, non_friend) @@ -257,15 +249,10 @@ defp fetch_public_timeline(user, :with_blocks) do Benchee.run( %{ - "public timeline with user block" => fn opts -> + "public timeline with user block" => fn -> ActivityPub.fetch_public_activities(opts) end }, - inputs: %{ - "old filtering" => Map.delete(opts, :method), - "with psql fun" => Map.put(opts, :method, :fun), - "with unnest" => Map.put(opts, :method, :unnest) - } ) domains = @@ -289,11 +276,6 @@ defp fetch_public_timeline(user, :with_blocks) do "public timeline with domain block" => fn opts -> ActivityPub.fetch_public_activities(opts) end - }, - inputs: %{ - "old filtering" => Map.delete(opts, :method), - "with psql fun" => Map.put(opts, :method, :fun), - "with unnest" => Map.put(opts, :method, :unnest) } ) end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index e7958f7a8..673b10b22 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -932,37 +932,16 @@ defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do query = if has_named_binding?(query, :object), do: query, else: Activity.with_joined_object(query) - # TODO: update after benchmarks - query = - case opts[:method] do - :fun -> - from(a in query, - where: - fragment( - "recipients_contain_blocked_domains(?, ?) = false", - a.recipients, - ^domain_blocks - ) - ) - - :unnest -> - from(a in query, - where: - fragment( - "NOT ? && (SELECT ARRAY(SELECT split_part(UNNEST(?), '/', 3)))", - ^domain_blocks, - a.recipients - ) - ) - - _ -> - query - end - from( [activity, object: o] in query, where: fragment("not (? = ANY(?))", activity.actor, ^blocked_ap_ids), where: fragment("not (? && ?)", activity.recipients, ^blocked_ap_ids), + where: + fragment( + "recipients_contain_blocked_domains(?, ?) = false", + activity.recipients, + ^domain_blocks + ), where: fragment( "not (?->>'type' = 'Announce' and ?->'to' \\?| ?)", diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index 375b441a1..8e19bace7 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -62,13 +62,6 @@ def public_operation do only_media_param(), with_muted_param(), exclude_visibilities_param(), - # TODO: remove after benchmarks - Operation.parameter( - :method, - :query, - %Schema{type: :string}, - "Temp parameter" - ), reply_visibility_param() | pagination_params() ], operationId: "TimelineController.public", diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 1734df4b5..958567510 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -109,23 +109,14 @@ def public(%{assigns: %{user: user}} = conn, params) do if restrict? and is_nil(user) do render_error(conn, :unauthorized, "authorization required for timeline view") else - # TODO: return back after benchmarks - params = + activities = params |> Map.put("type", ["Create", "Announce"]) |> Map.put("local_only", local_only) |> Map.put("blocking_user", user) |> Map.put("muting_user", user) |> Map.put("reply_filtering_user", user) - - params = - if params["method"] do - Map.put(params, :method, String.to_existing_atom(params["method"])) - else - params - end - - activities = ActivityPub.fetch_public_activities(params) + |> ActivityPub.fetch_public_activities() conn |> add_link_headers(activities, %{"local" => local_only}) diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 3474c0cf9..2ad6828ad 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -111,7 +111,6 @@ test "doesn't return replies if follower is posting with blocked user" do [%{"id" => ^activity_id}] = json_response_and_validate_schema(res_conn, 200) end - # TODO: update after benchmarks test "doesn't return replies if follow is posting with users from blocked domain" do %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) friend = insert(:user) @@ -129,31 +128,7 @@ test "doesn't return replies if follow is posting with users from blocked domain {:ok, _reply_from_friend} = CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) - res_conn = get(conn, "/api/v1/timelines/public?method=fun") - - activities = json_response_and_validate_schema(res_conn, 200) - [%{"id" => ^activity_id}] = activities - end - - # TODO: update after benchmarks - test "doesn't return replies if follow is posting with users from blocked domain with unnest param" do - %{conn: conn, user: blocker} = oauth_access(["read:statuses"]) - friend = insert(:user) - blockee = insert(:user, ap_id: "https://example.com/users/blocked") - {:ok, blocker} = User.follow(blocker, friend) - {:ok, blocker} = User.block_domain(blocker, "example.com") - - conn = assign(conn, :user, blocker) - - {:ok, %{id: activity_id} = activity} = CommonAPI.post(friend, %{status: "hey!"}) - - {:ok, reply_from_blockee} = - CommonAPI.post(blockee, %{status: "heya", in_reply_to_status_id: activity}) - - {:ok, _reply_from_friend} = - CommonAPI.post(friend, %{status: "status", in_reply_to_status_id: reply_from_blockee}) - - res_conn = get(conn, "/api/v1/timelines/public?method=unnest") + res_conn = get(conn, "/api/v1/timelines/public") activities = json_response_and_validate_schema(res_conn, 200) [%{"id" => ^activity_id}] = activities -- cgit v1.2.3 From 81fb45a71ba1d606e6a522ac746f3c7a7dd8136b Mon Sep 17 00:00:00 2001 From: Fristi Date: Mon, 1 Jun 2020 16:25:57 +0000 Subject: Translated using Weblate (Dutch) Currently translated at 29.2% (31 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/nl/ --- priv/gettext/nl/LC_MESSAGES/errors.po | 84 ++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/priv/gettext/nl/LC_MESSAGES/errors.po b/priv/gettext/nl/LC_MESSAGES/errors.po index 7e12ff96c..3118f6b5d 100644 --- a/priv/gettext/nl/LC_MESSAGES/errors.po +++ b/priv/gettext/nl/LC_MESSAGES/errors.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-15 09:37+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2020-06-02 07:36+0000\n" +"Last-Translator: Fristi \n" +"Language-Team: Dutch \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.5.1\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.4\n" ## This file is a PO Template file. ## @@ -23,142 +25,142 @@ msgstr "" ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "kan niet leeg zijn" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "is al bezet" ## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "is ongeldig" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" -msgstr "" +msgstr "heeft een ongeldig formaat" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "heeft een ongeldige entry" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "is gereserveerd" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "komt niet overeen met bevestiging" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -msgstr "" +msgstr "is nog geassocieerd met deze entry" msgid "are still associated with this entry" -msgstr "" +msgstr "zijn nog geassocieerd met deze entry" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient %{count} karakter te bevatten" +msgstr[1] "dient %{count} karakters te bevatten" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient %{count} item te bevatten" +msgstr[1] "dient %{count} items te bevatten" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient ten minste %{count} karakter te bevatten" +msgstr[1] "dient ten minste %{count} karakters te bevatten" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient ten minste %{count} item te bevatten" +msgstr[1] "dient ten minste %{count} items te bevatten" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient niet meer dan %{count} karakter te bevatten" +msgstr[1] "dient niet meer dan %{count} karakters te bevatten" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dient niet meer dan %{count} item te bevatten" +msgstr[1] "dient niet meer dan %{count} items te bevatten" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" -msgstr "" +msgstr "dient kleiner te zijn dan %{number}" msgid "must be greater than %{number}" -msgstr "" +msgstr "dient groter te zijn dan %{number}" msgid "must be less than or equal to %{number}" -msgstr "" +msgstr "dient kleiner dan of gelijk te zijn aan %{number}" msgid "must be greater than or equal to %{number}" -msgstr "" +msgstr "dient groter dan of gelijk te zijn aan %{number}" msgid "must be equal to %{number}" -msgstr "" +msgstr "dient gelijk te zijn aan %{number}" #: lib/pleroma/web/common_api/common_api.ex:421 #, elixir-format msgid "Account not found" -msgstr "" +msgstr "Account niet gevonden" #: lib/pleroma/web/common_api/common_api.ex:249 #, elixir-format msgid "Already voted" -msgstr "" +msgstr "Al gestemd" #: lib/pleroma/web/oauth/oauth_controller.ex:360 #, elixir-format msgid "Bad request" -msgstr "" +msgstr "Bad request" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 #, elixir-format msgid "Can't delete object" -msgstr "" +msgstr "Object kan niet verwijderd worden" #: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 #, elixir-format msgid "Can't delete this post" -msgstr "" +msgstr "Bericht kan niet verwijderd worden" #: lib/pleroma/web/controller_helper.ex:95 #: lib/pleroma/web/controller_helper.ex:101 #, elixir-format msgid "Can't display this activity" -msgstr "" +msgstr "Activiteit kan niet worden getoond" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 #, elixir-format msgid "Can't find user" -msgstr "" +msgstr "Gebruiker kan niet gevonden worden" #: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 #, elixir-format msgid "Can't get favorites" -msgstr "" +msgstr "Favorieten konden niet opgehaald worden" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 #, elixir-format msgid "Can't like object" -msgstr "" +msgstr "Object kan niet geliked worden" #: lib/pleroma/web/common_api/utils.ex:556 #, elixir-format msgid "Cannot post an empty status without attachments" -msgstr "" +msgstr "Status kan niet geplaatst worden zonder tekst of bijlagen" #: lib/pleroma/web/common_api/utils.ex:504 #, elixir-format msgid "Comment must be up to %{max_size} characters" -msgstr "" +msgstr "Opmerking dient maximaal %{max_size} karakters te bevatten" #: lib/pleroma/config/config_db.ex:222 #, elixir-format -- cgit v1.2.3 From 165a4b2a690ff7809ebbae65cddff3221d52489a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Mon, 1 Jun 2020 22:18:20 +0300 Subject: Do not include activities of invisible users unless explicitly requested Closes #1833 --- lib/pleroma/user/query.ex | 6 +++--- lib/pleroma/web/activity_pub/activity_pub.ex | 12 ++++++++++++ lib/pleroma/web/admin_api/search.ex | 3 +-- test/tasks/relay_test.exs | 3 ++- .../web/admin_api/controllers/admin_api_controller_test.exs | 13 +++++-------- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 293bbc082..66ffe9090 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -45,7 +45,7 @@ defmodule Pleroma.User.Query do is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), - exclude_service_users: boolean(), + invisible: boolean(), followers: User.t(), friends: User.t(), recipients_from_activity: [String.t()], @@ -89,8 +89,8 @@ defp compose_query({key, value}, query) where(query, [u], ilike(field(u, ^key), ^"%#{value}%")) end - defp compose_query({:exclude_service_users, _}, query) do - where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch")) + defp compose_query({:invisible, bool}, query) when is_boolean(bool) do + where(query, [u], u.invisible == ^bool) end defp compose_query({key, value}, query) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b8a2873d8..a38f9a3c8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1030,6 +1030,17 @@ defp exclude_poll_votes(query, _) do end end + defp exclude_invisible_actors(query, %{"invisible_actors" => true}), do: query + + defp exclude_invisible_actors(query, _opts) do + invisible_ap_ids = + User.Query.build(%{invisible: true, select: [:ap_id]}) + |> Repo.all() + |> Enum.map(fn %{ap_id: ap_id} -> ap_id end) + + from([activity] in query, where: activity.actor not in ^invisible_ap_ids) + end + defp exclude_id(query, %{"exclude_id" => id}) when is_binary(id) do from(activity in query, where: activity.id != ^id) end @@ -1135,6 +1146,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_instance(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) + |> exclude_invisible_actors(opts) |> exclude_visibility(opts) end diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex index c28efadd5..0bfb8f022 100644 --- a/lib/pleroma/web/admin_api/search.ex +++ b/lib/pleroma/web/admin_api/search.ex @@ -21,7 +21,7 @@ def user(params \\ %{}) do query = params |> Map.drop([:page, :page_size]) - |> Map.put(:exclude_service_users, true) + |> Map.put(:invisible, false) |> User.Query.build() |> order_by([u], u.nickname) @@ -31,7 +31,6 @@ def user(params \\ %{}) do count = Repo.aggregate(query, :count, :id) results = Repo.all(paginated_query) - {:ok, results, count} end end diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index d3d88467d..678288854 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -65,7 +65,8 @@ test "relay is unfollowed" do "type" => "Undo", "actor_id" => follower_id, "limit" => 1, - "skip_preload" => true + "skip_preload" => true, + "invisible_actors" => true }) assert undo_activity.data["type"] == "Undo" diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index ead840186..193690469 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -757,8 +757,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do end test "pagination works correctly with service users", %{conn: conn} do - service1 = insert(:user, ap_id: Web.base_url() <> "/relay") - service2 = insert(:user, ap_id: Web.base_url() <> "/internal/fetch") + service1 = User.get_or_create_service_actor_by_ap_id(Web.base_url() <> "/meido", "meido") + insert_list(25, :user) assert %{"count" => 26, "page_size" => 10, "users" => users1} = @@ -767,8 +767,7 @@ test "pagination works correctly with service users", %{conn: conn} do |> json_response(200) assert Enum.count(users1) == 10 - assert service1 not in [users1] - assert service2 not in [users1] + assert service1 not in users1 assert %{"count" => 26, "page_size" => 10, "users" => users2} = conn @@ -776,8 +775,7 @@ test "pagination works correctly with service users", %{conn: conn} do |> json_response(200) assert Enum.count(users2) == 10 - assert service1 not in [users2] - assert service2 not in [users2] + assert service1 not in users2 assert %{"count" => 26, "page_size" => 10, "users" => users3} = conn @@ -785,8 +783,7 @@ test "pagination works correctly with service users", %{conn: conn} do |> json_response(200) assert Enum.count(users3) == 6 - assert service1 not in [users3] - assert service2 not in [users3] + assert service1 not in users3 end test "renders empty array for the second page", %{conn: conn} do -- cgit v1.2.3 From 805ab86933d90d4284c83e4a8ebfd6bf4b0395b3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 13:24:34 +0200 Subject: Notifications: Make notifications save their type. --- lib/pleroma/following_relationship.ex | 11 ++-- lib/pleroma/notification.ex | 61 +++++++++++++++++++++- lib/pleroma/web/activity_pub/transmogrifier.ex | 3 ++ .../api_spec/operations/notification_operation.ex | 1 + lib/pleroma/web/common_api/common_api.ex | 1 + .../web/mastodon_api/views/notification_view.ex | 20 +------ .../20200602094828_add_type_to_notifications.exs | 9 ++++ test/notification_test.exs | 11 ++-- 8 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 priv/repo/migrations/20200602094828_add_type_to_notifications.exs diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 3a3082e72..0343a20d4 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -62,10 +62,13 @@ def update(%User{} = follower, %User{} = following, state) do follow(follower, following, state) following_relationship -> - following_relationship - |> cast(%{state: state}, [:state]) - |> validate_required([:state]) - |> Repo.update() + {:ok, relationship} = + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() + + {:ok, relationship} end end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index efafbce48..41ac53505 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -30,12 +30,26 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) + field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) timestamps() end + def update_notification_type(user, activity) do + with %__MODULE__{} = notification <- + Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do + type = + activity + |> type_from_activity() + + notification + |> changeset(%{type: type}) + |> Repo.update() + end + end + @spec unread_notifications_count(User.t()) :: integer() def unread_notifications_count(%User{id: user_id}) do from(q in __MODULE__, @@ -46,7 +60,7 @@ def unread_notifications_count(%User{id: user_id}) do def changeset(%Notification{} = notification, attrs) do notification - |> cast(attrs, [:seen]) + |> cast(attrs, [:seen, :type]) end @spec last_read_query(User.t()) :: Ecto.Queryable.t() @@ -330,12 +344,55 @@ defp do_create_notifications(%Activity{} = activity) do {:ok, notifications} end + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + if Activity.follow_accepted?(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + "Create" -> + activity + |> type_from_activity_object() + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.normalize(activity, false) + + case object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end + # TODO move to sql, too. def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do unless skip?(activity, user) do {:ok, %{notification: notification}} = Multi.new() - |> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity}) + |> Multi.insert(:notification, %Notification{ + user_id: user.id, + activity: activity, + type: type_from_activity(activity) + }) |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4ac0d43fc..886403fcd 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo @@ -595,6 +596,8 @@ def handle_incoming( User.update_follower_count(followed) User.update_following_count(follower) + Notification.update_notification_type(followed, follow_activity) + ActivityPub.accept(%{ to: follow_activity.data["to"], type: "Accept", diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 46e72f8bf..c966b553a 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -185,6 +185,7 @@ defp notification_type do "mention", "poll", "pleroma:emoji_reaction", + "pleroma:chat_mention", "move", "follow_request" ], diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index e0987b1a7..5a194910d 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -121,6 +121,7 @@ def accept_follow_request(follower, followed) do object: follow_activity.data["id"], type: "Accept" }) do + Notification.update_notification_type(followed, follow_activity) {:ok, follower} end end diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 07d55a3e9..c090be8ad 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -81,22 +81,6 @@ def render( end end - # This returns the notification type by activity, but both chats and statuses - # are in "Create" activities. - mastodon_type = - case Activity.mastodon_notification_type(activity) do - "mention" -> - object = Object.normalize(activity) - - case object do - %{data: %{"type" => "ChatMessage"}} -> "pleroma:chat_mention" - _ -> "mention" - end - - type -> - type - end - # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} @@ -107,7 +91,7 @@ def render( ) do response = %{ id: to_string(notification.id), - type: mastodon_type, + type: notification.type, created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), account: account, pleroma: %{ @@ -115,7 +99,7 @@ def render( } } - case mastodon_type do + case notification.type do "mention" -> put_status(response, activity, reading_user, status_render_opts) diff --git a/priv/repo/migrations/20200602094828_add_type_to_notifications.exs b/priv/repo/migrations/20200602094828_add_type_to_notifications.exs new file mode 100644 index 000000000..19c733628 --- /dev/null +++ b/priv/repo/migrations/20200602094828_add_type_to_notifications.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddTypeToNotifications do + use Ecto.Migration + + def change do + alter table(:notifications) do + add(:type, :string) + end + end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index 37c255fee..421b7fc40 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -31,6 +31,7 @@ test "creates a notification for an emoji reaction" do {:ok, [notification]} = Notification.create_notifications(activity) assert notification.user_id == user.id + assert notification.type == "pleroma:emoji_reaction" end test "notifies someone when they are directly addressed" do @@ -48,6 +49,7 @@ test "notifies someone when they are directly addressed" do notified_ids = Enum.sort([notification.user_id, other_notification.user_id]) assert notified_ids == [other_user.id, third_user.id] assert notification.activity_id == activity.id + assert notification.type == "mention" assert other_notification.activity_id == activity.id assert [%Pleroma.Marker{unread_count: 2}] = @@ -335,9 +337,12 @@ test "it creates `follow_request` notification for pending Follow activity" do # After request is accepted, the same notification is rendered with type "follow": assert {:ok, _} = CommonAPI.accept_follow_request(user, followed_user) - notification_id = notification.id - assert [%{id: ^notification_id}] = Notification.for_user(followed_user) - assert %{type: "follow"} = NotificationView.render("show.json", render_opts) + notification = + Repo.get(Notification, notification.id) + |> Repo.preload(:activity) + + assert %{type: "follow"} = + NotificationView.render("show.json", notification: notification, for: followed_user) end test "it doesn't create a notification for follow-unfollow-follow chains" do -- cgit v1.2.3 From 127ccc4e1c76c2782b26a0cfbb154bc1317f31b3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 14:05:53 +0200 Subject: NotificationController: Don't return chat_mentions by default. --- docs/API/chats.md | 2 +- .../controllers/notification_controller.ex | 14 +++++++++++++- lib/pleroma/web/mastodon_api/mastodon_api.ex | 15 ++------------- .../controllers/notification_controller_test.exs | 21 +++++++++++++++++++++ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 2eca5adf6..d1d39f495 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -204,7 +204,7 @@ Returned data is the deleted message. ### Notifications -There's a new `pleroma:chat_mention` notification, which has this form: +There's a new `pleroma:chat_mention` notification, which has this form. It is not given out in the notifications endpoint by default, you need to explicitly request it with `include_types[]=pleroma:chat_mention`: ```json { diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex index bcd12c73f..e25cef30b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex @@ -42,8 +42,20 @@ def index(conn, %{account_id: account_id} = params) do end end + @default_notification_types ~w{ + mention + follow + follow_request + reblog + favourite + move + pleroma:emoji_reaction + } def index(%{assigns: %{user: user}} = conn, params) do - params = Map.new(params, fn {k, v} -> {to_string(k), v} end) + params = + Map.new(params, fn {k, v} -> {to_string(k), v} end) + |> Map.put_new("include_types", @default_notification_types) + notifications = MastodonAPI.get_notifications(user, params) conn diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index 70da64a7a..694bf5ca8 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do import Ecto.Query import Ecto.Changeset - alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Pagination alias Pleroma.ScheduledActivity @@ -82,15 +81,11 @@ defp cast_params(params) do end defp restrict(query, :include_types, %{include_types: mastodon_types = [_ | _]}) do - ap_types = convert_and_filter_mastodon_types(mastodon_types) - - where(query, [q, a], fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + where(query, [n], n.type in ^mastodon_types) end defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do - ap_types = convert_and_filter_mastodon_types(mastodon_types) - - where(query, [q, a], not fragment("? @> ARRAY[?->>'type']::varchar[]", ^ap_types, a.data)) + where(query, [n], n.type not in ^mastodon_types) end defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do @@ -98,10 +93,4 @@ defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do end defp restrict(query, _, _), do: query - - defp convert_and_filter_mastodon_types(types) do - types - |> Enum.map(&Activity.from_mastodon_notification_type/1) - |> Enum.filter(& &1) - 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 e278d61f5..698c99711 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -54,6 +54,27 @@ test "list of notifications" do assert response == expected_response end + test "by default, does not contain pleroma:chat_mention" do + %{user: user, conn: conn} = oauth_access(["read:notifications"]) + other_user = insert(:user) + + {:ok, _activity} = CommonAPI.post_chat_message(other_user, user, "hey") + + result = + conn + |> get("/api/v1/notifications") + |> json_response_and_validate_schema(200) + + assert [] == result + + result = + conn + |> get("/api/v1/notifications?include_types[]=pleroma:chat_mention") + |> json_response_and_validate_schema(200) + + assert [_] = result + end + test "getting a single notification" do %{user: user, conn: conn} = oauth_access(["read:notifications"]) other_user = insert(:user) -- cgit v1.2.3 From 37542a9dfa99cc4324f211b45254acea758ac1ae Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 14:22:16 +0200 Subject: Activity: Remove notifications-related functions. --- lib/pleroma/activity.ex | 36 ---------------------- .../web/mastodon_api/views/notification_view.ex | 15 +++++---- lib/pleroma/web/push/impl.ex | 14 +++------ test/web/push/impl_test.exs | 25 ++++++++------- 4 files changed, 26 insertions(+), 64 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 6213d0eb7..da1be20b3 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -24,16 +24,6 @@ defmodule Pleroma.Activity do @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} - # https://github.com/tootsuite/mastodon/blob/master/app/models/notification.rb#L19 - @mastodon_notification_types %{ - "Create" => "mention", - "Follow" => ["follow", "follow_request"], - "Announce" => "reblog", - "Like" => "favourite", - "Move" => "move", - "EmojiReact" => "pleroma:emoji_reaction" - } - schema "activities" do field(:data, :map) field(:local, :boolean, default: true) @@ -300,32 +290,6 @@ def follow_accepted?( def follow_accepted?(_), do: false - @spec mastodon_notification_type(Activity.t()) :: String.t() | nil - - for {ap_type, type} <- @mastodon_notification_types, not is_list(type) do - def mastodon_notification_type(%Activity{data: %{"type" => unquote(ap_type)}}), - do: unquote(type) - end - - def mastodon_notification_type(%Activity{data: %{"type" => "Follow"}} = activity) do - if follow_accepted?(activity) do - "follow" - else - "follow_request" - end - end - - def mastodon_notification_type(%Activity{}), do: nil - - @spec from_mastodon_notification_type(String.t()) :: String.t() | nil - @doc "Converts Mastodon notification type to AR activity type" - def from_mastodon_notification_type(type) do - with {k, _v} <- - Enum.find(@mastodon_notification_types, fn {_k, v} -> type in List.wrap(v) end) do - k - end - end - def all_by_actor_and_id(actor, status_ids \\ []) def all_by_actor_and_id(_actor, []), do: [] diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index c090be8ad..af15bba48 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -16,18 +16,17 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.PleromaAPI.ChatMessageView + @parent_types ~w{Like Announce EmojiReact} + def render("index.json", %{notifications: notifications, for: reading_user} = opts) do activities = Enum.map(notifications, & &1.activity) parent_activities = activities - |> Enum.filter( - &(Activity.mastodon_notification_type(&1) in [ - "favourite", - "reblog", - "pleroma:emoji_reaction" - ]) - ) + |> Enum.filter(fn + %{data: %{"type" => type}} -> + type in @parent_types + end) |> Enum.map(& &1.data["object"]) |> Activity.create_by_object_ap_id() |> Activity.with_preloaded_object(:left) @@ -44,7 +43,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op true -> move_activities_targets = activities - |> Enum.filter(&(Activity.mastodon_notification_type(&1) == "move")) + |> Enum.filter(&(&1.data["type"] == "Move")) |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) actors = diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 691725702..125f33755 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,8 +16,6 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query - defdelegate mastodon_notification_type(activity), to: Activity - @types ["Create", "Follow", "Announce", "Like", "Move"] @doc "Performs sending notifications for user subscriptions" @@ -31,7 +29,7 @@ def perform( when activity_type in @types do actor = User.get_cached_by_ap_id(notification.activity.data["actor"]) - mastodon_type = mastodon_notification_type(notification.activity) + mastodon_type = notification.type gcm_api_key = Application.get_env(:web_push_encryption, :gcm_api_key) avatar_url = User.avatar_url(actor) object = Object.normalize(activity) @@ -116,7 +114,7 @@ def build_content( end def build_content(notification, actor, object, mastodon_type) do - mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + mastodon_type = mastodon_type || notification.type %{ title: format_title(notification, mastodon_type), @@ -151,7 +149,7 @@ def format_body( mastodon_type ) when type in ["Follow", "Like"] do - mastodon_type = mastodon_type || mastodon_notification_type(notification.activity) + mastodon_type = mastodon_type || notification.type case mastodon_type do "follow" -> "@#{actor.nickname} has followed you" @@ -166,10 +164,8 @@ def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_typ "New Direct Message" end - def format_title(%{activity: activity}, mastodon_type) do - mastodon_type = mastodon_type || mastodon_notification_type(activity) - - case mastodon_type do + def format_title(%{type: type}, mastodon_type) do + case mastodon_type || type do "mention" -> "New Mention" "follow" -> "New Follower" "follow_request" -> "New Follow Request" diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index a826b24c9..26c65bc82 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -60,7 +60,8 @@ test "performs sending notifications" do notif = insert(:notification, user: user, - activity: activity + activity: activity, + type: "mention" ) assert Impl.perform(notif) == {:ok, [:ok, :ok]} @@ -126,7 +127,7 @@ test "renders title and body for create activity" do ) == "@Bob: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini..." - assert Impl.format_title(%{activity: activity}) == + assert Impl.format_title(%{activity: activity, type: "mention"}) == "New Mention" end @@ -136,9 +137,10 @@ test "renders title and body for follow activity" do {:ok, _, _, activity} = CommonAPI.follow(user, other_user) object = Object.normalize(activity, false) - assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has followed you" + assert Impl.format_body(%{activity: activity, type: "follow"}, user, object) == + "@Bob has followed you" - assert Impl.format_title(%{activity: activity}) == + assert Impl.format_title(%{activity: activity, type: "follow"}) == "New Follower" end @@ -157,7 +159,7 @@ test "renders title and body for announce activity" do assert Impl.format_body(%{activity: announce_activity}, user, object) == "@#{user.nickname} repeated: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini..." - assert Impl.format_title(%{activity: announce_activity}) == + assert Impl.format_title(%{activity: announce_activity, type: "reblog"}) == "New Repeat" end @@ -173,9 +175,10 @@ test "renders title and body for like activity" do {:ok, activity} = CommonAPI.favorite(user, activity.id) object = Object.normalize(activity) - assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has favorited your post" + assert Impl.format_body(%{activity: activity, type: "favourite"}, user, object) == + "@Bob has favorited your post" - assert Impl.format_title(%{activity: activity}) == + assert Impl.format_title(%{activity: activity, type: "favourite"}) == "New Favorite" end @@ -218,7 +221,7 @@ test "hides details for notifications when privacy option enabled" do status: "Lorem ipsum dolor sit amet, consectetur :firefox: adipiscing elit. Fusce sagittis finibus turpis." }) - notif = insert(:notification, user: user2, activity: activity) + notif = insert(:notification, user: user2, activity: activity, type: "mention") actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) object = Object.normalize(activity) @@ -281,7 +284,7 @@ test "returns regular content for notifications with privacy option disabled" do {:ok, activity} = CommonAPI.favorite(user, activity.id) - notif = insert(:notification, user: user2, activity: activity) + notif = insert(:notification, user: user2, activity: activity, type: "favourite") actor = User.get_cached_by_ap_id(notif.activity.data["actor"]) object = Object.normalize(activity) -- cgit v1.2.3 From 38dce485c47e9315663c5c9cfd67dab4164b1bbe Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 14:49:56 +0200 Subject: Notification: Add function to backfill notification types --- lib/pleroma/notification.ex | 20 ++++++++++++++++++++ test/notification_test.exs | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 41ac53505..c8b964400 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -37,6 +37,26 @@ defmodule Pleroma.Notification do timestamps() end + def fill_in_notification_types() do + query = + from(n in __MODULE__, + where: is_nil(n.type), + preload: :activity + ) + + query + |> Repo.all() + |> Enum.each(fn notification -> + type = + notification.activity + |> type_from_activity() + + notification + |> changeset(%{type: type}) + |> Repo.update() + end) + end + def update_notification_type(user, activity) do with %__MODULE__{} = notification <- Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do diff --git a/test/notification_test.exs b/test/notification_test.exs index 421b7fc40..6bc2b6904 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -20,6 +20,34 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer + describe "fill_in_notification_types" do + test "it fills in missing notification types" do + user = insert(:user) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) + {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") + {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + {:ok, like} = CommonAPI.favorite(other_user, post.id) + + assert {4, nil} = Repo.update_all(Notification, set: [type: nil]) + + Notification.fill_in_notification_types() + + assert %{type: "mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) + + assert %{type: "favourite"} = + Repo.get_by(Notification, user_id: user.id, activity_id: like.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + + assert %{type: "pleroma:chat_mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) + end + end + describe "create_notifications" do test "creates a notification for an emoji reaction" do user = insert(:user) -- cgit v1.2.3 From 6cd2fa2a4cbffaaab7c911f1051d4917e8a06c78 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 15:13:19 +0200 Subject: Migrations: Add a migration to backfill notification types. --- lib/pleroma/notification.ex | 23 ++++++++++++++++++---- .../20200602125218_backfill_notification_types.exs | 10 ++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 priv/repo/migrations/20200602125218_backfill_notification_types.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index c8b964400..d89ee4645 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -49,7 +49,7 @@ def fill_in_notification_types() do |> Enum.each(fn notification -> type = notification.activity - |> type_from_activity() + |> type_from_activity(no_cachex: true) notification |> changeset(%{type: type}) @@ -364,10 +364,23 @@ defp do_create_notifications(%Activity{} = activity) do {:ok, notifications} end - defp type_from_activity(%{data: %{"type" => type}} = activity) do + defp type_from_activity(%{data: %{"type" => type}} = activity, opts \\ []) do case type do "Follow" -> - if Activity.follow_accepted?(activity) do + accepted_function = + if Keyword.get(opts, :no_cachex, false) do + # A special function to make this usable in a migration. + fn activity -> + with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), + %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do + Pleroma.FollowingRelationship.following?(follower, followed) + end + end + else + &Activity.follow_accepted?/1 + end + + if accepted_function.(activity) do "follow" else "follow_request" @@ -394,8 +407,10 @@ defp type_from_activity(%{data: %{"type" => type}} = activity) do end end + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do - object = Object.normalize(activity, false) + object = Object.get_by_ap_id(activity.data["object"]) case object.data["type"] do "ChatMessage" -> "pleroma:chat_mention" diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs new file mode 100644 index 000000000..493c0280c --- /dev/null +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do + use Ecto.Migration + + def up do + Pleroma.Notification.fill_in_notification_types() + end + + def down do + end +end -- cgit v1.2.3 From 2c6ebe709a9fb84bedb5d50c24715fd4532272f9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 2 Jun 2020 15:14:52 +0200 Subject: Credo fixes --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d89ee4645..0f33d282d 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -37,7 +37,7 @@ defmodule Pleroma.Notification do timestamps() end - def fill_in_notification_types() do + def fill_in_notification_types do query = from(n in __MODULE__, where: is_nil(n.type), -- cgit v1.2.3 From f73b2063f484e83c0972527c00c42d4fbdd11a0c Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 2 Jun 2020 10:18:06 -0400 Subject: encode data properly --- lib/pleroma/web/fallback_redirect_controller.ex | 7 ++-- lib/pleroma/web/preload.ex | 2 ++ lib/pleroma/web/preload/timelines.ex | 31 ++++++++--------- test/web/fallback_test.exs | 46 +++++++++++++++---------- test/web/preload/timeline_test.exs | 12 +++---- test/web/streamer/streamer_test.exs | 2 +- 6 files changed, 55 insertions(+), 45 deletions(-) diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 932fb8d7e..431ad5485 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,7 +4,9 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller + require Logger + alias Pleroma.User alias Pleroma.Web.Metadata alias Pleroma.Web.Preload @@ -38,8 +40,7 @@ def redirector_with_meta(conn, params) do response = index_content - |> String.replace("", tags) - |> String.replace("", preloads) + |> String.replace("", tags <> preloads) conn |> put_resp_content_type("text/html") @@ -56,7 +57,7 @@ def redirector_with_preload(conn, params) do response = index_content - |> String.replace("", preloads) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index c2211c597..f13932b89 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -22,6 +22,8 @@ def build_tags(_conn, params) do end def build_script_tag(content) do + content = Base.encode64(content) + HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index dbd7db407..2bb57567b 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -11,32 +11,29 @@ defmodule Pleroma.Web.Preload.Providers.Timelines do @public_url :"/api/v1/timelines/public" @impl Provider - def generate_terms(_params) do - build_public_tag(%{}) + def generate_terms(params) do + build_public_tag(%{}, params) end - def build_public_tag(acc) do + def build_public_tag(acc, params) do if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do acc else - Map.put(acc, @public_url, public_timeline(nil)) + Map.put(acc, @public_url, public_timeline(params)) end end - defp public_timeline(user) do - activities = - create_timeline_params(user) - |> Map.put("local_only", false) - |> ActivityPub.fetch_public_activities() + defp public_timeline(%{"path" => ["main", "all"]}), do: get_public_timeline(false) - StatusView.render("index.json", activities: activities, for: user, as: :activity) - end + defp public_timeline(_params), do: get_public_timeline(true) + + defp get_public_timeline(local_only) do + activities = + ActivityPub.fetch_public_activities(%{ + "type" => ["Create"], + "local_only" => local_only + }) - defp create_timeline_params(user) do - %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + StatusView.render("index.json", activities: activities, for: nil, as: :activity) end end diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3b7a51d5e..a65865860 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -11,7 +11,12 @@ test "GET /registration/:token", %{conn: conn} do response = get(conn, "/registration/foo") assert html_response(response, 200) =~ "" - assert html_response(response, 200) =~ "" + end + + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" end end @@ -21,20 +26,35 @@ test "GET /:maybe_nickname_or_id", %{conn: conn} do user_missing = get(conn, "/foo") user_present = get(conn, "/#{user.nickname}") - assert html_response(user_missing, 200) =~ "" + assert(html_response(user_missing, 200) =~ "") refute html_response(user_present, 200) =~ "" + assert html_response(user_present, 200) =~ "initial-results" + end - assert html_response(user_missing, 200) =~ "" - refute html_response(user_present, 200) =~ "" + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" + + refute conn + |> get("/foo/bar") + |> html_response(200) =~ "" end end - describe "preloaded data only attached to" do - test "GET /*path", %{conn: conn} do + describe "preloaded data is attached to" do + test "GET /main/public", %{conn: conn} do public_page = get(conn, "/main/public") - assert html_response(public_page, 200) =~ "" - refute html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" + end + + test "GET /main/all", %{conn: conn} do + public_page = get(conn, "/main/all") + + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" end end @@ -48,16 +68,6 @@ test "GET /pleroma/admin -> /pleroma/admin/", %{conn: conn} do assert redirected_to(get(conn, "/pleroma/admin")) =~ "/pleroma/admin/" end - test "GET /*path", %{conn: conn} do - assert conn - |> get("/foo") - |> html_response(200) =~ "" - - assert conn - |> get("/foo/bar") - |> html_response(200) =~ "" - end - test "OPTIONS /*path", %{conn: conn} do assert conn |> options("/foo") diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index 00b10d0ab..da6a3aded 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -52,9 +52,9 @@ test "returns the timeline when not restricted" do end test "returns public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) @@ -62,9 +62,9 @@ test "returns public items", %{user: user} do end test "does not return non-public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!", visibility: "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 4cf640ce8..3f012259a 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -124,7 +124,7 @@ test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do |> Map.put("object", activity.data["object"]) |> Map.put("actor", user.ap_id) - {:ok, %Pleroma.Activity{data: data, local: false} = announce} = + {:ok, %Pleroma.Activity{data: _data, local: false} = announce} = Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data) assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce} -- cgit v1.2.3 From 7922e63825e2e25ccb52ae6e0a6c0011207a598d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 2 Jun 2020 19:07:17 +0400 Subject: Update OpenAPI spec for AdminAPI.StatusController --- lib/pleroma/web/admin_api/controllers/status_controller.ex | 4 +--- lib/pleroma/web/api_spec/operations/admin/status_operation.ex | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index c91fbc771..574196be8 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -41,9 +41,7 @@ def index(%{assigns: %{user: _admin}} = conn, params) do def show(conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do - conn - |> put_view(Pleroma.Web.AdminAPI.StatusView) - |> render("show.json", %{activity: activity}) + render(conn, "show.json", %{activity: activity}) else nil -> {:error, :not_found} end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex index 0b138dc79..2947e6b34 100644 --- a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -74,7 +74,7 @@ def show_operation do parameters: [id_param()], security: [%{"oAuth" => ["read:statuses"]}], responses: %{ - 200 => Operation.response("Status", "application/json", Status), + 200 => Operation.response("Status", "application/json", status()), 404 => Operation.response("Not Found", "application/json", ApiError) } } -- cgit v1.2.3 From 030240ee8f80472c8dab0c1f9bb2f30f4271272f Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Wed, 3 Jun 2020 04:36:09 +0000 Subject: docs: clients.md: Add Husky --- docs/clients.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/clients.md b/docs/clients.md index 7f98dc7b1..ea751637e 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -42,6 +42,12 @@ Feel free to contact us to be added to this list! - Platforms: SailfishOS - Features: No Streaming +### Husky +- Source code: +- Contact: [@Husky@enigmatic.observer](https://enigmatic.observer/users/Husky) +- Platforms: Android +- Features: No Streaming, Emoji Reactions, Text Formatting, FE Stickers + ### Nekonium - Homepage: [F-Droid Repository](https://repo.gdgd.jp.net/), [Google Play](https://play.google.com/store/apps/details?id=com.apps.nekonium), [Amazon](https://www.amazon.co.jp/dp/B076FXPRBC/) - Source: -- cgit v1.2.3 From aa22fce8f46cf2e7f871b3584fbfff7ac2ebe4c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 12:30:12 +0200 Subject: ChatMessageReference: Introduce and switch in chat controller. --- lib/pleroma/chat.ex | 5 ++ lib/pleroma/chat_message_reference.ex | 80 ++++++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 9 ++- .../web/pleroma_api/controllers/chat_controller.ex | 60 +++++++++------- .../views/chat_message_reference_view.ex | 45 ++++++++++++ .../web/pleroma_api/views/chat_message_view.ex | 36 ---------- ...0200602150528_create_chat_message_reference.exs | 20 ++++++ test/web/activity_pub/side_effects_test.exs | 13 +++- .../controllers/chat_controller_test.exs | 22 ++++-- .../views/chat_message_reference_view_test.exs | 62 +++++++++++++++++ .../pleroma_api/views/chat_message_view_test.exs | 54 --------------- 11 files changed, 283 insertions(+), 123 deletions(-) create mode 100644 lib/pleroma/chat_message_reference.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex delete mode 100644 lib/pleroma/web/pleroma_api/views/chat_message_view.ex create mode 100644 priv/repo/migrations/20200602150528_create_chat_message_reference.exs create mode 100644 test/web/pleroma_api/views/chat_message_reference_view_test.exs delete mode 100644 test/web/pleroma_api/views/chat_message_view_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 4c92a58c7..211b872f9 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -72,6 +72,11 @@ def creation_cng(struct, params) do |> unique_constraint(:user_id, name: :chats_user_id_recipient_index) end + def get_by_id(id) do + __MODULE__ + |> Repo.get(id) + end + def get(user_id, recipient) do __MODULE__ |> Repo.get_by(user_id: user_id, recipient: recipient) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex new file mode 100644 index 000000000..e9ca3dfe8 --- /dev/null +++ b/lib/pleroma/chat_message_reference.ex @@ -0,0 +1,80 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ChatMessageReference do + @moduledoc """ + A reference that builds a relation between an AP chat message that a user can see and whether it has been seen + by them, or should be displayed to them. Used to build the chat view that is presented to the user. + """ + + use Ecto.Schema + + alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Repo + + import Ecto.Changeset + import Ecto.Query + + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + + schema "chat_message_references" do + belongs_to(:object, Object) + belongs_to(:chat, Chat) + + field(:seen, :boolean, default: false) + + timestamps() + end + + def changeset(struct, params) do + struct + |> cast(params, [:object_id, :chat_id, :seen]) + |> validate_required([:object_id, :chat_id, :seen]) + end + + def get_by_id(id) do + __MODULE__ + |> Repo.get(id) + |> Repo.preload(:object) + end + + def delete(cm_ref) do + cm_ref + |> Repo.delete() + end + + def delete_for_object(%{id: object_id}) do + from(cr in __MODULE__, + where: cr.object_id == ^object_id + ) + |> Repo.delete_all() + end + + def for_chat_and_object(%{id: chat_id}, %{id: object_id}) do + __MODULE__ + |> Repo.get_by(chat_id: chat_id, object_id: object_id) + |> Repo.preload(:object) + end + + def for_chat_query(chat) do + from(cr in __MODULE__, + where: cr.chat_id == ^chat.id, + order_by: [desc: :id], + preload: [:object] + ) + end + + def create(chat, object, seen) do + params = %{ + chat_id: chat.id, + object_id: object.id, + seen: seen + } + + %__MODULE__{} + |> changeset(params) + |> Repo.insert() + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a34bf6a05..cda52b00e 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do """ alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -104,6 +105,8 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, Object.decrease_replies_count(in_reply_to) end + ChatMessageReference.delete_for_object(deleted_object) + ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) :ok @@ -137,9 +140,11 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do if user.ap_id == actor.ap_id do - Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + ChatMessageReference.create(chat, object, true) else - Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + ChatMessageReference.create(chat, object, false) end end end) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 210c8ec4a..c54681054 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -6,14 +6,15 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageView alias Pleroma.Web.PleromaAPI.ChatView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView import Ecto.Query import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] @@ -35,28 +36,38 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation - def delete_message(%{assigns: %{user: %{ap_id: actor} = user}} = conn, %{ - message_id: id + def delete_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + message_id: message_id, + id: chat_id }) do - with %Object{ - data: %{ - "actor" => ^actor, - "id" => object, - "to" => [recipient], - "type" => "ChatMessage" - } - } = message <- Object.get_by_id(id), - %Chat{} = chat <- Chat.get(user.id, recipient), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(object), - {:ok, _delete} <- CommonAPI.delete(activity.id, user) do + with %ChatMessageReference{} = cm_ref <- + ChatMessageReference.get_by_id(message_id), + ^chat_id <- cm_ref.chat_id |> to_string(), + %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), + {:ok, _} <- remove_or_delete(cm_ref, user) do conn - |> put_view(ChatMessageView) - |> render("show.json", for: user, object: message, chat: chat) + |> put_view(ChatMessageReferenceView) + |> render("show.json", chat_message_reference: cm_ref) else - _e -> {:error, :could_not_delete} + _e -> + {:error, :could_not_delete} end end + defp remove_or_delete( + %{object: %{data: %{"actor" => actor, "id" => id}}}, + %{ap_id: actor} = user + ) do + with %Activity{} = activity <- Activity.get_create_by_object_ap_id(id) do + CommonAPI.delete(activity.id, user) + end + end + + defp remove_or_delete(cm_ref, _) do + cm_ref + |> ChatMessageReference.delete() + end + def post_chat_message( %{body_params: params, assigns: %{user: %{id: user_id} = user}} = conn, %{ @@ -69,10 +80,11 @@ def post_chat_message( CommonAPI.post_chat_message(user, recipient, params[:content], media_id: params[:media_id] ), - message <- Object.normalize(activity) do + message <- Object.normalize(activity, false), + cm_ref <- ChatMessageReference.for_chat_and_object(chat, message) do conn - |> put_view(ChatMessageView) - |> render("show.json", for: user, object: message, chat: chat) + |> put_view(ChatMessageReferenceView) + |> render("show.json", for: user, chat_message_reference: cm_ref) end end @@ -87,14 +99,14 @@ def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do - messages = + cm_refs = chat - |> Chat.messages_for_chat_query() + |> ChatMessageReference.for_chat_query() |> Pagination.fetch_paginated(params |> stringify_keys()) conn - |> put_view(ChatMessageView) - |> render("index.json", for: user, objects: messages, chat: chat) + |> put_view(ChatMessageReferenceView) + |> render("index.json", for: user, chat_message_references: cm_refs) else _ -> conn diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex new file mode 100644 index 000000000..ff170e162 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex @@ -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.PleromaAPI.ChatMessageReferenceView do + use Pleroma.Web, :view + + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.StatusView + + def render( + "show.json", + %{ + chat_message_reference: %{ + id: id, + object: %{data: chat_message}, + chat_id: chat_id, + seen: seen + } + } + ) do + %{ + id: id |> to_string(), + content: chat_message["content"], + chat_id: chat_id |> to_string(), + account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, + created_at: Utils.to_masto_date(chat_message["published"]), + emojis: StatusView.build_emojis(chat_message["emoji"]), + attachment: + chat_message["attachment"] && + StatusView.render("attachment.json", attachment: chat_message["attachment"]), + seen: seen + } + end + + def render("index.json", opts) do + render_many( + opts[:chat_message_references], + __MODULE__, + "show.json", + Map.put(opts, :as, :chat_message_reference) + ) + end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_view.ex deleted file mode 100644 index b088a8734..000000000 --- a/lib/pleroma/web/pleroma_api/views/chat_message_view.ex +++ /dev/null @@ -1,36 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.ChatMessageView do - use Pleroma.Web, :view - - alias Pleroma.Chat - alias Pleroma.User - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.MastodonAPI.StatusView - - def render( - "show.json", - %{ - object: %{id: id, data: %{"type" => "ChatMessage"} = chat_message}, - chat: %Chat{id: chat_id} - } - ) do - %{ - id: id |> to_string(), - content: chat_message["content"], - chat_id: chat_id |> to_string(), - account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, - created_at: Utils.to_masto_date(chat_message["published"]), - emojis: StatusView.build_emojis(chat_message["emoji"]), - attachment: - chat_message["attachment"] && - StatusView.render("attachment.json", attachment: chat_message["attachment"]) - } - end - - def render("index.json", opts) do - render_many(opts[:objects], __MODULE__, "show.json", Map.put(opts, :as, :object)) - end -end diff --git a/priv/repo/migrations/20200602150528_create_chat_message_reference.exs b/priv/repo/migrations/20200602150528_create_chat_message_reference.exs new file mode 100644 index 000000000..6f9148b7c --- /dev/null +++ b/priv/repo/migrations/20200602150528_create_chat_message_reference.exs @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.CreateChatMessageReference do + use Ecto.Migration + + def change do + create table(:chat_message_references, primary_key: false) do + add(:id, :uuid, primary_key: true) + add(:chat_id, references(:chats, on_delete: :delete_all), null: false) + add(:object_id, references(:objects, on_delete: :delete_all), null: false) + add(:seen, :boolean, default: false, null: false) + + timestamps() + end + + create(index(:chat_message_references, [:chat_id, "id desc"])) + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 210ba6ef0..ff6b3ac15 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -330,7 +331,7 @@ test "it streams the created ChatMessage" do end end - test "it creates a Chat for the local users and bumps the unread count, except for the author" do + test "it creates a Chat and ChatMessageReferences for the local users and bumps the unread count, except for the author" do author = insert(:user, local: true) recipient = insert(:user, local: true) @@ -347,8 +348,18 @@ test "it creates a Chat for the local users and bumps the unread count, except f chat = Chat.get(author.id, recipient.ap_id) assert chat.unread == 0 + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.seen == true + chat = Chat.get(recipient.id, author.ap_id) assert chat.unread == 1 + + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.seen == false end test "it creates a Chat for the local users and bumps the unread count" do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index d79aa3148..bd4024c09 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -95,7 +96,7 @@ test "it works with an attachment", %{conn: conn, user: user} do describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do setup do: oauth_access(["write:statuses"]) - test "it deletes a message for the author of the message", %{conn: conn, user: user} do + test "it deletes a message from the chat", %{conn: conn, user: user} do recipient = insert(:user) {:ok, message} = @@ -107,23 +108,32 @@ test "it deletes a message for the author of the message", %{conn: conn, user: u chat = Chat.get(user.id, recipient.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + # Deleting your own message removes the message and the reference result = conn |> put_req_header("content-type", "application/json") - |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}") |> json_response_and_validate_schema(200) - assert result["id"] == to_string(object.id) + assert result["id"] == cm_ref.id + refute ChatMessageReference.get_by_id(cm_ref.id) + assert %{data: %{"type" => "Tombstone"}} = Object.get_by_id(object.id) + # Deleting other people's messages just removes the reference object = Object.normalize(other_message, false) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) result = conn |> put_req_header("content-type", "application/json") - |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{object.id}") - |> json_response(400) + |> delete("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}") + |> json_response_and_validate_schema(200) - assert result == %{"error" => "could_not_delete"} + assert result["id"] == cm_ref.id + refute ChatMessageReference.get_by_id(cm_ref.id) + assert Object.get_by_id(object.id) end end diff --git a/test/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/web/pleroma_api/views/chat_message_reference_view_test.exs new file mode 100644 index 000000000..00024d52c --- /dev/null +++ b/test/web/pleroma_api/views/chat_message_reference_view_test.exs @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceViewTest do + use Pleroma.DataCase + + alias Pleroma.Chat + alias Pleroma.ChatMessageReference + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + + import Pleroma.Factory + + test "it displays a chat message" do + user = insert(:user) + recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") + + chat = Chat.get(user.id, recipient.ap_id) + + object = Object.normalize(activity) + + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + chat_message = ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + + assert chat_message[:id] == cm_ref.id + assert chat_message[:content] == "kippis :firefox:" + assert chat_message[:account_id] == user.id + assert chat_message[:chat_id] + assert chat_message[:created_at] + assert chat_message[:seen] == true + assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) + + {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) + + object = Object.normalize(activity) + + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + chat_message_two = + ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + + assert chat_message_two[:id] == cm_ref.id + assert chat_message_two[:content] == "gkgkgk" + assert chat_message_two[:account_id] == recipient.id + assert chat_message_two[:chat_id] == chat_message[:chat_id] + assert chat_message_two[:attachment] + assert chat_message_two[:seen] == false + end +end diff --git a/test/web/pleroma_api/views/chat_message_view_test.exs b/test/web/pleroma_api/views/chat_message_view_test.exs deleted file mode 100644 index d7a2d10a5..000000000 --- a/test/web/pleroma_api/views/chat_message_view_test.exs +++ /dev/null @@ -1,54 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.ChatMessageViewTest do - use Pleroma.DataCase - - alias Pleroma.Chat - alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageView - - import Pleroma.Factory - - test "it displays a chat message" do - user = insert(:user) - recipient = insert(:user) - - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) - {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") - - chat = Chat.get(user.id, recipient.ap_id) - - object = Object.normalize(activity) - - chat_message = ChatMessageView.render("show.json", object: object, for: user, chat: chat) - - assert chat_message[:id] == object.id |> to_string() - assert chat_message[:content] == "kippis :firefox:" - assert chat_message[:account_id] == user.id - assert chat_message[:chat_id] - assert chat_message[:created_at] - assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) - - {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) - - object = Object.normalize(activity) - - chat_message_two = ChatMessageView.render("show.json", object: object, for: user, chat: chat) - - assert chat_message_two[:id] == object.id |> to_string() - assert chat_message_two[:content] == "gkgkgk" - assert chat_message_two[:account_id] == recipient.id - assert chat_message_two[:chat_id] == chat_message[:chat_id] - assert chat_message_two[:attachment] - end -end -- cgit v1.2.3 From f3ccd50a33c9eec3661bf2116fe38542f04986aa Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 12:49:53 +0200 Subject: ChatMessageReferences: Adjust views --- lib/pleroma/chat_message_reference.ex | 7 +++++++ lib/pleroma/web/mastodon_api/views/notification_view.ex | 8 +++++--- lib/pleroma/web/pleroma_api/views/chat_view.ex | 8 +++++--- lib/pleroma/web/views/streamer_view.ex | 8 +++++++- test/web/mastodon_api/views/notification_view_test.exs | 7 +++++-- test/web/pleroma_api/views/chat_view_test.exs | 7 +++++-- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index e9ca3dfe8..6808d1365 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -66,6 +66,13 @@ def for_chat_query(chat) do ) end + def last_message_for_chat(chat) do + chat + |> for_chat_query() + |> limit(1) + |> Repo.one() + end + def create(chat, object, seen) do params = %{ chat_id: chat.id, diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index af15bba48..2ae82eb2d 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do use Pleroma.Web, :view alias Pleroma.Activity + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -14,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView @parent_types ~w{Like Announce EmojiReact} @@ -138,8 +139,9 @@ defp put_chat_message(response, activity, reading_user, opts) do object = Object.normalize(activity) author = User.get_cached_by_ap_id(object.data["actor"]) chat = Pleroma.Chat.get(reading_user.id, author.ap_id) - render_opts = Map.merge(opts, %{object: object, for: reading_user, chat: chat}) - chat_message_render = ChatMessageView.render("show.json", render_opts) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref}) + chat_message_render = ChatMessageReferenceView.render("show.json", render_opts) Map.put(response, :chat_message, chat_message_render) end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 223b64987..331c1d282 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -6,22 +6,24 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do use Pleroma.Web, :view alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = opts[:message] || Chat.last_message_for_chat(chat) + last_message = opts[:last_message] || ChatMessageReference.last_message_for_chat(chat) %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), unread: chat.unread, last_message: - last_message && ChatMessageView.render("show.json", chat: chat, object: last_message), + last_message && + ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), updated_at: Utils.to_masto_date(chat.updated_at) } end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 5e953d770..616e0c4f2 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.StreamerView do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.User @@ -15,10 +16,15 @@ defmodule Pleroma.Web.StreamerView do def render("chat_update.json", object, user, recipients) do chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) + # Explicitly giving the cmr for the object here, so we don't accidentally + # send a later 'last_message' that was inserted between inserting this and + # streaming it out + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + representation = Pleroma.Web.PleromaAPI.ChatView.render( "show.json", - %{message: object, chat: chat} + %{last_message: cm_ref, chat: chat} ) %{ diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 384fe7253..c5691341a 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Activity alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -16,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView import Pleroma.Factory defp test_notifications_rendering(notifications, user, expected_result) do @@ -44,13 +45,15 @@ test "ChatMessage notification" do object = Object.normalize(activity) chat = Chat.get(recipient.id, user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + expected = %{ id: to_string(notification.id), pleroma: %{is_seen: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), chat_message: - ChatMessageView.render("show.json", %{object: object, for: recipient, chat: chat}), + ChatMessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), created_at: Utils.to_masto_date(notification.inserted_at) } diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 6062a0cfe..f3bd12616 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,11 +6,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageView + alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory @@ -39,7 +40,9 @@ test "it represents a chat" do represented_chat = ChatView.render("show.json", chat: chat) + cm_ref = ChatMessageReference.for_chat_and_object(chat, chat_message) + assert represented_chat[:last_message] == - ChatMessageView.render("show.json", chat: chat, object: chat_message) + ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) end end -- cgit v1.2.3 From 8a43611e01cef670c6eac8457be95c5d20efcbc8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 3 Jun 2020 14:53:46 +0400 Subject: Use AdminAPI.StatusView in api/admin/users --- lib/pleroma/web/admin_api/controllers/admin_api_controller.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 9f499e202..cc93fb509 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -30,7 +30,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint - alias Pleroma.Web.MastodonAPI alias Pleroma.Web.Router require Logger @@ -279,7 +278,7 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do }) conn - |> put_view(MastodonAPI.StatusView) + |> put_view(AdminAPI.StatusView) |> render("index.json", %{activities: activities, as: :activity}) else _ -> {:error, :not_found} -- cgit v1.2.3 From 2591745fc2417771f96340ed3f36177c0da194c3 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 12:56:39 +0200 Subject: ChatMessageReferences: Move tests --- lib/pleroma/chat.ex | 34 ---------------------------------- test/chat_message_reference_test.exs | 29 +++++++++++++++++++++++++++++ test/chat_test.exs | 16 ---------------- 3 files changed, 29 insertions(+), 50 deletions(-) create mode 100644 test/chat_message_reference_test.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 211b872f9..65938c7a4 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -6,9 +6,7 @@ defmodule Pleroma.Chat do use Ecto.Schema import Ecto.Changeset - import Ecto.Query - alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -26,38 +24,6 @@ defmodule Pleroma.Chat do timestamps() end - def last_message_for_chat(chat) do - messages_for_chat_query(chat) - |> order_by(desc: :id) - |> limit(1) - |> Repo.one() - end - - def messages_for_chat_query(chat) do - chat = - chat - |> Repo.preload(:user) - - from(o in Object, - where: fragment("?->>'type' = ?", o.data, "ChatMessage"), - where: - fragment( - """ - (?->>'actor' = ? and ?->'to' = ?) - OR (?->>'actor' = ? and ?->'to' = ?) - """, - o.data, - ^chat.user.ap_id, - o.data, - ^[chat.recipient], - o.data, - ^chat.recipient, - o.data, - ^[chat.user.ap_id] - ) - ) - end - def creation_cng(struct, params) do struct |> cast(params, [:user_id, :recipient, :unread]) diff --git a/test/chat_message_reference_test.exs b/test/chat_message_reference_test.exs new file mode 100644 index 000000000..963a0e225 --- /dev/null +++ b/test/chat_message_reference_test.exs @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ChatMessageReferencTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Chat + alias Pleroma.ChatMessageReference + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "messages" do + test "it returns the last message in a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") + {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + message = ChatMessageReference.last_message_for_chat(chat) + + assert message.object.data["content"] == "ho" + end + end +end diff --git a/test/chat_test.exs b/test/chat_test.exs index dfcb6422e..42e01fe27 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -10,22 +10,6 @@ defmodule Pleroma.ChatTest do import Pleroma.Factory - describe "messages" do - test "it returns the last message in a chat" do - user = insert(:user) - recipient = insert(:user) - - {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") - {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") - - {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) - - message = Chat.last_message_for_chat(chat) - - assert message.data["content"] == "ho" - end - end - describe "creation and getting" do test "it only works if the recipient is a valid user (for now)" do user = insert(:user) -- cgit v1.2.3 From 6413e06a861bd383196c79d7754a67d96cd5e2a4 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 13:13:44 +0200 Subject: Migrations: Add unique index to ChatMessageReferences. --- ...3105113_add_unique_index_to_chat_message_references.exs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs new file mode 100644 index 000000000..1101be94f --- /dev/null +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -0,0 +1,14 @@ +defmodule Pleroma.Repo.Migrations.BackfillChatMessageReferences do + use Ecto.Migration + + alias Pleroma.Chat + alias Pleroma.ChatMessageReference + alias Pleroma.Object + alias Pleroma.Repo + + import Ecto.Query + + def change do + create(unique_index(:chat_message_references, [:object_id, :chat_id])) + end +end -- cgit v1.2.3 From 73127cff750736c5ebe15606db2f928a8924499a Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 13:17:29 +0200 Subject: Credo fixes. --- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index c54681054..f22f33de9 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -13,8 +13,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatView alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.ChatView import Ecto.Query import Pleroma.Web.ActivityPub.ObjectValidator, only: [stringify_keys: 1] -- cgit v1.2.3 From 8edead7c1dc33457dc30b301b544d71482ef0f28 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 13:19:38 +0200 Subject: Migration: Remove superfluous imports --- .../20200603105113_add_unique_index_to_chat_message_references.exs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs index 1101be94f..623ac6c85 100644 --- a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -1,13 +1,6 @@ defmodule Pleroma.Repo.Migrations.BackfillChatMessageReferences do use Ecto.Migration - alias Pleroma.Chat - alias Pleroma.ChatMessageReference - alias Pleroma.Object - alias Pleroma.Repo - - import Ecto.Query - def change do create(unique_index(:chat_message_references, [:object_id, :chat_id])) end -- cgit v1.2.3 From 7f5c5b11a5baeddec36ccc01b4954ac8aa9f8590 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:26:50 +0200 Subject: Chats: Remove `unread` from the db, calculate from unseen messages. --- lib/pleroma/chat.ex | 13 +++---------- lib/pleroma/chat_message_reference.ex | 16 ++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 9 ++------- .../web/pleroma_api/controllers/chat_controller.ex | 2 +- lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- .../20200603120448_remove_unread_from_chats.exs | 9 +++++++++ test/chat_test.exs | 6 +----- test/web/activity_pub/side_effects_test.exs | 2 -- .../web/pleroma_api/controllers/chat_controller_test.exs | 11 +++++++---- 9 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 priv/repo/migrations/20200603120448_remove_unread_from_chats.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 65938c7a4..5aefddc5e 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -19,14 +19,13 @@ defmodule Pleroma.Chat do schema "chats" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) - field(:unread, :integer, default: 0, read_after_writes: true) timestamps() end def creation_cng(struct, params) do struct - |> cast(params, [:user_id, :recipient, :unread]) + |> cast(params, [:user_id, :recipient]) |> validate_change(:recipient, fn :recipient, recipient -> case User.get_cached_by_ap_id(recipient) do @@ -61,16 +60,10 @@ def get_or_create(user_id, recipient) do def bump_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient, unread: 1}) + |> creation_cng(%{user_id: user_id, recipient: recipient}) |> Repo.insert( - on_conflict: [set: [updated_at: NaiveDateTime.utc_now()], inc: [unread: 1]], + on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], conflict_target: [:user_id, :recipient] ) end - - def mark_as_read(chat) do - chat - |> change(%{unread: 0}) - |> Repo.update() - end end diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index 6808d1365..ad174b294 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -84,4 +84,20 @@ def create(chat, object, seen) do |> changeset(params) |> Repo.insert() end + + def unread_count_for_chat(chat) do + chat + |> for_chat_query() + |> where([cmr], cmr.seen == false) + |> Repo.aggregate(:count) + end + + def set_all_seen_for_chat(chat) do + chat + |> for_chat_query() + |> exclude(:order_by) + |> exclude(:preload) + |> where([cmr], cmr.seen == false) + |> Repo.update_all(set: [seen: true]) + end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index cda52b00e..884d399d0 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -139,13 +139,8 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do [[actor, recipient], [recipient, actor]] |> Enum.each(fn [user, other_user] -> if user.local do - if user.ap_id == actor.ap_id do - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) - ChatMessageReference.create(chat, object, true) - else - {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - ChatMessageReference.create(chat, object, false) - end + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) end end) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index f22f33de9..29922da99 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -90,7 +90,7 @@ def post_chat_message( def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), - {:ok, chat} <- Chat.mark_as_read(chat) do + {_n, _} <- ChatMessageReference.set_all_seen_for_chat(chat) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 331c1d282..c903a71fd 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -20,7 +20,7 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: chat.unread, + unread: ChatMessageReference.unread_count_for_chat(chat), last_message: last_message && ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), diff --git a/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs b/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs new file mode 100644 index 000000000..6322137d5 --- /dev/null +++ b/priv/repo/migrations/20200603120448_remove_unread_from_chats.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.RemoveUnreadFromChats do + use Ecto.Migration + + def change do + alter table(:chats) do + remove(:unread, :integer, default: 0) + end + end +end diff --git a/test/chat_test.exs b/test/chat_test.exs index 42e01fe27..332f2180a 100644 --- a/test/chat_test.exs +++ b/test/chat_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.ChatTest do use Pleroma.DataCase, async: true alias Pleroma.Chat - alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -35,7 +34,6 @@ test "it returns and bumps a chat for a user and recipient if it already exists" {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) assert chat.id == chat_two.id - assert chat_two.unread == 2 end test "it returns a chat for a user and recipient if it already exists" do @@ -48,15 +46,13 @@ test "it returns a chat for a user and recipient if it already exists" do assert chat.id == chat_two.id end - test "a returning chat will have an updated `update_at` field and an incremented unread count" do + test "a returning chat will have an updated `update_at` field" do user = insert(:user) other_user = insert(:user) {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - assert chat.unread == 1 :timer.sleep(1500) {:ok, chat_two} = Chat.bump_or_create(user.id, other_user.ap_id) - assert chat_two.unread == 2 assert chat.id == chat_two.id assert chat.updated_at != chat_two.updated_at diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index ff6b3ac15..f2fa062b4 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -346,7 +346,6 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps SideEffects.handle(create_activity, local: false, object_data: chat_message_data) chat = Chat.get(author.id, recipient.ap_id) - assert chat.unread == 0 [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() @@ -354,7 +353,6 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps assert cm_ref.seen == true chat = Chat.get(recipient.id, author.ap_id) - assert chat.unread == 1 [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index bd4024c09..e62b71799 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -19,9 +19,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do test "it marks all messages in a chat as read", %{conn: conn, user: user} do other_user = insert(:user) - {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + object = Object.normalize(create, false) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert chat.unread == 1 + assert cm_ref.seen == false result = conn @@ -30,9 +33,9 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do assert result["unread"] == 0 - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert chat.unread == 0 + assert cm_ref.seen == true end end -- cgit v1.2.3 From 1e9efcf7c3de2aa4d57d4292dfa5843761bff111 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:27:54 +0200 Subject: Migrations: Fix migration module name --- .../20200603105113_add_unique_index_to_chat_message_references.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs index 623ac6c85..fdf85132e 100644 --- a/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs +++ b/priv/repo/migrations/20200603105113_add_unique_index_to_chat_message_references.exs @@ -1,4 +1,4 @@ -defmodule Pleroma.Repo.Migrations.BackfillChatMessageReferences do +defmodule Pleroma.Repo.Migrations.AddUniqueIndexToChatMessageReferences do use Ecto.Migration def change do -- cgit v1.2.3 From 7b79871e9721dca9b134598c182df890b909047c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:32:19 +0200 Subject: Migrations: Add chat_id, seen index to ChatMessageReferences This ensures fast count of unseen messages --- ...00603122732_add_seen_index_to_chat_message_references.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs diff --git a/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs b/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs new file mode 100644 index 000000000..a5065d612 --- /dev/null +++ b/priv/repo/migrations/20200603122732_add_seen_index_to_chat_message_references.exs @@ -0,0 +1,12 @@ +defmodule Pleroma.Repo.Migrations.AddSeenIndexToChatMessageReferences do + use Ecto.Migration + + def change do + create( + index(:chat_message_references, [:chat_id], + where: "seen = false", + name: "unseen_messages_count_index" + ) + ) + end +end -- cgit v1.2.3 From 903955b189561d3a95d5955feda723999078b894 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 14:40:44 +0200 Subject: FollowingRelationship: Remove meaningless change --- lib/pleroma/following_relationship.ex | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 0343a20d4..3a3082e72 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -62,13 +62,10 @@ def update(%User{} = follower, %User{} = following, state) do follow(follower, following, state) following_relationship -> - {:ok, relationship} = - following_relationship - |> cast(%{state: state}, [:state]) - |> validate_required([:state]) - |> Repo.update() - - {:ok, relationship} + following_relationship + |> cast(%{state: state}, [:state]) + |> validate_required([:state]) + |> Repo.update() end end -- cgit v1.2.3 From fb4ae9c720054372c1f0e41e3227fb8ad24e6c2d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 16:45:04 +0200 Subject: Streamer, SideEffects: Stream out ChatMessageReferences Saves us a few calles to fetch things from the DB that we already have. --- lib/pleroma/web/activity_pub/side_effects.ex | 8 +++-- lib/pleroma/web/streamer/streamer.ex | 25 ++++++--------- lib/pleroma/web/views/streamer_view.ex | 46 +++++++++++++--------------- test/web/activity_pub/side_effects_test.exs | 5 ++- test/web/streamer/streamer_test.exs | 28 +++++++++++++---- 5 files changed, 60 insertions(+), 52 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 884d399d0..0c5709356 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -140,11 +140,15 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) + {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) + + Streamer.stream( + ["user", "user:pleroma_chat"], + {user, %{cm_ref | chat: chat, object: object}} + ) end end) - Streamer.stream(["user", "user:pleroma_chat"], object) {:ok, object, meta} end end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 2201cbfef..5e37e2cf2 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -6,11 +6,11 @@ defmodule Pleroma.Web.Streamer do require Logger alias Pleroma.Activity + alias Pleroma.ChatMessageReference alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.Object - alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility @@ -201,22 +201,15 @@ defp do_stream(topic, %Notification{} = item) end) end - defp do_stream(topic, %{data: %{"type" => "ChatMessage"}} = object) + defp do_stream(topic, {user, %ChatMessageReference{} = cm_ref}) when topic in ["user", "user:pleroma_chat"] do - recipients = [object.data["actor"] | object.data["to"]] - - topics = - %{ap_id: recipients, local: true} - |> Pleroma.User.Query.build() - |> Repo.all() - |> Enum.map(fn %{id: id} = user -> {user, "#{topic}:#{id}"} end) - - Enum.each(topics, fn {user, topic} -> - Registry.dispatch(@registry, topic, fn list -> - Enum.each(list, fn {pid, _auth} -> - text = StreamerView.render("chat_update.json", object, user, recipients) - send(pid, {:text, text}) - end) + topic = "#{topic}:#{user.id}" + + text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + + Registry.dispatch(@registry, topic, fn list -> + Enum.each(list, fn {pid, _auth} -> + send(pid, {:text, text}) end) end) end diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 616e0c4f2..a6efd0109 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -6,36 +6,11 @@ defmodule Pleroma.Web.StreamerView do use Pleroma.Web, :view alias Pleroma.Activity - alias Pleroma.Chat - alias Pleroma.ChatMessageReference alias Pleroma.Conversation.Participation alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.MastodonAPI.NotificationView - def render("chat_update.json", object, user, recipients) do - chat = Chat.get(user.id, hd(recipients -- [user.ap_id])) - - # Explicitly giving the cmr for the object here, so we don't accidentally - # send a later 'last_message' that was inserted between inserting this and - # streaming it out - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - - representation = - Pleroma.Web.PleromaAPI.ChatView.render( - "show.json", - %{last_message: cm_ref, chat: chat} - ) - - %{ - event: "pleroma:chat_update", - payload: - representation - |> Jason.encode!() - } - |> Jason.encode!() - end - def render("update.json", %Activity{} = activity, %User{} = user) do %{ event: "update", @@ -76,6 +51,27 @@ def render("update.json", %Activity{} = activity) do |> Jason.encode!() end + def render("chat_update.json", %{chat_message_reference: cm_ref}) do + # Explicitly giving the cmr for the object here, so we don't accidentally + # send a later 'last_message' that was inserted between inserting this and + # streaming it out + Logger.debug("Trying to stream out #{inspect(cm_ref)}") + + representation = + Pleroma.Web.PleromaAPI.ChatView.render( + "show.json", + %{last_message: cm_ref, chat: cm_ref.chat} + ) + + %{ + event: "pleroma:chat_update", + payload: + representation + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("conversation.json", %Participation{} = participation) do %{ event: "conversation", diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index f2fa062b4..92c266d84 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -325,9 +325,8 @@ test "it streams the created ChatMessage" do {:ok, _create_activity, _meta} = SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - object = Object.normalize(create_activity, false) - - assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], object)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {author, :_})) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {recipient, :_})) end end diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index bcb05a02d..893ae5449 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.Web.StreamerTest do import Pleroma.Factory + alias Pleroma.Chat + alias Pleroma.ChatMessageReference alias Pleroma.Conversation.Participation alias Pleroma.List alias Pleroma.Object @@ -150,22 +152,36 @@ test "it sends notify to in the 'user:notification' stream", %{user: user, notif test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} do other_user = insert(:user) - {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) + chat = Chat.get(user.id, other_user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = %{cm_ref | chat: chat, object: object} + Streamer.get_topic_and_add_socket("user:pleroma_chat", user) - Streamer.stream("user:pleroma_chat", object) - text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + Streamer.stream("user:pleroma_chat", {user, cm_ref}) + + text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + + assert text =~ "hey cirno" assert_receive {:text, ^text} end test "it sends chat messages to the 'user' stream", %{user: user} do other_user = insert(:user) - {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey") + {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) + chat = Chat.get(user.id, other_user.ap_id) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = %{cm_ref | chat: chat, object: object} + Streamer.get_topic_and_add_socket("user", user) - Streamer.stream("user", object) - text = StreamerView.render("chat_update.json", object, user, [user.ap_id, other_user.ap_id]) + Streamer.stream("user", {user, cm_ref}) + + text = StreamerView.render("chat_update.json", %{chat_message_reference: cm_ref}) + + assert text =~ "hey cirno" assert_receive {:text, ^text} end -- cgit v1.2.3 From 9d572f2f66d600d77cf74e40547dea0f959fe357 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 21 May 2020 19:43:56 +0400 Subject: Move report actions to AdminAPI.ReportController --- .../admin_api/controllers/admin_api_controller.ex | 97 ------ .../web/admin_api/controllers/report_controller.ex | 129 ++++++++ lib/pleroma/web/router.ex | 10 +- .../controllers/admin_api_controller_test.exs | 341 ------------------- .../controllers/report_controller_test.exs | 368 +++++++++++++++++++++ 5 files changed, 502 insertions(+), 443 deletions(-) create mode 100644 lib/pleroma/web/admin_api/controllers/report_controller.ex create mode 100644 test/web/admin_api/controllers/report_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index cc93fb509..467d05375 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -7,28 +7,22 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do import Pleroma.Web.ControllerHelper, only: [json_response: 3] - alias Pleroma.Activity alias Pleroma.Config alias Pleroma.ConfigDB alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Plugs.OAuthScopesPlug - alias Pleroma.ReportNote alias Pleroma.Stats alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay - alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ModerationLogView - alias Pleroma.Web.AdminAPI.Report - alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.AdminAPI.Search - alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint alias Pleroma.Web.Router @@ -71,18 +65,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do when action in [:user_follow, :user_unfollow, :relay_follow, :relay_unfollow] ) - plug( - OAuthScopesPlug, - %{scopes: ["read:reports"], admin: true} - when action in [:list_reports, :report_show] - ) - - plug( - OAuthScopesPlug, - %{scopes: ["write:reports"], admin: true} - when action in [:reports_update, :report_notes_create, :report_notes_delete] - ) - plug( OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} @@ -645,85 +627,6 @@ def update_user_credentials( end end - def list_reports(conn, params) do - {page, page_size} = page_params(params) - - reports = Utils.get_reports(params, page, page_size) - - conn - |> put_view(ReportView) - |> render("index.json", %{reports: reports}) - end - - def report_show(conn, %{"id" => id}) do - with %Activity{} = report <- Activity.get_by_id(id) do - conn - |> put_view(ReportView) - |> render("show.json", Report.extract_report_info(report)) - else - _ -> {:error, :not_found} - end - end - - def reports_update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do - result = - reports - |> Enum.map(fn report -> - with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do - ModerationLog.insert_log(%{ - action: "report_update", - actor: admin, - subject: activity - }) - - activity - else - {:error, message} -> %{id: report["id"], error: message} - end - end) - - case Enum.any?(result, &Map.has_key?(&1, :error)) do - true -> json_response(conn, :bad_request, result) - false -> json_response(conn, :no_content, "") - end - end - - def report_notes_create(%{assigns: %{user: user}} = conn, %{ - "id" => report_id, - "content" => content - }) do - with {:ok, _} <- ReportNote.create(user.id, report_id, content) do - ModerationLog.insert_log(%{ - action: "report_note", - actor: user, - subject: Activity.get_by_id(report_id), - text: content - }) - - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - - def report_notes_delete(%{assigns: %{user: user}} = conn, %{ - "id" => note_id, - "report_id" => report_id - }) do - with {:ok, note} <- ReportNote.destroy(note_id) do - ModerationLog.insert_log(%{ - action: "report_note_delete", - actor: user, - subject: Activity.get_by_id(report_id), - text: note.content - }) - - json_response(conn, :no_content, "") - else - _ -> json_response(conn, :bad_request, "") - end - end - def list_log(conn, params) do {page, page_size} = page_params(params) diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex new file mode 100644 index 000000000..23f0174d4 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ReportController do + use Pleroma.Web, :controller + + import Pleroma.Web.ControllerHelper, only: [json_response: 3] + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.ReportNote + alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.AdminAPI + alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.CommonAPI + + require Logger + + @users_page_size 50 + + plug(OAuthScopesPlug, %{scopes: ["read:reports"], admin: true} when action in [:index, :show]) + + plug( + OAuthScopesPlug, + %{scopes: ["write:reports"], admin: true} + when action in [:update, :notes_create, :notes_delete] + ) + + action_fallback(AdminAPI.FallbackController) + + def index(conn, params) do + {page, page_size} = page_params(params) + + reports = Utils.get_reports(params, page, page_size) + + render(conn, "index.json", reports: reports) + end + + def show(conn, %{"id" => id}) do + with %Activity{} = report <- Activity.get_by_id(id) do + render(conn, "show.json", Report.extract_report_info(report)) + else + _ -> {:error, :not_found} + end + end + + def update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do + result = + reports + |> Enum.map(fn report -> + with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity + }) + + activity + else + {:error, message} -> %{id: report["id"], error: message} + end + end) + + case Enum.any?(result, &Map.has_key?(&1, :error)) do + true -> json_response(conn, :bad_request, result) + false -> json_response(conn, :no_content, "") + end + end + + def notes_create(%{assigns: %{user: user}} = conn, %{ + "id" => report_id, + "content" => content + }) do + with {:ok, _} <- ReportNote.create(user.id, report_id, content) do + ModerationLog.insert_log(%{ + action: "report_note", + actor: user, + subject: Activity.get_by_id(report_id), + text: content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + def notes_delete(%{assigns: %{user: user}} = conn, %{ + "id" => note_id, + "report_id" => report_id + }) do + with {:ok, note} <- ReportNote.destroy(note_id) do + ModerationLog.insert_log(%{ + action: "report_note_delete", + actor: user, + subject: Activity.get_by_id(report_id), + text: note.content + }) + + json_response(conn, :no_content, "") + else + _ -> json_response(conn, :bad_request, "") + end + end + + defp page_params(params) do + {get_page(params["page"]), get_page_size(params["page_size"])} + end + + defp get_page(page_string) when is_nil(page_string), do: 1 + + defp get_page(page_string) do + case Integer.parse(page_string) do + {page, _} -> page + :error -> 1 + end + end + + defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size + + defp get_page_size(page_size_string) do + case Integer.parse(page_size_string) do + {page_size, _} -> page_size + :error -> @users_page_size + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 369c11138..80ea28364 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -183,11 +183,11 @@ defmodule Pleroma.Web.Router do patch("/users/confirm_email", AdminAPIController, :confirm_email) patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email) - get("/reports", AdminAPIController, :list_reports) - get("/reports/:id", AdminAPIController, :report_show) - patch("/reports", AdminAPIController, :reports_update) - post("/reports/:id/notes", AdminAPIController, :report_notes_create) - delete("/reports/:report_id/notes/:id", AdminAPIController, :report_notes_delete) + get("/reports", ReportController, :index) + get("/reports/:id", ReportController, :show) + patch("/reports", ReportController, :update) + post("/reports/:id/notes", ReportController, :notes_create) + delete("/reports/:report_id/notes/:id", ReportController, :notes_delete) get("/statuses/:id", StatusController, :show) put("/statuses/:id", StatusController, :update) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index d72851c9e..a1bff5688 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -17,7 +17,6 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do alias Pleroma.MFA alias Pleroma.ModerationLog alias Pleroma.Repo - alias Pleroma.ReportNote alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web @@ -1198,286 +1197,6 @@ test "returns 404 if user not found", %{conn: conn} do end end - describe "GET /api/pleroma/admin/reports/:id" do - test "returns report by its id", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - response = - conn - |> get("/api/pleroma/admin/reports/#{report_id}") - |> json_response(:ok) - - assert response["id"] == report_id - end - - test "returns 404 when report id is invalid", %{conn: conn} do - conn = get(conn, "/api/pleroma/admin/reports/test") - - assert json_response(conn, :not_found) == %{"error" => "Not found"} - end - end - - describe "PATCH /api/pleroma/admin/reports" do - setup do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - {:ok, %{id: second_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel very offended", - status_ids: [activity.id] - }) - - %{ - id: report_id, - second_report_id: second_report_id - } - end - - test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do - read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"]) - write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"]) - - response = - conn - |> assign(:token, read_token) - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [%{"state" => "resolved", "id" => id}] - }) - |> json_response(403) - - assert response == %{ - "error" => "Insufficient permissions: admin:write:reports." - } - - conn - |> assign(:token, write_token) - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [%{"state" => "resolved", "id" => id}] - }) - |> json_response(:no_content) - end - - test "mark report as resolved", %{conn: conn, id: id, admin: admin} do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "resolved", "id" => id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - assert activity.data["state"] == "resolved" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" - end - - test "closes report", %{conn: conn, id: id, admin: admin} do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "closed", "id" => id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - assert activity.data["state"] == "closed" - - log_entry = Repo.one(ModerationLog) - - assert ModerationLog.get_log_entry_message(log_entry) == - "@#{admin.nickname} updated report ##{id} with 'closed' state" - end - - test "returns 400 when state is unknown", %{conn: conn, id: id} do - conn = - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "test", "id" => id} - ] - }) - - assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" - end - - test "returns 404 when report is not exist", %{conn: conn} do - conn = - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "closed", "id" => "test"} - ] - }) - - assert hd(json_response(conn, :bad_request))["error"] == "not_found" - end - - test "updates state of multiple reports", %{ - conn: conn, - id: id, - admin: admin, - second_report_id: second_report_id - } do - conn - |> patch("/api/pleroma/admin/reports", %{ - "reports" => [ - %{"state" => "resolved", "id" => id}, - %{"state" => "closed", "id" => second_report_id} - ] - }) - |> json_response(:no_content) - - activity = Activity.get_by_id(id) - second_activity = Activity.get_by_id(second_report_id) - assert activity.data["state"] == "resolved" - assert second_activity.data["state"] == "closed" - - [first_log_entry, second_log_entry] = Repo.all(ModerationLog) - - assert ModerationLog.get_log_entry_message(first_log_entry) == - "@#{admin.nickname} updated report ##{id} with 'resolved' state" - - assert ModerationLog.get_log_entry_message(second_log_entry) == - "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" - end - end - - describe "GET /api/pleroma/admin/reports" do - test "returns empty response when no reports created", %{conn: conn} do - response = - conn - |> get("/api/pleroma/admin/reports") - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 - end - - test "returns reports", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - response = - conn - |> get("/api/pleroma/admin/reports") - |> json_response(:ok) - - [report] = response["reports"] - - assert length(response["reports"]) == 1 - assert report["id"] == report_id - - assert response["total"] == 1 - end - - test "returns reports with specified state", %{conn: conn} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: first_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - {:ok, %{id: second_report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I don't like this user" - }) - - CommonAPI.update_report_state(second_report_id, "closed") - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "open" - }) - |> json_response(:ok) - - [open_report] = response["reports"] - - assert length(response["reports"]) == 1 - assert open_report["id"] == first_report_id - - assert response["total"] == 1 - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "closed" - }) - |> json_response(:ok) - - [closed_report] = response["reports"] - - assert length(response["reports"]) == 1 - assert closed_report["id"] == second_report_id - - assert response["total"] == 1 - - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "resolved" - }) - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 - end - - test "returns 403 when requested by a non-admin" do - user = insert(:user) - token = insert(:oauth_token, user: user) - - conn = - build_conn() - |> assign(:user, user) - |> assign(:token, token) - |> get("/api/pleroma/admin/reports") - - assert json_response(conn, :forbidden) == - %{"error" => "User is not an admin or OAuth admin scope is not granted."} - end - - test "returns 403 when requested by anonymous" do - conn = get(build_conn(), "/api/pleroma/admin/reports") - - assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} - end - end - describe "GET /api/pleroma/admin/config" do setup do: clear_config(:configurable_from_database, true) @@ -3195,66 +2914,6 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do end end - describe "POST /reports/:id/notes" do - setup %{conn: conn, admin: admin} do - [reporter, target_user] = insert_pair(:user) - activity = insert(:note_activity, user: target_user) - - {:ok, %{id: report_id}} = - CommonAPI.report(reporter, %{ - account_id: target_user.id, - comment: "I feel offended", - status_ids: [activity.id] - }) - - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ - content: "this is disgusting!" - }) - - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ - content: "this is disgusting2!" - }) - - %{ - admin_id: admin.id, - report_id: report_id - } - end - - test "it creates report note", %{admin_id: admin_id, report_id: report_id} do - [note, _] = Repo.all(ReportNote) - - assert %{ - activity_id: ^report_id, - content: "this is disgusting!", - user_id: ^admin_id - } = note - end - - test "it returns reports with notes", %{conn: conn, admin: admin} do - conn = get(conn, "/api/pleroma/admin/reports") - - response = json_response(conn, 200) - notes = hd(response["reports"])["notes"] - [note, _] = notes - - assert note["user"]["nickname"] == admin.nickname - assert note["content"] == "this is disgusting!" - assert note["created_at"] - assert response["total"] == 1 - end - - test "it deletes the note", %{conn: conn, report_id: report_id} do - assert ReportNote |> Repo.all() |> length() == 2 - - [note, _] = Repo.all(ReportNote) - - delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") - - assert ReportNote |> Repo.all() |> length() == 1 - end - end - describe "GET /api/pleroma/admin/config/descriptions" do test "structure", %{conn: conn} do admin = insert(:user, is_admin: true) diff --git a/test/web/admin_api/controllers/report_controller_test.exs b/test/web/admin_api/controllers/report_controller_test.exs new file mode 100644 index 000000000..0eddb369c --- /dev/null +++ b/test/web/admin_api/controllers/report_controller_test.exs @@ -0,0 +1,368 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ReportControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + alias Pleroma.Activity + alias Pleroma.Config + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.ReportNote + alias Pleroma.Web.CommonAPI + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/reports/:id" do + test "returns report by its id", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + response = + conn + |> get("/api/pleroma/admin/reports/#{report_id}") + |> json_response(:ok) + + assert response["id"] == report_id + end + + test "returns 404 when report id is invalid", %{conn: conn} do + conn = get(conn, "/api/pleroma/admin/reports/test") + + assert json_response(conn, :not_found) == %{"error" => "Not found"} + end + end + + describe "PATCH /api/pleroma/admin/reports" do + setup do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel very offended", + status_ids: [activity.id] + }) + + %{ + id: report_id, + second_report_id: second_report_id + } + end + + test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} do + read_token = insert(:oauth_token, user: admin, scopes: ["admin:read"]) + write_token = insert(:oauth_token, user: admin, scopes: ["admin:write:reports"]) + + response = + conn + |> assign(:token, read_token) + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [%{"state" => "resolved", "id" => id}] + }) + |> json_response(403) + + assert response == %{ + "error" => "Insufficient permissions: admin:write:reports." + } + + conn + |> assign(:token, write_token) + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [%{"state" => "resolved", "id" => id}] + }) + |> json_response(:no_content) + end + + test "mark report as resolved", %{conn: conn, id: id, admin: admin} do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "resolved", "id" => id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + assert activity.data["state"] == "resolved" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated report ##{id} with 'resolved' state" + end + + test "closes report", %{conn: conn, id: id, admin: admin} do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "closed", "id" => id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + assert activity.data["state"] == "closed" + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} updated report ##{id} with 'closed' state" + end + + test "returns 400 when state is unknown", %{conn: conn, id: id} do + conn = + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "test", "id" => id} + ] + }) + + assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" + end + + test "returns 404 when report is not exist", %{conn: conn} do + conn = + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "closed", "id" => "test"} + ] + }) + + assert hd(json_response(conn, :bad_request))["error"] == "not_found" + end + + test "updates state of multiple reports", %{ + conn: conn, + id: id, + admin: admin, + second_report_id: second_report_id + } do + conn + |> patch("/api/pleroma/admin/reports", %{ + "reports" => [ + %{"state" => "resolved", "id" => id}, + %{"state" => "closed", "id" => second_report_id} + ] + }) + |> json_response(:no_content) + + activity = Activity.get_by_id(id) + second_activity = Activity.get_by_id(second_report_id) + assert activity.data["state"] == "resolved" + assert second_activity.data["state"] == "closed" + + [first_log_entry, second_log_entry] = Repo.all(ModerationLog) + + assert ModerationLog.get_log_entry_message(first_log_entry) == + "@#{admin.nickname} updated report ##{id} with 'resolved' state" + + assert ModerationLog.get_log_entry_message(second_log_entry) == + "@#{admin.nickname} updated report ##{second_report_id} with 'closed' state" + end + end + + describe "GET /api/pleroma/admin/reports" do + test "returns empty response when no reports created", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/reports") + |> json_response(:ok) + + assert Enum.empty?(response["reports"]) + assert response["total"] == 0 + end + + test "returns reports", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + response = + conn + |> get("/api/pleroma/admin/reports") + |> json_response(:ok) + + [report] = response["reports"] + + assert length(response["reports"]) == 1 + assert report["id"] == report_id + + assert response["total"] == 1 + end + + test "returns reports with specified state", %{conn: conn} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: first_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + {:ok, %{id: second_report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I don't like this user" + }) + + CommonAPI.update_report_state(second_report_id, "closed") + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "open" + }) + |> json_response(:ok) + + [open_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert open_report["id"] == first_report_id + + assert response["total"] == 1 + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "closed" + }) + |> json_response(:ok) + + [closed_report] = response["reports"] + + assert length(response["reports"]) == 1 + assert closed_report["id"] == second_report_id + + assert response["total"] == 1 + + response = + conn + |> get("/api/pleroma/admin/reports", %{ + "state" => "resolved" + }) + |> json_response(:ok) + + assert Enum.empty?(response["reports"]) + assert response["total"] == 0 + end + + test "returns 403 when requested by a non-admin" do + user = insert(:user) + token = insert(:oauth_token, user: user) + + conn = + build_conn() + |> assign(:user, user) + |> assign(:token, token) + |> get("/api/pleroma/admin/reports") + + assert json_response(conn, :forbidden) == + %{"error" => "User is not an admin or OAuth admin scope is not granted."} + end + + test "returns 403 when requested by anonymous" do + conn = get(build_conn(), "/api/pleroma/admin/reports") + + assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} + end + end + + describe "POST /api/pleroma/admin/reports/:id/notes" do + setup %{conn: conn, admin: admin} do + [reporter, target_user] = insert_pair(:user) + activity = insert(:note_activity, user: target_user) + + {:ok, %{id: report_id}} = + CommonAPI.report(reporter, %{ + account_id: target_user.id, + comment: "I feel offended", + status_ids: [activity.id] + }) + + post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is disgusting!" + }) + + post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + content: "this is disgusting2!" + }) + + %{ + admin_id: admin.id, + report_id: report_id + } + end + + test "it creates report note", %{admin_id: admin_id, report_id: report_id} do + [note, _] = Repo.all(ReportNote) + + assert %{ + activity_id: ^report_id, + content: "this is disgusting!", + user_id: ^admin_id + } = note + end + + test "it returns reports with notes", %{conn: conn, admin: admin} do + conn = get(conn, "/api/pleroma/admin/reports") + + response = json_response(conn, 200) + notes = hd(response["reports"])["notes"] + [note, _] = notes + + assert note["user"]["nickname"] == admin.nickname + assert note["content"] == "this is disgusting!" + assert note["created_at"] + assert response["total"] == 1 + end + + test "it deletes the note", %{conn: conn, report_id: report_id} do + assert ReportNote |> Repo.all() |> length() == 2 + + [note, _] = Repo.all(ReportNote) + + delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") + + assert ReportNote |> Repo.all() |> length() == 1 + end + end +end -- cgit v1.2.3 From c16315d055d07206dddb228583956d5b718ecdd4 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Wed, 3 Jun 2020 19:10:11 +0400 Subject: Add OpenAPI spec for AdminAPI.ReportController --- docs/API/admin_api.md | 4 +- lib/pleroma/web/activity_pub/utils.ex | 1 + .../web/admin_api/controllers/report_controller.ex | 76 +++---- .../api_spec/operations/admin/report_operation.ex | 237 +++++++++++++++++++++ .../api_spec/operations/admin/status_operation.ex | 2 +- .../controllers/report_controller_test.exs | 80 +++---- 6 files changed, 311 insertions(+), 89 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/admin/report_operation.ex diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 639c3224d..92816baf9 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -547,7 +547,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ```json { - "totalReports" : 1, + "total" : 1, "reports": [ { "account": { @@ -768,7 +768,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - 400 Bad Request `"Invalid parameters"` when `status` is missing - On success: `204`, empty response -## `POST /api/pleroma/admin/reports/:report_id/notes/:id` +## `DELETE /api/pleroma/admin/reports/:report_id/notes/:id` ### Delete report note diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index f2375bcc4..a76a699ee 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -740,6 +740,7 @@ defp build_flag_object(_), do: [] def get_reports(params, page, page_size) do params = params + |> Map.new(fn {key, value} -> {to_string(key), value} end) |> Map.put("type", "Flag") |> Map.put("skip_preload", true) |> Map.put("preload_report_notes", true) diff --git a/lib/pleroma/web/admin_api/controllers/report_controller.ex b/lib/pleroma/web/admin_api/controllers/report_controller.ex index 23f0174d4..4c011e174 100644 --- a/lib/pleroma/web/admin_api/controllers/report_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/report_controller.ex @@ -18,8 +18,7 @@ defmodule Pleroma.Web.AdminAPI.ReportController do require Logger - @users_page_size 50 - + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["read:reports"], admin: true} when action in [:index, :show]) plug( @@ -30,15 +29,15 @@ defmodule Pleroma.Web.AdminAPI.ReportController do action_fallback(AdminAPI.FallbackController) - def index(conn, params) do - {page, page_size} = page_params(params) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ReportOperation - reports = Utils.get_reports(params, page, page_size) + def index(conn, params) do + reports = Utils.get_reports(params, params.page, params.page_size) render(conn, "index.json", reports: reports) end - def show(conn, %{"id" => id}) do + def show(conn, %{id: id}) do with %Activity{} = report <- Activity.get_by_id(id) do render(conn, "show.json", Report.extract_report_info(report)) else @@ -46,32 +45,33 @@ def show(conn, %{"id" => id}) do end end - def update(%{assigns: %{user: admin}} = conn, %{"reports" => reports}) do + def update(%{assigns: %{user: admin}, body_params: %{reports: reports}} = conn, _) do result = - reports - |> Enum.map(fn report -> - with {:ok, activity} <- CommonAPI.update_report_state(report["id"], report["state"]) do - ModerationLog.insert_log(%{ - action: "report_update", - actor: admin, - subject: activity - }) - - activity - else - {:error, message} -> %{id: report["id"], error: message} + Enum.map(reports, fn report -> + case CommonAPI.update_report_state(report.id, report.state) do + {:ok, activity} -> + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: activity + }) + + activity + + {:error, message} -> + %{id: report.id, error: message} end end) - case Enum.any?(result, &Map.has_key?(&1, :error)) do - true -> json_response(conn, :bad_request, result) - false -> json_response(conn, :no_content, "") + if Enum.any?(result, &Map.has_key?(&1, :error)) do + json_response(conn, :bad_request, result) + else + json_response(conn, :no_content, "") end end - def notes_create(%{assigns: %{user: user}} = conn, %{ - "id" => report_id, - "content" => content + def notes_create(%{assigns: %{user: user}, body_params: %{content: content}} = conn, %{ + id: report_id }) do with {:ok, _} <- ReportNote.create(user.id, report_id, content) do ModerationLog.insert_log(%{ @@ -88,8 +88,8 @@ def notes_create(%{assigns: %{user: user}} = conn, %{ end def notes_delete(%{assigns: %{user: user}} = conn, %{ - "id" => note_id, - "report_id" => report_id + id: note_id, + report_id: report_id }) do with {:ok, note} <- ReportNote.destroy(note_id) do ModerationLog.insert_log(%{ @@ -104,26 +104,4 @@ def notes_delete(%{assigns: %{user: user}} = conn, %{ _ -> json_response(conn, :bad_request, "") end end - - defp page_params(params) do - {get_page(params["page"]), get_page_size(params["page_size"])} - end - - defp get_page(page_string) when is_nil(page_string), do: 1 - - defp get_page(page_string) do - case Integer.parse(page_string) do - {page, _} -> page - :error -> 1 - end - end - - defp get_page_size(page_size_string) when is_nil(page_size_string), do: @users_page_size - - defp get_page_size(page_size_string) do - case Integer.parse(page_size_string) do - {page_size, _} -> page_size - :error -> @users_page_size - end - end end diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex new file mode 100644 index 000000000..15e78bfaf --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -0,0 +1,237 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.FlakeID + alias Pleroma.Web.ApiSpec.Schemas.Status + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Get a list of reports", + operationId: "AdminAPI.ReportController.index", + security: [%{"oAuth" => ["read:reports"]}], + parameters: [ + Operation.parameter( + :state, + :query, + report_state(), + "Filter by report state" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer}, + "The number of records to retrieve" + ), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page number" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number number of log entries per page" + ) + ], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{ + total: %Schema{type: :integer}, + reports: %Schema{ + type: :array, + items: report() + } + } + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def show_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Get an individual report", + operationId: "AdminAPI.ReportController.show", + parameters: [id_param()], + security: [%{"oAuth" => ["read:reports"]}], + responses: %{ + 200 => Operation.response("Report", "application/json", report()), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Change the state of one or multiple reports", + operationId: "AdminAPI.ReportController.update", + security: [%{"oAuth" => ["write:reports"]}], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 204 => no_content_response(), + 400 => Operation.response("Bad Request", "application/json", update_400_response()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def notes_create_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Create report note", + operationId: "AdminAPI.ReportController.notes_create", + parameters: [id_param()], + requestBody: + request_body("Parameters", %Schema{ + type: :object, + properties: %{ + content: %Schema{type: :string, description: "The message"} + } + }), + security: [%{"oAuth" => ["write:reports"]}], + responses: %{ + 204 => no_content_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def notes_delete_operation do + %Operation{ + tags: ["Admin", "Reports"], + summary: "Delete report note", + operationId: "AdminAPI.ReportController.notes_delete", + parameters: [ + Operation.parameter(:report_id, :path, :string, "Report ID"), + Operation.parameter(:id, :path, :string, "Note ID") + ], + security: [%{"oAuth" => ["write:reports"]}], + responses: %{ + 204 => no_content_response(), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp report_state do + %Schema{type: :string, enum: ["open", "closed", "resolved"]} + end + + defp id_param do + Operation.parameter(:id, :path, FlakeID, "Report ID", + example: "9umDrYheeY451cQnEe", + required: true + ) + end + + defp report do + %Schema{ + type: :object, + properties: %{ + id: FlakeID, + state: report_state(), + account: account_admin(), + actor: account_admin(), + content: %Schema{type: :string}, + created_at: %Schema{type: :string, format: :"date-time"}, + statuses: %Schema{type: :array, items: Status}, + notes: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :integer}, + user_id: FlakeID, + content: %Schema{type: :string}, + inserted_at: %Schema{type: :string, format: :"date-time"} + } + } + } + } + } + end + + defp account_admin do + %Schema{ + title: "Account", + description: "Account view for admins", + type: :object, + properties: + Map.merge(Account.schema().properties, %{ + nickname: %Schema{type: :string}, + deactivated: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + confirmation_pending: %Schema{type: :boolean} + }) + } + end + + defp update_request do + %Schema{ + type: :object, + required: [:reports], + properties: %{ + reports: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Required, report ID"}, + state: %Schema{ + type: :string, + description: + "Required, the new state. Valid values are `open`, `closed` and `resolved`" + } + } + }, + example: %{ + "reports" => [ + %{"id" => "123", "state" => "closed"}, + %{"id" => "1337", "state" => "resolved"} + ] + } + } + } + } + end + + defp update_400_response do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{allOf: [FlakeID], description: "Report ID"}, + error: %Schema{type: :string, description: "Error message"} + } + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex index 2947e6b34..745399b4b 100644 --- a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -123,7 +123,7 @@ defp status do } end - defp admin_account do + def admin_account do %Schema{ type: :object, properties: %{ diff --git a/test/web/admin_api/controllers/report_controller_test.exs b/test/web/admin_api/controllers/report_controller_test.exs index 0eddb369c..940bce340 100644 --- a/test/web/admin_api/controllers/report_controller_test.exs +++ b/test/web/admin_api/controllers/report_controller_test.exs @@ -41,7 +41,7 @@ test "returns report by its id", %{conn: conn} do response = conn |> get("/api/pleroma/admin/reports/#{report_id}") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response["id"] == report_id end @@ -49,7 +49,7 @@ test "returns report by its id", %{conn: conn} do test "returns 404 when report id is invalid", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/reports/test") - assert json_response(conn, :not_found) == %{"error" => "Not found"} + assert json_response_and_validate_schema(conn, :not_found) == %{"error" => "Not found"} end end @@ -85,10 +85,11 @@ test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} d response = conn |> assign(:token, read_token) + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [%{"state" => "resolved", "id" => id}] }) - |> json_response(403) + |> json_response_and_validate_schema(403) assert response == %{ "error" => "Insufficient permissions: admin:write:reports." @@ -96,20 +97,22 @@ test "requires admin:write:reports scope", %{conn: conn, id: id, admin: admin} d conn |> assign(:token, write_token) + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [%{"state" => "resolved", "id" => id}] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) end test "mark report as resolved", %{conn: conn, id: id, admin: admin} do conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "resolved", "id" => id} ] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) activity = Activity.get_by_id(id) assert activity.data["state"] == "resolved" @@ -122,12 +125,13 @@ test "mark report as resolved", %{conn: conn, id: id, admin: admin} do test "closes report", %{conn: conn, id: id, admin: admin} do conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "closed", "id" => id} ] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) activity = Activity.get_by_id(id) assert activity.data["state"] == "closed" @@ -141,25 +145,28 @@ test "closes report", %{conn: conn, id: id, admin: admin} do test "returns 400 when state is unknown", %{conn: conn, id: id} do conn = conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "test", "id" => id} ] }) - assert hd(json_response(conn, :bad_request))["error"] == "Unsupported state" + assert "Unsupported state" = + hd(json_response_and_validate_schema(conn, :bad_request))["error"] end test "returns 404 when report is not exist", %{conn: conn} do conn = conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "closed", "id" => "test"} ] }) - assert hd(json_response(conn, :bad_request))["error"] == "not_found" + assert hd(json_response_and_validate_schema(conn, :bad_request))["error"] == "not_found" end test "updates state of multiple reports", %{ @@ -169,13 +176,14 @@ test "updates state of multiple reports", %{ second_report_id: second_report_id } do conn + |> put_req_header("content-type", "application/json") |> patch("/api/pleroma/admin/reports", %{ "reports" => [ %{"state" => "resolved", "id" => id}, %{"state" => "closed", "id" => second_report_id} ] }) - |> json_response(:no_content) + |> json_response_and_validate_schema(:no_content) activity = Activity.get_by_id(id) second_activity = Activity.get_by_id(second_report_id) @@ -197,7 +205,7 @@ test "returns empty response when no reports created", %{conn: conn} do response = conn |> get("/api/pleroma/admin/reports") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert Enum.empty?(response["reports"]) assert response["total"] == 0 @@ -217,7 +225,7 @@ test "returns reports", %{conn: conn} do response = conn |> get("/api/pleroma/admin/reports") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) [report] = response["reports"] @@ -248,12 +256,10 @@ test "returns reports with specified state", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "open" - }) - |> json_response(:ok) + |> get("/api/pleroma/admin/reports?state=open") + |> json_response_and_validate_schema(:ok) - [open_report] = response["reports"] + assert [open_report] = response["reports"] assert length(response["reports"]) == 1 assert open_report["id"] == first_report_id @@ -262,27 +268,22 @@ test "returns reports with specified state", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "closed" - }) - |> json_response(:ok) + |> get("/api/pleroma/admin/reports?state=closed") + |> json_response_and_validate_schema(:ok) - [closed_report] = response["reports"] + assert [closed_report] = response["reports"] assert length(response["reports"]) == 1 assert closed_report["id"] == second_report_id assert response["total"] == 1 - response = - conn - |> get("/api/pleroma/admin/reports", %{ - "state" => "resolved" - }) - |> json_response(:ok) - - assert Enum.empty?(response["reports"]) - assert response["total"] == 0 + assert %{"total" => 0, "reports" => []} == + conn + |> get("/api/pleroma/admin/reports?state=resolved", %{ + "" => "" + }) + |> json_response_and_validate_schema(:ok) end test "returns 403 when requested by a non-admin" do @@ -302,7 +303,9 @@ test "returns 403 when requested by a non-admin" do test "returns 403 when requested by anonymous" do conn = get(build_conn(), "/api/pleroma/admin/reports") - assert json_response(conn, :forbidden) == %{"error" => "Invalid credentials."} + assert json_response(conn, :forbidden) == %{ + "error" => "Invalid credentials." + } end end @@ -318,11 +321,15 @@ test "returns 403 when requested by anonymous" do status_ids: [activity.id] }) - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/#{report_id}/notes", %{ content: "this is disgusting!" }) - post(conn, "/api/pleroma/admin/reports/#{report_id}/notes", %{ + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/reports/#{report_id}/notes", %{ content: "this is disgusting2!" }) @@ -333,7 +340,7 @@ test "returns 403 when requested by anonymous" do end test "it creates report note", %{admin_id: admin_id, report_id: report_id} do - [note, _] = Repo.all(ReportNote) + assert [note, _] = Repo.all(ReportNote) assert %{ activity_id: ^report_id, @@ -345,7 +352,7 @@ test "it creates report note", %{admin_id: admin_id, report_id: report_id} do test "it returns reports with notes", %{conn: conn, admin: admin} do conn = get(conn, "/api/pleroma/admin/reports") - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) notes = hd(response["reports"])["notes"] [note, _] = notes @@ -357,8 +364,7 @@ test "it returns reports with notes", %{conn: conn, admin: admin} do test "it deletes the note", %{conn: conn, report_id: report_id} do assert ReportNote |> Repo.all() |> length() == 2 - - [note, _] = Repo.all(ReportNote) + assert [note, _] = Repo.all(ReportNote) delete(conn, "/api/pleroma/admin/reports/#{report_id}/notes/#{note.id}") -- cgit v1.2.3 From c020fd435216012f08812efdb9ee0c05352cec10 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 18:58:58 +0200 Subject: ChatMessageReferenceView: Return read status as `unread`. --- lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex | 2 +- test/web/pleroma_api/views/chat_message_reference_view_test.exs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex index ff170e162..f9405aec5 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex @@ -30,7 +30,7 @@ def render( attachment: chat_message["attachment"] && StatusView.render("attachment.json", attachment: chat_message["attachment"]), - seen: seen + unread: !seen } end diff --git a/test/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/web/pleroma_api/views/chat_message_reference_view_test.exs index 00024d52c..b53bd3490 100644 --- a/test/web/pleroma_api/views/chat_message_reference_view_test.exs +++ b/test/web/pleroma_api/views/chat_message_reference_view_test.exs @@ -40,7 +40,7 @@ test "it displays a chat message" do assert chat_message[:account_id] == user.id assert chat_message[:chat_id] assert chat_message[:created_at] - assert chat_message[:seen] == true + assert chat_message[:unread] == false assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) @@ -57,6 +57,6 @@ test "it displays a chat message" do assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] assert chat_message_two[:attachment] - assert chat_message_two[:seen] == false + assert chat_message_two[:unread] == true end end -- cgit v1.2.3 From b3407344d3acafa4a1271289d985632c058e7a6e Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 19:21:23 +0200 Subject: ChatController: Add function to mark single message as read. --- lib/pleroma/chat_message_reference.ex | 6 +++++ .../web/api_spec/operations/chat_operation.ex | 31 ++++++++++++++++++++-- .../web/pleroma_api/controllers/chat_controller.ex | 23 +++++++++++++++- lib/pleroma/web/router.ex | 1 + .../controllers/chat_controller_test.exs | 28 +++++++++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index ad174b294..9b00443f5 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -92,6 +92,12 @@ def unread_count_for_chat(chat) do |> Repo.aggregate(:count) end + def mark_as_read(cm_ref) do + cm_ref + |> changeset(%{seen: true}) + |> Repo.update() + end + def set_all_seen_for_chat(chat) do chat |> for_chat_query() diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index a1c5db5dc..6ad325113 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -39,6 +39,31 @@ def mark_as_read_operation do } end + def mark_message_as_read_operation do + %Operation{ + tags: ["chat"], + summary: "Mark one message in the chat as read", + operationId: "ChatController.mark_message_as_read", + parameters: [ + Operation.parameter(:id, :path, :string, "The ID of the Chat"), + Operation.parameter(:message_id, :path, :string, "The ID of the message") + ], + responses: %{ + 200 => + Operation.response( + "The read ChatMessage", + "application/json", + ChatMessage + ) + }, + security: [ + %{ + "oAuth" => ["write"] + } + ] + } + end + def show_operation do %Operation{ tags: ["chat"], @@ -274,7 +299,8 @@ def chat_messages_response do "content" => "Check this out :firefox:", "id" => "13", "chat_id" => "1", - "actor_id" => "someflakeid" + "actor_id" => "someflakeid", + "unread" => false }, %{ "actor_id" => "someflakeid", @@ -282,7 +308,8 @@ def chat_messages_response do "id" => "12", "chat_id" => "1", "emojis" => [], - "created_at" => "2020-04-21T15:06:45.000Z" + "created_at" => "2020-04-21T15:06:45.000Z", + "unread" => false } ] } diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 29922da99..01d47045d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -24,7 +24,13 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, %{scopes: ["write:statuses"]} - when action in [:post_chat_message, :create, :mark_as_read, :delete_message] + when action in [ + :post_chat_message, + :create, + :mark_as_read, + :mark_message_as_read, + :delete_message + ] ) plug( @@ -88,6 +94,21 @@ def post_chat_message( end end + def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + id: chat_id, + message_id: message_id + }) do + with %ChatMessageReference{} = cm_ref <- + ChatMessageReference.get_by_id(message_id), + ^chat_id <- cm_ref.chat_id |> to_string(), + %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), + {:ok, cm_ref} <- ChatMessageReference.mark_as_read(cm_ref) do + conn + |> put_view(ChatMessageReferenceView) + |> render("show.json", for: user, chat_message_reference: cm_ref) + end + end + def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), {_n, _} <- ChatMessageReference.set_all_seen_for_chat(chat) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index fef277ac6..fd2dc82ca 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -313,6 +313,7 @@ defmodule Pleroma.Web.Router do post("/chats/:id/messages", ChatController, :post_chat_message) delete("/chats/:id/messages/:message_id", ChatController, :delete_message) post("/chats/:id/read", ChatController, :mark_as_read) + post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read) get("/conversations/:id/statuses", ConversationController, :statuses) get("/conversations/:id", ConversationController, :show) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index e62b71799..e7892142a 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -13,6 +13,33 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory + describe "POST /api/v1/pleroma/chats/:id/messages/:message_id/read" do + setup do: oauth_access(["write:statuses"]) + + test "it marks one message as read", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + object = Object.normalize(create, false) + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + assert cm_ref.seen == false + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/messages/#{cm_ref.id}/read") + |> json_response_and_validate_schema(200) + + assert result["unread"] == false + + cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + + assert cm_ref.seen == true + end + end + describe "POST /api/v1/pleroma/chats/:id/read" do setup do: oauth_access(["write:statuses"]) @@ -20,6 +47,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do other_user = insert(:user) {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) object = Object.normalize(create, false) cm_ref = ChatMessageReference.for_chat_and_object(chat, object) -- cgit v1.2.3 From 286bd8eb83e3fd9a2546e27c5e5d98f5316934a0 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 19:24:37 +0200 Subject: Docs: Add `mark_message_as_read` to docs --- docs/API/chats.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index d1d39f495..c0ef75664 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -94,6 +94,15 @@ Returned data: } ``` +### Marking a single chat message as read + +To set the `unread` property of a message to `false` + +`POST /api/v1/pleroma/chats/:id/messages/:message_id/read` + +Returned data: + +The modified chat message ### Getting a list of Chats @@ -149,7 +158,8 @@ Returned data: "visible_in_picker": false } ], - "id": "13" + "id": "13", + "unread": true }, { "account_id": "someflakeid", @@ -157,7 +167,8 @@ Returned data: "content": "Whats' up?", "created_at": "2020-04-21T15:06:45.000Z", "emojis": [], - "id": "12" + "id": "12", + "unread": false } ] ``` @@ -190,7 +201,8 @@ Returned data: "visible_in_picker": false } ], - "id": "13" + "id": "13", + "unread": false } ``` @@ -215,7 +227,8 @@ There's a new `pleroma:chat_mention` notification, which has this form. It is no "chat_id": "1", "id": "10", "content": "Hello", - "account_id": "someflakeid" + "account_id": "someflakeid", + "unread": false }, "created_at": "somedate" } -- cgit v1.2.3 From e213e3157737f87513999ef2aa00dffa735a8ada Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 19:25:57 +0200 Subject: Changelog: Add chats to changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 839bf90ab..1cf2210f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added +- Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` - Instance: Extend `/api/v1/instance` with Pleroma-specific information. -- cgit v1.2.3 From aa26dc6a130614b049696784ecb29e341956bbc2 Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 13:40:48 -0400 Subject: add status_net/config --- config/config.exs | 3 ++- lib/pleroma/web/preload.ex | 10 ++++++--- lib/pleroma/web/preload/status_net.ex | 24 ++++++++++++++++++++++ .../web/twitter_api/controllers/util_controller.ex | 13 ++---------- lib/pleroma/web/twitter_api/views/util_view.ex | 14 +++++++++++++ test/web/preload/status_net_test.exs | 14 +++++++++++++ 6 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 lib/pleroma/web/preload/status_net.ex create mode 100644 test/web/preload/status_net_test.exs diff --git a/config/config.exs b/config/config.exs index 1539b15c6..394c24d85 100644 --- a/config/config.exs +++ b/config/config.exs @@ -419,7 +419,8 @@ providers: [ Pleroma.Web.Preload.Providers.Instance, Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines + Pleroma.Web.Preload.Providers.Timelines, + Pleroma.Web.Preload.Providers.StatusNet ] config :pleroma, :http_security, diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index f13932b89..90e454468 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -9,7 +9,13 @@ defmodule Pleroma.Web.Preload do def build_tags(_conn, params) do preload_data = Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> - Map.merge(acc, parser.generate_terms(params)) + terms = + params + |> parser.generate_terms() + |> Enum.map(fn {k, v} -> {k, Base.encode64(Jason.encode!(v))} end) + |> Enum.into(%{}) + + Map.merge(acc, terms) end) rendered_html = @@ -22,8 +28,6 @@ def build_tags(_conn, params) do end def build_script_tag(content) do - content = Base.encode64(content) - HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex new file mode 100644 index 000000000..7e592d60d --- /dev/null +++ b/lib/pleroma/web/preload/status_net.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNet do + alias Pleroma.Web.TwitterAPI.UtilView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @config_url :"/api/statusnet/config.json" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_config_tag() + end + + defp build_config_tag(acc) do + instance = Pleroma.Config.get(:instance) + info_data = UtilView.status_net_config(instance) + + Map.put(acc, @config_url, info_data) + end +end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index fd2aee175..aaca182ec 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.CommonAPI + alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.WebFinger plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) @@ -90,17 +91,7 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ def config(%{assigns: %{format: "xml"}} = conn, _params) do instance = Pleroma.Config.get(:instance) - - response = """ - - - #{Keyword.get(instance, :name)} - #{Web.base_url()} - #{Keyword.get(instance, :limit)} - #{!Keyword.get(instance, :registrations_open)} - - - """ + response = UtilView.status_net_config(instance) conn |> put_resp_content_type("application/xml") diff --git a/lib/pleroma/web/twitter_api/views/util_view.ex b/lib/pleroma/web/twitter_api/views/util_view.ex index 52054e020..d3bdb4f62 100644 --- a/lib/pleroma/web/twitter_api/views/util_view.ex +++ b/lib/pleroma/web/twitter_api/views/util_view.ex @@ -5,4 +5,18 @@ defmodule Pleroma.Web.TwitterAPI.UtilView do use Pleroma.Web, :view import Phoenix.HTML.Form + alias Pleroma.Web + + def status_net_config(instance) do + """ + + + #{Keyword.get(instance, :name)} + #{Web.base_url()} + #{Keyword.get(instance, :limit)} + #{!Keyword.get(instance, :registrations_open)} + + + """ + end end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs new file mode 100644 index 000000000..ab6823a7e --- /dev/null +++ b/test/web/preload/status_net_test.exs @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNetTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.StatusNet + + setup do: {:ok, StatusNet.generate_terms(nil)} + + test "it renders the info", %{"/api/statusnet/config.json": info} do + assert info =~ "Pleroma" + end +end -- cgit v1.2.3 From e46aecda55b20c0d48463fb2a5c0040d4fc34e97 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 3 Jun 2020 20:51:59 +0200 Subject: Notification: Fix notifications backfill for compacted activities --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 0f33d282d..455d214bf 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -412,7 +412,7 @@ defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do object = Object.get_by_ap_id(activity.data["object"]) - case object.data["type"] do + case object && object.data["type"] do "ChatMessage" -> "pleroma:chat_mention" _ -> "mention" end -- cgit v1.2.3 From 29ae5bb77166d9d7f8108a965b0c3d147b747e80 Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 12 May 2020 11:08:00 -0400 Subject: preload data into index.html --- config/config.exs | 25 ++--- lib/pleroma/web/fallback_redirect_controller.ex | 82 ++++++++++----- lib/pleroma/web/nodeinfo/nodeinfo.ex | 130 ++++++++++++++++++++++++ lib/pleroma/web/nodeinfo/nodeinfo_controller.ex | 114 +++------------------ lib/pleroma/web/preload.ex | 30 ++++++ lib/pleroma/web/preload/instance.ex | 49 +++++++++ lib/pleroma/web/preload/provider.ex | 7 ++ lib/pleroma/web/preload/timelines.ex | 42 ++++++++ lib/pleroma/web/preload/user.ex | 25 +++++ lib/pleroma/web/router.ex | 2 +- test/plugs/instance_static_test.exs | 2 +- test/web/fallback_test.exs | 38 ++++--- test/web/preload/instance_test.exs | 37 +++++++ test/web/preload/timeline_test.exs | 74 ++++++++++++++ test/web/preload/user_test.exs | 33 ++++++ 15 files changed, 533 insertions(+), 157 deletions(-) create mode 100644 lib/pleroma/web/nodeinfo/nodeinfo.ex create mode 100644 lib/pleroma/web/preload.ex create mode 100644 lib/pleroma/web/preload/instance.ex create mode 100644 lib/pleroma/web/preload/provider.ex create mode 100644 lib/pleroma/web/preload/timelines.ex create mode 100644 lib/pleroma/web/preload/user.ex create mode 100644 test/web/preload/instance_test.exs create mode 100644 test/web/preload/timeline_test.exs create mode 100644 test/web/preload/user_test.exs diff --git a/config/config.exs b/config/config.exs index 9508ae077..ee81eb899 100644 --- a/config/config.exs +++ b/config/config.exs @@ -241,18 +241,7 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false, - multi_factor_authentication: [ - totp: [ - # digits 6 or 8 - digits: 6, - period: 30 - ], - backup_codes: [ - number: 5, - length: 16 - ] - ] + cleanup_attachments: false config :pleroma, :feed, post_title: %{ @@ -361,8 +350,7 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [], - reject_deletes: [] + banner_removal: [] config :pleroma, :mrf_keyword, reject: [], @@ -428,6 +416,13 @@ ], unfurl_nsfw: false +config :pleroma, Pleroma.Web.Preload, + providers: [ + Pleroma.Web.Preload.Providers.Instance, + Pleroma.Web.Preload.Providers.User, + Pleroma.Web.Preload.Providers.Timelines + ] + config :pleroma, :http_security, enabled: true, sts: false, @@ -682,8 +677,6 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} -config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 0d9d578fc..932fb8d7e 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,11 +4,10 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller - require Logger - alias Pleroma.User alias Pleroma.Web.Metadata + alias Pleroma.Web.Preload def api_not_implemented(conn, _params) do conn @@ -16,16 +15,7 @@ def api_not_implemented(conn, _params) do |> json(%{error: "Not implemented"}) end - def redirector(conn, _params, code \\ 200) - - # redirect to admin section - # /pleroma/admin -> /pleroma/admin/ - # - def redirector(conn, %{"path" => ["pleroma", "admin"]} = _, _code) do - redirect(conn, to: "/pleroma/admin/") - end - - def redirector(conn, _params, code) do + def redirector(conn, _params, code \\ 200) do conn |> put_resp_content_type("text/html") |> send_file(code, index_file_path()) @@ -43,28 +33,34 @@ def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} def redirector_with_meta(conn, params) do {:ok, index_content} = File.read(index_file_path()) - tags = - try do - Metadata.build_tags(params) - rescue - e -> - Logger.error( - "Metadata rendering for #{conn.request_path} failed.\n" <> - Exception.format(:error, e, __STACKTRACE__) - ) - - "" - end + tags = build_tags(conn, params) + preloads = preload_data(conn, params) - response = String.replace(index_content, "", tags) + response = + index_content + |> String.replace("", tags) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") |> send_resp(200, response) end - def index_file_path do - Pleroma.Plugs.InstanceStatic.file_path("index.html") + def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do + redirect(conn, to: "/pleroma/admin/") + end + + def redirector_with_preload(conn, params) do + {:ok, index_content} = File.read(index_file_path()) + preloads = preload_data(conn, params) + + response = + index_content + |> String.replace("", preloads) + + conn + |> put_resp_content_type("text/html") + |> send_resp(200, response) end def registration_page(conn, params) do @@ -76,4 +72,36 @@ def empty(conn, _params) do |> put_status(204) |> text("") end + + defp index_file_path do + Pleroma.Plugs.InstanceStatic.file_path("index.html") + end + + defp build_tags(conn, params) do + try do + Metadata.build_tags(params) + rescue + e -> + Logger.error( + "Metadata rendering for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end + + defp preload_data(conn, params) do + try do + Preload.build_tags(conn, params) + rescue + e -> + Logger.error( + "Preloading for #{conn.request_path} failed.\n" <> + Exception.format(:error, e, __STACKTRACE__) + ) + + "" + end + end end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex new file mode 100644 index 000000000..d26b7c938 --- /dev/null +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -0,0 +1,130 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Nodeinfo.Nodeinfo do + alias Pleroma.Config + alias Pleroma.Stats + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.Federator.Publisher + + # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field + # under software. + def get_nodeinfo("2.0") do + stats = Stats.get_stats() + + quarantined = Config.get([:instance, :quarantined_instances], []) + + staff_accounts = + User.all_superusers() + |> Enum.map(fn u -> u.ap_id end) + + federation_response = + if Config.get([:instance, :mrf_transparency]) do + {:ok, data} = MRF.describe() + + data + |> Map.merge(%{quarantined_instances: quarantined}) + else + %{} + end + |> Map.put(:enabled, Config.get([:instance, :federating])) + + features = + [ + "pleroma_api", + "mastodon_api", + "mastodon_api_streaming", + "polls", + "pleroma_explicit_addressing", + "shareable_emoji_packs", + "multifetch", + "pleroma:api/v1/notifications:include_types_filter", + if Config.get([:media_proxy, :enabled]) do + "media_proxy" + end, + if Config.get([:gopher, :enabled]) do + "gopher" + end, + if Config.get([:chat, :enabled]) do + "chat" + end, + if Config.get([:instance, :allow_relay]) do + "relay" + end, + if Config.get([:instance, :safe_dm_mentions]) do + "safe_dm_mentions" + end, + "pleroma_emoji_reactions" + ] + |> Enum.filter(& &1) + + %{ + version: "2.0", + software: %{ + name: Pleroma.Application.name() |> String.downcase(), + version: Pleroma.Application.version() + }, + protocols: Publisher.gather_nodeinfo_protocol_names(), + services: %{ + inbound: [], + outbound: [] + }, + openRegistrations: Config.get([:instance, :registrations_open]), + usage: %{ + users: %{ + total: Map.get(stats, :user_count, 0) + }, + localPosts: Map.get(stats, :status_count, 0) + }, + metadata: %{ + nodeName: Config.get([:instance, :name]), + nodeDescription: Config.get([:instance, :description]), + private: !Config.get([:instance, :public], true), + suggestions: %{ + enabled: false + }, + staffAccounts: staff_accounts, + federation: federation_response, + pollLimits: Config.get([:instance, :poll_limits]), + postFormats: Config.get([:instance, :allowed_post_formats]), + uploadLimits: %{ + general: Config.get([:instance, :upload_limit]), + avatar: Config.get([:instance, :avatar_upload_limit]), + banner: Config.get([:instance, :banner_upload_limit]), + background: Config.get([:instance, :background_upload_limit]) + }, + fieldsLimits: %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + }, + accountActivationRequired: Config.get([:instance, :account_activation_required], false), + invitesEnabled: Config.get([:instance, :invites_enabled], false), + mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), + features: features, + restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), + skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) + } + } + end + + def get_nodeinfo("2.1") do + raw_response = get_nodeinfo("2.0") + + updated_software = + raw_response + |> Map.get(:software) + |> Map.put(:repository, Pleroma.Application.repository()) + + raw_response + |> Map.put(:software, updated_software) + |> Map.put(:version, "2.1") + end + + def get_nodeinfo(_version) do + {:error, :missing} + end +end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 721b599d4..8c7a9e565 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -5,12 +5,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do use Pleroma.Web, :controller - alias Pleroma.Config - alias Pleroma.Stats - alias Pleroma.User alias Pleroma.Web - alias Pleroma.Web.Federator.Publisher - alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo def schemas(conn, _params) do response = %{ @@ -29,102 +25,20 @@ def schemas(conn, _params) do json(conn, response) end - # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field - # under software. - def raw_nodeinfo do - stats = Stats.get_stats() - - staff_accounts = - User.all_superusers() - |> Enum.map(fn u -> u.ap_id end) - - features = InstanceView.features() - federation = InstanceView.federation() - - %{ - version: "2.0", - software: %{ - name: Pleroma.Application.name() |> String.downcase(), - version: Pleroma.Application.version() - }, - protocols: Publisher.gather_nodeinfo_protocol_names(), - services: %{ - inbound: [], - outbound: [] - }, - openRegistrations: Config.get([:instance, :registrations_open]), - usage: %{ - users: %{ - total: Map.get(stats, :user_count, 0) - }, - localPosts: Map.get(stats, :status_count, 0) - }, - metadata: %{ - nodeName: Config.get([:instance, :name]), - nodeDescription: Config.get([:instance, :description]), - private: !Config.get([:instance, :public], true), - suggestions: %{ - enabled: false - }, - staffAccounts: staff_accounts, - federation: federation, - pollLimits: Config.get([:instance, :poll_limits]), - postFormats: Config.get([:instance, :allowed_post_formats]), - uploadLimits: %{ - general: Config.get([:instance, :upload_limit]), - avatar: Config.get([:instance, :avatar_upload_limit]), - banner: Config.get([:instance, :banner_upload_limit]), - background: Config.get([:instance, :background_upload_limit]) - }, - fieldsLimits: %{ - maxFields: Config.get([:instance, :max_account_fields]), - maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), - nameLength: Config.get([:instance, :account_field_name_length]), - valueLength: Config.get([:instance, :account_field_value_length]) - }, - accountActivationRequired: Config.get([:instance, :account_activation_required], false), - invitesEnabled: Config.get([:instance, :invites_enabled], false), - mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), - features: features, - restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false) - } - } - end - # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json - def nodeinfo(conn, %{"version" => "2.0"}) do - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" - ) - |> json(raw_nodeinfo()) - end - - def nodeinfo(conn, %{"version" => "2.1"}) do - raw_response = raw_nodeinfo() - - updated_software = - raw_response - |> Map.get(:software) - |> Map.put(:repository, Pleroma.Application.repository()) - - response = - raw_response - |> Map.put(:software, updated_software) - |> Map.put(:version, "2.1") - - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8" - ) - |> json(response) - end - - def nodeinfo(conn, _) do - render_error(conn, :not_found, "Nodeinfo schema version not handled") + def nodeinfo(conn, %{"version" => version}) do + case Nodeinfo.get_nodeinfo(version) do + {:error, :missing} -> + render_error(conn, :not_found, "Nodeinfo schema version not handled") + + node_info -> + conn + |> put_resp_header( + "content-type", + "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + ) + |> json(node_info) + end end end diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex new file mode 100644 index 000000000..c2211c597 --- /dev/null +++ b/lib/pleroma/web/preload.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.Preload do + alias Phoenix.HTML + require Logger + + def build_tags(_conn, params) do + preload_data = + Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> + Map.merge(acc, parser.generate_terms(params)) + end) + + rendered_html = + preload_data + |> Jason.encode!() + |> build_script_tag() + |> HTML.safe_to_string() + + rendered_html + end + + def build_script_tag(content) do + HTML.Tag.content_tag(:script, HTML.raw(content), + id: "initial-results", + type: "application/json" + ) + end +end diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex new file mode 100644 index 000000000..0b6fd3313 --- /dev/null +++ b/lib/pleroma/web/preload/instance.ex @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Instance do + alias Pleroma.Web.MastodonAPI.InstanceView + alias Pleroma.Web.Nodeinfo.Nodeinfo + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @instance_url :"/api/v1/instance" + @panel_url :"/instance/panel.html" + @nodeinfo_url :"/nodeinfo/2.0" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_info_tag() + |> build_panel_tag() + |> build_nodeinfo_tag() + end + + defp build_info_tag(acc) do + info_data = InstanceView.render("show.json", %{}) + + Map.put(acc, @instance_url, info_data) + end + + defp build_panel_tag(acc) do + instance_path = Path.join(:code.priv_dir(:pleroma), "static/instance/panel.html") + + if File.exists?(instance_path) do + panel_data = File.read!(instance_path) + Map.put(acc, @panel_url, panel_data) + else + acc + end + end + + defp build_nodeinfo_tag(acc) do + case Nodeinfo.get_nodeinfo("2.0") do + {:error, _} -> + acc + + nodeinfo_data -> + Map.put(acc, @nodeinfo_url, nodeinfo_data) + end + end +end diff --git a/lib/pleroma/web/preload/provider.ex b/lib/pleroma/web/preload/provider.ex new file mode 100644 index 000000000..7ef595a34 --- /dev/null +++ b/lib/pleroma/web/preload/provider.ex @@ -0,0 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Provider do + @callback generate_terms(map()) :: map() +end diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex new file mode 100644 index 000000000..dbd7db407 --- /dev/null +++ b/lib/pleroma/web/preload/timelines.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.Timelines do + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @public_url :"/api/v1/timelines/public" + + @impl Provider + def generate_terms(_params) do + build_public_tag(%{}) + end + + def build_public_tag(acc) do + if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do + acc + else + Map.put(acc, @public_url, public_timeline(nil)) + end + end + + defp public_timeline(user) do + activities = + create_timeline_params(user) + |> Map.put("local_only", false) + |> ActivityPub.fetch_public_activities() + + StatusView.render("index.json", activities: activities, for: user, as: :activity) + end + + defp create_timeline_params(user) do + %{} + |> Map.put("type", ["Create", "Announce"]) + |> Map.put("blocking_user", user) + |> Map.put("muting_user", user) + |> Map.put("user", user) + end +end diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex new file mode 100644 index 000000000..3a244845b --- /dev/null +++ b/lib/pleroma/web/preload/user.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.Preload.Providers.User do + alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @account_url :"/api/v1/accounts" + + @impl Provider + def generate_terms(%{user: user}) do + build_accounts_tag(%{}, user) + end + + def generate_terms(_params), do: %{} + + def build_accounts_tag(acc, nil), do: acc + + def build_accounts_tag(acc, user) do + account_data = AccountView.render("show.json", %{user: user, for: user}) + Map.put(acc, @account_url, account_data) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 80ea28364..3b55afede 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -718,7 +718,7 @@ defmodule Pleroma.Web.Router do get("/registration/:token", RedirectController, :registration_page) get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) get("/api*path", RedirectController, :api_not_implemented) - get("/*path", RedirectController, :redirector) + get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) end diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index b8f070d6a..be2613ad0 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.RuntimeStaticPlugTest do test "overrides index" do bundled_index = get(build_conn(), "/") - assert html_response(bundled_index, 200) == File.read!("priv/static/index.html") + refute html_response(bundled_index, 200) == "hello world" File.write!(@dir <> "/index.html", "hello world") diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3919ef93a..3b7a51d5e 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -6,22 +6,36 @@ defmodule Pleroma.Web.FallbackTest do use Pleroma.Web.ConnCase import Pleroma.Factory - test "GET /registration/:token", %{conn: conn} do - assert conn - |> get("/registration/foo") - |> html_response(200) =~ "" + describe "neither preloaded data nor metadata attached to" do + test "GET /registration/:token", %{conn: conn} do + response = get(conn, "/registration/foo") + + assert html_response(response, 200) =~ "" + assert html_response(response, 200) =~ "" + end end - test "GET /:maybe_nickname_or_id", %{conn: conn} do - user = insert(:user) + describe "preloaded data and metadata attached to" do + test "GET /:maybe_nickname_or_id", %{conn: conn} do + user = insert(:user) + user_missing = get(conn, "/foo") + user_present = get(conn, "/#{user.nickname}") - assert conn - |> get("/foo") - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" - refute conn - |> get("/" <> user.nickname) - |> html_response(200) =~ "" + assert html_response(user_missing, 200) =~ "" + refute html_response(user_present, 200) =~ "" + end + end + + describe "preloaded data only attached to" do + test "GET /*path", %{conn: conn} do + public_page = get(conn, "/main/public") + + assert html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + end end test "GET /api*path", %{conn: conn} do diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs new file mode 100644 index 000000000..52f9bab3b --- /dev/null +++ b/test/web/preload/instance_test.exs @@ -0,0 +1,37 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.InstanceTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.Instance + + setup do: {:ok, Instance.generate_terms(nil)} + + test "it renders the info", %{"/api/v1/instance": info} do + assert %{ + description: description, + email: "admin@example.com", + registrations: true + } = info + + assert String.equivalent?(description, "A Pleroma instance, an alternative fediverse server") + end + + test "it renders the panel", %{"/instance/panel.html": panel} do + assert String.contains?( + panel, + "

    Welcome to Pleroma!

    " + ) + end + + test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do + %{ + metadata: metadata, + version: "2.0" + } = nodeinfo + + assert metadata.private == false + assert metadata.suggestions == %{enabled: false} + end +end diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs new file mode 100644 index 000000000..00b10d0ab --- /dev/null +++ b/test/web/preload/timeline_test.exs @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.TimelineTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Preload.Providers.Timelines + + @public_url :"/api/v1/timelines/public" + + describe "unauthenticated timeliness when restricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + :ok + end + + test "return nothing" do + tl_data = Timelines.generate_terms(%{}) + + refute Map.has_key?(tl_data, "/api/v1/timelines/public") + end + end + + describe "unauthenticated timeliness when unrestricted" do + setup do + svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + + Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{ + local: false, + federated: false + }) + + on_exit(fn -> + Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) + end) + + {:ok, user: insert(:user)} + end + + test "returns the timeline when not restricted" do + assert Timelines.generate_terms(%{}) + |> Map.has_key?(@public_url) + end + + test "returns public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 3 + end + + test "does not return non-public items", %{user: user} do + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + + assert Timelines.generate_terms(%{}) + |> Map.fetch!(@public_url) + |> Enum.count() == 1 + end + end +end diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs new file mode 100644 index 000000000..99232cdfa --- /dev/null +++ b/test/web/preload/user_test.exs @@ -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.Preload.Providers.UserTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.Web.Preload.Providers.User + + describe "returns empty when user doesn't exist" do + test "nil user specified" do + refute User.generate_terms(%{user: nil}) + |> Map.has_key?("/api/v1/accounts") + end + + test "missing user specified" do + refute User.generate_terms(%{user: :not_a_user}) + |> Map.has_key?("/api/v1/accounts") + end + end + + describe "specified user exists" do + setup do + user = insert(:user) + + {:ok, User.generate_terms(%{user: user})} + end + + test "account is rendered", %{"/api/v1/accounts": accounts} do + assert %{acct: user, username: user} = accounts + end + end +end -- cgit v1.2.3 From dbcc1b105ee1a2552595d189d8ac9d8484ffb601 Mon Sep 17 00:00:00 2001 From: stwf Date: Tue, 2 Jun 2020 10:18:06 -0400 Subject: encode data properly --- lib/pleroma/web/fallback_redirect_controller.ex | 7 ++-- lib/pleroma/web/preload.ex | 2 ++ lib/pleroma/web/preload/timelines.ex | 31 ++++++++--------- test/web/fallback_test.exs | 46 +++++++++++++++---------- test/web/preload/timeline_test.exs | 12 +++---- 5 files changed, 54 insertions(+), 44 deletions(-) diff --git a/lib/pleroma/web/fallback_redirect_controller.ex b/lib/pleroma/web/fallback_redirect_controller.ex index 932fb8d7e..431ad5485 100644 --- a/lib/pleroma/web/fallback_redirect_controller.ex +++ b/lib/pleroma/web/fallback_redirect_controller.ex @@ -4,7 +4,9 @@ defmodule Fallback.RedirectController do use Pleroma.Web, :controller + require Logger + alias Pleroma.User alias Pleroma.Web.Metadata alias Pleroma.Web.Preload @@ -38,8 +40,7 @@ def redirector_with_meta(conn, params) do response = index_content - |> String.replace("", tags) - |> String.replace("", preloads) + |> String.replace("", tags <> preloads) conn |> put_resp_content_type("text/html") @@ -56,7 +57,7 @@ def redirector_with_preload(conn, params) do response = index_content - |> String.replace("", preloads) + |> String.replace("", preloads) conn |> put_resp_content_type("text/html") diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index c2211c597..f13932b89 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -22,6 +22,8 @@ def build_tags(_conn, params) do end def build_script_tag(content) do + content = Base.encode64(content) + HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index dbd7db407..2bb57567b 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -11,32 +11,29 @@ defmodule Pleroma.Web.Preload.Providers.Timelines do @public_url :"/api/v1/timelines/public" @impl Provider - def generate_terms(_params) do - build_public_tag(%{}) + def generate_terms(params) do + build_public_tag(%{}, params) end - def build_public_tag(acc) do + def build_public_tag(acc, params) do if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do acc else - Map.put(acc, @public_url, public_timeline(nil)) + Map.put(acc, @public_url, public_timeline(params)) end end - defp public_timeline(user) do - activities = - create_timeline_params(user) - |> Map.put("local_only", false) - |> ActivityPub.fetch_public_activities() + defp public_timeline(%{"path" => ["main", "all"]}), do: get_public_timeline(false) - StatusView.render("index.json", activities: activities, for: user, as: :activity) - end + defp public_timeline(_params), do: get_public_timeline(true) + + defp get_public_timeline(local_only) do + activities = + ActivityPub.fetch_public_activities(%{ + "type" => ["Create"], + "local_only" => local_only + }) - defp create_timeline_params(user) do - %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + StatusView.render("index.json", activities: activities, for: nil, as: :activity) end end diff --git a/test/web/fallback_test.exs b/test/web/fallback_test.exs index 3b7a51d5e..a65865860 100644 --- a/test/web/fallback_test.exs +++ b/test/web/fallback_test.exs @@ -11,7 +11,12 @@ test "GET /registration/:token", %{conn: conn} do response = get(conn, "/registration/foo") assert html_response(response, 200) =~ "" - assert html_response(response, 200) =~ "" + end + + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" end end @@ -21,20 +26,35 @@ test "GET /:maybe_nickname_or_id", %{conn: conn} do user_missing = get(conn, "/foo") user_present = get(conn, "/#{user.nickname}") - assert html_response(user_missing, 200) =~ "" + assert(html_response(user_missing, 200) =~ "") refute html_response(user_present, 200) =~ "" + assert html_response(user_present, 200) =~ "initial-results" + end - assert html_response(user_missing, 200) =~ "" - refute html_response(user_present, 200) =~ "" + test "GET /*path", %{conn: conn} do + assert conn + |> get("/foo") + |> html_response(200) =~ "" + + refute conn + |> get("/foo/bar") + |> html_response(200) =~ "" end end - describe "preloaded data only attached to" do - test "GET /*path", %{conn: conn} do + describe "preloaded data is attached to" do + test "GET /main/public", %{conn: conn} do public_page = get(conn, "/main/public") - assert html_response(public_page, 200) =~ "" - refute html_response(public_page, 200) =~ "" + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" + end + + test "GET /main/all", %{conn: conn} do + public_page = get(conn, "/main/all") + + refute html_response(public_page, 200) =~ "" + assert html_response(public_page, 200) =~ "initial-results" end end @@ -48,16 +68,6 @@ test "GET /pleroma/admin -> /pleroma/admin/", %{conn: conn} do assert redirected_to(get(conn, "/pleroma/admin")) =~ "/pleroma/admin/" end - test "GET /*path", %{conn: conn} do - assert conn - |> get("/foo") - |> html_response(200) =~ "" - - assert conn - |> get("/foo/bar") - |> html_response(200) =~ "" - end - test "OPTIONS /*path", %{conn: conn} do assert conn |> options("/foo") diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index 00b10d0ab..da6a3aded 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -52,9 +52,9 @@ test "returns the timeline when not restricted" do end test "returns public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) @@ -62,9 +62,9 @@ test "returns public items", %{user: user} do end test "does not return non-public items", %{user: user} do - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 1!", "visibility" => "unlisted"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 2!", "visibility" => "direct"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "it's post 3!"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 1!", visibility: "unlisted"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 2!", visibility: "direct"}) + {:ok, _} = CommonAPI.post(user, %{status: "it's post 3!"}) assert Timelines.generate_terms(%{}) |> Map.fetch!(@public_url) -- cgit v1.2.3 From 3b8180d7d1f52a9eae1913a59b9c970f6600e674 Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 13:40:48 -0400 Subject: add status_net/config --- config/config.exs | 3 ++- lib/pleroma/web/preload.ex | 10 ++++++--- lib/pleroma/web/preload/status_net.ex | 24 ++++++++++++++++++++++ .../web/twitter_api/controllers/util_controller.ex | 13 ++---------- lib/pleroma/web/twitter_api/views/util_view.ex | 14 +++++++++++++ test/web/preload/status_net_test.exs | 14 +++++++++++++ 6 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 lib/pleroma/web/preload/status_net.ex create mode 100644 test/web/preload/status_net_test.exs diff --git a/config/config.exs b/config/config.exs index ee81eb899..0dca26152 100644 --- a/config/config.exs +++ b/config/config.exs @@ -420,7 +420,8 @@ providers: [ Pleroma.Web.Preload.Providers.Instance, Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines + Pleroma.Web.Preload.Providers.Timelines, + Pleroma.Web.Preload.Providers.StatusNet ] config :pleroma, :http_security, diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index f13932b89..90e454468 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -9,7 +9,13 @@ defmodule Pleroma.Web.Preload do def build_tags(_conn, params) do preload_data = Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> - Map.merge(acc, parser.generate_terms(params)) + terms = + params + |> parser.generate_terms() + |> Enum.map(fn {k, v} -> {k, Base.encode64(Jason.encode!(v))} end) + |> Enum.into(%{}) + + Map.merge(acc, terms) end) rendered_html = @@ -22,8 +28,6 @@ def build_tags(_conn, params) do end def build_script_tag(content) do - content = Base.encode64(content) - HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", type: "application/json" diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex new file mode 100644 index 000000000..7e592d60d --- /dev/null +++ b/lib/pleroma/web/preload/status_net.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNet do + alias Pleroma.Web.TwitterAPI.UtilView + alias Pleroma.Web.Preload.Providers.Provider + + @behaviour Provider + @config_url :"/api/statusnet/config.json" + + @impl Provider + def generate_terms(_params) do + %{} + |> build_config_tag() + end + + defp build_config_tag(acc) do + instance = Pleroma.Config.get(:instance) + info_data = UtilView.status_net_config(instance) + + Map.put(acc, @config_url, info_data) + end +end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index fd2aee175..aaca182ec 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.User alias Pleroma.Web alias Pleroma.Web.CommonAPI + alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.WebFinger plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) @@ -90,17 +91,7 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ def config(%{assigns: %{format: "xml"}} = conn, _params) do instance = Pleroma.Config.get(:instance) - - response = """ - - - #{Keyword.get(instance, :name)} - #{Web.base_url()} - #{Keyword.get(instance, :limit)} - #{!Keyword.get(instance, :registrations_open)} - - - """ + response = UtilView.status_net_config(instance) conn |> put_resp_content_type("application/xml") diff --git a/lib/pleroma/web/twitter_api/views/util_view.ex b/lib/pleroma/web/twitter_api/views/util_view.ex index 52054e020..d3bdb4f62 100644 --- a/lib/pleroma/web/twitter_api/views/util_view.ex +++ b/lib/pleroma/web/twitter_api/views/util_view.ex @@ -5,4 +5,18 @@ defmodule Pleroma.Web.TwitterAPI.UtilView do use Pleroma.Web, :view import Phoenix.HTML.Form + alias Pleroma.Web + + def status_net_config(instance) do + """ + + + #{Keyword.get(instance, :name)} + #{Web.base_url()} + #{Keyword.get(instance, :limit)} + #{!Keyword.get(instance, :registrations_open)} + + + """ + end end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs new file mode 100644 index 000000000..ab6823a7e --- /dev/null +++ b/test/web/preload/status_net_test.exs @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Preload.Providers.StatusNetTest do + use Pleroma.DataCase + alias Pleroma.Web.Preload.Providers.StatusNet + + setup do: {:ok, StatusNet.generate_terms(nil)} + + test "it renders the info", %{"/api/statusnet/config.json": info} do + assert info =~ "Pleroma" + end +end -- cgit v1.2.3 From 5677b21e824aa7f3b5124068ef65041e04002579 Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 16:32:38 -0400 Subject: clean up --- config/config.exs | 18 ++++++++++++++++-- lib/pleroma/web/preload/status_net.ex | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0dca26152..6e75b79ec 100644 --- a/config/config.exs +++ b/config/config.exs @@ -241,7 +241,18 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false + cleanup_attachments: false, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 5, + length: 16 + ] + ] config :pleroma, :feed, post_title: %{ @@ -350,7 +361,8 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [] + banner_removal: [], + reject_deletes: [] config :pleroma, :mrf_keyword, reject: [], @@ -678,6 +690,8 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex index 7e592d60d..367442d5c 100644 --- a/lib/pleroma/web/preload/status_net.ex +++ b/lib/pleroma/web/preload/status_net.ex @@ -3,8 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.StatusNet do - alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.Preload.Providers.Provider + alias Pleroma.Web.TwitterAPI.UtilView @behaviour Provider @config_url :"/api/statusnet/config.json" -- cgit v1.2.3 From 75e886b5065dd5ef6b7c99cbbe162f405e1b105e Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 3 Jun 2020 17:32:03 -0400 Subject: fix config --- config/config.exs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0dca26152..6e75b79ec 100644 --- a/config/config.exs +++ b/config/config.exs @@ -241,7 +241,18 @@ account_field_value_length: 2048, external_user_synchronization: true, extended_nickname_format: true, - cleanup_attachments: false + cleanup_attachments: false, + multi_factor_authentication: [ + totp: [ + # digits 6 or 8 + digits: 6, + period: 30 + ], + backup_codes: [ + number: 5, + length: 16 + ] + ] config :pleroma, :feed, post_title: %{ @@ -350,7 +361,8 @@ reject: [], accept: [], avatar_removal: [], - banner_removal: [] + banner_removal: [], + reject_deletes: [] config :pleroma, :mrf_keyword, reject: [], @@ -678,6 +690,8 @@ profiles: %{local: false, remote: false}, activities: %{local: false, remote: false} +config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" -- cgit v1.2.3 From a8132690bd80b83fc0057566d78a49eceefe0349 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 4 Jun 2020 13:46:13 +0400 Subject: Fix credo --- test/web/admin_api/controllers/admin_api_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index f4c37ae6e..2aaec510d 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1744,7 +1744,6 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do end end - describe "/api/pleroma/admin/stats" do test "status visibility count", %{conn: conn} do admin = insert(:user, is_admin: true) -- cgit v1.2.3 From 5d7dda883e76041025384e453da74110c550aa3b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 14:46:41 +0200 Subject: SideEffectsTest: More tests. --- test/web/activity_pub/side_effects_test.exs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 92c266d84..82d72119e 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -321,7 +321,27 @@ test "it streams the created ChatMessage" do {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - with_mock Pleroma.Web.Streamer, [], stream: fn _, _ -> nil end do + with_mock Pleroma.Web.Streamer, [], + stream: fn _, payload -> + case payload do + {^author, cm_ref} -> + assert cm_ref.seen == true + + {^recipient, cm_ref} -> + assert cm_ref.seen == false + + view = + Pleroma.Web.PleromaAPI.ChatView.render("show.json", + last_message: cm_ref, + chat: cm_ref.chat + ) + + assert view.unread == 1 + + _ -> + nil + end + end do {:ok, _create_activity, _meta} = SideEffects.handle(create_activity, local: false, object_data: chat_message_data) -- cgit v1.2.3 From b952f3f37907c735e3426ba43d01027f6f49c5b5 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 14:49:10 +0200 Subject: WebPush: Push out chat message notications. --- lib/pleroma/web/push/impl.ex | 3 ++- lib/pleroma/web/push/subscription.ex | 2 +- test/web/push/impl_test.exs | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 125f33755..006a242af 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -32,7 +32,7 @@ def perform( mastodon_type = notification.type 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, false) user = User.get_cached_by_id(user_id) direct_conversation_id = Activity.direct_conversation_id(activity, user) @@ -171,6 +171,7 @@ def format_title(%{type: type}, mastodon_type) do "follow_request" -> "New Follow Request" "reblog" -> "New Repeat" "favourite" -> "New Favorite" + "pleroma:chat_mention" -> "New Chat Message" type -> "New #{String.capitalize(type || "event")}" end end diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 3e401a490..5b5aa0d59 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Web.Push.Subscription do timestamps() end - @supported_alert_types ~w[follow favourite mention reblog]a + @supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention]a defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 26c65bc82..8fb7faaa5 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.Push.ImplTest do use Pleroma.DataCase + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -196,6 +197,22 @@ test "renders title for create activity with direct visibility" do end describe "build_content/3" do + test "builds content for chat messages" do + user = insert(:user) + recipient = insert(:user) + + {:ok, chat} = CommonAPI.post_chat_message(user, recipient, "hey") + object = Object.normalize(chat, false) + [notification] = Notification.for_user(recipient) + + res = Impl.build_content(notification, user, object) + + assert res == %{ + body: "@#{user.nickname}: hey", + title: "New Chat Message" + } + end + test "hides details for notifications when privacy option enabled" do user = insert(:user, nickname: "Bob") user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) -- cgit v1.2.3 From 6e103a18af6cfd7f454a911e2f0e1ae35cd45aa4 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 14:49:36 +0200 Subject: Docs: Document WebPush changes. --- docs/API/chats.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index c0ef75664..abeee698f 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -237,3 +237,7 @@ There's a new `pleroma:chat_mention` notification, which has this form. It is no ### Streaming There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +### Web Push + +If you want to receive push messages for this type, you'll need to add the `pleroma:chat_mention` type to your alerts in the push subscription. -- cgit v1.2.3 From a42d135cce3e6326cd8a16f08f4ab83633386c2e Mon Sep 17 00:00:00 2001 From: stwf Date: Thu, 4 Jun 2020 10:51:24 -0400 Subject: test fix --- test/web/preload/instance_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 52f9bab3b..42a0d87bc 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -15,7 +15,7 @@ test "it renders the info", %{"/api/v1/instance": info} do registrations: true } = info - assert String.equivalent?(description, "A Pleroma instance, an alternative fediverse server") + assert String.equivalent?(description, "Pleroma: An efficient and flexible fediverse server") end test "it renders the panel", %{"/instance/panel.html": panel} do -- cgit v1.2.3 From 00748e9650e911d828dfe6f769ac20a6b31c8b69 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 17:14:42 +0200 Subject: ChatMessageReferences: Change seen -> unread --- lib/pleroma/chat_message_reference.ex | 18 ++++++------- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- .../views/chat_message_reference_view.ex | 4 +-- ...e_seen_to_unread_in_chat_message_references.exs | 30 ++++++++++++++++++++++ test/web/activity_pub/side_effects_test.exs | 8 +++--- .../controllers/chat_controller_test.exs | 8 +++--- 6 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index 9b00443f5..fc2aaae7a 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -23,15 +23,15 @@ defmodule Pleroma.ChatMessageReference do belongs_to(:object, Object) belongs_to(:chat, Chat) - field(:seen, :boolean, default: false) + field(:unread, :boolean, default: true) timestamps() end def changeset(struct, params) do struct - |> cast(params, [:object_id, :chat_id, :seen]) - |> validate_required([:object_id, :chat_id, :seen]) + |> cast(params, [:object_id, :chat_id, :unread]) + |> validate_required([:object_id, :chat_id, :unread]) end def get_by_id(id) do @@ -73,11 +73,11 @@ def last_message_for_chat(chat) do |> Repo.one() end - def create(chat, object, seen) do + def create(chat, object, unread) do params = %{ chat_id: chat.id, object_id: object.id, - seen: seen + unread: unread } %__MODULE__{} @@ -88,13 +88,13 @@ def create(chat, object, seen) do def unread_count_for_chat(chat) do chat |> for_chat_query() - |> where([cmr], cmr.seen == false) + |> where([cmr], cmr.unread == true) |> Repo.aggregate(:count) end def mark_as_read(cm_ref) do cm_ref - |> changeset(%{seen: true}) + |> changeset(%{unread: false}) |> Repo.update() end @@ -103,7 +103,7 @@ def set_all_seen_for_chat(chat) do |> for_chat_query() |> exclude(:order_by) |> exclude(:preload) - |> where([cmr], cmr.seen == false) - |> Repo.update_all(set: [seen: true]) + |> where([cmr], cmr.unread == true) + |> Repo.update_all(set: [unread: false]) end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0c5709356..e9f109d80 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -140,7 +140,7 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id == actor.ap_id) + {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) Streamer.stream( ["user", "user:pleroma_chat"], diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex index f9405aec5..592bb17f0 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex @@ -16,7 +16,7 @@ def render( id: id, object: %{data: chat_message}, chat_id: chat_id, - seen: seen + unread: unread } } ) do @@ -30,7 +30,7 @@ def render( attachment: chat_message["attachment"] && StatusView.render("attachment.json", attachment: chat_message["attachment"]), - unread: !seen + unread: unread } end diff --git a/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs b/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs new file mode 100644 index 000000000..fd6bc7bc7 --- /dev/null +++ b/priv/repo/migrations/20200604150318_migrate_seen_to_unread_in_chat_message_references.exs @@ -0,0 +1,30 @@ +defmodule Pleroma.Repo.Migrations.MigrateSeenToUnreadInChatMessageReferences do + use Ecto.Migration + + def change do + drop( + index(:chat_message_references, [:chat_id], + where: "seen = false", + name: "unseen_messages_count_index" + ) + ) + + alter table(:chat_message_references) do + add(:unread, :boolean, default: true) + end + + execute("update chat_message_references set unread = not seen") + + alter table(:chat_message_references) do + modify(:unread, :boolean, default: true, null: false) + remove(:seen, :boolean, default: false, null: false) + end + + create( + index(:chat_message_references, [:chat_id], + where: "unread = true", + name: "unread_messages_count_index" + ) + ) + end +end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 82d72119e..40df664eb 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -325,10 +325,10 @@ test "it streams the created ChatMessage" do stream: fn _, payload -> case payload do {^author, cm_ref} -> - assert cm_ref.seen == true + assert cm_ref.unread == false {^recipient, cm_ref} -> - assert cm_ref.seen == false + assert cm_ref.unread == true view = Pleroma.Web.PleromaAPI.ChatView.render("show.json", @@ -369,14 +369,14 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" - assert cm_ref.seen == true + assert cm_ref.unread == false chat = Chat.get(recipient.id, author.ap_id) [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" - assert cm_ref.seen == false + assert cm_ref.unread == true end test "it creates a Chat for the local users and bumps the unread count" do diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index e7892142a..7af6dec1c 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -25,7 +25,7 @@ test "it marks one message as read", %{conn: conn, user: user} do object = Object.normalize(create, false) cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == false + assert cm_ref.unread == true result = conn @@ -36,7 +36,7 @@ test "it marks one message as read", %{conn: conn, user: user} do cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == true + assert cm_ref.unread == false end end @@ -52,7 +52,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do object = Object.normalize(create, false) cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == false + assert cm_ref.unread == true result = conn @@ -63,7 +63,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - assert cm_ref.seen == true + assert cm_ref.unread == false end end -- cgit v1.2.3 From 41503b167335a5f54eb122ecfd945018c9b50f90 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 15:16:10 +0000 Subject: Apply suggestion to test/web/activity_pub/transmogrifier/chat_message_test.exs --- test/web/activity_pub/transmogrifier/chat_message_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index 820090de3..d6736dc3e 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.ChatMessageTest do alias Pleroma.Web.ActivityPub.Transmogrifier describe "handle_incoming" do - test "handles this" do + test "handles chonks with attachment" do data = %{ "@context" => "https://www.w3.org/ns/activitystreams", "actor" => "https://honk.tedunangst.com/u/tedu", -- cgit v1.2.3 From 9a53f619e03cd515460a7b1570d339e4554e1740 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 15:16:15 +0000 Subject: Apply suggestion to test/chat_message_reference_test.exs --- test/chat_message_reference_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/chat_message_reference_test.exs b/test/chat_message_reference_test.exs index 963a0e225..66bf493b4 100644 --- a/test/chat_message_reference_test.exs +++ b/test/chat_message_reference_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.ChatMessageReferencTest do +defmodule Pleroma.ChatMessageReferenceTest do use Pleroma.DataCase, async: true alias Pleroma.Chat -- cgit v1.2.3 From 56dfa0e0fb0ca34054930e64e092f4a3a1b87fd1 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 19:22:49 +0200 Subject: Transmogrifier: Update notification after accepting. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 1 + test/web/activity_pub/transmogrifier/follow_handling_test.exs | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 886403fcd..b2461de2b 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -538,6 +538,7 @@ def handle_incoming( {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked}, {_, false} <- {:user_locked, User.locked?(followed)}, {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, + _ <- Notification.update_notification_type(followed, activity), {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, {:ok, _relationship} <- diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 967389fae..6b003b51c 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do use Pleroma.DataCase alias Pleroma.Activity + alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier @@ -57,9 +58,12 @@ test "it works for incoming follow requests" do activity = Repo.get(Activity, activity.id) assert activity.data["state"] == "accept" assert User.following?(User.get_cached_by_ap_id(data["actor"]), user) + + [notification] = Notification.for_user(user) + assert notification.type == "follow" end - test "with locked accounts, it does not create a follow or an accept" do + test "with locked accounts, it does create a Follow, but not an Accept" do user = insert(:user, locked: true) data = @@ -81,6 +85,9 @@ test "with locked accounts, it does not create a follow or an accept" do |> Repo.all() assert Enum.empty?(accepts) + + [notification] = Notification.for_user(user) + assert notification.type == "follow_request" end test "it works for follow requests when you are already followed, creating a new accept activity" do -- cgit v1.2.3 From 317e2b8d6126d86eafb493fe6c3b7a29af65ee21 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 4 Jun 2020 21:33:16 +0400 Subject: Use atoms as keys in `ActivityPub.fetch_*` functions options --- benchmarks/load_testing/fetcher.ex | 194 +++++---- benchmarks/mix/tasks/pleroma/benchmarks/tags.ex | 16 +- lib/pleroma/bbs/handler.ex | 8 +- lib/pleroma/conversation/participation.ex | 4 +- lib/pleroma/pagination.ex | 17 +- lib/pleroma/web/activity_pub/activity_pub.ex | 448 ++++++++++----------- .../web/activity_pub/activity_pub_controller.ex | 16 +- lib/pleroma/web/activity_pub/utils.ex | 15 +- .../admin_api/controllers/admin_api_controller.ex | 14 +- .../web/admin_api/controllers/status_controller.ex | 10 +- lib/pleroma/web/admin_api/views/report_view.ex | 2 +- lib/pleroma/web/controller_helper.ex | 3 +- lib/pleroma/web/feed/tag_controller.ex | 4 +- lib/pleroma/web/feed/user_controller.ex | 6 +- .../mastodon_api/controllers/account_controller.ex | 4 +- .../controllers/conversation_controller.ex | 17 - .../mastodon_api/controllers/status_controller.ex | 11 +- .../controllers/timeline_controller.ex | 65 ++- .../web/mastodon_api/views/conversation_view.ex | 4 +- .../pleroma_api/controllers/account_controller.ex | 7 +- .../controllers/conversation_controller.ex | 9 +- .../pleroma_api/controllers/scrobble_controller.ex | 5 +- lib/pleroma/web/static_fe/static_fe_controller.ex | 8 +- test/pagination_test.exs | 12 +- test/tasks/relay_test.exs | 10 +- test/user_test.exs | 4 +- test/web/activity_pub/activity_pub_test.exs | 261 ++++++------ 27 files changed, 538 insertions(+), 636 deletions(-) diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 22a06e472..15fd06c3d 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -52,12 +52,12 @@ defp render_views(user) do defp opts_for_home_timeline(user) do %{ - "blocking_user" => user, - "count" => "20", - "muting_user" => user, - "type" => ["Create", "Announce"], - "user" => user, - "with_muted" => "true" + blocking_user: user, + count: "20", + muting_user: user, + type: ["Create", "Announce"], + user: user, + with_muted: true } end @@ -70,17 +70,17 @@ defp fetch_home_timeline(user) do 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)) + 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)) + 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)) + ActivityPub.fetch_activities(recipients, Map.put(opts, :max_id, third_page_last.id)) |> Enum.reverse() |> List.last() @@ -90,19 +90,19 @@ defp fetch_home_timeline(user) do }, 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" => 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"), + 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"), + 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"), + 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") + Map.put(opts, :max_id, forth_page_last.id) |> Map.put(:only_media, true) }, formatters: formatters() ) @@ -110,12 +110,12 @@ defp fetch_home_timeline(user) do defp opts_for_direct_timeline(user) do %{ - :visibility => "direct", - "blocking_user" => user, - "count" => "20", - "type" => "Create", - "user" => user, - "with_muted" => "true" + visibility: "direct", + blocking_user: user, + count: "20", + type: "Create", + user: user, + with_muted: true } end @@ -130,7 +130,7 @@ defp fetch_direct_timeline(user) do |> Pagination.fetch_paginated(opts) |> List.last() - opts2 = Map.put(opts, "max_id", first_page_last.id) + opts2 = Map.put(opts, :max_id, first_page_last.id) second_page_last = recipients @@ -138,7 +138,7 @@ defp fetch_direct_timeline(user) do |> Pagination.fetch_paginated(opts2) |> List.last() - opts3 = Map.put(opts, "max_id", second_page_last.id) + opts3 = Map.put(opts, :max_id, second_page_last.id) third_page_last = recipients @@ -146,7 +146,7 @@ defp fetch_direct_timeline(user) do |> Pagination.fetch_paginated(opts3) |> List.last() - opts4 = Map.put(opts, "max_id", third_page_last.id) + opts4 = Map.put(opts, :max_id, third_page_last.id) forth_page_last = recipients @@ -165,7 +165,7 @@ defp fetch_direct_timeline(user) do "2 page" => opts2, "3 page" => opts3, "4 page" => opts4, - "5 page" => Map.put(opts4, "max_id", forth_page_last.id) + "5 page" => Map.put(opts4, :max_id, forth_page_last.id) }, formatters: formatters() ) @@ -173,34 +173,34 @@ defp fetch_direct_timeline(user) do defp opts_for_public_timeline(user) do %{ - "type" => ["Create", "Announce"], - "local_only" => false, - "blocking_user" => user, - "muting_user" => user + 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 + 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" + 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 @@ -223,7 +223,7 @@ defp fetch_public_timeline(user, :tag) do end defp fetch_public_timeline(user, :only_media) do - opts = opts_for_public_timeline(user) |> Map.put("only_media", "true") + opts = opts_for_public_timeline(user) |> Map.put(:only_media, true) fetch_public_timeline(opts, "public timeline only media") end @@ -245,15 +245,13 @@ defp fetch_public_timeline(user, :with_blocks) do user = User.get_by_id(user.id) - opts = Map.put(opts, "blocking_user", user) + opts = Map.put(opts, :blocking_user, user) - Benchee.run( - %{ - "public timeline with user block" => fn -> - ActivityPub.fetch_public_activities(opts) - end - }, - ) + Benchee.run(%{ + "public timeline with user block" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) domains = Enum.reduce(remote_non_friends, [], fn non_friend, domains -> @@ -269,30 +267,28 @@ defp fetch_public_timeline(user, :with_blocks) do end) user = User.get_by_id(user.id) - opts = Map.put(opts, "blocking_user", user) + opts = Map.put(opts, :blocking_user, user) - Benchee.run( - %{ - "public timeline with domain block" => fn opts -> - ActivityPub.fetch_public_activities(opts) - end - } - ) + Benchee.run(%{ + "public timeline with domain block" => fn -> + ActivityPub.fetch_public_activities(opts) + end + }) 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)) + 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)) + 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)) + ActivityPub.fetch_public_activities(Map.put(opts, :max_id, third_page_last.id)) |> List.last() Benchee.run( @@ -303,17 +299,17 @@ defp fetch_public_timeline(opts, title) when is_binary(title) do }, 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) + "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"} + %{count: "20", with_muted: true} end defp fetch_notifications(user) do @@ -322,15 +318,15 @@ defp fetch_notifications(user) do 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)) + 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)) + 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)) + MastodonAPI.get_notifications(user, Map.put(opts, :max_id, third_page_last.id)) |> List.last() Benchee.run( @@ -341,10 +337,10 @@ defp fetch_notifications(user) do }, 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) + "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() ) @@ -354,13 +350,13 @@ 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() + 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() + 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() + ActivityPub.fetch_favourites(user, %{:max_id => third_page_last.id}) |> List.last() Benchee.run( %{ @@ -370,10 +366,10 @@ defp fetch_favourites(user) do }, 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} + "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() ) @@ -381,8 +377,8 @@ defp fetch_favourites(user) do defp opts_for_long_thread(user) do %{ - "blocking_user" => user, - "user" => user + blocking_user: user, + user: user } end @@ -392,9 +388,9 @@ defp fetch_long_thread(user) do opts = opts_for_long_thread(user) - private_input = {private.data["context"], Map.put(opts, "exclude_id", private.id)} + private_input = {private.data["context"], Map.put(opts, :exclude_id, private.id)} - public_input = {public.data["context"], Map.put(opts, "exclude_id", public.id)} + public_input = {public.data["context"], Map.put(opts, :exclude_id, public.id)} Benchee.run( %{ @@ -514,13 +510,13 @@ defp render_long_thread(user) do public_context = ActivityPub.fetch_activities_for_context( public.data["context"], - Map.put(fetch_opts, "exclude_id", public.id) + Map.put(fetch_opts, :exclude_id, public.id) ) private_context = ActivityPub.fetch_activities_for_context( private.data["context"], - Map.put(fetch_opts, "exclude_id", private.id) + Map.put(fetch_opts, :exclude_id, private.id) ) Benchee.run( @@ -551,14 +547,14 @@ defp fetch_timelines_with_reply_filtering(user) do end, "Public timeline with reply filtering - following" => fn -> public_params - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() end, "Public timeline with reply filtering - self" => fn -> public_params - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() end }, @@ -577,16 +573,16 @@ defp fetch_timelines_with_reply_filtering(user) do "Home timeline with reply filtering - following" => fn -> private_params = private_params - |> Map.put("reply_filtering_user", user) - |> Map.put("reply_visibility", "following") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:reply_visibility, "following") ActivityPub.fetch_activities(recipients, private_params) end, "Home timeline with reply filtering - self" => fn -> private_params = private_params - |> Map.put("reply_filtering_user", user) - |> Map.put("reply_visibility", "self") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:reply_visibility, "self") ActivityPub.fetch_activities(recipients, private_params) end diff --git a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex index 1162b2e06..c051335a5 100644 --- a/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex +++ b/benchmarks/mix/tasks/pleroma/benchmarks/tags.ex @@ -100,14 +100,14 @@ defp hashtag_fetching(params, user, local_only) do _activities = params - |> Map.put("type", "Create") - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("tag", tags) - |> Map.put("tag_all", tag_all) - |> Map.put("tag_reject", tag_reject) + |> Map.put(:type, "Create") + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:tag, tags) + |> Map.put(:tag_all, tag_all) + |> Map.put(:tag_reject, tag_reject) |> Pleroma.Web.ActivityPub.ActivityPub.fetch_public_activities() end end diff --git a/lib/pleroma/bbs/handler.ex b/lib/pleroma/bbs/handler.ex index 12d64c2fe..cd523cf7d 100644 --- a/lib/pleroma/bbs/handler.ex +++ b/lib/pleroma/bbs/handler.ex @@ -92,10 +92,10 @@ def handle_command(state, "home") do params = %{} - |> Map.put("type", ["Create"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) activities = [user.ap_id | Pleroma.User.following(user)] diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index 51bb1bda9..ce7bd2396 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -163,8 +163,8 @@ def for_user_with_last_activity_id(user, params \\ %{}) do |> Enum.map(fn participation -> activity_id = ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - "user" => user, - "blocking_user" => user + user: user, + blocking_user: user }) %{ diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index d43a96cd2..0ccc7b1f2 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -16,19 +16,16 @@ defmodule Pleroma.Pagination do @default_limit 20 @max_limit 40 - @page_keys ["max_id", "min_id", "limit", "since_id", "order"] - - def page_keys, do: @page_keys @spec fetch_paginated(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] def fetch_paginated(query, params, type \\ :keyset, table_binding \\ nil) - def fetch_paginated(query, %{"total" => true} = params, :keyset, table_binding) do + def fetch_paginated(query, %{total: true} = params, :keyset, table_binding) do total = Repo.aggregate(query, :count, :id) %{ total: total, - items: fetch_paginated(query, Map.drop(params, ["total"]), :keyset, table_binding) + items: fetch_paginated(query, Map.drop(params, [:total]), :keyset, table_binding) } end @@ -41,7 +38,7 @@ def fetch_paginated(query, params, :keyset, table_binding) do |> enforce_order(options) end - def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) do + def fetch_paginated(query, %{total: true} = params, :offset, table_binding) do total = query |> Ecto.Query.exclude(:left_join) @@ -49,7 +46,7 @@ def fetch_paginated(query, %{"total" => true} = params, :offset, table_binding) %{ total: total, - items: fetch_paginated(query, Map.drop(params, ["total"]), :offset, table_binding) + items: fetch_paginated(query, Map.drop(params, [:total]), :offset, table_binding) } end @@ -90,12 +87,6 @@ defp cast_params(params) do skip_order: :boolean } - params = - Enum.reduce(params, %{}, fn - {key, _value}, acc when is_atom(key) -> Map.drop(acc, [key]) - {key, value}, acc -> Map.put(acc, key, value) - end) - changeset = cast({%{}, param_types}, params, Map.keys(param_types)) changeset.changes end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 958f3e5af..ef21f180b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -67,16 +67,12 @@ defp get_recipients(data) do {recipients, to, cc} end - defp check_actor_is_active(actor) do - if not is_nil(actor) do - with user <- User.get_cached_by_ap_id(actor), - false <- user.deactivated do - true - else - _e -> false - end - else - true + defp check_actor_is_active(nil), do: true + + defp check_actor_is_active(actor) when is_binary(actor) do + case User.get_cached_by_ap_id(actor) do + %User{deactivated: deactivated} -> not deactivated + _ -> false end end @@ -87,7 +83,7 @@ defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil( defp check_remote_limit(_), do: true - def increase_note_count_if_public(actor, object) do + defp increase_note_count_if_public(actor, object) do if is_public?(object), do: User.increase_note_count(actor), else: {:ok, actor} end @@ -95,36 +91,26 @@ def decrease_note_count_if_public(actor, object) do if is_public?(object), do: User.decrease_note_count(actor), else: {:ok, actor} end - def increase_replies_count_if_reply(%{ - "object" => %{"inReplyTo" => reply_ap_id} = object, - "type" => "Create" - }) do + defp increase_replies_count_if_reply(%{ + "object" => %{"inReplyTo" => reply_ap_id} = object, + "type" => "Create" + }) do if is_public?(object) do Object.increase_replies_count(reply_ap_id) end end - def increase_replies_count_if_reply(_create_data), do: :noop - - def decrease_replies_count_if_reply(%Object{ - data: %{"inReplyTo" => reply_ap_id} = object - }) do - if is_public?(object) do - Object.decrease_replies_count(reply_ap_id) - end - end - - def decrease_replies_count_if_reply(_object), do: :noop + defp increase_replies_count_if_reply(_create_data), do: :noop - def increase_poll_votes_if_vote(%{ - "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, - "type" => "Create", - "actor" => actor - }) do + defp increase_poll_votes_if_vote(%{ + "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, + "type" => "Create", + "actor" => actor + }) do Object.increase_vote_count(reply_ap_id, name, actor) end - def increase_poll_votes_if_vote(_create_data), do: :noop + defp increase_poll_votes_if_vote(_create_data), do: :noop @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(object, meta) do @@ -203,8 +189,8 @@ def notify_and_stream(activity) do defp create_or_bump_conversation(activity, actor) do with {:ok, conversation} <- Conversation.create_or_bump_for(activity), - %User{} = user <- User.get_cached_by_ap_id(actor), - Participation.mark_as_read(user, conversation) do + %User{} = user <- User.get_cached_by_ap_id(actor) do + Participation.mark_as_read(user, conversation) {:ok, conversation} end end @@ -226,13 +212,15 @@ def stream_out_participations(participations) do end def stream_out_participations(%Object{data: %{"context" => context}}, user) do - with %Conversation{} = conversation <- Conversation.get_for_ap_id(context), - conversation = Repo.preload(conversation, :participations), - last_activity_id = - fetch_latest_activity_id_for_context(conversation.ap_id, %{ - "user" => user, - "blocking_user" => user - }) do + with %Conversation{} = conversation <- Conversation.get_for_ap_id(context) do + conversation = Repo.preload(conversation, :participations) + + last_activity_id = + fetch_latest_activity_id_for_context(conversation.ap_id, %{ + user: user, + blocking_user: user + }) + if last_activity_id do stream_out_participations(conversation.participations) end @@ -266,12 +254,13 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param published = params[:published] quick_insert? = Config.get([:env]) == :benchmark - with create_data <- - make_create_data( - %{to: to, actor: actor, published: published, context: context, object: object}, - additional - ), - {:ok, activity} <- insert(create_data, local, fake), + create_data = + make_create_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ) + + with {:ok, activity} <- insert(create_data, local, fake), {:fake, false, activity} <- {:fake, fake, activity}, _ <- increase_replies_count_if_reply(create_data), _ <- increase_poll_votes_if_vote(create_data), @@ -299,12 +288,13 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d local = !(params[:local] == false) published = params[:published] - with listen_data <- - make_listen_data( - %{to: to, actor: actor, published: published, context: context, object: object}, - additional - ), - {:ok, activity} <- insert(listen_data, local), + listen_data = + make_listen_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ) + + with {:ok, activity} <- insert(listen_data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -322,14 +312,15 @@ def reject(params) do end @spec accept_or_reject(String.t(), map()) :: {:ok, Activity.t()} | {:error, any()} - def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do + defp accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do local = Map.get(params, :local, true) activity_id = Map.get(params, :activity_id, nil) - with data <- - %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} - |> Utils.maybe_put("id", activity_id), - {:ok, activity} <- insert(data, local), + data = + %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} + |> Utils.maybe_put("id", activity_id) + + with {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -341,15 +332,17 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do local = !(params[:local] == false) activity_id = params[:activity_id] - with data <- %{ - "to" => to, - "cc" => cc, - "type" => "Update", - "actor" => actor, - "object" => object - }, - data <- Utils.maybe_put(data, "id", activity_id), - {:ok, activity} <- insert(data, local), + data = + %{ + "to" => to, + "cc" => cc, + "type" => "Update", + "actor" => actor, + "object" => object + } + |> Utils.maybe_put("id", activity_id) + + with {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -366,8 +359,9 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) do end defp do_follow(follower, followed, activity_id, local) do - with data <- make_follow_data(follower, followed, activity_id), - {:ok, activity} <- insert(data, local), + data = make_follow_data(follower, followed, activity_id) + + with {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -411,13 +405,13 @@ def block(blocker, blocked, activity_id \\ nil, local \\ true) do defp do_block(blocker, blocked, activity_id, local) do unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) - if unfollow_blocked do - follow_activity = fetch_latest_follow(blocker, blocked) - if follow_activity, do: unfollow(blocker, blocked, nil, local) + if unfollow_blocked and fetch_latest_follow(blocker, blocked) do + unfollow(blocker, blocked, nil, local) end - with block_data <- make_block_data(blocker, blocked, activity_id), - {:ok, activity} <- insert(block_data, local), + block_data = make_block_data(blocker, blocked, activity_id) + + with {:ok, activity} <- insert(block_data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} @@ -496,8 +490,8 @@ def fetch_activities_for_context_query(context, opts) do public = [Constants.as_public()] recipients = - if opts["user"], - do: [opts["user"].ap_id | User.following(opts["user"])] ++ public, + if opts[:user], + do: [opts[:user].ap_id | User.following(opts[:user])] ++ public, else: public from(activity in Activity) @@ -505,7 +499,7 @@ def fetch_activities_for_context_query(context, opts) do |> maybe_preload_bookmarks(opts) |> maybe_set_thread_muted_field(opts) |> restrict_blocked(opts) - |> restrict_recipients(recipients, opts["user"]) + |> restrict_recipients(recipients, opts[:user]) |> where( [activity], fragment( @@ -532,7 +526,7 @@ def fetch_activities_for_context(context, opts \\ %{}) do FlakeId.Ecto.CompatType.t() | nil def fetch_latest_activity_id_for_context(context, opts \\ %{}) do context - |> fetch_activities_for_context_query(Map.merge(%{"skip_preload" => true}, opts)) + |> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts)) |> limit(1) |> select([a], a.id) |> Repo.one() @@ -540,24 +534,18 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do - opts = Map.drop(opts, ["user"]) - - query = fetch_activities_query([Constants.as_public()], opts) - - query = - if opts["restrict_unlisted"] do - restrict_unlisted(query) - else - query - end + opts = Map.delete(opts, :user) - Pagination.fetch_paginated(query, opts, pagination) + [Constants.as_public()] + |> fetch_activities_query(opts) + |> restrict_unlisted(opts) + |> Pagination.fetch_paginated(opts, pagination) end @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do opts - |> Map.put("restrict_unlisted", true) + |> Map.put(:restrict_unlisted, true) |> fetch_public_or_unlisted_activities(pagination) end @@ -566,20 +554,17 @@ def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do defp restrict_visibility(query, %{visibility: visibility}) when is_list(visibility) do if Enum.all?(visibility, &(&1 in @valid_visibilities)) do - query = - from( - a in query, - where: - fragment( - "activity_visibility(?, ?, ?) = ANY (?)", - a.actor, - a.recipients, - a.data, - ^visibility - ) - ) - - query + from( + a in query, + where: + fragment( + "activity_visibility(?, ?, ?) = ANY (?)", + a.actor, + a.recipients, + a.data, + ^visibility + ) + ) else Logger.error("Could not restrict visibility to #{visibility}") end @@ -601,7 +586,7 @@ defp restrict_visibility(_query, %{visibility: visibility}) defp restrict_visibility(query, _visibility), do: query - defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) + defp exclude_visibility(query, %{exclude_visibilities: visibility}) when is_list(visibility) do if Enum.all?(visibility, &(&1 in @valid_visibilities)) do from( @@ -621,7 +606,7 @@ defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) end end - defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) + defp exclude_visibility(query, %{exclude_visibilities: visibility}) when visibility in @valid_visibilities do from( a in query, @@ -636,7 +621,7 @@ defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) ) end - defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) + defp exclude_visibility(query, %{exclude_visibilities: visibility}) when visibility not in [nil | @valid_visibilities] do Logger.error("Could not exclude visibility to #{visibility}") query @@ -647,14 +632,10 @@ defp exclude_visibility(query, _visibility), do: query defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _), do: query - defp restrict_thread_visibility( - query, - %{"user" => %User{skip_thread_containment: true}}, - _ - ), - do: query + defp restrict_thread_visibility(query, %{user: %User{skip_thread_containment: true}}, _), + do: query - defp restrict_thread_visibility(query, %{"user" => %User{ap_id: ap_id}}, _) do + defp restrict_thread_visibility(query, %{user: %User{ap_id: ap_id}}, _) do from( a in query, where: fragment("thread_visibility(?, (?)->>'id') = true", ^ap_id, a.data) @@ -666,87 +647,79 @@ defp restrict_thread_visibility(query, _, _), do: query def fetch_user_abstract_activities(user, reading_user, params \\ %{}) do params = params - |> Map.put("user", reading_user) - |> Map.put("actor_id", user.ap_id) - - recipients = - user_activities_recipients(%{ - "godmode" => params["godmode"], - "reading_user" => reading_user - }) + |> Map.put(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) - fetch_activities(recipients, params) + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params) |> Enum.reverse() end def fetch_user_activities(user, reading_user, params \\ %{}) do params = params - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("user", reading_user) - |> Map.put("actor_id", user.ap_id) - |> Map.put("pinned_activity_ids", user.pinned_activities) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) + |> Map.put(:pinned_activity_ids, user.pinned_activities) params = if User.blocks?(reading_user, user) do params else params - |> Map.put("blocking_user", reading_user) - |> Map.put("muting_user", reading_user) + |> Map.put(:blocking_user, reading_user) + |> Map.put(:muting_user, reading_user) end - recipients = - user_activities_recipients(%{ - "godmode" => params["godmode"], - "reading_user" => reading_user - }) - - fetch_activities(recipients, params) + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params) |> Enum.reverse() end def fetch_statuses(reading_user, params) do - params = - params - |> Map.put("type", ["Create", "Announce"]) + params = Map.put(params, :type, ["Create", "Announce"]) - recipients = - user_activities_recipients(%{ - "godmode" => params["godmode"], - "reading_user" => reading_user - }) - - fetch_activities(recipients, params, :offset) + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> user_activities_recipients() + |> fetch_activities(params, :offset) |> Enum.reverse() end - defp user_activities_recipients(%{"godmode" => true}) do - [] - end + defp user_activities_recipients(%{godmode: true}), do: [] - defp user_activities_recipients(%{"reading_user" => reading_user}) do + defp user_activities_recipients(%{reading_user: reading_user}) do if reading_user do - [Constants.as_public()] ++ [reading_user.ap_id | User.following(reading_user)] + [Constants.as_public(), reading_user.ap_id | User.following(reading_user)] else [Constants.as_public()] end end - defp restrict_since(query, %{"since_id" => ""}), do: query + defp restrict_since(query, %{since_id: ""}), do: query - defp restrict_since(query, %{"since_id" => since_id}) do + defp restrict_since(query, %{since_id: since_id}) do from(activity in query, where: activity.id > ^since_id) end defp restrict_since(query, _), do: query - defp restrict_tag_reject(_query, %{"tag_reject" => _tag_reject, "skip_preload" => true}) do + defp restrict_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_tag_reject(query, %{"tag_reject" => tag_reject}) - when is_list(tag_reject) and tag_reject != [] do + defp restrict_tag_reject(query, %{tag_reject: [_ | _] = tag_reject}) do from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) @@ -755,12 +728,11 @@ defp restrict_tag_reject(query, %{"tag_reject" => tag_reject}) defp restrict_tag_reject(query, _), do: query - defp restrict_tag_all(_query, %{"tag_all" => _tag_all, "skip_preload" => true}) do + defp restrict_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_tag_all(query, %{"tag_all" => tag_all}) - when is_list(tag_all) and tag_all != [] do + defp restrict_tag_all(query, %{tag_all: [_ | _] = tag_all}) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) @@ -769,18 +741,18 @@ defp restrict_tag_all(query, %{"tag_all" => tag_all}) defp restrict_tag_all(query, _), do: query - defp restrict_tag(_query, %{"tag" => _tag, "skip_preload" => true}) do + defp restrict_tag(_query, %{tag: _tag, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_tag(query, %{"tag" => tag}) when is_list(tag) do + defp restrict_tag(query, %{tag: tag}) when is_list(tag) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) ) end - defp restrict_tag(query, %{"tag" => tag}) when is_binary(tag) do + defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\? (?)", object.data, ^tag) @@ -803,35 +775,35 @@ defp restrict_recipients(query, recipients, user) do ) end - defp restrict_local(query, %{"local_only" => true}) do + defp restrict_local(query, %{local_only: true}) do from(activity in query, where: activity.local == true) end defp restrict_local(query, _), do: query - defp restrict_actor(query, %{"actor_id" => actor_id}) do + defp restrict_actor(query, %{actor_id: actor_id}) do from(activity in query, where: activity.actor == ^actor_id) end defp restrict_actor(query, _), do: query - defp restrict_type(query, %{"type" => type}) when is_binary(type) do + defp restrict_type(query, %{type: type}) when is_binary(type) do from(activity in query, where: fragment("?->>'type' = ?", activity.data, ^type)) end - defp restrict_type(query, %{"type" => type}) do + defp restrict_type(query, %{type: type}) do from(activity in query, where: fragment("?->>'type' = ANY(?)", activity.data, ^type)) end defp restrict_type(query, _), do: query - defp restrict_state(query, %{"state" => state}) do + defp restrict_state(query, %{state: state}) do from(activity in query, where: fragment("?->>'state' = ?", activity.data, ^state)) end defp restrict_state(query, _), do: query - defp restrict_favorited_by(query, %{"favorited_by" => ap_id}) do + defp restrict_favorited_by(query, %{favorited_by: ap_id}) do from( [_activity, object] in query, where: fragment("(?)->'likes' \\? (?)", object.data, ^ap_id) @@ -840,11 +812,11 @@ defp restrict_favorited_by(query, %{"favorited_by" => ap_id}) do defp restrict_favorited_by(query, _), do: query - defp restrict_media(_query, %{"only_media" => _val, "skip_preload" => true}) do + defp restrict_media(_query, %{only_media: _val, skip_preload: true}) do raise "Can't use the child object without preloading!" end - defp restrict_media(query, %{"only_media" => val}) when val in [true, "true", "1"] do + defp restrict_media(query, %{only_media: true}) do from( [_activity, object] in query, where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) @@ -853,7 +825,7 @@ defp restrict_media(query, %{"only_media" => val}) when val in [true, "true", "1 defp restrict_media(query, _), do: query - defp restrict_replies(query, %{"exclude_replies" => val}) when val in [true, "true", "1"] do + defp restrict_replies(query, %{exclude_replies: true}) do from( [_activity, object] in query, where: fragment("?->>'inReplyTo' is null", object.data) @@ -861,8 +833,8 @@ defp restrict_replies(query, %{"exclude_replies" => val}) when val in [true, "tr end defp restrict_replies(query, %{ - "reply_filtering_user" => user, - "reply_visibility" => "self" + reply_filtering_user: user, + reply_visibility: "self" }) do from( [activity, object] in query, @@ -877,8 +849,8 @@ defp restrict_replies(query, %{ end defp restrict_replies(query, %{ - "reply_filtering_user" => user, - "reply_visibility" => "following" + reply_filtering_user: user, + reply_visibility: "following" }) do from( [activity, object] in query, @@ -897,16 +869,16 @@ defp restrict_replies(query, %{ defp restrict_replies(query, _), do: query - defp restrict_reblogs(query, %{"exclude_reblogs" => val}) when val in [true, "true", "1"] do + defp restrict_reblogs(query, %{exclude_reblogs: true}) do from(activity in query, where: fragment("?->>'type' != 'Announce'", activity.data)) end defp restrict_reblogs(query, _), do: query - defp restrict_muted(query, %{"with_muted" => val}) when val in [true, "true", "1"], do: query + defp restrict_muted(query, %{with_muted: true}), do: query - defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do - mutes = opts["muted_users_ap_ids"] || User.muted_users_ap_ids(user) + defp restrict_muted(query, %{muting_user: %User{} = user} = opts) do + mutes = opts[:muted_users_ap_ids] || User.muted_users_ap_ids(user) query = from([activity] in query, @@ -914,7 +886,7 @@ defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do where: fragment("not (?->'to' \\?| ?)", activity.data, ^mutes) ) - unless opts["skip_preload"] do + unless opts[:skip_preload] do from([thread_mute: tm] in query, where: is_nil(tm.user_id)) else query @@ -923,8 +895,8 @@ defp restrict_muted(query, %{"muting_user" => %User{} = user} = opts) do defp restrict_muted(query, _), do: query - defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do - blocked_ap_ids = opts["blocked_users_ap_ids"] || User.blocked_users_ap_ids(user) + defp restrict_blocked(query, %{blocking_user: %User{} = user} = opts) do + blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user) domain_blocks = user.domain_blocks || [] following_ap_ids = User.get_friends_ap_ids(user) @@ -970,7 +942,7 @@ defp restrict_blocked(query, %{"blocking_user" => %User{} = user} = opts) do defp restrict_blocked(query, _), do: query - defp restrict_unlisted(query) do + defp restrict_unlisted(query, %{restrict_unlisted: true}) do from( activity in query, where: @@ -982,19 +954,16 @@ defp restrict_unlisted(query) do ) end - # TODO: when all endpoints migrated to OpenAPI compare `pinned` with `true` (boolean) only, - # the same for `restrict_media/2`, `restrict_replies/2`, 'restrict_reblogs/2' - # and `restrict_muted/2` + defp restrict_unlisted(query, _), do: query - defp restrict_pinned(query, %{"pinned" => pinned, "pinned_activity_ids" => ids}) - when pinned in [true, "true", "1"] do + defp restrict_pinned(query, %{pinned: true, pinned_activity_ids: ids}) do from(activity in query, where: activity.id in ^ids) end defp restrict_pinned(query, _), do: query - defp restrict_muted_reblogs(query, %{"muting_user" => %User{} = user} = opts) do - muted_reblogs = opts["reblog_muted_users_ap_ids"] || User.reblog_muted_users_ap_ids(user) + defp restrict_muted_reblogs(query, %{muting_user: %User{} = user} = opts) do + muted_reblogs = opts[:reblog_muted_users_ap_ids] || User.reblog_muted_users_ap_ids(user) from( activity in query, @@ -1010,7 +979,7 @@ defp restrict_muted_reblogs(query, %{"muting_user" => %User{} = user} = opts) do defp restrict_muted_reblogs(query, _), do: query - defp restrict_instance(query, %{"instance" => instance}) do + defp restrict_instance(query, %{instance: instance}) do users = from( u in User, @@ -1024,7 +993,7 @@ defp restrict_instance(query, %{"instance" => instance}) do defp restrict_instance(query, _), do: query - defp exclude_poll_votes(query, %{"include_poll_votes" => true}), do: query + defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query defp exclude_poll_votes(query, _) do if has_named_binding?(query, :object) do @@ -1036,7 +1005,7 @@ defp exclude_poll_votes(query, _) do end end - defp exclude_invisible_actors(query, %{"invisible_actors" => true}), do: query + defp exclude_invisible_actors(query, %{invisible_actors: true}), do: query defp exclude_invisible_actors(query, _opts) do invisible_ap_ids = @@ -1047,38 +1016,38 @@ defp exclude_invisible_actors(query, _opts) do from([activity] in query, where: activity.actor not in ^invisible_ap_ids) end - defp exclude_id(query, %{"exclude_id" => id}) when is_binary(id) do + defp exclude_id(query, %{exclude_id: id}) when is_binary(id) do from(activity in query, where: activity.id != ^id) end defp exclude_id(query, _), do: query - defp maybe_preload_objects(query, %{"skip_preload" => true}), do: query + defp maybe_preload_objects(query, %{skip_preload: true}), do: query defp maybe_preload_objects(query, _) do query |> Activity.with_preloaded_object() end - defp maybe_preload_bookmarks(query, %{"skip_preload" => true}), do: query + defp maybe_preload_bookmarks(query, %{skip_preload: true}), do: query defp maybe_preload_bookmarks(query, opts) do query - |> Activity.with_preloaded_bookmark(opts["user"]) + |> Activity.with_preloaded_bookmark(opts[:user]) end - defp maybe_preload_report_notes(query, %{"preload_report_notes" => true}) do + defp maybe_preload_report_notes(query, %{preload_report_notes: true}) do query |> Activity.with_preloaded_report_notes() end defp maybe_preload_report_notes(query, _), do: query - defp maybe_set_thread_muted_field(query, %{"skip_preload" => true}), do: query + defp maybe_set_thread_muted_field(query, %{skip_preload: true}), do: query defp maybe_set_thread_muted_field(query, opts) do query - |> Activity.with_set_thread_muted_field(opts["muting_user"] || opts["user"]) + |> Activity.with_set_thread_muted_field(opts[:muting_user] || opts[:user]) end defp maybe_order(query, %{order: :desc}) do @@ -1094,24 +1063,23 @@ defp maybe_order(query, %{order: :asc}) do defp maybe_order(query, _), do: query defp fetch_activities_query_ap_ids_ops(opts) do - source_user = opts["muting_user"] + source_user = opts[:muting_user] ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] ap_id_relationships = - ap_id_relationships ++ - if opts["blocking_user"] && opts["blocking_user"] == source_user do - [:block] - else - [] - end + if opts[:blocking_user] && opts[:blocking_user] == source_user do + [:block | ap_id_relationships] + else + ap_id_relationships + end 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) + 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) restrict_muted_reblogs_opts = - Map.merge(%{"reblog_muted_users_ap_ids" => preloaded_ap_ids[:reblog_mute]}, opts) + Map.merge(%{reblog_muted_users_ap_ids: preloaded_ap_ids[:reblog_mute]}, opts) {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} end @@ -1130,7 +1098,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> maybe_preload_report_notes(opts) |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) - |> restrict_recipients(recipients, opts["user"]) + |> restrict_recipients(recipients, opts[:user]) |> restrict_replies(opts) |> restrict_tag(opts) |> restrict_tag_reject(opts) @@ -1157,12 +1125,12 @@ def fetch_activities_query(recipients, opts \\ %{}) do end def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do - list_memberships = Pleroma.List.memberships(opts["user"]) + list_memberships = Pleroma.List.memberships(opts[:user]) fetch_activities_query(recipients ++ list_memberships, opts) |> Pagination.fetch_paginated(opts, pagination) |> Enum.reverse() - |> maybe_update_cc(list_memberships, opts["user"]) + |> maybe_update_cc(list_memberships, opts[:user]) end @doc """ @@ -1178,16 +1146,15 @@ def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do |> select([_like, object, activity], %{activity | object: object}) |> order_by([like, _, _], desc_nulls_last: like.id) |> Pagination.fetch_paginated( - Map.merge(params, %{"skip_order" => true}), + Map.merge(params, %{skip_order: true}), pagination, :object_activity ) end - defp maybe_update_cc(activities, list_memberships, %User{ap_id: user_ap_id}) - when is_list(list_memberships) and length(list_memberships) > 0 do + defp maybe_update_cc(activities, [_ | _] = list_memberships, %User{ap_id: user_ap_id}) do Enum.map(activities, fn - %{data: %{"bcc" => bcc}} = activity when is_list(bcc) and length(bcc) > 0 -> + %{data: %{"bcc" => [_ | _] = bcc}} = activity -> if Enum.any?(bcc, &(&1 in list_memberships)) do update_in(activity.data["cc"], &[user_ap_id | &1]) else @@ -1201,7 +1168,7 @@ defp maybe_update_cc(activities, list_memberships, %User{ap_id: user_ap_id}) defp maybe_update_cc(activities, _, _), do: activities - def fetch_activities_bounded_query(query, recipients, recipients_with_public) do + defp fetch_activities_bounded_query(query, recipients, recipients_with_public) do from(activity in query, where: fragment("? && ?", activity.recipients, ^recipients) or @@ -1276,8 +1243,8 @@ defp object_to_user_data(data) do %{"type" => "Emoji"} -> true _ -> false end) - |> Enum.reduce(%{}, fn %{"icon" => %{"url" => url}, "name" => name}, acc -> - Map.put(acc, String.trim(name, ":"), url) + |> Map.new(fn %{"icon" => %{"url" => url}, "name" => name} -> + {String.trim(name, ":"), url} end) locked = data["manuallyApprovesFollowers"] || false @@ -1323,18 +1290,15 @@ defp object_to_user_data(data) do } # nickname can be nil because of virtual actors - user_data = - if data["preferredUsername"] do - Map.put( - user_data, - :nickname, - "#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}" - ) - else - Map.put(user_data, :nickname, nil) - end - - {:ok, user_data} + if data["preferredUsername"] do + Map.put( + user_data, + :nickname, + "#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}" + ) + else + Map.put(user_data, :nickname, nil) + end end def fetch_follow_information_for_user(user) do @@ -1409,9 +1373,8 @@ defp collection_private(%{"first" => first}) do defp collection_private(_data), do: {:ok, true} def user_data_from_user_object(data) do - with {:ok, data} <- MRF.filter(data), - {:ok, data} <- object_to_user_data(data) do - {:ok, data} + with {:ok, data} <- MRF.filter(data) do + {:ok, object_to_user_data(data)} else e -> {:error, e} end @@ -1419,15 +1382,14 @@ def user_data_from_user_object(data) do def fetch_and_prepare_user_from_ap_id(ap_id) do with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), - {:ok, data} <- user_data_from_user_object(data), - data <- maybe_update_follow_information(data) do - {:ok, data} + {:ok, data} <- user_data_from_user_object(data) do + {:ok, maybe_update_follow_information(data)} else - {:error, "Object has been deleted"} = e -> + {:error, "Object has been deleted" = e} -> Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} - e -> + {:error, e} -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} end @@ -1450,8 +1412,6 @@ def make_user_from_ap_id(ap_id) do |> Repo.insert() |> User.set_cache() end - else - e -> {:error, e} end end end @@ -1465,7 +1425,7 @@ def make_user_from_nickname(nickname) do end # filter out broken threads - def contain_broken_threads(%Activity{} = activity, %User{} = user) do + defp contain_broken_threads(%Activity{} = activity, %User{} = user) do entire_thread_visible_for_user?(activity, user) end @@ -1476,7 +1436,7 @@ def contain_activity(%Activity{} = activity, %User{} = user) do def fetch_direct_messages_query do Activity - |> restrict_type(%{"type" => "Create"}) + |> restrict_type(%{type: "Create"}) |> restrict_visibility(%{visibility: "direct"}) |> order_by([activity], asc: activity.id) end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 28727d619..55947925e 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -233,16 +233,16 @@ def outbox( activities = if params["max_id"] do ActivityPub.fetch_user_activities(user, for_user, %{ - "max_id" => params["max_id"], + max_id: params["max_id"], # This is a hack because postgres generates inefficient queries when filtering by # 'Answer', poll votes will be hidden by the visibility filter in this case anyway - "include_poll_votes" => true, - "limit" => 10 + include_poll_votes: true, + limit: 10 }) else ActivityPub.fetch_user_activities(user, for_user, %{ - "limit" => 10, - "include_poll_votes" => true + limit: 10, + include_poll_votes: true }) end @@ -356,11 +356,11 @@ def read_inbox( activities = if params["max_id"] do ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{ - "max_id" => params["max_id"], - "limit" => 10 + max_id: params["max_id"], + limit: 10 }) else - ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{"limit" => 10}) + ActivityPub.fetch_activities([user.ap_id | User.following(user)], %{limit: 10}) end conn diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a76a699ee..1c40afdb2 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -244,7 +244,7 @@ defp lazy_put_object_defaults(activity, _), do: activity Inserts a full object if it is contained in an activity. """ def insert_full_object(%{"object" => %{"type" => type} = object_data} = map) - when is_map(object_data) and type in @supported_object_types do + when type in @supported_object_types do with {:ok, object} <- Object.create(object_data) do map = Map.put(map, "object", object.data["id"]) @@ -740,13 +740,12 @@ defp build_flag_object(_), do: [] def get_reports(params, page, page_size) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Flag") - |> Map.put("skip_preload", true) - |> Map.put("preload_report_notes", true) - |> Map.put("total", true) - |> Map.put("limit", page_size) - |> Map.put("offset", (page - 1) * page_size) + |> Map.put(:type, "Flag") + |> Map.put(:skip_preload, true) + |> Map.put(:preload_report_notes, true) + |> Map.put(:total, true) + |> Map.put(:limit, page_size) + |> Map.put(:offset, (page - 1) * page_size) ActivityPub.fetch_activities([], params, :offset) end diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index bf24581cc..edd3abc63 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -228,10 +228,10 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do activities = ActivityPub.fetch_statuses(nil, %{ - "instance" => instance, - "limit" => page_size, - "offset" => (page - 1) * page_size, - "exclude_reblogs" => !with_reblogs && "true" + instance: instance, + limit: page_size, + offset: (page - 1) * page_size, + exclude_reblogs: not with_reblogs }) conn @@ -248,9 +248,9 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do activities = ActivityPub.fetch_user_activities(user, nil, %{ - "limit" => page_size, - "godmode" => godmode, - "exclude_reblogs" => !with_reblogs && "true" + limit: page_size, + godmode: godmode, + exclude_reblogs: not with_reblogs }) conn diff --git a/lib/pleroma/web/admin_api/controllers/status_controller.ex b/lib/pleroma/web/admin_api/controllers/status_controller.ex index 574196be8..bc48cc527 100644 --- a/lib/pleroma/web/admin_api/controllers/status_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/status_controller.ex @@ -29,11 +29,11 @@ defmodule Pleroma.Web.AdminAPI.StatusController do def index(%{assigns: %{user: _admin}} = conn, params) do activities = ActivityPub.fetch_statuses(nil, %{ - "godmode" => params.godmode, - "local_only" => params.local_only, - "limit" => params.page_size, - "offset" => (params.page - 1) * params.page_size, - "exclude_reblogs" => not params.with_reblogs + godmode: params.godmode, + local_only: params.local_only, + limit: params.page_size, + offset: (params.page - 1) * params.page_size, + exclude_reblogs: not params.with_reblogs }) render(conn, "index.json", activities: activities, as: :activity) diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index f432b8c2c..773f798fe 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -18,7 +18,7 @@ def render("index.json", %{reports: reports}) do %{ reports: reports[:items] - |> Enum.map(&Report.extract_report_info(&1)) + |> Enum.map(&Report.extract_report_info/1) |> Enum.map(&render(__MODULE__, "show.json", &1)) |> Enum.reverse(), total: reports[:total] diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5a1316a5f..9f0ca5b69 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -81,8 +81,7 @@ def add_link_headers(conn, activities, extra_params) do end def assign_account_by_id(conn, _) do - # TODO: use `conn.params[:id]` only after moving to OpenAPI - case Pleroma.User.get_cached_by_id(conn.params[:id] || conn.params["id"]) do + case Pleroma.User.get_cached_by_id(conn.params.id) do %Pleroma.User{} = account -> assign(conn, :account, account) nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() end diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 8133f8480..3404d2856 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -15,8 +15,8 @@ def feed(conn, %{"tag" => raw_tag} = params) do {format, tag} = parse_tag(raw_tag) activities = - %{"type" => ["Create"], "tag" => tag} - |> put_if_exist("max_id", params["max_id"]) + %{type: ["Create"], tag: tag} + |> put_if_exist(:max_id, params["max_id"]) |> ActivityPub.fetch_public_activities() conn diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 5a6fc9de0..7bf9bd3e3 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -52,10 +52,10 @@ def feed(conn, %{"nickname" => nickname} = params) do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do activities = %{ - "type" => ["Create"], - "actor_id" => user.ap_id + type: ["Create"], + actor_id: user.ap_id } - |> put_if_exist("max_id", params["max_id"]) + |> put_if_exist(:max_id, params["max_id"]) |> ActivityPub.fetch_public_or_unlisted_activities() conn diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 97295a52f..edecbf418 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -254,9 +254,7 @@ def statuses(%{assigns: %{user: reading_user}} = conn, params) do params = params |> Map.delete(:tagged) - |> Enum.filter(&(not is_nil(&1))) - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("tag", params[:tagged]) + |> Map.put(:tag, params[:tagged]) activities = ActivityPub.fetch_user_activities(user, reading_user, params) diff --git a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex index 69f0e3846..f35ec3596 100644 --- a/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex @@ -21,7 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do @doc "GET /api/v1/conversations" def index(%{assigns: %{user: user}} = conn, params) do - params = stringify_pagination_params(params) participations = Participation.for_user_with_last_activity_id(user, params) conn @@ -37,20 +36,4 @@ def mark_as_read(%{assigns: %{user: user}} = conn, %{id: participation_id}) do render(conn, "participation.json", participation: participation, for: user) end end - - defp stringify_pagination_params(params) do - atom_keys = - Pleroma.Pagination.page_keys() - |> Enum.map(&String.to_atom(&1)) - - str_keys = - params - |> Map.take(atom_keys) - |> Enum.map(fn {key, value} -> {to_string(key), value} end) - |> Enum.into(%{}) - - params - |> Map.delete(atom_keys) - |> Map.merge(str_keys) - end end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index f20157a5f..468b44b67 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -359,9 +359,9 @@ def context(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id(id) do activities = ActivityPub.fetch_activities_for_context(activity.data["context"], %{ - "blocking_user" => user, - "user" => user, - "exclude_id" => activity.id + blocking_user: user, + user: user, + exclude_id: activity.id }) render(conn, "context.json", activity: activity, activities: activities, user: user) @@ -370,11 +370,6 @@ def context(%{assigns: %{user: user}} = conn, %{id: id}) do @doc "GET /api/v1/favourites" def favourites(%{assigns: %{user: %User{} = user}} = conn, params) do - params = - params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.take(Pleroma.Pagination.page_keys()) - activities = ActivityPub.fetch_favourites(user, params) conn diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index f67f75430..ed74a771a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -44,12 +44,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do def home(%{assigns: %{user: user}} = conn, params) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_filtering_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:user, user) recipients = [user.ap_id | User.following(user)] @@ -71,10 +70,9 @@ def home(%{assigns: %{user: user}} = conn, params) do def direct(%{assigns: %{user: user}} = conn, params) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Create") - |> Map.put("blocking_user", user) - |> Map.put("user", user) + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:user, user) |> Map.put(:visibility, "direct") activities = @@ -93,9 +91,7 @@ def direct(%{assigns: %{user: user}} = conn, params) do # GET /api/v1/timelines/public def public(%{assigns: %{user: user}} = conn, params) do - params = Map.new(params, fn {key, value} -> {to_string(key), value} end) - - local_only = params["local"] + local_only = params[:local] cfg_key = if local_only do @@ -111,11 +107,11 @@ def public(%{assigns: %{user: user}} = conn, params) do else activities = params - |> Map.put("type", ["Create"]) - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create"]) + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() conn @@ -130,39 +126,38 @@ def public(%{assigns: %{user: user}} = conn, params) do defp hashtag_fetching(params, user, local_only) do tags = - [params["tag"], params["any"]] + [params[:tag], params[:any]] |> List.flatten() |> Enum.uniq() - |> Enum.filter(& &1) - |> Enum.map(&String.downcase(&1)) + |> Enum.reject(&is_nil/1) + |> Enum.map(&String.downcase/1) tag_all = params - |> Map.get("all", []) - |> Enum.map(&String.downcase(&1)) + |> Map.get(:all, []) + |> Enum.map(&String.downcase/1) tag_reject = params - |> Map.get("none", []) - |> Enum.map(&String.downcase(&1)) + |> Map.get(:none, []) + |> Enum.map(&String.downcase/1) _activities = params - |> Map.put("type", "Create") - |> Map.put("local_only", local_only) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("tag", tags) - |> Map.put("tag_all", tag_all) - |> Map.put("tag_reject", tag_reject) + |> Map.put(:type, "Create") + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:tag, tags) + |> Map.put(:tag_all, tag_all) + |> Map.put(:tag_reject, tag_reject) |> ActivityPub.fetch_public_activities() end # GET /api/v1/timelines/tag/:tag def hashtag(%{assigns: %{user: user}} = conn, params) do - params = Map.new(params, fn {key, value} -> {to_string(key), value} end) - local_only = params["local"] + local_only = params[:local] activities = hashtag_fetching(params, user, local_only) conn diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 2b6f84c72..fbe618377 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -24,8 +24,8 @@ def render("participation.json", %{participation: participation, for: user}) do last_activity_id = with nil <- participation.last_activity_id do ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - "user" => user, - "blocking_user" => user + user: user, + blocking_user: user }) end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 0a3f45620..f3554d919 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -126,10 +126,9 @@ def favourites(%{assigns: %{account: %{hide_favorites: true}}} = conn, _params) def favourites(%{assigns: %{user: for_user, account: user}} = conn, params) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Create") - |> Map.put("favorited_by", user.ap_id) - |> Map.put("blocking_user", for_user) + |> Map.put(:type, "Create") + |> Map.put(:favorited_by, user.ap_id) + |> Map.put(:blocking_user, for_user) recipients = if for_user do diff --git a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex index 21d5eb8d5..3d007f324 100644 --- a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex @@ -42,15 +42,14 @@ def statuses( Participation.get(participation_id, preload: [:conversation]) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) activities = participation.conversation.ap_id |> ActivityPub.fetch_activities_for_context_query(params) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, "total", false)) + |> Pleroma.Pagination.fetch_paginated(Map.put(params, :total, false)) |> Enum.reverse() conn diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 8665ca56c..e9a4fba92 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -36,10 +36,7 @@ def create(%{assigns: %{user: user}, body_params: params} = conn, _) do def index(%{assigns: %{user: reading_user}} = conn, %{id: id} = params) do with %User{} = user <- User.get_cached_by_nickname_or_id(id, for: reading_user) do - params = - params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", ["Listen"]) + params = Map.put(params, :type, ["Listen"]) activities = ActivityPub.fetch_user_abstract_activities(user, reading_user, params) diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index c3efb6651..a7a891b13 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -111,8 +111,14 @@ def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do %User{} = user -> meta = Metadata.build_tags(%{user: user}) + params = + params + |> Map.take(@page_keys) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + timeline = - ActivityPub.fetch_user_activities(user, nil, Map.take(params, @page_keys)) + user + |> ActivityPub.fetch_user_activities(nil, params) |> Enum.map(&represent/1) prev_page_id = diff --git a/test/pagination_test.exs b/test/pagination_test.exs index d5b1b782d..9165427ae 100644 --- a/test/pagination_test.exs +++ b/test/pagination_test.exs @@ -21,7 +21,7 @@ test "paginates by min_id", %{notes: notes} do id = Enum.at(notes, 2).id |> Integer.to_string() %{total: total, items: paginated} = - Pagination.fetch_paginated(Object, %{"min_id" => id, "total" => true}) + Pagination.fetch_paginated(Object, %{min_id: id, total: true}) assert length(paginated) == 2 assert total == 5 @@ -31,7 +31,7 @@ test "paginates by since_id", %{notes: notes} do id = Enum.at(notes, 2).id |> Integer.to_string() %{total: total, items: paginated} = - Pagination.fetch_paginated(Object, %{"since_id" => id, "total" => true}) + Pagination.fetch_paginated(Object, %{since_id: id, total: true}) assert length(paginated) == 2 assert total == 5 @@ -41,7 +41,7 @@ test "paginates by max_id", %{notes: notes} do id = Enum.at(notes, 1).id |> Integer.to_string() %{total: total, items: paginated} = - Pagination.fetch_paginated(Object, %{"max_id" => id, "total" => true}) + Pagination.fetch_paginated(Object, %{max_id: id, total: true}) assert length(paginated) == 1 assert total == 5 @@ -50,7 +50,7 @@ test "paginates by max_id", %{notes: notes} do test "paginates by min_id & limit", %{notes: notes} do id = Enum.at(notes, 2).id |> Integer.to_string() - paginated = Pagination.fetch_paginated(Object, %{"min_id" => id, "limit" => 1}) + paginated = Pagination.fetch_paginated(Object, %{min_id: id, limit: 1}) assert length(paginated) == 1 end @@ -64,13 +64,13 @@ test "paginates by min_id & limit", %{notes: notes} do end test "paginates by limit" do - paginated = Pagination.fetch_paginated(Object, %{"limit" => 2}, :offset) + paginated = Pagination.fetch_paginated(Object, %{limit: 2}, :offset) assert length(paginated) == 2 end test "paginates by limit & offset" do - paginated = Pagination.fetch_paginated(Object, %{"limit" => 2, "offset" => 4}, :offset) + paginated = Pagination.fetch_paginated(Object, %{limit: 2, offset: 4}, :offset) assert length(paginated) == 1 end diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index 678288854..a8ba0658d 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -62,11 +62,11 @@ test "relay is unfollowed" do [undo_activity] = ActivityPub.fetch_activities([], %{ - "type" => "Undo", - "actor_id" => follower_id, - "limit" => 1, - "skip_preload" => true, - "invisible_actors" => true + type: "Undo", + actor_id: follower_id, + limit: 1, + skip_preload: true, + invisible_actors: true }) assert undo_activity.data["type"] == "Undo" diff --git a/test/user_test.exs b/test/user_test.exs index 6b344158d..48c7605f5 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1122,7 +1122,7 @@ test "hide a user's statuses from timelines and notifications" do assert [%{activity | thread_muted?: CommonAPI.thread_muted?(user2, activity)}] == ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{ - "user" => user2 + user: user2 }) {:ok, _user} = User.deactivate(user) @@ -1132,7 +1132,7 @@ test "hide a user's statuses from timelines and notifications" do assert [] == ActivityPub.fetch_activities([user2.ap_id | User.following(user2)], %{ - "user" => user2 + user: user2 }) end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 3dcb62873..2f65dfc8e 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -82,30 +82,28 @@ test "it restricts by the appropriate visibility" do {:ok, private_activity} = CommonAPI.post(user, %{status: ".", visibility: "private"}) - activities = - ActivityPub.fetch_activities([], %{:visibility => "direct", "actor_id" => user.ap_id}) + activities = ActivityPub.fetch_activities([], %{visibility: "direct", actor_id: user.ap_id}) assert activities == [direct_activity] activities = - ActivityPub.fetch_activities([], %{:visibility => "unlisted", "actor_id" => user.ap_id}) + ActivityPub.fetch_activities([], %{visibility: "unlisted", actor_id: user.ap_id}) assert activities == [unlisted_activity] activities = - ActivityPub.fetch_activities([], %{:visibility => "private", "actor_id" => user.ap_id}) + ActivityPub.fetch_activities([], %{visibility: "private", actor_id: user.ap_id}) assert activities == [private_activity] - activities = - ActivityPub.fetch_activities([], %{:visibility => "public", "actor_id" => user.ap_id}) + activities = ActivityPub.fetch_activities([], %{visibility: "public", actor_id: user.ap_id}) assert activities == [public_activity] activities = ActivityPub.fetch_activities([], %{ - :visibility => ~w[private public], - "actor_id" => user.ap_id + visibility: ~w[private public], + actor_id: user.ap_id }) assert activities == [public_activity, private_activity] @@ -126,8 +124,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "direct", - "actor_id" => user.ap_id + exclude_visibilities: "direct", + actor_id: user.ap_id }) assert public_activity in activities @@ -137,8 +135,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "unlisted", - "actor_id" => user.ap_id + exclude_visibilities: "unlisted", + actor_id: user.ap_id }) assert public_activity in activities @@ -148,8 +146,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "private", - "actor_id" => user.ap_id + exclude_visibilities: "private", + actor_id: user.ap_id }) assert public_activity in activities @@ -159,8 +157,8 @@ test "it excludes by the appropriate visibility" do activities = ActivityPub.fetch_activities([], %{ - "exclude_visibilities" => "public", - "actor_id" => user.ap_id + exclude_visibilities: "public", + actor_id: user.ap_id }) refute public_activity in activities @@ -193,23 +191,22 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) - fetch_one = ActivityPub.fetch_activities([], %{"type" => "Create", "tag" => "test"}) + fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) - fetch_two = - ActivityPub.fetch_activities([], %{"type" => "Create", "tag" => ["test", "essais"]}) + fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) fetch_three = ActivityPub.fetch_activities([], %{ - "type" => "Create", - "tag" => ["test", "essais"], - "tag_reject" => ["reject"] + type: "Create", + tag: ["test", "essais"], + tag_reject: ["reject"] }) fetch_four = ActivityPub.fetch_activities([], %{ - "type" => "Create", - "tag" => ["test"], - "tag_all" => ["test", "reject"] + type: "Create", + tag: ["test"], + tag_all: ["test", "reject"] }) assert fetch_one == [status_one, status_three] @@ -375,7 +372,7 @@ test "can be fetched into a timeline" do _listen_activity_2 = insert(:listen) _listen_activity_3 = insert(:listen) - timeline = ActivityPub.fetch_activities([], %{"type" => ["Listen"]}) + timeline = ActivityPub.fetch_activities([], %{type: ["Listen"]}) assert length(timeline) == 3 end @@ -507,7 +504,7 @@ test "retrieves activities that have a given context" do {:ok, _user_relationship} = User.block(user, %{ap_id: activity_five.data["actor"]}) - activities = ActivityPub.fetch_activities_for_context("2hu", %{"blocking_user" => user}) + activities = ActivityPub.fetch_activities_for_context("2hu", %{blocking_user: user}) assert activities == [activity_two, activity] end end @@ -520,8 +517,7 @@ test "doesn't return blocked activities" do booster = insert(:user) {:ok, _user_relationship} = User.block(user, %{ap_id: activity_one.data["actor"]}) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -529,8 +525,7 @@ test "doesn't return blocked activities" do {:ok, _user_block} = User.unblock(user, %{ap_id: activity_one.data["actor"]}) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -541,16 +536,14 @@ test "doesn't return blocked activities" do %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) activity_three = Activity.get_by_id(activity_three.id) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) refute Enum.member?(activities, activity_three) refute Enum.member?(activities, boost_activity) assert Enum.member?(activities, activity_one) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => nil, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: nil, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -573,7 +566,7 @@ test "doesn't return transitive interactions concerning blocked users" do {:ok, activity_four} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"}) - activities = ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + activities = ActivityPub.fetch_activities([], %{blocking_user: blocker}) assert Enum.member?(activities, activity_one) refute Enum.member?(activities, activity_two) @@ -595,7 +588,7 @@ test "doesn't return announce activities concerning blocked users" do {:ok, activity_three} = CommonAPI.repeat(activity_two.id, friend) activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + ActivityPub.fetch_activities([], %{blocking_user: blocker}) |> Enum.map(fn act -> act.id end) assert Enum.member?(activities, activity_one.id) @@ -611,8 +604,7 @@ test "doesn't return activities from blocked domains" do user = insert(:user) {:ok, user} = User.block_domain(user, domain) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) refute activity in activities @@ -620,8 +612,7 @@ test "doesn't return activities from blocked domains" do ActivityPub.follow(user, followed_user) {:ok, repeat_activity} = CommonAPI.repeat(activity.id, followed_user) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) refute repeat_activity in activities end @@ -641,8 +632,7 @@ test "does return activities from followed users on blocked domains" do note = insert(:note, %{data: %{"actor" => domain_user.ap_id}}) activity = insert(:note_activity, %{note: note}) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true}) assert activity in activities @@ -653,8 +643,7 @@ test "does return activities from followed users on blocked domains" do bad_activity = insert(:note_activity, %{note: bad_note}) {:ok, repeat_activity} = CommonAPI.repeat(bad_activity.id, domain_user) - activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{blocking_user: blocker, skip_preload: true}) refute repeat_activity in activities end @@ -669,8 +658,7 @@ test "doesn't return muted activities" do activity_one_actor = User.get_by_ap_id(activity_one.data["actor"]) {:ok, _user_relationships} = User.mute(user, activity_one_actor) - activities = - ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -679,9 +667,9 @@ test "doesn't return muted activities" do # Calling with 'with_muted' will deliver muted activities, too. activities = ActivityPub.fetch_activities([], %{ - "muting_user" => user, - "with_muted" => true, - "skip_preload" => true + muting_user: user, + with_muted: true, + skip_preload: true }) assert Enum.member?(activities, activity_two) @@ -690,8 +678,7 @@ test "doesn't return muted activities" do {:ok, _user_mute} = User.unmute(user, activity_one_actor) - activities = - ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -703,15 +690,14 @@ test "doesn't return muted activities" do %Activity{} = boost_activity = Activity.get_create_by_object_ap_id(id) activity_three = Activity.get_by_id(activity_three.id) - activities = - ActivityPub.fetch_activities([], %{"muting_user" => user, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: user, skip_preload: true}) assert Enum.member?(activities, activity_two) refute Enum.member?(activities, activity_three) refute Enum.member?(activities, boost_activity) assert Enum.member?(activities, activity_one) - activities = ActivityPub.fetch_activities([], %{"muting_user" => nil, "skip_preload" => true}) + activities = ActivityPub.fetch_activities([], %{muting_user: nil, skip_preload: true}) assert Enum.member?(activities, activity_two) assert Enum.member?(activities, activity_three) @@ -727,7 +713,7 @@ test "doesn't return thread muted activities" do {:ok, _activity_two} = CommonAPI.add_mute(user, activity_two) - assert [_activity_one] = ActivityPub.fetch_activities([], %{"muting_user" => user}) + assert [_activity_one] = ActivityPub.fetch_activities([], %{muting_user: user}) end test "returns thread muted activities when with_muted is set" do @@ -739,7 +725,7 @@ test "returns thread muted activities when with_muted is set" do {:ok, _activity_two} = CommonAPI.add_mute(user, activity_two) assert [_activity_two, _activity_one] = - ActivityPub.fetch_activities([], %{"muting_user" => user, "with_muted" => true}) + ActivityPub.fetch_activities([], %{muting_user: user, with_muted: true}) end test "does include announces on request" do @@ -761,7 +747,7 @@ test "excludes reblogs on request" do {:ok, expected_activity} = ActivityBuilder.insert(%{"type" => "Create"}, %{:user => user}) {:ok, _} = ActivityBuilder.insert(%{"type" => "Announce"}, %{:user => user}) - [activity] = ActivityPub.fetch_user_activities(user, nil, %{"exclude_reblogs" => "true"}) + [activity] = ActivityPub.fetch_user_activities(user, nil, %{exclude_reblogs: true}) assert activity == expected_activity end @@ -804,7 +790,7 @@ test "retrieves ids starting from a since_id" do expected_activities = ActivityBuilder.insert_list(10) since_id = List.last(activities).id - activities = ActivityPub.fetch_public_activities(%{"since_id" => since_id}) + activities = ActivityPub.fetch_public_activities(%{since_id: since_id}) assert collect_ids(activities) == collect_ids(expected_activities) assert length(activities) == 10 @@ -819,7 +805,7 @@ test "retrieves ids up to max_id" do |> ActivityBuilder.insert_list() |> List.first() - activities = ActivityPub.fetch_public_activities(%{"max_id" => max_id}) + activities = ActivityPub.fetch_public_activities(%{max_id: max_id}) assert length(activities) == 20 assert collect_ids(activities) == collect_ids(expected_activities) @@ -831,8 +817,7 @@ test "paginates via offset/limit" do later_activities = ActivityBuilder.insert_list(10) - activities = - ActivityPub.fetch_public_activities(%{"page" => "2", "page_size" => "20"}, :offset) + activities = ActivityPub.fetch_public_activities(%{page: "2", page_size: "20"}, :offset) assert length(activities) == 20 @@ -848,7 +833,7 @@ test "doesn't return reblogs for users for whom reblogs have been muted" do {:ok, activity} = CommonAPI.repeat(activity.id, booster) - activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + activities = ActivityPub.fetch_activities([], %{muting_user: user}) refute Enum.any?(activities, fn %{id: id} -> id == activity.id end) end @@ -862,7 +847,7 @@ test "returns reblogs for users for whom reblogs have not been muted" do {:ok, activity} = CommonAPI.repeat(activity.id, booster) - activities = ActivityPub.fetch_activities([], %{"muting_user" => user}) + activities = ActivityPub.fetch_activities([], %{muting_user: user}) assert Enum.any?(activities, fn %{id: id} -> id == activity.id end) end @@ -1066,7 +1051,7 @@ test "it filters broken threads" do assert length(activities) == 3 activities = - ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{"user" => user1}) + ActivityPub.fetch_activities([user1.ap_id | User.following(user1)], %{user: user1}) |> Enum.map(fn a -> a.id end) assert [public_activity.id, private_activity_1.id] == activities @@ -1115,7 +1100,7 @@ test "returned pinned statuses" do CommonAPI.pin(activity_three.id, user) user = refresh_record(user) - activities = ActivityPub.fetch_user_activities(user, nil, %{"pinned" => "true"}) + activities = ActivityPub.fetch_user_activities(user, nil, %{pinned: true}) assert 3 = length(activities) end @@ -1226,7 +1211,7 @@ test "fetch_activities/2 returns activities addressed to a list " do activity = Repo.preload(activity, :bookmark) activity = %Activity{activity | thread_muted?: !!activity.thread_muted?} - assert ActivityPub.fetch_activities([], %{"user" => user}) == [activity] + assert ActivityPub.fetch_activities([], %{user: user}) == [activity] end def data_uri do @@ -1400,7 +1385,7 @@ test "returns a favourite activities sorted by adds to favorite" do assert Enum.map(result, & &1.id) == [a1.id, a5.id, a3.id, a4.id] - result = ActivityPub.fetch_favourites(user, %{"limit" => 2}) + result = ActivityPub.fetch_favourites(user, %{limit: 2}) assert Enum.map(result, & &1.id) == [a1.id, a5.id] end end @@ -1470,7 +1455,7 @@ test "doesn't retrieve replies activities with exclude_replies" do {:ok, _reply} = CommonAPI.post(user, %{status: "yeah", in_reply_to_status_id: activity.id}) - [result] = ActivityPub.fetch_public_activities(%{"exclude_replies" => "true"}) + [result] = ActivityPub.fetch_public_activities(%{exclude_replies: true}) assert result.id == activity.id @@ -1483,11 +1468,11 @@ test "doesn't retrieve replies activities with exclude_replies" do test "public timeline", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1504,12 +1489,12 @@ test "public timeline with reply_visibility `following`", %{ } do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1531,12 +1516,12 @@ test "public timeline with reply_visibility `self`", %{ } do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1555,11 +1540,11 @@ test "home timeline", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1593,12 +1578,12 @@ test "home timeline with reply_visibility `following`", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1632,12 +1617,12 @@ test "home timeline with reply_visibility `self`", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1666,11 +1651,11 @@ test "home timeline with reply_visibility `self`", %{ test "public timeline", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1680,13 +1665,13 @@ test "public timeline", %{users: %{u1: user}} do test "public timeline with default reply_visibility `following`", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1696,13 +1681,13 @@ test "public timeline with default reply_visibility `following`", %{users: %{u1: test "public timeline with default reply_visibility `self`", %{users: %{u1: user}} do activities_ids = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("local_only", false) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) + |> Map.put(:user, user) |> ActivityPub.fetch_public_activities() |> Enum.map(& &1.id) @@ -1712,10 +1697,10 @@ test "public timeline with default reply_visibility `self`", %{users: %{u1: user test "home timeline", %{users: %{u1: user}} do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1727,12 +1712,12 @@ test "home timeline", %{users: %{u1: user}} do test "home timeline with default reply_visibility `following`", %{users: %{u1: user}} do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "following") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "following") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) @@ -1751,12 +1736,12 @@ test "home timeline with default reply_visibility `self`", %{ } do params = %{} - |> Map.put("type", ["Create", "Announce"]) - |> Map.put("blocking_user", user) - |> Map.put("muting_user", user) - |> Map.put("user", user) - |> Map.put("reply_visibility", "self") - |> Map.put("reply_filtering_user", user) + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:reply_visibility, "self") + |> Map.put(:reply_filtering_user, user) activities_ids = ActivityPub.fetch_activities([user.ap_id | User.following(user)], params) -- cgit v1.2.3 From d44da91bbf50ae91e8246ebe3669cfaf1fabda1b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 20:28:33 +0200 Subject: SubscriptionOperation: Let chat mentions through. --- lib/pleroma/web/api_spec/operations/subscription_operation.ex | 5 +++++ test/web/mastodon_api/controllers/subscription_controller_test.exs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/subscription_operation.ex b/lib/pleroma/web/api_spec/operations/subscription_operation.ex index c575a87e6..775dd795d 100644 --- a/lib/pleroma/web/api_spec/operations/subscription_operation.ex +++ b/lib/pleroma/web/api_spec/operations/subscription_operation.ex @@ -141,6 +141,11 @@ defp create_request do allOf: [BooleanLike], nullable: true, description: "Receive poll notifications?" + }, + "pleroma:chat_mention": %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Receive chat notifications?" } } } diff --git a/test/web/mastodon_api/controllers/subscription_controller_test.exs b/test/web/mastodon_api/controllers/subscription_controller_test.exs index 4aa260663..d36bb1ae8 100644 --- a/test/web/mastodon_api/controllers/subscription_controller_test.exs +++ b/test/web/mastodon_api/controllers/subscription_controller_test.exs @@ -58,7 +58,9 @@ test "successful creation", %{conn: conn} do result = conn |> post("/api/v1/push/subscription", %{ - "data" => %{"alerts" => %{"mention" => true, "test" => true}}, + "data" => %{ + "alerts" => %{"mention" => true, "test" => true, "pleroma:chat_mention" => true} + }, "subscription" => @sub }) |> json_response_and_validate_schema(200) @@ -66,7 +68,7 @@ test "successful creation", %{conn: conn} do [subscription] = Pleroma.Repo.all(Subscription) assert %{ - "alerts" => %{"mention" => true}, + "alerts" => %{"mention" => true, "pleroma:chat_mention" => true}, "endpoint" => subscription.endpoint, "id" => to_string(subscription.id), "server_key" => @server_key -- cgit v1.2.3 From aa2ac76510d95f2412e23f3739e8e1ae4402643f Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 4 Jun 2020 20:40:46 +0200 Subject: Notification: Don't break on figuring out the type of old EmojiReactions --- lib/pleroma/notification.ex | 4 ++++ test/notification_test.exs | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 455d214bf..e5b880b10 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -398,6 +398,10 @@ defp type_from_activity(%{data: %{"type" => type}} = activity, opts \\ []) do "EmojiReact" -> "pleroma:emoji_reaction" + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + "Create" -> activity |> type_from_activity_object() diff --git a/test/notification_test.exs b/test/notification_test.exs index 6bc2b6904..f2115a29e 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -8,8 +8,10 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory import Mock + alias Pleroma.Activity alias Pleroma.FollowingRelationship alias Pleroma.Notification + alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -29,8 +31,18 @@ test "it fills in missing notification types" do {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") {:ok, like} = CommonAPI.favorite(other_user, post.id) + {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - assert {4, nil} = Repo.update_all(Notification, set: [type: nil]) + data = + react_2.data + |> Map.put("type", "EmojiReaction") + + {:ok, react_2} = + react_2 + |> Activity.change(%{data: data}) + |> Repo.update() + + assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) Notification.fill_in_notification_types() @@ -43,6 +55,9 @@ test "it fills in missing notification types" do assert %{type: "pleroma:emoji_reaction"} = Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) + assert %{type: "pleroma:chat_mention"} = Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) end -- cgit v1.2.3 From cc8a7dc205a4516452c48659e6bf081f3f730496 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 12:01:33 +0200 Subject: SideEffects / ChatView: Add an unread cache. This is to prevent wrong values in the stream. --- lib/pleroma/web/activity_pub/side_effects.ex | 5 +++++ lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- lib/pleroma/web/streamer/streamer.ex | 28 +++++++------------------- lib/pleroma/web/views/streamer_view.ex | 2 ++ test/web/pleroma_api/views/chat_view_test.exs | 15 ++++++++++++++ 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index e9f109d80..992c04ac1 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -142,6 +142,11 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) + # We add a cache of the unread value here so that it doesn't change when being streamed out + chat = + chat + |> Map.put(:unread, ChatMessageReference.unread_count_for_chat(chat)) + Streamer.stream( ["user", "user:pleroma_chat"], {user, %{cm_ref | chat: chat, object: object}} diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index c903a71fd..91d50dd1e 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -20,7 +20,7 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: ChatMessageReference.unread_count_for_chat(chat), + unread: Map.get(chat, :unread) || ChatMessageReference.unread_count_for_chat(chat), last_message: last_message && ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 5e37e2cf2..b22297955 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -90,34 +90,20 @@ def remove_socket(topic) do if should_env_send?(), do: Registry.unregister(@registry, topic) end - def stream(topics, item) when is_list(topics) do + def stream(topics, items) do if should_env_send?() do - Enum.each(topics, fn t -> - spawn(fn -> do_stream(t, item) end) + List.wrap(topics) + |> Enum.each(fn topic -> + List.wrap(items) + |> Enum.each(fn item -> + spawn(fn -> do_stream(topic, item) end) + end) end) end :ok end - def stream(topic, items) when is_list(items) do - if should_env_send?() do - Enum.each(items, fn i -> - spawn(fn -> do_stream(topic, i) end) - end) - - :ok - end - end - - def stream(topic, item) do - if should_env_send?() do - spawn(fn -> do_stream(topic, item) end) - end - - :ok - end - def filtered_by_user?(%User{} = user, %Activity{} = item) do %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute]) diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index a6efd0109..b000e7ce0 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -55,6 +55,8 @@ def render("chat_update.json", %{chat_message_reference: cm_ref}) do # Explicitly giving the cmr for the object here, so we don't accidentally # send a later 'last_message' that was inserted between inserting this and # streaming it out + # + # It also contains the chat with a cache of the correct unread count Logger.debug("Trying to stream out #{inspect(cm_ref)}") representation = diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index f3bd12616..f77584dd1 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -16,6 +16,21 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do import Pleroma.Factory + test "giving a chat with an 'unread' field, it uses that" do + user = insert(:user) + recipient = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + chat = + chat + |> Map.put(:unread, 5) + + represented_chat = ChatView.render("show.json", chat: chat) + + assert represented_chat[:unread] == 5 + end + test "it represents a chat" do user = insert(:user) recipient = insert(:user) -- cgit v1.2.3 From 0efa8aa0b9567f42b1af63e2b93a9c51e9a0fb11 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 12:26:07 +0200 Subject: Transmogrifier: For follows, create notifications last. As the notification type changes depending on the follow state, the notification should not be created and streamed out before the state settles. For this reason, the notification creation has been delayed until it's clear if the user has been followed or not. This is a bit hacky but it will be properly rewritten using the pipeline soon. --- lib/pleroma/web/activity_pub/activity_pub.ex | 12 +++++++----- lib/pleroma/web/activity_pub/transmogrifier.ex | 5 +++-- .../transmogrifier/follow_handling_test.exs | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 568db2348..4f7043c92 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -363,19 +363,21 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do end end - @spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: + @spec follow(User.t(), User.t(), String.t() | nil, boolean(), keyword()) :: {:ok, Activity.t()} | {:error, any()} - def follow(follower, followed, activity_id \\ nil, local \\ true) do + def follow(follower, followed, activity_id \\ nil, local \\ true, opts \\ []) do with {:ok, result} <- - Repo.transaction(fn -> do_follow(follower, followed, activity_id, local) end) do + Repo.transaction(fn -> do_follow(follower, followed, activity_id, local, opts) end) do result end end - defp do_follow(follower, followed, activity_id, local) do + defp do_follow(follower, followed, activity_id, local, opts) do + skip_notify_and_stream = Keyword.get(opts, :skip_notify_and_stream, false) + with data <- make_follow_data(follower, followed, activity_id), {:ok, activity} <- insert(data, local), - _ <- notify_and_stream(activity), + _ <- skip_notify_and_stream || notify_and_stream(activity), :ok <- maybe_federate(activity) do {:ok, activity} else diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b2461de2b..50f3216f3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -533,12 +533,12 @@ def handle_incoming( User.get_cached_by_ap_id(Containment.get_actor(%{"actor" => followed})), {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(Containment.get_actor(%{"actor" => follower})), - {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do + {:ok, activity} <- + ActivityPub.follow(follower, followed, id, false, skip_notify_and_stream: true) do with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]), {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked}, {_, false} <- {:user_locked, User.locked?(followed)}, {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, - _ <- Notification.update_notification_type(followed, activity), {_, {:ok, _}} <- {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, {:ok, _relationship} <- @@ -577,6 +577,7 @@ def handle_incoming( :noop end + ActivityPub.notify_and_stream(activity) {:ok, activity} else _e -> diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 6b003b51c..06c39eed6 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.FollowHandlingTest do import Pleroma.Factory import Ecto.Query + import Mock setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -151,6 +152,23 @@ test "it rejects incoming follow requests from blocked users when deny_follow_bl assert activity.data["state"] == "reject" end + test "it rejects incoming follow requests if the following errors for some reason" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + with_mock Pleroma.User, [:passthrough], follow: fn _, _ -> {:error, :testing} end do + {:ok, %Activity{data: %{"id" => id}}} = Transmogrifier.handle_incoming(data) + + %Activity{} = activity = Activity.get_by_ap_id(id) + + assert activity.data["state"] == "reject" + end + end + test "it works for incoming follow requests from hubzilla" do user = insert(:user) -- cgit v1.2.3 From f3ea6ee2c82b2d63991d3e658566298c722ac0af Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 12:45:25 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 3 ++- lib/pleroma/web/views/streamer_view.ex | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 992c04ac1..e7d050e81 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -142,7 +142,8 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) - # We add a cache of the unread value here so that it doesn't change when being streamed out + # We add a cache of the unread value here so that it + # doesn't change when being streamed out chat = chat |> Map.put(:unread, ChatMessageReference.unread_count_for_chat(chat)) diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index b000e7ce0..476a33245 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -55,7 +55,7 @@ def render("chat_update.json", %{chat_message_reference: cm_ref}) do # Explicitly giving the cmr for the object here, so we don't accidentally # send a later 'last_message' that was inserted between inserting this and # streaming it out - # + # # It also contains the chat with a cache of the correct unread count Logger.debug("Trying to stream out #{inspect(cm_ref)}") -- cgit v1.2.3 From 65689ba9bd44e291fc626cce2bd5136b93a5da90 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 13:10:48 +0200 Subject: If Credo fixes is so good, why is there no Credo fixes 2? --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index e7d050e81..b3aacff40 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -142,7 +142,7 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) - # We add a cache of the unread value here so that it + # We add a cache of the unread value here so that it # doesn't change when being streamed out chat = chat -- cgit v1.2.3 From 115d08a7542b92c5e1d889da41c0ee6837a1235e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 16:47:02 +0200 Subject: Pipeline: Add a side effects step after the transaction finishes This is to run things like streaming notifications out, which will sometimes need data that is created by the transaction, but is streamed out asynchronously. --- lib/pleroma/notification.ex | 26 ++++++--- lib/pleroma/web/activity_pub/pipeline.ex | 4 ++ lib/pleroma/web/activity_pub/side_effects.ex | 30 +++++++++- test/web/activity_pub/pipeline_test.exs | 9 ++- test/web/activity_pub/side_effects_test.exs | 86 ++++++++++++++++++++++++---- test/web/common_api/common_api_test.exs | 45 ++++++++++++--- 6 files changed, 170 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index e5b880b10..49e27c05a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -334,30 +334,34 @@ def dismiss(%{id: user_id} = _user, id) do end end - def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do + def create_notifications(activity, options \\ []) + + def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity, options) do object = Object.normalize(activity, false) if object && object.data["type"] == "Answer" do {:ok, []} else - do_create_notifications(activity) + do_create_notifications(activity, options) end end - def create_notifications(%Activity{data: %{"type" => type}} = activity) + def create_notifications(%Activity{data: %{"type" => type}} = activity, options) when type in ["Follow", "Like", "Announce", "Move", "EmojiReact"] do - do_create_notifications(activity) + do_create_notifications(activity, options) end - def create_notifications(_), do: {:ok, []} + def create_notifications(_, _), do: {:ok, []} + + defp do_create_notifications(%Activity{} = activity, options) do + do_send = Keyword.get(options, :do_send, true) - 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 + do_send = do_send && user in enabled_receivers create_notification(activity, user, do_send) end) @@ -623,4 +627,12 @@ def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, end def skip?(_, _, _), do: false + + def for_user_and_activity(user, activity) do + from(n in __MODULE__, + where: n.user_id == ^user.id, + where: n.activity_id == ^activity.id + ) + |> Repo.one() + end end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 0c54c4b23..6875c47f6 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -17,6 +17,10 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + {:ok, {:ok, activity, meta}} -> + SideEffects.handle_after_transaction(meta) + {:ok, activity, meta} + {:ok, value} -> value diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index b3aacff40..10136789a 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Streamer + alias Pleroma.Web.Push def handle(object, meta \\ []) @@ -37,7 +38,12 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do with {:ok, _object, _meta} <- handle_object_creation(meta[:object_data], meta) do - Notification.create_notifications(activity) + {:ok, notifications} = Notification.create_notifications(activity, do_send: false) + + meta = + meta + |> add_notifications(notifications) + {:ok, activity, meta} else e -> Repo.rollback(e) @@ -200,4 +206,26 @@ def handle_undoing( end def handle_undoing(object), do: {:error, ["don't know how to handle", object]} + + defp send_notifications(meta) do + Keyword.get(meta, :created_notifications, []) + |> Enum.each(fn notification -> + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end) + + meta + end + + defp add_notifications(meta, notifications) do + existing = Keyword.get(meta, :created_notifications, []) + + meta + |> Keyword.put(:created_notifications, notifications ++ existing) + end + + def handle_after_transaction(meta) do + meta + |> send_notifications() + end end diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index 26557720b..8deb64501 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -33,7 +33,10 @@ test "it goes through validation, filtering, persisting, side effects and federa { Pleroma.Web.ActivityPub.SideEffects, [], - [handle: fn o, m -> {:ok, o, m} end] + [ + handle: fn o, m -> {:ok, o, m} end, + handle_after_transaction: fn m -> m end + ] }, { Pleroma.Web.Federator, @@ -71,7 +74,7 @@ test "it goes through validation, filtering, persisting, side effects without fe { Pleroma.Web.ActivityPub.SideEffects, [], - [handle: fn o, m -> {:ok, o, m} end] + [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end] }, { Pleroma.Web.Federator, @@ -110,7 +113,7 @@ test "it goes through validation, filtering, persisting, side effects without fe { Pleroma.Web.ActivityPub.SideEffects, [], - [handle: fn o, m -> {:ok, o, m} end] + [handle: fn o, m -> {:ok, o, m} end, handle_after_transaction: fn m -> m end] }, { Pleroma.Web.Federator, diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 40df664eb..43ffe1337 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -22,6 +22,47 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Pleroma.Factory import Mock + describe "handle_after_transaction" do + test "it streams out notifications" do + author = insert(:user, local: true) + recipient = insert(:user, local: true) + + {:ok, chat_message_data, _meta} = Builder.chat_message(author, recipient.ap_id, "hey") + + {:ok, create_activity_data, _meta} = + Builder.create(author, chat_message_data["id"], [recipient.ap_id]) + + {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) + + {:ok, _create_activity, meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + + assert [notification] = meta[:created_notifications] + + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do + SideEffects.handle_after_transaction(meta) + + assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + assert called(Pleroma.Web.Push.send(notification)) + end + end + end + describe "delete objects" do setup do user = insert(:user) @@ -361,22 +402,47 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - {:ok, _create_activity, _meta} = - SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do + {:ok, _create_activity, meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - chat = Chat.get(author.id, recipient.ap_id) + # The notification gets created + assert [notification] = meta[:created_notifications] + assert notification.activity_id == create_activity.id - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + # But it is not sent out + refute called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + refute called(Pleroma.Web.Push.send(notification)) - assert cm_ref.object.data["content"] == "hey" - assert cm_ref.unread == false + chat = Chat.get(author.id, recipient.ap_id) - chat = Chat.get(recipient.id, author.ap_id) + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.unread == false - assert cm_ref.object.data["content"] == "hey" - assert cm_ref.unread == true + chat = Chat.get(recipient.id, author.ap_id) + + [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + + assert cm_ref.object.data["content"] == "hey" + assert cm_ref.unread == true + end end test "it creates a Chat for the local users and bumps the unread count" do diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 611a9ae66..63b59820e 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.CommonAPITest do alias Pleroma.Activity alias Pleroma.Chat alias Pleroma.Conversation.Participation + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -39,15 +40,41 @@ test "it posts a chat message without content but with an attachment" do {:ok, upload} = ActivityPub.upload(file, actor: author.ap_id) - {:ok, activity} = - CommonAPI.post_chat_message( - author, - recipient, - nil, - media_id: upload.id - ) - - assert activity + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> + nil + end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do + {:ok, activity} = + CommonAPI.post_chat_message( + author, + recipient, + nil, + media_id: upload.id + ) + + notification = + Notification.for_user_and_activity(recipient, activity) + |> Repo.preload(:activity) + + assert called(Pleroma.Web.Push.send(notification)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + + assert activity + end end test "it adds html newlines" do -- cgit v1.2.3 From 54bae06b4fa960eadb9918414f50b9ececc1faa4 Mon Sep 17 00:00:00 2001 From: Haelwenn Date: Fri, 5 Jun 2020 14:48:02 +0000 Subject: Create Pleroma.Maps.put_if_present(map, key, value, value_fun // &{:ok, &1}) Unifies all the similar functions to one and simplify some blocks with it. --- lib/pleroma/helpers/uri_helper.ex | 8 ----- lib/pleroma/maps.ex | 15 +++++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 20 +++--------- lib/pleroma/web/activity_pub/transmogrifier.ex | 17 ++++------ lib/pleroma/web/activity_pub/utils.ex | 18 +++++------ .../web/admin_api/controllers/config_controller.ex | 5 ++- .../admin_api/controllers/oauth_app_controller.ex | 14 ++------- lib/pleroma/web/controller_helper.ex | 5 --- lib/pleroma/web/feed/tag_controller.ex | 4 +-- lib/pleroma/web/feed/user_controller.ex | 4 +-- .../mastodon_api/controllers/account_controller.ex | 36 ++++++++-------------- lib/pleroma/web/mastodon_api/views/app_view.ex | 6 +--- .../mastodon_api/views/scheduled_activity_view.ex | 8 ++--- lib/pleroma/web/oauth/oauth_controller.ex | 5 +-- 14 files changed, 59 insertions(+), 106 deletions(-) create mode 100644 lib/pleroma/maps.ex diff --git a/lib/pleroma/helpers/uri_helper.ex b/lib/pleroma/helpers/uri_helper.ex index 69d8c8fe0..6d205a636 100644 --- a/lib/pleroma/helpers/uri_helper.ex +++ b/lib/pleroma/helpers/uri_helper.ex @@ -17,14 +17,6 @@ def append_uri_params(uri, appended_params) do |> URI.to_string() end - def append_param_if_present(%{} = params, param_name, param_value) do - if param_value do - Map.put(params, param_name, param_value) - else - params - end - end - def maybe_add_base("/" <> uri, base), do: Path.join([base, uri]) def maybe_add_base(uri, _base), do: uri end diff --git a/lib/pleroma/maps.ex b/lib/pleroma/maps.ex new file mode 100644 index 000000000..ab2e32e2f --- /dev/null +++ b/lib/pleroma/maps.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Maps do + def put_if_present(map, key, value, value_function \\ &{:ok, &1}) when is_map(map) do + with false <- is_nil(key), + false <- is_nil(value), + {:ok, new_value} <- value_function.(value) do + Map.put(map, key, new_value) + else + _ -> map + end + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 958f3e5af..75468f415 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Constants alias Pleroma.Conversation alias Pleroma.Conversation.Participation + alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment @@ -19,7 +20,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.Transmogrifier - alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger alias Pleroma.Workers.BackgroundWorker @@ -161,12 +161,7 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when }) # Splice in the child object if we have one. - activity = - if not is_nil(object) do - Map.put(activity, :object, object) - else - activity - end + activity = Maps.put_if_present(activity, :object, object) BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) @@ -328,7 +323,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do with data <- %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} - |> Utils.maybe_put("id", activity_id), + |> Maps.put_if_present("id", activity_id), {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do @@ -348,7 +343,7 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do "actor" => actor, "object" => object }, - data <- Utils.maybe_put(data, "id", activity_id), + data <- Maps.put_if_present(data, "id", activity_id), {:ok, activity} <- insert(data, local), _ <- notify_and_stream(activity), :ok <- maybe_federate(activity) do @@ -1225,12 +1220,7 @@ def fetch_activities_bounded( @spec upload(Upload.source(), keyword()) :: {:ok, Object.t()} | {:error, any()} def upload(file, opts \\ []) do with {:ok, data} <- Upload.store(file, opts) do - obj_data = - if opts[:actor] do - Map.put(data, "actor", opts[:actor]) - else - data - end + obj_data = Maps.put_if_present(data, "actor", opts[:actor]) Repo.insert(%Object{data: obj_data}) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 8443c284c..fda1c71df 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship + alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo @@ -208,12 +209,6 @@ 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 -> @@ -241,13 +236,13 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm attachment_url = %{"href" => href} - |> add_if_present("mediaType", media_type) - |> add_if_present("type", Map.get(url || %{}, "type")) + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_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"]) + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("type", data["type"]) + |> Maps.put_if_present("name", data["name"]) end) Map.put(object, "attachment", attachments) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a76a699ee..5fce0ba63 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Ecto.UUID alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -307,7 +308,7 @@ def make_like_data( "cc" => cc, "context" => object.data["context"] } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def make_emoji_reaction_data(user, object, emoji, activity_id) do @@ -477,7 +478,7 @@ def make_follow_data( "object" => followed_id, "state" => "pending" } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do @@ -546,7 +547,7 @@ def make_announce_data( "cc" => [], "context" => object.data["context"] } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def make_announce_data( @@ -563,7 +564,7 @@ def make_announce_data( "cc" => [Pleroma.Constants.as_public()], "context" => object.data["context"] } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end def make_undo_data( @@ -582,7 +583,7 @@ def make_undo_data( "cc" => [Pleroma.Constants.as_public()], "context" => context } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end @spec add_announce_to_object(Activity.t(), Object.t()) :: @@ -627,7 +628,7 @@ def make_unfollow_data(follower, followed, follow_activity, activity_id) do "to" => [followed.ap_id], "object" => follow_activity.data } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end #### Block-related helpers @@ -650,7 +651,7 @@ def make_block_data(blocker, blocked, activity_id) do "to" => [blocked.ap_id], "object" => blocked.ap_id } - |> maybe_put("id", activity_id) + |> Maps.put_if_present("id", activity_id) end #### Create-related helpers @@ -871,7 +872,4 @@ def get_existing_votes(actor, %{data: %{"id" => id}}) do |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data)) |> Repo.all() end - - def maybe_put(map, _key, nil), do: map - def maybe_put(map, key, value), do: Map.put(map, key, value) end diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index e221d9418..d6e2019bc 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -61,13 +61,12 @@ def show(conn, _params) do value end - setting = %{ + %{ group: ConfigDB.convert(group), key: ConfigDB.convert(key), value: ConfigDB.convert(merged_value) } - - if db, do: Map.put(setting, :db, db), else: setting + |> Pleroma.Maps.put_if_present(:db, db) end) end) |> List.flatten() diff --git a/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex index 04e629fc1..dca23ea73 100644 --- a/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex @@ -42,12 +42,7 @@ def index(conn, params) do end def create(%{body_params: params} = conn, _) do - params = - if params[:name] do - Map.put(params, :client_name, params[:name]) - else - params - end + params = Pleroma.Maps.put_if_present(params, :client_name, params[:name]) case App.create(params) do {:ok, app} -> @@ -59,12 +54,7 @@ def create(%{body_params: params} = conn, _) do end def update(%{body_params: params} = conn, %{id: id}) do - params = - if params[:name] do - Map.put(params, :client_name, params.name) - else - params - end + params = Pleroma.Maps.put_if_present(params, :client_name, params[:name]) with {:ok, app} <- App.update(id, params) do render(conn, "show.json", app: app, admin: true) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5a1316a5f..bf832fe94 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -99,11 +99,6 @@ def try_render(conn, _, _) do render_error(conn, :not_implemented, "Can't display this activity") end - @spec put_if_exist(map(), atom() | String.t(), any) :: map() - def put_if_exist(map, _key, nil), do: map - - def put_if_exist(map, key, value), do: Map.put(map, key, value) - @doc """ Returns true if request specifies to include embedded relationships in account objects. May only be used in selected account-related endpoints; has no effect for status- or diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index 8133f8480..4e86cfeb5 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -9,14 +9,12 @@ defmodule Pleroma.Web.Feed.TagController do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.Feed.FeedView - import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] - def feed(conn, %{"tag" => raw_tag} = params) do {format, tag} = parse_tag(raw_tag) activities = %{"type" => ["Create"], "tag" => tag} - |> put_if_exist("max_id", params["max_id"]) + |> Pleroma.Maps.put_if_present("max_id", params["max_id"]) |> ActivityPub.fetch_public_activities() conn diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 5a6fc9de0..7c2e0d522 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -11,8 +11,6 @@ defmodule Pleroma.Web.Feed.UserController do alias Pleroma.Web.ActivityPub.ActivityPubController alias Pleroma.Web.Feed.FeedView - import Pleroma.Web.ControllerHelper, only: [put_if_exist: 3] - plug(Pleroma.Plugs.SetFormatPlug when action in [:feed_redirect]) action_fallback(:errors) @@ -55,7 +53,7 @@ def feed(conn, %{"nickname" => nickname} = params) do "type" => ["Create"], "actor_id" => user.ap_id } - |> put_if_exist("max_id", params["max_id"]) + |> Pleroma.Maps.put_if_present("max_id", params["max_id"]) |> ActivityPub.fetch_public_or_unlisted_activities() conn diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 97295a52f..5734bb854 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do json_response: 3 ] + alias Pleroma.Maps alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter @@ -160,23 +161,22 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p :discoverable ] |> Enum.reduce(%{}, fn key, acc -> - add_if_present(acc, params, key, key, &{:ok, truthy_param?(&1)}) + Maps.put_if_present(acc, key, params[key], &{:ok, truthy_param?(&1)}) end) - |> add_if_present(params, :display_name, :name) - |> 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, + |> Maps.put_if_present(:name, params[:display_name]) + |> Maps.put_if_present(:bio, params[:note]) + |> Maps.put_if_present(:avatar, params[:avatar]) + |> Maps.put_if_present(:banner, params[:header]) + |> Maps.put_if_present(:background, params[:pleroma_background_image]) + |> Maps.put_if_present( :raw_fields, + params[:fields_attributes], &{: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["source"], "privacy", :default_scope) - |> add_if_present(params, :actor_type, :actor_type) + |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store]) + |> Maps.put_if_present(:default_scope, params[:default_scope]) + |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) + |> Maps.put_if_present(:actor_type, params[:actor_type]) changeset = User.update_changeset(user, user_params) @@ -206,16 +206,6 @@ defp build_update_activity_params(user) do } end - defp add_if_present(map, params, params_field, map_field, value_function \\ &{:ok, &1}) do - with true <- is_map(params), - true <- Map.has_key?(params, params_field), - {:ok, new_value} <- value_function.(Map.get(params, params_field)) do - Map.put(map, map_field, new_value) - else - _ -> map - end - end - defp normalize_fields_attributes(fields) do if Enum.all?(fields, &is_tuple/1) do Enum.map(fields, fn {_, v} -> v end) diff --git a/lib/pleroma/web/mastodon_api/views/app_view.ex b/lib/pleroma/web/mastodon_api/views/app_view.ex index 36071cd25..e44272c6f 100644 --- a/lib/pleroma/web/mastodon_api/views/app_view.ex +++ b/lib/pleroma/web/mastodon_api/views/app_view.ex @@ -45,10 +45,6 @@ def render("short.json", %{app: %App{website: webiste, client_name: name}}) do defp with_vapid_key(data) do vapid_key = Application.get_env(:web_push_encryption, :vapid_details, [])[:public_key] - if vapid_key do - Map.put(data, "vapid_key", vapid_key) - else - data - end + Pleroma.Maps.put_if_present(data, "vapid_key", vapid_key) end end diff --git a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex index 458f6bc78..5b896bf3b 100644 --- a/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex +++ b/lib/pleroma/web/mastodon_api/views/scheduled_activity_view.ex @@ -30,7 +30,7 @@ defp with_media_attachments(data, %{params: %{"media_attachments" => media_attac defp with_media_attachments(data, _), do: data defp status_params(params) do - data = %{ + %{ text: params["status"], sensitive: params["sensitive"], spoiler_text: params["spoiler_text"], @@ -39,10 +39,6 @@ defp status_params(params) do poll: params["poll"], in_reply_to_id: params["in_reply_to_id"] } - - case params["media_ids"] do - nil -> data - media_ids -> Map.put(data, :media_ids, media_ids) - end + |> Pleroma.Maps.put_if_present(:media_ids, params["media_ids"]) end end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 7c804233c..c557778ca 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper + alias Pleroma.Maps alias Pleroma.MFA alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration @@ -108,7 +109,7 @@ defp handle_existing_authorization( if redirect_uri in String.split(app.redirect_uris) do redirect_uri = redirect_uri(conn, redirect_uri) url_params = %{access_token: token.token} - url_params = UriHelper.append_param_if_present(url_params, :state, params["state"]) + url_params = Maps.put_if_present(url_params, :state, params["state"]) url = UriHelper.append_uri_params(redirect_uri, url_params) redirect(conn, external: url) else @@ -147,7 +148,7 @@ def after_create_authorization(%Plug.Conn{} = conn, %Authorization{} = auth, %{ if redirect_uri in String.split(app.redirect_uris) do redirect_uri = redirect_uri(conn, redirect_uri) url_params = %{code: auth.token} - url_params = UriHelper.append_param_if_present(url_params, :state, auth_attrs["state"]) + url_params = Maps.put_if_present(url_params, :state, auth_attrs["state"]) url = UriHelper.append_uri_params(redirect_uri, url_params) redirect(conn, external: url) else -- cgit v1.2.3 From f24d2f714f44175cae9fcd878de1629ee32be73c Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 5 Jun 2020 17:18:48 +0200 Subject: Credo fixes --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 10136789a..5258212ec 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -15,8 +15,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.Streamer alias Pleroma.Web.Push + alias Pleroma.Web.Streamer def handle(object, meta \\ []) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f97ab510e..d2347cdc9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.FollowingRelationship - alias Pleroma.Notification alias Pleroma.Maps + alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo -- cgit v1.2.3 From 167812a3f2c1470012cb161f3c5ba4c021fbad97 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 5 Jun 2020 23:18:29 +0400 Subject: Fix pagination --- lib/pleroma/pagination.ex | 3 +++ lib/pleroma/web/activity_pub/activity_pub_controller.ex | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 0ccc7b1f2..1b99e44f9 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -16,6 +16,9 @@ defmodule Pleroma.Pagination do @default_limit 20 @max_limit 40 + @page_keys ["max_id", "min_id", "limit", "since_id", "order"] + + def page_keys, do: @page_keys @spec fetch_paginated(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] def fetch_paginated(query, params, type \\ :keyset, table_binding \\ nil) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 5b8441384..f0b5c6e93 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -238,6 +238,7 @@ def outbox( params |> Map.drop(["nickname", "page"]) |> Map.put("include_poll_votes", true) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) activities = ActivityPub.fetch_user_activities(user, for_user, params) @@ -354,6 +355,7 @@ def read_inbox( |> Map.drop(["nickname", "page"]) |> Map.put("blocking_user", user) |> Map.put("user", user) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) activities = [user.ap_id | User.following(user)] -- cgit v1.2.3 From 4e8c0eecd5179428a47795380a9da1ab0419e024 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 09:46:07 +0200 Subject: WebPush: Don't break on contentless chat messages. --- lib/pleroma/web/push/impl.ex | 7 +++++++ test/web/push/impl_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index 006a242af..cdb827e76 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -124,6 +124,13 @@ def build_content(notification, actor, object, mastodon_type) do def format_body(activity, actor, object, mastodon_type \\ nil) + def format_body(_activity, actor, %{data: %{"type" => "ChatMessage", "content" => content}}, _) do + case content do + nil -> "@#{actor.nickname}: (Attachment)" + content -> "@#{actor.nickname}: #{Utils.scrub_html_and_truncate(content, 80)}" + end + end + def format_body( %{activity: %{data: %{"type" => "Create"}}}, actor, diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 8fb7faaa5..b48952b29 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.Push.ImplTest do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.CommonAPI alias Pleroma.Web.Push.Impl alias Pleroma.Web.Push.Subscription @@ -213,6 +214,30 @@ test "builds content for chat messages" do } end + test "builds content for chat messages with no content" do + user = insert(:user) + recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + + {:ok, chat} = CommonAPI.post_chat_message(user, recipient, nil, media_id: upload.id) + object = Object.normalize(chat, false) + [notification] = Notification.for_user(recipient) + + res = Impl.build_content(notification, user, object) + + assert res == %{ + body: "@#{user.nickname}: (Attachment)", + title: "New Chat Message" + } + end + test "hides details for notifications when privacy option enabled" do user = insert(:user, nickname: "Bob") user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) -- cgit v1.2.3 From c5e3f2454c736e09de5c433a2bf578e8eb0e70c3 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 10:35:38 +0200 Subject: Docs: Unify parameters in examples. --- docs/API/chats.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index abeee698f..761047336 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -41,7 +41,7 @@ This is the overview of using the API. The API is also documented via OpenAPI, s To create or get an existing Chat for a certain recipient (identified by Account ID) you can call: -`POST /api/v1/pleroma/chats/by-account-id/{account_id}` +`POST /api/v1/pleroma/chats/by-account-id/:account_id` The account id is the normal FlakeId of the user ``` @@ -136,7 +136,7 @@ The usual pagination options are implemented. For a given Chat id, you can get the associated messages with -`GET /api/v1/pleroma/chats/{id}/messages` +`GET /api/v1/pleroma/chats/:id/messages` This will return all messages, sorted by most recent to least recent. The usual pagination options are implemented. @@ -177,7 +177,7 @@ Returned data: Posting a chat message for given Chat id works like this: -`POST /api/v1/pleroma/chats/{id}/messages` +`POST /api/v1/pleroma/chats/:id/messages` Parameters: - content: The text content of the message. Optional if media is attached. @@ -210,7 +210,7 @@ Returned data: Deleting a chat message for given Chat id works like this: -`DELETE /api/v1/pleroma/chats/{chat_id}/messages/{message_id}` +`DELETE /api/v1/pleroma/chats/:chat_id/messages/:message_id` Returned data is the deleted message. -- cgit v1.2.3 From 239d03499ebe9196c099b6c8ded05f1f6634c09d Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 10:38:45 +0200 Subject: Chat: creation_cng -> changeset Make our usage of this more uniform. --- lib/pleroma/chat.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 5aefddc5e..4fe31de94 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Chat do timestamps() end - def creation_cng(struct, params) do + def changeset(struct, params) do struct |> cast(params, [:user_id, :recipient]) |> validate_change(:recipient, fn @@ -49,7 +49,7 @@ def get(user_id, recipient) do def get_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> changeset(%{user_id: user_id, recipient: recipient}) |> Repo.insert( # Need to set something, otherwise we get nothing back at all on_conflict: [set: [recipient: recipient]], @@ -60,7 +60,7 @@ def get_or_create(user_id, recipient) do def bump_or_create(user_id, recipient) do %__MODULE__{} - |> creation_cng(%{user_id: user_id, recipient: recipient}) + |> changeset(%{user_id: user_id, recipient: recipient}) |> Repo.insert( on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], conflict_target: [:user_id, :recipient] -- cgit v1.2.3 From 137adef6e061a1d7d7fc704feac27ebf5319a768 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 10:42:24 +0200 Subject: ChatMessageReference: Use FlakeId.Ecto.Type No need for compat because this is brand new. --- lib/pleroma/chat_message_reference.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex index fc2aaae7a..6e836cad9 100644 --- a/lib/pleroma/chat_message_reference.ex +++ b/lib/pleroma/chat_message_reference.ex @@ -17,7 +17,7 @@ defmodule Pleroma.ChatMessageReference do import Ecto.Changeset import Ecto.Query - @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} schema "chat_message_references" do belongs_to(:object, Object) -- cgit v1.2.3 From ca0e6e702be3714bb40ff0fb48e9c08aaf322fff Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 11:51:10 +0200 Subject: ChatMessageReference -> Chat.MessageReference --- lib/pleroma/chat/message_reference.ex | 109 +++++++++++++++++++++ lib/pleroma/chat_message_reference.ex | 109 --------------------- lib/pleroma/web/activity_pub/side_effects.ex | 8 +- .../web/mastodon_api/views/notification_view.ex | 8 +- .../web/pleroma_api/controllers/chat_controller.ex | 30 +++--- .../views/chat/message_reference_view.ex | 45 +++++++++ .../views/chat_message_reference_view.ex | 45 --------- lib/pleroma/web/pleroma_api/views/chat_view.ex | 10 +- lib/pleroma/web/streamer/streamer.ex | 4 +- test/chat/message_reference_test.exs | 29 ++++++ test/chat_message_reference_test.exs | 29 ------ test/web/activity_pub/side_effects_test.exs | 8 +- .../mastodon_api/views/notification_view_test.exs | 9 +- .../controllers/chat_controller_test.exs | 18 ++-- .../views/chat/message_reference_view_test.exs | 61 ++++++++++++ .../views/chat_message_reference_view_test.exs | 62 ------------ test/web/pleroma_api/views/chat_view_test.exs | 8 +- test/web/streamer/streamer_test.exs | 6 +- 18 files changed, 298 insertions(+), 300 deletions(-) create mode 100644 lib/pleroma/chat/message_reference.ex delete mode 100644 lib/pleroma/chat_message_reference.ex create mode 100644 lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex delete mode 100644 lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex create mode 100644 test/chat/message_reference_test.exs delete mode 100644 test/chat_message_reference_test.exs create mode 100644 test/web/pleroma_api/views/chat/message_reference_view_test.exs delete mode 100644 test/web/pleroma_api/views/chat_message_reference_view_test.exs diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex new file mode 100644 index 000000000..4b201db2e --- /dev/null +++ b/lib/pleroma/chat/message_reference.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat.MessageReference do + @moduledoc """ + A reference that builds a relation between an AP chat message that a user can see and whether it has been seen + by them, or should be displayed to them. Used to build the chat view that is presented to the user. + """ + + use Ecto.Schema + + alias Pleroma.Chat + alias Pleroma.Object + alias Pleroma.Repo + + import Ecto.Changeset + import Ecto.Query + + @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} + + schema "chat_message_references" do + belongs_to(:object, Object) + belongs_to(:chat, Chat) + + field(:unread, :boolean, default: true) + + timestamps() + end + + def changeset(struct, params) do + struct + |> cast(params, [:object_id, :chat_id, :unread]) + |> validate_required([:object_id, :chat_id, :unread]) + end + + def get_by_id(id) do + __MODULE__ + |> Repo.get(id) + |> Repo.preload(:object) + end + + def delete(cm_ref) do + cm_ref + |> Repo.delete() + end + + def delete_for_object(%{id: object_id}) do + from(cr in __MODULE__, + where: cr.object_id == ^object_id + ) + |> Repo.delete_all() + end + + def for_chat_and_object(%{id: chat_id}, %{id: object_id}) do + __MODULE__ + |> Repo.get_by(chat_id: chat_id, object_id: object_id) + |> Repo.preload(:object) + end + + def for_chat_query(chat) do + from(cr in __MODULE__, + where: cr.chat_id == ^chat.id, + order_by: [desc: :id], + preload: [:object] + ) + end + + def last_message_for_chat(chat) do + chat + |> for_chat_query() + |> limit(1) + |> Repo.one() + end + + def create(chat, object, unread) do + params = %{ + chat_id: chat.id, + object_id: object.id, + unread: unread + } + + %__MODULE__{} + |> changeset(params) + |> Repo.insert() + end + + def unread_count_for_chat(chat) do + chat + |> for_chat_query() + |> where([cmr], cmr.unread == true) + |> Repo.aggregate(:count) + end + + def mark_as_read(cm_ref) do + cm_ref + |> changeset(%{unread: false}) + |> Repo.update() + end + + def set_all_seen_for_chat(chat) do + chat + |> for_chat_query() + |> exclude(:order_by) + |> exclude(:preload) + |> where([cmr], cmr.unread == true) + |> Repo.update_all(set: [unread: false]) + end +end diff --git a/lib/pleroma/chat_message_reference.ex b/lib/pleroma/chat_message_reference.ex deleted file mode 100644 index 6e836cad9..000000000 --- a/lib/pleroma/chat_message_reference.ex +++ /dev/null @@ -1,109 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ChatMessageReference do - @moduledoc """ - A reference that builds a relation between an AP chat message that a user can see and whether it has been seen - by them, or should be displayed to them. Used to build the chat view that is presented to the user. - """ - - use Ecto.Schema - - alias Pleroma.Chat - alias Pleroma.Object - alias Pleroma.Repo - - import Ecto.Changeset - import Ecto.Query - - @primary_key {:id, FlakeId.Ecto.Type, autogenerate: true} - - schema "chat_message_references" do - belongs_to(:object, Object) - belongs_to(:chat, Chat) - - field(:unread, :boolean, default: true) - - timestamps() - end - - def changeset(struct, params) do - struct - |> cast(params, [:object_id, :chat_id, :unread]) - |> validate_required([:object_id, :chat_id, :unread]) - end - - def get_by_id(id) do - __MODULE__ - |> Repo.get(id) - |> Repo.preload(:object) - end - - def delete(cm_ref) do - cm_ref - |> Repo.delete() - end - - def delete_for_object(%{id: object_id}) do - from(cr in __MODULE__, - where: cr.object_id == ^object_id - ) - |> Repo.delete_all() - end - - def for_chat_and_object(%{id: chat_id}, %{id: object_id}) do - __MODULE__ - |> Repo.get_by(chat_id: chat_id, object_id: object_id) - |> Repo.preload(:object) - end - - def for_chat_query(chat) do - from(cr in __MODULE__, - where: cr.chat_id == ^chat.id, - order_by: [desc: :id], - preload: [:object] - ) - end - - def last_message_for_chat(chat) do - chat - |> for_chat_query() - |> limit(1) - |> Repo.one() - end - - def create(chat, object, unread) do - params = %{ - chat_id: chat.id, - object_id: object.id, - unread: unread - } - - %__MODULE__{} - |> changeset(params) - |> Repo.insert() - end - - def unread_count_for_chat(chat) do - chat - |> for_chat_query() - |> where([cmr], cmr.unread == true) - |> Repo.aggregate(:count) - end - - def mark_as_read(cm_ref) do - cm_ref - |> changeset(%{unread: false}) - |> Repo.update() - end - - def set_all_seen_for_chat(chat) do - chat - |> for_chat_query() - |> exclude(:order_by) - |> exclude(:preload) - |> where([cmr], cmr.unread == true) - |> Repo.update_all(set: [unread: false]) - end -end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5258212ec..1e9d6c2fc 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do """ alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -111,7 +111,7 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, Object.decrease_replies_count(in_reply_to) end - ChatMessageReference.delete_for_object(deleted_object) + MessageReference.delete_for_object(deleted_object) ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) @@ -146,13 +146,13 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do |> Enum.each(fn [user, other_user] -> if user.local do {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - {:ok, cm_ref} = ChatMessageReference.create(chat, object, user.ap_id != actor.ap_id) + {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) # We add a cache of the unread value here so that it # doesn't change when being streamed out chat = chat - |> Map.put(:unread, ChatMessageReference.unread_count_for_chat(chat)) + |> Map.put(:unread, MessageReference.unread_count_for_chat(chat)) Streamer.stream( ["user", "user:pleroma_chat"], diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 2ae82eb2d..b11578623 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do use Pleroma.Web, :view alias Pleroma.Activity - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -15,7 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView @parent_types ~w{Like Announce EmojiReact} @@ -139,9 +139,9 @@ defp put_chat_message(response, activity, reading_user, opts) do object = Object.normalize(activity) author = User.get_cached_by_ap_id(object.data["actor"]) chat = Pleroma.Chat.get(reading_user.id, author.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref}) - chat_message_render = ChatMessageReferenceView.render("show.json", render_opts) + chat_message_render = MessageReferenceView.render("show.json", render_opts) Map.put(response, :chat_message, chat_message_render) end diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 01d47045d..d6b3415d1 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -6,14 +6,14 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.Pagination alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView alias Pleroma.Web.PleromaAPI.ChatView import Ecto.Query @@ -46,13 +46,13 @@ def delete_message(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ message_id: message_id, id: chat_id }) do - with %ChatMessageReference{} = cm_ref <- - ChatMessageReference.get_by_id(message_id), + with %MessageReference{} = cm_ref <- + MessageReference.get_by_id(message_id), ^chat_id <- cm_ref.chat_id |> to_string(), %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), {:ok, _} <- remove_or_delete(cm_ref, user) do conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("show.json", chat_message_reference: cm_ref) else _e -> @@ -71,7 +71,7 @@ defp remove_or_delete( defp remove_or_delete(cm_ref, _) do cm_ref - |> ChatMessageReference.delete() + |> MessageReference.delete() end def post_chat_message( @@ -87,9 +87,9 @@ def post_chat_message( media_id: params[:media_id] ), message <- Object.normalize(activity, false), - cm_ref <- ChatMessageReference.for_chat_and_object(chat, message) do + cm_ref <- MessageReference.for_chat_and_object(chat, message) do conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("show.json", for: user, chat_message_reference: cm_ref) end end @@ -98,20 +98,20 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ id: chat_id, message_id: message_id }) do - with %ChatMessageReference{} = cm_ref <- - ChatMessageReference.get_by_id(message_id), + with %MessageReference{} = cm_ref <- + MessageReference.get_by_id(message_id), ^chat_id <- cm_ref.chat_id |> to_string(), %Chat{user_id: ^user_id} <- Chat.get_by_id(chat_id), - {:ok, cm_ref} <- ChatMessageReference.mark_as_read(cm_ref) do + {:ok, cm_ref} <- MessageReference.mark_as_read(cm_ref) do conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("show.json", for: user, chat_message_reference: cm_ref) end end def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), - {_n, _} <- ChatMessageReference.set_all_seen_for_chat(chat) do + {_n, _} <- MessageReference.set_all_seen_for_chat(chat) do conn |> put_view(ChatView) |> render("show.json", chat: chat) @@ -122,11 +122,11 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do cm_refs = chat - |> ChatMessageReference.for_chat_query() + |> MessageReference.for_chat_query() |> Pagination.fetch_paginated(params |> stringify_keys()) conn - |> put_view(ChatMessageReferenceView) + |> put_view(MessageReferenceView) |> render("index.json", for: user, chat_message_references: cm_refs) else _ -> diff --git a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex new file mode 100644 index 000000000..f2112a86e --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -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.PleromaAPI.Chat.MessageReferenceView do + use Pleroma.Web, :view + + alias Pleroma.User + alias Pleroma.Web.CommonAPI.Utils + alias Pleroma.Web.MastodonAPI.StatusView + + def render( + "show.json", + %{ + chat_message_reference: %{ + id: id, + object: %{data: chat_message}, + chat_id: chat_id, + unread: unread + } + } + ) do + %{ + id: id |> to_string(), + content: chat_message["content"], + chat_id: chat_id |> to_string(), + account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, + created_at: Utils.to_masto_date(chat_message["published"]), + emojis: StatusView.build_emojis(chat_message["emoji"]), + attachment: + chat_message["attachment"] && + StatusView.render("attachment.json", attachment: chat_message["attachment"]), + unread: unread + } + end + + def render("index.json", opts) do + render_many( + opts[:chat_message_references], + __MODULE__, + "show.json", + Map.put(opts, :as, :chat_message_reference) + ) + end +end diff --git a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex deleted file mode 100644 index 592bb17f0..000000000 --- a/lib/pleroma/web/pleroma_api/views/chat_message_reference_view.ex +++ /dev/null @@ -1,45 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceView do - use Pleroma.Web, :view - - alias Pleroma.User - alias Pleroma.Web.CommonAPI.Utils - alias Pleroma.Web.MastodonAPI.StatusView - - def render( - "show.json", - %{ - chat_message_reference: %{ - id: id, - object: %{data: chat_message}, - chat_id: chat_id, - unread: unread - } - } - ) do - %{ - id: id |> to_string(), - content: chat_message["content"], - chat_id: chat_id |> to_string(), - account_id: User.get_cached_by_ap_id(chat_message["actor"]).id, - created_at: Utils.to_masto_date(chat_message["published"]), - emojis: StatusView.build_emojis(chat_message["emoji"]), - attachment: - chat_message["attachment"] && - StatusView.render("attachment.json", attachment: chat_message["attachment"]), - unread: unread - } - end - - def render("index.json", opts) do - render_many( - opts[:chat_message_references], - __MODULE__, - "show.json", - Map.put(opts, :as, :chat_message_reference) - ) - end -end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 91d50dd1e..d4c10977f 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -6,24 +6,24 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do use Pleroma.Web, :view alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = opts[:last_message] || ChatMessageReference.last_message_for_chat(chat) + last_message = opts[:last_message] || MessageReference.last_message_for_chat(chat) %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: Map.get(chat, :unread) || ChatMessageReference.unread_count_for_chat(chat), + unread: Map.get(chat, :unread) || MessageReference.unread_count_for_chat(chat), last_message: last_message && - ChatMessageReferenceView.render("show.json", chat_message_reference: last_message), + MessageReferenceView.render("show.json", chat_message_reference: last_message), updated_at: Utils.to_masto_date(chat.updated_at) } end diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index b22297955..d1d2c9b9c 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.Streamer do require Logger alias Pleroma.Activity - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Notification @@ -187,7 +187,7 @@ defp do_stream(topic, %Notification{} = item) end) end - defp do_stream(topic, {user, %ChatMessageReference{} = cm_ref}) + defp do_stream(topic, {user, %MessageReference{} = cm_ref}) when topic in ["user", "user:pleroma_chat"] do topic = "#{topic}:#{user.id}" diff --git a/test/chat/message_reference_test.exs b/test/chat/message_reference_test.exs new file mode 100644 index 000000000..aaa7c1ad4 --- /dev/null +++ b/test/chat/message_reference_test.exs @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Chat.MessageReferenceTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Chat + alias Pleroma.Chat.MessageReference + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "messages" do + test "it returns the last message in a chat" do + user = insert(:user) + recipient = insert(:user) + + {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") + {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") + + {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) + + message = MessageReference.last_message_for_chat(chat) + + assert message.object.data["content"] == "ho" + end + end +end diff --git a/test/chat_message_reference_test.exs b/test/chat_message_reference_test.exs deleted file mode 100644 index 66bf493b4..000000000 --- a/test/chat_message_reference_test.exs +++ /dev/null @@ -1,29 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ChatMessageReferenceTest do - use Pleroma.DataCase, async: true - - alias Pleroma.Chat - alias Pleroma.ChatMessageReference - alias Pleroma.Web.CommonAPI - - import Pleroma.Factory - - describe "messages" do - test "it returns the last message in a chat" do - user = insert(:user) - recipient = insert(:user) - - {:ok, _message_1} = CommonAPI.post_chat_message(user, recipient, "hey") - {:ok, _message_2} = CommonAPI.post_chat_message(recipient, user, "ho") - - {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) - - message = ChatMessageReference.last_message_for_chat(chat) - - assert message.object.data["content"] == "ho" - end - end -end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 43ffe1337..b1afa6a2e 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -391,7 +391,7 @@ test "it streams the created ChatMessage" do end end - test "it creates a Chat and ChatMessageReferences for the local users and bumps the unread count, except for the author" do + test "it creates a Chat and MessageReferences for the local users and bumps the unread count, except for the author" do author = insert(:user, local: true) recipient = insert(:user, local: true) @@ -431,14 +431,14 @@ test "it creates a Chat and ChatMessageReferences for the local users and bumps chat = Chat.get(author.id, recipient.ap_id) - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" assert cm_ref.unread == false chat = Chat.get(recipient.id, author.ap_id) - [cm_ref] = ChatMessageReference.for_chat_query(chat) |> Repo.all() + [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() assert cm_ref.object.data["content"] == "hey" assert cm_ref.unread == true diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index c5691341a..b2fa5b302 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Activity alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -17,7 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView import Pleroma.Factory defp test_notifications_rendering(notifications, user, expected_result) do @@ -45,15 +45,14 @@ test "ChatMessage notification" do object = Object.normalize(activity) chat = Chat.get(recipient.id, user.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) expected = %{ id: to_string(notification.id), pleroma: %{is_seen: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), - chat_message: - ChatMessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), + chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), created_at: Utils.to_masto_date(notification.inserted_at) } diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 7af6dec1c..e73e4a32e 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do use Pleroma.Web.ConnCase, async: true alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -23,7 +23,7 @@ test "it marks one message as read", %{conn: conn, user: user} do {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) object = Object.normalize(create, false) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == true @@ -34,7 +34,7 @@ test "it marks one message as read", %{conn: conn, user: user} do assert result["unread"] == false - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == false end @@ -50,7 +50,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) object = Object.normalize(create, false) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == true @@ -61,7 +61,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do assert result["unread"] == 0 - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) assert cm_ref.unread == false end @@ -139,7 +139,7 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do chat = Chat.get(user.id, recipient.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) # Deleting your own message removes the message and the reference result = @@ -149,12 +149,12 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do |> json_response_and_validate_schema(200) assert result["id"] == cm_ref.id - refute ChatMessageReference.get_by_id(cm_ref.id) + refute MessageReference.get_by_id(cm_ref.id) assert %{data: %{"type" => "Tombstone"}} = Object.get_by_id(object.id) # Deleting other people's messages just removes the reference object = Object.normalize(other_message, false) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) result = conn @@ -163,7 +163,7 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do |> json_response_and_validate_schema(200) assert result["id"] == cm_ref.id - refute ChatMessageReference.get_by_id(cm_ref.id) + refute MessageReference.get_by_id(cm_ref.id) assert Object.get_by_id(object.id) end end diff --git a/test/web/pleroma_api/views/chat/message_reference_view_test.exs b/test/web/pleroma_api/views/chat/message_reference_view_test.exs new file mode 100644 index 000000000..e5b165255 --- /dev/null +++ b/test/web/pleroma_api/views/chat/message_reference_view_test.exs @@ -0,0 +1,61 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceViewTest do + use Pleroma.DataCase + + alias Pleroma.Chat + alias Pleroma.Chat.MessageReference + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView + + import Pleroma.Factory + + test "it displays a chat message" do + user = insert(:user) + recipient = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") + + chat = Chat.get(user.id, recipient.ap_id) + + object = Object.normalize(activity) + + cm_ref = MessageReference.for_chat_and_object(chat, object) + + chat_message = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) + + assert chat_message[:id] == cm_ref.id + assert chat_message[:content] == "kippis :firefox:" + assert chat_message[:account_id] == user.id + assert chat_message[:chat_id] + assert chat_message[:created_at] + assert chat_message[:unread] == false + assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) + + {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) + + object = Object.normalize(activity) + + cm_ref = MessageReference.for_chat_and_object(chat, object) + + chat_message_two = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) + + assert chat_message_two[:id] == cm_ref.id + assert chat_message_two[:content] == "gkgkgk" + assert chat_message_two[:account_id] == recipient.id + assert chat_message_two[:chat_id] == chat_message[:chat_id] + assert chat_message_two[:attachment] + assert chat_message_two[:unread] == true + end +end diff --git a/test/web/pleroma_api/views/chat_message_reference_view_test.exs b/test/web/pleroma_api/views/chat_message_reference_view_test.exs deleted file mode 100644 index b53bd3490..000000000 --- a/test/web/pleroma_api/views/chat_message_reference_view_test.exs +++ /dev/null @@ -1,62 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.PleromaAPI.ChatMessageReferenceViewTest do - use Pleroma.DataCase - - alias Pleroma.Chat - alias Pleroma.ChatMessageReference - alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.CommonAPI - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView - - import Pleroma.Factory - - test "it displays a chat message" do - user = insert(:user) - recipient = insert(:user) - - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, upload} = ActivityPub.upload(file, actor: user.ap_id) - {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "kippis :firefox:") - - chat = Chat.get(user.id, recipient.ap_id) - - object = Object.normalize(activity) - - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - - chat_message = ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) - - assert chat_message[:id] == cm_ref.id - assert chat_message[:content] == "kippis :firefox:" - assert chat_message[:account_id] == user.id - assert chat_message[:chat_id] - assert chat_message[:created_at] - assert chat_message[:unread] == false - assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) - - {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) - - object = Object.normalize(activity) - - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) - - chat_message_two = - ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) - - assert chat_message_two[:id] == cm_ref.id - assert chat_message_two[:content] == "gkgkgk" - assert chat_message_two[:account_id] == recipient.id - assert chat_message_two[:chat_id] == chat_message[:chat_id] - assert chat_message_two[:attachment] - assert chat_message_two[:unread] == true - end -end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index f77584dd1..f7af5d4e0 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -6,12 +6,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do use Pleroma.DataCase alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.AccountView - alias Pleroma.Web.PleromaAPI.ChatMessageReferenceView + alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView alias Pleroma.Web.PleromaAPI.ChatView import Pleroma.Factory @@ -55,9 +55,9 @@ test "it represents a chat" do represented_chat = ChatView.render("show.json", chat: chat) - cm_ref = ChatMessageReference.for_chat_and_object(chat, chat_message) + cm_ref = MessageReference.for_chat_and_object(chat, chat_message) assert represented_chat[:last_message] == - ChatMessageReferenceView.render("show.json", chat_message_reference: cm_ref) + MessageReferenceView.render("show.json", chat_message_reference: cm_ref) end end diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 893ae5449..245f6e63f 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.StreamerTest do import Pleroma.Factory alias Pleroma.Chat - alias Pleroma.ChatMessageReference + alias Pleroma.Chat.MessageReference alias Pleroma.Conversation.Participation alias Pleroma.List alias Pleroma.Object @@ -155,7 +155,7 @@ test "it sends chat messages to the 'user:pleroma_chat' stream", %{user: user} d {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) chat = Chat.get(user.id, other_user.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) cm_ref = %{cm_ref | chat: chat, object: object} Streamer.get_topic_and_add_socket("user:pleroma_chat", user) @@ -173,7 +173,7 @@ test "it sends chat messages to the 'user' stream", %{user: user} do {:ok, create_activity} = CommonAPI.post_chat_message(other_user, user, "hey cirno") object = Object.normalize(create_activity, false) chat = Chat.get(user.id, other_user.ap_id) - cm_ref = ChatMessageReference.for_chat_and_object(chat, object) + cm_ref = MessageReference.for_chat_and_object(chat, object) cm_ref = %{cm_ref | chat: chat, object: object} Streamer.get_topic_and_add_socket("user", user) -- cgit v1.2.3 From 9fa3f0b156f92ba575b58b191685fa068a83f4d2 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 13:08:45 +0200 Subject: Notification: Change type of `type` to an enum. --- lib/pleroma/notification.ex | 3 ++ ...05430_change_type_to_enum_for_notifications.exs | 36 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 49e27c05a..5c8994e35 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -30,6 +30,9 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) + # This is an enum type in the database. If you add a new notification type, + # remembert to add a migration to add it to the `notifications_type` enum + # as well. field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) diff --git a/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs b/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs new file mode 100644 index 000000000..9ea34436b --- /dev/null +++ b/priv/repo/migrations/20200606105430_change_type_to_enum_for_notifications.exs @@ -0,0 +1,36 @@ +defmodule Pleroma.Repo.Migrations.ChangeTypeToEnumForNotifications do + use Ecto.Migration + + def up do + """ + create type notification_type as enum ( + 'follow', + 'follow_request', + 'mention', + 'move', + 'pleroma:emoji_reaction', + 'pleroma:chat_mention', + 'reblog', + 'favourite' + ) + """ + |> execute() + + """ + alter table notifications + alter column type type notification_type using (type::notification_type) + """ + |> execute() + end + + def down do + alter table(:notifications) do + modify(:type, :string) + end + + """ + drop type notification_type + """ + |> execute() + end +end -- cgit v1.2.3 From 9189b489eef29be723389e1b3642a843bc0d01bc Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 15:33:02 +0200 Subject: Migrations: Move Notification migration code to helper --- lib/pleroma/migration_helper.ex | 85 ++++++++++++++++++++++ lib/pleroma/notification.ex | 37 +--------- .../20200602125218_backfill_notification_types.exs | 2 +- test/migration_helper_test.exs | 56 ++++++++++++++ test/notification_test.exs | 42 ----------- 5 files changed, 144 insertions(+), 78 deletions(-) create mode 100644 lib/pleroma/migration_helper.ex create mode 100644 test/migration_helper_test.exs diff --git a/lib/pleroma/migration_helper.ex b/lib/pleroma/migration_helper.ex new file mode 100644 index 000000000..e6346aff1 --- /dev/null +++ b/lib/pleroma/migration_helper.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper do + alias Pleroma.User + alias Pleroma.Object + alias Pleroma.Notification + alias Pleroma.Repo + + import Ecto.Query + + def fill_in_notification_types do + query = + from(n in Pleroma.Notification, + where: is_nil(n.type), + preload: :activity + ) + + query + |> Repo.all() + |> Enum.each(fn notification -> + type = + notification.activity + |> type_from_activity() + + notification + |> Notification.changeset(%{type: type}) + |> Repo.update() + end) + end + + # This is copied over from Notifications to keep this stable. + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + accepted_function = fn activity -> + with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), + %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do + Pleroma.FollowingRelationship.following?(follower, followed) + end + end + + if accepted_function.(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + + "Create" -> + activity + |> type_from_activity_object() + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.get_by_ap_id(activity.data["object"]) + + case object && object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end +end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 5c8994e35..682a26912 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -40,26 +40,6 @@ defmodule Pleroma.Notification do timestamps() end - def fill_in_notification_types do - query = - from(n in __MODULE__, - where: is_nil(n.type), - preload: :activity - ) - - query - |> Repo.all() - |> Enum.each(fn notification -> - type = - notification.activity - |> type_from_activity(no_cachex: true) - - notification - |> changeset(%{type: type}) - |> Repo.update() - end) - end - def update_notification_type(user, activity) do with %__MODULE__{} = notification <- Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do @@ -371,23 +351,10 @@ defp do_create_notifications(%Activity{} = activity, options) do {:ok, notifications} end - defp type_from_activity(%{data: %{"type" => type}} = activity, opts \\ []) do + defp type_from_activity(%{data: %{"type" => type}} = activity) do case type do "Follow" -> - accepted_function = - if Keyword.get(opts, :no_cachex, false) do - # A special function to make this usable in a migration. - fn activity -> - with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), - %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do - Pleroma.FollowingRelationship.following?(follower, followed) - end - end - else - &Activity.follow_accepted?/1 - end - - if accepted_function.(activity) do + if Activity.follow_accepted?(activity) do "follow" else "follow_request" diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs index 493c0280c..58943fad0 100644 --- a/priv/repo/migrations/20200602125218_backfill_notification_types.exs +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -2,7 +2,7 @@ defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do use Ecto.Migration def up do - Pleroma.Notification.fill_in_notification_types() + Pleroma.MigrationHelper.fill_in_notification_types() end def down do diff --git a/test/migration_helper_test.exs b/test/migration_helper_test.exs new file mode 100644 index 000000000..1c8173987 --- /dev/null +++ b/test/migration_helper_test.exs @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelperTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.MigrationHelper + alias Pleroma.Notification + alias Pleroma.Repo + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "fill_in_notification_types" do + test "it fills in missing notification types" do + user = insert(:user) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) + {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") + {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + {:ok, like} = CommonAPI.favorite(other_user, post.id) + {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + + data = + react_2.data + |> Map.put("type", "EmojiReaction") + + {:ok, react_2} = + react_2 + |> Activity.change(%{data: data}) + |> Repo.update() + + assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) + + MigrationHelper.fill_in_notification_types() + + assert %{type: "mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) + + assert %{type: "favourite"} = + Repo.get_by(Notification, user_id: user.id, activity_id: like.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) + + assert %{type: "pleroma:chat_mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) + end + end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index f2115a29e..b9bbdceca 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.NotificationTest do import Pleroma.Factory import Mock - alias Pleroma.Activity alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Repo @@ -22,47 +21,6 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer - describe "fill_in_notification_types" do - test "it fills in missing notification types" do - user = insert(:user) - other_user = insert(:user) - - {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) - {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") - {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - {:ok, like} = CommonAPI.favorite(other_user, post.id) - {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - - data = - react_2.data - |> Map.put("type", "EmojiReaction") - - {:ok, react_2} = - react_2 - |> Activity.change(%{data: data}) - |> Repo.update() - - assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) - - Notification.fill_in_notification_types() - - assert %{type: "mention"} = - Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) - - assert %{type: "favourite"} = - Repo.get_by(Notification, user_id: user.id, activity_id: like.id) - - assert %{type: "pleroma:emoji_reaction"} = - Repo.get_by(Notification, user_id: user.id, activity_id: react.id) - - assert %{type: "pleroma:emoji_reaction"} = - Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) - - assert %{type: "pleroma:chat_mention"} = - Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) - end - end - describe "create_notifications" do test "creates a notification for an emoji reaction" do user = insert(:user) -- cgit v1.2.3 From f77d4a302d8ee5999979136290caac556cac8873 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 15:51:08 +0200 Subject: Credo fixes. --- lib/pleroma/migration_helper.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/migration_helper.ex b/lib/pleroma/migration_helper.ex index e6346aff1..a20d27a01 100644 --- a/lib/pleroma/migration_helper.ex +++ b/lib/pleroma/migration_helper.ex @@ -3,10 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MigrationHelper do - alias Pleroma.User - alias Pleroma.Object alias Pleroma.Notification + alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.User import Ecto.Query -- cgit v1.2.3 From e1b07402ab077899dd5b9c0023fbe1c48af259e9 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 23 Mar 2020 22:52:25 +0100 Subject: User: Add raw_bio, storing unformatted bio Related: https://git.pleroma.social/pleroma/pleroma/issues/1643 --- lib/pleroma/user.ex | 13 ++++++++++- .../mastodon_api/controllers/account_controller.ex | 1 + lib/pleroma/web/mastodon_api/views/account_view.ex | 13 +---------- .../migrations/20200322174133_user_raw_bio.exs | 9 ++++++++ .../20200328193433_populate_user_raw_bio.exs | 25 ++++++++++++++++++++++ test/support/factory.ex | 3 ++- .../account_controller/update_credentials_test.exs | 13 +++++++---- test/web/mastodon_api/views/account_view_test.exs | 3 ++- 8 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 priv/repo/migrations/20200322174133_user_raw_bio.exs create mode 100644 priv/repo/migrations/20200328193433_populate_user_raw_bio.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 72ee2d58e..23ca8c9f3 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -79,6 +79,7 @@ defmodule Pleroma.User do schema "users" do field(:bio, :string) + field(:raw_bio, :string) field(:email, :string) field(:name, :string) field(:nickname, :string) @@ -432,6 +433,7 @@ def update_changeset(struct, params \\ %{}) do params, [ :bio, + :raw_bio, :name, :emoji, :avatar, @@ -607,7 +609,16 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do struct |> confirmation_changeset(need_confirmation: need_confirmation?) - |> cast(params, [:bio, :email, :name, :nickname, :password, :password_confirmation, :emoji]) + |> cast(params, [ + :bio, + :raw_bio, + :email, + :name, + :nickname, + :password, + :password_confirmation, + :emoji + ]) |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 5734bb854..ebfa533dd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -165,6 +165,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p end) |> Maps.put_if_present(:name, params[:display_name]) |> Maps.put_if_present(:bio, params[:note]) + |> Maps.put_if_present(:raw_bio, params[:note]) |> Maps.put_if_present(:avatar, params[:avatar]) |> Maps.put_if_present(:banner, params[:header]) |> Maps.put_if_present(:background, params[:pleroma_background_image]) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 04c419d2f..5326b02c6 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -224,7 +224,7 @@ defp do_render("show.json", %{user: user} = opts) do fields: user.fields, bot: bot, source: %{ - note: prepare_user_bio(user), + note: user.raw_bio || "", sensitive: false, fields: user.raw_fields, pleroma: %{ @@ -259,17 +259,6 @@ defp do_render("show.json", %{user: user} = opts) do |> maybe_put_unread_notification_count(user, opts[:for]) end - defp prepare_user_bio(%User{bio: ""}), do: "" - - defp prepare_user_bio(%User{bio: bio}) when is_binary(bio) do - bio - |> String.replace(~r(
    ), "\n") - |> Pleroma.HTML.strip_tags() - |> HtmlEntities.decode() - end - - defp prepare_user_bio(_), do: "" - defp username_from_nickname(string) when is_binary(string) do hd(String.split(string, "@")) end diff --git a/priv/repo/migrations/20200322174133_user_raw_bio.exs b/priv/repo/migrations/20200322174133_user_raw_bio.exs new file mode 100644 index 000000000..ddf9be4f5 --- /dev/null +++ b/priv/repo/migrations/20200322174133_user_raw_bio.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.UserRawBio do + use Ecto.Migration + + def change do + alter table(:users) do + add_if_not_exists(:raw_bio, :text) + end + end +end diff --git a/priv/repo/migrations/20200328193433_populate_user_raw_bio.exs b/priv/repo/migrations/20200328193433_populate_user_raw_bio.exs new file mode 100644 index 000000000..cb35db3f5 --- /dev/null +++ b/priv/repo/migrations/20200328193433_populate_user_raw_bio.exs @@ -0,0 +1,25 @@ +defmodule Pleroma.Repo.Migrations.PopulateUserRawBio do + use Ecto.Migration + import Ecto.Query + alias Pleroma.User + alias Pleroma.Repo + + def change do + {:ok, _} = Application.ensure_all_started(:fast_sanitize) + + User.Query.build(%{local: true}) + |> select([u], struct(u, [:id, :ap_id, :bio])) + |> Repo.stream() + |> Enum.each(fn %{bio: bio} = user -> + if bio do + raw_bio = + bio + |> String.replace(~r(
    ), "\n") + |> Pleroma.HTML.strip_tags() + + Ecto.Changeset.cast(user, %{raw_bio: raw_bio}, [:raw_bio]) + |> Repo.update() + end + end) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e3676aca..1a9b96180 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -42,7 +42,8 @@ def user_factory do user | ap_id: User.ap_id(user), follower_address: User.ap_followers(user), - following_address: User.ap_following(user) + following_address: User.ap_following(user), + raw_bio: user.bio } 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 7c420985d..76e6d603a 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 @@ -83,10 +83,9 @@ test "sets user settings in a generic way", %{conn: conn} do test "updates the user's bio", %{conn: conn} do user2 = insert(:user) - conn = - patch(conn, "/api/v1/accounts/update_credentials", %{ - "note" => "I drink #cofe with @#{user2.nickname}\n\nsuya.." - }) + raw_bio = "I drink #cofe with @#{user2.nickname}\n\nsuya.." + + conn = patch(conn, "/api/v1/accounts/update_credentials", %{"note" => raw_bio}) assert user_data = json_response_and_validate_schema(conn, 200) @@ -94,6 +93,12 @@ test "updates the user's bio", %{conn: conn} do ~s(I drink #cofe with @#{user2.nickname}

    suya..) + + assert user_data["source"]["note"] == raw_bio + + user = Repo.get(User, user_data["id"]) + + assert user.raw_bio == raw_bio 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 f91333e5c..7ac70dc58 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -33,7 +33,8 @@ test "Represent a user account" do bio: "valid html. a
    b
    c
    d
    f '&<>\"", inserted_at: ~N[2017-08-15 15:47:06.597036], - emoji: %{"karjalanpiirakka" => "/file.png"} + emoji: %{"karjalanpiirakka" => "/file.png"}, + raw_bio: "valid html. a\nb\nc\nd\nf '&<>\"" }) expected = %{ -- cgit v1.2.3 From f4cf4ae16ee84655bf6630cf7e98e9eef2f410cc Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 16:48:02 +0200 Subject: ChatController: Use new oauth scope *:chats. --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 14 +++++++------- .../web/pleroma_api/controllers/chat_controller.ex | 4 ++-- .../web/pleroma_api/controllers/chat_controller_test.exs | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 6ad325113..74c3ad0bd 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -33,7 +33,7 @@ def mark_as_read_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -58,7 +58,7 @@ def mark_message_as_read_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -120,7 +120,7 @@ def create_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -137,7 +137,7 @@ def index_operation do }, security: [ %{ - "oAuth" => ["read"] + "oAuth" => ["read:chats"] } ] } @@ -161,7 +161,7 @@ def messages_operation do }, security: [ %{ - "oAuth" => ["read"] + "oAuth" => ["read:chats"] } ] } @@ -187,7 +187,7 @@ def post_chat_message_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } @@ -212,7 +212,7 @@ def delete_message_operation do }, security: [ %{ - "oAuth" => ["write"] + "oAuth" => ["write:chats"] } ] } diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index d6b3415d1..983550b13 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["write:statuses"]} + %{scopes: ["write:chats"]} when action in [ :post_chat_message, :create, @@ -35,7 +35,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do plug( OAuthScopesPlug, - %{scopes: ["read:statuses"]} when action in [:messages, :index, :show] + %{scopes: ["read:chats"]} when action in [:messages, :index, :show] ) plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index e73e4a32e..2128fd9dd 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do import Pleroma.Factory describe "POST /api/v1/pleroma/chats/:id/messages/:message_id/read" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it marks one message as read", %{conn: conn, user: user} do other_user = insert(:user) @@ -41,7 +41,7 @@ test "it marks one message as read", %{conn: conn, user: user} do end describe "POST /api/v1/pleroma/chats/:id/read" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it marks all messages in a chat as read", %{conn: conn, user: user} do other_user = insert(:user) @@ -68,7 +68,7 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do end describe "POST /api/v1/pleroma/chats/:id/messages" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it posts a message to the chat", %{conn: conn, user: user} do other_user = insert(:user) @@ -125,7 +125,7 @@ test "it works with an attachment", %{conn: conn, user: user} do end describe "DELETE /api/v1/pleroma/chats/:id/messages/:message_id" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it deletes a message from the chat", %{conn: conn, user: user} do recipient = insert(:user) @@ -169,7 +169,7 @@ test "it deletes a message from the chat", %{conn: conn, user: user} do end describe "GET /api/v1/pleroma/chats/:id/messages" do - setup do: oauth_access(["read:statuses"]) + setup do: oauth_access(["read:chats"]) test "it paginates", %{conn: conn, user: user} do recipient = insert(:user) @@ -229,7 +229,7 @@ test "it returns the messages for a given chat", %{conn: conn, user: user} do end describe "POST /api/v1/pleroma/chats/by-account-id/:id" do - setup do: oauth_access(["write:statuses"]) + setup do: oauth_access(["write:chats"]) test "it creates or returns a chat", %{conn: conn} do other_user = insert(:user) @@ -244,7 +244,7 @@ test "it creates or returns a chat", %{conn: conn} do end describe "GET /api/v1/pleroma/chats/:id" do - setup do: oauth_access(["read:statuses"]) + setup do: oauth_access(["read:chats"]) test "it returns a chat", %{conn: conn, user: user} do other_user = insert(:user) @@ -261,7 +261,7 @@ test "it returns a chat", %{conn: conn, user: user} do end describe "GET /api/v1/pleroma/chats" do - setup do: oauth_access(["read:statuses"]) + setup do: oauth_access(["read:chats"]) test "it does not return chats with users you blocked", %{conn: conn, user: user} do recipient = insert(:user) -- cgit v1.2.3 From 40fc4e974e5f60c3d61702b17029566774898e84 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 6 Jun 2020 16:59:08 +0200 Subject: Notfication: Add validation of notification types --- lib/pleroma/notification.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 682a26912..3ac8737e2 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -61,9 +61,21 @@ def unread_notifications_count(%User{id: user_id}) do |> Repo.aggregate(:count, :id) end + @notification_types ~w{ + favourite + follow + follow_request + mention + move + pleroma:chat_mention + pleroma:emoji_reaction + reblog + } + def changeset(%Notification{} = notification, attrs) do notification |> cast(attrs, [:seen, :type]) + |> validate_inclusion(:type, @notification_types) end @spec last_read_query(User.t()) :: Ecto.Queryable.t() -- cgit v1.2.3 From 0365053c8dbbcae4a4883f68b7eaec263c14f656 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 09:19:00 +0200 Subject: AttachmentValidator: Check if the mime type is valid. --- .../web/activity_pub/object_validators/attachment_validator.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index c4b502cb9..f53bb02be 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -45,11 +45,11 @@ def fix_media_type(data) do data |> Map.put_new("mediaType", data["mimeType"]) - if data["mediaType"] == "" do + if MIME.valid?(data["mediaType"]) do data - |> Map.put("mediaType", "application/octet-stream") else data + |> Map.put("mediaType", "application/octet-stream") end end -- cgit v1.2.3 From 1a11f0e453527070a8ab5511318045470abc95e2 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 14:25:30 +0200 Subject: Chats: Change id to flake id. --- lib/pleroma/chat.ex | 3 +++ lib/pleroma/chat/message_reference.ex | 2 +- .../20200607112923_change_chat_id_to_flake.exs | 23 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs diff --git a/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 4fe31de94..24a86371e 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -16,6 +16,8 @@ defmodule Pleroma.Chat do It is a helper only, to make it easy to display a list of chats with other people, ordered by last bump. The actual messages are retrieved by querying the recipients of the ChatMessages. """ + @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} + schema "chats" do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) @@ -63,6 +65,7 @@ def bump_or_create(user_id, recipient) do |> changeset(%{user_id: user_id, recipient: recipient}) |> Repo.insert( on_conflict: [set: [updated_at: NaiveDateTime.utc_now()]], + returning: true, conflict_target: [:user_id, :recipient] ) end diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex index 4b201db2e..7ee7508ca 100644 --- a/lib/pleroma/chat/message_reference.ex +++ b/lib/pleroma/chat/message_reference.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Chat.MessageReference do schema "chat_message_references" do belongs_to(:object, Object) - belongs_to(:chat, Chat) + belongs_to(:chat, Chat, type: FlakeId.Ecto.CompatType) field(:unread, :boolean, default: true) diff --git a/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs b/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs new file mode 100644 index 000000000..f14e269ca --- /dev/null +++ b/priv/repo/migrations/20200607112923_change_chat_id_to_flake.exs @@ -0,0 +1,23 @@ +defmodule Pleroma.Repo.Migrations.ChangeChatIdToFlake do + use Ecto.Migration + + def up do + execute(""" + alter table chats + drop constraint chats_pkey cascade, + alter column id drop default, + alter column id set data type uuid using cast( lpad( to_hex(id), 32, '0') as uuid), + add primary key (id) + """) + + execute(""" + alter table chat_message_references + alter column chat_id set data type uuid using cast( lpad( to_hex(chat_id), 32, '0') as uuid), + add constraint chat_message_references_chat_id_fkey foreign key (chat_id) references chats(id) on delete cascade + """) + end + + def down do + :ok + end +end -- cgit v1.2.3 From 2cdaac433035d8df3890eae098b55380b9e1c9fc Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 14:52:56 +0200 Subject: SideEffects: Move streaming of chats to after the transaction. --- lib/pleroma/web/activity_pub/side_effects.ex | 61 ++++++++++++++++---------- lib/pleroma/web/pleroma_api/views/chat_view.ex | 3 +- test/web/activity_pub/side_effects_test.exs | 41 +++++------------ test/web/common_api/common_api_test.exs | 1 + test/web/pleroma_api/views/chat_view_test.exs | 15 ------- 5 files changed, 53 insertions(+), 68 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1e9d6c2fc..1a1cc675c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -37,7 +37,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Rollback if we couldn't create it # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do - with {:ok, _object, _meta} <- handle_object_creation(meta[:object_data], meta) do + with {:ok, _object, meta} <- handle_object_creation(meta[:object_data], meta) do {:ok, notifications} = Notification.create_notifications(activity, do_send: false) meta = @@ -142,24 +142,24 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do actor = User.get_cached_by_ap_id(object.data["actor"]) recipient = User.get_cached_by_ap_id(hd(object.data["to"])) - [[actor, recipient], [recipient, actor]] - |> Enum.each(fn [user, other_user] -> - if user.local do - {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) - {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) - - # We add a cache of the unread value here so that it - # doesn't change when being streamed out - chat = - chat - |> Map.put(:unread, MessageReference.unread_count_for_chat(chat)) - - Streamer.stream( - ["user", "user:pleroma_chat"], - {user, %{cm_ref | chat: chat, object: object}} - ) - end - end) + streamables = + [[actor, recipient], [recipient, actor]] + |> Enum.map(fn [user, other_user] -> + if user.local do + {:ok, chat} = Chat.bump_or_create(user.id, other_user.ap_id) + {:ok, cm_ref} = MessageReference.create(chat, object, user.ap_id != actor.ap_id) + + { + ["user", "user:pleroma_chat"], + {user, %{cm_ref | chat: chat, object: object}} + } + end + end) + |> Enum.filter(& &1) + + meta = + meta + |> add_streamables(streamables) {:ok, object, meta} end @@ -208,7 +208,7 @@ def handle_undoing( def handle_undoing(object), do: {:error, ["don't know how to handle", object]} defp send_notifications(meta) do - Keyword.get(meta, :created_notifications, []) + Keyword.get(meta, :notifications, []) |> Enum.each(fn notification -> Streamer.stream(["user", "user:notification"], notification) Push.send(notification) @@ -217,15 +217,32 @@ defp send_notifications(meta) do meta end + defp send_streamables(meta) do + Keyword.get(meta, :streamables, []) + |> Enum.each(fn {topics, items} -> + Streamer.stream(topics, items) + end) + + meta + end + + defp add_streamables(meta, streamables) do + existing = Keyword.get(meta, :streamables, []) + + meta + |> Keyword.put(:streamables, streamables ++ existing) + end + defp add_notifications(meta, notifications) do - existing = Keyword.get(meta, :created_notifications, []) + existing = Keyword.get(meta, :notifications, []) meta - |> Keyword.put(:created_notifications, notifications ++ existing) + |> Keyword.put(:notifications, notifications ++ existing) end def handle_after_transaction(meta) do meta |> send_notifications() + |> send_streamables() end end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index d4c10977f..1c996da11 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -14,13 +14,12 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) - last_message = opts[:last_message] || MessageReference.last_message_for_chat(chat) %{ id: chat.id |> to_string(), account: AccountView.render("show.json", Map.put(opts, :user, recipient)), - unread: Map.get(chat, :unread) || MessageReference.unread_count_for_chat(chat), + unread: MessageReference.unread_count_for_chat(chat), last_message: last_message && MessageReferenceView.render("show.json", chat_message_reference: last_message), diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index b1afa6a2e..6bbbaae87 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -23,7 +23,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do import Mock describe "handle_after_transaction" do - test "it streams out notifications" do + test "it streams out notifications and streams" do author = insert(:user, local: true) recipient = insert(:user, local: true) @@ -37,7 +37,7 @@ test "it streams out notifications" do {:ok, _create_activity, meta} = SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - assert [notification] = meta[:created_notifications] + assert [notification] = meta[:notifications] with_mocks([ { @@ -58,6 +58,7 @@ test "it streams out notifications" do SideEffects.handle_after_transaction(meta) assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) assert called(Pleroma.Web.Push.send(notification)) end end @@ -362,33 +363,10 @@ test "it streams the created ChatMessage" do {:ok, create_activity, _meta} = ActivityPub.persist(create_activity_data, local: false) - with_mock Pleroma.Web.Streamer, [], - stream: fn _, payload -> - case payload do - {^author, cm_ref} -> - assert cm_ref.unread == false - - {^recipient, cm_ref} -> - assert cm_ref.unread == true - - view = - Pleroma.Web.PleromaAPI.ChatView.render("show.json", - last_message: cm_ref, - chat: cm_ref.chat - ) - - assert view.unread == 1 - - _ -> - nil - end - end do - {:ok, _create_activity, _meta} = - SideEffects.handle(create_activity, local: false, object_data: chat_message_data) + {:ok, _create_activity, meta} = + SideEffects.handle(create_activity, local: false, object_data: chat_message_data) - assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {author, :_})) - assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], {recipient, :_})) - end + assert [_, _] = meta[:streamables] end test "it creates a Chat and MessageReferences for the local users and bumps the unread count, except for the author" do @@ -422,13 +400,18 @@ test "it creates a Chat and MessageReferences for the local users and bumps the SideEffects.handle(create_activity, local: false, object_data: chat_message_data) # The notification gets created - assert [notification] = meta[:created_notifications] + assert [notification] = meta[:notifications] assert notification.activity_id == create_activity.id # But it is not sent out refute called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) refute called(Pleroma.Web.Push.send(notification)) + # Same for the user chat stream + assert [{topics, _}, _] = meta[:streamables] + assert topics == ["user", "user:pleroma_chat"] + refute called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) + chat = Chat.get(author.id, recipient.ap_id) [cm_ref] = MessageReference.for_chat_query(chat) |> Repo.all() diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 63b59820e..6bd26050e 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -72,6 +72,7 @@ test "it posts a chat message without content but with an attachment" do assert called(Pleroma.Web.Push.send(notification)) assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], notification)) + assert called(Pleroma.Web.Streamer.stream(["user", "user:pleroma_chat"], :_)) assert activity end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index f7af5d4e0..14eecb1bd 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -16,21 +16,6 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do import Pleroma.Factory - test "giving a chat with an 'unread' field, it uses that" do - user = insert(:user) - recipient = insert(:user) - - {:ok, chat} = Chat.get_or_create(user.id, recipient.ap_id) - - chat = - chat - |> Map.put(:unread, 5) - - represented_chat = ChatView.render("show.json", chat: chat) - - assert represented_chat[:unread] == 5 - end - test "it represents a chat" do user = insert(:user) recipient = insert(:user) -- cgit v1.2.3 From 801e668a97adff4a33451dd7bb48799562ed8796 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 15:38:33 +0200 Subject: ChatController: Add `last_read_id` option to mark_as_read. --- lib/pleroma/chat/message_reference.ex | 20 ++++++++++++------ .../web/api_spec/operations/chat_operation.ex | 18 ++++++++++++++++ .../web/pleroma_api/controllers/chat_controller.ex | 3 ++- .../controllers/chat_controller_test.exs | 24 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/chat/message_reference.ex b/lib/pleroma/chat/message_reference.ex index 7ee7508ca..131ae0186 100644 --- a/lib/pleroma/chat/message_reference.ex +++ b/lib/pleroma/chat/message_reference.ex @@ -98,12 +98,20 @@ def mark_as_read(cm_ref) do |> Repo.update() end - def set_all_seen_for_chat(chat) do - chat - |> for_chat_query() - |> exclude(:order_by) - |> exclude(:preload) - |> where([cmr], cmr.unread == true) + def set_all_seen_for_chat(chat, last_read_id \\ nil) do + query = + chat + |> for_chat_query() + |> exclude(:order_by) + |> exclude(:preload) + |> where([cmr], cmr.unread == true) + + if last_read_id do + query + |> where([cmr], cmr.id <= ^last_read_id) + else + query + end |> Repo.update_all(set: [unread: false]) end end diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 74c3ad0bd..45fbad311 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -23,6 +23,7 @@ def mark_as_read_operation do summary: "Mark all messages in the chat as read", operationId: "ChatController.mark_as_read", parameters: [Operation.parameter(:id, :path, :string, "The ID of the Chat")], + requestBody: request_body("Parameters", mark_as_read()), responses: %{ 200 => Operation.response( @@ -333,4 +334,21 @@ def chat_message_create do } } end + + def mark_as_read do + %Schema{ + title: "MarkAsReadRequest", + description: "POST body for marking a number of chat messages as read", + type: :object, + properties: %{ + last_read_id: %Schema{ + type: :string, + description: "The content of your message. Optional." + } + }, + example: %{ + "last_read_id" => "abcdef12456" + } + } + end end diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 983550b13..002b75082 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -111,7 +111,8 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), - {_n, _} <- MessageReference.set_all_seen_for_chat(chat) do + {_n, _} <- + MessageReference.set_all_seen_for_chat(chat, conn.body_params[:last_read_id]) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 2128fd9dd..63cd89c73 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -65,6 +65,30 @@ test "it marks all messages in a chat as read", %{conn: conn, user: user} do assert cm_ref.unread == false end + + test "it given a `last_read_id` ", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") + {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + object = Object.normalize(create, false) + cm_ref = MessageReference.for_chat_and_object(chat, object) + + assert cm_ref.unread == true + + result = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/pleroma/chats/#{chat.id}/read", %{"last_read_id" => cm_ref.id}) + |> json_response_and_validate_schema(200) + + assert result["unread"] == 1 + + cm_ref = MessageReference.for_chat_and_object(chat, object) + + assert cm_ref.unread == false + end end describe "POST /api/v1/pleroma/chats/:id/messages" do -- cgit v1.2.3 From 680fa5fa36d8b30a9a9749edacf1a2c69fded29a Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 15:41:46 +0200 Subject: Docs: Update docs on mark as read. --- docs/API/chats.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/API/chats.md b/docs/API/chats.md index 761047336..81ff57941 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -79,6 +79,11 @@ To set the `unread` count of a chat to 0, call `POST /api/v1/pleroma/chats/:id/read` + +Parameters: +- last_read_id: Given this id, all chat messages until this one will be marked as read. This should always be used. + + Returned data: ```json -- cgit v1.2.3 From 8d9e58688712ea416109aaee2883cc9ace644e02 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Sun, 7 Jun 2020 17:31:37 +0200 Subject: Delete pending follow requests on user deletion --- lib/pleroma/following_relationship.ex | 6 ++++++ lib/pleroma/user.ex | 8 ++++++++ test/user_test.exs | 5 +++++ 3 files changed, 19 insertions(+) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 3a3082e72..093b1f405 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -141,6 +141,12 @@ def following_query(%User{} = user) do |> where([r], r.state == ^:follow_accept) end + def outgoing_pending_follow_requests_query(%User{} = follower) do + __MODULE__ + |> where([r], r.follower_id == ^follower.id) + |> where([r], r.state == ^:follow_pending) + end + def following(%User{} = user) do following = following_query(user) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 72ee2d58e..c5c74d132 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1489,6 +1489,8 @@ def perform(:delete, %User{} = user) do delete_user_activities(user) + delete_outgoing_pending_follow_requests(user) + delete_or_deactivate(user) end @@ -1611,6 +1613,12 @@ defp delete_activity(%{data: %{"type" => type}} = activity, user) defp delete_activity(_activity, _user), do: "Doing nothing" + defp delete_outgoing_pending_follow_requests(user) do + user + |> FollowingRelationship.outgoing_pending_follow_requests_query() + |> Repo.delete_all() + end + def html_filter_policy(%User{no_rich_text: true}) do Pleroma.HTML.Scrubber.TwitterText end diff --git a/test/user_test.exs b/test/user_test.exs index 6b344158d..d68b4a58c 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1159,6 +1159,9 @@ test "it deactivates a user, all follow relationships and all activities", %{use follower = insert(:user) {:ok, follower} = User.follow(follower, user) + locked_user = insert(:user, name: "locked", locked: true) + {:ok, _} = User.follow(user, locked_user, :follow_pending) + object = insert(:note, user: user) activity = insert(:note_activity, user: user, note: object) @@ -1177,6 +1180,8 @@ test "it deactivates a user, all follow relationships and all activities", %{use refute User.following?(follower, user) assert %{deactivated: true} = User.get_by_id(user.id) + assert [] == User.get_follow_requests(locked_user) + user_activities = user.ap_id |> Activity.Queries.by_actor() -- cgit v1.2.3 From fe2a5d061463313f447b0557de05572fa3771728 Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 20:22:08 +0200 Subject: ChatController: Make last_read_id mandatory. --- .../web/api_spec/operations/chat_operation.ex | 3 ++- .../web/pleroma_api/controllers/chat_controller.ex | 7 ++++-- .../controllers/chat_controller_test.exs | 28 ++++------------------ 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 45fbad311..cf299bfc2 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -340,10 +340,11 @@ def mark_as_read do title: "MarkAsReadRequest", description: "POST body for marking a number of chat messages as read", type: :object, + required: [:last_read_id], properties: %{ last_read_id: %Schema{ type: :string, - description: "The content of your message. Optional." + description: "The content of your message." } }, example: %{ diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 002b75082..b9949236c 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -109,10 +109,13 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ end end - def mark_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id}) do + def mark_as_read( + %{body_params: %{last_read_id: last_read_id}, assigns: %{user: %{id: user_id}}} = conn, + %{id: id} + ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), {_n, _} <- - MessageReference.set_all_seen_for_chat(chat, conn.body_params[:last_read_id]) do + MessageReference.set_all_seen_for_chat(chat, last_read_id) do conn |> put_view(ChatView) |> render("show.json", chat: chat) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 63cd89c73..c2960956d 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -43,30 +43,10 @@ test "it marks one message as read", %{conn: conn, user: user} do describe "POST /api/v1/pleroma/chats/:id/read" do setup do: oauth_access(["write:chats"]) - test "it marks all messages in a chat as read", %{conn: conn, user: user} do - other_user = insert(:user) - - {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") - {:ok, _create} = CommonAPI.post_chat_message(other_user, user, "sup part 2") - {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) - object = Object.normalize(create, false) - cm_ref = MessageReference.for_chat_and_object(chat, object) - - assert cm_ref.unread == true - - result = - conn - |> post("/api/v1/pleroma/chats/#{chat.id}/read") - |> json_response_and_validate_schema(200) - - assert result["unread"] == 0 - - cm_ref = MessageReference.for_chat_and_object(chat, object) - - assert cm_ref.unread == false - end - - test "it given a `last_read_id` ", %{conn: conn, user: user} do + test "given a `last_read_id`, it marks everything until then as read", %{ + conn: conn, + user: user + } do other_user = insert(:user) {:ok, create} = CommonAPI.post_chat_message(other_user, user, "sup") -- cgit v1.2.3 From 1a2acce7c5927cd113ebcffd0acc7a5c547bbf0e Mon Sep 17 00:00:00 2001 From: lain Date: Sun, 7 Jun 2020 20:23:17 +0200 Subject: Docs: Document new mandatory parameter. --- docs/API/chats.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 81ff57941..9eb581943 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -75,13 +75,13 @@ Returned data: ### Marking a chat as read -To set the `unread` count of a chat to 0, call +To mark a number of messages in a chat up to a certain message as read, you can use `POST /api/v1/pleroma/chats/:id/read` Parameters: -- last_read_id: Given this id, all chat messages until this one will be marked as read. This should always be used. +- last_read_id: Given this id, all chat messages until this one will be marked as read. Required. Returned data: -- cgit v1.2.3 From 89b85f65297ef4b8ce92eacb27c90e8f7c874f54 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 8 Jun 2020 11:09:53 +0200 Subject: ChatController: Remove nonsensical pagination. --- docs/API/chats.md | 2 +- lib/pleroma/web/pleroma_api/controllers/chat_controller.ex | 4 ++-- test/web/pleroma_api/controllers/chat_controller_test.exs | 11 ++--------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/API/chats.md b/docs/API/chats.md index 9eb581943..aa6119670 100644 --- a/docs/API/chats.md +++ b/docs/API/chats.md @@ -135,7 +135,7 @@ Returned data: ``` The recipient of messages that are sent to this chat is given by their AP ID. -The usual pagination options are implemented. +No pagination is implemented for now. ### Getting the messages for a Chat diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index b9949236c..e4760f53e 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -140,7 +140,7 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para end end - def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do + def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do blocked_ap_ids = User.blocked_users_ap_ids(user) chats = @@ -149,7 +149,7 @@ def index(%{assigns: %{user: %{id: user_id} = user}} = conn, params) do where: c.recipient not in ^blocked_ap_ids, order_by: [desc: c.updated_at] ) - |> Pagination.fetch_paginated(params |> stringify_keys) + |> Repo.all() conn |> put_view(ChatView) diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index c2960956d..82e16741d 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -289,7 +289,7 @@ test "it does not return chats with users you blocked", %{conn: conn, user: user assert length(result) == 0 end - test "it paginates", %{conn: conn, user: user} do + test "it returns all chats", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> recipient = insert(:user) {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) @@ -300,14 +300,7 @@ test "it paginates", %{conn: conn, user: user} do |> get("/api/v1/pleroma/chats") |> json_response_and_validate_schema(200) - assert length(result) == 20 - - result = - conn - |> get("/api/v1/pleroma/chats?max_id=#{List.last(result)["id"]}") - |> json_response_and_validate_schema(200) - - assert length(result) == 10 + assert length(result) == 30 end test "it return a list of chats the current user is participating in, in descending order of updates", -- cgit v1.2.3 From d44843e6774ed1c60d510a5307e0113e39569416 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 8 Jun 2020 17:56:34 +0400 Subject: Restrict ActivityExpirationPolicy to Notes only --- .../activity_pub/mrf/activity_expiration_policy.ex | 6 ++++- .../mrf/activity_expiration_policy_test.exs | 26 +++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index a9bdf3b69..8e47f1e02 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @impl true def filter(activity) do activity = - if activity["type"] == "Create" && local?(activity) do + if note?(activity) and local?(activity) do maybe_add_expiration(activity) else activity @@ -25,6 +25,10 @@ defp local?(%{"id" => id}) do String.starts_with?(id, Pleroma.Web.Endpoint.url()) end + defp note?(activity) do + match?(%{"type" => "Create", "object" => %{"type" => "Note"}}, activity) + end + defp maybe_add_expiration(activity) do days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) expires_at = NaiveDateTime.utc_now() |> Timex.shift(days: days) diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 0d3bcc457..8babf49e7 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -10,7 +10,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do test "adds `expires_at` property" do assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = - ActivityExpirationPolicy.filter(%{"id" => @id, "type" => "Create"}) + ActivityExpirationPolicy.filter(%{ + "id" => @id, + "type" => "Create", + "object" => %{"type" => "Note"} + }) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 end @@ -22,7 +26,8 @@ test "keeps existing `expires_at` if it less than the config setting" do ActivityExpirationPolicy.filter(%{ "id" => @id, "type" => "Create", - "expires_at" => expires_at + "expires_at" => expires_at, + "object" => %{"type" => "Note"} }) end @@ -33,7 +38,8 @@ test "overwrites existing `expires_at` if it greater than the config setting" do ActivityExpirationPolicy.filter(%{ "id" => @id, "type" => "Create", - "expires_at" => too_distant_future + "expires_at" => too_distant_future, + "object" => %{"type" => "Note"} }) assert Timex.diff(expires_at, NaiveDateTime.utc_now(), :days) == 364 @@ -43,13 +49,14 @@ test "ignores remote activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", - "type" => "Create" + "type" => "Create", + "object" => %{"type" => "Note"} }) refute Map.has_key?(activity, "expires_at") end - test "ignores non-Create activities" do + test "ignores non-Create/Note activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", @@ -57,5 +64,14 @@ test "ignores non-Create activities" do }) refute Map.has_key?(activity, "expires_at") + + assert {:ok, activity} = + ActivityExpirationPolicy.filter(%{ + "id" => "https://example.com/123", + "type" => "Create", + "object" => %{"type" => "Cofe"} + }) + + refute Map.has_key?(activity, "expires_at") end end -- cgit v1.2.3 From fe1cb56fdc52092a8af2895fae4c020679156674 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 8 Jun 2020 21:04:16 +0200 Subject: transmogrifier: MIME.valid?/1 for mediaType No issues with the rest of the network yet but this makes sure it will work once https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2429 is merged. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fda1c71df..543972ae9 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -221,9 +221,9 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm 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"] + is_map(url) && MIME.valid?(url["mediaType"]) -> url["mediaType"] + MIME.valid?(data["mediaType"]) -> data["mediaType"] + MIME.valid?(data["mimeType"]) -> data["mimeType"] true -> nil end -- cgit v1.2.3 From fc04a138d46c43860a2838d60fc8668112fdc1ec Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 8 Jun 2020 20:01:37 +0000 Subject: Apply suggestion to lib/pleroma/notification.ex --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 3ac8737e2..3386a1933 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -31,7 +31,7 @@ defmodule Pleroma.Notification do schema "notifications" do field(:seen, :boolean, default: false) # This is an enum type in the database. If you add a new notification type, - # remembert to add a migration to add it to the `notifications_type` enum + # remember to add a migration to add it to the `notifications_type` enum # as well. field(:type, :string) belongs_to(:user, User, type: FlakeId.Ecto.CompatType) -- cgit v1.2.3 From e1bc37d11852684a5007a9550208944d899800ca Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 9 Jun 2020 09:20:55 +0200 Subject: MigrationHelper: Move notification backfilling to own module. --- lib/pleroma/migration_helper.ex | 85 ---------------------- .../migration_helper/notification_backfill.ex | 85 ++++++++++++++++++++++ .../20200602125218_backfill_notification_types.exs | 2 +- .../notification_backfill_test.exs | 56 ++++++++++++++ test/migration_helper_test.exs | 56 -------------- 5 files changed, 142 insertions(+), 142 deletions(-) delete mode 100644 lib/pleroma/migration_helper.ex create mode 100644 lib/pleroma/migration_helper/notification_backfill.ex create mode 100644 test/migration_helper/notification_backfill_test.exs delete mode 100644 test/migration_helper_test.exs diff --git a/lib/pleroma/migration_helper.ex b/lib/pleroma/migration_helper.ex deleted file mode 100644 index a20d27a01..000000000 --- a/lib/pleroma/migration_helper.ex +++ /dev/null @@ -1,85 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.MigrationHelper do - alias Pleroma.Notification - alias Pleroma.Object - alias Pleroma.Repo - alias Pleroma.User - - import Ecto.Query - - def fill_in_notification_types do - query = - from(n in Pleroma.Notification, - where: is_nil(n.type), - preload: :activity - ) - - query - |> Repo.all() - |> Enum.each(fn notification -> - type = - notification.activity - |> type_from_activity() - - notification - |> Notification.changeset(%{type: type}) - |> Repo.update() - end) - end - - # This is copied over from Notifications to keep this stable. - defp type_from_activity(%{data: %{"type" => type}} = activity) do - case type do - "Follow" -> - accepted_function = fn activity -> - with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), - %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do - Pleroma.FollowingRelationship.following?(follower, followed) - end - end - - if accepted_function.(activity) do - "follow" - else - "follow_request" - end - - "Announce" -> - "reblog" - - "Like" -> - "favourite" - - "Move" -> - "move" - - "EmojiReact" -> - "pleroma:emoji_reaction" - - # Compatibility with old reactions - "EmojiReaction" -> - "pleroma:emoji_reaction" - - "Create" -> - activity - |> type_from_activity_object() - - t -> - raise "No notification type for activity type #{t}" - end - end - - defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" - - defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do - object = Object.get_by_ap_id(activity.data["object"]) - - case object && object.data["type"] do - "ChatMessage" -> "pleroma:chat_mention" - _ -> "mention" - end - end -end diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex new file mode 100644 index 000000000..09647d12a --- /dev/null +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper.NotificationBackfill do + alias Pleroma.Notification + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + + def fill_in_notification_types do + query = + from(n in Pleroma.Notification, + where: is_nil(n.type), + preload: :activity + ) + + query + |> Repo.all() + |> Enum.each(fn notification -> + type = + notification.activity + |> type_from_activity() + + notification + |> Notification.changeset(%{type: type}) + |> Repo.update() + end) + end + + # This is copied over from Notifications to keep this stable. + defp type_from_activity(%{data: %{"type" => type}} = activity) do + case type do + "Follow" -> + accepted_function = fn activity -> + with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), + %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do + Pleroma.FollowingRelationship.following?(follower, followed) + end + end + + if accepted_function.(activity) do + "follow" + else + "follow_request" + end + + "Announce" -> + "reblog" + + "Like" -> + "favourite" + + "Move" -> + "move" + + "EmojiReact" -> + "pleroma:emoji_reaction" + + # Compatibility with old reactions + "EmojiReaction" -> + "pleroma:emoji_reaction" + + "Create" -> + activity + |> type_from_activity_object() + + t -> + raise "No notification type for activity type #{t}" + end + end + + defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention" + + defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do + object = Object.get_by_ap_id(activity.data["object"]) + + case object && object.data["type"] do + "ChatMessage" -> "pleroma:chat_mention" + _ -> "mention" + end + end +end diff --git a/priv/repo/migrations/20200602125218_backfill_notification_types.exs b/priv/repo/migrations/20200602125218_backfill_notification_types.exs index 58943fad0..996d721ee 100644 --- a/priv/repo/migrations/20200602125218_backfill_notification_types.exs +++ b/priv/repo/migrations/20200602125218_backfill_notification_types.exs @@ -2,7 +2,7 @@ defmodule Pleroma.Repo.Migrations.BackfillNotificationTypes do use Ecto.Migration def up do - Pleroma.MigrationHelper.fill_in_notification_types() + Pleroma.MigrationHelper.NotificationBackfill.fill_in_notification_types() end def down do diff --git a/test/migration_helper/notification_backfill_test.exs b/test/migration_helper/notification_backfill_test.exs new file mode 100644 index 000000000..2a62a2b00 --- /dev/null +++ b/test/migration_helper/notification_backfill_test.exs @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.MigrationHelper.NotificationBackfillTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.MigrationHelper.NotificationBackfill + alias Pleroma.Notification + alias Pleroma.Repo + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "fill_in_notification_types" do + test "it fills in missing notification types" do + user = insert(:user) + other_user = insert(:user) + + {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) + {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") + {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + {:ok, like} = CommonAPI.favorite(other_user, post.id) + {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") + + data = + react_2.data + |> Map.put("type", "EmojiReaction") + + {:ok, react_2} = + react_2 + |> Activity.change(%{data: data}) + |> Repo.update() + + assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) + + NotificationBackfill.fill_in_notification_types() + + assert %{type: "mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) + + assert %{type: "favourite"} = + Repo.get_by(Notification, user_id: user.id, activity_id: like.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react.id) + + assert %{type: "pleroma:emoji_reaction"} = + Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) + + assert %{type: "pleroma:chat_mention"} = + Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) + end + end +end diff --git a/test/migration_helper_test.exs b/test/migration_helper_test.exs deleted file mode 100644 index 1c8173987..000000000 --- a/test/migration_helper_test.exs +++ /dev/null @@ -1,56 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.MigrationHelperTest do - use Pleroma.DataCase - - alias Pleroma.Activity - alias Pleroma.MigrationHelper - alias Pleroma.Notification - alias Pleroma.Repo - alias Pleroma.Web.CommonAPI - - import Pleroma.Factory - - describe "fill_in_notification_types" do - test "it fills in missing notification types" do - user = insert(:user) - other_user = insert(:user) - - {:ok, post} = CommonAPI.post(user, %{status: "yeah, @#{other_user.nickname}"}) - {:ok, chat} = CommonAPI.post_chat_message(user, other_user, "yo") - {:ok, react} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - {:ok, like} = CommonAPI.favorite(other_user, post.id) - {:ok, react_2} = CommonAPI.react_with_emoji(post.id, other_user, "☕") - - data = - react_2.data - |> Map.put("type", "EmojiReaction") - - {:ok, react_2} = - react_2 - |> Activity.change(%{data: data}) - |> Repo.update() - - assert {5, nil} = Repo.update_all(Notification, set: [type: nil]) - - MigrationHelper.fill_in_notification_types() - - assert %{type: "mention"} = - Repo.get_by(Notification, user_id: other_user.id, activity_id: post.id) - - assert %{type: "favourite"} = - Repo.get_by(Notification, user_id: user.id, activity_id: like.id) - - assert %{type: "pleroma:emoji_reaction"} = - Repo.get_by(Notification, user_id: user.id, activity_id: react.id) - - assert %{type: "pleroma:emoji_reaction"} = - Repo.get_by(Notification, user_id: user.id, activity_id: react_2.id) - - assert %{type: "pleroma:chat_mention"} = - Repo.get_by(Notification, user_id: other_user.id, activity_id: chat.id) - end - end -end -- cgit v1.2.3 From 063e6b9841ec72c7e89339c54581d199fa31e675 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 9 Jun 2020 10:53:40 +0200 Subject: StatusController: Correctly paginate favorites. Favorites were paginating wrongly, because the pagination headers where using the id of the id of the `Create` activity, while the ordering was by the id of the `Like` activity. This isn't easy to notice in most cases, as they usually have a similar order because people tend to favorite posts as they come in. This commit adds a way to give different pagination ids to the pagination helper, so we can paginate correctly in cases like this. --- lib/pleroma/activity.ex | 4 ++ lib/pleroma/web/activity_pub/activity_pub.ex | 5 +- .../web/api_spec/operations/status_operation.ex | 3 +- lib/pleroma/web/controller_helper.ex | 58 +++++++++++++--------- .../controllers/status_controller_test.exs | 43 ++++++++++++++-- 5 files changed, 80 insertions(+), 33 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 6213d0eb7..f800447fd 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -41,6 +41,10 @@ defmodule Pleroma.Activity do field(:recipients, {:array, :string}, default: []) field(:thread_muted?, :boolean, virtual: true) + # A field that can be used if you need to join some kind of other + # id to order / paginate this field by + field(:pagination_id, :string, virtual: true) + # This is a fake relation, # do not use outside of with_preloaded_user_actor/with_joined_user_actor has_one(:user_actor, User, on_delete: :nothing, foreign_key: :id) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index eb73c95fe..cc883ccce 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1138,12 +1138,11 @@ def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do |> Activity.Queries.by_type("Like") |> Activity.with_joined_object() |> Object.with_joined_activity() - |> select([_like, object, activity], %{activity | object: object}) + |> select([like, object, activity], %{activity | object: object, pagination_id: like.id}) |> order_by([like, _, _], desc_nulls_last: like.id) |> Pagination.fetch_paginated( Map.merge(params, %{skip_order: true}), - pagination, - :object_activity + pagination ) end diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index ca9db01e5..0b7fad793 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -333,7 +333,8 @@ def favourites_operation do %Operation{ tags: ["Statuses"], summary: "Favourited statuses", - description: "Statuses the user has favourited", + description: + "Statuses the user has favourited. Please note that you have to use the link headers to paginate this. You can not build the query parameters yourself.", operationId: "StatusController.favourites", parameters: pagination_params(), security: [%{"oAuth" => ["read:favourites"]}], diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5d67d75b5..5e33e0810 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -57,35 +57,45 @@ def add_link_headers(conn, activities, extra_params) do end end + defp build_pagination_fields(conn, min_id, max_id, extra_params) do + params = + conn.params + |> Map.drop(Map.keys(conn.path_params)) + |> Map.merge(extra_params) + |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) + + fields = %{ + "next" => current_url(conn, Map.put(params, :max_id, max_id)), + "prev" => current_url(conn, Map.put(params, :min_id, min_id)) + } + + # Generating an `id` without already present pagination keys would + # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` + # instead of the `q.id > ^min_id` and `q.id < ^max_id`. + # This is because we only have ids present inside of the page, while + # `min_id`, `since_id` and `max_id` requires to know one outside of it. + if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do + Map.put(fields, "id", current_url(conn, conn.params)) + else + fields + end + end + def get_pagination_fields(conn, activities, extra_params \\ %{}) do case List.last(activities) do - %{id: max_id} -> - params = - conn.params - |> Map.drop(Map.keys(conn.path_params)) - |> Map.merge(extra_params) - |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) + %{pagination_id: max_id} when not is_nil(max_id) -> + %{pagination_id: min_id} = + activities + |> List.first() + + build_pagination_fields(conn, min_id, max_id, extra_params) - min_id = + %{id: max_id} -> + %{id: min_id} = activities |> List.first() - |> Map.get(:id) - - fields = %{ - "next" => current_url(conn, Map.put(params, :max_id, max_id)), - "prev" => current_url(conn, Map.put(params, :min_id, min_id)) - } - - # Generating an `id` without already present pagination keys would - # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` - # instead of the `q.id > ^min_id` and `q.id < ^max_id`. - # This is because we only have ids present inside of the page, while - # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do - Map.put(fields, "id", current_url(conn, conn.params)) - else - fields - end + + build_pagination_fields(conn, min_id, max_id, extra_params) _ -> %{} diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 700c82e4f..648e6f2ce 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1541,14 +1541,49 @@ test "context" do } = response end + test "favorites paginate correctly" do + %{user: user, conn: conn} = oauth_access(["read:favourites"]) + other_user = insert(:user) + {:ok, first_post} = CommonAPI.post(other_user, %{status: "bla"}) + {:ok, second_post} = CommonAPI.post(other_user, %{status: "bla"}) + {:ok, third_post} = CommonAPI.post(other_user, %{status: "bla"}) + + {:ok, _first_favorite} = CommonAPI.favorite(user, third_post.id) + {:ok, _second_favorite} = CommonAPI.favorite(user, first_post.id) + {:ok, third_favorite} = CommonAPI.favorite(user, second_post.id) + + result = + conn + |> get("/api/v1/favourites?limit=1") + + assert [%{"id" => post_id}] = json_response_and_validate_schema(result, 200) + assert post_id == second_post.id + + # Using the header for pagination works correctly + [next, _] = get_resp_header(result, "link") |> hd() |> String.split(", ") + [_, max_id] = Regex.run(~r/max_id=(.*)>;/, next) + + assert max_id == third_favorite.id + + result = + conn + |> get("/api/v1/favourites?max_id=#{max_id}") + + assert [%{"id" => first_post_id}, %{"id" => third_post_id}] = + json_response_and_validate_schema(result, 200) + + assert first_post_id == first_post.id + assert third_post_id == third_post.id + end + test "returns the favorites of a user" do %{user: user, conn: conn} = oauth_access(["read:favourites"]) other_user = insert(:user) {:ok, _} = CommonAPI.post(other_user, %{status: "bla"}) - {:ok, activity} = CommonAPI.post(other_user, %{status: "traps are happy"}) + {:ok, activity} = CommonAPI.post(other_user, %{status: "trees are happy"}) - {:ok, _} = CommonAPI.favorite(user, activity.id) + {:ok, last_like} = CommonAPI.favorite(user, activity.id) first_conn = get(conn, "/api/v1/favourites") @@ -1566,9 +1601,7 @@ test "returns the favorites of a user" do {:ok, _} = CommonAPI.favorite(user, second_activity.id) - last_like = status["id"] - - second_conn = get(conn, "/api/v1/favourites?since_id=#{last_like}") + second_conn = get(conn, "/api/v1/favourites?since_id=#{last_like.id}") assert [second_status] = json_response_and_validate_schema(second_conn, 200) assert second_status["id"] == to_string(second_activity.id) -- cgit v1.2.3 From 3dd1de61a78f9571a1d886411d70cd52584e084a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 8 Jun 2020 22:08:57 +0400 Subject: Add `url` field to AdminAPI.AccountView --- lib/pleroma/web/admin_api/views/account_view.ex | 3 +- .../controllers/admin_api_controller_test.exs | 66 ++++++++++++++-------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 120159527..e1e929632 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -76,7 +76,8 @@ def render("show.json", %{user: user}) do "local" => user.local, "roles" => User.roles(user), "tags" => user.tags || [], - "confirmation_pending" => user.confirmation_pending + "confirmation_pending" => user.confirmation_pending, + "url" => user.uri || user.ap_id } end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index bea810c4a..e3d3ccb8d 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -337,7 +337,8 @@ test "Show", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } assert expected == json_response(conn, 200) @@ -614,7 +615,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id }, %{ "deactivated" => user.deactivated, @@ -625,7 +627,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "tags" => ["foo", "bar"], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -697,7 +700,8 @@ test "regular search", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -722,7 +726,8 @@ test "search by domain", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -747,7 +752,8 @@ test "search by full nickname", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -772,7 +778,8 @@ test "search by display name", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -797,7 +804,8 @@ test "search by email", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -822,7 +830,8 @@ test "regular search with page size", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -842,7 +851,8 @@ test "regular search with page size", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user2.ap_id } ] } @@ -874,7 +884,8 @@ test "only local users" do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -899,7 +910,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id }, %{ "deactivated" => admin.deactivated, @@ -910,7 +922,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id }, %{ "deactivated" => false, @@ -921,7 +934,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "tags" => [], "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => old_admin.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -951,7 +965,8 @@ test "load only admins", %{conn: conn, admin: admin} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id }, %{ "deactivated" => false, @@ -962,7 +977,8 @@ test "load only admins", %{conn: conn, admin: admin} do "tags" => [], "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => second_admin.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -994,7 +1010,8 @@ test "load only moderators", %{conn: conn} do "tags" => [], "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => moderator.ap_id } ] } @@ -1019,7 +1036,8 @@ test "load users with tags list", %{conn: conn} do "tags" => ["first"], "avatar" => User.avatar_url(user1) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user1.name || user1.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user1.ap_id }, %{ "deactivated" => false, @@ -1030,7 +1048,8 @@ test "load users with tags list", %{conn: conn} do "tags" => ["second"], "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user2.ap_id } ] |> Enum.sort_by(& &1["nickname"]) @@ -1069,7 +1088,8 @@ test "it works with multiple filters" do "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } ] } @@ -1093,7 +1113,8 @@ test "it omits relay user", %{admin: admin, conn: conn} do "tags" => [], "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => admin.ap_id } ] } @@ -1155,7 +1176,8 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "tags" => [], "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), - "confirmation_pending" => false + "confirmation_pending" => false, + "url" => user.ap_id } log_entry = Repo.one(ModerationLog) -- cgit v1.2.3 From c4f267b3bef90dcac21b7db2a91f86d3ba5dc7c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 08:02:26 +0000 Subject: Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 5e33e0810..6cb19d539 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -57,6 +57,7 @@ def add_link_headers(conn, activities, extra_params) do end end + @id_keys Pagination.page_keys() -- ["limit", "order"] defp build_pagination_fields(conn, min_id, max_id, extra_params) do params = conn.params -- cgit v1.2.3 From be7c322865b2b7aa1c8c25147cc598b6362ab187 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 08:02:35 +0000 Subject: Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 6cb19d539..b7971e940 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -63,7 +63,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do conn.params |> Map.drop(Map.keys(conn.path_params)) |> Map.merge(extra_params) - |> Map.drop(Pagination.page_keys() -- ["limit", "order"]) + |> Map.drop(@id_keys) fields = %{ "next" => current_url(conn, Map.put(params, :max_id, max_id)), -- cgit v1.2.3 From b4c50be9df701dc9faf0a25f776f631d2175c99f Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 08:12:29 +0000 Subject: Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index b7971e940..ab6e6c61a 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,7 +75,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # instead of the `q.id > ^min_id` and `q.id < ^max_id`. # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, Pagination.page_keys() -- ["limit", "order"]) != [] do + if Map.take(conn.params, @id_keys) != %{} do Map.put(fields, "id", current_url(conn, conn.params)) else fields -- cgit v1.2.3 From 86fec45f40dfa45cc89eddc6dcc7799e89d6f461 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 11:09:45 +0200 Subject: ControllerHelper: Fix wrong comparison. --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index ab6e6c61a..88f2cc6f1 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,7 +75,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # instead of the `q.id > ^min_id` and `q.id < ^max_id`. # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, @id_keys) != %{} do + if Map.take(conn.params, @id_keys) != [] do Map.put(fields, "id", current_url(conn, conn.params)) else fields -- cgit v1.2.3 From 9e411372d0b7ae286941063956305c0a2eae46a6 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 12:10:09 +0200 Subject: ActivityPub: Don't show announces of your own objects in timeline. --- lib/pleroma/web/activity_pub/activity_pub.ex | 40 ++++++++++++---------- .../controllers/timeline_controller.ex | 1 + test/web/activity_pub/activity_pub_test.exs | 24 +++++++++++++ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index eb73c95fe..4182275bc 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -31,25 +31,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do require Logger require Pleroma.Constants - # For Announce activities, we filter the recipients based on following status for any actors - # that match actual users. See issue #164 for more information about why this is necessary. - defp get_recipients(%{"type" => "Announce"} = data) do - to = Map.get(data, "to", []) - cc = Map.get(data, "cc", []) - bcc = Map.get(data, "bcc", []) - actor = User.get_cached_by_ap_id(data["actor"]) - - recipients = - Enum.filter(Enum.concat([to, cc, bcc]), fn recipient -> - case User.get_cached_by_ap_id(recipient) do - nil -> true - user -> User.following?(user, actor) - end - end) - - {recipients, to, cc} - end - defp get_recipients(%{"type" => "Create"} = data) do to = Map.get(data, "to", []) cc = Map.get(data, "cc", []) @@ -702,6 +683,26 @@ defp user_activities_recipients(%{reading_user: reading_user}) do end end + defp restrict_announce_object_actor(_query, %{announce_filtering_user: _, skip_preload: true}) do + raise "Can't use the child object without preloading!" + end + + defp restrict_announce_object_actor(query, %{announce_filtering_user: %{ap_id: actor}}) do + from( + [activity, object] in query, + where: + fragment( + "?->>'type' != ? or ?->>'actor' != ?", + activity.data, + "Announce", + object.data, + ^actor + ) + ) + end + + defp restrict_announce_object_actor(query, _), do: query + defp restrict_since(query, %{since_id: ""}), do: query defp restrict_since(query, %{since_id: since_id}) do @@ -1113,6 +1114,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_pinned(opts) |> restrict_muted_reblogs(restrict_muted_reblogs_opts) |> restrict_instance(opts) + |> restrict_announce_object_actor(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) |> exclude_invisible_actors(opts) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 9270ca267..4bdd46d7e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -48,6 +48,7 @@ def home(%{assigns: %{user: user}} = conn, params) do |> Map.put(:blocking_user, user) |> Map.put(:muting_user, user) |> Map.put(:reply_filtering_user, user) + |> Map.put(:announce_filtering_user, user) |> Map.put(:user, user) activities = diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 2f65dfc8e..e17cc4ab1 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1643,6 +1643,30 @@ test "home timeline with reply_visibility `self`", %{ assert Enum.all?(visible_ids, &(&1 in activities_ids)) end + + test "filtering out announces where the user is the actor of the announced message" do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + User.follow(user, other_user) + + {:ok, post} = CommonAPI.post(user, %{status: "yo"}) + {:ok, other_post} = CommonAPI.post(third_user, %{status: "yo"}) + {:ok, _announce} = CommonAPI.repeat(post.id, other_user) + {:ok, _announce} = CommonAPI.repeat(post.id, third_user) + {:ok, announce} = CommonAPI.repeat(other_post.id, other_user) + + params = %{ + type: ["Announce"], + announce_filtering_user: user + } + + [result] = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + + assert result.id == announce.id + end end describe "replies filtering with private messages" do -- cgit v1.2.3 From 600e2ea07396489325e06dee3e8432288e0e13c2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 12:15:56 +0200 Subject: ActivityPubTest: Make test easier to understand. --- test/web/activity_pub/activity_pub_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index e17cc4ab1..6cd3b8d1b 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1656,6 +1656,16 @@ test "filtering out announces where the user is the actor of the announced messa {:ok, _announce} = CommonAPI.repeat(post.id, third_user) {:ok, announce} = CommonAPI.repeat(other_post.id, other_user) + params = %{ + type: ["Announce"] + } + + results = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + + assert length(results) == 3 + params = %{ type: ["Announce"], announce_filtering_user: user -- cgit v1.2.3 From 570123ae21382c7e78b99442e3c025b0e66b8f6d Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sun, 7 Jun 2020 18:21:11 +0200 Subject: Add test --- test/web/activity_pub/activity_pub_test.exs | 35 ++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 6cd3b8d1b..72d3f3dfa 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -574,7 +574,7 @@ test "doesn't return transitive interactions concerning blocked users" do refute Enum.member?(activities, activity_four) end - test "doesn't return announce activities concerning blocked users" do + test "doesn't return announce activities with blocked users in 'to'" do blocker = insert(:user) blockee = insert(:user) friend = insert(:user) @@ -596,6 +596,39 @@ test "doesn't return announce activities concerning blocked users" do refute Enum.member?(activities, activity_three.id) end + test "doesn't return announce activities with blocked users in 'cc'" do + blocker = insert(:user) + blockee = insert(:user) + friend = insert(:user) + + {:ok, _user_relationship} = User.block(blocker, blockee) + + {:ok, activity_one} = CommonAPI.post(friend, %{status: "hey!"}) + + {:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"}) + + assert object = Pleroma.Object.normalize(activity_two) + + data = %{ + "actor" => friend.ap_id, + "object" => object.data["id"], + "context" => object.data["context"], + "type" => "Announce", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [blockee.ap_id] + } + + assert {:ok, activity_three} = ActivityPub.insert(data) + + activities = + ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + |> Enum.map(fn act -> act.id end) + + assert Enum.member?(activities, activity_one.id) + refute Enum.member?(activities, activity_two.id) + refute Enum.member?(activities, activity_three.id) + end + test "doesn't return activities from blocked domains" do domain = "dogwhistle.zone" domain_user = insert(:user, %{ap_id: "https://#{domain}/@pundit"}) -- cgit v1.2.3 From 5d87405b51efe9f99fea669090a5914db22ca9ed Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 16:55:30 +0200 Subject: ActivityPubTest: Update test for atomized parameters. --- test/web/activity_pub/activity_pub_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 72d3f3dfa..b239b812f 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -621,7 +621,7 @@ test "doesn't return announce activities with blocked users in 'cc'" do assert {:ok, activity_three} = ActivityPub.insert(data) activities = - ActivityPub.fetch_activities([], %{"blocking_user" => blocker}) + ActivityPub.fetch_activities([], %{blocking_user: blocker}) |> Enum.map(fn act -> act.id end) assert Enum.member?(activities, activity_one.id) -- cgit v1.2.3 From 99afc7f4e423997079aaee1287b9ffb28a851d8b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 10 Jun 2020 20:09:16 +0300 Subject: HTTP security plug: add media proxy base url host to csp --- lib/pleroma/plugs/http_security_plug.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 6a339b32c..620408d0f 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -113,6 +113,10 @@ defp get_proxy_and_attachment_sources do add_source(acc, host) end) + media_proxy_base_url = + if Config.get([Pleroma.Upload, :base_url]), + do: URI.parse(Config.get([:media_proxy, :base_url])).host + upload_base_url = if Config.get([Pleroma.Upload, :base_url]), do: URI.parse(Config.get([Pleroma.Upload, :base_url])).host @@ -122,6 +126,7 @@ defp get_proxy_and_attachment_sources do do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host [] + |> add_source(media_proxy_base_url) |> add_source(upload_base_url) |> add_source(s3_endpoint) |> add_source(media_proxy_whitelist) -- cgit v1.2.3 From 7c47f791a803aa5cee2f2f6931b8445d2c0551e5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 10 Jun 2020 13:02:08 -0500 Subject: Add command to reload emoji packs from cli for OTP users Not useful for source releases as we don't have a way to automate connecting to the running instance. --- docs/administration/CLI_tasks/emoji.md | 8 ++++++++ lib/mix/tasks/pleroma/emoji.ex | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index 3d524a52b..ddcb7e62c 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -44,3 +44,11 @@ Currently, only .zip archives are recognized as remote pack files and packs are 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). + +## Reload emoji packs + +```sh tab="OTP" +./bin/pleroma_ctl emoji reload +``` + +This command only works with OTP releases. diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 29a5fa99c..f4eaeac98 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -237,6 +237,12 @@ def run(["gen-pack" | args]) do end end + def run(["reload"]) do + start_pleroma() + Pleroma.Emoji.reload() + IO.puts("Emoji packs have been reloaded.") + end + defp fetch_and_decode(from) do with {:ok, json} <- fetch(from) do Jason.decode!(json) -- cgit v1.2.3 From 5e44e9d69871f2e5805a8dddcfce43ae713eb52d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 10 Jun 2020 18:56:46 +0000 Subject: Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 88f2cc6f1..a5eb3e9e0 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -76,7 +76,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. if Map.take(conn.params, @id_keys) != [] do - Map.put(fields, "id", current_url(conn, conn.params)) + Map.put(fields, "id", current_url(conn)) else fields end -- cgit v1.2.3 From b28cec4271c52d55f6e6cf8a1bcdb41efec3ef03 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 11 Jun 2020 16:05:14 +0300 Subject: [#1794] Fixes URI query handling for hashtags extraction in search. --- .../web/mastodon_api/controllers/search_controller.ex | 14 ++++++++++++++ .../mastodon_api/controllers/search_controller_test.exs | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 8840fc19c..46bcf4228 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -124,6 +124,7 @@ defp resource_search(:v1, "hashtags", query, _options) do defp prepare_tags(query, add_joined_tag \\ true) do tags = query + |> preprocess_uri_query() |> String.split(~r/[^#\w]+/u, trim: true) |> Enum.uniq_by(&String.downcase/1) @@ -147,6 +148,19 @@ defp prepare_tags(query, add_joined_tag \\ true) do end end + # If `query` is a URI, returns last component of its path, otherwise returns `query` + defp preprocess_uri_query(query) do + if query =~ ~r/https?:\/\// do + query + |> URI.parse() + |> Map.get(:path) + |> String.split("/") + |> Enum.at(-1) + else + query + end + end + defp joined_tag(tags) do tags |> Enum.map(fn tag -> String.capitalize(tag) end) diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 84d46895e..0e025adca 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -111,6 +111,15 @@ test "constructs hashtags from search query", %{conn: conn} do %{"name" => "prone", "url" => "#{Web.base_url()}/tag/prone"}, %{"name" => "AccidentProne", "url" => "#{Web.base_url()}/tag/AccidentProne"} ] + + results = + conn + |> get("/api/v2/search?#{URI.encode_query(%{q: "https://shpposter.club/users/shpuld"})}") + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "shpuld", "url" => "#{Web.base_url()}/tag/shpuld"} + ] end test "excludes a blocked users from search results", %{conn: conn} do -- cgit v1.2.3 From 1f35acce54ea6924a54b4fc387be3346a6f5551e Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 11 Jun 2020 17:57:31 +0400 Subject: Merge OGP parser with TwitterCard --- CHANGELOG.md | 1 + config/config.exs | 1 - config/description.exs | 2 - lib/pleroma/web/rich_media/parser.ex | 4 +- .../web/rich_media/parsers/meta_tags_parser.ex | 25 ++-- .../web/rich_media/parsers/oembed_parser.ex | 4 +- lib/pleroma/web/rich_media/parsers/ogp.ex | 3 +- lib/pleroma/web/rich_media/parsers/twitter_card.ex | 15 +-- test/web/rich_media/parsers/twitter_card_test.exs | 130 ++++++++++++--------- 9 files changed, 91 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2210f5..575eb67b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- OGP rich media parser merged with TwitterCard
    API Changes - **Breaking:** Emoji API: changed methods and renamed routes. diff --git a/config/config.exs b/config/config.exs index 9508ae077..cafa40820 100644 --- a/config/config.exs +++ b/config/config.exs @@ -385,7 +385,6 @@ ignore_tld: ["local", "localdomain", "lan"], parsers: [ Pleroma.Web.RichMedia.Parsers.TwitterCard, - Pleroma.Web.RichMedia.Parsers.OGP, Pleroma.Web.RichMedia.Parsers.OEmbed ], ttl_setters: [Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl] diff --git a/config/description.exs b/config/description.exs index 807c945e0..b993959d7 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2091,9 +2091,7 @@ description: "List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom module you need to use full name.", suggestions: [ - Pleroma.Web.RichMedia.Parsers.MetaTagsParser, Pleroma.Web.RichMedia.Parsers.OEmbed, - Pleroma.Web.RichMedia.Parsers.OGP, Pleroma.Web.RichMedia.Parsers.TwitterCard ] }, diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 40980def8..78e9048f3 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -105,8 +105,8 @@ defp parse_html(html), do: Floki.parse_document!(html) defp maybe_parse(html) do Enum.reduce_while(parsers(), %{}, fn parser, acc -> case parser.parse(html, acc) do - {:ok, data} -> {:halt, data} - {:error, _msg} -> {:cont, acc} + data when data != %{} -> {:halt, data} + _ -> {:cont, acc} end end) end diff --git a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex index ae0f36702..c09b96eae 100644 --- a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex @@ -3,22 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.MetaTagsParser do - def parse(html, data, prefix, error_message, key_name, value_name \\ "content") do - meta_data = - html - |> get_elements(key_name, prefix) - |> Enum.reduce(data, fn el, acc -> - attributes = normalize_attributes(el, prefix, key_name, value_name) - - Map.merge(acc, attributes) - end) - |> maybe_put_title(html) - - if Enum.empty?(meta_data) do - {:error, error_message} - else - {:ok, meta_data} - end + def parse(data, html, prefix, key_name, value_name \\ "content") do + html + |> get_elements(key_name, prefix) + |> Enum.reduce(data, fn el, acc -> + attributes = normalize_attributes(el, prefix, key_name, value_name) + + Map.merge(acc, attributes) + end) + |> maybe_put_title(html) end defp get_elements(html, key_name, prefix) do diff --git a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex index 8f32bf91b..5d87a90e9 100644 --- a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex @@ -7,9 +7,9 @@ def parse(html, _data) do with elements = [_ | _] <- get_discovery_data(html), {:ok, oembed_url} <- get_oembed_url(elements), {:ok, oembed_data} <- get_oembed_data(oembed_url) do - {:ok, oembed_data} + oembed_data else - _e -> {:error, "No OEmbed data found"} + _e -> %{} end end diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index 3e9012588..5eebe42f7 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -5,10 +5,9 @@ defmodule Pleroma.Web.RichMedia.Parsers.OGP do def parse(html, data) do Pleroma.Web.RichMedia.Parsers.MetaTagsParser.parse( - html, data, + html, "og", - "No OGP metadata found", "property" ) end diff --git a/lib/pleroma/web/rich_media/parsers/twitter_card.ex b/lib/pleroma/web/rich_media/parsers/twitter_card.ex index 09d4b526e..4a04865d2 100644 --- a/lib/pleroma/web/rich_media/parsers/twitter_card.ex +++ b/lib/pleroma/web/rich_media/parsers/twitter_card.ex @@ -5,18 +5,11 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCard do alias Pleroma.Web.RichMedia.Parsers.MetaTagsParser - @spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()} + @spec parse(list(), map()) :: map() def parse(html, data) do data - |> parse_name_attrs(html) - |> parse_property_attrs(html) - end - - defp parse_name_attrs(data, html) do - MetaTagsParser.parse(html, data, "twitter", %{}, "name") - end - - defp parse_property_attrs({_, data}, html) do - MetaTagsParser.parse(html, data, "twitter", "No twitter card metadata found", "property") + |> MetaTagsParser.parse(html, "og", "property") + |> MetaTagsParser.parse(html, "twitter", "name") + |> MetaTagsParser.parse(html, "twitter", "property") end end diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs index 87c767c15..3ccf26651 100644 --- a/test/web/rich_media/parsers/twitter_card_test.exs +++ b/test/web/rich_media/parsers/twitter_card_test.exs @@ -7,8 +7,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do alias Pleroma.Web.RichMedia.Parsers.TwitterCard test "returns error when html not contains twitter card" do - assert TwitterCard.parse([{"html", [], [{"head", [], []}, {"body", [], []}]}], %{}) == - {:error, "No twitter card metadata found"} + assert TwitterCard.parse([{"html", [], [{"head", [], []}, {"body", [], []}]}], %{}) == %{} end test "parses twitter card with only name attributes" do @@ -17,15 +16,21 @@ test "parses twitter card with only name attributes" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - site: nil, - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times" - }} + %{ + "app:id:googleplay": "com.nytimes.android", + "app:name:googleplay": "NYTimes", + "app:url:googleplay": "nytimes://reader/id/100000006583622", + site: nil, + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", + type: "article", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database." + } end test "parses twitter card with only property attributes" do @@ -34,19 +39,19 @@ test "parses twitter card with only property attributes" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - card: "summary_large_image", - description: - "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: - "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: - "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" - }} + %{ + card: "summary_large_image", + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", + "image:alt": "", + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", + type: "article" + } end test "parses twitter card with name & property attributes" do @@ -55,23 +60,23 @@ test "parses twitter card with name & property attributes" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - card: "summary_large_image", - description: - "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: - "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - site: nil, - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: - "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" - }} + %{ + "app:id:googleplay": "com.nytimes.android", + "app:name:googleplay": "NYTimes", + "app:url:googleplay": "nytimes://reader/id/100000006583622", + card: "summary_large_image", + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", + "image:alt": "", + site: nil, + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", + type: "article" + } end test "respect only first title tag on the page" do @@ -84,14 +89,17 @@ test "respect only first title tag on the page" do File.read!("test/fixtures/margaret-corbin-grave-west-point.html") |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - site: "@atlasobscura", - title: - "The Missing Grave of Margaret Corbin, Revolutionary War Veteran - Atlas Obscura", - card: "summary_large_image", - image: image_path - }} + %{ + site: "@atlasobscura", + title: "The Missing Grave of Margaret Corbin, Revolutionary War Veteran", + card: "summary_large_image", + image: image_path, + description: + "She's the only woman veteran honored with a monument at West Point. But where was she buried?", + site_name: "Atlas Obscura", + type: "article", + url: "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" + } end test "takes first founded title in html head if there is html markup error" do @@ -100,14 +108,20 @@ test "takes first founded title in html head if there is html markup error" do |> Floki.parse_document!() assert TwitterCard.parse(html, %{}) == - {:ok, - %{ - site: nil, - title: - "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times", - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622" - }} + %{ + site: nil, + title: + "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", + "app:id:googleplay": "com.nytimes.android", + "app:name:googleplay": "NYTimes", + "app:url:googleplay": "nytimes://reader/id/100000006583622", + description: + "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", + image: + "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", + type: "article", + url: + "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" + } end end -- cgit v1.2.3 From 7f7a1a467677471e0e1ec688e4eca9ba759d976a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 11 Jun 2020 11:05:22 -0500 Subject: Check for media proxy base_url, not Upload base_url --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 620408d0f..1420a9611 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -114,7 +114,7 @@ defp get_proxy_and_attachment_sources do end) media_proxy_base_url = - if Config.get([Pleroma.Upload, :base_url]), + if Config.get([:media_proxy, :base_url]), do: URI.parse(Config.get([:media_proxy, :base_url])).host upload_base_url = -- cgit v1.2.3 From 2419776e192316cefbdbe607306c9b92ec558319 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 11 Jun 2020 23:11:46 +0400 Subject: Deprecate Pleroma.Web.RichMedia.Parsers.OGP --- lib/pleroma/web/rich_media/parsers/ogp.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index 5eebe42f7..363815f81 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parsers.OGP do + @deprecated "OGP parser is deprecated. Use TwitterCard instead." def parse(html, data) do Pleroma.Web.RichMedia.Parsers.MetaTagsParser.parse( data, -- cgit v1.2.3 From 40970f6bb94760d19cc1d3201405df5bb32f5083 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 11 Jun 2020 22:54:39 +0200 Subject: New mix task: pleroma.user reset_mfa --- docs/administration/CLI_tasks/user.md | 10 ++++++++++ lib/mix/tasks/pleroma/user.ex | 12 ++++++++++++ test/tasks/user_test.exs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index afeb8d52f..1e6f4a8b4 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -135,6 +135,16 @@ mix pleroma.user reset_password ``` +## Disable Multi Factor Authentication (MFA/2FA) for a user +```sh tab="OTP" + ./bin/pleroma_ctl user reset_mfa +``` + +```sh tab="From Source" +mix pleroma.user reset_mfa +``` + + ## Set the value of the given user's settings ```sh tab="OTP" ./bin/pleroma_ctl user set [option ...] diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 3635c02bc..bca7e87bf 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -144,6 +144,18 @@ def run(["reset_password", nickname]) do end end + def run(["reset_mfa", nickname]) do + start_pleroma() + + with %User{local: true} = user <- User.get_cached_by_nickname(nickname), + {:ok, _token} <- Pleroma.MFA.disable(user) do + shell_info("Multi-Factor Authentication disabled for #{user.nickname}") + else + _ -> + shell_error("No local user #{nickname}") + end + end + def run(["deactivate", nickname]) do start_pleroma() diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index b55aa1cdb..9220d23fc 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -4,6 +4,7 @@ defmodule Mix.Tasks.Pleroma.UserTest do alias Pleroma.Activity + alias Pleroma.MFA alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers @@ -278,6 +279,35 @@ test "no user to reset password" do end end + describe "running reset_mfa" do + test "disables MFA" do + user = + insert(:user, + multi_factor_authentication_settings: %MFA.Settings{ + enabled: true, + totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true} + } + ) + + Mix.Tasks.Pleroma.User.run(["reset_mfa", user.nickname]) + + assert_received {:mix_shell, :info, [message]} + assert message == "Multi-Factor Authentication disabled for #{user.nickname}" + + assert %{enabled: false, totp: false} == + user.nickname + |> User.get_cached_by_nickname() + |> MFA.mfa_settings() + end + + test "no user to reset MFA" do + Mix.Tasks.Pleroma.User.run(["reset_password", "nonexistent"]) + + assert_received {:mix_shell, :error, [message]} + assert message =~ "No local user" + end + end + describe "running invite" do test "invite token is generated" do assert capture_io(fn -> -- cgit v1.2.3 From 122328b93a708e396b5c0cd1930a4b759e7b7db6 Mon Sep 17 00:00:00 2001 From: normandy Date: Fri, 12 Jun 2020 01:41:09 +0000 Subject: Update pleroma.nginx to support TLSv1.3 Based on SSL config from https://ssl-config.mozilla.org/ --- installation/pleroma.nginx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/installation/pleroma.nginx b/installation/pleroma.nginx index 688be3e71..d301ca615 100644 --- a/installation/pleroma.nginx +++ b/installation/pleroma.nginx @@ -37,18 +37,17 @@ server { listen 443 ssl http2; listen [::]:443 ssl http2; - ssl_session_timeout 5m; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; # about 40000 sessions + ssl_session_tickets off; ssl_trusted_certificate /etc/letsencrypt/live/example.tld/chain.pem; ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem; - # Add TLSv1.0 to support older devices - ssl_protocols TLSv1.2; - # Uncomment line below if you want to support older devices (Before Android 4.4.2, IE 8, etc.) - # ssl_ciphers "HIGH:!aNULL:!MD5 or HIGH:!aNULL:!MD5:!3DES"; + ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; - ssl_prefer_server_ciphers on; + ssl_prefer_server_ciphers off; # In case of an old server with an OpenSSL version of 1.0.2 or below, # leave only prime256v1 or comment out the following line. ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1; -- cgit v1.2.3 From 21880970660906d8072dc501e6a8b25fb4a4b0c7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 12 Jun 2020 14:25:41 +0300 Subject: [#1794] Fixes URI query handling for hashtags extraction in search. --- .../mastodon_api/controllers/search_controller.ex | 1 + .../controllers/search_controller_test.exs | 29 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 46bcf4228..3be0ca095 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -152,6 +152,7 @@ defp prepare_tags(query, add_joined_tag \\ true) do defp preprocess_uri_query(query) do if query =~ ~r/https?:\/\// do query + |> String.trim_trailing("/") |> URI.parse() |> Map.get(:path) |> String.split("/") diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 0e025adca..c605957b1 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -120,6 +120,35 @@ test "constructs hashtags from search query", %{conn: conn} do assert results["hashtags"] == [ %{"name" => "shpuld", "url" => "#{Web.base_url()}/tag/shpuld"} ] + + results = + conn + |> get( + "/api/v2/search?#{ + URI.encode_query(%{ + q: + "https://www.washingtonpost.com/sports/2020/06/10/" <> + "nascar-ban-display-confederate-flag-all-events-properties/" + }) + }" + ) + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "nascar", "url" => "#{Web.base_url()}/tag/nascar"}, + %{"name" => "ban", "url" => "#{Web.base_url()}/tag/ban"}, + %{"name" => "display", "url" => "#{Web.base_url()}/tag/display"}, + %{"name" => "confederate", "url" => "#{Web.base_url()}/tag/confederate"}, + %{"name" => "flag", "url" => "#{Web.base_url()}/tag/flag"}, + %{"name" => "all", "url" => "#{Web.base_url()}/tag/all"}, + %{"name" => "events", "url" => "#{Web.base_url()}/tag/events"}, + %{"name" => "properties", "url" => "#{Web.base_url()}/tag/properties"}, + %{ + "name" => "NascarBanDisplayConfederateFlagAllEventsProperties", + "url" => + "#{Web.base_url()}/tag/NascarBanDisplayConfederateFlagAllEventsProperties" + } + ] end test "excludes a blocked users from search results", %{conn: conn} do -- cgit v1.2.3 From f9dcf15ecb684b4b802d731a216448c76913d462 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 12 Jun 2020 14:49:54 +0300 Subject: added admin api for MediaProxy cache invalidation --- .../controllers/media_proxy_cache_controller.ex | 38 +++++++ .../web/admin_api/views/media_proxy_cache_view.ex | 11 +++ .../admin/media_proxy_cache_operation.ex | 109 +++++++++++++++++++++ lib/pleroma/web/router.ex | 4 + .../media_proxy_cache_controller_test.exs | 66 +++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex create mode 100644 test/web/admin_api/controllers/media_proxy_cache_controller_test.exs diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex new file mode 100644 index 000000000..7b28f7c72 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do + use Pleroma.Web, :controller + + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Web.ApiSpec.Admin, as: Spec + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["read:media_proxy_caches"], admin: true} when action in [:index] + ) + + plug( + OAuthScopesPlug, + %{scopes: ["write:media_proxy_caches"], admin: true} when action in [:purge, :delete] + ) + + action_fallback(Pleroma.Web.AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation + + def index(%{assigns: %{user: _}} = conn, _) do + render(conn, "index.json", urls: []) + end + + def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do + render(conn, "index.json", urls: urls) + end + + def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: _ban}} = conn, _) do + render(conn, "index.json", urls: urls) + end +end diff --git a/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex new file mode 100644 index 000000000..c97400beb --- /dev/null +++ b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex @@ -0,0 +1,11 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheView do + use Pleroma.Web, :view + + def render("index.json", %{urls: urls}) do + %{urls: urls} + end +end diff --git a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex new file mode 100644 index 000000000..0358cfbad --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.MediaProxyCacheOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Admin", "MediaProxyCache"], + summary: "Fetch a paginated list of all banned MediaProxy URLs in Cachex", + operationId: "AdminAPI.MediaProxyCacheController.index", + security: [%{"oAuth" => ["read:media_proxy_caches"]}], + parameters: [ + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], + responses: %{ + 200 => success_response() + } + } + end + + def delete_operation do + %Operation{ + tags: ["Admin", "MediaProxyCache"], + summary: "Remove a banned MediaProxy URL from Cachex", + operationId: "AdminAPI.MediaProxyCacheController.delete", + security: [%{"oAuth" => ["write:media_proxy_caches"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:urls], + properties: %{ + urls: %Schema{type: :array, items: %Schema{type: :string, format: :uri}} + } + }, + required: true + ), + responses: %{ + 200 => success_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def purge_operation do + %Operation{ + tags: ["Admin", "MediaProxyCache"], + summary: "Purge and optionally ban a MediaProxy URL", + operationId: "AdminAPI.MediaProxyCacheController.purge", + security: [%{"oAuth" => ["write:media_proxy_caches"]}], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + required: [:urls], + properties: %{ + urls: %Schema{type: :array, items: %Schema{type: :string, format: :uri}}, + ban: %Schema{type: :boolean, default: true} + } + }, + required: true + ), + responses: %{ + 200 => success_response(), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + defp success_response do + Operation.response("Array of banned MediaProxy URLs in Cachex", "application/json", %Schema{ + type: :object, + properties: %{ + urls: %Schema{ + type: :array, + items: %Schema{ + type: :string, + format: :uri, + description: "MediaProxy URLs" + } + } + } + }) + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 57570b672..eda74a171 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -209,6 +209,10 @@ defmodule Pleroma.Web.Router do post("/oauth_app", OAuthAppController, :create) patch("/oauth_app/:id", OAuthAppController, :update) delete("/oauth_app/:id", OAuthAppController, :delete) + + get("/media_proxy_caches", MediaProxyCacheController, :index) + post("/media_proxy_caches/delete", MediaProxyCacheController, :delete) + post("/media_proxy_caches/purge", MediaProxyCacheController, :purge) end scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs new file mode 100644 index 000000000..1b1d6bc36 --- /dev/null +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do + use Pleroma.Web.ConnCase + + import Pleroma.Factory + + setup do + admin = insert(:user, is_admin: true) + token = insert(:oauth_admin_token, user: admin) + + conn = + build_conn() + |> assign(:user, admin) + |> assign(:token, token) + + {:ok, %{admin: admin, token: token, conn: conn}} + end + + describe "GET /api/pleroma/admin/media_proxy_caches" do + test "shows banned MediaProxy URLs", %{conn: conn} do + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches") + |> json_response_and_validate_schema(200) + + assert response["urls"] == [] + end + end + + describe "DELETE /api/pleroma/admin/media_proxy_caches/delete" do + test "deleted MediaProxy URLs from banned", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/delete", %{ + urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] + }) + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] + end + end + + describe "PURGE /api/pleroma/admin/media_proxy_caches/purge" do + test "perform invalidates cache of MediaProxy", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ + urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] + }) + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] + end + end +end -- cgit v1.2.3 From c2048f75cd09696e30b443423cae4ba6ef3e593b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 12 Jun 2020 08:42:23 -0500 Subject: Add changelog entry for emoji pack reload command --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2210f5..b19cae8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Add support for filtering replies in public and home timelines - Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoint for status view. +- OTP: Add command to reload emoji packs
    ### Fixed -- cgit v1.2.3 From e505e59d9c43db286ccf7fe70da2fa974ae3d700 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 12 Jun 2020 08:51:11 -0500 Subject: Document new mix task feature to reset mfa --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf2210f5..c23beec9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Configuration: `filename_display_max_length` option to set filename truncate limit, if filename display enabled (0 = no limit). - 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. - Mix task to create trusted OAuth App. +- Mix task to reset MFA for user accounts - Notifications: Added `follow_request` notification type. - Added `:reject_deletes` group to SimplePolicy - MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances -- cgit v1.2.3 From 4655407451c8dd05b6024f607e598359047efce2 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 12 Jun 2020 14:03:33 +0000 Subject: Apply suggestion to config/description.exs --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 086a28ace..add1601e2 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1476,7 +1476,7 @@ key: :mrf_activity_expiration, label: "MRF Activity Expiration Policy", type: :group, - description: "Adds expiration to all local Create activities", + description: "Adds expiration to all local Create Note activities", children: [ %{ key: :days, -- cgit v1.2.3 From 09d31d24de568aac06fe203beeb8bb2a9de8f602 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Fri, 12 Jun 2020 18:37:32 +0400 Subject: Return an empty map from Pleroma.Web.RichMedia.Parsers.OGP.parse/2 --- lib/pleroma/web/rich_media/parsers/ogp.ex | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/rich_media/parsers/ogp.ex b/lib/pleroma/web/rich_media/parsers/ogp.ex index 363815f81..b3b3b059c 100644 --- a/lib/pleroma/web/rich_media/parsers/ogp.ex +++ b/lib/pleroma/web/rich_media/parsers/ogp.ex @@ -4,12 +4,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.OGP do @deprecated "OGP parser is deprecated. Use TwitterCard instead." - def parse(html, data) do - Pleroma.Web.RichMedia.Parsers.MetaTagsParser.parse( - data, - html, - "og", - "property" - ) + def parse(_html, _data) do + %{} end end -- cgit v1.2.3 From 520367d6fd8a268e0bc8c145a46aca46a62e8b66 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 9 Jun 2020 21:49:24 +0400 Subject: Fix atom leak in Rich Media Parser --- lib/pleroma/web/mastodon_api/views/status_view.ex | 14 ++-- lib/pleroma/web/rich_media/helpers.ex | 6 +- lib/pleroma/web/rich_media/parser.ex | 12 ++-- .../web/rich_media/parsers/meta_tags_parser.ex | 8 +-- .../web/rich_media/parsers/oembed_parser.ex | 18 ++---- test/web/rich_media/parser_test.exs | 75 +++++++++++----------- test/web/rich_media/parsers/twitter_card_test.exs | 60 ++++++++--------- 7 files changed, 91 insertions(+), 102 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 8e3715093..2c49bedb3 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -377,8 +377,8 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url_data = URI.parse(page_url) page_url_data = - if rich_media[:url] != nil do - URI.merge(page_url_data, URI.parse(rich_media[:url])) + if is_binary(rich_media["url"]) do + URI.merge(page_url_data, URI.parse(rich_media["url"])) else page_url_data end @@ -386,11 +386,9 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url = page_url_data |> to_string image_url = - if rich_media[:image] != nil do - URI.merge(page_url_data, URI.parse(rich_media[:image])) + if is_binary(rich_media["image"]) do + URI.merge(page_url_data, URI.parse(rich_media["image"])) |> to_string - else - nil end %{ @@ -399,8 +397,8 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do provider_url: page_url_data.scheme <> "://" <> page_url_data.host, url: page_url, image: image_url |> MediaProxy.url(), - title: rich_media[:title] || "", - description: rich_media[:description] || "", + title: rich_media["title"] || "", + description: rich_media["description"] || "", pleroma: %{ opengraph: rich_media } diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 9d3d7f978..1729141e9 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do alias Pleroma.Object alias Pleroma.Web.RichMedia.Parser - @spec validate_page_url(any()) :: :ok | :error + @spec validate_page_url(URI.t() | binary()) :: :ok | :error defp validate_page_url(page_url) when is_binary(page_url) do validate_tld = Application.get_env(:auto_linker, :opts)[:validate_tld] @@ -18,8 +18,8 @@ defp validate_page_url(page_url) when is_binary(page_url) do |> parse_uri(page_url) end - defp validate_page_url(%URI{host: host, scheme: scheme, authority: authority}) - when scheme == "https" and not is_nil(authority) do + defp validate_page_url(%URI{host: host, scheme: "https", authority: authority}) + when is_binary(authority) do cond do host in Config.get([:rich_media, :ignore_hosts], []) -> :error diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 40980def8..d9b5068b1 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -91,7 +91,7 @@ defp parse_url(url) do html |> parse_html() |> maybe_parse() - |> Map.put(:url, url) + |> Map.put("url", url) |> clean_parsed_data() |> check_parsed_data() rescue @@ -111,8 +111,8 @@ defp maybe_parse(html) do end) end - defp check_parsed_data(%{title: title} = data) - when is_binary(title) and byte_size(title) > 0 do + defp check_parsed_data(%{"title" => title} = data) + when is_binary(title) and title != "" do {:ok, data} end @@ -123,11 +123,7 @@ defp check_parsed_data(data) do defp clean_parsed_data(data) do data |> Enum.reject(fn {key, val} -> - with {:ok, _} <- Jason.encode(%{key => val}) do - false - else - _ -> true - end + not match?({:ok, _}, Jason.encode(%{key => val})) end) |> Map.new() end diff --git a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex index ae0f36702..2762b5902 100644 --- a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex @@ -29,19 +29,19 @@ defp normalize_attributes(html_node, prefix, key_name, value_name) do {_tag, attributes, _children} = html_node data = - Enum.into(attributes, %{}, fn {name, value} -> + Map.new(attributes, fn {name, value} -> {name, String.trim_leading(value, "#{prefix}:")} end) - %{String.to_atom(data[key_name]) => data[value_name]} + %{data[key_name] => data[value_name]} end - defp maybe_put_title(%{title: _} = meta, _), do: meta + defp maybe_put_title(%{"title" => _} = meta, _), do: meta defp maybe_put_title(meta, html) when meta != %{} do case get_page_title(html) do "" -> meta - title -> Map.put_new(meta, :title, title) + title -> Map.put_new(meta, "title", title) end end diff --git a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex index 8f32bf91b..db8ccf15d 100644 --- a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.OEmbed do def parse(html, _data) do with elements = [_ | _] <- get_discovery_data(html), - {:ok, oembed_url} <- get_oembed_url(elements), + oembed_url when is_binary(oembed_url) <- get_oembed_url(elements), {:ok, oembed_data} <- get_oembed_data(oembed_url) do {:ok, oembed_data} else @@ -17,19 +17,13 @@ defp get_discovery_data(html) do html |> Floki.find("link[type='application/json+oembed']") end - defp get_oembed_url(nodes) do - {"link", attributes, _children} = nodes |> hd() - - {:ok, Enum.into(attributes, %{})["href"]} + defp get_oembed_url([{"link", attributes, _children} | _]) do + Enum.find_value(attributes, fn {k, v} -> if k == "href", do: v end) end defp get_oembed_data(url) do - {:ok, %Tesla.Env{body: json}} = Pleroma.HTTP.get(url, [], adapter: [pool: :media]) - - {:ok, data} = Jason.decode(json) - - data = data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) - - {:ok, data} + with {:ok, %Tesla.Env{body: json}} <- Pleroma.HTTP.get(url, [], adapter: [pool: :media]) do + Jason.decode(json) + end end end diff --git a/test/web/rich_media/parser_test.exs b/test/web/rich_media/parser_test.exs index e54a13bc8..420a612c6 100644 --- a/test/web/rich_media/parser_test.exs +++ b/test/web/rich_media/parser_test.exs @@ -60,19 +60,19 @@ test "returns error when no metadata present" do test "doesn't just add a title" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/non-ogp") == {:error, - "Found metadata was invalid or incomplete: %{url: \"http://example.com/non-ogp\"}"} + "Found metadata was invalid or incomplete: %{\"url\" => \"http://example.com/non-ogp\"}"} end test "parses ogp" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/ogp") == {:ok, %{ - image: "http://ia.media-imdb.com/images/rock.jpg", - title: "The Rock", - description: + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "title" => "The Rock", + "description" => "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer.", - type: "video.movie", - url: "http://example.com/ogp" + "type" => "video.movie", + "url" => "http://example.com/ogp" }} end @@ -80,12 +80,12 @@ test "falls back to when ogp:title is missing" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/ogp-missing-title") == {:ok, %{ - image: "http://ia.media-imdb.com/images/rock.jpg", - title: "The Rock (1996)", - description: + "image" => "http://ia.media-imdb.com/images/rock.jpg", + "title" => "The Rock (1996)", + "description" => "Directed by Michael Bay. With Sean Connery, Nicolas Cage, Ed Harris, John Spencer.", - type: "video.movie", - url: "http://example.com/ogp-missing-title" + "type" => "video.movie", + "url" => "http://example.com/ogp-missing-title" }} end @@ -93,12 +93,12 @@ test "parses twitter card" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/twitter-card") == {:ok, %{ - card: "summary", - site: "@flickr", - image: "https://farm6.staticflickr.com/5510/14338202952_93595258ff_z.jpg", - title: "Small Island Developing States Photo Submission", - description: "View the album on Flickr.", - url: "http://example.com/twitter-card" + "card" => "summary", + "site" => "@flickr", + "image" => "https://farm6.staticflickr.com/5510/14338202952_93595258ff_z.jpg", + "title" => "Small Island Developing States Photo Submission", + "description" => "View the album on Flickr.", + "url" => "http://example.com/twitter-card" }} end @@ -106,27 +106,28 @@ test "parses OEmbed" do assert Pleroma.Web.RichMedia.Parser.parse("http://example.com/oembed") == {:ok, %{ - author_name: "‮‭‬bees‬", - author_url: "https://www.flickr.com/photos/bees/", - cache_age: 3600, - flickr_type: "photo", - height: "768", - html: + "author_name" => "‮‭‬bees‬", + "author_url" => "https://www.flickr.com/photos/bees/", + "cache_age" => 3600, + "flickr_type" => "photo", + "height" => "768", + "html" => "<a data-flickr-embed=\"true\" href=\"https://www.flickr.com/photos/bees/2362225867/\" title=\"Bacon Lollys by ‮‭‬bees‬, on Flickr\"><img src=\"https://farm4.staticflickr.com/3040/2362225867_4a87ab8baf_b.jpg\" width=\"1024\" height=\"768\" alt=\"Bacon Lollys\"></a><script async src=\"https://embedr.flickr.com/assets/client-code.js\" charset=\"utf-8\"></script>", - license: "All Rights Reserved", - license_id: 0, - provider_name: "Flickr", - provider_url: "https://www.flickr.com/", - thumbnail_height: 150, - thumbnail_url: "https://farm4.staticflickr.com/3040/2362225867_4a87ab8baf_q.jpg", - thumbnail_width: 150, - title: "Bacon Lollys", - type: "photo", - url: "http://example.com/oembed", - version: "1.0", - web_page: "https://www.flickr.com/photos/bees/2362225867/", - web_page_short_url: "https://flic.kr/p/4AK2sc", - width: "1024" + "license" => "All Rights Reserved", + "license_id" => 0, + "provider_name" => "Flickr", + "provider_url" => "https://www.flickr.com/", + "thumbnail_height" => 150, + "thumbnail_url" => + "https://farm4.staticflickr.com/3040/2362225867_4a87ab8baf_q.jpg", + "thumbnail_width" => 150, + "title" => "Bacon Lollys", + "type" => "photo", + "url" => "http://example.com/oembed", + "version" => "1.0", + "web_page" => "https://www.flickr.com/photos/bees/2362225867/", + "web_page_short_url" => "https://flic.kr/p/4AK2sc", + "width" => "1024" }} end diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs index 87c767c15..847623535 100644 --- a/test/web/rich_media/parsers/twitter_card_test.exs +++ b/test/web/rich_media/parsers/twitter_card_test.exs @@ -19,11 +19,11 @@ test "parses twitter card with only name attributes" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - site: nil, - title: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times" }} end @@ -36,15 +36,15 @@ test "parses twitter card with only property attributes" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - card: "summary_large_image", - description: + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - title: + "image:alt" => "", + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" }} end @@ -57,19 +57,19 @@ test "parses twitter card with name & property attributes" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - card: "summary_large_image", - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - site: nil, - title: + "image:alt" => "", + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" }} end @@ -86,11 +86,11 @@ test "respect only first title tag on the page" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - site: "@atlasobscura", - title: + "site" => "@atlasobscura", + "title" => "The Missing Grave of Margaret Corbin, Revolutionary War Veteran - Atlas Obscura", - card: "summary_large_image", - image: image_path + "card" => "summary_large_image", + "image" => image_path }} end @@ -102,12 +102,12 @@ test "takes first founded title in html head if there is html markup error" do assert TwitterCard.parse(html, %{}) == {:ok, %{ - site: nil, - title: + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database. - The New York Times", - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622" + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622" }} end end -- cgit v1.2.3 From cb7be6eef252216d7ba5d5f72c8005d66b04986c Mon Sep 17 00:00:00 2001 From: href <href@random.sh> Date: Wed, 10 Jun 2020 17:34:23 +0200 Subject: Remove use of atoms in MRF.UserAllowListPolicy --- config/description.exs | 6 ++---- docs/configuration/cheatsheet.md | 5 +++-- lib/pleroma/config/deprecation_warnings.ex | 25 +++++++++++++++++++++- .../web/activity_pub/mrf/user_allow_list_policy.ex | 2 +- .../mrf/user_allowlist_policy_test.exs | 6 +++--- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/config/description.exs b/config/description.exs index add1601e2..2f1eaf5f2 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1623,14 +1623,12 @@ # %{ # group: :pleroma, # key: :mrf_user_allowlist, - # type: :group, + # type: :map, # description: # "The keys in this section are the domain names that the policy should apply to." <> # " Each key should be assigned a list of users that should be allowed through by their ActivityPub ID", - # children: [ - # ["example.org": ["https://example.org/users/admin"]], # suggestions: [ - # ["example.org": ["https://example.org/users/admin"]] + # %{"example.org" => ["https://example.org/users/admin"]} # ] # ] # }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 456762151..fad67fc4d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -138,8 +138,9 @@ their ActivityPub ID. An example: ```elixir -config :pleroma, :mrf_user_allowlist, - "example.org": ["https://example.org/users/admin"] +config :pleroma, :mrf_user_allowlist, %{ + "example.org" => ["https://example.org/users/admin"] +} ``` #### :mrf_object_age diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index c39a8984b..b68ded01f 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -4,9 +4,10 @@ defmodule Pleroma.Config.DeprecationWarnings do require Logger + alias Pleroma.Config def check_hellthread_threshold do - if Pleroma.Config.get([:mrf_hellthread, :threshold]) do + if Config.get([:mrf_hellthread, :threshold]) do Logger.warn(""" !!!DEPRECATION WARNING!!! You are using the old configuration mechanism for the hellthread filter. Please check config.md. @@ -14,7 +15,29 @@ def check_hellthread_threshold do end end + def mrf_user_allowlist do + config = Config.get(:mrf_user_allowlist) + + if config && Enum.any?(config, fn {k, _} -> is_atom(k) end) do + rewritten = + Enum.reduce(Config.get(:mrf_user_allowlist), Map.new(), fn {k, v}, acc -> + Map.put(acc, to_string(k), v) + end) + + Config.put(:mrf_user_allowlist, rewritten) + + Logger.error(""" + !!!DEPRECATION WARNING!!! + As of Pleroma 2.0.7, the `mrf_user_allowlist` setting changed of format. + Pleroma 2.1 will remove support for the old format. Please change your configuration to match this: + + config :pleroma, :mrf_user_allowlist, #{inspect(rewritten, pretty: true)} + """) + end + end + def warn do check_hellthread_threshold() + mrf_user_allowlist() end end diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index a927a4ed8..651aed70f 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -24,7 +24,7 @@ def filter(%{"actor" => actor} = object) do allow_list = Config.get( - [:mrf_user_allowlist, String.to_atom(actor_info.host)], + [:mrf_user_allowlist, actor_info.host], [] ) diff --git a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs index 724bae058..ba1b69658 100644 --- a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs +++ b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicyTest do alias Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy - setup do: clear_config([:mrf_user_allowlist, :localhost]) + setup do: clear_config(:mrf_user_allowlist) test "pass filter if allow list is empty" do actor = insert(:user) @@ -17,14 +17,14 @@ test "pass filter if allow list is empty" do test "pass filter if allow list isn't empty and user in allow list" do actor = insert(:user) - Pleroma.Config.put([:mrf_user_allowlist, :localhost], [actor.ap_id, "test-ap-id"]) + Pleroma.Config.put([:mrf_user_allowlist], %{"localhost" => [actor.ap_id, "test-ap-id"]}) message = %{"actor" => actor.ap_id} assert UserAllowListPolicy.filter(message) == {:ok, message} end test "rejected if allow list isn't empty and user not in allow list" do actor = insert(:user) - Pleroma.Config.put([:mrf_user_allowlist, :localhost], ["test-ap-id"]) + Pleroma.Config.put([:mrf_user_allowlist], %{"localhost" => ["test-ap-id"]}) message = %{"actor" => actor.ap_id} assert UserAllowListPolicy.filter(message) == {:reject, nil} end -- cgit v1.2.3 From 4b865bba107b0db1de886cefd14227454cbece1e Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 13 Jun 2020 10:37:15 +0000 Subject: Apply suggestion to lib/pleroma/web/controller_helper.ex --- lib/pleroma/web/controller_helper.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index a5eb3e9e0..d5e9c33f5 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -75,7 +75,7 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do # instead of the `q.id > ^min_id` and `q.id < ^max_id`. # This is because we only have ids present inside of the page, while # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, @id_keys) != [] do + if Map.take(conn.params, @id_keys) != %{} do Map.put(fields, "id", current_url(conn)) else fields -- cgit v1.2.3 From 1d625c29a09cf7c0fb415d5606a91315902efaad Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 13 Jun 2020 13:12:43 +0200 Subject: ControllerHelper: Always return id field. --- lib/pleroma/web/controller_helper.ex | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index d5e9c33f5..69946fb81 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -65,21 +65,11 @@ defp build_pagination_fields(conn, min_id, max_id, extra_params) do |> Map.merge(extra_params) |> Map.drop(@id_keys) - fields = %{ + %{ "next" => current_url(conn, Map.put(params, :max_id, max_id)), - "prev" => current_url(conn, Map.put(params, :min_id, min_id)) + "prev" => current_url(conn, Map.put(params, :min_id, min_id)), + "id" => current_url(conn) } - - # Generating an `id` without already present pagination keys would - # need a query-restriction with an `q.id >= ^id` or `q.id <= ^id` - # instead of the `q.id > ^min_id` and `q.id < ^max_id`. - # This is because we only have ids present inside of the page, while - # `min_id`, `since_id` and `max_id` requires to know one outside of it. - if Map.take(conn.params, @id_keys) != %{} do - Map.put(fields, "id", current_url(conn)) - else - fields - end end def get_pagination_fields(conn, activities, extra_params \\ %{}) do -- cgit v1.2.3 From b15cfc3d365dcfa5f99159fe06e29de6f8aceb4f Mon Sep 17 00:00:00 2001 From: eugenijm <eugenijm@protonmail.com> Date: Mon, 18 May 2020 18:46:04 +0300 Subject: Mastodon API: ensure the notification endpoint doesn't return less than the requested amount of records unless it's the last page --- CHANGELOG.md | 1 + lib/pleroma/notification.ex | 19 +++++- lib/pleroma/user.ex | 8 +++ .../web/mastodon_api/views/notification_view.ex | 68 ++++++++++------------ ...5_delete_notifications_from_invisible_users.exs | 18 ++++++ test/notification_test.exs | 8 +++ .../controllers/notification_controller_test.exs | 27 +++++++++ .../mastodon_api/views/notification_view_test.exs | 4 +- 8 files changed, 112 insertions(+), 41 deletions(-) create mode 100644 priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9361fa260..b3f2dd10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Filtering of push notifications on activities from blocked domains - Resolving Peertube accounts with Webfinger - `blob:` urls not being allowed by connect-src CSP +- Mastodon API: fix `GET /api/v1/notifications` not returning the full result set ## [Unreleased (patch)] diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 3386a1933..9ee9606be 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -166,8 +166,16 @@ defp exclude_visibility(query, %{exclude_visibilities: visibility}) query |> join(:left, [n, a], mutated_activity in Pleroma.Activity, on: - fragment("?->>'context'", a.data) == - fragment("?->>'context'", mutated_activity.data) and + fragment( + "COALESCE((?->'object')->>'id', ?->>'object')", + a.data, + a.data + ) == + fragment( + "COALESCE((?->'object')->>'id', ?->>'object')", + mutated_activity.data, + mutated_activity.data + ) and fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and fragment("?->>'type'", mutated_activity.data) == "Create", as: :mutated_activity @@ -541,6 +549,7 @@ def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do def skip?(%Activity{} = activity, %User{} = user) do [ :self, + :invisible, :followers, :follows, :non_followers, @@ -557,6 +566,12 @@ def skip?(:self, %Activity{} = activity, %User{} = user) do activity.data["actor"] == user.ap_id end + def skip?(:invisible, %Activity{} = activity, _) do + actor = activity.data["actor"] + user = User.get_cached_by_ap_id(actor) + User.invisible?(user) + end + def skip?( :followers, %Activity{} = activity, diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c5c74d132..52ac9052b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1488,6 +1488,7 @@ def perform(:delete, %User{} = user) do end) delete_user_activities(user) + delete_notifications_from_user_activities(user) delete_outgoing_pending_follow_requests(user) @@ -1576,6 +1577,13 @@ def follow_import(%User{} = follower, followed_identifiers) }) end + def delete_notifications_from_user_activities(%User{ap_id: ap_id}) do + Notification + |> join(:inner, [n], activity in assoc(n, :activity)) + |> where([n, a], fragment("? = ?", a.actor, ^ap_id)) + |> Repo.delete_all() + end + def delete_user_activities(%User{ap_id: ap_id} = user) do ap_id |> Activity.Queries.by_actor() diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index b11578623..3865be280 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -46,6 +46,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op activities |> Enum.filter(&(&1.data["type"] == "Move")) |> Enum.map(&User.get_cached_by_ap_id(&1.data["target"])) + |> Enum.filter(& &1) actors = activities @@ -84,50 +85,45 @@ def render( # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} - with %{id: _} = account <- - AccountView.render( - "show.json", - %{user: actor, for: reading_user} - ) do - response = %{ - id: to_string(notification.id), - type: notification.type, - created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), - account: account, - pleroma: %{ - is_seen: notification.seen - } + account = + AccountView.render( + "show.json", + %{user: actor, for: reading_user} + ) + + response = %{ + id: to_string(notification.id), + type: notification.type, + created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), + account: account, + pleroma: %{ + is_seen: notification.seen } + } - case notification.type do - "mention" -> - put_status(response, activity, reading_user, status_render_opts) + case notification.type do + "mention" -> + put_status(response, activity, reading_user, status_render_opts) - "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + "favourite" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) + "reblog" -> + put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - "move" -> - put_target(response, activity, reading_user, %{}) + "move" -> + put_target(response, activity, reading_user, %{}) - "pleroma:emoji_reaction" -> - response - |> put_status(parent_activity_fn.(), reading_user, status_render_opts) - |> put_emoji(activity) + "pleroma:emoji_reaction" -> + response + |> put_status(parent_activity_fn.(), reading_user, status_render_opts) + |> put_emoji(activity) - "pleroma:chat_mention" -> - put_chat_message(response, activity, reading_user, status_render_opts) + "pleroma:chat_mention" -> + put_chat_message(response, activity, reading_user, status_render_opts) - type when type in ["follow", "follow_request"] -> - response - - _ -> - nil - end - else - _ -> nil + type when type in ["follow", "follow_request"] -> + response end end diff --git a/priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs b/priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs new file mode 100644 index 000000000..9e95a8111 --- /dev/null +++ b/priv/repo/migrations/20200527163635_delete_notifications_from_invisible_users.exs @@ -0,0 +1,18 @@ +defmodule Pleroma.Repo.Migrations.DeleteNotificationsFromInvisibleUsers do + use Ecto.Migration + + import Ecto.Query + alias Pleroma.Repo + + def up do + Pleroma.Notification + |> join(:inner, [n], activity in assoc(n, :activity)) + |> where( + [n, a], + fragment("? in (SELECT ap_id FROM users WHERE invisible = true)", a.actor) + ) + |> Repo.delete_all() + end + + def down, do: :ok +end diff --git a/test/notification_test.exs b/test/notification_test.exs index b9bbdceca..526f43fab 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -306,6 +306,14 @@ test "it doesn't create subscription notifications if the recipient cannot see t assert {:ok, []} == Notification.create_notifications(status) end + + test "it disables notifications from people who are invisible" do + author = insert(:user, invisible: true) + user = insert(:user) + + {:ok, status} = CommonAPI.post(author, %{status: "hey @#{user.nickname}"}) + refute Notification.create_notification(status, user) + end end describe "follow / follow_request notifications" do diff --git a/test/web/mastodon_api/controllers/notification_controller_test.exs b/test/web/mastodon_api/controllers/notification_controller_test.exs index 698c99711..70ef0e8b5 100644 --- a/test/web/mastodon_api/controllers/notification_controller_test.exs +++ b/test/web/mastodon_api/controllers/notification_controller_test.exs @@ -313,6 +313,33 @@ test "filters notifications for Announce activities" do assert public_activity.id in activity_ids refute unlisted_activity.id in activity_ids end + + test "doesn't return less than the requested amount of records when the user's reply is liked" do + user = insert(:user) + %{user: other_user, conn: conn} = oauth_access(["read:notifications"]) + + {:ok, mention} = + CommonAPI.post(user, %{status: "@#{other_user.nickname}", visibility: "public"}) + + {:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + + {:ok, reply} = + CommonAPI.post(other_user, %{ + status: ".", + visibility: "public", + in_reply_to_status_id: activity.id + }) + + {:ok, _favorite} = CommonAPI.favorite(user, reply.id) + + activity_ids = + conn + |> get("/api/v1/notifications?exclude_visibilities[]=direct&limit=2") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["status"]["id"]) + + assert [reply.id, mention.id] == activity_ids + end end test "filters notifications using exclude_types" do diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index b2fa5b302..9c399b2df 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -139,9 +139,7 @@ test "Follow notification" do test_notifications_rendering([notification], followed, [expected]) User.perform(:delete, follower) - notification = Notification |> Repo.one() |> Repo.preload(:activity) - - test_notifications_rendering([notification], followed, []) + refute Repo.one(Notification) end @tag capture_log: true -- cgit v1.2.3 From 2e8a236cef28c0b754aecb04a5c60c3b7655c5a6 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Sun, 14 Jun 2020 21:02:57 +0300 Subject: fix invalidates media url's --- config/config.exs | 7 ++ config/description.exs | 64 ++++++++++ docs/configuration/cheatsheet.md | 6 +- installation/nginx-cache-purge.sh.example | 4 +- lib/pleroma/application.ex | 3 +- lib/pleroma/plugs/uploaded_media.ex | 16 ++- lib/pleroma/web/media_proxy/invalidation.ex | 29 +++-- lib/pleroma/web/media_proxy/invalidations/http.ex | 8 +- .../web/media_proxy/invalidations/script.ex | 36 +++--- lib/pleroma/web/media_proxy/media_proxy.ex | 35 +++++- .../web/media_proxy/media_proxy_controller.ex | 3 +- lib/pleroma/workers/attachments_cleanup_worker.ex | 129 +++++++++++---------- test/web/media_proxy/invalidation_test.exs | 65 +++++++++++ test/web/media_proxy/invalidations/http_test.exs | 13 ++- test/web/media_proxy/invalidations/script_test.exs | 21 ++-- .../media_proxy/media_proxy_controller_test.exs | 17 +++ 16 files changed, 344 insertions(+), 112 deletions(-) create mode 100644 test/web/media_proxy/invalidation_test.exs diff --git a/config/config.exs b/config/config.exs index 9508ae077..e299fb8dd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -406,6 +406,13 @@ ], whitelist: [] +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http, + method: :purge, + headers: [], + options: [] + +config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, script_path: nil + config :pleroma, :chat, enabled: true config :phoenix, :format_encoders, json: Jason diff --git a/config/description.exs b/config/description.exs index 807c945e0..857293794 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1637,6 +1637,31 @@ "The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.", suggestions: ["https://example.com"] }, + %{ + key: :invalidation, + type: :keyword, + descpiption: "", + suggestions: [ + enabled: true, + provider: Pleroma.Web.MediaProxy.Invalidation.Script + ], + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables invalidate media cache" + }, + %{ + key: :provider, + type: :module, + description: "Module which will be used to cache purge.", + suggestions: [ + Pleroma.Web.MediaProxy.Invalidation.Script, + Pleroma.Web.MediaProxy.Invalidation.Http + ] + } + ] + }, %{ key: :proxy_opts, type: :keyword, @@ -1709,6 +1734,45 @@ } ] }, + %{ + group: :pleroma, + key: Pleroma.Web.MediaProxy.Invalidation.Http, + type: :group, + description: "HTTP invalidate settings", + children: [ + %{ + key: :method, + type: :atom, + description: "HTTP method of request. Default: :purge" + }, + %{ + key: :headers, + type: {:list, :tuple}, + description: "HTTP headers of request.", + suggestions: [{"x-refresh", 1}] + }, + %{ + key: :options, + type: :keyword, + description: "Request options.", + suggestions: [params: %{ts: "xxx"}] + } + ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.MediaProxy.Invalidation.Script, + type: :group, + description: "Script invalidate settings", + children: [ + %{ + key: :script_path, + type: :string, + description: "Path to shell script. Which will run purge cache.", + suggestions: ["./installation/nginx-cache-purge.sh.example"] + } + ] + }, %{ group: :pleroma, key: :gopher, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 505acb293..20bd0ed85 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -262,7 +262,7 @@ This section describe PWA manifest instance-specific values. Currently this opti #### Pleroma.Web.MediaProxy.Invalidation.Script -This strategy allow perform external bash script to purge cache. +This strategy allow perform external shell script to purge cache. Urls of attachments pass to script as arguments. * `script_path`: path to external script. @@ -278,8 +278,8 @@ config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, This strategy allow perform custom http request to purge cache. * `method`: http method. default is `purge` -* `headers`: http headers. default is empty -* `options`: request options. default is empty +* `headers`: http headers. +* `options`: request options. Example: ```elixir diff --git a/installation/nginx-cache-purge.sh.example b/installation/nginx-cache-purge.sh.example index b2915321c..5f6cbb128 100755 --- a/installation/nginx-cache-purge.sh.example +++ b/installation/nginx-cache-purge.sh.example @@ -13,7 +13,7 @@ CACHE_DIRECTORY="/tmp/pleroma-media-cache" ## $3 - (optional) the number of parallel processes to run for grep. get_cache_files() { local max_parallel=${3-16} - find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E Rl "^KEY:.*$1" | sort -u + find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E -Rl "^KEY:.*$1" | sort -u } ## Removes an item from the given cache zone. @@ -37,4 +37,4 @@ purge() { } -purge $1 +purge $@ diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9d3d92b38..adebebc7a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -148,7 +148,8 @@ defp cachex_children do build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500), build_cachex("web_resp", limit: 2500), build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), - build_cachex("failed_proxy_url", limit: 2500) + build_cachex("failed_proxy_url", limit: 2500), + build_cachex("deleted_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) ] end diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 94147e0c4..2f3fde002 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -10,6 +10,8 @@ defmodule Pleroma.Plugs.UploadedMedia do import Pleroma.Web.Gettext require Logger + alias Pleroma.Web.MediaProxy + @behaviour Plug # no slashes @path "media" @@ -35,8 +37,7 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do %{query_params: %{"name" => name}} = conn -> name = String.replace(name, "\"", "\\\"") - conn - |> put_resp_header("content-disposition", "filename=\"#{name}\"") + put_resp_header(conn, "content-disposition", "filename=\"#{name}\"") conn -> conn @@ -47,7 +48,8 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do with uploader <- Keyword.fetch!(config, :uploader), proxy_remote = Keyword.get(config, :proxy_remote, false), - {:ok, get_method} <- uploader.get_file(file) do + {:ok, get_method} <- uploader.get_file(file), + false <- media_is_deleted(conn, get_method) do get_media(conn, get_method, proxy_remote, opts) else _ -> @@ -59,6 +61,14 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do def call(conn, _opts), do: conn + defp media_is_deleted(%{request_path: path} = _conn, {:static_dir, _}) do + MediaProxy.in_deleted_urls(Pleroma.Web.base_url() <> path) + end + + defp media_is_deleted(_, {:url, url}), do: MediaProxy.in_deleted_urls(url) + + defp media_is_deleted(_, _), do: false + defp get_media(conn, {:static_dir, directory}, _, opts) do static_opts = Map.get(opts, :static_plug_opts) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index c037ff13e..83ff8589c 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -5,22 +5,33 @@ defmodule Pleroma.Web.MediaProxy.Invalidation do @moduledoc false - @callback purge(list(String.t()), map()) :: {:ok, String.t()} | {:error, String.t()} + @callback purge(list(String.t()), Keyword.t()) :: {:ok, list(String.t())} | {:error, String.t()} alias Pleroma.Config + alias Pleroma.Web.MediaProxy - @spec purge(list(String.t())) :: {:ok, String.t()} | {:error, String.t()} + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:media_proxy, :invalidation, :enabled]) + + @spec purge(list(String.t()) | String.t()) :: {:ok, list(String.t())} | {:error, String.t()} def purge(urls) do - [:media_proxy, :invalidation, :enabled] - |> Config.get() - |> do_purge(urls) + prepared_urls = prepare_urls(urls) + + if enabled?() do + do_purge(prepared_urls) + else + {:ok, prepared_urls} + end end - defp do_purge(true, urls) do + defp do_purge(urls) do provider = Config.get([:media_proxy, :invalidation, :provider]) - options = Config.get(provider) - provider.purge(urls, options) + provider.purge(urls, Config.get(provider)) end - defp do_purge(_, _), do: :ok + def prepare_urls(urls) do + urls + |> List.wrap() + |> Enum.map(&MediaProxy.url(&1)) + end end diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 07248df6e..3694b56e8 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -10,9 +10,9 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do @impl Pleroma.Web.MediaProxy.Invalidation def purge(urls, opts) do - method = Map.get(opts, :method, :purge) - headers = Map.get(opts, :headers, []) - options = Map.get(opts, :options, []) + method = Keyword.get(opts, :method, :purge) + headers = Keyword.get(opts, :headers, []) + options = Keyword.get(opts, :options, []) Logger.debug("Running cache purge: #{inspect(urls)}") @@ -22,7 +22,7 @@ def purge(urls, opts) do end end) - {:ok, "success"} + {:ok, urls} end defp do_purge(method, url, headers, options) do diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index 6be782132..d41d647bb 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -10,32 +10,34 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, %{script_path: script_path} = _options) do + def purge(urls, opts) do args = urls |> List.wrap() |> Enum.uniq() |> Enum.join(" ") - path = Path.expand(script_path) - - Logger.debug("Running cache purge: #{inspect(urls)}, #{path}") - - case do_purge(path, [args]) do - {result, exit_status} when exit_status > 0 -> - Logger.error("Error while cache purge: #{inspect(result)}") - {:error, inspect(result)} - - _ -> - {:ok, "success"} - end + opts + |> Keyword.get(:script_path, nil) + |> do_purge([args]) + |> handle_result(urls) end - def purge(_, _), do: {:error, "not found script path"} - - defp do_purge(path, args) do + defp do_purge(script_path, args) when is_binary(script_path) do + path = Path.expand(script_path) + Logger.debug("Running cache purge: #{inspect(args)}, #{inspect(path)}") System.cmd(path, args) rescue - error -> {inspect(error), 1} + error -> error + end + + defp do_purge(_, _), do: {:error, "not found script path"} + + defp handle_result({_result, 0}, urls), do: {:ok, urls} + defp handle_result({:error, error}, urls), do: handle_result(error, urls) + + defp handle_result(error, _) do + Logger.error("Error while cache purge: #{inspect(error)}") + {:error, inspect(error)} end end diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index b2b524524..59ca217ab 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -6,20 +6,53 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Config alias Pleroma.Upload alias Pleroma.Web + alias Pleroma.Web.MediaProxy.Invalidation @base64_opts [padding: false] + @spec in_deleted_urls(String.t()) :: boolean() + def in_deleted_urls(url), do: elem(Cachex.exists?(:deleted_urls_cache, url(url)), 1) + + def remove_from_deleted_urls(urls) when is_list(urls) do + Cachex.execute!(:deleted_urls_cache, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &Cachex.del(cache, &1)) + end) + end + + def remove_from_deleted_urls(url) when is_binary(url) do + Cachex.del(:deleted_urls_cache, url(url)) + end + + def put_in_deleted_urls(urls) when is_list(urls) do + Cachex.execute!(:deleted_urls_cache, fn cache -> + Enum.each(Invalidation.prepare_urls(urls), &Cachex.put(cache, &1, true)) + end) + end + + def put_in_deleted_urls(url) when is_binary(url) do + Cachex.put(:deleted_urls_cache, url(url), true) + end + def url(url) when is_nil(url) or url == "", do: nil def url("/" <> _ = url), do: url def url(url) do - if disabled?() or local?(url) or whitelisted?(url) do + if disabled?() or not is_url_proxiable?(url) do url else encode_url(url) end end + @spec is_url_proxiable?(String.t()) :: boolean() + def is_url_proxiable?(url) do + if local?(url) or whitelisted?(url) do + false + else + true + end + end + defp disabled?, do: !Config.get([:media_proxy, :enabled], false) defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 4657a4383..ff0158d83 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -14,10 +14,11 @@ def remote(conn, %{"sig" => sig64, "url" => url64} = params) do with config <- Pleroma.Config.get([:media_proxy], []), true <- Keyword.get(config, :enabled, false), {:ok, url} <- MediaProxy.decode_url(sig64, url64), + {_, false} <- {:in_deleted_urls, MediaProxy.in_deleted_urls(url)}, :ok <- filename_matches(params, conn.request_path, url) do ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts)) else - false -> + error when error in [false, {:in_deleted_urls, true}] -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex index 49352db2a..4ad19c0fc 100644 --- a/lib/pleroma/workers/attachments_cleanup_worker.ex +++ b/lib/pleroma/workers/attachments_cleanup_worker.ex @@ -23,8 +23,25 @@ def perform( Enum.map(attachment["url"], & &1["href"]) end) - names = Enum.map(attachments, & &1["name"]) + # find all objects for copies of the attachments, name and actor doesn't matter here + hrefs + |> fetch_objects + |> prepare_objects(actor, Enum.map(attachments, & &1["name"])) + |> Enum.reduce({[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> + with 1 <- count do + {ids ++ [id], hrefs ++ [href]} + else + _ -> {ids ++ [id], hrefs} + end + end) + |> do_clean + + {:ok, :success} + end + def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: {:ok, :skip} + + defp do_clean({object_ids, attachment_urls}) do uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) prefix = @@ -39,68 +56,60 @@ def perform( "/" ) - # find all objects for copies of the attachments, name and actor doesn't matter here - object_ids_and_hrefs = - from(o in Object, - where: - fragment( - "to_jsonb(array(select jsonb_array_elements((?)#>'{url}') ->> 'href' where jsonb_typeof((?)#>'{url}') = 'array'))::jsonb \\?| (?)", - o.data, - o.data, - ^hrefs - ) - ) - # The query above can be time consumptive on large instances until we - # refactor how uploads are stored - |> Repo.all(timeout: :infinity) - # we should delete 1 object for any given attachment, but don't delete - # files if there are more than 1 object for it - |> Enum.reduce(%{}, fn %{ - id: id, - data: %{ - "url" => [%{"href" => href}], - "actor" => obj_actor, - "name" => name - } - }, - acc -> - Map.update(acc, href, %{id: id, count: 1}, fn val -> - case obj_actor == actor and name in names do - true -> - # set id of the actor's object that will be deleted - %{val | id: id, count: val.count + 1} - - false -> - # another actor's object, just increase count to not delete file - %{val | count: val.count + 1} - end - end) - end) - |> Enum.map(fn {href, %{id: id, count: count}} -> - # only delete files that have single instance - with 1 <- count do - href - |> String.trim_leading("#{base_url}/#{prefix}") - |> uploader.delete_file() - - {id, href} - else - _ -> {id, nil} - end - end) - - object_ids = Enum.map(object_ids_and_hrefs, fn {id, _} -> id end) + Enum.each(attachment_urls, fn href -> + href + |> String.trim_leading("#{base_url}/#{prefix}") + |> uploader.delete_file() + end) - from(o in Object, where: o.id in ^object_ids) - |> Repo.delete_all() + delete_objects(object_ids) + end - object_ids_and_hrefs - |> Enum.filter(fn {_, href} -> not is_nil(href) end) - |> Enum.map(&elem(&1, 1)) - |> Pleroma.Web.MediaProxy.Invalidation.purge() + defp delete_objects([_ | _] = object_ids) do + Repo.delete_all(from(o in Object, where: o.id in ^object_ids)) + end - {:ok, :success} + defp delete_objects(_), do: :ok + + # we should delete 1 object for any given attachment, but don't delete + # files if there are more than 1 object for it + def prepare_objects(objects, actor, names) do + objects + |> Enum.reduce(%{}, fn %{ + id: id, + data: %{ + "url" => [%{"href" => href}], + "actor" => obj_actor, + "name" => name + } + }, + acc -> + Map.update(acc, href, %{id: id, count: 1}, fn val -> + case obj_actor == actor and name in names do + true -> + # set id of the actor's object that will be deleted + %{val | id: id, count: val.count + 1} + + false -> + # another actor's object, just increase count to not delete file + %{val | count: val.count + 1} + end + end) + end) end - def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: {:ok, :skip} + def fetch_objects(hrefs) do + from(o in Object, + where: + fragment( + "to_jsonb(array(select jsonb_array_elements((?)#>'{url}') ->> 'href' where jsonb_typeof((?)#>'{url}') = 'array'))::jsonb \\?| (?)", + o.data, + o.data, + ^hrefs + ) + ) + # The query above can be time consumptive on large instances until we + # refactor how uploads are stored + |> Repo.all(timeout: :infinity) + end end diff --git a/test/web/media_proxy/invalidation_test.exs b/test/web/media_proxy/invalidation_test.exs new file mode 100644 index 000000000..3a9fa8c88 --- /dev/null +++ b/test/web/media_proxy/invalidation_test.exs @@ -0,0 +1,65 @@ +defmodule Pleroma.Web.MediaProxy.InvalidationTest do + use ExUnit.Case + use Pleroma.Tests.Helpers + + alias Pleroma.Config + alias Pleroma.Web.MediaProxy.Invalidation + + import ExUnit.CaptureLog + import Mock + import Tesla.Mock + + setup do: clear_config([:media_proxy]) + + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + + describe "Invalidation.Http" do + test "perform request to clear cache" do + Config.put([:media_proxy, :enabled], false) + Config.put([:media_proxy, :invalidation, :enabled], true) + Config.put([:media_proxy, :invalidation, :provider], Invalidation.Http) + + Config.put([Invalidation.Http], method: :purge, headers: [{"x-refresh", 1}]) + image_url = "http://example.com/media/example.jpg" + Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + + mock(fn + %{ + method: :purge, + url: "http://example.com/media/example.jpg", + headers: [{"x-refresh", 1}] + } -> + %Tesla.Env{status: 200} + end) + + assert capture_log(fn -> + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Invalidation.purge([image_url]) == {:ok, [image_url]} + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + end) =~ "Running cache purge: [\"#{image_url}\"]" + end + end + + describe "Invalidation.Script" do + test "run script to clear cache" do + Config.put([:media_proxy, :enabled], false) + Config.put([:media_proxy, :invalidation, :enabled], true) + Config.put([:media_proxy, :invalidation, :provider], Invalidation.Script) + Config.put([Invalidation.Script], script_path: "purge-nginx") + + image_url = "http://example.com/media/example.jpg" + Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + + with_mocks [{System, [], [cmd: fn _, _ -> {"ok", 0} end]}] do + assert capture_log(fn -> + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Invalidation.purge([image_url]) == {:ok, [image_url]} + assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + end) =~ "Running cache purge: [\"#{image_url}\"]" + end + end + end +end diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs index 8a3b4141c..09e7ca0fb 100644 --- a/test/web/media_proxy/invalidations/http_test.exs +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -5,6 +5,11 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do import ExUnit.CaptureLog import Tesla.Mock + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + test "logs hasn't error message when request is valid" do mock(fn %{method: :purge, url: "http://example.com/media/example.jpg"} -> @@ -14,8 +19,8 @@ test "logs hasn't error message when request is valid" do refute capture_log(fn -> assert Invalidation.Http.purge( ["http://example.com/media/example.jpg"], - %{} - ) == {:ok, "success"} + [] + ) == {:ok, ["http://example.com/media/example.jpg"]} end) =~ "Error while cache purge" end @@ -28,8 +33,8 @@ test "it write error message in logs when request invalid" do assert capture_log(fn -> assert Invalidation.Http.purge( ["http://example.com/media/example1.jpg"], - %{} - ) == {:ok, "success"} + [] + ) == {:ok, ["http://example.com/media/example1.jpg"]} end) =~ "Error while cache purge: url - http://example.com/media/example1.jpg" end end diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs index 1358963ab..c69cec07a 100644 --- a/test/web/media_proxy/invalidations/script_test.exs +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -4,17 +4,24 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do import ExUnit.CaptureLog + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + test "it logger error when script not found" do assert capture_log(fn -> assert Invalidation.Script.purge( ["http://example.com/media/example.jpg"], - %{script_path: "./example"} - ) == {:error, "\"%ErlangError{original: :enoent}\""} - end) =~ "Error while cache purge: \"%ErlangError{original: :enoent}\"" + script_path: "./example" + ) == {:error, "%ErlangError{original: :enoent}"} + end) =~ "Error while cache purge: %ErlangError{original: :enoent}" - assert Invalidation.Script.purge( - ["http://example.com/media/example.jpg"], - %{} - ) == {:error, "not found script path"} + capture_log(fn -> + assert Invalidation.Script.purge( + ["http://example.com/media/example.jpg"], + [] + ) == {:error, "\"not found script path\""} + end) end end diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index da79d38a5..2b6b25221 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -10,6 +10,11 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do setup do: clear_config(:media_proxy) setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base]) + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end + test "it returns 404 when MediaProxy disabled", %{conn: conn} do Config.put([:media_proxy, :enabled], false) @@ -66,4 +71,16 @@ test "it performs ReverseProxy.call when signature valid", %{conn: conn} do assert %Plug.Conn{status: :success} = get(conn, url) end end + + test "it returns 404 when url contains in deleted_urls cache", %{conn: conn} do + Config.put([:media_proxy, :enabled], true) + Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") + url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") + Pleroma.Web.MediaProxy.put_in_deleted_urls("https://google.fn/test.png") + + with_mock Pleroma.ReverseProxy, + call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do + assert %Plug.Conn{status: 404, resp_body: "Not Found"} = get(conn, url) + end + end end -- cgit v1.2.3 From b7df7436c813bfcb4f27ac64c85ebc1507153601 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 15 Jun 2020 12:27:13 +0200 Subject: Conversations: Return last dm for conversation, not last message. --- lib/pleroma/conversation/participation.ex | 11 +++++++---- lib/pleroma/web/activity_pub/activity_pub.ex | 7 ++++--- lib/pleroma/web/mastodon_api/views/conversation_view.ex | 11 +++++++---- test/web/mastodon_api/views/conversation_view_test.exs | 11 ++++++++++- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/conversation/participation.ex b/lib/pleroma/conversation/participation.ex index ce7bd2396..8bc3e85d6 100644 --- a/lib/pleroma/conversation/participation.ex +++ b/lib/pleroma/conversation/participation.ex @@ -162,10 +162,13 @@ def for_user_with_last_activity_id(user, params \\ %{}) do for_user(user, params) |> Enum.map(fn participation -> activity_id = - ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - user: user, - blocking_user: user - }) + ActivityPub.fetch_latest_direct_activity_id_for_context( + participation.conversation.ap_id, + %{ + user: user, + blocking_user: user + } + ) %{ participation diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c9dc6135c..3e4f3ad30 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -210,7 +210,7 @@ def stream_out_participations(%Object{data: %{"context" => context}}, user) do conversation = Repo.preload(conversation, :participations) last_activity_id = - fetch_latest_activity_id_for_context(conversation.ap_id, %{ + fetch_latest_direct_activity_id_for_context(conversation.ap_id, %{ user: user, blocking_user: user }) @@ -517,11 +517,12 @@ def fetch_activities_for_context(context, opts \\ %{}) do |> Repo.all() end - @spec fetch_latest_activity_id_for_context(String.t(), keyword() | map()) :: + @spec fetch_latest_direct_activity_id_for_context(String.t(), keyword() | map()) :: FlakeId.Ecto.CompatType.t() | nil - def fetch_latest_activity_id_for_context(context, opts \\ %{}) do + def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do context |> fetch_activities_for_context_query(Map.merge(%{skip_preload: true}, opts)) + |> restrict_visibility(%{visibility: "direct"}) |> limit(1) |> select([a], a.id) |> Repo.one() diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index fbe618377..06f0c1728 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -23,10 +23,13 @@ def render("participation.json", %{participation: participation, for: user}) do last_activity_id = with nil <- participation.last_activity_id do - ActivityPub.fetch_latest_activity_id_for_context(participation.conversation.ap_id, %{ - user: user, - blocking_user: user - }) + ActivityPub.fetch_latest_direct_activity_id_for_context( + participation.conversation.ap_id, + %{ + user: user, + blocking_user: user + } + ) end activity = Activity.get_by_id_with_object(last_activity_id) diff --git a/test/web/mastodon_api/views/conversation_view_test.exs b/test/web/mastodon_api/views/conversation_view_test.exs index 6f84366f8..2e8203c9b 100644 --- a/test/web/mastodon_api/views/conversation_view_test.exs +++ b/test/web/mastodon_api/views/conversation_view_test.exs @@ -15,8 +15,17 @@ test "represents a Mastodon Conversation entity" do user = insert(:user) other_user = insert(:user) + {:ok, parent} = CommonAPI.post(user, %{status: "parent"}) + {:ok, activity} = - CommonAPI.post(user, %{status: "hey @#{other_user.nickname}", visibility: "direct"}) + CommonAPI.post(user, %{ + status: "hey @#{other_user.nickname}", + visibility: "direct", + in_reply_to_id: parent.id + }) + + {:ok, _reply_activity} = + CommonAPI.post(user, %{status: "hu", visibility: "public", in_reply_to_id: parent.id}) [participation] = Participation.for_user_with_last_activity_id(user) -- cgit v1.2.3 From 1092b3650068169ece0ac95cd88ec0e4da30036b Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 15 Jun 2020 12:30:11 +0200 Subject: Changelog: Add info about conversation view changes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f2dd10f..c546f1f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- In Conversations, return only direct messages as `last_status` - MFR policy to set global expiration for all local Create activities <details> <summary>API Changes</summary> -- cgit v1.2.3 From 62b8c31b7a84dadb2a46861fe0f2dd1dbf9d40f0 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 15 Jun 2020 14:55:00 +0300 Subject: added tests --- .../controllers/media_proxy_cache_controller.ex | 31 +++++- .../web/media_proxy/invalidations/script.ex | 2 +- .../media_proxy_cache_controller_test.exs | 116 +++++++++++++++++---- 3 files changed, 127 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index 7b28f7c72..e3fa0ac28 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Web.ApiSpec.Admin, as: Spec + alias Pleroma.Web.MediaProxy plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -24,15 +25,39 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation - def index(%{assigns: %{user: _}} = conn, _) do - render(conn, "index.json", urls: []) + def index(%{assigns: %{user: _}} = conn, params) do + cursor = + :deleted_urls_cache + |> :ets.table([{:traverse, {:select, Cachex.Query.create(true, :key)}}]) + |> :qlc.cursor() + + urls = + case params.page do + 1 -> + :qlc.next_answers(cursor, params.page_size) + + _ -> + :qlc.next_answers(cursor, (params.page - 1) * params.page_size) + :qlc.next_answers(cursor, params.page_size) + end + + :qlc.delete_cursor(cursor) + + render(conn, "index.json", urls: urls) end def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do + MediaProxy.remove_from_deleted_urls(urls) render(conn, "index.json", urls: urls) end - def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: _ban}} = conn, _) do + def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _) do + MediaProxy.Invalidation.purge(urls) + + if ban do + MediaProxy.put_in_deleted_urls(urls) + end + render(conn, "index.json", urls: urls) end end diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index d41d647bb..0217b119d 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, opts) do + def purge(urls, opts \\ %{}) do args = urls |> List.wrap() diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 1b1d6bc36..76a96f46f 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -6,6 +6,16 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do use Pleroma.Web.ConnCase import Pleroma.Factory + import Mock + + alias Pleroma.Web.MediaProxy + + setup do: clear_config([:media_proxy]) + + setup do + on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + :ok + end setup do admin = insert(:user, is_admin: true) @@ -16,51 +26,121 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do |> assign(:user, admin) |> assign(:token, token) + Config.put([:media_proxy, :enabled], true) + Config.put([:media_proxy, :invalidation, :enabled], true) + Config.put([:media_proxy, :invalidation, :provider], MediaProxy.Invalidation.Script) + {:ok, %{admin: admin, token: token, conn: conn}} end describe "GET /api/pleroma/admin/media_proxy_caches" do test "shows banned MediaProxy URLs", %{conn: conn} do + MediaProxy.put_in_deleted_urls([ + "http://localhost:4001/media/a688346.jpg", + "http://localhost:4001/media/fb1f4d.jpg" + ]) + + MediaProxy.put_in_deleted_urls("http://localhost:4001/media/gb1f44.jpg") + MediaProxy.put_in_deleted_urls("http://localhost:4001/media/tb13f47.jpg") + MediaProxy.put_in_deleted_urls("http://localhost:4001/media/wb1f46.jpg") + + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2") + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://localhost:4001/media/fb1f4d.jpg", + "http://localhost:4001/media/a688346.jpg" + ] + response = conn - |> get("/api/pleroma/admin/media_proxy_caches") + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&page=2") |> json_response_and_validate_schema(200) - assert response["urls"] == [] + assert response["urls"] == [ + "http://localhost:4001/media/gb1f44.jpg", + "http://localhost:4001/media/tb13f47.jpg" + ] + + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&page=3") + |> json_response_and_validate_schema(200) + + assert response["urls"] == ["http://localhost:4001/media/wb1f46.jpg"] end end describe "DELETE /api/pleroma/admin/media_proxy_caches/delete" do test "deleted MediaProxy URLs from banned", %{conn: conn} do + MediaProxy.put_in_deleted_urls([ + "http://localhost:4001/media/a688346.jpg", + "http://localhost:4001/media/fb1f4d.jpg" + ]) + response = conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/media_proxy_caches/delete", %{ - urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] + urls: ["http://localhost:4001/media/a688346.jpg"] }) |> json_response_and_validate_schema(200) - assert response["urls"] == [ - "http://example.com/media/a688346.jpg", - "http://example.com/media/fb1f4d.jpg" - ] + assert response["urls"] == ["http://localhost:4001/media/a688346.jpg"] + refute MediaProxy.in_deleted_urls("http://localhost:4001/media/a688346.jpg") + assert MediaProxy.in_deleted_urls("http://localhost:4001/media/fb1f4d.jpg") end end describe "PURGE /api/pleroma/admin/media_proxy_caches/purge" do test "perform invalidates cache of MediaProxy", %{conn: conn} do - response = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ - urls: ["http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg"] - }) - |> json_response_and_validate_schema(200) + urls = [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] - assert response["urls"] == [ - "http://example.com/media/a688346.jpg", - "http://example.com/media/fb1f4d.jpg" - ] + with_mocks [ + {MediaProxy.Invalidation.Script, [], + [ + purge: fn _, _ -> {"ok", 0} end + ]} + ] do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{urls: urls, ban: false}) + |> json_response_and_validate_schema(200) + + assert response["urls"] == urls + + refute MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") + refute MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + end + end + + test "perform invalidates cache of MediaProxy and adds url to banned", %{conn: conn} do + urls = [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] + + with_mocks [{MediaProxy.Invalidation.Script, [], [purge: fn _, _ -> {"ok", 0} end]}] do + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ + urls: urls, + ban: true + }) + |> json_response_and_validate_schema(200) + + assert response["urls"] == urls + + assert MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") + assert MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + end end end end -- cgit v1.2.3 From bd63089a633099233d4fc19faece2796253a7ee0 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 15 Jun 2020 16:20:05 +0400 Subject: Fix tests --- test/web/rich_media/parsers/twitter_card_test.exs | 88 +++++++++++------------ 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/test/web/rich_media/parsers/twitter_card_test.exs b/test/web/rich_media/parsers/twitter_card_test.exs index 3ccf26651..219f005a2 100644 --- a/test/web/rich_media/parsers/twitter_card_test.exs +++ b/test/web/rich_media/parsers/twitter_card_test.exs @@ -17,18 +17,18 @@ test "parses twitter card with only name attributes" do assert TwitterCard.parse(html, %{}) == %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - site: nil, - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "site" => nil, + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", - type: "article", - url: + "type" => "article", + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", - title: + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database." } end @@ -40,17 +40,17 @@ test "parses twitter card with only property attributes" do assert TwitterCard.parse(html, %{}) == %{ - card: "summary_large_image", - description: + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - title: + "image:alt" => "", + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", - type: "article" + "type" => "article" } end @@ -61,21 +61,21 @@ test "parses twitter card with name & property attributes" do assert TwitterCard.parse(html, %{}) == %{ - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - card: "summary_large_image", - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "card" => "summary_large_image", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-videoSixteenByNineJumbo1600.jpg", - "image:alt": "", - site: nil, - title: + "image:alt" => "", + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - url: + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html", - type: "article" + "type" => "article" } end @@ -90,15 +90,15 @@ test "respect only first title tag on the page" do assert TwitterCard.parse(html, %{}) == %{ - site: "@atlasobscura", - title: "The Missing Grave of Margaret Corbin, Revolutionary War Veteran", - card: "summary_large_image", - image: image_path, - description: + "site" => "@atlasobscura", + "title" => "The Missing Grave of Margaret Corbin, Revolutionary War Veteran", + "card" => "summary_large_image", + "image" => image_path, + "description" => "She's the only woman veteran honored with a monument at West Point. But where was she buried?", - site_name: "Atlas Obscura", - type: "article", - url: "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" + "site_name" => "Atlas Obscura", + "type" => "article", + "url" => "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point" } end @@ -109,18 +109,18 @@ test "takes first founded title in html head if there is html markup error" do assert TwitterCard.parse(html, %{}) == %{ - site: nil, - title: + "site" => nil, + "title" => "She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.", - "app:id:googleplay": "com.nytimes.android", - "app:name:googleplay": "NYTimes", - "app:url:googleplay": "nytimes://reader/id/100000006583622", - description: + "app:id:googleplay" => "com.nytimes.android", + "app:name:googleplay" => "NYTimes", + "app:url:googleplay" => "nytimes://reader/id/100000006583622", + "description" => "With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.", - image: + "image" => "https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg", - type: "article", - url: + "type" => "article", + "url" => "https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html" } end -- cgit v1.2.3 From efdfc85c2d8e5118c1aa18e4f04026ec90cd11d2 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 15 Jun 2020 15:24:00 +0300 Subject: update docs --- docs/API/admin_api.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 92816baf9..6659b605d 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1224,4 +1224,66 @@ Loads json generated from `config/descriptions.exs`. - Response: - On success: `204`, empty response - On failure: - - 400 Bad Request `"Invalid parameters"` when `status` is missing \ No newline at end of file + - 400 Bad Request `"Invalid parameters"` when `status` is missing + +## `GET /api/pleroma/admin/media_proxy_caches` + +### Get a list of all banned MediaProxy URLs in Cachex + +- Authentication: required +- Params: +- *optional* `page`: **integer** page number +- *optional* `page_size`: **integer** number of log entries per page (default is `50`) + +- Response: + +``` json +{ + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` + +## `POST /api/pleroma/admin/media_proxy_caches/delete` + +### Remove a banned MediaProxy URL from Cachex + +- Authentication: required +- Params: + - `urls` + +- Response: + +``` json +{ + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` + +## `POST /api/pleroma/admin/media_proxy_caches/purge` + +### Purge a MediaProxy URL + +- Authentication: required +- Params: + - `urls` + - `ban` + +- Response: + +``` json +{ + "urls": [ + "http://example.com/media/a688346.jpg", + "http://example.com/media/fb1f4d.jpg" + ] +} + +``` -- cgit v1.2.3 From e1ee8bc1da17a356c88b535db7a9228fccc5251f Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 15 Jun 2020 14:29:34 +0200 Subject: User: update_follower_count refactor. --- lib/pleroma/user.ex | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 52ac9052b..39a9e13e8 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -747,7 +747,6 @@ def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do follower |> update_following_count() - |> set_cache() end end @@ -776,7 +775,6 @@ defp do_unfollow(%User{} = follower, %User{} = followed) do {:ok, follower} = follower |> update_following_count() - |> set_cache() {:ok, follower, followed} @@ -1128,35 +1126,25 @@ defp follow_information_changeset(user, params) do ]) end + @spec update_follower_count(User.t()) :: {:ok, User.t()} def update_follower_count(%User{} = user) do if user.local or !Pleroma.Config.get([:instance, :external_user_synchronization]) do - follower_count_query = - User.Query.build(%{followers: user, deactivated: false}) - |> select([u], %{count: count(u.id)}) - - User - |> where(id: ^user.id) - |> join(:inner, [u], s in subquery(follower_count_query)) - |> update([u, s], - set: [follower_count: s.count] - ) - |> select([u], u) - |> Repo.update_all([]) - |> case do - {1, [user]} -> set_cache(user) - _ -> {:error, user} - end + follower_count = FollowingRelationship.follower_count(user) + + user + |> follow_information_changeset(%{follower_count: follower_count}) + |> update_and_set_cache else {:ok, maybe_fetch_follow_information(user)} end end - @spec update_following_count(User.t()) :: User.t() + @spec update_following_count(User.t()) :: {:ok, User.t()} def update_following_count(%User{local: false} = user) do if Pleroma.Config.get([:instance, :external_user_synchronization]) do - maybe_fetch_follow_information(user) + {:ok, maybe_fetch_follow_information(user)} else - user + {:ok, user} end end @@ -1165,7 +1153,7 @@ def update_following_count(%User{local: true} = user) do user |> follow_information_changeset(%{following_count: following_count}) - |> Repo.update!() + |> update_and_set_cache() end def set_unread_conversation_count(%User{local: true} = user) do -- cgit v1.2.3 From faba1a6e337715af557e2e222e62de6fd35c9e8a Mon Sep 17 00:00:00 2001 From: stwf <steven.fuchs@dockyard.com> Date: Mon, 15 Jun 2020 12:25:03 -0400 Subject: fix tests --- lib/pleroma/web/preload/timelines.ex | 4 ++-- test/web/node_info_test.exs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index 2bb57567b..e531b8960 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -30,8 +30,8 @@ defp public_timeline(_params), do: get_public_timeline(true) defp get_public_timeline(local_only) do activities = ActivityPub.fetch_public_activities(%{ - "type" => ["Create"], - "local_only" => local_only + type: ["Create"], + local_only: local_only }) StatusView.render("index.json", activities: activities, for: nil, as: :activity) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 00925caad..9bcc07b37 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -145,8 +145,7 @@ test "it shows default features flags", %{conn: conn} do "shareable_emoji_packs", "multifetch", "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter", - "pleroma_chat_messages" + "pleroma:api/v1/notifications:include_types_filter" ] assert MapSet.subset?( -- cgit v1.2.3 From b02311079961c5193af1c144516a3caeee72b582 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 15 Jun 2020 20:47:02 +0300 Subject: fixed a visibility of functions --- lib/pleroma/workers/attachments_cleanup_worker.ex | 31 +++++++++++------------ 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex index 4ad19c0fc..8deeabda0 100644 --- a/lib/pleroma/workers/attachments_cleanup_worker.ex +++ b/lib/pleroma/workers/attachments_cleanup_worker.ex @@ -18,22 +18,11 @@ def perform( }, _job ) do - hrefs = - Enum.flat_map(attachments, fn attachment -> - Enum.map(attachment["url"], & &1["href"]) - end) - - # find all objects for copies of the attachments, name and actor doesn't matter here - hrefs + attachments + |> Enum.flat_map(fn item -> Enum.map(item["url"], & &1["href"]) end) |> fetch_objects |> prepare_objects(actor, Enum.map(attachments, & &1["name"])) - |> Enum.reduce({[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> - with 1 <- count do - {ids ++ [id], hrefs ++ [href]} - else - _ -> {ids ++ [id], hrefs} - end - end) + |> filter_objects |> do_clean {:ok, :success} @@ -73,7 +62,17 @@ defp delete_objects(_), do: :ok # we should delete 1 object for any given attachment, but don't delete # files if there are more than 1 object for it - def prepare_objects(objects, actor, names) do + defp filter_objects(objects) do + Enum.reduce(objects, {[], []}, fn {href, %{id: id, count: count}}, {ids, hrefs} -> + with 1 <- count do + {ids ++ [id], hrefs ++ [href]} + else + _ -> {ids ++ [id], hrefs} + end + end) + end + + defp prepare_objects(objects, actor, names) do objects |> Enum.reduce(%{}, fn %{ id: id, @@ -98,7 +97,7 @@ def prepare_objects(objects, actor, names) do end) end - def fetch_objects(hrefs) do + defp fetch_objects(hrefs) do from(o in Object, where: fragment( -- cgit v1.2.3 From 1eb6cedaadee4e1ab3e0885b4e03a8dd17ba08ea Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 16 Jun 2020 13:08:27 +0200 Subject: ActivityPub: When restricting to media posts, only show 'Creates'. --- lib/pleroma/web/activity_pub/activity_pub.ex | 3 ++- test/web/mastodon_api/controllers/account_controller_test.exs | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index c9dc6135c..efb8b81db 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -833,7 +833,8 @@ defp restrict_media(_query, %{only_media: _val, skip_preload: true}) do defp restrict_media(query, %{only_media: true}) do from( - [_activity, object] in query, + [activity, object] in query, + where: fragment("(?)->>'type' = ?", activity.data, "Create"), where: fragment("not (?)->'attachment' = (?)", object.data, ^[]) ) end diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 1ce97378d..2343a9d2d 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -350,9 +350,10 @@ test "unimplemented pinned statuses feature", %{conn: conn} do assert json_response_and_validate_schema(conn, 200) == [] end - test "gets an users media", %{conn: conn} do + test "gets an users media, excludes reblogs", %{conn: conn} do note = insert(:note_activity) user = User.get_cached_by_ap_id(note.data["actor"]) + other_user = insert(:user) file = %Plug.Upload{ content_type: "image/jpg", @@ -364,6 +365,13 @@ test "gets an users media", %{conn: conn} do {:ok, %{id: image_post_id}} = CommonAPI.post(user, %{status: "cofe", media_ids: [media_id]}) + {:ok, %{id: media_id}} = ActivityPub.upload(file, actor: other_user.ap_id) + + {:ok, %{id: other_image_post_id}} = + CommonAPI.post(other_user, %{status: "cofe2", media_ids: [media_id]}) + + {:ok, _announce} = CommonAPI.repeat(other_image_post_id, user) + conn = get(conn, "/api/v1/accounts/#{user.id}/statuses?only_media=true") assert [%{"id" => ^image_post_id}] = json_response_and_validate_schema(conn, 200) -- cgit v1.2.3 From 4733f6a3371504ebb3eeb447d7c20d56c10b43bf Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 16 Jun 2020 13:09:28 +0200 Subject: Changelog: Add info about `only_media` changes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2629bf84..eee442817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- Using the `only_media` filter on timelines will now exclude reblog media - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard <details> -- cgit v1.2.3 From 015f9258a9bd1430ab079f449b118b664c3b9664 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 16 Jun 2020 14:48:46 +0200 Subject: Transmogrifier: Extract user update handling tests. --- .../transmogrifier/user_update_handling_test.exs | 154 ++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 156 --------------------- 2 files changed, 154 insertions(+), 156 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/user_update_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs new file mode 100644 index 000000000..8e5d3b883 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -0,0 +1,154 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + + test "it works for incoming update activities" do + user = insert(:user, local: false) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) + + assert data["id"] == update_data["id"] + + user = User.get_cached_by_ap_id(data["actor"]) + assert user.name == "gargle" + + assert user.avatar["url"] == [ + %{ + "href" => + "https://cd.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg" + } + ] + + assert user.banner["url"] == [ + %{ + "href" => + "https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png" + } + ] + + assert user.bio == "<p>Some bio</p>" + end + + test "it works with alsoKnownAs" do + %{ap_id: actor} = insert(:user, local: false) + + assert User.get_cached_by_ap_id(actor).also_known_as == [] + + {:ok, _activity} = + "test/fixtures/mastodon-update.json" + |> File.read!() + |> Poison.decode!() + |> Map.put("actor", actor) + |> Map.update!("object", fn object -> + object + |> Map.put("actor", actor) + |> Map.put("id", actor) + |> Map.put("alsoKnownAs", [ + "http://mastodon.example.org/users/foo", + "http://example.org/users/bar" + ]) + end) + |> Transmogrifier.handle_incoming() + + assert User.get_cached_by_ap_id(actor).also_known_as == [ + "http://mastodon.example.org/users/foo", + "http://example.org/users/bar" + ] + end + + test "it works with custom profile fields" do + user = insert(:user, local: false) + + assert user.fields == [] + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + {:ok, _update_activity} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert user.fields == [ + %{"name" => "foo", "value" => "updated"}, + %{"name" => "foo1", "value" => "updated"} + ] + + Pleroma.Config.put([:instance, :max_remote_account_fields], 2) + + update_data = + put_in(update_data, ["object", "attachment"], [ + %{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}, + %{"name" => "foo11", "type" => "PropertyValue", "value" => "bar11"}, + %{"name" => "foo22", "type" => "PropertyValue", "value" => "bar22"} + ]) + + {:ok, _} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert user.fields == [ + %{"name" => "foo", "value" => "updated"}, + %{"name" => "foo1", "value" => "updated"} + ] + + update_data = put_in(update_data, ["object", "attachment"], []) + + {:ok, _} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert user.fields == [] + end + + test "it works for incoming update activities which lock the account" do + user = insert(:user, local: false) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() + + object = + update_data["object"] + |> Map.put("actor", user.ap_id) + |> Map.put("id", user.ap_id) + |> Map.put("manuallyApprovesFollowers", true) + + update_data = + update_data + |> Map.put("actor", user.ap_id) + |> Map.put("object", object) + + {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(update_data) + + user = User.get_cached_by_ap_id(user.ap_id) + assert user.locked == true + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 94d8552e8..b542bb7b8 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -401,162 +401,6 @@ test "it strips internal reactions" do refute Map.has_key?(object_data, "reaction_count") end - test "it works for incoming update activities" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() - - object = - update_data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("id", data["actor"]) - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) - - assert data["id"] == update_data["id"] - - user = User.get_cached_by_ap_id(data["actor"]) - assert user.name == "gargle" - - assert user.avatar["url"] == [ - %{ - "href" => - "https://cd.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg" - } - ] - - assert user.banner["url"] == [ - %{ - "href" => - "https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png" - } - ] - - assert user.bio == "<p>Some bio</p>" - end - - test "it works with alsoKnownAs" do - {:ok, %Activity{data: %{"actor" => actor}}} = - "test/fixtures/mastodon-post-activity.json" - |> File.read!() - |> Poison.decode!() - |> Transmogrifier.handle_incoming() - - assert User.get_cached_by_ap_id(actor).also_known_as == ["http://example.org/users/foo"] - - {:ok, _activity} = - "test/fixtures/mastodon-update.json" - |> File.read!() - |> Poison.decode!() - |> Map.put("actor", actor) - |> Map.update!("object", fn object -> - object - |> Map.put("actor", actor) - |> Map.put("id", actor) - |> Map.put("alsoKnownAs", [ - "http://mastodon.example.org/users/foo", - "http://example.org/users/bar" - ]) - end) - |> Transmogrifier.handle_incoming() - - assert User.get_cached_by_ap_id(actor).also_known_as == [ - "http://mastodon.example.org/users/foo", - "http://example.org/users/bar" - ] - end - - test "it works with custom profile fields" do - {:ok, activity} = - "test/fixtures/mastodon-post-activity.json" - |> File.read!() - |> Poison.decode!() - |> Transmogrifier.handle_incoming() - - user = User.get_cached_by_ap_id(activity.actor) - - assert user.fields == [ - %{"name" => "foo", "value" => "bar"}, - %{"name" => "foo1", "value" => "bar1"} - ] - - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() - - object = - update_data["object"] - |> Map.put("actor", user.ap_id) - |> Map.put("id", user.ap_id) - - update_data = - update_data - |> Map.put("actor", user.ap_id) - |> Map.put("object", object) - - {:ok, _update_activity} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(user.ap_id) - - assert user.fields == [ - %{"name" => "foo", "value" => "updated"}, - %{"name" => "foo1", "value" => "updated"} - ] - - Pleroma.Config.put([:instance, :max_remote_account_fields], 2) - - update_data = - put_in(update_data, ["object", "attachment"], [ - %{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}, - %{"name" => "foo11", "type" => "PropertyValue", "value" => "bar11"}, - %{"name" => "foo22", "type" => "PropertyValue", "value" => "bar22"} - ]) - - {:ok, _} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(user.ap_id) - - assert user.fields == [ - %{"name" => "foo", "value" => "updated"}, - %{"name" => "foo1", "value" => "updated"} - ] - - update_data = put_in(update_data, ["object", "attachment"], []) - - {:ok, _} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(user.ap_id) - - assert user.fields == [] - end - - test "it works for incoming update activities which lock the account" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Poison.decode!() - - object = - update_data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("id", data["actor"]) - |> Map.put("manuallyApprovesFollowers", true) - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) - - user = User.get_cached_by_ap_id(data["actor"]) - assert user.locked == true - end - test "it works for incomming unfollows with an existing follow" do user = insert(:user) -- cgit v1.2.3 From 9a4fde97661595630ea840917ef83b4786f2e2d3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 10:46:02 +0300 Subject: Mogrify args as custom tuples --- lib/mix/tasks/pleroma/config.ex | 10 +- lib/pleroma/config/config_db.ex | 276 +++++----- lib/pleroma/config/transfer_task.ex | 12 +- lib/pleroma/config/type/atom.ex | 22 + lib/pleroma/config/type/binary_value.ex | 23 + .../web/admin_api/controllers/config_controller.ex | 34 +- lib/pleroma/web/admin_api/views/config_view.ex | 19 +- test/config/config_db_test.exs | 587 ++++++++------------- test/config/transfer_task_test.exs | 94 +--- test/support/factory.ex | 17 +- test/tasks/config_test.exs | 41 +- test/upload/filter/mogrify_test.exs | 8 +- .../controllers/config_controller_test.exs | 256 ++++++--- 13 files changed, 620 insertions(+), 779 deletions(-) create mode 100644 lib/pleroma/config/type/atom.ex create mode 100644 lib/pleroma/config/type/binary_value.ex diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 5c9ef6904..f1b3a8766 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -72,8 +72,7 @@ defp create(group, settings) do group |> Pleroma.Config.Loader.filter_group(settings) |> Enum.each(fn {key, value} -> - key = inspect(key) - {:ok, _} = ConfigDB.update_or_create(%{group: inspect(group), key: key, value: value}) + {:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value}) shell_info("Settings for key #{key} migrated.") end) @@ -131,12 +130,9 @@ defp write_and_delete(config, file, delete?) do end defp write(config, file) do - value = - config.value - |> ConfigDB.from_binary() - |> inspect(limit: :infinity) + value = inspect(config.value, limit: :infinity) - IO.write(file, "config #{config.group}, #{config.key}, #{value}\r\n\r\n") + IO.write(file, "config #{inspect(config.group)}, #{inspect(config.key)}, #{value}\r\n\r\n") config end diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 2b43d4c36..39b37c42e 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -6,7 +6,7 @@ defmodule Pleroma.ConfigDB do use Ecto.Schema import Ecto.Changeset - import Ecto.Query + import Ecto.Query, only: [select: 3] import Pleroma.Web.Gettext alias __MODULE__ @@ -14,16 +14,6 @@ defmodule Pleroma.ConfigDB do @type t :: %__MODULE__{} - @full_key_update [ - {:pleroma, :ecto_repos}, - {:quack, :meta}, - {:mime, :types}, - {:cors_plug, [:max_age, :methods, :expose, :headers]}, - {:auto_linker, :opts}, - {:swarm, :node_blacklist}, - {:logger, :backends} - ] - @full_subkey_update [ {:pleroma, :assets, :mascots}, {:pleroma, :emoji, :groups}, @@ -32,14 +22,10 @@ defmodule Pleroma.ConfigDB do {:pleroma, :mrf_keyword, :replace} ] - @regex ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u - - @delimiters ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] - schema "config" do - field(:key, :string) - field(:group, :string) - field(:value, :binary) + field(:key, Pleroma.Config.Type.Atom) + field(:group, Pleroma.Config.Type.Atom) + field(:value, Pleroma.Config.Type.BinaryValue) field(:db, {:array, :string}, virtual: true, default: []) timestamps() @@ -51,10 +37,6 @@ def get_all_as_keyword do |> select([c], {c.group, c.key, c.value}) |> Repo.all() |> Enum.reduce([], fn {group, key, value}, acc -> - group = ConfigDB.from_string(group) - key = ConfigDB.from_string(key) - value = from_binary(value) - Keyword.update(acc, group, [{key, value}], &Keyword.merge(&1, [{key, value}])) end) end @@ -64,50 +46,41 @@ def get_by_params(params), do: Repo.get_by(ConfigDB, params) @spec changeset(ConfigDB.t(), map()) :: Changeset.t() def changeset(config, params \\ %{}) do - params = Map.put(params, :value, transform(params[:value])) - config |> cast(params, [:key, :group, :value]) |> validate_required([:key, :group, :value]) |> unique_constraint(:key, name: :config_group_key_index) end - @spec create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} - def create(params) do + defp create(params) do %ConfigDB{} |> changeset(params) |> Repo.insert() end - @spec update(ConfigDB.t(), map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} - def update(%ConfigDB{} = config, %{value: value}) do + defp update(%ConfigDB{} = config, %{value: value}) do config |> changeset(%{value: value}) |> Repo.update() end - @spec get_db_keys(ConfigDB.t()) :: [String.t()] - def get_db_keys(%ConfigDB{} = config) do - config.value - |> ConfigDB.from_binary() - |> get_db_keys(config.key) - end - @spec get_db_keys(keyword(), any()) :: [String.t()] def get_db_keys(value, key) do - if Keyword.keyword?(value) do - value |> Keyword.keys() |> Enum.map(&convert(&1)) - else - [convert(key)] - end + keys = + if Keyword.keyword?(value) do + Keyword.keys(value) + else + [key] + end + + Enum.map(keys, &to_json_types(&1)) end @spec merge_group(atom(), atom(), keyword(), keyword()) :: keyword() def merge_group(group, key, old_value, new_value) do - new_keys = to_map_set(new_value) + new_keys = to_mapset(new_value) - intersect_keys = - old_value |> to_map_set() |> MapSet.intersection(new_keys) |> MapSet.to_list() + intersect_keys = old_value |> to_mapset() |> MapSet.intersection(new_keys) |> MapSet.to_list() merged_value = ConfigDB.merge(old_value, new_value) @@ -120,12 +93,10 @@ def merge_group(group, key, old_value, new_value) do [] end) |> List.flatten() - |> Enum.reduce(merged_value, fn subkey, acc -> - Keyword.put(acc, subkey, new_value[subkey]) - end) + |> Enum.reduce(merged_value, &Keyword.put(&2, &1, new_value[&1])) end - defp to_map_set(keyword) do + defp to_mapset(keyword) do keyword |> Keyword.keys() |> MapSet.new() @@ -159,43 +130,39 @@ defp deep_merge(_key, value1, value2) do @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} def update_or_create(params) do + params = Map.put(params, :value, to_elixir_types(params[:value])) search_opts = Map.take(params, [:group, :key]) with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), - {:partial_update, true, config} <- - {:partial_update, can_be_partially_updated?(config), config}, - old_value <- from_binary(config.value), - transformed_value <- do_transform(params[:value]), - {:can_be_merged, true, config} <- {:can_be_merged, is_list(transformed_value), config}, - new_value <- - merge_group( - ConfigDB.from_string(config.group), - ConfigDB.from_string(config.key), - old_value, - transformed_value - ) do - ConfigDB.update(config, %{value: new_value}) + {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, + {_, true, config} <- {:can_be_merged, is_list(params[:value]), config} do + new_value = merge_group(config.group, config.key, config.value, params[:value]) + update(config, %{value: new_value}) else {reason, false, config} when reason in [:partial_update, :can_be_merged] -> - ConfigDB.update(config, params) + update(config, params) nil -> - ConfigDB.create(params) + create(params) end end defp can_be_partially_updated?(%ConfigDB{} = config), do: not only_full_update?(config) - defp only_full_update?(%ConfigDB{} = config) do - config_group = ConfigDB.from_string(config.group) - config_key = ConfigDB.from_string(config.key) - - Enum.any?(@full_key_update, fn - {group, key} when is_list(key) -> - config_group == group and config_key in key - - {group, key} -> - config_group == group and config_key == key + defp only_full_update?(%ConfigDB{group: group, key: key}) do + full_key_update = [ + {:pleroma, :ecto_repos}, + {:quack, :meta}, + {:mime, :types}, + {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, + {:swarm, :node_blacklist}, + {:logger, :backends} + ] + + Enum.any?(full_key_update, fn + {s_group, s_key} -> + group == s_group and ((is_list(s_key) and key in s_key) or key == s_key) end) end @@ -205,11 +172,10 @@ def delete(params) do with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), {config, sub_keys} when is_list(sub_keys) <- {config, params[:subkeys]}, - old_value <- from_binary(config.value), - keys <- Enum.map(sub_keys, &do_transform_string(&1)), - {:partial_remove, config, new_value} when new_value != [] <- - {:partial_remove, config, Keyword.drop(old_value, keys)} do - ConfigDB.update(config, %{value: new_value}) + keys <- Enum.map(sub_keys, &string_to_elixir_types(&1)), + {_, config, new_value} when new_value != [] <- + {:partial_remove, config, Keyword.drop(config.value, keys)} do + update(config, %{value: new_value}) else {:partial_remove, config, []} -> Repo.delete(config) @@ -225,37 +191,32 @@ def delete(params) do end end - @spec from_binary(binary()) :: term() - def from_binary(binary), do: :erlang.binary_to_term(binary) - - @spec from_binary_with_convert(binary()) :: any() - def from_binary_with_convert(binary) do - binary - |> from_binary() - |> do_convert() + @spec to_json_types(term()) :: map() | list() | boolean() | String.t() + def to_json_types(entity) when is_list(entity) do + Enum.map(entity, &to_json_types/1) end - @spec from_string(String.t()) :: atom() | no_return() - def from_string(string), do: do_transform_string(string) - - @spec convert(any()) :: any() - def convert(entity), do: do_convert(entity) + def to_json_types(%Regex{} = entity), do: inspect(entity) - defp do_convert(entity) when is_list(entity) do - for v <- entity, into: [], do: do_convert(v) + def to_json_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_json_types(k), to_json_types(v)} end) end - defp do_convert(%Regex{} = entity), do: inspect(entity) + def to_json_types({:args, args}) when is_list(args) do + arguments = + Enum.map(args, fn + arg when is_tuple(arg) -> inspect(arg) + arg -> to_json_types(arg) + end) - defp do_convert(entity) when is_map(entity) do - for {k, v} <- entity, into: %{}, do: {do_convert(k), do_convert(v)} + %{"tuple" => [":args", arguments]} end - defp do_convert({:proxy_url, {type, :localhost, port}}) do - %{"tuple" => [":proxy_url", %{"tuple" => [do_convert(type), "localhost", port]}]} + def to_json_types({:proxy_url, {type, :localhost, port}}) do + %{"tuple" => [":proxy_url", %{"tuple" => [to_json_types(type), "localhost", port]}]} end - defp do_convert({:proxy_url, {type, host, port}}) when is_tuple(host) do + def to_json_types({:proxy_url, {type, host, port}}) when is_tuple(host) do ip = host |> :inet_parse.ntoa() @@ -264,66 +225,64 @@ defp do_convert({:proxy_url, {type, host, port}}) when is_tuple(host) do %{ "tuple" => [ ":proxy_url", - %{"tuple" => [do_convert(type), ip, port]} + %{"tuple" => [to_json_types(type), ip, port]} ] } end - defp do_convert({:proxy_url, {type, host, port}}) do + def to_json_types({:proxy_url, {type, host, port}}) do %{ "tuple" => [ ":proxy_url", - %{"tuple" => [do_convert(type), to_string(host), port]} + %{"tuple" => [to_json_types(type), to_string(host), port]} ] } end - defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]} + def to_json_types({:partial_chain, entity}), + do: %{"tuple" => [":partial_chain", inspect(entity)]} - defp do_convert(entity) when is_tuple(entity) do + def to_json_types(entity) when is_tuple(entity) do value = entity |> Tuple.to_list() - |> do_convert() + |> to_json_types() %{"tuple" => value} end - defp do_convert(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do + def to_json_types(entity) when is_binary(entity), do: entity + + def to_json_types(entity) when is_boolean(entity) or is_number(entity) or is_nil(entity) do entity end - defp do_convert(entity) - when is_atom(entity) and entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do + def to_json_types(entity) when entity in [:"tlsv1.1", :"tlsv1.2", :"tlsv1.3"] do ":#{entity}" end - defp do_convert(entity) when is_atom(entity), do: inspect(entity) + def to_json_types(entity) when is_atom(entity), do: inspect(entity) - defp do_convert(entity) when is_binary(entity), do: entity + @spec to_elixir_types(boolean() | String.t() | map() | list()) :: term() + def to_elixir_types(%{"tuple" => [":args", args]}) when is_list(args) do + arguments = + Enum.map(args, fn arg -> + if String.contains?(arg, ["{", "}"]) do + {elem, []} = Code.eval_string(arg) + elem + else + to_elixir_types(arg) + end + end) - @spec transform(any()) :: binary() | no_return() - def transform(entity) when is_binary(entity) or is_map(entity) or is_list(entity) do - entity - |> do_transform() - |> to_binary() + {:args, arguments} end - def transform(entity), do: to_binary(entity) - - @spec transform_with_out_binary(any()) :: any() - def transform_with_out_binary(entity), do: do_transform(entity) - - @spec to_binary(any()) :: binary() - def to_binary(entity), do: :erlang.term_to_binary(entity) - - defp do_transform(%Regex{} = entity), do: entity - - defp do_transform(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do - {:proxy_url, {do_transform_string(type), parse_host(host), port}} + def to_elixir_types(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}) do + {:proxy_url, {string_to_elixir_types(type), parse_host(host), port}} end - defp do_transform(%{"tuple" => [":partial_chain", entity]}) do + def to_elixir_types(%{"tuple" => [":partial_chain", entity]}) do {partial_chain, []} = entity |> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "") @@ -332,25 +291,51 @@ defp do_transform(%{"tuple" => [":partial_chain", entity]}) do {:partial_chain, partial_chain} end - defp do_transform(%{"tuple" => entity}) do - Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end) + def to_elixir_types(%{"tuple" => entity}) do + Enum.reduce(entity, {}, &Tuple.append(&2, to_elixir_types(&1))) end - defp do_transform(entity) when is_map(entity) do - for {k, v} <- entity, into: %{}, do: {do_transform(k), do_transform(v)} + def to_elixir_types(entity) when is_map(entity) do + Map.new(entity, fn {k, v} -> {to_elixir_types(k), to_elixir_types(v)} end) end - defp do_transform(entity) when is_list(entity) do - for v <- entity, into: [], do: do_transform(v) + def to_elixir_types(entity) when is_list(entity) do + Enum.map(entity, &to_elixir_types/1) end - defp do_transform(entity) when is_binary(entity) do + def to_elixir_types(entity) when is_binary(entity) do entity |> String.trim() - |> do_transform_string() + |> string_to_elixir_types() + end + + def to_elixir_types(entity), do: entity + + @spec string_to_elixir_types(String.t()) :: + atom() | Regex.t() | module() | String.t() | no_return() + def string_to_elixir_types("~r" <> _pattern = regex) do + pattern = + ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u + + delimiters = ["/", "|", "\"", "'", {"(", ")"}, {"[", "]"}, {"{", "}"}, {"<", ">"}] + + with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- + Regex.named_captures(pattern, regex), + {:ok, {leading, closing}} <- find_valid_delimiter(delimiters, pattern, regex_delimiter), + {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do + result + end end - defp do_transform(entity), do: entity + def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) + + def string_to_elixir_types(value) do + if is_module_name?(value) do + String.to_existing_atom("Elixir." <> value) + else + value + end + end defp parse_host("localhost"), do: :localhost @@ -387,25 +372,6 @@ defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do end end - defp do_transform_string("~r" <> _pattern = regex) do - with %{"modifier" => modifier, "pattern" => pattern, "delimiter" => regex_delimiter} <- - Regex.named_captures(@regex, regex), - {:ok, {leading, closing}} <- find_valid_delimiter(@delimiters, pattern, regex_delimiter), - {result, _} <- Code.eval_string("~r#{leading}#{pattern}#{closing}#{modifier}") do - result - end - end - - defp do_transform_string(":" <> atom), do: String.to_atom(atom) - - defp do_transform_string(value) do - if is_module_name?(value) do - String.to_existing_atom("Elixir." <> value) - else - value - end - end - @spec is_module_name?(String.t()) :: boolean() def is_module_name?(string) do Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index c02b70e96..eb86b8ff4 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -28,10 +28,6 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, Pleroma.Captcha, [:seconds_valid]}, {:pleroma, Pleroma.Upload, [:proxy_remote]}, {:pleroma, :instance, [:upload_limit]}, - {:pleroma, :email_notifications, [:digest]}, - {:pleroma, :oauth2, [:clean_expired_tokens]}, - {:pleroma, Pleroma.ActivityExpiration, [:enabled]}, - {:pleroma, Pleroma.ScheduledActivity, [:enabled]}, {:pleroma, :gopher, [:enabled]} ] @@ -48,7 +44,7 @@ def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do {logger, other} = (Repo.all(ConfigDB) ++ deleted_settings) - |> Enum.map(&transform_and_merge/1) + |> Enum.map(&merge_with_default/1) |> Enum.split_with(fn {group, _, _, _} -> group in [:logger, :quack] end) logger @@ -92,11 +88,7 @@ defp maybe_set_pleroma_last(apps) do end end - defp transform_and_merge(%{group: group, key: key, value: value} = setting) do - group = ConfigDB.from_string(group) - key = ConfigDB.from_string(key) - value = ConfigDB.from_binary(value) - + defp merge_with_default(%{group: group, key: key, value: value} = setting) do default = Config.Holder.default_config(group, key) merged = diff --git a/lib/pleroma/config/type/atom.ex b/lib/pleroma/config/type/atom.ex new file mode 100644 index 000000000..387869284 --- /dev/null +++ b/lib/pleroma/config/type/atom.ex @@ -0,0 +1,22 @@ +defmodule Pleroma.Config.Type.Atom do + use Ecto.Type + + def type, do: :atom + + def cast(key) when is_atom(key) do + {:ok, key} + end + + def cast(key) when is_binary(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def cast(_), do: :error + + def load(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def dump(key) when is_atom(key), do: {:ok, inspect(key)} + def dump(_), do: :error +end diff --git a/lib/pleroma/config/type/binary_value.ex b/lib/pleroma/config/type/binary_value.ex new file mode 100644 index 000000000..17c5524a3 --- /dev/null +++ b/lib/pleroma/config/type/binary_value.ex @@ -0,0 +1,23 @@ +defmodule Pleroma.Config.Type.BinaryValue do + use Ecto.Type + + def type, do: :term + + def cast(value) when is_binary(value) do + if String.valid?(value) do + {:ok, value} + else + {:ok, :erlang.binary_to_term(value)} + end + end + + def cast(value), do: {:ok, value} + + def load(value) when is_binary(value) do + {:ok, :erlang.binary_to_term(value)} + end + + def dump(value) do + {:ok, :erlang.term_to_binary(value)} + end +end diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index d6e2019bc..7f60470cb 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -33,7 +33,11 @@ def descriptions(conn, _params) do def show(conn, %{only_db: true}) do with :ok <- configurable_from_database() do configs = Pleroma.Repo.all(ConfigDB) - render(conn, "index.json", %{configs: configs}) + + render(conn, "index.json", %{ + configs: configs, + need_reboot: Restarter.Pleroma.need_reboot?() + }) end end @@ -61,17 +65,20 @@ def show(conn, _params) do value end - %{ - group: ConfigDB.convert(group), - key: ConfigDB.convert(key), - value: ConfigDB.convert(merged_value) + %ConfigDB{ + group: group, + key: key, + value: merged_value } |> Pleroma.Maps.put_if_present(:db, db) end) end) |> List.flatten() - json(conn, %{configs: merged, need_reboot: Restarter.Pleroma.need_reboot?()}) + render(conn, "index.json", %{ + configs: merged, + need_reboot: Restarter.Pleroma.need_reboot?() + }) end end @@ -91,24 +98,17 @@ def update(%{body_params: %{configs: configs}} = conn, _) do {deleted, updated} = results - |> Enum.map(fn {:ok, config} -> - Map.put(config, :db, ConfigDB.get_db_keys(config)) - end) - |> Enum.split_with(fn config -> - Ecto.get_meta(config, :state) == :deleted + |> Enum.map(fn {:ok, %{key: key, value: value} = config} -> + Map.put(config, :db, ConfigDB.get_db_keys(value, key)) end) + |> Enum.split_with(&(Ecto.get_meta(&1, :state) == :deleted)) Config.TransferTask.load_and_update_env(deleted, false) if not Restarter.Pleroma.need_reboot?() do changed_reboot_settings? = (updated ++ deleted) - |> Enum.any?(fn config -> - group = ConfigDB.from_string(config.group) - key = ConfigDB.from_string(config.key) - value = ConfigDB.from_binary(config.value) - Config.TransferTask.pleroma_need_restart?(group, key, value) - end) + |> Enum.any?(&Config.TransferTask.pleroma_need_restart?(&1.group, &1.key, &1.value)) if changed_reboot_settings?, do: Restarter.Pleroma.need_reboot() end diff --git a/lib/pleroma/web/admin_api/views/config_view.ex b/lib/pleroma/web/admin_api/views/config_view.ex index 587ef760e..d2d8b5907 100644 --- a/lib/pleroma/web/admin_api/views/config_view.ex +++ b/lib/pleroma/web/admin_api/views/config_view.ex @@ -5,23 +5,20 @@ defmodule Pleroma.Web.AdminAPI.ConfigView do use Pleroma.Web, :view + alias Pleroma.ConfigDB + def render("index.json", %{configs: configs} = params) do - map = %{ - configs: render_many(configs, __MODULE__, "show.json", as: :config) + %{ + configs: render_many(configs, __MODULE__, "show.json", as: :config), + need_reboot: params[:need_reboot] } - - if params[:need_reboot] do - Map.put(map, :need_reboot, true) - else - map - end end def render("show.json", %{config: config}) do map = %{ - key: config.key, - group: config.group, - value: Pleroma.ConfigDB.from_binary_with_convert(config.value) + key: ConfigDB.to_json_types(config.key), + group: ConfigDB.to_json_types(config.group), + value: ConfigDB.to_json_types(config.value) } if config.db != [] do diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index 336de7359..a04575c6f 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -7,40 +7,28 @@ defmodule Pleroma.ConfigDBTest do import Pleroma.Factory alias Pleroma.ConfigDB - test "get_by_key/1" do + test "get_by_params/1" do config = insert(:config) insert(:config) assert config == ConfigDB.get_by_params(%{group: config.group, key: config.key}) end - test "create/1" do - {:ok, config} = ConfigDB.create(%{group: ":pleroma", key: ":some_key", value: "some_value"}) - assert config == ConfigDB.get_by_params(%{group: ":pleroma", key: ":some_key"}) - end - - test "update/1" do - config = insert(:config) - {:ok, updated} = ConfigDB.update(config, %{value: "some_value"}) - loaded = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - assert loaded == updated - end - test "get_all_as_keyword/0" do saved = insert(:config) - insert(:config, group: ":quack", key: ":level", value: ConfigDB.to_binary(:info)) - insert(:config, group: ":quack", key: ":meta", value: ConfigDB.to_binary([:none])) + insert(:config, group: ":quack", key: ":level", value: :info) + insert(:config, group: ":quack", key: ":meta", value: [:none]) insert(:config, group: ":quack", key: ":webhook_url", - value: ConfigDB.to_binary("https://hooks.slack.com/services/KEY/some_val") + value: "https://hooks.slack.com/services/KEY/some_val" ) config = ConfigDB.get_all_as_keyword() assert config[:pleroma] == [ - {ConfigDB.from_string(saved.key), ConfigDB.from_binary(saved.value)} + {saved.key, saved.value} ] assert config[:quack][:level] == :info @@ -51,11 +39,11 @@ test "get_all_as_keyword/0" do describe "update_or_create/1" do test "common" do config = insert(:config) - key2 = "another_key" + key2 = :another_key params = [ - %{group: "pleroma", key: key2, value: "another_value"}, - %{group: config.group, key: config.key, value: "new_value"} + %{group: :pleroma, key: key2, value: "another_value"}, + %{group: :pleroma, key: config.key, value: "new_value"} ] assert Repo.all(ConfigDB) |> length() == 1 @@ -65,16 +53,16 @@ test "common" do assert Repo.all(ConfigDB) |> length() == 2 config1 = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - config2 = ConfigDB.get_by_params(%{group: "pleroma", key: key2}) + config2 = ConfigDB.get_by_params(%{group: :pleroma, key: key2}) - assert config1.value == ConfigDB.transform("new_value") - assert config2.value == ConfigDB.transform("another_value") + assert config1.value == "new_value" + assert config2.value == "another_value" end test "partial update" do - config = insert(:config, value: ConfigDB.to_binary(key1: "val1", key2: :val2)) + config = insert(:config, value: [key1: "val1", key2: :val2]) - {:ok, _config} = + {:ok, config} = ConfigDB.update_or_create(%{ group: config.group, key: config.key, @@ -83,15 +71,14 @@ test "partial update" do updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - value = ConfigDB.from_binary(updated.value) - assert length(value) == 3 - assert value[:key1] == :val1 - assert value[:key2] == :val2 - assert value[:key3] == :val3 + assert config.value == updated.value + assert updated.value[:key1] == :val1 + assert updated.value[:key2] == :val2 + assert updated.value[:key3] == :val3 end test "deep merge" do - config = insert(:config, value: ConfigDB.to_binary(key1: "val1", key2: [k1: :v1, k2: "v2"])) + config = insert(:config, value: [key1: "val1", key2: [k1: :v1, k2: "v2"]]) {:ok, config} = ConfigDB.update_or_create(%{ @@ -103,18 +90,15 @@ test "deep merge" do updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) assert config.value == updated.value - - value = ConfigDB.from_binary(updated.value) - assert value[:key1] == :val1 - assert value[:key2] == [k1: :v1, k2: :v2, k3: :v3] - assert value[:key3] == :val3 + assert updated.value[:key1] == :val1 + assert updated.value[:key2] == [k1: :v1, k2: :v2, k3: :v3] + assert updated.value[:key3] == :val3 end test "only full update for some keys" do - config1 = insert(:config, key: ":ecto_repos", value: ConfigDB.to_binary(repo: Pleroma.Repo)) + config1 = insert(:config, key: :ecto_repos, value: [repo: Pleroma.Repo]) - config2 = - insert(:config, group: ":cors_plug", key: ":max_age", value: ConfigDB.to_binary(18)) + config2 = insert(:config, group: :cors_plug, key: :max_age, value: 18) {:ok, _config} = ConfigDB.update_or_create(%{ @@ -133,8 +117,8 @@ test "only full update for some keys" do updated1 = ConfigDB.get_by_params(%{group: config1.group, key: config1.key}) updated2 = ConfigDB.get_by_params(%{group: config2.group, key: config2.key}) - assert ConfigDB.from_binary(updated1.value) == [another_repo: [Pleroma.Repo]] - assert ConfigDB.from_binary(updated2.value) == 777 + assert updated1.value == [another_repo: [Pleroma.Repo]] + assert updated2.value == 777 end test "full update if value is not keyword" do @@ -142,7 +126,7 @@ test "full update if value is not keyword" do insert(:config, group: ":tesla", key: ":adapter", - value: ConfigDB.to_binary(Tesla.Adapter.Hackney) + value: Tesla.Adapter.Hackney ) {:ok, _config} = @@ -154,20 +138,20 @@ test "full update if value is not keyword" do updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) - assert ConfigDB.from_binary(updated.value) == Tesla.Adapter.Httpc + assert updated.value == Tesla.Adapter.Httpc end test "only full update for some subkeys" do config1 = insert(:config, key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) + value: [groups: [a: 1, b: 2], key: [a: 1]] ) config2 = insert(:config, key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) + value: [mascots: [a: 1, b: 2], key: [a: 1]] ) {:ok, _config} = @@ -187,8 +171,8 @@ test "only full update for some subkeys" do updated1 = ConfigDB.get_by_params(%{group: config1.group, key: config1.key}) updated2 = ConfigDB.get_by_params(%{group: config2.group, key: config2.key}) - assert ConfigDB.from_binary(updated1.value) == [groups: [c: 3, d: 4], key: [a: 1, b: 2]] - assert ConfigDB.from_binary(updated2.value) == [mascots: [c: 3, d: 4], key: [a: 1, b: 2]] + assert updated1.value == [groups: [c: 3, d: 4], key: [a: 1, b: 2]] + assert updated2.value == [mascots: [c: 3, d: 4], key: [a: 1, b: 2]] end end @@ -206,14 +190,14 @@ test "full delete" do end test "partial subkeys delete" do - config = insert(:config, value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1])) + config = insert(:config, value: [groups: [a: 1, b: 2], key: [a: 1]]) {:ok, deleted} = ConfigDB.delete(%{group: config.group, key: config.key, subkeys: [":groups"]}) assert Ecto.get_meta(deleted, :state) == :loaded - assert deleted.value == ConfigDB.to_binary(key: [a: 1]) + assert deleted.value == [key: [a: 1]] updated = ConfigDB.get_by_params(%{group: config.group, key: config.key}) @@ -221,7 +205,7 @@ test "partial subkeys delete" do end test "full delete if remaining value after subkeys deletion is empty list" do - config = insert(:config, value: ConfigDB.to_binary(groups: [a: 1, b: 2])) + config = insert(:config, value: [groups: [a: 1, b: 2]]) {:ok, deleted} = ConfigDB.delete(%{group: config.group, key: config.key, subkeys: [":groups"]}) @@ -232,234 +216,159 @@ test "full delete if remaining value after subkeys deletion is empty list" do end end - describe "transform/1" do + describe "to_elixir_types/1" do test "string" do - binary = ConfigDB.transform("value as string") - assert binary == :erlang.term_to_binary("value as string") - assert ConfigDB.from_binary(binary) == "value as string" + assert ConfigDB.to_elixir_types("value as string") == "value as string" end test "boolean" do - binary = ConfigDB.transform(false) - assert binary == :erlang.term_to_binary(false) - assert ConfigDB.from_binary(binary) == false + assert ConfigDB.to_elixir_types(false) == false end test "nil" do - binary = ConfigDB.transform(nil) - assert binary == :erlang.term_to_binary(nil) - assert ConfigDB.from_binary(binary) == nil + assert ConfigDB.to_elixir_types(nil) == nil end test "integer" do - binary = ConfigDB.transform(150) - assert binary == :erlang.term_to_binary(150) - assert ConfigDB.from_binary(binary) == 150 + assert ConfigDB.to_elixir_types(150) == 150 end test "atom" do - binary = ConfigDB.transform(":atom") - assert binary == :erlang.term_to_binary(:atom) - assert ConfigDB.from_binary(binary) == :atom + assert ConfigDB.to_elixir_types(":atom") == :atom end test "ssl options" do - binary = ConfigDB.transform([":tlsv1", ":tlsv1.1", ":tlsv1.2"]) - assert binary == :erlang.term_to_binary([:tlsv1, :"tlsv1.1", :"tlsv1.2"]) - assert ConfigDB.from_binary(binary) == [:tlsv1, :"tlsv1.1", :"tlsv1.2"] + assert ConfigDB.to_elixir_types([":tlsv1", ":tlsv1.1", ":tlsv1.2"]) == [ + :tlsv1, + :"tlsv1.1", + :"tlsv1.2" + ] end test "pleroma module" do - binary = ConfigDB.transform("Pleroma.Bookmark") - assert binary == :erlang.term_to_binary(Pleroma.Bookmark) - assert ConfigDB.from_binary(binary) == Pleroma.Bookmark + assert ConfigDB.to_elixir_types("Pleroma.Bookmark") == Pleroma.Bookmark end test "pleroma string" do - binary = ConfigDB.transform("Pleroma") - assert binary == :erlang.term_to_binary("Pleroma") - assert ConfigDB.from_binary(binary) == "Pleroma" + assert ConfigDB.to_elixir_types("Pleroma") == "Pleroma" end test "phoenix module" do - binary = ConfigDB.transform("Phoenix.Socket.V1.JSONSerializer") - assert binary == :erlang.term_to_binary(Phoenix.Socket.V1.JSONSerializer) - assert ConfigDB.from_binary(binary) == Phoenix.Socket.V1.JSONSerializer + assert ConfigDB.to_elixir_types("Phoenix.Socket.V1.JSONSerializer") == + Phoenix.Socket.V1.JSONSerializer end test "tesla module" do - binary = ConfigDB.transform("Tesla.Adapter.Hackney") - assert binary == :erlang.term_to_binary(Tesla.Adapter.Hackney) - assert ConfigDB.from_binary(binary) == Tesla.Adapter.Hackney + assert ConfigDB.to_elixir_types("Tesla.Adapter.Hackney") == Tesla.Adapter.Hackney end test "ExSyslogger module" do - binary = ConfigDB.transform("ExSyslogger") - assert binary == :erlang.term_to_binary(ExSyslogger) - assert ConfigDB.from_binary(binary) == ExSyslogger + assert ConfigDB.to_elixir_types("ExSyslogger") == ExSyslogger end test "Quack.Logger module" do - binary = ConfigDB.transform("Quack.Logger") - assert binary == :erlang.term_to_binary(Quack.Logger) - assert ConfigDB.from_binary(binary) == Quack.Logger + assert ConfigDB.to_elixir_types("Quack.Logger") == Quack.Logger end test "Swoosh.Adapters modules" do - binary = ConfigDB.transform("Swoosh.Adapters.SMTP") - assert binary == :erlang.term_to_binary(Swoosh.Adapters.SMTP) - assert ConfigDB.from_binary(binary) == Swoosh.Adapters.SMTP - binary = ConfigDB.transform("Swoosh.Adapters.AmazonSES") - assert binary == :erlang.term_to_binary(Swoosh.Adapters.AmazonSES) - assert ConfigDB.from_binary(binary) == Swoosh.Adapters.AmazonSES + assert ConfigDB.to_elixir_types("Swoosh.Adapters.SMTP") == Swoosh.Adapters.SMTP + assert ConfigDB.to_elixir_types("Swoosh.Adapters.AmazonSES") == Swoosh.Adapters.AmazonSES end test "sigil" do - binary = ConfigDB.transform("~r[comp[lL][aA][iI][nN]er]") - assert binary == :erlang.term_to_binary(~r/comp[lL][aA][iI][nN]er/) - assert ConfigDB.from_binary(binary) == ~r/comp[lL][aA][iI][nN]er/ + assert ConfigDB.to_elixir_types("~r[comp[lL][aA][iI][nN]er]") == ~r/comp[lL][aA][iI][nN]er/ end test "link sigil" do - binary = ConfigDB.transform("~r/https:\/\/example.com/") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/ + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/") == ~r/https:\/\/example.com/ end test "link sigil with um modifiers" do - binary = ConfigDB.transform("~r/https:\/\/example.com/um") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/um) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/um + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/um") == + ~r/https:\/\/example.com/um end test "link sigil with i modifier" do - binary = ConfigDB.transform("~r/https:\/\/example.com/i") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/i) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/i + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/i") == ~r/https:\/\/example.com/i end test "link sigil with s modifier" do - binary = ConfigDB.transform("~r/https:\/\/example.com/s") - assert binary == :erlang.term_to_binary(~r/https:\/\/example.com/s) - assert ConfigDB.from_binary(binary) == ~r/https:\/\/example.com/s + assert ConfigDB.to_elixir_types("~r/https:\/\/example.com/s") == ~r/https:\/\/example.com/s end test "raise if valid delimiter not found" do assert_raise ArgumentError, "valid delimiter for Regex expression not found", fn -> - ConfigDB.transform("~r/https://[]{}<>\"'()|example.com/s") + ConfigDB.to_elixir_types("~r/https://[]{}<>\"'()|example.com/s") end end test "2 child tuple" do - binary = ConfigDB.transform(%{"tuple" => ["v1", ":v2"]}) - assert binary == :erlang.term_to_binary({"v1", :v2}) - assert ConfigDB.from_binary(binary) == {"v1", :v2} + assert ConfigDB.to_elixir_types(%{"tuple" => ["v1", ":v2"]}) == {"v1", :v2} end test "proxy tuple with localhost" do - binary = - ConfigDB.transform(%{ - "tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}] - }) - - assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, :localhost, 1234}}) - assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, :localhost, 1234}} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "localhost", 1234]}] + }) == {:proxy_url, {:socks5, :localhost, 1234}} end test "proxy tuple with domain" do - binary = - ConfigDB.transform(%{ - "tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}] - }) - - assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, 'domain.com', 1234}}) - assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, 'domain.com', 1234}} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "domain.com", 1234]}] + }) == {:proxy_url, {:socks5, 'domain.com', 1234}} end test "proxy tuple with ip" do - binary = - ConfigDB.transform(%{ - "tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}] - }) - - assert binary == :erlang.term_to_binary({:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}}) - assert ConfigDB.from_binary(binary) == {:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [":proxy_url", %{"tuple" => [":socks5", "127.0.0.1", 1234]}] + }) == {:proxy_url, {:socks5, {127, 0, 0, 1}, 1234}} end test "tuple with n childs" do - binary = - ConfigDB.transform(%{ - "tuple" => [ - "v1", - ":v2", - "Pleroma.Bookmark", - 150, - false, - "Phoenix.Socket.V1.JSONSerializer" - ] - }) - - assert binary == - :erlang.term_to_binary( - {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer} - ) - - assert ConfigDB.from_binary(binary) == - {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer} + assert ConfigDB.to_elixir_types(%{ + "tuple" => [ + "v1", + ":v2", + "Pleroma.Bookmark", + 150, + false, + "Phoenix.Socket.V1.JSONSerializer" + ] + }) == {"v1", :v2, Pleroma.Bookmark, 150, false, Phoenix.Socket.V1.JSONSerializer} end test "map with string key" do - binary = ConfigDB.transform(%{"key" => "value"}) - assert binary == :erlang.term_to_binary(%{"key" => "value"}) - assert ConfigDB.from_binary(binary) == %{"key" => "value"} + assert ConfigDB.to_elixir_types(%{"key" => "value"}) == %{"key" => "value"} end test "map with atom key" do - binary = ConfigDB.transform(%{":key" => "value"}) - assert binary == :erlang.term_to_binary(%{key: "value"}) - assert ConfigDB.from_binary(binary) == %{key: "value"} + assert ConfigDB.to_elixir_types(%{":key" => "value"}) == %{key: "value"} end test "list of strings" do - binary = ConfigDB.transform(["v1", "v2", "v3"]) - assert binary == :erlang.term_to_binary(["v1", "v2", "v3"]) - assert ConfigDB.from_binary(binary) == ["v1", "v2", "v3"] + assert ConfigDB.to_elixir_types(["v1", "v2", "v3"]) == ["v1", "v2", "v3"] end test "list of modules" do - binary = ConfigDB.transform(["Pleroma.Repo", "Pleroma.Activity"]) - assert binary == :erlang.term_to_binary([Pleroma.Repo, Pleroma.Activity]) - assert ConfigDB.from_binary(binary) == [Pleroma.Repo, Pleroma.Activity] + assert ConfigDB.to_elixir_types(["Pleroma.Repo", "Pleroma.Activity"]) == [ + Pleroma.Repo, + Pleroma.Activity + ] end test "list of atoms" do - binary = ConfigDB.transform([":v1", ":v2", ":v3"]) - assert binary == :erlang.term_to_binary([:v1, :v2, :v3]) - assert ConfigDB.from_binary(binary) == [:v1, :v2, :v3] + assert ConfigDB.to_elixir_types([":v1", ":v2", ":v3"]) == [:v1, :v2, :v3] end test "list of mixed values" do - binary = - ConfigDB.transform([ - "v1", - ":v2", - "Pleroma.Repo", - "Phoenix.Socket.V1.JSONSerializer", - 15, - false - ]) - - assert binary == - :erlang.term_to_binary([ - "v1", - :v2, - Pleroma.Repo, - Phoenix.Socket.V1.JSONSerializer, - 15, - false - ]) - - assert ConfigDB.from_binary(binary) == [ + assert ConfigDB.to_elixir_types([ + "v1", + ":v2", + "Pleroma.Repo", + "Phoenix.Socket.V1.JSONSerializer", + 15, + false + ]) == [ "v1", :v2, Pleroma.Repo, @@ -470,40 +379,23 @@ test "list of mixed values" do end test "simple keyword" do - binary = ConfigDB.transform([%{"tuple" => [":key", "value"]}]) - assert binary == :erlang.term_to_binary([{:key, "value"}]) - assert ConfigDB.from_binary(binary) == [{:key, "value"}] - assert ConfigDB.from_binary(binary) == [key: "value"] + assert ConfigDB.to_elixir_types([%{"tuple" => [":key", "value"]}]) == [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] + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]} + ]) == [partial_chain: &:hackney_connect.partial_chain/1] end test "keyword" do - binary = - ConfigDB.transform([ - %{"tuple" => [":types", "Pleroma.PostgresTypes"]}, - %{"tuple" => [":telemetry_event", ["Pleroma.Repo.Instrumenter"]]}, - %{"tuple" => [":migration_lock", nil]}, - %{"tuple" => [":key1", 150]}, - %{"tuple" => [":key2", "string"]} - ]) - - assert binary == - :erlang.term_to_binary( - types: Pleroma.PostgresTypes, - telemetry_event: [Pleroma.Repo.Instrumenter], - migration_lock: nil, - key1: 150, - key2: "string" - ) - - assert ConfigDB.from_binary(binary) == [ + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":types", "Pleroma.PostgresTypes"]}, + %{"tuple" => [":telemetry_event", ["Pleroma.Repo.Instrumenter"]]}, + %{"tuple" => [":migration_lock", nil]}, + %{"tuple" => [":key1", 150]}, + %{"tuple" => [":key2", "string"]} + ]) == [ types: Pleroma.PostgresTypes, telemetry_event: [Pleroma.Repo.Instrumenter], migration_lock: nil, @@ -513,85 +405,55 @@ test "keyword" do end test "complex keyword with nested mixed childs" do - binary = - ConfigDB.transform([ - %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]}, - %{"tuple" => [":filters", ["Pleroma.Upload.Filter.Dedupe"]]}, - %{"tuple" => [":link_name", true]}, - %{"tuple" => [":proxy_remote", false]}, - %{"tuple" => [":common_map", %{":key" => "value"}]}, - %{ - "tuple" => [ - ":proxy_opts", - [ - %{"tuple" => [":redirect_on_failure", false]}, - %{"tuple" => [":max_body_length", 1_048_576]}, - %{ - "tuple" => [ - ":http", - [%{"tuple" => [":follow_redirect", true]}, %{"tuple" => [":pool", ":upload"]}] - ] - } - ] - ] - } - ]) - - assert binary == - :erlang.term_to_binary( - uploader: Pleroma.Uploaders.Local, - filters: [Pleroma.Upload.Filter.Dedupe], - link_name: true, - proxy_remote: false, - common_map: %{key: "value"}, - proxy_opts: [ - redirect_on_failure: false, - max_body_length: 1_048_576, - http: [ - follow_redirect: true, - pool: :upload + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]}, + %{"tuple" => [":filters", ["Pleroma.Upload.Filter.Dedupe"]]}, + %{"tuple" => [":link_name", true]}, + %{"tuple" => [":proxy_remote", false]}, + %{"tuple" => [":common_map", %{":key" => "value"}]}, + %{ + "tuple" => [ + ":proxy_opts", + [ + %{"tuple" => [":redirect_on_failure", false]}, + %{"tuple" => [":max_body_length", 1_048_576]}, + %{ + "tuple" => [ + ":http", + [ + %{"tuple" => [":follow_redirect", true]}, + %{"tuple" => [":pool", ":upload"]} + ] + ] + } ] ] - ) - - assert ConfigDB.from_binary(binary) == - [ - uploader: Pleroma.Uploaders.Local, - filters: [Pleroma.Upload.Filter.Dedupe], - link_name: true, - proxy_remote: false, - common_map: %{key: "value"}, - proxy_opts: [ - redirect_on_failure: false, - max_body_length: 1_048_576, - http: [ - follow_redirect: true, - pool: :upload - ] + } + ]) == [ + uploader: Pleroma.Uploaders.Local, + filters: [Pleroma.Upload.Filter.Dedupe], + link_name: true, + proxy_remote: false, + common_map: %{key: "value"}, + proxy_opts: [ + redirect_on_failure: false, + max_body_length: 1_048_576, + http: [ + follow_redirect: true, + pool: :upload ] ] + ] end test "common keyword" do - binary = - ConfigDB.transform([ - %{"tuple" => [":level", ":warn"]}, - %{"tuple" => [":meta", [":all"]]}, - %{"tuple" => [":path", ""]}, - %{"tuple" => [":val", nil]}, - %{"tuple" => [":webhook_url", "https://hooks.slack.com/services/YOUR-KEY-HERE"]} - ]) - - assert binary == - :erlang.term_to_binary( - level: :warn, - meta: [:all], - path: "", - val: nil, - webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE" - ) - - assert ConfigDB.from_binary(binary) == [ + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":level", ":warn"]}, + %{"tuple" => [":meta", [":all"]]}, + %{"tuple" => [":path", ""]}, + %{"tuple" => [":val", nil]}, + %{"tuple" => [":webhook_url", "https://hooks.slack.com/services/YOUR-KEY-HERE"]} + ]) == [ level: :warn, meta: [:all], path: "", @@ -601,98 +463,73 @@ test "common keyword" do end test "complex keyword with sigil" do - binary = - ConfigDB.transform([ - %{"tuple" => [":federated_timeline_removal", []]}, - %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]}, - %{"tuple" => [":replace", []]} - ]) - - assert binary == - :erlang.term_to_binary( - federated_timeline_removal: [], - reject: [~r/comp[lL][aA][iI][nN]er/], - replace: [] - ) - - assert ConfigDB.from_binary(binary) == - [federated_timeline_removal: [], reject: [~r/comp[lL][aA][iI][nN]er/], replace: []] + assert ConfigDB.to_elixir_types([ + %{"tuple" => [":federated_timeline_removal", []]}, + %{"tuple" => [":reject", ["~r/comp[lL][aA][iI][nN]er/"]]}, + %{"tuple" => [":replace", []]} + ]) == [ + federated_timeline_removal: [], + reject: [~r/comp[lL][aA][iI][nN]er/], + replace: [] + ] end test "complex keyword with tuples with more than 2 values" do - binary = - ConfigDB.transform([ - %{ - "tuple" => [ - ":http", - [ - %{ - "tuple" => [ - ":key1", - [ - %{ - "tuple" => [ - ":_", - [ - %{ - "tuple" => [ - "/api/v1/streaming", - "Pleroma.Web.MastodonAPI.WebsocketHandler", - [] - ] - }, - %{ - "tuple" => [ - "/websocket", - "Phoenix.Endpoint.CowboyWebSocket", - %{ - "tuple" => [ - "Phoenix.Transports.WebSocket", - %{ - "tuple" => [ - "Pleroma.Web.Endpoint", - "Pleroma.Web.UserSocket", - [] - ] - } - ] - } - ] - }, - %{ - "tuple" => [ - ":_", - "Phoenix.Endpoint.Cowboy2Handler", - %{"tuple" => ["Pleroma.Web.Endpoint", []]} - ] - } - ] - ] - } - ] - ] - } - ] - ] - } - ]) - - assert binary == - :erlang.term_to_binary( - http: [ - key1: [ - _: [ - {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, - {"/websocket", Phoenix.Endpoint.CowboyWebSocket, - {Phoenix.Transports.WebSocket, - {Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, []}}}, - {:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}} - ] + assert ConfigDB.to_elixir_types([ + %{ + "tuple" => [ + ":http", + [ + %{ + "tuple" => [ + ":key1", + [ + %{ + "tuple" => [ + ":_", + [ + %{ + "tuple" => [ + "/api/v1/streaming", + "Pleroma.Web.MastodonAPI.WebsocketHandler", + [] + ] + }, + %{ + "tuple" => [ + "/websocket", + "Phoenix.Endpoint.CowboyWebSocket", + %{ + "tuple" => [ + "Phoenix.Transports.WebSocket", + %{ + "tuple" => [ + "Pleroma.Web.Endpoint", + "Pleroma.Web.UserSocket", + [] + ] + } + ] + } + ] + }, + %{ + "tuple" => [ + ":_", + "Phoenix.Endpoint.Cowboy2Handler", + %{"tuple" => ["Pleroma.Web.Endpoint", []]} + ] + } + ] + ] + } + ] + ] + } ] ] - ) - - assert ConfigDB.from_binary(binary) == [ + } + ]) == [ http: [ key1: [ {:_, diff --git a/test/config/transfer_task_test.exs b/test/config/transfer_task_test.exs index 473899d1d..f53829e09 100644 --- a/test/config/transfer_task_test.exs +++ b/test/config/transfer_task_test.exs @@ -6,9 +6,9 @@ defmodule Pleroma.Config.TransferTaskTest do use Pleroma.DataCase import ExUnit.CaptureLog + import Pleroma.Factory alias Pleroma.Config.TransferTask - alias Pleroma.ConfigDB setup do: clear_config(:configurable_from_database, true) @@ -19,31 +19,11 @@ test "transfer config values from db to env" do refute Application.get_env(:postgrex, :test_key) initial = Application.get_env(:logger, :level) - ConfigDB.create(%{ - group: ":pleroma", - key: ":test_key", - value: [live: 2, com: 3] - }) - - ConfigDB.create(%{ - group: ":idna", - key: ":test_key", - value: [live: 15, com: 35] - }) - - ConfigDB.create(%{ - group: ":quack", - key: ":test_key", - value: [:test_value1, :test_value2] - }) - - ConfigDB.create(%{ - group: ":postgrex", - key: ":test_key", - value: :value - }) - - ConfigDB.create(%{group: ":logger", key: ":level", value: :debug}) + insert(:config, key: :test_key, value: [live: 2, com: 3]) + insert(:config, group: :idna, key: :test_key, value: [live: 15, com: 35]) + insert(:config, group: :quack, key: :test_key, value: [:test_value1, :test_value2]) + insert(:config, group: :postgrex, key: :test_key, value: :value) + insert(:config, group: :logger, key: :level, value: :debug) TransferTask.start_link([]) @@ -66,17 +46,8 @@ test "transfer config values for 1 group and some keys" do level = Application.get_env(:quack, :level) meta = Application.get_env(:quack, :meta) - ConfigDB.create(%{ - group: ":quack", - key: ":level", - value: :info - }) - - ConfigDB.create(%{ - group: ":quack", - key: ":meta", - value: [:none] - }) + insert(:config, group: :quack, key: :level, value: :info) + insert(:config, group: :quack, key: :meta, value: [:none]) TransferTask.start_link([]) @@ -95,17 +66,8 @@ test "transfer config values with full subkey update" do clear_config(:emoji) clear_config(:assets) - ConfigDB.create(%{ - group: ":pleroma", - key: ":emoji", - value: [groups: [a: 1, b: 2]] - }) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":assets", - value: [mascots: [a: 1, b: 2]] - }) + insert(:config, key: :emoji, value: [groups: [a: 1, b: 2]]) + insert(:config, key: :assets, value: [mascots: [a: 1, b: 2]]) TransferTask.start_link([]) @@ -122,12 +84,7 @@ test "transfer config values with full subkey update" do test "don't restart if no reboot time settings were changed" do clear_config(:emoji) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":emoji", - value: [groups: [a: 1, b: 2]] - }) + insert(:config, key: :emoji, value: [groups: [a: 1, b: 2]]) refute String.contains?( capture_log(fn -> TransferTask.start_link([]) end), @@ -137,25 +94,13 @@ test "don't restart if no reboot time settings were changed" do test "on reboot time key" do clear_config(:chat) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":chat", - value: [enabled: false] - }) - + insert(:config, key: :chat, value: [enabled: false]) assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted" end test "on reboot time subkey" do clear_config(Pleroma.Captcha) - - ConfigDB.create(%{ - group: ":pleroma", - key: "Pleroma.Captcha", - value: [seconds_valid: 60] - }) - + insert(:config, key: Pleroma.Captcha, value: [seconds_valid: 60]) assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted" end @@ -163,17 +108,8 @@ test "don't restart pleroma on reboot time key and subkey if there is false flag clear_config(:chat) clear_config(Pleroma.Captcha) - ConfigDB.create(%{ - group: ":pleroma", - key: ":chat", - value: [enabled: false] - }) - - ConfigDB.create(%{ - group: ":pleroma", - key: "Pleroma.Captcha", - value: [seconds_valid: 60] - }) + insert(:config, key: :chat, value: [enabled: false]) + insert(:config, key: Pleroma.Captcha, value: [seconds_valid: 60]) refute String.contains?( capture_log(fn -> TransferTask.load_and_update_env([], false) end), diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e3676aca..e517d5bc6 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -396,24 +396,17 @@ def registration_factory do } end - def config_factory do + def config_factory(attrs \\ %{}) do %Pleroma.ConfigDB{ - key: - sequence(:key, fn key -> - # Atom dynamic registration hack in tests - "some_key_#{key}" - |> String.to_atom() - |> inspect() - end), - group: ":pleroma", + key: sequence(:key, &String.to_atom("some_key_#{&1}")), + group: :pleroma, value: sequence( :value, - fn key -> - :erlang.term_to_binary(%{another_key: "#{key}somevalue", another: "#{key}somevalue"}) - end + &%{another_key: "#{&1}somevalue", another: "#{&1}somevalue"} ) } + |> merge_attributes(attrs) end def marker_factory do diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index 04bc947a9..e1bddfebf 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -5,6 +5,8 @@ defmodule Mix.Tasks.Pleroma.ConfigTest do use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.ConfigDB alias Pleroma.Repo @@ -49,24 +51,19 @@ test "filtered settings are migrated to db" do refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"}) - assert ConfigDB.from_binary(config1.value) == [key: "value", key2: [Repo]] - assert ConfigDB.from_binary(config2.value) == [key: "value2", key2: ["Activity"]] - assert ConfigDB.from_binary(config3.value) == :info + assert config1.value == [key: "value", key2: [Repo]] + assert config2.value == [key: "value2", key2: ["Activity"]] + assert config3.value == :info end test "config table is truncated before migration" do - ConfigDB.create(%{ - group: ":pleroma", - key: ":first_setting", - value: [key: "value", key2: ["Activity"]] - }) - + insert(:config, key: :first_setting, value: [key: "value", key2: ["Activity"]]) assert Repo.aggregate(ConfigDB, :count, :id) == 1 Mix.Tasks.Pleroma.Config.migrate_to_db("test/fixtures/config/temp.secret.exs") config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":first_setting"}) - assert ConfigDB.from_binary(config.value) == [key: "value", key2: [Repo]] + assert config.value == [key: "value", key2: [Repo]] end end @@ -82,19 +79,9 @@ test "config table is truncated before migration" do end test "settings are migrated to file and deleted from db", %{temp_file: temp_file} do - ConfigDB.create(%{ - group: ":pleroma", - key: ":setting_first", - value: [key: "value", key2: ["Activity"]] - }) - - ConfigDB.create(%{ - group: ":pleroma", - key: ":setting_second", - value: [key: "value2", key2: [Repo]] - }) - - ConfigDB.create(%{group: ":quack", key: ":level", value: :info}) + insert(:config, key: :setting_first, value: [key: "value", key2: ["Activity"]]) + insert(:config, key: :setting_second, value: [key: "value2", key2: [Repo]]) + insert(:config, group: :quack, key: :level, value: :info) Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) @@ -107,9 +94,8 @@ test "settings are migrated to file and deleted from db", %{temp_file: temp_file end test "load a settings with large values and pass to file", %{temp_file: temp_file} do - ConfigDB.create(%{ - group: ":pleroma", - key: ":instance", + insert(:config, + key: :instance, value: [ name: "Pleroma", email: "example@example.com", @@ -163,7 +149,6 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil extended_nickname_format: true, multi_factor_authentication: [ totp: [ - # digits 6 or 8 digits: 6, period: 30 ], @@ -173,7 +158,7 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil ] ] ] - }) + ) Mix.Tasks.Pleroma.Config.run(["migrate_from_db", "--env", "temp", "-d"]) diff --git a/test/upload/filter/mogrify_test.exs b/test/upload/filter/mogrify_test.exs index b6a463e8c..62ca30487 100644 --- a/test/upload/filter/mogrify_test.exs +++ b/test/upload/filter/mogrify_test.exs @@ -6,21 +6,17 @@ defmodule Pleroma.Upload.Filter.MogrifyTest do use Pleroma.DataCase import Mock - alias Pleroma.Config - alias Pleroma.Upload alias Pleroma.Upload.Filter - setup do: clear_config([Filter.Mogrify, :args]) - test "apply mogrify filter" do - Config.put([Filter.Mogrify, :args], [{"tint", "40"}]) + clear_config(Filter.Mogrify, args: [{"tint", "40"}]) File.cp!( "test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg" ) - upload = %Upload{ + upload = %Pleroma.Upload{ name: "an… image.jpg", content_type: "image/jpg", path: Path.absname("test/fixtures/image_tmp.jpg"), diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs index 780de8d18..064ef9bc7 100644 --- a/test/web/admin_api/controllers/config_controller_test.exs +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -57,12 +57,12 @@ test "with settings only in db", %{conn: conn} do ] } = json_response_and_validate_schema(conn, 200) - assert key1 == config1.key - assert key2 == config2.key + assert key1 == inspect(config1.key) + assert key2 == inspect(config2.key) end test "db is added to settings that are in db", %{conn: conn} do - _config = insert(:config, key: ":instance", value: ConfigDB.to_binary(name: "Some name")) + _config = insert(:config, key: ":instance", value: [name: "Some name"]) %{"configs" => configs} = conn @@ -83,7 +83,7 @@ test "merged default setting with db settings", %{conn: conn} do config3 = insert(:config, - value: ConfigDB.to_binary(k1: :v1, k2: :v2) + value: [k1: :v1, k2: :v2] ) %{"configs" => configs} = @@ -93,42 +93,45 @@ test "merged default setting with db settings", %{conn: conn} do assert length(configs) > 3 + saved_configs = [config1, config2, config3] + keys = Enum.map(saved_configs, &inspect(&1.key)) + received_configs = Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key, config3.key] + group == ":pleroma" and key in keys end) assert length(received_configs) == 3 db_keys = config3.value - |> ConfigDB.from_binary() |> Keyword.keys() - |> ConfigDB.convert() + |> ConfigDB.to_json_types() + + keys = Enum.map(saved_configs -- [config3], &inspect(&1.key)) + + values = Enum.map(saved_configs, &ConfigDB.to_json_types(&1.value)) + + mapset_keys = MapSet.new(keys ++ db_keys) Enum.each(received_configs, fn %{"value" => value, "db" => db} -> - assert db in [[config1.key], [config2.key], db_keys] + db = MapSet.new(db) + assert MapSet.subset?(db, mapset_keys) - assert value in [ - ConfigDB.from_binary_with_convert(config1.value), - ConfigDB.from_binary_with_convert(config2.value), - ConfigDB.from_binary_with_convert(config3.value) - ] + assert value in values end) end test "subkeys with full update right merge", %{conn: conn} do - config1 = - insert(:config, - key: ":emoji", - value: ConfigDB.to_binary(groups: [a: 1, b: 2], key: [a: 1]) - ) + insert(:config, + key: ":emoji", + value: [groups: [a: 1, b: 2], key: [a: 1]] + ) - config2 = - insert(:config, - key: ":assets", - value: ConfigDB.to_binary(mascots: [a: 1, b: 2], key: [a: 1]) - ) + insert(:config, + key: ":assets", + value: [mascots: [a: 1, b: 2], key: [a: 1]] + ) %{"configs" => configs} = conn @@ -137,14 +140,14 @@ test "subkeys with full update right merge", %{conn: conn} do vals = Enum.filter(configs, fn %{"group" => group, "key" => key} -> - group == ":pleroma" and key in [config1.key, config2.key] + group == ":pleroma" and key in [":emoji", ":assets"] end) emoji = Enum.find(vals, fn %{"key" => key} -> key == ":emoji" end) assets = Enum.find(vals, fn %{"key" => key} -> key == ":assets" end) - emoji_val = ConfigDB.transform_with_out_binary(emoji["value"]) - assets_val = ConfigDB.transform_with_out_binary(assets["value"]) + emoji_val = ConfigDB.to_elixir_types(emoji["value"]) + assets_val = ConfigDB.to_elixir_types(assets["value"]) assert emoji_val[:groups] == [a: 1, b: 2] assert assets_val[:mascots] == [a: 1, b: 2] @@ -277,7 +280,8 @@ test "create new config setting in db", %{conn: conn} do "value" => %{"tuple" => ["string", "Pleroma.Captcha.NotReal", []]}, "db" => [":key5"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:pleroma, :key1) == "value1" @@ -357,7 +361,8 @@ test "save configs setting without explicit key", %{conn: conn} do "value" => "https://hooks.slack.com/services/KEY", "db" => [":webhook_url"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:quack, :level) == :info @@ -366,14 +371,14 @@ test "save configs setting without explicit key", %{conn: conn} do end test "saving config with partial update", %{conn: conn} do - config = insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) + insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: 2)) conn = conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config.group, key: config.key, value: [%{"tuple" => [":key3", 3]}]} + %{group: ":pleroma", key: ":key1", value: [%{"tuple" => [":key3", 3]}]} ] }) @@ -389,7 +394,8 @@ test "saving config with partial update", %{conn: conn} do ], "db" => [":key1", ":key2", ":key3"] } - ] + ], + "need_reboot" => false } end @@ -500,8 +506,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", end test "saving config with nested merge", %{conn: conn} do - config = - insert(:config, key: ":key1", value: :erlang.term_to_binary(key1: 1, key2: [k1: 1, k2: 2])) + insert(:config, key: :key1, value: [key1: 1, key2: [k1: 1, k2: 2]]) conn = conn @@ -509,8 +514,8 @@ test "saving config with nested merge", %{conn: conn} do |> post("/api/pleroma/admin/config", %{ configs: [ %{ - group: config.group, - key: config.key, + group: ":pleroma", + key: ":key1", value: [ %{"tuple" => [":key3", 3]}, %{ @@ -548,7 +553,8 @@ test "saving config with nested merge", %{conn: conn} do ], "db" => [":key1", ":key3", ":key2"] } - ] + ], + "need_reboot" => false } end @@ -588,7 +594,8 @@ test "saving special atoms", %{conn: conn} do ], "db" => [":ssl_options"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:pleroma, :key1) == [ @@ -600,12 +607,11 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do backends = Application.get_env(:logger, :backends) on_exit(fn -> Application.put_env(:logger, :backends, backends) end) - config = - insert(:config, - group: ":logger", - key: ":backends", - value: :erlang.term_to_binary([]) - ) + insert(:config, + group: :logger, + key: :backends, + value: [] + ) Pleroma.Config.TransferTask.load_and_update_env([], false) @@ -617,8 +623,8 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do |> post("/api/pleroma/admin/config", %{ configs: [ %{ - group: config.group, - key: config.key, + group: ":logger", + key: ":backends", value: [":console"] } ] @@ -634,7 +640,8 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do ], "db" => [":backends"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:logger, :backends) == [ @@ -643,19 +650,18 @@ test "saving full setting if value is in full_key_update list", %{conn: conn} do end test "saving full setting if value is not keyword", %{conn: conn} do - config = - insert(:config, - group: ":tesla", - key: ":adapter", - value: :erlang.term_to_binary(Tesla.Adapter.Hackey) - ) + insert(:config, + group: :tesla, + key: :adapter, + value: Tesla.Adapter.Hackey + ) conn = conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config.group, key: config.key, value: "Tesla.Adapter.Httpc"} + %{group: ":tesla", key: ":adapter", value: "Tesla.Adapter.Httpc"} ] }) @@ -667,7 +673,8 @@ test "saving full setting if value is not keyword", %{conn: conn} do "value" => "Tesla.Adapter.Httpc", "db" => [":adapter"] } - ] + ], + "need_reboot" => false } end @@ -677,13 +684,13 @@ test "update config setting & delete with fallback to default value", %{ token: token } do ueberauth = Application.get_env(:ueberauth, Ueberauth) - config1 = insert(:config, key: ":keyaa1") - config2 = insert(:config, key: ":keyaa2") + insert(:config, key: :keyaa1) + insert(:config, key: :keyaa2) config3 = insert(:config, - group: ":ueberauth", - key: "Ueberauth" + group: :ueberauth, + key: Ueberauth ) conn = @@ -691,8 +698,8 @@ test "update config setting & delete with fallback to default value", %{ |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config1.group, key: config1.key, value: "another_value"}, - %{group: config2.group, key: config2.key, value: "another_value"} + %{group: ":pleroma", key: ":keyaa1", value: "another_value"}, + %{group: ":pleroma", key: ":keyaa2", value: "another_value"} ] }) @@ -700,22 +707,23 @@ test "update config setting & delete with fallback to default value", %{ "configs" => [ %{ "group" => ":pleroma", - "key" => config1.key, + "key" => ":keyaa1", "value" => "another_value", "db" => [":keyaa1"] }, %{ "group" => ":pleroma", - "key" => config2.key, + "key" => ":keyaa2", "value" => "another_value", "db" => [":keyaa2"] } - ] + ], + "need_reboot" => false } assert Application.get_env(:pleroma, :keyaa1) == "another_value" assert Application.get_env(:pleroma, :keyaa2) == "another_value" - assert Application.get_env(:ueberauth, Ueberauth) == ConfigDB.from_binary(config3.value) + assert Application.get_env(:ueberauth, Ueberauth) == config3.value conn = build_conn() @@ -724,7 +732,7 @@ test "update config setting & delete with fallback to default value", %{ |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/config", %{ configs: [ - %{group: config2.group, key: config2.key, delete: true}, + %{group: ":pleroma", key: ":keyaa2", delete: true}, %{ group: ":ueberauth", key: "Ueberauth", @@ -734,7 +742,8 @@ test "update config setting & delete with fallback to default value", %{ }) assert json_response_and_validate_schema(conn, 200) == %{ - "configs" => [] + "configs" => [], + "need_reboot" => false } assert Application.get_env(:ueberauth, Ueberauth) == ueberauth @@ -801,7 +810,8 @@ test "common config example", %{conn: conn} do ":name" ] } - ] + ], + "need_reboot" => false } end @@ -935,7 +945,8 @@ test "tuples with more than two values", %{conn: conn} do ], "db" => [":http"] } - ] + ], + "need_reboot" => false } end @@ -1000,7 +1011,8 @@ test "settings with nesting map", %{conn: conn} do ], "db" => [":key2", ":key3"] } - ] + ], + "need_reboot" => false } end @@ -1027,7 +1039,8 @@ test "value as map", %{conn: conn} do "value" => %{"key" => "some_val"}, "db" => [":key1"] } - ] + ], + "need_reboot" => false } end @@ -1077,16 +1090,16 @@ test "queues key as atom", %{conn: conn} do ":background" ] } - ] + ], + "need_reboot" => false } end test "delete part of settings by atom subkeys", %{conn: conn} do - config = - insert(:config, - key: ":keyaa1", - value: :erlang.term_to_binary(subkey1: "val1", subkey2: "val2", subkey3: "val3") - ) + insert(:config, + key: :keyaa1, + value: [subkey1: "val1", subkey2: "val2", subkey3: "val3"] + ) conn = conn @@ -1094,8 +1107,8 @@ test "delete part of settings by atom subkeys", %{conn: conn} do |> post("/api/pleroma/admin/config", %{ configs: [ %{ - group: config.group, - key: config.key, + group: ":pleroma", + key: ":keyaa1", subkeys: [":subkey1", ":subkey3"], delete: true } @@ -1110,7 +1123,8 @@ test "delete part of settings by atom subkeys", %{conn: conn} do "value" => [%{"tuple" => [":subkey2", "val2"]}], "db" => [":subkey2"] } - ] + ], + "need_reboot" => false } end @@ -1236,6 +1250,90 @@ test "doesn't set keys not in the whitelist", %{conn: conn} do assert Application.get_env(:pleroma, Pleroma.Captcha.NotReal) == "value5" assert Application.get_env(:not_real, :anything) == "value6" end + + test "args for Pleroma.Upload.Filter.Mogrify with custom tuples", %{conn: conn} do + clear_config(Pleroma.Upload.Filter.Mogrify) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: "Pleroma.Upload.Filter.Mogrify", + value: [ + %{"tuple" => [":args", ["auto-orient", "strip"]]} + ] + } + ] + }) + |> json_response_and_validate_schema(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Upload.Filter.Mogrify", + "value" => [ + %{"tuple" => [":args", ["auto-orient", "strip"]]} + ], + "db" => [":args"] + } + ], + "need_reboot" => false + } + + assert Config.get(Pleroma.Upload.Filter.Mogrify) == [args: ["auto-orient", "strip"]] + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{ + configs: [ + %{ + group: ":pleroma", + key: "Pleroma.Upload.Filter.Mogrify", + value: [ + %{ + "tuple" => [ + ":args", + [ + "auto-orient", + "strip", + "{\"implode\", \"1\"}", + "{\"resize\", \"3840x1080>\"}" + ] + ] + } + ] + } + ] + }) + |> json_response(200) == %{ + "configs" => [ + %{ + "group" => ":pleroma", + "key" => "Pleroma.Upload.Filter.Mogrify", + "value" => [ + %{ + "tuple" => [ + ":args", + [ + "auto-orient", + "strip", + "{\"implode\", \"1\"}", + "{\"resize\", \"3840x1080>\"}" + ] + ] + } + ], + "db" => [":args"] + } + ], + "need_reboot" => false + } + + assert Config.get(Pleroma.Upload.Filter.Mogrify) == [ + args: ["auto-orient", "strip", {"implode", "1"}, {"resize", "3840x1080>"}] + ] + end end describe "GET /api/pleroma/admin/config/descriptions" do -- cgit v1.2.3 From 23decaab81b900bff0f6eacad7ea6a894239e4ce Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 12:38:24 +0300 Subject: fix for updated hackney warning: :hackney_connect.partial_chain/1 is undefined or private --- test/config/config_db_test.exs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index a04575c6f..8d753e255 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -382,12 +382,6 @@ test "simple keyword" do assert ConfigDB.to_elixir_types([%{"tuple" => [":key", "value"]}]) == [key: "value"] end - test "keyword with partial_chain key" do - assert ConfigDB.to_elixir_types([ - %{"tuple" => [":partial_chain", "&:hackney_connect.partial_chain/1"]} - ]) == [partial_chain: &:hackney_connect.partial_chain/1] - end - test "keyword" do assert ConfigDB.to_elixir_types([ %{"tuple" => [":types", "Pleroma.PostgresTypes"]}, -- cgit v1.2.3 From e1603ac8fee2a660c3dc510dee5967e0fd1bbd98 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 13:25:04 +0300 Subject: fix attemps to merge map --- lib/pleroma/config/config_db.ex | 3 ++- test/config/config_db_test.exs | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 39b37c42e..70be17ecf 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -135,7 +135,8 @@ def update_or_create(params) do with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, - {_, true, config} <- {:can_be_merged, is_list(params[:value]), config} do + {_, true, config} <- + {:can_be_merged, is_list(params[:value]) and is_list(config.value), config} do new_value = merge_group(config.group, config.key, config.value, params[:value]) update(config, %{value: new_value}) else diff --git a/test/config/config_db_test.exs b/test/config/config_db_test.exs index 8d753e255..3895e2cda 100644 --- a/test/config/config_db_test.exs +++ b/test/config/config_db_test.exs @@ -43,7 +43,7 @@ test "common" do params = [ %{group: :pleroma, key: key2, value: "another_value"}, - %{group: :pleroma, key: config.key, value: "new_value"} + %{group: :pleroma, key: config.key, value: [a: 1, b: 2, c: "new_value"]} ] assert Repo.all(ConfigDB) |> length() == 1 @@ -55,7 +55,7 @@ test "common" do config1 = ConfigDB.get_by_params(%{group: config.group, key: config.key}) config2 = ConfigDB.get_by_params(%{group: :pleroma, key: key2}) - assert config1.value == "new_value" + assert config1.value == [a: 1, b: 2, c: "new_value"] assert config2.value == "another_value" end @@ -398,6 +398,10 @@ test "keyword" do ] end + test "trandformed keyword" do + assert ConfigDB.to_elixir_types(a: 1, b: 2, c: "string") == [a: 1, b: 2, c: "string"] + end + test "complex keyword with nested mixed childs" do assert ConfigDB.to_elixir_types([ %{"tuple" => [":uploader", "Pleroma.Uploaders.Local"]}, -- cgit v1.2.3 From 32c6576b600e2f24310f429f4b2391f95a5b5ba0 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sun, 31 May 2020 14:42:15 +0300 Subject: naming --- lib/pleroma/config/config_db.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 70be17ecf..30bd51b05 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -331,7 +331,7 @@ def string_to_elixir_types("~r" <> _pattern = regex) do def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) def string_to_elixir_types(value) do - if is_module_name?(value) do + if module_name?(value) do String.to_existing_atom("Elixir." <> value) else value @@ -373,8 +373,8 @@ defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do end end - @spec is_module_name?(String.t()) :: boolean() - def is_module_name?(string) do + @spec module_name?(String.t()) :: boolean() + def module_name?(string) do Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or string in ["Oban", "Ueberauth", "ExSyslogger"] end -- cgit v1.2.3 From aca6a7543ae97da2d1af8a6f9c547a0088d9e240 Mon Sep 17 00:00:00 2001 From: Steven Fuchs <steven.fuchs@dockyard.com> Date: Tue, 16 Jun 2020 13:18:29 +0000 Subject: Upgrade to Elixir 1.9 --- .gitlab-ci.yml | 2 +- elixir_buildpack.config | 4 ++-- mix.exs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aad28a2d8..bc7b289a2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: elixir:1.8.1 +image: elixir:1.9.4 variables: &global_variables POSTGRES_DB: pleroma_test diff --git a/elixir_buildpack.config b/elixir_buildpack.config index c23b08fb8..946408c12 100644 --- a/elixir_buildpack.config +++ b/elixir_buildpack.config @@ -1,2 +1,2 @@ -elixir_version=1.8.2 -erlang_version=21.3.7 +elixir_version=1.9.4 +erlang_version=22.3.4.1 diff --git a/mix.exs b/mix.exs index 03b060bc0..6040c994e 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ def project do [ app: :pleroma, version: version("2.0.50"), - elixir: "~> 1.8", + elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), elixirc_options: [warnings_as_errors: warnings_as_errors(Mix.env())], -- cgit v1.2.3 From 3c2cee33adcd79a76b192a66f6f2d3772e2fda99 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 16 Jun 2020 17:50:33 +0300 Subject: moving custom ecto types in context folders --- lib/pleroma/config/config_db.ex | 6 ++-- lib/pleroma/config/type/atom.ex | 22 ------------ lib/pleroma/config/type/binary_value.ex | 23 ------------- .../activity_pub/object_validators/date_time.ex | 38 ++++++++++++++++++++ .../activity_pub/object_validators/object_id.ex | 27 +++++++++++++++ .../activity_pub/object_validators/recipients.ex | 40 ++++++++++++++++++++++ .../activity_pub/object_validators/safe_text.ex | 25 ++++++++++++++ .../activity_pub/object_validators/uri.ex | 24 +++++++++++++ lib/pleroma/ecto_type/config/atom.ex | 26 ++++++++++++++ lib/pleroma/ecto_type/config/binary_value.ex | 27 +++++++++++++++ lib/pleroma/signature.ex | 4 +-- lib/pleroma/user.ex | 4 +-- lib/pleroma/web/activity_pub/object_validator.ex | 4 +-- .../object_validators/announce_validator.ex | 14 ++++---- .../object_validators/chat_message_validator.ex | 12 +++---- .../create_chat_message_validator.ex | 10 +++--- .../object_validators/create_note_validator.ex | 6 ++-- .../object_validators/delete_validator.ex | 14 ++++---- .../object_validators/emoji_react_validator.ex | 8 ++--- .../object_validators/like_validator.ex | 14 ++++---- .../object_validators/note_validator.ex | 12 +++---- .../object_validators/types/date_time.ex | 34 ------------------ .../object_validators/types/object_id.ex | 23 ------------- .../object_validators/types/recipients.ex | 36 ------------------- .../object_validators/types/safe_text.ex | 25 -------------- .../activity_pub/object_validators/types/uri.ex | 20 ----------- .../object_validators/undo_validator.ex | 8 ++--- .../object_validators/url_object_validator.ex | 8 +++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 4 +-- .../object_validators/types/date_time_test.exs | 2 +- .../object_validators/types/object_id_test.exs | 2 +- .../object_validators/types/recipients_test.exs | 2 +- .../object_validators/types/safe_text_test.exs | 2 +- 33 files changed, 277 insertions(+), 249 deletions(-) delete mode 100644 lib/pleroma/config/type/atom.ex delete mode 100644 lib/pleroma/config/type/binary_value.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex create mode 100644 lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex create mode 100644 lib/pleroma/ecto_type/config/atom.ex create mode 100644 lib/pleroma/ecto_type/config/binary_value.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/types/date_time.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/types/object_id.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/types/recipients.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/types/uri.ex diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 30bd51b05..2f4eb8581 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -23,9 +23,9 @@ defmodule Pleroma.ConfigDB do ] schema "config" do - field(:key, Pleroma.Config.Type.Atom) - field(:group, Pleroma.Config.Type.Atom) - field(:value, Pleroma.Config.Type.BinaryValue) + field(:key, Pleroma.EctoType.Config.Atom) + field(:group, Pleroma.EctoType.Config.Atom) + field(:value, Pleroma.EctoType.Config.BinaryValue) field(:db, {:array, :string}, virtual: true, default: []) timestamps() diff --git a/lib/pleroma/config/type/atom.ex b/lib/pleroma/config/type/atom.ex deleted file mode 100644 index 387869284..000000000 --- a/lib/pleroma/config/type/atom.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Pleroma.Config.Type.Atom do - use Ecto.Type - - def type, do: :atom - - def cast(key) when is_atom(key) do - {:ok, key} - end - - def cast(key) when is_binary(key) do - {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} - end - - def cast(_), do: :error - - def load(key) do - {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} - end - - def dump(key) when is_atom(key), do: {:ok, inspect(key)} - def dump(_), do: :error -end diff --git a/lib/pleroma/config/type/binary_value.ex b/lib/pleroma/config/type/binary_value.ex deleted file mode 100644 index 17c5524a3..000000000 --- a/lib/pleroma/config/type/binary_value.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Pleroma.Config.Type.BinaryValue do - use Ecto.Type - - def type, do: :term - - def cast(value) when is_binary(value) do - if String.valid?(value) do - {:ok, value} - else - {:ok, :erlang.binary_to_term(value)} - end - end - - def cast(value), do: {:ok, value} - - def load(value) when is_binary(value) do - {:ok, :erlang.binary_to_term(value)} - end - - def dump(value) do - {:ok, :erlang.term_to_binary(value)} - end -end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex new file mode 100644 index 000000000..d852c0abd --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/date_time.ex @@ -0,0 +1,38 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.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/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex new file mode 100644 index 000000000..8034235b0 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/object_id.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID do + use Ecto.Type + + 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{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, object} + _ -> :error + end + end + + def cast(%{"id" => object}), do: cast(object) + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex new file mode 100644 index 000000000..205527a96 --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients do + use Ecto.Type + + alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID + + def type, do: {:array, ObjectID} + + def cast(object) when is_binary(object) do + cast([object]) + end + + def cast(data) when is_list(data) do + data + |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, {:ok, [id | list]}} + + _ -> + {:halt, :error} + end + end) + end + + def cast(_) do + :error + end + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex new file mode 100644 index 000000000..7f0405c7b --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.SafeText do + use Ecto.Type + + alias Pleroma.HTML + + def type, do: :string + + def cast(str) when is_binary(str) do + {:ok, HTML.filter_tags(str)} + end + + def cast(_), do: :error + + def dump(data) do + {:ok, data} + end + + def load(data) do + {:ok, data} + end +end diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex new file mode 100644 index 000000000..2054c26be --- /dev/null +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/uri.ex @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.Uri do + use Ecto.Type + + def type, do: :string + + def cast(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{host: nil} -> :error + %URI{host: ""} -> :error + %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, uri} + _ -> :error + end + end + + def cast(_), do: :error + + def dump(data), do: {:ok, data} + + def load(data), do: {:ok, data} +end diff --git a/lib/pleroma/ecto_type/config/atom.ex b/lib/pleroma/ecto_type/config/atom.ex new file mode 100644 index 000000000..df565d432 --- /dev/null +++ b/lib/pleroma/ecto_type/config/atom.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.Config.Atom do + use Ecto.Type + + def type, do: :atom + + def cast(key) when is_atom(key) do + {:ok, key} + end + + def cast(key) when is_binary(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def cast(_), do: :error + + def load(key) do + {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + end + + def dump(key) when is_atom(key), do: {:ok, inspect(key)} + def dump(_), do: :error +end diff --git a/lib/pleroma/ecto_type/config/binary_value.ex b/lib/pleroma/ecto_type/config/binary_value.ex new file mode 100644 index 000000000..bbd2608c5 --- /dev/null +++ b/lib/pleroma/ecto_type/config/binary_value.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.EctoType.Config.BinaryValue do + use Ecto.Type + + def type, do: :term + + def cast(value) when is_binary(value) do + if String.valid?(value) do + {:ok, value} + else + {:ok, :erlang.binary_to_term(value)} + end + end + + def cast(value), do: {:ok, value} + + def load(value) when is_binary(value) do + {:ok, :erlang.binary_to_term(value)} + end + + def dump(value) do + {:ok, :erlang.term_to_binary(value)} + end +end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index d01728361..3aa6909d2 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -5,10 +5,10 @@ defmodule Pleroma.Signature do @behaviour HTTPSignatures.Adapter + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Keys alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.ObjectValidators.Types def key_id_to_actor_id(key_id) do uri = @@ -24,7 +24,7 @@ def key_id_to_actor_id(key_id) do maybe_ap_id = URI.to_string(uri) - case Types.ObjectID.cast(maybe_ap_id) do + case ObjectValidators.ObjectID.cast(maybe_ap_id) do {:ok, ap_id} -> {:ok, ap_id} diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 52ac9052b..686ab0123 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -14,6 +14,7 @@ defmodule Pleroma.User do alias Pleroma.Config alias Pleroma.Conversation.Participation alias Pleroma.Delivery + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Emoji alias Pleroma.FollowingRelationship alias Pleroma.Formatter @@ -30,7 +31,6 @@ defmodule Pleroma.User do alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI @@ -115,7 +115,7 @@ defmodule Pleroma.User do field(:is_admin, :boolean, default: false) field(:show_role, :boolean, default: true) field(:settings, :map, default: nil) - field(:uri, Types.Uri, default: nil) + field(:uri, ObjectValidators.Uri, default: nil) field(:hide_followers_count, :boolean, default: false) field(:hide_follows_count, :boolean, default: false) field(:hide_followers, :boolean, default: false) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index c01c5f780..3d699e8a5 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do """ alias Pleroma.Object + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator @@ -17,7 +18,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} @@ -120,7 +120,7 @@ def stringify_keys(object) when is_list(object) do def stringify_keys(object), do: object def fetch_actor(object) do - with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do + with {:ok, actor} <- ObjectValidators.ObjectID.cast(object["actor"]) do User.get_or_fetch_by_ap_id(actor) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index 40f861f47..6f757f49c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -19,14 +19,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:context, :string, autogenerate: {Utils, :generate_context_id, []}) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) - field(:published, Types.DateTime) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:published, ObjectValidators.DateTime) end def cast_and_validate(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 138736f23..c481d79e0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -5,9 +5,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.Transmogrifier, only: [fix_emoji: 1] @@ -16,12 +16,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator do @derive Jason.Encoder embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:to, Types.Recipients, default: []) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:to, ObjectValidators.Recipients, default: []) field(:type, :string) - field(:content, Types.SafeText) - field(:actor, Types.ObjectID) - field(:published, Types.DateTime) + field(:content, ObjectValidators.SafeText) + field(:actor, ObjectValidators.ObjectID) + field(:published, ObjectValidators.DateTime) field(:emoji, :map, default: %{}) embeds_one(:attachment, AttachmentValidator) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index fc582400b..7269f9ff0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -7,9 +7,9 @@ # - doesn't embed, will only get the object id defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -17,11 +17,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:actor, Types.ObjectID) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:actor, ObjectValidators.ObjectID) field(:type, :string) - field(:to, Types.Recipients, default: []) - field(:object, Types.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:object, ObjectValidators.ObjectID) end def cast_and_apply(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex index 926804ce7..316bd0c07 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex @@ -5,16 +5,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:actor, Types.ObjectID) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:actor, ObjectValidators.ObjectID) field(:type, :string) field(:to, {:array, :string}) field(:cc, {:array, :string}) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index e5d08eb5c..93a7b0e0b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -15,13 +15,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:actor, Types.ObjectID) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) - field(:deleted_activity_id, Types.ObjectID) - field(:object, Types.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:deleted_activity_id, ObjectValidators.ObjectID) + field(:object, ObjectValidators.ObjectID) end def cast_data(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index e87519c59..a543af1f8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:context, :string) field(:content, :string) field(:to, {:array, :string}, default: []) 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 034f25492..493e4c247 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Utils import Ecto.Changeset @@ -15,13 +15,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:context, :string) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) end def cast_and_validate(data) do @@ -67,7 +67,7 @@ def fix_recipients(cng) do with {[], []} <- {to, cc}, %Object{data: %{"actor" => actor}} <- Object.get_cached_by_ap_id(object), - {:ok, actor} <- Types.ObjectID.cast(actor) do + {:ok, actor} <- ObjectValidators.ObjectID.cast(actor) do cng |> put_change(:to, [actor]) else 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 462a5620a..a10728ac6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -5,14 +5,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) field(:bto, {:array, :string}, default: []) @@ -22,10 +22,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:type, :string) field(:content, :string) field(:context, :string) - field(:actor, Types.ObjectID) - field(:attributedTo, Types.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) field(:summary, :string) - field(:published, Types.DateTime) + field(:published, ObjectValidators.DateTime) # TODO: Write type field(:emoji, :map, default: %{}) field(:sensitive, :boolean, default: false) @@ -35,7 +35,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inRepyTo, :string) - field(:uri, Types.Uri) + field(:uri, ObjectValidators.Uri) field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) 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 deleted file mode 100644 index 4f412fcde..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/types/date_time.ex +++ /dev/null @@ -1,34 +0,0 @@ -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/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex b/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex deleted file mode 100644 index f71f76370..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/types/object_id.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID do - use Ecto.Type - - 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{host: ""} -> :error - %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, object} - _ -> :error - end - end - - def cast(%{"id" => object}), do: cast(object) - - def cast(_), do: :error - - def dump(data), do: {:ok, data} - - def load(data), do: {:ok, data} -end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex b/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex deleted file mode 100644 index 408e0f6ee..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/types/recipients.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do - use Ecto.Type - - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID - - def type, do: {:array, ObjectID} - - def cast(object) when is_binary(object) do - cast([object]) - end - - def cast(data) when is_list(data) do - data - |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> - case ObjectID.cast(element) do - {:ok, id} -> - {:cont, {:ok, [id | list]}} - - _ -> - {:halt, :error} - end - end) - end - - def cast(_) do - :error - end - - def dump(data) do - {:ok, data} - end - - def load(data) do - {:ok, data} - end -end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex b/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex deleted file mode 100644 index 95c948123..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/types/safe_text.ex +++ /dev/null @@ -1,25 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText do - use Ecto.Type - - alias Pleroma.HTML - - def type, do: :string - - def cast(str) when is_binary(str) do - {:ok, HTML.filter_tags(str)} - end - - def cast(_), do: :error - - def dump(data) do - {:ok, data} - end - - def load(data) do - {:ok, data} - end -end diff --git a/lib/pleroma/web/activity_pub/object_validators/types/uri.ex b/lib/pleroma/web/activity_pub/object_validators/types/uri.ex deleted file mode 100644 index 24845bcc0..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/types/uri.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Uri do - use Ecto.Type - - def type, do: :string - - def cast(uri) when is_binary(uri) do - case URI.parse(uri) do - %URI{host: nil} -> :error - %URI{host: ""} -> :error - %URI{scheme: scheme} when scheme in ["https", "http"] -> {:ok, uri} - _ -> :error - end - end - - def cast(_), do: :error - - def dump(data), do: {:ok, data} - - def load(data), do: {:ok, data} -end diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex index d0ba418e8..e8d2d39c1 100644 --- a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do use Ecto.Schema alias Pleroma.Activity - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:type, :string) - field(:object, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) end diff --git a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex index 47e231150..f64fac46d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex @@ -1,14 +1,18 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset @primary_key false embedded_schema do field(:type, :string) - field(:href, Types.Uri) + field(:href, ObjectValidators.Uri) field(:mediaType, :string) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 985921aa0..851f474b8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do """ alias Pleroma.Activity alias Pleroma.EarmarkRenderer + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.FollowingRelationship alias Pleroma.Maps alias Pleroma.Notification @@ -18,7 +19,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -725,7 +725,7 @@ def handle_incoming( else {:error, {:validate_object, _}} = e -> # Check if we have a create activity for this - with {:ok, object_id} <- Types.ObjectID.cast(data["object"]), + with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]), %Activity{data: %{"actor" => actor}} <- Activity.create_by_object_ap_id(object_id) |> Repo.one(), # We have one, insert a tombstone and retry 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 index 3e17a9497..43be8e936 100644 --- a/test/web/activity_pub/object_validators/types/date_time_test.exs +++ b/test/web/activity_pub/object_validators/types/date_time_test.exs @@ -1,5 +1,5 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTimeTest do - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.DateTime + alias Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime use Pleroma.DataCase test "it validates an xsd:Datetime" do 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 c8911948e..e0ab76379 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 @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ObjectValidators.Types.ObjectIDTest do - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID + alias Pleroma.EctoType.ActivityPub.ObjectValidators.ObjectID use Pleroma.DataCase @uris [ diff --git a/test/web/activity_pub/object_validators/types/recipients_test.exs b/test/web/activity_pub/object_validators/types/recipients_test.exs index f278f039b..053916bdd 100644 --- a/test/web/activity_pub/object_validators/types/recipients_test.exs +++ b/test/web/activity_pub/object_validators/types/recipients_test.exs @@ -1,5 +1,5 @@ defmodule Pleroma.Web.ObjectValidators.Types.RecipientsTest do - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients + alias Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients use Pleroma.DataCase test "it asserts that all elements of the list are object ids" do diff --git a/test/web/activity_pub/object_validators/types/safe_text_test.exs b/test/web/activity_pub/object_validators/types/safe_text_test.exs index d4a574554..9c08606f6 100644 --- a/test/web/activity_pub/object_validators/types/safe_text_test.exs +++ b/test/web/activity_pub/object_validators/types/safe_text_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeTextTest do use Pleroma.DataCase - alias Pleroma.Web.ActivityPub.ObjectValidators.Types.SafeText + alias Pleroma.EctoType.ActivityPub.ObjectValidators.SafeText test "it lets normal text go through" do text = "hey how are you" -- cgit v1.2.3 From ed189568f3c2c6fc6ae9ba4d676e95902b3019d1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 21 Mar 2020 09:47:05 +0300 Subject: moving mrf settings from instance to separate group --- CHANGELOG.md | 5 +- config/config.exs | 8 ++- config/description.exs | 64 ++++++++++++---------- docs/configuration/cheatsheet.md | 45 ++++++++------- docs/configuration/mrf.md | 14 ++--- lib/pleroma/config/config_db.ex | 8 ++- lib/pleroma/config/deprecation_warnings.ex | 44 +++++++++++++++ lib/pleroma/web/activity_pub/mrf.ex | 4 +- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 28 +++++----- .../web/mastodon_api/views/instance_view.ex | 2 +- ...421_mrf_config_move_from_instance_namespace.exs | 39 +++++++++++++ test/config/deprecation_warnings_test.exs | 57 +++++++++++++++++++ test/tasks/config_test.exs | 5 +- test/web/activity_pub/mrf/mrf_test.exs | 4 +- test/web/federator_test.exs | 4 +- test/web/node_info_test.exs | 46 +++++----------- 16 files changed, 256 insertions(+), 121 deletions(-) create mode 100644 priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs create mode 100644 test/config/deprecation_warnings_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index d2629bf84..12e8d58e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,13 @@ 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] - ### Changed - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard +- Configuration: `rewrite_policy` renamed to `policies` and moved from `instance` to `mrf` group. Old config namespace is deprecated. +- Configuration: `mrf_transparency` renamed to `transparency` and moved from `instance` to `mrf` group. Old config namespace is deprecated. +- Configuration: `mrf_transparency_exclusions` renamed to `transparency_exclusions` and moved from `instance` to `mrf` group. Old config namespace is deprecated. + <details> <summary>API Changes</summary> - **Breaking:** Emoji API: changed methods and renamed routes. diff --git a/config/config.exs b/config/config.exs index 6a7bb9e06..3d6336a66 100644 --- a/config/config.exs +++ b/config/config.exs @@ -209,7 +209,6 @@ Pleroma.Web.ActivityPub.Publisher ], allow_relay: true, - rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, public: true, quarantined_instances: [], managed_config: true, @@ -220,8 +219,6 @@ "text/markdown", "text/bbcode" ], - mrf_transparency: true, - mrf_transparency_exclusions: [], autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, @@ -685,6 +682,11 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false +config :pleroma, :mrf, + policies: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, + transparency: true, + transparency_exclusions: [] + # 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 b21d7840c..2ab95e5ab 100644 --- a/config/description.exs +++ b/config/description.exs @@ -689,17 +689,6 @@ type: :boolean, description: "Enable Pleroma's Relay, which makes it possible to follow a whole instance" }, - %{ - key: :rewrite_policy, - type: [:module, {:list, :module}], - description: - "A list of enabled MRF policies. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/web/activity_pub/mrf", - "Elixir.Pleroma.Web.ActivityPub.MRF." - ) - }, %{ key: :public, type: :boolean, @@ -742,23 +731,6 @@ "text/bbcode" ] }, - %{ - key: :mrf_transparency, - label: "MRF transparency", - type: :boolean, - description: - "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" - }, - %{ - key: :mrf_transparency_exclusions, - label: "MRF transparency exclusions", - type: {:list, :string}, - description: - "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", - suggestions: [ - "exclusion.com" - ] - }, %{ key: :extended_nickname_format, type: :boolean, @@ -3325,5 +3297,41 @@ suggestions: [false] } ] + }, + %{ + group: :pleroma, + key: :mrf, + type: :group, + description: "General MRF settings", + children: [ + %{ + key: :policies, + type: [:module, {:list, :module}], + description: + "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/web/activity_pub/mrf", + "Elixir.Pleroma.Web.ActivityPub.MRF." + ) + }, + %{ + key: :transparency, + label: "MRF transparency", + type: :boolean, + description: + "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" + }, + %{ + key: :transparency_exclusions, + label: "MRF transparency exclusions", + type: {:list, :string}, + description: + "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", + suggestions: [ + "exclusion.com" + ] + } + ] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index fad67fc4d..e9af604e2 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -36,26 +36,10 @@ To add configuration to your config file, you can copy it from the base config. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. * `allow_relay`: Enable Pleroma’s Relay, which makes it possible to follow a whole instance. -* `rewrite_policy`: Message Rewrite Policy, either one or a list. Here are the ones available by default: - * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). - * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. - * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certain instances (See [`:mrf_simple`](#mrf_simple)). - * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). - * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). - * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). - * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. - * `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. - * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. - * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). - * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). - * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). - * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Adds expiration to all local Create activities (see [`:mrf_activity_expiration`](#mrf_activity_expiration)). * `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). -* `mrf_transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). -* `mrf_transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. * `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with older software for theses nicknames. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. @@ -78,11 +62,30 @@ To add configuration to your config file, you can copy it from the base config. * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. +## Message rewrite facility + +### :mrf +* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default: + * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). + * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. + * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). + * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). + * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). + * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). + * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. + * `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. + * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. + * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). + * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). + * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). +* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). +* `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. + ## Federation ### MRF policies !!! note - Configuring MRF policies is not enough for them to take effect. You have to enable them by specifying their module in `rewrite_policy` under [:instance](#instance) section. + Configuring MRF policies is not enough for them to take effect. You have to enable them by specifying their module in `policies` under [:mrf](#mrf) section. #### :mrf_simple * `media_removal`: List of instances to remove media from. @@ -969,13 +972,13 @@ config :pleroma, :database_config_whitelist, [ Restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. -* `timelines` - public and federated timelines - * `local` - public timeline +* `timelines`: public and federated timelines + * `local`: public timeline * `federated` -* `profiles` - user profiles +* `profiles`: user profiles * `local` * `remote` -* `activities` - statuses +* `activities`: statuses * `local` * `remote` diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md index d48d0cc99..31c66e098 100644 --- a/docs/configuration/mrf.md +++ b/docs/configuration/mrf.md @@ -34,9 +34,9 @@ config :pleroma, :instance, To use `SimplePolicy`, you must enable it. Do so by adding the following to your `:instance` config object, so that it looks like this: ```elixir -config :pleroma, :instance, +config :pleroma, :mrf, [...] - rewrite_policy: Pleroma.Web.ActivityPub.MRF.SimplePolicy + policies: Pleroma.Web.ActivityPub.MRF.SimplePolicy ``` Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_simple` config object. These groups are: @@ -58,8 +58,8 @@ Servers should be configured as lists. This example will enable `SimplePolicy`, block media from `illegalporn.biz`, mark media as NSFW from `porn.biz` and `porn.business`, reject messages from `spam.com`, remove messages from `spam.university` from the federated timeline and block reports (flags) from `whiny.whiner`: ```elixir -config :pleroma, :instance, - rewrite_policy: [Pleroma.Web.ActivityPub.MRF.SimplePolicy] +config :pleroma, :mrf, + policies: [Pleroma.Web.ActivityPub.MRF.SimplePolicy] config :pleroma, :mrf_simple, media_removal: ["illegalporn.biz"], @@ -75,7 +75,7 @@ The effects of MRF policies can be very drastic. It is important to use this fun ## Writing your own MRF Policy -As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `rewrite_policy` config setting. +As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `policies` config setting. For example, here is a sample policy module which rewrites all messages to "new message content": @@ -125,8 +125,8 @@ end If you save this file as `lib/pleroma/web/activity_pub/mrf/rewrite_policy.ex`, it will be included when you next rebuild Pleroma. You can enable it in the configuration like so: ```elixir -config :pleroma, :instance, - rewrite_policy: [ +config :pleroma, :mrf, + policies: [ Pleroma.Web.ActivityPub.MRF.SimplePolicy, Pleroma.Web.ActivityPub.MRF.RewritePolicy ] diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 30bd51b05..c0f3fe888 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -54,13 +54,13 @@ def changeset(config, params \\ %{}) do defp create(params) do %ConfigDB{} - |> changeset(params) + |> changeset(params, transform?) |> Repo.insert() end defp update(%ConfigDB{} = config, %{value: value}) do config - |> changeset(%{value: value}) + |> changeset(%{value: value}, transform?) |> Repo.update() end @@ -167,7 +167,9 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do end) end - @spec delete(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + @spec delete(ConfigDB.t() | map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} + def delete(%ConfigDB{} = config), do: Repo.delete(config) + def delete(params) do search_opts = Map.delete(params, :subkeys) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index b68ded01f..0a6c724fb 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -3,9 +3,23 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.DeprecationWarnings do + alias Pleroma.Config + require Logger alias Pleroma.Config + @type config_namespace() :: [atom()] + @type config_map() :: {config_namespace(), config_namespace(), String.t()} + + @mrf_config_map [ + {[:instance, :rewrite_policy], [:mrf, :policies], + "\n* `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies`"}, + {[:instance, :mrf_transparency], [:mrf, :transparency], + "\n* `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency`"}, + {[:instance, :mrf_transparency_exclusions], [:mrf, :transparency_exclusions], + "\n* `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions`"} + ] + def check_hellthread_threshold do if Config.get([:mrf_hellthread, :threshold]) do Logger.warn(""" @@ -39,5 +53,35 @@ def mrf_user_allowlist do def warn do check_hellthread_threshold() mrf_user_allowlist() + check_old_mrf_config() + end + + def check_old_mrf_config do + warning_preface = """ + !!!DEPRECATION WARNING!!! + Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later: + """ + + move_namespace_and_warn(@mrf_config_map, warning_preface) + end + + @spec move_namespace_and_warn([config_map()], String.t()) :: :ok + def move_namespace_and_warn(config_map, warning_preface) do + warning = + Enum.reduce(config_map, "", fn + {old, new, err_msg}, acc -> + old_config = Config.get(old) + + if old_config do + Config.put(new, old_config) + acc <> err_msg + else + acc + end + end) + + if warning != "" do + Logger.warn(warning_preface <> warning) + end end end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 5a4a76085..206d6af52 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -16,7 +16,7 @@ def filter(policies, %{} = object) do def filter(%{} = object), do: get_policies() |> filter(object) def get_policies do - Pleroma.Config.get([:instance, :rewrite_policy], []) |> get_policies() + Pleroma.Config.get([:mrf, :policies], []) |> get_policies() end defp get_policies(policy) when is_atom(policy), do: [policy] @@ -51,7 +51,7 @@ def describe(policies) do get_policies() |> Enum.map(fn policy -> to_string(policy) |> String.split(".") |> List.last() end) - exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions]) + exclusions = Pleroma.Config.get([:mrf, :transparency_exclusions]) base = %{ diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index b7dcb1b86..9cea6bcf9 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -3,21 +3,23 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do - alias Pleroma.User - alias Pleroma.Web.ActivityPub.MRF @moduledoc "Filter activities depending on their origin instance" @behaviour Pleroma.Web.ActivityPub.MRF + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF + require Pleroma.Constants defp check_accept(%{host: actor_host} = _actor_info, object) do accepts = - Pleroma.Config.get([:mrf_simple, :accept]) + Config.get([:mrf_simple, :accept]) |> MRF.subdomains_regex() cond do accepts == [] -> {:ok, object} - actor_host == Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} + actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} MRF.subdomain_match?(accepts, actor_host) -> {:ok, object} true -> {:reject, nil} end @@ -25,7 +27,7 @@ defp check_accept(%{host: actor_host} = _actor_info, object) do defp check_reject(%{host: actor_host} = _actor_info, object) do rejects = - Pleroma.Config.get([:mrf_simple, :reject]) + Config.get([:mrf_simple, :reject]) |> MRF.subdomains_regex() if MRF.subdomain_match?(rejects, actor_host) do @@ -41,7 +43,7 @@ defp check_media_removal( ) when length(child_attachment) > 0 do media_removal = - Pleroma.Config.get([:mrf_simple, :media_removal]) + Config.get([:mrf_simple, :media_removal]) |> MRF.subdomains_regex() object = @@ -65,7 +67,7 @@ defp check_media_nsfw( } = object ) do media_nsfw = - Pleroma.Config.get([:mrf_simple, :media_nsfw]) + Config.get([:mrf_simple, :media_nsfw]) |> MRF.subdomains_regex() object = @@ -85,7 +87,7 @@ defp check_media_nsfw(_actor_info, object), do: {:ok, object} defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do timeline_removal = - Pleroma.Config.get([:mrf_simple, :federated_timeline_removal]) + Config.get([:mrf_simple, :federated_timeline_removal]) |> MRF.subdomains_regex() object = @@ -108,7 +110,7 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do report_removal = - Pleroma.Config.get([:mrf_simple, :report_removal]) + Config.get([:mrf_simple, :report_removal]) |> MRF.subdomains_regex() if MRF.subdomain_match?(report_removal, actor_host) do @@ -122,7 +124,7 @@ defp check_report_removal(_actor_info, object), do: {:ok, object} defp check_avatar_removal(%{host: actor_host} = _actor_info, %{"icon" => _icon} = object) do avatar_removal = - Pleroma.Config.get([:mrf_simple, :avatar_removal]) + Config.get([:mrf_simple, :avatar_removal]) |> MRF.subdomains_regex() if MRF.subdomain_match?(avatar_removal, actor_host) do @@ -136,7 +138,7 @@ defp check_avatar_removal(_actor_info, object), do: {:ok, object} defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image} = object) do banner_removal = - Pleroma.Config.get([:mrf_simple, :banner_removal]) + Config.get([:mrf_simple, :banner_removal]) |> MRF.subdomains_regex() if MRF.subdomain_match?(banner_removal, actor_host) do @@ -197,10 +199,10 @@ def filter(object), do: {:ok, object} @impl true def describe do - exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions]) + exclusions = Config.get([:mrf, :transparency_exclusions]) mrf_simple = - Pleroma.Config.get(:mrf_simple) + Config.get(:mrf_simple) |> Enum.map(fn {k, v} -> {k, Enum.reject(v, fn v -> v in exclusions end)} end) |> Enum.into(%{}) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index c498fe632..4f0ae4e8f 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -78,7 +78,7 @@ def features do def federation do quarantined = Config.get([:instance, :quarantined_instances], []) - if Config.get([:instance, :mrf_transparency]) do + if Config.get([:mrf, :transparency]) do {:ok, data} = MRF.describe() data diff --git a/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs new file mode 100644 index 000000000..6f6094613 --- /dev/null +++ b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs @@ -0,0 +1,39 @@ +defmodule Pleroma.Repo.Migrations.MrfConfigMoveFromInstanceNamespace do + use Ecto.Migration + + alias Pleroma.ConfigDB + + @old_keys [:rewrite_policy, :mrf_transparency, :mrf_transparency_exclusions] + def change do + config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":instance"}) + + if config do + old_instance = ConfigDB.from_binary(config.value) + + mrf = + old_instance + |> Keyword.take(@old_keys) + |> Keyword.new(fn + {:rewrite_policy, policies} -> {:policies, policies} + {:mrf_transparency, transparency} -> {:transparency, transparency} + {:mrf_transparency_exclusions, exclusions} -> {:transparency_exclusions, exclusions} + end) + + if mrf != [] do + {:ok, _} = + ConfigDB.create( + %{group: ":pleroma", key: ":mrf", value: ConfigDB.to_binary(mrf)}, + false + ) + + new_instance = Keyword.drop(old_instance, @old_keys) + + if new_instance != [] do + {:ok, _} = ConfigDB.update(config, %{value: ConfigDB.to_binary(new_instance)}, false) + else + {:ok, _} = ConfigDB.delete(config) + end + end + end + end +end diff --git a/test/config/deprecation_warnings_test.exs b/test/config/deprecation_warnings_test.exs new file mode 100644 index 000000000..548ee87b0 --- /dev/null +++ b/test/config/deprecation_warnings_test.exs @@ -0,0 +1,57 @@ +defmodule Pleroma.Config.DeprecationWarningsTest do + use ExUnit.Case, async: true + use Pleroma.Tests.Helpers + + import ExUnit.CaptureLog + + test "check_old_mrf_config/0" do + clear_config([:instance, :rewrite_policy], Pleroma.Web.ActivityPub.MRF.NoOpPolicy) + clear_config([:instance, :mrf_transparency], true) + clear_config([:instance, :mrf_transparency_exclusions], []) + + assert capture_log(fn -> Pleroma.Config.DeprecationWarnings.check_old_mrf_config() end) =~ + """ + !!!DEPRECATION WARNING!!! + Your config is using old namespaces for MRF configuration. They should work for now, but you are advised to change to new namespaces to prevent possible issues later: + + * `config :pleroma, :instance, rewrite_policy` is now `config :pleroma, :mrf, policies` + * `config :pleroma, :instance, mrf_transparency` is now `config :pleroma, :mrf, transparency` + * `config :pleroma, :instance, mrf_transparency_exclusions` is now `config :pleroma, :mrf, transparency_exclusions` + """ + end + + test "move_namespace_and_warn/2" do + old_group1 = [:group, :key] + old_group2 = [:group, :key2] + old_group3 = [:group, :key3] + + new_group1 = [:another_group, :key4] + new_group2 = [:another_group, :key5] + new_group3 = [:another_group, :key6] + + clear_config(old_group1, 1) + clear_config(old_group2, 2) + clear_config(old_group3, 3) + + clear_config(new_group1) + clear_config(new_group2) + clear_config(new_group3) + + config_map = [ + {old_group1, new_group1, "\n error :key"}, + {old_group2, new_group2, "\n error :key2"}, + {old_group3, new_group3, "\n error :key3"} + ] + + assert capture_log(fn -> + Pleroma.Config.DeprecationWarnings.move_namespace_and_warn( + config_map, + "Warning preface" + ) + end) =~ "Warning preface\n error :key\n error :key2\n error :key3" + + assert Pleroma.Config.get(new_group1) == 1 + assert Pleroma.Config.get(new_group2) == 2 + assert Pleroma.Config.get(new_group3) == 3 + end +end diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index e1bddfebf..bae171074 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -120,14 +120,11 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil federation_reachability_timeout_days: 7, federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher], allow_relay: true, - rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, public: true, quarantined_instances: [], managed_config: true, static_dir: "instance/static/", allowed_post_formats: ["text/plain", "text/html", "text/markdown", "text/bbcode"], - mrf_transparency: true, - mrf_transparency_exclusions: [], autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, @@ -174,7 +171,7 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil end assert file == - "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n rewrite_policy: Pleroma.Web.ActivityPub.MRF.NoOpPolicy,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n mrf_transparency: true,\n mrf_transparency_exclusions: [],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" + "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" end end end diff --git a/test/web/activity_pub/mrf/mrf_test.exs b/test/web/activity_pub/mrf/mrf_test.exs index c941066f2..a63b25423 100644 --- a/test/web/activity_pub/mrf/mrf_test.exs +++ b/test/web/activity_pub/mrf/mrf_test.exs @@ -60,8 +60,6 @@ test "matches are case-insensitive" do end describe "describe/0" do - setup do: clear_config([:instance, :rewrite_policy]) - test "it works as expected with noop policy" do expected = %{ mrf_policies: ["NoOpPolicy"], @@ -72,7 +70,7 @@ test "it works as expected with noop policy" do end test "it works as expected with mock policy" do - Pleroma.Config.put([:instance, :rewrite_policy], [MRFModuleMock]) + clear_config([:mrf, :policies], [MRFModuleMock]) expected = %{ mrf_policies: ["MRFModuleMock"], diff --git a/test/web/federator_test.exs b/test/web/federator_test.exs index de90aa6e0..592fdccd1 100644 --- a/test/web/federator_test.exs +++ b/test/web/federator_test.exs @@ -23,7 +23,7 @@ defmodule Pleroma.Web.FederatorTest do setup_all do: clear_config([:instance, :federating], true) setup do: clear_config([:instance, :allow_relay]) - setup do: clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:mrf, :policies]) setup do: clear_config([:mrf_keyword]) describe "Publish an activity" do @@ -158,7 +158,7 @@ test "it does not crash if MRF rejects the post" do Pleroma.Config.put([:mrf_keyword, :reject], ["lain"]) Pleroma.Config.put( - [:instance, :rewrite_policy], + [:mrf, :policies], Pleroma.Web.ActivityPub.MRF.KeywordPolicy ) diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 00925caad..06b33607f 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -67,10 +67,10 @@ test "returns software.repository field in nodeinfo 2.1", %{conn: conn} do end test "returns fieldsLimits field", %{conn: conn} do - 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) + clear_config([:instance, :max_account_fields], 10) + clear_config([:instance, :max_remote_account_fields], 15) + clear_config([:instance, :account_field_name_length], 255) + clear_config([:instance, :account_field_value_length], 2048) response = conn @@ -84,8 +84,7 @@ test "returns fieldsLimits field", %{conn: conn} do end test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do - option = Config.get([:instance, :safe_dm_mentions]) - Config.put([:instance, :safe_dm_mentions], true) + clear_config([:instance, :safe_dm_mentions], true) response = conn @@ -102,8 +101,6 @@ test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do |> json_response(:ok) refute "safe_dm_mentions" in response["metadata"]["features"] - - Config.put([:instance, :safe_dm_mentions], option) end describe "`metadata/federation/enabled`" do @@ -156,14 +153,11 @@ test "it shows default features flags", %{conn: conn} do end test "it shows MRF transparency data if enabled", %{conn: conn} do - config = Config.get([:instance, :rewrite_policy]) - Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - - option = Config.get([:instance, :mrf_transparency]) - Config.put([:instance, :mrf_transparency], true) + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf, :transparency], true) simple_config = %{"reject" => ["example.com"]} - Config.put(:mrf_simple, simple_config) + clear_config(:mrf_simple, simple_config) response = conn @@ -171,26 +165,17 @@ test "it shows MRF transparency data if enabled", %{conn: conn} do |> json_response(:ok) assert response["metadata"]["federation"]["mrf_simple"] == simple_config - - 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 = Config.get([:instance, :rewrite_policy]) - Config.put([:instance, :rewrite_policy], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) - - option = Config.get([:instance, :mrf_transparency]) - Config.put([:instance, :mrf_transparency], true) - - exclusions = Config.get([:instance, :mrf_transparency_exclusions]) - Config.put([:instance, :mrf_transparency_exclusions], ["other.site"]) + clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.SimplePolicy]) + clear_config([:mrf, :transparency], true) + clear_config([:mrf, :transparency_exclusions], ["other.site"]) simple_config = %{"reject" => ["example.com", "other.site"]} - expected_config = %{"reject" => ["example.com"]} + clear_config(:mrf_simple, simple_config) - Config.put(:mrf_simple, simple_config) + expected_config = %{"reject" => ["example.com"]} response = conn @@ -199,10 +184,5 @@ 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 - - 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 -- cgit v1.2.3 From b66e6eb521c2901da119179016c99751cb5e6f95 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 16 Jun 2020 19:03:45 +0300 Subject: fixes for tests --- docs/configuration/storing_remote_media.md | 4 ++-- lib/pleroma/config/config_db.ex | 4 ++-- test/web/activity_pub/activity_pub_test.exs | 4 ++-- test/workers/cron/purge_expired_activities_worker_test.exs | 6 +----- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/configuration/storing_remote_media.md b/docs/configuration/storing_remote_media.md index 7e91fe7d9..c01985d25 100644 --- a/docs/configuration/storing_remote_media.md +++ b/docs/configuration/storing_remote_media.md @@ -33,6 +33,6 @@ as soon as the post is received by your instance. Add to your `prod.secret.exs`: ``` -config :pleroma, :instance, - rewrite_policy: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] +config :pleroma, :mrf, + policies: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] ``` diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index c0f3fe888..134116863 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -54,13 +54,13 @@ def changeset(config, params \\ %{}) do defp create(params) do %ConfigDB{} - |> changeset(params, transform?) + |> changeset(params) |> Repo.insert() end defp update(%ConfigDB{} = config, %{value: value}) do config - |> changeset(%{value: value}, transform?) + |> changeset(%{value: value}) |> Repo.update() end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 7693f6400..1c684df1a 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -2055,11 +2055,11 @@ test "it just returns the input if the user has no following/follower addresses" end describe "global activity expiration" do - setup do: clear_config([:instance, :rewrite_policy]) + setup do: clear_config([:mrf, :policies]) test "creates an activity expiration for local Create activities" do Pleroma.Config.put( - [:instance, :rewrite_policy], + [:mrf, :policies], Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy ) diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index 6d2991a60..b1db59fdf 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -13,7 +13,6 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorkerTest do setup do clear_config([ActivityExpiration, :enabled]) - clear_config([:instance, :rewrite_policy]) end test "deletes an expiration activity" do @@ -42,10 +41,7 @@ test "deletes an expiration activity" do test "works with ActivityExpirationPolicy" do Pleroma.Config.put([ActivityExpiration, :enabled], true) - Pleroma.Config.put( - [:instance, :rewrite_policy], - Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy - ) + clear_config([:mrf, :policies], Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy) user = insert(:user) -- cgit v1.2.3 From 5c0e1039ce41a2717598992a590658d4d079451c Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Tue, 16 Jun 2020 23:45:59 +0300 Subject: Chunk the notification type backfill migration Long-term we want that migration to be done entirely in SQL, but for now this is a hotfix to not cause OOMs on large databases. This is using a homegrown version of `Repo.stream`, it's worse in terms of performance than the upstream since it doesn't use the same prepared query for chunk queries, but unlike the upstream it supports preloads. --- .../migration_helper/notification_backfill.ex | 2 +- lib/pleroma/repo.ex | 28 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex index 09647d12a..b3770307a 100644 --- a/lib/pleroma/migration_helper/notification_backfill.ex +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -18,7 +18,7 @@ def fill_in_notification_types do ) query - |> Repo.all() + |> Repo.chunk_stream(100) |> Enum.each(fn notification -> type = notification.activity diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index f62138466..6d85d70bc 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Repo do adapter: Ecto.Adapters.Postgres, migration_timestamps: [type: :naive_datetime_usec] + import Ecto.Query require Logger defmodule Instrumenter do @@ -78,6 +79,33 @@ def check_migrations_applied!() do :ok end end + + def chunk_stream(query, chunk_size) do + # We don't actually need start and end funcitons of resource streaming, + # but it seems to be the only way to not fetch records one-by-one and + # have individual records be the elements of the stream, instead of + # lists of records + Stream.resource( + fn -> 0 end, + fn + last_id -> + query + |> order_by(asc: :id) + |> where([r], r.id > ^last_id) + |> limit(^chunk_size) + |> all() + |> case do + [] -> + {:halt, last_id} + + records -> + last_id = List.last(records).id + {records, last_id} + end + end, + fn _ -> :ok end + ) + end end defmodule Pleroma.Repo.UnappliedMigrationsError do -- cgit v1.2.3 From 55d8263c0040b32075b9bb90ab1d6693e627f6bf Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Wed, 17 Jun 2020 02:27:28 +0300 Subject: Update OTP releases to official images of 1.10.3 This is necessary since we bumped required version of elixir to 1.9. The dlsym bug should be gone by now. --- .gitlab-ci.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bc7b289a2..b4bd59b43 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -170,8 +170,7 @@ stop_review_app: amd64: stage: release - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0 + image: elixir:1.10.3 only: &release-only - stable@pleroma/pleroma - develop@pleroma/pleroma @@ -208,8 +207,7 @@ amd64-musl: stage: release artifacts: *release-artifacts only: *release-only - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: &before-release-musl @@ -225,8 +223,7 @@ arm: only: *release-only tags: - arm32 - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm + image: elixir:1.10.3 cache: *release-cache variables: *release-variables before_script: *before-release @@ -238,8 +235,7 @@ arm-musl: only: *release-only tags: - arm32 - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl @@ -251,8 +247,7 @@ arm64: only: *release-only tags: - arm - # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm64 + image: elixir:1.10.3 cache: *release-cache variables: *release-variables before_script: *before-release @@ -265,7 +260,7 @@ arm64-musl: tags: - arm # TODO: Replace with upstream image when 1.9.0 comes out - image: rinpatch/elixir:1.9.0-rc.0-arm64-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: *before-release-musl -- cgit v1.2.3 From 281ecd6b30a165843c5b6a1899894646dc25c0f9 Mon Sep 17 00:00:00 2001 From: rinpatch <rinpatch@sdf.org> Date: Wed, 17 Jun 2020 02:29:32 +0300 Subject: CHANGELOG.md: mention minimal elixir version update --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f291ad2a..3ee13904f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- **Breaking:** Elixir >=1.9 is now required (was >= 1.8) - In Conversations, return only direct messages as `last_status` - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard -- cgit v1.2.3 From 02a5648febb8a508116c29e2271e1ade2ffafb2d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 17 Jun 2020 09:15:35 +0300 Subject: fixed migration the settings to DB --- lib/mix/tasks/pleroma/config.ex | 1 + lib/pleroma/config/loader.ex | 1 - ...20190510135645_add_fts_index_to_objects_two.exs | 31 +++++++++++++--------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index f1b3a8766..65691f9c1 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -52,6 +52,7 @@ def migrate_to_db(file_path \\ nil) do defp do_migrate_to_db(config_file) do if File.exists?(config_file) do + shell_info("Running migrate settings from file: #{Path.expand(config_file)}") Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 0f3ecf1ed..76559e70c 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Config.Loader do Pleroma.Web.Endpoint, :env, :configurable_from_database, - :database, :swarm ] diff --git a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs index 6227769dc..79bde163d 100644 --- a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs +++ b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs @@ -2,24 +2,29 @@ defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do use Ecto.Migration def up do - execute("create extension if not exists rum") - drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) - alter table(:objects) do - add(:fts_content, :tsvector) - end + if Pleroma.Config.get([:database, :rum_enabled]) do + execute("create extension if not exists rum") + drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) + alter table(:objects) do + add(:fts_content, :tsvector) + end - execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ - begin + execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ + begin new.fts_content := to_tsvector('english', new.data->>'content'); return new; - end - $$ LANGUAGE plpgsql") - execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") + end + $$ LANGUAGE plpgsql") + execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") - execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects - FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") + execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects + FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") - execute("UPDATE objects SET updated_at = NOW()") + execute("UPDATE objects SET updated_at = NOW()") + else + raise Ecto.MigrationError, + message: "Migration is not allowed. You can change this behavior by setting `database/rum_enabled` to true." + end end def down do -- cgit v1.2.3 From a77b0388f48084bd3a420855a232bf2e504f0bce Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 17 Jun 2020 10:31:06 +0300 Subject: credo fix --- lib/pleroma/web/activity_pub/object_validator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 3d699e8a5..6a83a2c33 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -9,8 +9,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do the system. """ - alias Pleroma.Object alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator -- cgit v1.2.3 From abda3f2d92eda0888e018cea0a2fffb21d9e0a60 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 17 Jun 2020 10:47:20 +0300 Subject: suggestion for changelog --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e8d58e6..6095cf139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard -- Configuration: `rewrite_policy` renamed to `policies` and moved from `instance` to `mrf` group. Old config namespace is deprecated. -- Configuration: `mrf_transparency` renamed to `transparency` and moved from `instance` to `mrf` group. Old config namespace is deprecated. -- Configuration: `mrf_transparency_exclusions` renamed to `transparency_exclusions` and moved from `instance` to `mrf` group. Old config namespace is deprecated. +- Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. <details> <summary>API Changes</summary> -- cgit v1.2.3 From 9a82de219c264f467b485316570c5425e3fe2f00 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 17 Jun 2020 10:50:05 +0300 Subject: formatting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6095cf139..fab87b569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 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] + ### Changed - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard -- cgit v1.2.3 From 90613348ed8078e1906f7ffd18eebfa1a3b7f25a Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:56:13 +0000 Subject: 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 6659b605d..8a3d60187 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1253,7 +1253,7 @@ Loads json generated from `config/descriptions.exs`. - Authentication: required - Params: - - `urls` + - `urls` (array) - Response: -- cgit v1.2.3 From abfb1c756b62c24589d2881d77bf5974a80809d3 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:56:17 +0000 Subject: Apply suggestion to docs/API/admin_api.md --- docs/API/admin_api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 8a3d60187..c7f56cf5f 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1273,8 +1273,8 @@ Loads json generated from `config/descriptions.exs`. - Authentication: required - Params: - - `urls` - - `ban` + - `urls` (array) + - `ban` (boolean) - Response: -- cgit v1.2.3 From 74fd761637f737822d01aed945b6e0c75ced7008 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:56:30 +0000 Subject: Apply suggestion to lib/pleroma/web/media_proxy/invalidation.ex --- lib/pleroma/web/media_proxy/invalidation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index 83ff8589c..6da7eb720 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -32,6 +32,6 @@ defp do_purge(urls) do def prepare_urls(urls) do urls |> List.wrap() - |> Enum.map(&MediaProxy.url(&1)) + |> Enum.map(&MediaProxy.url/1) end end -- cgit v1.2.3 From 1b45bc7b2ac53e56a4868da7e2b5b198d16306ab Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:08 +0000 Subject: Apply suggestion to test/web/admin_api/controllers/media_proxy_cache_controller_test.exs --- test/web/admin_api/controllers/media_proxy_cache_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 76a96f46f..ddaf39f14 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -14,7 +14,6 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end setup do -- cgit v1.2.3 From 793a53f1ec18a42f15f58494e40ed3c37d35f95c Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:16 +0000 Subject: Apply suggestion to test/web/admin_api/controllers/media_proxy_cache_controller_test.exs --- test/web/admin_api/controllers/media_proxy_cache_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index ddaf39f14..81e20d001 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -72,7 +72,7 @@ test "shows banned MediaProxy URLs", %{conn: conn} do end end - describe "DELETE /api/pleroma/admin/media_proxy_caches/delete" do + describe "POST /api/pleroma/admin/media_proxy_caches/delete" do test "deleted MediaProxy URLs from banned", %{conn: conn} do MediaProxy.put_in_deleted_urls([ "http://localhost:4001/media/a688346.jpg", -- cgit v1.2.3 From 6d33a3a51bb8ff0afdf7f4f9880f8f5c5f2dfebc Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:28 +0000 Subject: Apply suggestion to test/web/admin_api/controllers/media_proxy_cache_controller_test.exs --- test/web/admin_api/controllers/media_proxy_cache_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 81e20d001..42a3c0dd8 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -93,7 +93,7 @@ test "deleted MediaProxy URLs from banned", %{conn: conn} do end end - describe "PURGE /api/pleroma/admin/media_proxy_caches/purge" do + describe "POST /api/pleroma/admin/media_proxy_caches/purge" do test "perform invalidates cache of MediaProxy", %{conn: conn} do urls = [ "http://example.com/media/a688346.jpg", -- cgit v1.2.3 From 11b22a42293ec2ac0e66897bf4b29b5363913c19 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:33 +0000 Subject: Apply suggestion to test/web/media_proxy/invalidations/http_test.exs --- test/web/media_proxy/invalidations/http_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs index 09e7ca0fb..9d181dd8b 100644 --- a/test/web/media_proxy/invalidations/http_test.exs +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end test "logs hasn't error message when request is valid" do -- cgit v1.2.3 From 2991aae4c4e4ae430539c1e6fac53fb8a0c991e9 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:38 +0000 Subject: Apply suggestion to test/web/media_proxy/invalidations/script_test.exs --- test/web/media_proxy/invalidations/script_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs index c69cec07a..8e155b705 100644 --- a/test/web/media_proxy/invalidations/script_test.exs +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end test "it logger error when script not found" do -- cgit v1.2.3 From 078d687e6ed66f921d7f54114f2dc6bf4abbf237 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 12:58:50 +0000 Subject: Apply suggestion to test/web/media_proxy/media_proxy_controller_test.exs --- test/web/media_proxy/media_proxy_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 2b6b25221..72da98a6a 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -12,7 +12,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end test "it returns 404 when MediaProxy disabled", %{conn: conn} do -- cgit v1.2.3 From 44ce97a9c9e90d5906386d1b51dea144cd258c32 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 13:12:32 +0000 Subject: Apply suggestion to lib/pleroma/web/media_proxy/invalidations/script.ex --- lib/pleroma/web/media_proxy/invalidations/script.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index 0217b119d..b0f44e8e2 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Script do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, opts \\ %{}) do + def purge(urls, opts \\ []) do args = urls |> List.wrap() -- cgit v1.2.3 From 9a371bf5f6245b0f372bec7af15e2f4fc41d4ab7 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 13:12:38 +0000 Subject: Apply suggestion to lib/pleroma/web/media_proxy/invalidations/script.ex --- lib/pleroma/web/media_proxy/invalidations/script.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidations/script.ex b/lib/pleroma/web/media_proxy/invalidations/script.ex index b0f44e8e2..d32ffc50b 100644 --- a/lib/pleroma/web/media_proxy/invalidations/script.ex +++ b/lib/pleroma/web/media_proxy/invalidations/script.ex @@ -18,7 +18,7 @@ def purge(urls, opts \\ []) do |> Enum.join(" ") opts - |> Keyword.get(:script_path, nil) + |> Keyword.get(:script_path) |> do_purge([args]) |> handle_result(urls) end -- cgit v1.2.3 From 96493da7bdab4ff4a51cbebf18df4127ddc47990 Mon Sep 17 00:00:00 2001 From: Maksim <parallel588@gmail.com> Date: Wed, 17 Jun 2020 13:14:01 +0000 Subject: Apply suggestion to test/web/media_proxy/invalidation_test.exs --- test/web/media_proxy/invalidation_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/media_proxy/invalidation_test.exs b/test/web/media_proxy/invalidation_test.exs index 3a9fa8c88..bf9af251c 100644 --- a/test/web/media_proxy/invalidation_test.exs +++ b/test/web/media_proxy/invalidation_test.exs @@ -13,7 +13,6 @@ defmodule Pleroma.Web.MediaProxy.InvalidationTest do setup do on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) - :ok end describe "Invalidation.Http" do -- cgit v1.2.3 From d4b5a9730e8fe7adf5a2eca15bf40fafff85f30d Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Wed, 17 Jun 2020 18:47:59 +0400 Subject: Remove `poll` from `notification_type` OpenAPI spec --- lib/pleroma/web/api_spec/operations/notification_operation.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index c966b553a..41328b5f2 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -183,7 +183,6 @@ defp notification_type do "favourite", "reblog", "mention", - "poll", "pleroma:emoji_reaction", "pleroma:chat_mention", "move", -- cgit v1.2.3 From 71a5d9bffb33d9424ea28900ea006678617e0096 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 17 Jun 2020 12:54:02 -0500 Subject: Empty list as default --- lib/pleroma/web/media_proxy/invalidations/http.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy/invalidations/http.ex b/lib/pleroma/web/media_proxy/invalidations/http.ex index 3694b56e8..bb81d8888 100644 --- a/lib/pleroma/web/media_proxy/invalidations/http.ex +++ b/lib/pleroma/web/media_proxy/invalidations/http.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.Http do require Logger @impl Pleroma.Web.MediaProxy.Invalidation - def purge(urls, opts) do + def purge(urls, opts \\ []) do method = Keyword.get(opts, :method, :purge) headers = Keyword.get(opts, :headers, []) options = Keyword.get(opts, :options, []) -- cgit v1.2.3 From c08c9db0c137d36896910194a6dc50a391a8fee2 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 17 Jun 2020 13:02:01 -0500 Subject: Remove misleading is_ prefix from boolean function --- lib/pleroma/web/media_proxy/media_proxy.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 59ca217ab..3dccd6b7f 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -37,15 +37,15 @@ def url(url) when is_nil(url) or url == "", do: nil def url("/" <> _ = url), do: url def url(url) do - if disabled?() or not is_url_proxiable?(url) do + if disabled?() or not url_proxiable?(url) do url else encode_url(url) end end - @spec is_url_proxiable?(String.t()) :: boolean() - def is_url_proxiable?(url) do + @spec url_proxiable?(String.t()) :: boolean() + def url_proxiable?(url) do if local?(url) or whitelisted?(url) do false else -- cgit v1.2.3 From 2731ea1334c2c91315465659a0874829cb9e1e11 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 17 Jun 2020 13:13:55 -0500 Subject: Change references from "deleted_urls" to "banned_urls" as nothing is handled via media deletions anymore; all actions are manual operations by an admin to ban the url --- lib/pleroma/application.ex | 2 +- lib/pleroma/plugs/uploaded_media.ex | 10 ++++----- .../controllers/media_proxy_cache_controller.ex | 6 +++--- lib/pleroma/web/media_proxy/media_proxy.ex | 20 +++++++++--------- .../web/media_proxy/media_proxy_controller.ex | 4 ++-- .../media_proxy_cache_controller_test.exs | 24 +++++++++++----------- test/web/media_proxy/invalidation_test.exs | 14 ++++++------- .../media_proxy/media_proxy_controller_test.exs | 6 +++--- 8 files changed, 43 insertions(+), 43 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index adebebc7a..4a21bf138 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -149,7 +149,7 @@ defp cachex_children do build_cachex("web_resp", limit: 2500), build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), - build_cachex("deleted_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) + build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) ] end diff --git a/lib/pleroma/plugs/uploaded_media.ex b/lib/pleroma/plugs/uploaded_media.ex index 2f3fde002..40984cfc0 100644 --- a/lib/pleroma/plugs/uploaded_media.ex +++ b/lib/pleroma/plugs/uploaded_media.ex @@ -49,7 +49,7 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do with uploader <- Keyword.fetch!(config, :uploader), proxy_remote = Keyword.get(config, :proxy_remote, false), {:ok, get_method} <- uploader.get_file(file), - false <- media_is_deleted(conn, get_method) do + false <- media_is_banned(conn, get_method) do get_media(conn, get_method, proxy_remote, opts) else _ -> @@ -61,13 +61,13 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do def call(conn, _opts), do: conn - defp media_is_deleted(%{request_path: path} = _conn, {:static_dir, _}) do - MediaProxy.in_deleted_urls(Pleroma.Web.base_url() <> path) + defp media_is_banned(%{request_path: path} = _conn, {:static_dir, _}) do + MediaProxy.in_banned_urls(Pleroma.Web.base_url() <> path) end - defp media_is_deleted(_, {:url, url}), do: MediaProxy.in_deleted_urls(url) + defp media_is_banned(_, {:url, url}), do: MediaProxy.in_banned_urls(url) - defp media_is_deleted(_, _), do: false + defp media_is_banned(_, _), do: false defp get_media(conn, {:static_dir, directory}, _, opts) do static_opts = diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index e3fa0ac28..e2759d59f 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -27,7 +27,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do def index(%{assigns: %{user: _}} = conn, params) do cursor = - :deleted_urls_cache + :banned_urls_cache |> :ets.table([{:traverse, {:select, Cachex.Query.create(true, :key)}}]) |> :qlc.cursor() @@ -47,7 +47,7 @@ def index(%{assigns: %{user: _}} = conn, params) do end def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do - MediaProxy.remove_from_deleted_urls(urls) + MediaProxy.remove_from_banned_urls(urls) render(conn, "index.json", urls: urls) end @@ -55,7 +55,7 @@ def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _ MediaProxy.Invalidation.purge(urls) if ban do - MediaProxy.put_in_deleted_urls(urls) + MediaProxy.put_in_banned_urls(urls) end render(conn, "index.json", urls: urls) diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 3dccd6b7f..077fabe47 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -10,27 +10,27 @@ defmodule Pleroma.Web.MediaProxy do @base64_opts [padding: false] - @spec in_deleted_urls(String.t()) :: boolean() - def in_deleted_urls(url), do: elem(Cachex.exists?(:deleted_urls_cache, url(url)), 1) + @spec in_banned_urls(String.t()) :: boolean() + def in_banned_urls(url), do: elem(Cachex.exists?(:banned_urls_cache, url(url)), 1) - def remove_from_deleted_urls(urls) when is_list(urls) do - Cachex.execute!(:deleted_urls_cache, fn cache -> + def remove_from_banned_urls(urls) when is_list(urls) do + Cachex.execute!(:banned_urls_cache, fn cache -> Enum.each(Invalidation.prepare_urls(urls), &Cachex.del(cache, &1)) end) end - def remove_from_deleted_urls(url) when is_binary(url) do - Cachex.del(:deleted_urls_cache, url(url)) + def remove_from_banned_urls(url) when is_binary(url) do + Cachex.del(:banned_urls_cache, url(url)) end - def put_in_deleted_urls(urls) when is_list(urls) do - Cachex.execute!(:deleted_urls_cache, fn cache -> + def put_in_banned_urls(urls) when is_list(urls) do + Cachex.execute!(:banned_urls_cache, fn cache -> Enum.each(Invalidation.prepare_urls(urls), &Cachex.put(cache, &1, true)) end) end - def put_in_deleted_urls(url) when is_binary(url) do - Cachex.put(:deleted_urls_cache, url(url), true) + def put_in_banned_urls(url) when is_binary(url) do + Cachex.put(:banned_urls_cache, url(url), true) end def url(url) when is_nil(url) or url == "", do: nil diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index ff0158d83..9a64b0ef3 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -14,11 +14,11 @@ def remote(conn, %{"sig" => sig64, "url" => url64} = params) do with config <- Pleroma.Config.get([:media_proxy], []), true <- Keyword.get(config, :enabled, false), {:ok, url} <- MediaProxy.decode_url(sig64, url64), - {_, false} <- {:in_deleted_urls, MediaProxy.in_deleted_urls(url)}, + {_, false} <- {:in_banned_urls, MediaProxy.in_banned_urls(url)}, :ok <- filename_matches(params, conn.request_path, url) do ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts)) else - error when error in [false, {:in_deleted_urls, true}] -> + error when error in [false, {:in_banned_urls, true}] -> send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404)) {:error, :invalid_signature} -> diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 42a3c0dd8..5ab6cb78a 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -13,7 +13,7 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do setup do: clear_config([:media_proxy]) setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end setup do @@ -34,14 +34,14 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheControllerTest do describe "GET /api/pleroma/admin/media_proxy_caches" do test "shows banned MediaProxy URLs", %{conn: conn} do - MediaProxy.put_in_deleted_urls([ + MediaProxy.put_in_banned_urls([ "http://localhost:4001/media/a688346.jpg", "http://localhost:4001/media/fb1f4d.jpg" ]) - MediaProxy.put_in_deleted_urls("http://localhost:4001/media/gb1f44.jpg") - MediaProxy.put_in_deleted_urls("http://localhost:4001/media/tb13f47.jpg") - MediaProxy.put_in_deleted_urls("http://localhost:4001/media/wb1f46.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/gb1f44.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/tb13f47.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/wb1f46.jpg") response = conn @@ -74,7 +74,7 @@ test "shows banned MediaProxy URLs", %{conn: conn} do describe "POST /api/pleroma/admin/media_proxy_caches/delete" do test "deleted MediaProxy URLs from banned", %{conn: conn} do - MediaProxy.put_in_deleted_urls([ + MediaProxy.put_in_banned_urls([ "http://localhost:4001/media/a688346.jpg", "http://localhost:4001/media/fb1f4d.jpg" ]) @@ -88,8 +88,8 @@ test "deleted MediaProxy URLs from banned", %{conn: conn} do |> json_response_and_validate_schema(200) assert response["urls"] == ["http://localhost:4001/media/a688346.jpg"] - refute MediaProxy.in_deleted_urls("http://localhost:4001/media/a688346.jpg") - assert MediaProxy.in_deleted_urls("http://localhost:4001/media/fb1f4d.jpg") + refute MediaProxy.in_banned_urls("http://localhost:4001/media/a688346.jpg") + assert MediaProxy.in_banned_urls("http://localhost:4001/media/fb1f4d.jpg") end end @@ -114,8 +114,8 @@ test "perform invalidates cache of MediaProxy", %{conn: conn} do assert response["urls"] == urls - refute MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") - refute MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + refute MediaProxy.in_banned_urls("http://example.com/media/a688346.jpg") + refute MediaProxy.in_banned_urls("http://example.com/media/fb1f4d.jpg") end end @@ -137,8 +137,8 @@ test "perform invalidates cache of MediaProxy and adds url to banned", %{conn: c assert response["urls"] == urls - assert MediaProxy.in_deleted_urls("http://example.com/media/a688346.jpg") - assert MediaProxy.in_deleted_urls("http://example.com/media/fb1f4d.jpg") + assert MediaProxy.in_banned_urls("http://example.com/media/a688346.jpg") + assert MediaProxy.in_banned_urls("http://example.com/media/fb1f4d.jpg") end end end diff --git a/test/web/media_proxy/invalidation_test.exs b/test/web/media_proxy/invalidation_test.exs index bf9af251c..926ae74ca 100644 --- a/test/web/media_proxy/invalidation_test.exs +++ b/test/web/media_proxy/invalidation_test.exs @@ -12,7 +12,7 @@ defmodule Pleroma.Web.MediaProxy.InvalidationTest do setup do: clear_config([:media_proxy]) setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end describe "Invalidation.Http" do @@ -23,7 +23,7 @@ test "perform request to clear cache" do Config.put([Invalidation.Http], method: :purge, headers: [{"x-refresh", 1}]) image_url = "http://example.com/media/example.jpg" - Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + Pleroma.Web.MediaProxy.put_in_banned_urls(image_url) mock(fn %{ @@ -35,9 +35,9 @@ test "perform request to clear cache" do end) assert capture_log(fn -> - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) assert Invalidation.purge([image_url]) == {:ok, [image_url]} - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) end) =~ "Running cache purge: [\"#{image_url}\"]" end end @@ -50,13 +50,13 @@ test "run script to clear cache" do Config.put([Invalidation.Script], script_path: "purge-nginx") image_url = "http://example.com/media/example.jpg" - Pleroma.Web.MediaProxy.put_in_deleted_urls(image_url) + Pleroma.Web.MediaProxy.put_in_banned_urls(image_url) with_mocks [{System, [], [cmd: fn _, _ -> {"ok", 0} end]}] do assert capture_log(fn -> - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) assert Invalidation.purge([image_url]) == {:ok, [image_url]} - assert Pleroma.Web.MediaProxy.in_deleted_urls(image_url) + assert Pleroma.Web.MediaProxy.in_banned_urls(image_url) end) =~ "Running cache purge: [\"#{image_url}\"]" end end diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index 72da98a6a..d61cef83b 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -11,7 +11,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base]) setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "it returns 404 when MediaProxy disabled", %{conn: conn} do @@ -71,11 +71,11 @@ test "it performs ReverseProxy.call when signature valid", %{conn: conn} do end end - test "it returns 404 when url contains in deleted_urls cache", %{conn: conn} do + test "it returns 404 when url contains in banned_urls cache", %{conn: conn} do Config.put([:media_proxy, :enabled], true) Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") - Pleroma.Web.MediaProxy.put_in_deleted_urls("https://google.fn/test.png") + Pleroma.Web.MediaProxy.put_in_banned_urls("https://google.fn/test.png") with_mock Pleroma.ReverseProxy, call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do -- cgit v1.2.3 From 4044f24e2e4935757e038e7f06373ed1c9172560 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 18 Jun 2020 05:02:33 +0300 Subject: fix test --- lib/pleroma/web/media_proxy/invalidation.ex | 3 ++- test/web/media_proxy/invalidations/http_test.exs | 2 +- test/web/media_proxy/invalidations/script_test.exs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/media_proxy/invalidation.ex b/lib/pleroma/web/media_proxy/invalidation.ex index 6da7eb720..5808861e6 100644 --- a/lib/pleroma/web/media_proxy/invalidation.ex +++ b/lib/pleroma/web/media_proxy/invalidation.ex @@ -26,7 +26,8 @@ def purge(urls) do defp do_purge(urls) do provider = Config.get([:media_proxy, :invalidation, :provider]) - provider.purge(urls, Config.get(provider)) + options = Config.get(provider) + provider.purge(urls, options) end def prepare_urls(urls) do diff --git a/test/web/media_proxy/invalidations/http_test.exs b/test/web/media_proxy/invalidations/http_test.exs index 9d181dd8b..a1bef5237 100644 --- a/test/web/media_proxy/invalidations/http_test.exs +++ b/test/web/media_proxy/invalidations/http_test.exs @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.HttpTest do import Tesla.Mock setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "logs hasn't error message when request is valid" do diff --git a/test/web/media_proxy/invalidations/script_test.exs b/test/web/media_proxy/invalidations/script_test.exs index 8e155b705..51833ab18 100644 --- a/test/web/media_proxy/invalidations/script_test.exs +++ b/test/web/media_proxy/invalidations/script_test.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MediaProxy.Invalidation.ScriptTest do import ExUnit.CaptureLog setup do - on_exit(fn -> Cachex.clear(:deleted_urls_cache) end) + on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "it logger error when script not found" do -- cgit v1.2.3 From c9b5e3fedabd0b6ef3bb9e6108385ffa3857af54 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 18 Jun 2020 05:29:31 +0300 Subject: revert 'database' option to rejected keys --- lib/pleroma/config/loader.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 76559e70c..0f3ecf1ed 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Config.Loader do Pleroma.Web.Endpoint, :env, :configurable_from_database, + :database, :swarm ] -- cgit v1.2.3 From e4c61f1741f32fec3201f7d9a8403bc1bc329710 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 18 Jun 2020 05:45:15 +0300 Subject: added test --- test/fixtures/config/temp.secret.exs | 2 ++ test/tasks/config_test.exs | 1 + 2 files changed, 3 insertions(+) diff --git a/test/fixtures/config/temp.secret.exs b/test/fixtures/config/temp.secret.exs index dc950ca30..fa8c7c7e8 100644 --- a/test/fixtures/config/temp.secret.exs +++ b/test/fixtures/config/temp.secret.exs @@ -9,3 +9,5 @@ config :pleroma, Pleroma.Repo, pool: Ecto.Adapters.SQL.Sandbox config :postgrex, :json_library, Poison + +config :pleroma, :database, rum_enabled: true diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index e1bddfebf..99038e544 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -50,6 +50,7 @@ test "filtered settings are migrated to db" do config3 = ConfigDB.get_by_params(%{group: ":quack", key: ":level"}) refute ConfigDB.get_by_params(%{group: ":pleroma", key: "Pleroma.Repo"}) refute ConfigDB.get_by_params(%{group: ":postgrex", key: ":json_library"}) + refute ConfigDB.get_by_params(%{group: ":pleroma", key: ":database"}) assert config1.value == [key: "value", key2: [Repo]] assert config2.value == [key: "value2", key2: ["Activity"]] -- cgit v1.2.3 From 3becdafd335f95d9320d287ecf9a55ea1b1765cd Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 18 Jun 2020 14:32:21 +0300 Subject: emoji packs pagination --- lib/pleroma/emoji/pack.ex | 19 +++++++++++++++---- .../operations/pleroma_emoji_pack_operation.ex | 14 ++++++++++++++ .../controllers/emoji_pack_controller.ex | 4 ++-- .../controllers/emoji_pack_controller_test.exs | 22 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 14a5185be..5660c4c9d 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Emoji.Pack do alias Pleroma.Emoji - @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} + @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} def create(name) do with :ok <- validate_not_empty([name]), dir <- Path.join(emoji_path(), name), @@ -120,8 +120,8 @@ def list_remote(url) do end end - @spec list_local() :: {:ok, map()} - def list_local do + @spec list_local(keyword()) :: {:ok, map()} + def list_local(opts) do with {:ok, results} <- list_packs_dir() do packs = results @@ -132,6 +132,17 @@ def list_local do end end) |> Enum.reject(&is_nil/1) + + packs = + case opts[:page] do + 1 -> + Enum.take(packs, opts[:page_size]) + + _ -> + packs + |> Enum.take(opts[:page] * opts[:page_size]) + |> Enum.take(-opts[:page_size]) + end |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) {:ok, packs} @@ -146,7 +157,7 @@ def get_archive(name) do end end - @spec download(String.t(), String.t(), String.t()) :: :ok | {:error, atom()} + @spec download(String.t(), String.t(), String.t()) :: {:ok, t()} | {:error, atom()} def download(name, url, as) do uri = url |> String.trim() |> URI.parse() diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 567688ff5..0d842382b 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -33,6 +33,20 @@ def index_operation do tags: ["Emoji Packs"], summary: "Lists local custom emoji packs", operationId: "PleromaAPI.EmojiPackController.index", + parameters: [ + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], responses: %{ 200 => emoji_packs_response() } diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index d1efdeb5d..5654b3fbe 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -37,13 +37,13 @@ def remote(conn, %{url: url}) do end end - def index(conn, _params) do + def index(conn, params) do emoji_path = [:instance, :static_dir] |> Pleroma.Config.get!() |> Path.join("emoji") - with {:ok, packs} <- Pack.list_local() do + with {:ok, packs} <- Pack.list_local(page: params.page, page_size: params.page_size) do json(conn, packs) else {:error, :create_dir, e} -> diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index ee3d281a0..aafca6359 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -39,6 +39,28 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do non_shared = resp["test_pack_nonshared"] assert non_shared["pack"]["share-files"] == false assert non_shared["pack"]["can-download"] == false + + resp = + conn + |> get("/api/pleroma/emoji/packs?page_size=1") + |> json_response_and_validate_schema(200) + + [pack1] = Map.keys(resp) + + resp = + conn + |> get("/api/pleroma/emoji/packs?page_size=1&page=2") + |> json_response_and_validate_schema(200) + + [pack2] = Map.keys(resp) + + resp = + conn + |> get("/api/pleroma/emoji/packs?page_size=1&page=3") + |> json_response_and_validate_schema(200) + + [pack3] = Map.keys(resp) + assert [pack1, pack2, pack3] |> Enum.uniq() |> length() == 3 end describe "GET /api/pleroma/emoji/packs/remote" do -- cgit v1.2.3 From 4975ed86bcca330373a68c9e6c6798a6b2167b14 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 18 Jun 2020 18:50:03 +0300 Subject: emoji pagination for pack show action --- docs/API/pleroma_api.md | 12 +++- lib/pleroma/emoji/pack.ex | 37 +++++++---- .../operations/pleroma_emoji_pack_operation.ex | 16 ++++- .../controllers/emoji_pack_controller.ex | 4 +- test/instance_static/emoji/test_pack/blank2.png | Bin 0 -> 95 bytes test/instance_static/emoji/test_pack/pack.json | 3 +- .../emoji/test_pack_nonshared/nonshared.zip | Bin 256 -> 548 bytes .../emoji/test_pack_nonshared/pack.json | 2 +- .../controllers/emoji_pack_controller_test.exs | 72 +++++++++++++++------ 9 files changed, 104 insertions(+), 42 deletions(-) create mode 100644 test/instance_static/emoji/test_pack/blank2.png diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 70d4755b7..d8d3ba85f 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -450,17 +450,25 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Response: JSON, list with updated files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. ## `GET /api/pleroma/emoji/packs` + ### Lists local custom emoji packs + * Method `GET` * Authentication: not required -* Params: None +* Params: + * `page`: page number for packs (default 1) + * `page_size`: page size for packs (default 50) * Response: JSON, "ok" and 200 status and the JSON hashmap of pack name to pack contents ## `GET /api/pleroma/emoji/packs/:name` + ### Get pack.json for the pack + * Method `GET` * Authentication: not required -* Params: None +* Params: + * `page`: page number for files (default 1) + * `page_size`: page size for files (default 50) * Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist ## `GET /api/pleroma/emoji/packs/:name/archive` diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 5660c4c9d..c033572c1 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -26,10 +26,27 @@ def create(name) do end end - @spec show(String.t()) :: {:ok, t()} | {:error, atom()} - def show(name) do + defp paginate(entities, 1, page_size), do: Enum.take(entities, page_size) + + defp paginate(entities, page, page_size) do + entities + |> Enum.take(page * page_size) + |> Enum.take(-page_size) + end + + @spec show(keyword()) :: {:ok, t()} | {:error, atom()} + def show(opts) do + name = opts[:name] + with :ok <- validate_not_empty([name]), {:ok, pack} <- load_pack(name) do + shortcodes = + pack.files + |> Map.keys() + |> paginate(opts[:page], opts[:page_size]) + + pack = Map.put(pack, :files, Map.take(pack.files, shortcodes)) + {:ok, validate_pack(pack)} end end @@ -132,17 +149,7 @@ def list_local(opts) do end end) |> Enum.reject(&is_nil/1) - - packs = - case opts[:page] do - 1 -> - Enum.take(packs, opts[:page_size]) - - _ -> - packs - |> Enum.take(opts[:page] * opts[:page_size]) - |> Enum.take(-opts[:page_size]) - end + |> paginate(opts[:page], opts[:page_size]) |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) {:ok, packs} @@ -307,7 +314,9 @@ defp downloadable?(pack) do # Otherwise, they'd have to download it from external-src pack.pack["share-files"] && Enum.all?(pack.files, fn {_, file} -> - File.exists?(Path.join(pack.path, file)) + pack.path + |> Path.join(file) + |> File.exists?() end) end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 0d842382b..e8abe654d 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -58,7 +58,21 @@ def show_operation do tags: ["Emoji Packs"], summary: "Show emoji pack", operationId: "PleromaAPI.EmojiPackController.show", - parameters: [name_param()], + parameters: [ + name_param(), + Operation.parameter( + :page, + :query, + %Schema{type: :integer, default: 1}, + "Page" + ), + Operation.parameter( + :page_size, + :query, + %Schema{type: :integer, default: 50}, + "Number of statuses to return" + ) + ], responses: %{ 200 => Operation.response("Emoji Pack", "application/json", emoji_pack()), 400 => Operation.response("Bad Request", "application/json", ApiError), diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 5654b3fbe..078fb88dd 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -60,10 +60,10 @@ def index(conn, params) do end end - def show(conn, %{name: name}) do + def show(conn, %{name: name, page: page, page_size: page_size}) do name = String.trim(name) - with {:ok, pack} <- Pack.show(name) do + with {:ok, pack} <- Pack.show(name: name, page: page, page_size: page_size) do json(conn, pack) else {:error, :not_found} -> diff --git a/test/instance_static/emoji/test_pack/blank2.png b/test/instance_static/emoji/test_pack/blank2.png new file mode 100644 index 000000000..8f50fa023 Binary files /dev/null and b/test/instance_static/emoji/test_pack/blank2.png differ diff --git a/test/instance_static/emoji/test_pack/pack.json b/test/instance_static/emoji/test_pack/pack.json index 481891b08..5b33fbb32 100644 --- a/test/instance_static/emoji/test_pack/pack.json +++ b/test/instance_static/emoji/test_pack/pack.json @@ -1,6 +1,7 @@ { "files": { - "blank": "blank.png" + "blank": "blank.png", + "blank2": "blank2.png" }, "pack": { "description": "Test description", diff --git a/test/instance_static/emoji/test_pack_nonshared/nonshared.zip b/test/instance_static/emoji/test_pack_nonshared/nonshared.zip index 148446c64..59bff37f0 100644 Binary files a/test/instance_static/emoji/test_pack_nonshared/nonshared.zip and b/test/instance_static/emoji/test_pack_nonshared/nonshared.zip differ diff --git a/test/instance_static/emoji/test_pack_nonshared/pack.json b/test/instance_static/emoji/test_pack_nonshared/pack.json index 93d643a5f..09f6274d1 100644 --- a/test/instance_static/emoji/test_pack_nonshared/pack.json +++ b/test/instance_static/emoji/test_pack_nonshared/pack.json @@ -4,7 +4,7 @@ "homepage": "https://pleroma.social", "description": "Test description", "fallback-src": "https://nonshared-pack", - "fallback-src-sha256": "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF", + "fallback-src-sha256": "1967BB4E42BCC34BCC12D57BE7811D3B7BE52F965BCE45C87BD377B9499CE11D", "share-files": false }, "files": { diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index aafca6359..f6239cae5 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -31,7 +31,7 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) shared = resp["test_pack"] - assert shared["files"] == %{"blank" => "blank.png"} + assert shared["files"] == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert Map.has_key?(shared["pack"], "download-sha256") assert shared["pack"]["can-download"] assert shared["pack"]["share-files"] @@ -354,7 +354,7 @@ test "for a pack with a fallback source", ctx do Map.put( new_data, "fallback-src-sha256", - "74409E2674DAA06C072729C6C8426C4CB3B7E0B85ED77792DB7A436E11D76DAF" + "1967BB4E42BCC34BCC12D57BE7811D3B7BE52F965BCE45C87BD377B9499CE11D" ) assert ctx[:admin_conn] @@ -420,7 +420,7 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", + shortcode: "blank3", filename: "dir/blank.png", file: %Plug.Upload{ filename: "blank.png", @@ -429,7 +429,8 @@ test "don't rewrite old emoji", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank2" => "dir/blank.png" + "blank2" => "blank2.png", + "blank3" => "dir/blank.png" } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") @@ -453,7 +454,7 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", + shortcode: "blank3", filename: "dir/blank.png", file: %Plug.Upload{ filename: "blank.png", @@ -462,7 +463,8 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank2" => "dir/blank.png" + "blank2" => "blank2.png", + "blank3" => "dir/blank.png" } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") @@ -470,14 +472,15 @@ test "rewrite old emoji with force option", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - new_shortcode: "blank3", + shortcode: "blank3", + new_shortcode: "blank4", new_filename: "dir_2/blank_3.png", force: true }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank3" => "dir_2/blank_3.png" + "blank2" => "blank2.png", + "blank4" => "dir_2/blank_3.png" } assert File.exists?("#{@emoji_path}/test_pack/dir_2/blank_3.png") @@ -503,7 +506,7 @@ test "add file with not loaded pack", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/pleroma/emoji/packs/not_loaded/files", %{ - shortcode: "blank2", + shortcode: "blank3", filename: "dir/blank.png", file: %Plug.Upload{ filename: "blank.png", @@ -557,7 +560,8 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank" => "blank.png", - "blank4" => "dir/blank.png" + "blank4" => "dir/blank.png", + "blank2" => "blank2.png" } assert File.exists?("#{@emoji_path}/test_pack/dir/blank.png") @@ -571,7 +575,8 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank3" => "dir_2/blank_3.png", - "blank" => "blank.png" + "blank" => "blank.png", + "blank2" => "blank2.png" } refute File.exists?("#{@emoji_path}/test_pack/dir/") @@ -579,7 +584,10 @@ test "new with shortcode as file with update", %{admin_conn: admin_conn} do assert admin_conn |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") - |> json_response_and_validate_schema(200) == %{"blank" => "blank.png"} + |> json_response_and_validate_schema(200) == %{ + "blank" => "blank.png", + "blank2" => "blank2.png" + } refute File.exists?("#{@emoji_path}/test_pack/dir_2/") @@ -603,7 +611,8 @@ test "new with shortcode from url", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "blank_url" => "blank_url.png", - "blank" => "blank.png" + "blank" => "blank.png", + "blank2" => "blank2.png" } assert File.exists?("#{@emoji_path}/test_pack/blank_url.png") @@ -624,15 +633,16 @@ test "new without shortcode", %{admin_conn: admin_conn} do }) |> json_response_and_validate_schema(200) == %{ "shortcode" => "shortcode.png", - "blank" => "blank.png" + "blank" => "blank.png", + "blank2" => "blank2.png" } end test "remove non existing shortcode in pack.json", %{admin_conn: admin_conn} do assert admin_conn - |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank2") + |> delete("/api/pleroma/emoji/packs/test_pack/files?shortcode=blank3") |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "Emoji \"blank2\" does not exist" + "error" => "Emoji \"blank3\" does not exist" } end @@ -640,12 +650,12 @@ test "update non existing emoji", %{admin_conn: admin_conn} do assert admin_conn |> put_req_header("content-type", "multipart/form-data") |> patch("/api/pleroma/emoji/packs/test_pack/files", %{ - shortcode: "blank2", - new_shortcode: "blank3", + shortcode: "blank3", + new_shortcode: "blank4", new_filename: "dir_2/blank_3.png" }) |> json_response_and_validate_schema(:bad_request) == %{ - "error" => "Emoji \"blank2\" does not exist" + "error" => "Emoji \"blank3\" does not exist" } end @@ -768,7 +778,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do describe "GET /api/pleroma/emoji/packs/:name" do test "shows pack.json", %{conn: conn} do assert %{ - "files" => %{"blank" => "blank.png"}, + "files" => files, "pack" => %{ "can-download" => true, "description" => "Test description", @@ -781,6 +791,26 @@ test "shows pack.json", %{conn: conn} do conn |> get("/api/pleroma/emoji/packs/test_pack") |> json_response_and_validate_schema(200) + + assert files == %{"blank" => "blank.png", "blank2" => "blank2.png"} + + assert %{ + "files" => files + } = + conn + |> get("/api/pleroma/emoji/packs/test_pack?page_size=1") + |> json_response_and_validate_schema(200) + + assert files |> Map.keys() |> length() == 1 + + assert %{ + "files" => files + } = + conn + |> get("/api/pleroma/emoji/packs/test_pack?page_size=1&page=2") + |> json_response_and_validate_schema(200) + + assert files |> Map.keys() |> length() == 1 end test "non existing pack", %{conn: conn} do -- cgit v1.2.3 From 3e3f9253e6db17b691c7393ad7a5f89df84348ea Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Jun 2020 10:17:24 +0300 Subject: adding overall count for packs and files --- docs/API/pleroma_api.md | 22 ++++++++++++++-- lib/pleroma/emoji/pack.ex | 20 +++++++++++---- .../controllers/emoji_pack_controller.ex | 4 +-- .../controllers/emoji_pack_controller_test.exs | 30 ++++++++++++++-------- 4 files changed, 56 insertions(+), 20 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index d8d3ba85f..e5bc29eb2 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -458,7 +458,17 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: * `page`: page number for packs (default 1) * `page_size`: page size for packs (default 50) -* Response: JSON, "ok" and 200 status and the JSON hashmap of pack name to pack contents +* Response: `packs` key with JSON hashmap of pack name to pack contents and `count` key for count of packs. + +```json +{ + "packs": { + "pack_name": {...}, // pack contents + ... + }, + "count": 0 // packs count +} +``` ## `GET /api/pleroma/emoji/packs/:name` @@ -469,7 +479,15 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Params: * `page`: page number for files (default 1) * `page_size`: page size for files (default 50) -* Response: JSON, pack json with `files` and `pack` keys with 200 status or 404 if the pack does not exist +* Response: JSON, pack json with `files`, `files_count` and `pack` keys with 200 status or 404 if the pack does not exist. + +```json +{ + "files": {...}, + "files_count": 0, // emoji count in pack + "pack": {...} +} +``` ## `GET /api/pleroma/emoji/packs/:name/archive` ### Requests a local pack archive from the instance diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index c033572c1..2dca21c93 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -1,6 +1,7 @@ defmodule Pleroma.Emoji.Pack do - @derive {Jason.Encoder, only: [:files, :pack]} + @derive {Jason.Encoder, only: [:files, :pack, :files_count]} defstruct files: %{}, + files_count: 0, pack_file: nil, path: nil, pack: %{}, @@ -8,6 +9,7 @@ defmodule Pleroma.Emoji.Pack do @type t() :: %__MODULE__{ files: %{String.t() => Path.t()}, + files_count: non_neg_integer(), pack_file: Path.t(), path: Path.t(), pack: map(), @@ -137,10 +139,10 @@ def list_remote(url) do end end - @spec list_local(keyword()) :: {:ok, map()} + @spec list_local(keyword()) :: {:ok, map(), non_neg_integer()} def list_local(opts) do with {:ok, results} <- list_packs_dir() do - packs = + all_packs = results |> Enum.map(fn name -> case load_pack(name) do @@ -149,10 +151,13 @@ def list_local(opts) do end end) |> Enum.reject(&is_nil/1) + + packs = + all_packs |> paginate(opts[:page], opts[:page_size]) |> Map.new(fn pack -> {pack.name, validate_pack(pack)} end) - {:ok, packs} + {:ok, packs, length(all_packs)} end end @@ -215,7 +220,12 @@ def load_pack(name) do |> Map.put(:path, Path.dirname(pack_file)) |> Map.put(:name, name) - {:ok, pack} + files_count = + pack.files + |> Map.keys() + |> length() + + {:ok, Map.put(pack, :files_count, files_count)} else {:error, :not_found} end diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 078fb88dd..33ecd1f70 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -43,8 +43,8 @@ def index(conn, params) do |> Pleroma.Config.get!() |> Path.join("emoji") - with {:ok, packs} <- Pack.list_local(page: params.page, page_size: params.page_size) do - json(conn, packs) + with {:ok, packs, count} <- Pack.list_local(page: params.page, page_size: params.page_size) do + json(conn, %{packs: packs, count: count}) else {:error, :create_dir, e} -> conn diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index f6239cae5..91312c832 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -30,13 +30,14 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerTest do test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - shared = resp["test_pack"] + assert resp["count"] == 3 + shared = resp["packs"]["test_pack"] assert shared["files"] == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert Map.has_key?(shared["pack"], "download-sha256") assert shared["pack"]["can-download"] assert shared["pack"]["share-files"] - non_shared = resp["test_pack_nonshared"] + non_shared = resp["packs"]["test_pack_nonshared"] assert non_shared["pack"]["share-files"] == false assert non_shared["pack"]["can-download"] == false @@ -45,21 +46,24 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> get("/api/pleroma/emoji/packs?page_size=1") |> json_response_and_validate_schema(200) - [pack1] = Map.keys(resp) + assert resp["count"] == 3 + [pack1] = Map.keys(resp["packs"]) resp = conn |> get("/api/pleroma/emoji/packs?page_size=1&page=2") |> json_response_and_validate_schema(200) - [pack2] = Map.keys(resp) + assert resp["count"] == 3 + [pack2] = Map.keys(resp["packs"]) resp = conn |> get("/api/pleroma/emoji/packs?page_size=1&page=3") |> json_response_and_validate_schema(200) - [pack3] = Map.keys(resp) + assert resp["count"] == 3 + [pack3] = Map.keys(resp["packs"]) assert [pack1, pack2, pack3] |> Enum.uniq() |> length() == 3 end @@ -683,7 +687,8 @@ test "creating and deleting a pack", %{admin_conn: admin_conn} do assert Jason.decode!(File.read!("#{@emoji_path}/test_created/pack.json")) == %{ "pack" => %{}, - "files" => %{} + "files" => %{}, + "files_count" => 0 } assert admin_conn @@ -741,14 +746,14 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - refute Map.has_key?(resp, "test_pack_for_import") + refute Map.has_key?(resp["packs"], "test_pack_for_import") assert admin_conn |> get("/api/pleroma/emoji/packs/import") |> json_response_and_validate_schema(200) == ["test_pack_for_import"] resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - assert resp["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} + assert resp["packs"]["test_pack_for_import"]["files"] == %{"blank" => "blank.png"} File.rm!("#{@emoji_path}/test_pack_for_import/pack.json") refute File.exists?("#{@emoji_path}/test_pack_for_import/pack.json") @@ -768,7 +773,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) - assert resp["test_pack_for_import"]["files"] == %{ + assert resp["packs"]["test_pack_for_import"]["files"] == %{ "blank" => "blank.png", "blank2" => "blank.png", "foo" => "blank.png" @@ -779,6 +784,7 @@ test "filesystem import", %{admin_conn: admin_conn, conn: conn} do test "shows pack.json", %{conn: conn} do assert %{ "files" => files, + "files_count" => 2, "pack" => %{ "can-download" => true, "description" => "Test description", @@ -795,7 +801,8 @@ test "shows pack.json", %{conn: conn} do assert files == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert %{ - "files" => files + "files" => files, + "files_count" => 2 } = conn |> get("/api/pleroma/emoji/packs/test_pack?page_size=1") @@ -804,7 +811,8 @@ test "shows pack.json", %{conn: conn} do assert files |> Map.keys() |> length() == 1 assert %{ - "files" => files + "files" => files, + "files_count" => 2 } = conn |> get("/api/pleroma/emoji/packs/test_pack?page_size=1&page=2") -- cgit v1.2.3 From 0c739b423aad4cc6baa3a59308200ca5a5060716 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Jun 2020 12:31:55 +0300 Subject: changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee13904f..ee657de13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added + - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` @@ -34,6 +35,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Notifications: Added `follow_request` notification type. - Added `:reject_deletes` group to SimplePolicy - MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances +- Support pagination in emoji packs API (for packs and for files in pack) + <details> <summary>API Changes</summary> - Mastodon API: Extended `/api/v1/instance`. -- cgit v1.2.3 From 02ca8a363f738ece7b605940690f6a538f6c2fa8 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Jun 2020 14:46:38 +0300 Subject: default page size for files --- docs/API/pleroma_api.md | 2 +- lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index e5bc29eb2..b7eee5192 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -478,7 +478,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa * Authentication: not required * Params: * `page`: page number for files (default 1) - * `page_size`: page size for files (default 50) + * `page_size`: page size for files (default 30) * Response: JSON, pack json with `files`, `files_count` and `pack` keys with 200 status or 404 if the pack does not exist. ```json diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index e8abe654d..da7cc5154 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -69,7 +69,7 @@ def show_operation do Operation.parameter( :page_size, :query, - %Schema{type: :integer, default: 50}, + %Schema{type: :integer, default: 30}, "Number of statuses to return" ) ], -- cgit v1.2.3 From 5237a2df9f123f661de30a53193b7d9fec69ecae Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Fri, 19 Jun 2020 16:14:06 +0300 Subject: [#1873] Fixes missing :offset pagination param support. Added pagination support for hashtags search. --- lib/pleroma/pagination.ex | 6 +++++ lib/pleroma/web/api_spec/helpers.ex | 6 +++++ .../mastodon_api/controllers/search_controller.ex | 31 +++++++++++++--------- .../controllers/search_controller_test.exs | 16 +++++++++++ .../controllers/status_controller_test.exs | 2 +- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 1b99e44f9..9a3795769 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -64,6 +64,12 @@ def fetch_paginated(query, params, :offset, table_binding) do @spec paginate(Ecto.Query.t(), map(), type(), atom() | nil) :: [Ecto.Schema.t()] def paginate(query, options, method \\ :keyset, table_binding \\ nil) + def paginate(list, options, _method, _table_binding) when is_list(list) do + offset = options[:offset] || 0 + limit = options[:limit] || 0 + Enum.slice(list, offset, limit) + end + def paginate(query, options, :keyset, table_binding) do query |> restrict(:min_id, options, table_binding) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index a9cfe0fed..a258e8421 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -39,6 +39,12 @@ def pagination_params do :string, "Return the newest items newer than this ID" ), + Operation.parameter( + :offset, + :query, + %Schema{type: :integer, default: 0}, + "Return items past this number of items" + ), Operation.parameter( :limit, :query, diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 3be0ca095..e50980122 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -107,21 +107,21 @@ defp resource_search(_, "statuses", query, options) do ) end - defp resource_search(:v2, "hashtags", query, _options) do + defp resource_search(:v2, "hashtags", query, options) do tags_path = Web.base_url() <> "/tag/" query - |> prepare_tags() + |> prepare_tags(options) |> Enum.map(fn tag -> %{name: tag, url: tags_path <> tag} end) end - defp resource_search(:v1, "hashtags", query, _options) do - prepare_tags(query) + defp resource_search(:v1, "hashtags", query, options) do + prepare_tags(query, options) end - defp prepare_tags(query, add_joined_tag \\ true) do + defp prepare_tags(query, options) do tags = query |> preprocess_uri_query() @@ -139,13 +139,20 @@ defp prepare_tags(query, add_joined_tag \\ true) do tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end) - if Enum.empty?(explicit_tags) && add_joined_tag do - tags - |> Kernel.++([joined_tag(tags)]) - |> Enum.uniq_by(&String.downcase/1) - else - tags - end + tags = + if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do + add_joined_tag(tags) + else + tags + end + + Pleroma.Pagination.paginate(tags, options) + end + + defp add_joined_tag(tags) do + tags + |> Kernel.++([joined_tag(tags)]) + |> Enum.uniq_by(&String.downcase/1) end # If `query` is a URI, returns last component of its path, otherwise returns `query` diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index c605957b1..826f37fbc 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -151,6 +151,22 @@ test "constructs hashtags from search query", %{conn: conn} do ] end + test "supports pagination of hashtags search results", %{conn: conn} do + results = + conn + |> get( + "/api/v2/search?#{ + URI.encode_query(%{q: "#some #text #with #hashtags", limit: 2, offset: 1}) + }" + ) + |> json_response_and_validate_schema(200) + + assert results["hashtags"] == [ + %{"name" => "text", "url" => "#{Web.base_url()}/tag/text"}, + %{"name" => "with", "url" => "#{Web.base_url()}/tag/with"} + ] + end + test "excludes a blocked users from search results", %{conn: conn} do user = insert(:user) user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"}) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index 648e6f2ce..a98e939e8 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -1561,7 +1561,7 @@ test "favorites paginate correctly" do # Using the header for pagination works correctly [next, _] = get_resp_header(result, "link") |> hd() |> String.split(", ") - [_, max_id] = Regex.run(~r/max_id=(.*)>;/, next) + [_, max_id] = Regex.run(~r/max_id=([^&]+)/, next) assert max_id == third_favorite.id -- cgit v1.2.3 From abdb540d450b5e68ea452f78d865d63bca764a49 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 19 Jun 2020 15:30:30 +0200 Subject: ObjectValidators: Add basic UpdateValidator. --- lib/pleroma/web/activity_pub/builder.ex | 15 ++++++++ lib/pleroma/web/activity_pub/object_validator.ex | 11 ++++++ .../object_validators/update_validator.ex | 43 ++++++++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 20 ++++++++++ 4 files changed, 89 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/update_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 1aac62c69..135a5c431 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -123,6 +123,21 @@ def like(actor, object) do end end + # Retricted to user updates for now, always public + @spec update(User.t(), Object.t()) :: {:ok, map(), keyword()} + def update(actor, object) do + to = [Pleroma.Constants.as_public(), actor.follower_address] + + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "type" => "Update", + "actor" => actor.ap_id, + "object" => object, + "to" => to + }, []} + end + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} def announce(actor, object, options \\ []) do public? = Keyword.get(options, :public, false) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 6a83a2c33..804a9d06e 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -19,10 +19,21 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Update"} = object, meta) do + with {:ok, object} <- + object + |> UpdateValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Undo"} = object, meta) do with {:ok, object} <- object diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex new file mode 100644 index 000000000..94d72491b --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -0,0 +1,43 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + # In this case, we save the full object in this activity instead of just a + # reference, so we can always see what was actually changed by this. + field(:object, :map) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Update"]) + |> validate_actor_presence() + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 31224abe0..adb56092d 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -622,4 +622,24 @@ test "returns an error if the actor can't announce the object", %{ assert {:actor, {"can not announce this object publicly", []}} in cng.errors end end + + describe "updates" do + setup do + user = insert(:user) + + object = %{ + "id" => user.ap_id, + "name" => "A new name", + "summary" => "A new bio" + } + + {:ok, valid_update, []} = Builder.update(user, object) + + %{user: user, valid_update: valid_update} + end + + test "validates a basic object", %{valid_update: valid_update} do + assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) + end + end end -- cgit v1.2.3 From d54b0432eae74a830a0294cf48f23933a16382aa Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 19 Jun 2020 15:49:34 +0200 Subject: README: Add some troubleshooting info for compilation issues. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 7fc1fd381..6ca3118fb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ Currently Pleroma is not packaged by any OS/Distros, but if you want to package ### Docker While we don’t provide docker files, other people have written very good ones. Take a look at <https://github.com/angristan/docker-pleroma> or <https://glitch.sh/sn0w/pleroma-docker>. +### Compilation Troubleshooting +If you ever encounter compilation issues during the updating of Pleroma, you can try these commands and see if they fix things: + +- `mix deps.clean --all` +- `mix local.rebar` +- `mix local.hex` +- `rm -r _build` + +If you are not developing Pleroma, it is better to use the OTP release, which comes with everything precompiled. + ## Documentation - Latest Released revision: <https://docs.pleroma.social> - Latest Git revision: <https://docs-develop.pleroma.social> -- cgit v1.2.3 From 75670a99e46a09f9bddc0959c680c2cb173e1f3b Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Fri, 19 Jun 2020 16:38:57 +0200 Subject: UpdateValidator: Only allow updates from the user themselves. --- .../activity_pub/object_validators/update_validator.ex | 16 ++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex index 94d72491b..b4ba5ede0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -33,6 +33,7 @@ def validate_data(cng) do |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Update"]) |> validate_actor_presence() + |> validate_updating_rights() end def cast_and_validate(data) do @@ -40,4 +41,19 @@ def cast_and_validate(data) do |> cast_data |> validate_data end + + # For now we only support updating users, and here the rule is easy: + # object id == actor id + def validate_updating_rights(cng) do + with actor = get_field(cng, :actor), + object = get_field(cng, :object), + {:ok, object_id} <- ObjectValidators.ObjectID.cast(object), + true <- actor == object_id do + cng + else + _e -> + cng + |> add_error(:object, "Can't be updated by this actor") + end + end end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index adb56092d..770a8dcf8 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -641,5 +641,17 @@ test "returns an error if the actor can't announce the object", %{ test "validates a basic object", %{valid_update: valid_update} do assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) end + + test "returns an error if the object can't be updated by the actor", %{ + valid_update: valid_update + } do + other_user = insert(:user) + + update = + valid_update + |> Map.put("actor", other_user.ap_id) + + assert {:error, _cng} = ObjectValidator.validate(update, []) + end end end -- cgit v1.2.3 From b63646169dbed68814bdb867c4b8b3c88a3d2360 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko <suprunenko.s@gmail.com> Date: Fri, 19 Jun 2020 21:18:07 +0200 Subject: Add support for bot field in update_credentials --- CHANGELOG.md | 1 + lib/pleroma/user.ex | 1 + .../mastodon_api/controllers/account_controller.ex | 3 + .../account_controller/update_credentials_test.exs | 67 ++++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee13904f..8a8798e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ 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`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. - Mastodon API: Add support for filtering replies in public and home timelines +- Mastodon API: Support for `bot` field in `/api/v1/accounts/update_credentials` - Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoint for status view. - OTP: Add command to reload emoji packs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f0ccc7c79..ae4f96aac 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -465,6 +465,7 @@ 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) + |> validate_inclusion(:actor_type, ["Person", "Service"]) |> put_fields() |> put_emoji() |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index c38c2b895..adbbac624 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -177,6 +177,9 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store]) |> Maps.put_if_present(:default_scope, params[:default_scope]) |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) + |> Maps.put_if_present(:actor_type, params[:bot], fn bot -> + if bot, do: {:ok, "Service"}, else: {:ok, "Person"} + end) |> Maps.put_if_present(:actor_type, params[:actor_type]) changeset = User.update_changeset(user, user_params) 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 76e6d603a..f67d294ba 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 @@ -400,4 +400,71 @@ test "update fields when invalid request", %{conn: conn} do |> json_response_and_validate_schema(403) end end + + describe "Mark account as bot" do + setup do: oauth_access(["write:accounts"]) + setup :request_content_type + + test "changing actor_type to Service makes account a bot", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{actor_type: "Service"}) + |> json_response_and_validate_schema(200) + + assert account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Service" + end + + test "changing actor_type to Person makes account a human", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{actor_type: "Person"}) + |> json_response_and_validate_schema(200) + + refute account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Person" + end + + test "changing actor_type to Application causes error", %{conn: conn} do + response = + conn + |> patch("/api/v1/accounts/update_credentials", %{actor_type: "Application"}) + |> json_response_and_validate_schema(403) + + assert %{"error" => "Invalid request"} == response + end + + test "changing bot field to true changes actor_type to Service", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{bot: "true"}) + |> json_response_and_validate_schema(200) + + assert account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Service" + end + + test "changing bot field to false changes actor_type to Person", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{bot: "false"}) + |> json_response_and_validate_schema(200) + + refute account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Person" + end + + test "actor_type field has a higher priority than bot", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{ + actor_type: "Person", + bot: "true" + }) + |> json_response_and_validate_schema(200) + + refute account["bot"] + assert account["source"]["pleroma"]["actor_type"] == "Person" + end + end end -- cgit v1.2.3 From ac0344dd24d520ab61e835b9caea97529f4c1dad Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko <suprunenko.s@gmail.com> Date: Fri, 19 Jun 2020 21:19:00 +0200 Subject: Only accounts with Service actor_type are considered as bots --- lib/pleroma/web/mastodon_api/views/account_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 68beb69b8..6c40b8ccd 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -179,7 +179,7 @@ defp do_render("show.json", %{user: user} = opts) do 0 end - bot = user.actor_type in ["Application", "Service"] + bot = user.actor_type == "Service" emojis = Enum.map(user.emoji, fn {shortcode, raw_url} -> -- cgit v1.2.3 From 3d4cfc9c5f3969e08c32781385c86f310eba70a2 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 18 Jun 2020 19:32:03 +0200 Subject: Stop filling conversation field on incoming objects (legacy, unused) conversation field is still set for outgoing federation for compatibility. --- lib/pleroma/web/activity_pub/object_validators/note_validator.ex | 1 - lib/pleroma/web/activity_pub/transmogrifier.ex | 6 +++--- test/web/activity_pub/transmogrifier_test.exs | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) 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 a10728ac6..56b93dde8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -41,7 +41,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:announcements, {:array, :string}, default: []) # see if needed - field(:conversation, :string) field(:context_id, :string) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 851f474b8..1c60ef8f5 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -172,8 +172,8 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) object |> Map.put("inReplyTo", replied_object.data["id"]) |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) - |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) |> Map.put("context", replied_object.data["context"] || object["conversation"]) + |> Map.drop(["conversation"]) else e -> Logger.error("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") @@ -207,7 +207,7 @@ def fix_context(object) do object |> Map.put("context", context) - |> Map.put("conversation", context) + |> Map.drop(["conversation"]) end def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do @@ -458,7 +458,7 @@ def handle_incoming( to: data["to"], object: object, actor: user, - context: object["conversation"], + context: object["context"], local: false, published: data["published"], additional: diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 94d8552e8..47d6e843a 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -1571,9 +1571,6 @@ test "returns modified object when allowed incoming reply", %{data: data} do assert modified_object["inReplyToAtomUri"] == "https://shitposter.club/notice/2827873" - assert modified_object["conversation"] == - "tag:shitposter.club,2017-05-05:objectType=thread:nonce=3c16e9c2681f6d26" - assert modified_object["context"] == "tag:shitposter.club,2017-05-05:objectType=thread:nonce=3c16e9c2681f6d26" end -- cgit v1.2.3 From 1a704e1f1e0acb73cbfb49acc4f614dd01799c46 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Jun 2020 10:56:28 +0300 Subject: fix for packs pagination --- lib/pleroma/emoji/pack.ex | 6 +++--- .../controllers/emoji_pack_controller_test.exs | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 2dca21c93..787ff8141 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -32,8 +32,8 @@ defp paginate(entities, 1, page_size), do: Enum.take(entities, page_size) defp paginate(entities, page, page_size) do entities - |> Enum.take(page * page_size) - |> Enum.take(-page_size) + |> Enum.chunk_every(page_size) + |> Enum.at(page - 1) end @spec show(keyword()) :: {:ok, t()} | {:error, atom()} @@ -470,7 +470,7 @@ defp list_packs_dir do # with the API so it should be sufficient with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)}, {:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do - {:ok, results} + {:ok, Enum.sort(results)} else {:create_dir, {:error, e}} -> {:error, :create_dir, e} {:ls, {:error, e}} -> {:error, :ls, e} diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index 91312c832..df58a5eb6 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -31,6 +31,11 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) assert resp["count"] == 3 + + assert resp["packs"] + |> Map.keys() + |> length() == 3 + shared = resp["packs"]["test_pack"] assert shared["files"] == %{"blank" => "blank.png", "blank2" => "blank2.png"} assert Map.has_key?(shared["pack"], "download-sha256") @@ -47,7 +52,12 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> json_response_and_validate_schema(200) assert resp["count"] == 3 - [pack1] = Map.keys(resp["packs"]) + + packs = Map.keys(resp["packs"]) + + assert length(packs) == 1 + + [pack1] = packs resp = conn @@ -55,7 +65,9 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> json_response_and_validate_schema(200) assert resp["count"] == 3 - [pack2] = Map.keys(resp["packs"]) + packs = Map.keys(resp["packs"]) + assert length(packs) == 1 + [pack2] = packs resp = conn @@ -63,7 +75,9 @@ test "GET /api/pleroma/emoji/packs", %{conn: conn} do |> json_response_and_validate_schema(200) assert resp["count"] == 3 - [pack3] = Map.keys(resp["packs"]) + packs = Map.keys(resp["packs"]) + assert length(packs) == 1 + [pack3] = packs assert [pack1, pack2, pack3] |> Enum.uniq() |> length() == 3 end -- cgit v1.2.3 From 4cb7b1ebc6b255faae635f6138bf90264e84e1fb Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 20 Jun 2020 09:34:34 +0000 Subject: Apply suggestion to lib/mix/tasks/pleroma/config.ex --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 65691f9c1..d5129d410 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -52,7 +52,7 @@ def migrate_to_db(file_path \\ nil) do defp do_migrate_to_db(config_file) do if File.exists?(config_file) do - shell_info("Running migrate settings from file: #{Path.expand(config_file)}") + shell_info("Migrating settings from file: #{Path.expand(config_file)}") Ecto.Adapters.SQL.query!(Repo, "TRUNCATE config;") Ecto.Adapters.SQL.query!(Repo, "ALTER SEQUENCE config_id_seq RESTART;") -- cgit v1.2.3 From 15ba5392584a2d4e8129a99e825f5025e57e6ebd Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 20 Jun 2020 11:39:06 +0200 Subject: cheatsheet.md: no_attachment_links → attachment_links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 6ebdab546..7e5f1cd29 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -60,7 +60,7 @@ To add configuration to your config file, you can copy it from the base config. older software for theses nicknames. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. * `autofollowed_nicknames`: Set to nicknames of (local) users that every new user should automatically follow. -* `no_attachment_links`: Set to true to disable automatically adding attachment link text to statuses. +* `attachment_links`: Set to true to enable automatically adding attachment link text to statuses. * `welcome_message`: A message that will be send to a newly registered users as a direct message. * `welcome_user_nickname`: The nickname of the local user that sends the welcome message. * `max_report_comment_size`: The maximum size of the report comment (Default: `1000`). -- cgit v1.2.3 From 0e789bc55fed24fd913d6bf1a5c6be135320b0c9 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Sat, 20 Jun 2020 09:39:50 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex --- lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index da7cc5154..caa849721 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -44,7 +44,7 @@ def index_operation do :page_size, :query, %Schema{type: :integer, default: 50}, - "Number of statuses to return" + "Number of emoji packs to return" ) ], responses: %{ -- cgit v1.2.3 From c5863438ba9079a01a832fe48e203907fe5b37cd Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Jun 2020 13:53:57 +0300 Subject: proper error codes for error in adminFE --- docs/API/admin_api.md | 56 ++++++++++++---------- .../admin_api/controllers/admin_api_controller.ex | 29 +++++------ .../admin_api/controllers/fallback_controller.ex | 6 +++ .../controllers/admin_api_controller_test.exs | 4 +- 4 files changed, 51 insertions(+), 44 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index c7f56cf5f..b6fb43dcb 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -488,35 +488,39 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret ### 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: +* 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` + +* Responses: + +Status: 200 ```json {"status": "success"} ``` +Status: 400 + ```json {"errors": {"actor_type": "is invalid"}, @@ -525,8 +529,10 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret } ``` +Status: 404 + ```json -{"error": "Unable to update user."} +{"error": "Not found"} ``` ## `GET /api/pleroma/admin/reports` diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 5cbf0dd4f..db2413dfe 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -111,8 +111,7 @@ def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) action: "delete" }) - conn - |> json(nicknames) + json(conn, nicknames) end def user_follow(%{assigns: %{user: admin}} = conn, %{ @@ -131,8 +130,7 @@ def user_follow(%{assigns: %{user: admin}} = conn, %{ }) end - conn - |> json("ok") + json(conn, "ok") end def user_unfollow(%{assigns: %{user: admin}} = conn, %{ @@ -151,8 +149,7 @@ def user_unfollow(%{assigns: %{user: admin}} = conn, %{ }) end - conn - |> json("ok") + json(conn, "ok") end def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do @@ -191,8 +188,7 @@ def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do action: "create" }) - conn - |> json(res) + json(conn, res) {:error, id, changeset, _} -> res = @@ -363,8 +359,8 @@ defp maybe_parse_filters(filters) do filters |> String.split(",") |> Enum.filter(&Enum.member?(@filters, &1)) - |> Enum.map(&String.to_atom(&1)) - |> Enum.into(%{}, &{&1, true}) + |> Enum.map(&String.to_atom/1) + |> Map.new(&{&1, true}) end def right_add_multiple(%{assigns: %{user: admin}} = conn, %{ @@ -568,10 +564,10 @@ def update_user_credentials( {:error, changeset} -> errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end) - json(conn, %{errors: errors}) + {:errors, errors} _ -> - json(conn, %{error: "Unable to update user."}) + {:error, :not_found} end end @@ -616,7 +612,7 @@ defp configurable_from_database do def reload_emoji(conn, _params) do Pleroma.Emoji.reload() - conn |> json("ok") + json(conn, "ok") end def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do @@ -630,7 +626,7 @@ def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames} action: "confirm_email" }) - conn |> json("") + json(conn, "") end def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do @@ -644,14 +640,13 @@ def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" = action: "resend_confirmation_email" }) - conn |> json("") + json(conn, "") end def stats(conn, _) do count = Stats.get_status_visibility_count() - conn - |> json(%{"status_visibility" => count}) + json(conn, %{"status_visibility" => count}) end defp page_params(params) do diff --git a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex index 82965936d..34d90db07 100644 --- a/lib/pleroma/web/admin_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/fallback_controller.ex @@ -17,6 +17,12 @@ def call(conn, {:error, reason}) do |> json(%{error: reason}) end + def call(conn, {:errors, errors}) do + conn + |> put_status(:bad_request) + |> json(%{errors: errors}) + end + def call(conn, {:param_cast, _}) do conn |> put_status(:bad_request) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index e3d3ccb8d..3a3eb822d 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1599,14 +1599,14 @@ test "changes actor type from permitted list", %{conn: conn, user: user} do assert patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{ "actor_type" => "Application" }) - |> json_response(200) == %{"errors" => %{"actor_type" => "is invalid"}} + |> json_response(400) == %{"errors" => %{"actor_type" => "is invalid"}} end test "update non existing user", %{conn: conn} do assert patch(conn, "/api/pleroma/admin/users/non-existing/credentials", %{ "password" => "new_password" }) - |> json_response(200) == %{"error" => "Unable to update user."} + |> json_response(404) == %{"error" => "Not found"} end end -- cgit v1.2.3 From b5f13af7ba66924f6aed448bd519f6becc269922 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Jun 2020 10:59:08 +0000 Subject: Apply suggestion to lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex --- lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index caa849721..b2b4f8713 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -70,7 +70,7 @@ def show_operation do :page_size, :query, %Schema{type: :integer, default: 30}, - "Number of statuses to return" + "Number of emoji to return" ) ], responses: %{ -- cgit v1.2.3 From 35e9282ffdafd8a04d1c09ec5eff3f176bb389de Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 10:35:11 +0200 Subject: HellthreadPolicy: Restrict to Notes and Articles. --- lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex | 7 +++++-- test/web/activity_pub/mrf/hellthread_policy_test.exs | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index 1764bc789..f6b2c4415 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -13,8 +13,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do defp delist_message(message, threshold) when threshold > 0 do follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address + to = message["to"] || [] + cc = message["cc"] || [] - follower_collection? = Enum.member?(message["to"] ++ message["cc"], follower_collection) + follower_collection? = Enum.member?(to ++ cc, follower_collection) message = case get_recipient_count(message) do @@ -71,7 +73,8 @@ defp get_recipient_count(message) do end @impl true - def filter(%{"type" => "Create"} = message) do + def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = message) + when object_type in ~w{Note Article} do reject_threshold = Pleroma.Config.get( [:mrf_hellthread, :reject_threshold], diff --git a/test/web/activity_pub/mrf/hellthread_policy_test.exs b/test/web/activity_pub/mrf/hellthread_policy_test.exs index 95ef0b168..6e9daa7f9 100644 --- a/test/web/activity_pub/mrf/hellthread_policy_test.exs +++ b/test/web/activity_pub/mrf/hellthread_policy_test.exs @@ -8,6 +8,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do import Pleroma.Web.ActivityPub.MRF.HellthreadPolicy + alias Pleroma.Web.CommonAPI + setup do user = insert(:user) @@ -20,7 +22,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do "https://instance.tld/users/user1", "https://instance.tld/users/user2", "https://instance.tld/users/user3" - ] + ], + "object" => %{ + "type" => "Note" + } } [user: user, message: message] @@ -28,6 +33,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicyTest do setup do: clear_config(:mrf_hellthread) + test "doesn't die on chat messages" do + Pleroma.Config.put([:mrf_hellthread], %{delist_threshold: 2, reject_threshold: 0}) + + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post_chat_message(user, other_user, "moin") + + assert {:ok, _} = filter(activity.data) + end + describe "reject" do test "rejects the message if the recipient count is above reject_threshold", %{ message: message -- cgit v1.2.3 From 9f7ee5dfa283f8db9a5fcb006630674263425ac8 Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 11:41:22 +0200 Subject: Add include for the "Further reading" section * I added an include and use this include for the installation guides that already had this section * I added the "Further reading" section as well as te "Questions" section to the English guides that didn't have it yet * I added a first point "How Federation Works/Why is my Federated Timeline empty?" to link to lains blogpost about this because we still get this question a lot in the #pleroma support channel * I reordered the list a bit --- docs/installation/alpine_linux_en.md | 5 +---- docs/installation/arch_linux_en.md | 5 +---- docs/installation/debian_based_en.md | 5 +---- docs/installation/debian_based_jp.md | 5 +---- docs/installation/further_reading.include | 5 +++++ docs/installation/gentoo_en.md | 5 +---- docs/installation/netbsd_en.md | 8 ++++++++ docs/installation/openbsd_en.md | 8 ++++++++ docs/installation/otp_en.md | 5 +---- 9 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 docs/installation/further_reading.include diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 2a9b8f6ff..c726d559f 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -225,10 +225,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md index 8370986ad..bf9cfb488 100644 --- a/docs/installation/arch_linux_en.md +++ b/docs/installation/arch_linux_en.md @@ -200,10 +200,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index 2c20d521a..8ae5044b5 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -186,10 +186,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index 1e5a9be91..42e91cda7 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -175,10 +175,7 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress #### その他の設定とカスタマイズ -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## 質問ある? diff --git a/docs/installation/further_reading.include b/docs/installation/further_reading.include new file mode 100644 index 000000000..46752c722 --- /dev/null +++ b/docs/installation/further_reading.include @@ -0,0 +1,5 @@ +* [How Federation Works/Why is my Federated Timeline empty?](https://blog.soykaf.com/post/how-federation-works/) +* [Backup your instance](../administration/backup.md) +* [Updating your instance](../administration/updating.md) +* [Hardening your instance](../configuration/hardening.md) +* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index 1e61373cc..32152aea7 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -283,10 +283,7 @@ If you opted to allow sudo for the `pleroma` user but would like to remove the a #### Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index 6a922a27e..3626acc69 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -196,3 +196,11 @@ incorrect timestamps. You should have ntpd running. ## Instances running NetBSD * <https://catgirl.science> + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index e8c5d844c..5dbe24f75 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -242,3 +242,11 @@ If your instance is up and running, you can create your first user with administ ``` LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin ``` + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 86135cd20..e4f822d1c 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -270,10 +270,7 @@ This will create an account withe the username of 'joeuser' with the email addre ## Further reading -* [Backup your instance](../administration/backup.md) -* [Hardening your instance](../configuration/hardening.md) -* [How to activate mediaproxy](../configuration/howto_mediaproxy.md) -* [Updating your instance](../administration/updating.md) +{! backend/installation/further_reading.include !} ## Questions -- cgit v1.2.3 From 31a4d42ce0470d74417279a855192294650cff97 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:15:37 +0200 Subject: SideEffects: Handle user updating. --- lib/pleroma/web/activity_pub/side_effects.ex | 12 ++++++++++++ test/web/activity_pub/side_effects_test.exs | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1a1cc675c..09fd7d7c9 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -20,6 +20,18 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) + # Tasks this handles: + # Update the user + def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do + {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) + + User.get_by_ap_id(updated_object["id"]) + |> User.remote_user_changeset(new_user_data) + |> User.update_and_set_cache() + + {:ok, object, meta} + end + # Tasks this handles: # - Add like to object # - Set up notification diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 6bbbaae87..1d7c2736b 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -64,6 +64,22 @@ test "it streams out notifications and streams" do end end + describe "update users" do + setup do + user = insert(:user) + {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"}) + {:ok, update, _meta} = ActivityPub.persist(update_data, local: true) + + %{user: user, update_data: update_data, update: update} + end + + test "it updates the user", %{user: user, update: update} do + {:ok, _, _} = SideEffects.handle(update) + user = User.get_by_id(user.id) + assert user.name == "new name!" + end + end + describe "delete objects" do setup do user = insert(:user) -- cgit v1.2.3 From 9438f83f83305f101b9fed65f68a5b9fd622bcbb Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:16:05 +0200 Subject: Transmogrifier: Handle `Update` with the pipeline. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 33 ++++---------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 851f474b8..8165218ee 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -684,35 +684,12 @@ def handle_incoming(%{"type" => type} = data, _options) end def handle_incoming( - %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} = - data, + %{"type" => "Update"} = data, _options - ) - when object_type in [ - "Person", - "Application", - "Service", - "Organization" - ] do - with %User{ap_id: ^actor_id} = actor <- User.get_cached_by_ap_id(object["id"]) do - {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object) - - actor - |> User.remote_user_changeset(new_user_data) - |> User.update_and_set_cache() - - ActivityPub.update(%{ - local: false, - to: data["to"] || [], - cc: data["cc"] || [], - object: object, - actor: actor_id, - activity_id: data["id"] - }) - else - e -> - Logger.error(e) - :error + ) do + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} end end -- cgit v1.2.3 From 1e7ca2443011f65aa766c3ddd5cd1203e79db50b Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:23:21 +0200 Subject: Update Handling Test: Fix for re-used update ids. --- .../activity_pub/transmogrifier/user_update_handling_test.exs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs index 8e5d3b883..64636656c 100644 --- a/test/web/activity_pub/transmogrifier/user_update_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -106,11 +106,13 @@ test "it works with custom profile fields" do Pleroma.Config.put([:instance, :max_remote_account_fields], 2) update_data = - put_in(update_data, ["object", "attachment"], [ + update_data + |> put_in(["object", "attachment"], [ %{"name" => "foo", "type" => "PropertyValue", "value" => "bar"}, %{"name" => "foo11", "type" => "PropertyValue", "value" => "bar11"}, %{"name" => "foo22", "type" => "PropertyValue", "value" => "bar22"} ]) + |> Map.put("id", update_data["id"] <> ".") {:ok, _} = Transmogrifier.handle_incoming(update_data) @@ -121,7 +123,10 @@ test "it works with custom profile fields" do %{"name" => "foo1", "value" => "updated"} ] - update_data = put_in(update_data, ["object", "attachment"], []) + update_data = + update_data + |> put_in(["object", "attachment"], []) + |> Map.put("id", update_data["id"] <> ".") {:ok, _} = Transmogrifier.handle_incoming(update_data) -- cgit v1.2.3 From 4f5af68b3e96c5b5b62185f86af39fc2f8955e10 Mon Sep 17 00:00:00 2001 From: Ben Is <srsbzns@cock.li> Date: Fri, 19 Jun 2020 14:33:58 +0000 Subject: Added translation using Weblate (Italian) --- priv/gettext/it/LC_MESSAGES/errors.po | 578 ++++++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 priv/gettext/it/LC_MESSAGES/errors.po diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po new file mode 100644 index 000000000..18ec03c83 --- /dev/null +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -0,0 +1,578 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-06-19 14:33+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 2.5.1\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:421 +#, elixir-format +msgid "Account not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:249 +#, elixir-format +msgid "Already voted" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:360 +#, elixir-format +msgid "Bad request" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 +#, elixir-format +msgid "Can't delete object" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 +#, elixir-format +msgid "Can't delete this post" +msgstr "" + +#: lib/pleroma/web/controller_helper.ex:95 +#: lib/pleroma/web/controller_helper.ex:101 +#, elixir-format +msgid "Can't display this activity" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 +#, elixir-format +msgid "Can't find user" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 +#, elixir-format +msgid "Can't get favorites" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 +#, elixir-format +msgid "Can't like object" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:556 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:504 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "" + +#: lib/pleroma/config/config_db.ex:222 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:95 +#, elixir-format +msgid "Could not delete" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:141 +#, elixir-format +msgid "Could not favorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:370 +#, elixir-format +msgid "Could not pin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:112 +#, elixir-format +msgid "Could not repeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:188 +#, elixir-format +msgid "Could not unfavorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:380 +#, elixir-format +msgid "Could not unpin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:126 +#, elixir-format +msgid "Could not unrepeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:428 +#: lib/pleroma/web/common_api/common_api.ex:437 +#, elixir-format +msgid "Could not update state" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 +#, elixir-format +msgid "Error." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 +#: lib/pleroma/web/oauth/oauth_controller.ex:569 +#, elixir-format +msgid "Invalid credentials" +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:265 +#, elixir-format +msgid "Invalid indices" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 +#, elixir-format +msgid "Invalid parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:411 +#, elixir-format +msgid "Invalid password." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 +#, elixir-format +msgid "Invalid request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 +#, elixir-format +msgid "Missing parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:540 +#, elixir-format +msgid "No such conversation" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:439 +#: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 +#, elixir-format +msgid "No such permission_group" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:74 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 +#: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:241 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:290 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 +#: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:566 +#, elixir-format +msgid "The status is over the character limit" +msgstr "" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "" + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:266 +#, elixir-format +msgid "Too many choices" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 +#, elixir-format +msgid "Unhandled activity type" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:536 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:218 +#: lib/pleroma/web/oauth/oauth_controller.ex:309 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:180 +#: lib/pleroma/web/oauth/oauth_controller.ex:332 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:388 +#, elixir-format +msgid "conversation is already muted" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 +#, elixir-format +msgid "error" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 +#, elixir-format +msgid "mascots can only be images" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 +#, elixir-format +msgid "not found" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:395 +#, elixir-format +msgid "Bad OAuth request." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:55 +#, elixir-format +msgid "Failed" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:411 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:442 +#, elixir-format +msgid "Failed to set up user account." +msgstr "" + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:94 +#, elixir-format +msgid "Internal Error" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:169 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:155 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:391 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "" + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:200 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:211 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:207 +#: lib/pleroma/web/oauth/oauth_controller.ex:322 +#, elixir-format +msgid "Password reset is required" +msgstr "" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/fallback_redirect_controller.ex:6 +#: lib/pleroma/web/feed/tag_controller.ex:6 lib/pleroma/web/feed/user_controller.ex:6 +#: lib/pleroma/web/mailer/subscription_controller.ex:2 lib/pleroma/web/masto_fe_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 +#: lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 +#: lib/pleroma/web/oauth/fallback_controller.ex:6 lib/pleroma/web/oauth/mfa_controller.ex:10 +#: lib/pleroma/web/oauth/oauth_controller.ex:6 lib/pleroma/web/ostatus/ostatus_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:2 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#, elixir-format +msgid "User is not an admin or OAuth admin scope is not granted." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "" + +#: lib/pleroma/web/admin_api/admin_api_controller.ex:502 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "" -- cgit v1.2.3 From 68c812eb2ecbfb1d582925c15d90bd1bd4e62b4b Mon Sep 17 00:00:00 2001 From: Ben Is <srsbzns@cock.li> Date: Fri, 19 Jun 2020 14:35:01 +0000 Subject: Translated using Weblate (Italian) Currently translated at 0.9% (1 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/it/ --- priv/gettext/it/LC_MESSAGES/errors.po | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po index 18ec03c83..726be628b 100644 --- a/priv/gettext/it/LC_MESSAGES/errors.po +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-06-19 14:33+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2020-06-19 20:38+0000\n" +"Last-Translator: Ben Is <srsbzns@cock.li>\n" +"Language-Team: Italian <https://translate.pleroma.social/projects/pleroma/" +"pleroma/it/>\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.5.1\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.0.4\n" ## This file is a PO Template file. ## @@ -23,7 +25,7 @@ msgstr "" ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "non può essere nullo" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -- cgit v1.2.3 From e785cd5caeab2c610f12a9071cade31a6b4549a4 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 13:59:45 +0200 Subject: ActivityPub: Remove `update` and switch to pipeline. --- lib/pleroma/web/activity_pub/activity_pub.ex | 22 --------- lib/pleroma/web/activity_pub/side_effects.ex | 18 ++++++-- .../mastodon_api/controllers/account_controller.ex | 53 ++++++++++++---------- test/web/activity_pub/activity_pub_test.exs | 46 ------------------- test/web/activity_pub/side_effects_test.exs | 9 ++++ 5 files changed, 52 insertions(+), 96 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 3e4f3ad30..4cc9fe16c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -321,28 +321,6 @@ defp accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do end end - @spec update(map()) :: {:ok, Activity.t()} | {:error, any()} - def update(%{to: to, cc: cc, actor: actor, object: object} = params) do - local = !(params[:local] == false) - activity_id = params[:activity_id] - - data = - %{ - "to" => to, - "cc" => cc, - "type" => "Update", - "actor" => actor, - "object" => object - } - |> Maps.put_if_present("id", activity_id) - - with {:ok, activity} <- insert(data, local), - _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity} - end - end - @spec follow(User.t(), User.t(), String.t() | nil, boolean(), keyword()) :: {:ok, Activity.t()} | {:error, any()} def follow(follower, followed, activity_id \\ nil, local \\ true, opts \\ []) do diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 09fd7d7c9..de143b8f0 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -21,13 +21,21 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) # Tasks this handles: - # Update the user + # - Update the user + # + # For a local user, we also get a changeset with the full information, so we + # can update non-federating, non-activitypub settings as well. def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do - {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) + if changeset = Keyword.get(meta, :user_update_changeset) do + changeset + |> User.update_and_set_cache() + else + {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object) - User.get_by_ap_id(updated_object["id"]) - |> User.remote_user_changeset(new_user_data) - |> User.update_and_set_cache() + User.get_by_ap_id(updated_object["id"]) + |> User.remote_user_changeset(new_user_data) + |> User.update_and_set_cache() + end {:ok, object, meta} end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index c38c2b895..f0499621a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -20,6 +20,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI @@ -179,34 +181,39 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:default_scope, params["source"]["privacy"]) |> Maps.put_if_present(:actor_type, params[:actor_type]) - changeset = User.update_changeset(user, user_params) - - with {:ok, user} <- User.update_and_set_cache(changeset) do - user - |> build_update_activity_params() - |> ActivityPub.update() - - render(conn, "show.json", user: user, for: user, with_pleroma_settings: true) + # What happens here: + # + # We want to update the user through the pipeline, but the ActivityPub + # update information is not quite enough for this, because this also + # contains local settings that don't federate and don't even appear + # in the Update activity. + # + # So we first build the normal local changeset, then apply it to the + # user data, but don't persist it. With this, we generate the object + # data for our update activity. We feed this and the changeset as meta + # inforation into the pipeline, where they will be properly updated and + # federated. + with changeset <- User.update_changeset(user, user_params), + {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update), + updated_object <- + Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) + |> Map.delete("@context"), + {:ok, update_data, []} <- Builder.update(user, updated_object), + {:ok, _update, _} <- + Pipeline.common_pipeline(update_data, + local: true, + user_update_changeset: changeset + ) do + render(conn, "show.json", + user: unpersisted_user, + for: unpersisted_user, + with_pleroma_settings: true + ) else _e -> render_error(conn, :forbidden, "Invalid request") end end - # Hotfix, handling will be redone with the pipeline - defp build_update_activity_params(user) do - object = - Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) - |> Map.delete("@context") - - %{ - local: true, - to: [user.follower_address], - cc: [], - object: object, - actor: user.ap_id - } - end - defp normalize_fields_attributes(fields) do if Enum.all?(fields, &is_tuple/1) do Enum.map(fields, fn {_, v} -> v end) diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 7693f6400..ce35c9605 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1092,52 +1092,6 @@ test "it filters broken threads" do end end - describe "update" do - setup do: clear_config([:instance, :max_pinned_statuses]) - - test "it creates an update activity with the new user data" do - user = insert(:user) - {:ok, user} = User.ensure_keys_present(user) - user_data = Pleroma.Web.ActivityPub.UserView.render("user.json", %{user: user}) - - {:ok, update} = - ActivityPub.update(%{ - actor: user_data["id"], - to: [user.follower_address], - cc: [], - object: user_data - }) - - assert update.data["actor"] == user.ap_id - assert update.data["to"] == [user.follower_address] - assert embedded_object = update.data["object"] - assert embedded_object["id"] == user_data["id"] - assert embedded_object["type"] == user_data["type"] - end - end - - test "returned pinned statuses" do - Config.put([:instance, :max_pinned_statuses], 3) - user = insert(:user) - - {:ok, activity_one} = CommonAPI.post(user, %{status: "HI!!!"}) - {:ok, activity_two} = CommonAPI.post(user, %{status: "HI!!!"}) - {:ok, activity_three} = CommonAPI.post(user, %{status: "HI!!!"}) - - CommonAPI.pin(activity_one.id, user) - user = refresh_record(user) - - CommonAPI.pin(activity_two.id, user) - user = refresh_record(user) - - CommonAPI.pin(activity_three.id, user) - user = refresh_record(user) - - activities = ActivityPub.fetch_user_activities(user, nil, %{pinned: true}) - - assert 3 = length(activities) - end - describe "flag/1" do setup do reporter = insert(:user) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 1d7c2736b..12c9ef1da 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -78,6 +78,15 @@ test "it updates the user", %{user: user, update: update} do user = User.get_by_id(user.id) assert user.name == "new name!" end + + test "it uses a given changeset to update", %{user: user, update: update} do + changeset = Ecto.Changeset.change(user, %{default_scope: "direct"}) + + assert user.default_scope == "public" + {:ok, _, _} = SideEffects.handle(update, user_update_changeset: changeset) + user = User.get_by_id(user.id) + assert user.default_scope == "direct" + end end describe "delete objects" do -- cgit v1.2.3 From b05f795326b77edd881ffea2c004d7ca0ddd7df9 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Mon, 22 Jun 2020 14:02:29 +0200 Subject: Credo fixes --- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index f0499621a..d4605c518 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -20,8 +20,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Plugs.RateLimiter alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.ListView alias Pleroma.Web.MastodonAPI.MastodonAPI @@ -186,8 +186,8 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p # We want to update the user through the pipeline, but the ActivityPub # update information is not quite enough for this, because this also # contains local settings that don't federate and don't even appear - # in the Update activity. - # + # in the Update activity. + # # So we first build the normal local changeset, then apply it to the # user data, but don't persist it. With this, we generate the object # data for our update activity. We feed this and the changeset as meta -- cgit v1.2.3 From a3b10a4f643d574b84ecee51fb891e26e7f0dbc2 Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 14:13:30 +0200 Subject: Fix 1586 Docs: provide a index.md * I renamed the introduction.md to index.md * I moved over the FE parts to an index file in the FE repo (will do an MR in the FE repo to actually add it) * While I was at it, I also fixed some broken links --- docs/index.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/index.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..fb9e32816 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,26 @@ +# Introduction to Pleroma +## What is Pleroma? +Pleroma is a federated social networking platform, compatible with Mastodon and other ActivityPub implementations. It is free software licensed under the AGPLv3. +It actually consists of two components: a backend, named simply Pleroma, and a user-facing frontend, named Pleroma-FE. It also includes the Mastodon frontend, if that's your thing. +It's part of what we call the fediverse, a federated network of instances which speak common protocols and can communicate with each other. +One account on an instance is enough to talk to the entire fediverse! + +## How can I use it? + +Pleroma instances are already widely deployed, a list can be found at <https://the-federation.info/pleroma> and <https://fediverse.network/pleroma>. + +If you don't feel like joining an existing instance, but instead prefer to deploy your own instance, that's easy too! +Installation instructions can be found in the installation section of these docs. + +## I got an account, now what? +Great! Now you can explore the fediverse! Open the login page for your Pleroma instance (e.g. <https://pleroma.soykaf.com>) and login with your username and password. (If you don't have an account yet, click on Register) + +### Pleroma-FE +The default front-end used by Pleroma is Pleroma-FE. You can find more information on what it is and how to use it in the [Introduction to Pleroma-FE](../frontend). + +### Mastodon interface +If the Pleroma interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! +Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! +The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. + +Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. -- cgit v1.2.3 From 1e089cdf2905309a5450e2acb32aa6b35a928c29 Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 14:18:55 +0200 Subject: I forgot to git add some files, oops (should be squashed with MR) --- docs/configuration/howto_theming_your_instance.md | 2 +- docs/dev.md | 2 +- docs/introduction.md | 65 ----------------------- 3 files changed, 2 insertions(+), 67 deletions(-) delete mode 100644 docs/introduction.md diff --git a/docs/configuration/howto_theming_your_instance.md b/docs/configuration/howto_theming_your_instance.md index d0daf5b25..cfa00f538 100644 --- a/docs/configuration/howto_theming_your_instance.md +++ b/docs/configuration/howto_theming_your_instance.md @@ -60,7 +60,7 @@ Example of `my-awesome-theme.json` where we add the name "My Awesome Theme" ### Set as default theme -Now we can set the new theme as default in the [Pleroma FE configuration](General-tips-for-customizing-Pleroma-FE.md). +Now we can set the new theme as default in the [Pleroma FE configuration](../../../frontend/CONFIGURATION). Example of adding the new theme in the back-end config files ```elixir diff --git a/docs/dev.md b/docs/dev.md index f1b4cbf8b..9c749c17c 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -20,4 +20,4 @@ This document contains notes and guidelines for Pleroma developers. ## Auth-related configuration, OAuth consumer mode etc. -See `Authentication` section of [`docs/configuration/cheatsheet.md`](docs/configuration/cheatsheet.md#authentication). +See `Authentication` section of [the configuration cheatsheet](configuration/cheatsheet.md#authentication). diff --git a/docs/introduction.md b/docs/introduction.md deleted file mode 100644 index a915c143c..000000000 --- a/docs/introduction.md +++ /dev/null @@ -1,65 +0,0 @@ -# Introduction to Pleroma -## What is Pleroma? -Pleroma is a federated social networking platform, compatible with GNU social, Mastodon and other OStatus and ActivityPub implementations. It is free software licensed under the AGPLv3. -It actually consists of two components: a backend, named simply Pleroma, and a user-facing frontend, named Pleroma-FE. It also includes the Mastodon frontend, if that's your thing. -It's part of what we call the fediverse, a federated network of instances which speak common protocols and can communicate with each other. -One account on an instance is enough to talk to the entire fediverse! - -## How can I use it? - -Pleroma instances are already widely deployed, a list can be found at <http://distsn.org/pleroma-instances.html>. Information on all existing fediverse instances can be found at <https://fediverse.network/>. - -If you don't feel like joining an existing instance, but instead prefer to deploy your own instance, that's easy too! -Installation instructions can be found in the installation section of these docs. - -## I got an account, now what? -Great! Now you can explore the fediverse! Open the login page for your Pleroma instance (e.g. <https://pleroma.soykaf.com>) and login with your username and password. (If you don't have an account yet, click on Register) - -At this point you will have two columns in front of you. - -### Left column - -- first block: here you can see your avatar, your nickname and statistics (Statuses, Following, Followers). Clicking your profile pic will open your profile. -Under that you have a text form which allows you to post new statuses. The number on the bottom of the text form is a character counter, every instance can have a different character limit (the default is 5000). -If you want to mention someone, type @ + name of the person. A drop-down menu will help you in finding the right person. -Under the text form there are also several visibility options and there is the option to use rich text. -Under that the icon on the left is for uploading media files and attach them to your post. There is also an emoji-picker and an option to post a poll. -To post your status, simply press Submit. -On the top right you will also see a wrench icon. This opens your personal settings. - -- second block: Here you can switch between the different timelines: - - Timeline: all the people that you follow - - Interactions: here you can switch between different timelines where there was interaction with your account. There is Mentions, Repeats and Favorites, and New follows - - Direct Messages: these are the Direct Messages sent to you - - Public Timeline: all the statutes from the local instance - - The Whole Known Network: all public posts the instance knows about, both local and remote! - - About: This isn't a Timeline but shows relevant info about the instance. You can find a list of the moderators and admins, Terms of Service, MRF policies and enabled features. -- Optional third block: This is the Instance panel that can be activated, but is deactivated by default. It's fully customisable and by default has links to the pleroma-fe and Mastodon-fe. -- fourth block: This is the Notifications block, here you will get notified whenever somebody mentions you, follows you, repeats or favorites one of your statuses. - -### Right column -This is where the interesting stuff happens! -Depending on the timeline you will see different statuses, but each status has a standard structure: - -- Profile pic, name and link to profile. An optional left-arrow if it's a reply to another status (hovering will reveal the reply-to status). Clicking on the profile pic will uncollapse the user's profile. -- A `+` button on the right allows you to Expand/Collapse an entire discussion thread. It also updates in realtime! -- An arrow icon allows you to open the status on the instance where it's originating from. -- The text of the status, including mentions and attachements. If you click on a mention, it will automatically open the profile page of that person. -- Three buttons (left to right): Reply, Repeat, Favorite. There is also a forth button, this is a dropdown menu for simple moderation like muting the conversation or, if you have moderation rights, delete the status from the server. - -### Top right - -- The magnifier icon opens the search screen where you can search for statuses, people and hashtags. It's also possible to import statusses from remote servers by pasting the url to the post in the search field. -- The gear icon gives you general settings -- If you have admin rights, you'll see an icon that opens the admin interface -- The last icon is to log out - -### Bottom right -On the bottom right you have a chatbox. Here you can communicate with people on the same instance in realtime. It is local-only, for now, but there are plans to make it extendable to the entire fediverse! - -### Mastodon interface -If the Pleroma interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! -Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! -The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. - -Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. -- cgit v1.2.3 From 499324f7bee55de4e08647f71fd4adbfd4bd039f Mon Sep 17 00:00:00 2001 From: Ilja <domainepublic@spectraltheorem.be> Date: Mon, 22 Jun 2020 14:22:23 +0200 Subject: Removed a space that was too much --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index fb9e32816..1a90d0a8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,4 +23,4 @@ If the Pleroma interface isn't your thing, or you're just trying something new b Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. -Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. +Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. -- cgit v1.2.3 From b0a40fc2e42a186fc6bb383621f291411b2a81be Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Mon, 22 Jun 2020 17:27:49 +0300 Subject: added verify RUM settings before start app --- lib/pleroma/application.ex | 2 +- lib/pleroma/application_requirements.ex | 103 +++++++++++++++++++++ lib/pleroma/repo.ex | 37 +------- ...20190510135645_add_fts_index_to_objects_two.exs | 35 +++---- test/application_requirements_test.exs | 67 ++++++++++++++ test/repo_test.exs | 34 ------- 6 files changed, 187 insertions(+), 91 deletions(-) create mode 100644 lib/pleroma/application_requirements.ex create mode 100644 test/application_requirements_test.exs diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9d3d92b38..c30e5aadf 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -39,7 +39,7 @@ def start(_type, _args) do Pleroma.HTML.compile_scrubbers() Config.DeprecationWarnings.warn() Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() - Pleroma.Repo.check_migrations_applied!() + Pleroma.ApplicationRequirements.verify!() setup_instrumenters() load_custom_modules() diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex new file mode 100644 index 000000000..3bba70b7b --- /dev/null +++ b/lib/pleroma/application_requirements.ex @@ -0,0 +1,103 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ApplicationRequirements do + @moduledoc """ + The module represents the collection of validations to runs before start server. + """ + + defmodule VerifyError, do: defexception([:message]) + + import Ecto.Query + + require Logger + + @spec verify!() :: :ok | VerifyError.t() + def verify! do + :ok + |> check_migrations_applied!() + |> check_rum!() + |> handle_result() + end + + defp handle_result(:ok), do: :ok + defp handle_result({:error, message}), do: raise(VerifyError, message: message) + + defp check_migrations_applied!(:ok) do + unless Pleroma.Config.get( + [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], + false + ) do + {_, res, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + down_migrations = + Ecto.Migrator.migrations(repo) + |> Enum.reject(fn + {:up, _, _} -> true + {:down, _, _} -> false + end) + + if length(down_migrations) > 0 do + down_migrations_text = + Enum.map(down_migrations, fn {:down, id, name} -> "- #{name} (#{id})\n" end) + + Logger.error( + "The following migrations were not applied:\n#{down_migrations_text}If you want to start Pleroma anyway, set\nconfig :pleroma, :i_am_aware_this_may_cause_data_loss, disable_migration_check: true" + ) + + {:error, "Unapplied Migrations detected"} + else + :ok + end + end) + + res + else + :ok + end + end + + defp check_migrations_applied!(result), do: result + + defp check_rum!(:ok) do + {_, res, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + migrate = + from(o in "columns", + where: o.table_name == "objects", + where: o.column_name == "fts_content" + ) + |> repo.exists?(prefix: "information_schema") + + setting = Pleroma.Config.get([:database, :rum_enabled], false) + + do_check_rum!(setting, migrate) + end) + + res + end + + defp check_rum!(result), do: result + + defp do_check_rum!(setting, migrate) do + case {setting, migrate} do + {true, false} -> + Logger.error( + "Use `RUM` index is enabled, but were not applied migrations for it.\nIf you want to start Pleroma anyway, set\nconfig :pleroma, :database, rum_enabled: false\nOtherwise apply the following migrations:\n`mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/`" + ) + + {:error, "Unapplied RUM Migrations detected"} + + {false, true} -> + Logger.error( + "Detected applied migrations to use `RUM` index, but `RUM` isn't enable in settings.\nIf you want to use `RUM`, set\nconfig :pleroma, :database, rum_enabled: true\nOtherwise roll `RUM` migrations back.\n`mix ecto.rollback --migrations-path priv/repo/optional_migrations/rum_indexing/`" + ) + + {:error, "RUM Migrations detected"} + + _ -> + :ok + end + end +end diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index 6d85d70bc..f317e4d58 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -11,9 +11,7 @@ defmodule Pleroma.Repo do import Ecto.Query require Logger - defmodule Instrumenter do - use Prometheus.EctoInstrumenter - end + defmodule Instrumenter, do: use(Prometheus.EctoInstrumenter) @doc """ Dynamically loads the repository url from the @@ -51,35 +49,6 @@ def get_assoc(resource, association) do end end - def check_migrations_applied!() do - unless Pleroma.Config.get( - [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], - false - ) do - Ecto.Migrator.with_repo(__MODULE__, fn repo -> - down_migrations = - Ecto.Migrator.migrations(repo) - |> Enum.reject(fn - {:up, _, _} -> true - {:down, _, _} -> false - end) - - if length(down_migrations) > 0 do - down_migrations_text = - Enum.map(down_migrations, fn {:down, id, name} -> "- #{name} (#{id})\n" end) - - Logger.error( - "The following migrations were not applied:\n#{down_migrations_text}If you want to start Pleroma anyway, set\nconfig :pleroma, :i_am_aware_this_may_cause_data_loss, disable_migration_check: true" - ) - - raise Pleroma.Repo.UnappliedMigrationsError - end - end) - else - :ok - end - end - def chunk_stream(query, chunk_size) do # We don't actually need start and end funcitons of resource streaming, # but it seems to be the only way to not fetch records one-by-one and @@ -107,7 +76,3 @@ def chunk_stream(query, chunk_size) do ) end end - -defmodule Pleroma.Repo.UnappliedMigrationsError do - defexception message: "Unapplied Migrations detected" -end diff --git a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs index 79bde163d..757afa129 100644 --- a/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs +++ b/priv/repo/optional_migrations/rum_indexing/20190510135645_add_fts_index_to_objects_two.exs @@ -2,29 +2,24 @@ defmodule Pleroma.Repo.Migrations.AddFtsIndexToObjectsTwo do use Ecto.Migration def up do - if Pleroma.Config.get([:database, :rum_enabled]) do - execute("create extension if not exists rum") - drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) - alter table(:objects) do - add(:fts_content, :tsvector) - end + execute("create extension if not exists rum") + drop_if_exists index(:objects, ["(to_tsvector('english', data->>'content'))"], using: :gin, name: :objects_fts) + alter table(:objects) do + add(:fts_content, :tsvector) + end - execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ - begin - new.fts_content := to_tsvector('english', new.data->>'content'); - return new; - end - $$ LANGUAGE plpgsql") - execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") + execute("CREATE FUNCTION objects_fts_update() RETURNS trigger AS $$ + begin + new.fts_content := to_tsvector('english', new.data->>'content'); + return new; + end + $$ LANGUAGE plpgsql") + execute("create index if not exists objects_fts on objects using RUM (fts_content rum_tsvector_addon_ops, inserted_at) with (attach = 'inserted_at', to = 'fts_content');") - execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects - FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") + execute("CREATE TRIGGER tsvectorupdate BEFORE INSERT OR UPDATE ON objects + FOR EACH ROW EXECUTE PROCEDURE objects_fts_update()") - execute("UPDATE objects SET updated_at = NOW()") - else - raise Ecto.MigrationError, - message: "Migration is not allowed. You can change this behavior by setting `database/rum_enabled` to true." - end + execute("UPDATE objects SET updated_at = NOW()") end def down do diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs new file mode 100644 index 000000000..0981fcdeb --- /dev/null +++ b/test/application_requirements_test.exs @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.RepoTest do + use Pleroma.DataCase + import ExUnit.CaptureLog + import Mock + + describe "check_rum!" do + setup_with_mocks([ + {Ecto.Migrator, [], + [ + with_repo: fn repo, fun -> passthrough([repo, fun]) end, + migrations: fn Pleroma.Repo -> [] end + ]} + ]) do + :ok + end + + setup do: clear_config([:database, :rum_enabled]) + + test "raises if rum is enabled and detects unapplied rum migrations" do + Pleroma.Config.put([:database, :rum_enabled], true) + + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Unapplied RUM Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + end + + describe "check_migrations_applied!" do + setup_with_mocks([ + {Ecto.Migrator, [], + [ + with_repo: fn repo, fun -> passthrough([repo, fun]) end, + migrations: fn Pleroma.Repo -> + [ + {:up, 20_191_128_153_944, "fix_missing_following_count"}, + {:up, 20_191_203_043_610, "create_report_notes"}, + {:down, 20_191_220_174_645, "add_scopes_to_pleroma_feo_auth_records"} + ] + end + ]} + ]) do + :ok + end + + setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) + + test "raises if it detects unapplied migrations" do + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Unapplied Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + + test "doesn't do anything if disabled" do + Pleroma.Config.put([:i_am_aware_this_may_cause_data_loss, :disable_migration_check], true) + + assert :ok == Pleroma.ApplicationRequirements.verify!() + end + end +end diff --git a/test/repo_test.exs b/test/repo_test.exs index daffc6542..92e827c95 100644 --- a/test/repo_test.exs +++ b/test/repo_test.exs @@ -4,9 +4,7 @@ defmodule Pleroma.RepoTest do use Pleroma.DataCase - import ExUnit.CaptureLog import Pleroma.Factory - import Mock alias Pleroma.User @@ -49,36 +47,4 @@ test "return error if has not assoc " do assert Repo.get_assoc(token, :user) == {:error, :not_found} end end - - describe "check_migrations_applied!" do - setup_with_mocks([ - {Ecto.Migrator, [], - [ - with_repo: fn repo, fun -> passthrough([repo, fun]) end, - migrations: fn Pleroma.Repo -> - [ - {:up, 20_191_128_153_944, "fix_missing_following_count"}, - {:up, 20_191_203_043_610, "create_report_notes"}, - {:down, 20_191_220_174_645, "add_scopes_to_pleroma_feo_auth_records"} - ] - end - ]} - ]) do - :ok - end - - setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) - - test "raises if it detects unapplied migrations" do - assert_raise Pleroma.Repo.UnappliedMigrationsError, fn -> - capture_log(&Repo.check_migrations_applied!/0) - end - end - - test "doesn't do anything if disabled" do - Pleroma.Config.put([:i_am_aware_this_may_cause_data_loss, :disable_migration_check], true) - - assert :ok == Repo.check_migrations_applied!() - end - end end -- cgit v1.2.3 From 7e6f43c0d7c625a03ee0216c2d9474253ef87b5a Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 22 Jun 2020 19:03:04 +0400 Subject: Add `is_muted` to notifications --- .../web/mastodon_api/views/notification_view.ex | 8 ++--- .../mastodon_api/views/notification_view_test.exs | 36 +++++++++++++++++----- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 3865be280..c97e6d32f 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -84,12 +84,7 @@ def render( # Note: :relationships contain user mutes (needed for :muted flag in :status) status_render_opts = %{relationships: opts[:relationships]} - - account = - AccountView.render( - "show.json", - %{user: actor, for: reading_user} - ) + account = AccountView.render("show.json", %{user: actor, for: reading_user}) response = %{ id: to_string(notification.id), @@ -97,6 +92,7 @@ def render( created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), account: account, pleroma: %{ + is_muted: User.mutes?(reading_user, actor), is_seen: notification.seen } } diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 9c399b2df..8e0e58538 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -49,7 +49,7 @@ test "ChatMessage notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}), @@ -68,7 +68,7 @@ test "Mention notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "mention", account: AccountView.render("show.json", %{ @@ -92,7 +92,7 @@ test "Favourite notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: create_activity, for: user}), @@ -112,7 +112,7 @@ test "Reblog notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "reblog", account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: reblog_activity, for: user}), @@ -130,7 +130,7 @@ test "Follow notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "follow", account: AccountView.render("show.json", %{user: follower, for: followed}), created_at: Utils.to_masto_date(notification.inserted_at) @@ -171,7 +171,7 @@ test "Move notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "move", account: AccountView.render("show.json", %{user: old_user, for: follower}), target: AccountView.render("show.json", %{user: new_user, for: follower}), @@ -196,7 +196,7 @@ test "EmojiReact notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false}, + pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:emoji_reaction", emoji: "☕", account: AccountView.render("show.json", %{user: other_user, for: user}), @@ -206,4 +206,26 @@ test "EmojiReact notification" do test_notifications_rendering([notification], user, [expected]) end + + test "muted notification" do + user = insert(:user) + another_user = insert(:user) + + {:ok, _} = Pleroma.UserRelationship.create_mute(user, another_user) + {:ok, create_activity} = CommonAPI.post(user, %{status: "hey"}) + {: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) + + expected = %{ + id: to_string(notification.id), + pleroma: %{is_seen: false, is_muted: true}, + type: "favourite", + account: AccountView.render("show.json", %{user: another_user, for: user}), + status: StatusView.render("show.json", %{activity: create_activity, for: user}), + created_at: Utils.to_masto_date(notification.inserted_at) + } + + test_notifications_rendering([notification], user, [expected]) + end end -- cgit v1.2.3 From b3a549e916c2a721da16f60e7665b6eb64d756dd Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Mon, 22 Jun 2020 19:18:33 +0400 Subject: Update NotificationOperation spec --- lib/pleroma/web/api_spec/operations/notification_operation.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 41328b5f2..f09be64cb 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -163,6 +163,13 @@ def notification do description: "Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.", nullable: true + }, + pleroma: %Schema{ + type: :object, + properties: %{ + is_seen: %Schema{type: :boolean}, + is_muted: %Schema{type: :boolean} + } } }, example: %{ @@ -170,7 +177,8 @@ def notification do "type" => "mention", "created_at" => "2019-11-23T07:49:02.064Z", "account" => Account.schema().example, - "status" => Status.schema().example + "status" => Status.schema().example, + "pleroma" => %{"is_seen" => false, "is_muted" => false} } } end -- cgit v1.2.3 From 8f6ba4b22f48dcd0256d6a9cf7259aa475895b84 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 22 Jun 2020 23:45:29 +0200 Subject: Add warning against parsing/reusing MastoFE settings blob --- lib/pleroma/web/masto_fe_controller.ex | 2 +- lib/pleroma/web/router.ex | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index d0d8bc8eb..43ec70021 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -49,7 +49,7 @@ def manifest(conn, _params) do |> render("manifest.json") end - @doc "PUT /api/web/settings" + @doc "PUT /api/web/settings: Backend-obscure settings blob for MastoFE, don't parse/reuse elsewhere" def put_settings(%{assigns: %{user: user}} = conn, %{"data" => settings} = _params) do with {:ok, _} <- User.mastodon_settings_update(user, settings) do json(conn, %{}) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index eda74a171..419aa55e4 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -467,6 +467,7 @@ defmodule Pleroma.Web.Router do scope "/api/web", Pleroma.Web do pipe_through(:authenticated_api) + # Backend-obscure settings blob for MastoFE, don't parse/reuse elsewhere put("/settings", MastoFEController, :put_settings) end -- cgit v1.2.3 From bf8310f3802c46e6305fcb3832bca297582990d9 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 22 Jun 2020 17:35:02 -0500 Subject: Add missing default config value for :instance, instance_thumbnail Follows up on b7fc61e17b --- config/config.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.exs b/config/config.exs index 4bf31f3fc..e0888fa9a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -186,6 +186,7 @@ notify_email: "noreply@example.com", description: "Pleroma: An efficient and flexible fediverse server", background_image: "/images/city.jpg", + instance_thumbnail: "/instance/thumbnail.jpeg", limit: 5_000, chat_limit: 5_000, remote_limit: 100_000, -- cgit v1.2.3 From df5e048cbb7d349b34203ccba49a8f646e4d93a3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 22 Jun 2020 17:39:02 -0500 Subject: Do not need a function to provide fallback value with default defined in config.exs --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index c498fe632..c6b54e570 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -23,7 +23,7 @@ def render("show.json", _) do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: instance_thumbnail(), + thumbnail: Keyword.get(instance, :instance_thumbnail), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), # Extra (not present in Mastodon): @@ -88,9 +88,4 @@ def federation do end |> Map.put(:enabled, Config.get([:instance, :federating])) end - - defp instance_thumbnail do - Pleroma.Config.get([:instance, :instance_thumbnail]) || - "#{Pleroma.Web.base_url()}/instance/thumbnail.jpeg" - end end -- cgit v1.2.3 From c116b6d6d6e4b12d9d751481926183f19cdb5248 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 23 Jun 2020 04:42:44 +0200 Subject: ActivityPubController: Update upload_media @doc Small cherry-pick from https://git.pleroma.social/pleroma/pleroma/-/merge_requests/1810 --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index f0b5c6e93..220c4fe52 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -514,7 +514,6 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do {new_user, for_user} end - # TODO: Add support for "object" field @doc """ Endpoint based on <https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload> @@ -525,6 +524,8 @@ defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do Response: - HTTP Code: 201 Created - HTTP Body: ActivityPub object to be inserted into another's `attachment` field + + Note: Will not point to a URL with a `Location` header because no standalone Activity has been created. """ def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do with {:ok, object} <- -- cgit v1.2.3 From 2715c40e1d36cc844be1dd7d41a0c6a16ca5f7b7 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Tue, 23 Jun 2020 06:56:17 +0300 Subject: added tests --- lib/pleroma/application_requirements.ex | 8 ++++-- test/application_requirements_test.exs | 48 ++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 3bba70b7b..88575a498 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -24,7 +24,9 @@ def verify! do defp handle_result(:ok), do: :ok defp handle_result({:error, message}), do: raise(VerifyError, message: message) - defp check_migrations_applied!(:ok) do + # Checks for pending migrations. + # + def check_migrations_applied!(:ok) do unless Pleroma.Config.get( [:i_am_aware_this_may_cause_data_loss, :disable_migration_check], false @@ -58,8 +60,10 @@ defp check_migrations_applied!(:ok) do end end - defp check_migrations_applied!(result), do: result + def check_migrations_applied!(result), do: result + # Checks for settings of RUM indexes. + # defp check_rum!(:ok) do {_, res, _} = Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index 0981fcdeb..b8d073e11 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -2,25 +2,22 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.RepoTest do +defmodule Pleroma.ApplicationRequirementsTest do use Pleroma.DataCase import ExUnit.CaptureLog import Mock describe "check_rum!" do setup_with_mocks([ - {Ecto.Migrator, [], - [ - with_repo: fn repo, fun -> passthrough([repo, fun]) end, - migrations: fn Pleroma.Repo -> [] end - ]} + {Pleroma.ApplicationRequirements, [:passthrough], + [check_migrations_applied!: fn _ -> :ok end]} ]) do :ok end setup do: clear_config([:database, :rum_enabled]) - test "raises if rum is enabled and detects unapplied rum migrations" do + test "raises if rum is enabled and detects unapplied rum migrations" do Pleroma.Config.put([:database, :rum_enabled], true) assert_raise Pleroma.ApplicationRequirements.VerifyError, @@ -29,6 +26,43 @@ test "raises if rum is enabled and detects unapplied rum migrations" do capture_log(&Pleroma.ApplicationRequirements.verify!/0) end end + + test "raises if rum is disabled and detects rum migrations" do + Pleroma.Config.put([:database, :rum_enabled], false) + + with_mocks([ + { + Pleroma.Repo, + [:passthrough], + [exists?: fn _, _ -> true end] + } + ]) do + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "RUM Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + end + + test "doesn't do anything if rum enabled and applied migrations" do + Pleroma.Config.put([:database, :rum_enabled], true) + + with_mocks([ + { + Pleroma.Repo, + [:passthrough], + [exists?: fn _, _ -> true end] + } + ]) do + assert Pleroma.ApplicationRequirements.verify!() == :ok + end + end + + test "doesn't do anything if rum disabled" do + Pleroma.Config.put([:database, :rum_enabled], false) + assert Pleroma.ApplicationRequirements.verify!() == :ok + end end describe "check_migrations_applied!" do -- cgit v1.2.3 From 84aa9c78dd314e93a5153e3584af38b8c218caed Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Tue, 23 Jun 2020 09:08:24 +0300 Subject: fix tests --- test/application_requirements_test.exs | 37 +++++++++++++++------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index b8d073e11..481cdfd73 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.ApplicationRequirementsTest do import ExUnit.CaptureLog import Mock + alias Pleroma.Repo + describe "check_rum!" do setup_with_mocks([ {Pleroma.ApplicationRequirements, [:passthrough], @@ -20,23 +22,19 @@ defmodule Pleroma.ApplicationRequirementsTest do test "raises if rum is enabled and detects unapplied rum migrations" do Pleroma.Config.put([:database, :rum_enabled], true) - assert_raise Pleroma.ApplicationRequirements.VerifyError, - "Unapplied RUM Migrations detected", - fn -> - capture_log(&Pleroma.ApplicationRequirements.verify!/0) - end + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> false end]}]) do + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Unapplied RUM Migrations detected", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end end test "raises if rum is disabled and detects rum migrations" do Pleroma.Config.put([:database, :rum_enabled], false) - with_mocks([ - { - Pleroma.Repo, - [:passthrough], - [exists?: fn _, _ -> true end] - } - ]) do + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> true end]}]) do assert_raise Pleroma.ApplicationRequirements.VerifyError, "RUM Migrations detected", fn -> @@ -48,20 +46,17 @@ test "raises if rum is disabled and detects rum migrations" do test "doesn't do anything if rum enabled and applied migrations" do Pleroma.Config.put([:database, :rum_enabled], true) - with_mocks([ - { - Pleroma.Repo, - [:passthrough], - [exists?: fn _, _ -> true end] - } - ]) do + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> true end]}]) do assert Pleroma.ApplicationRequirements.verify!() == :ok end end test "doesn't do anything if rum disabled" do Pleroma.Config.put([:database, :rum_enabled], false) - assert Pleroma.ApplicationRequirements.verify!() == :ok + + with_mocks([{Repo, [:passthrough], [exists?: fn _, _ -> false end]}]) do + assert Pleroma.ApplicationRequirements.verify!() == :ok + end end end @@ -70,7 +65,7 @@ test "doesn't do anything if rum disabled" do {Ecto.Migrator, [], [ with_repo: fn repo, fun -> passthrough([repo, fun]) end, - migrations: fn Pleroma.Repo -> + migrations: fn Repo -> [ {:up, 20_191_128_153_944, "fix_missing_following_count"}, {:up, 20_191_203_043_610, "create_report_notes"}, -- cgit v1.2.3 From 2737809bbf249696d06d4a351837a405d79d47e3 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 11:03:32 +0200 Subject: An act of desperation. --- test/web/activity_pub/activity_pub_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index e490a5744..6ea50fd96 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -665,7 +665,7 @@ test "it accepts announces with to as string instead of array", %{conn: conn} do |> post("/users/#{user.nickname}/inbox", data) assert "ok" == json_response(conn, 200) - ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + ObanHelpers.perform_all() %Activity{} = activity = Activity.get_by_ap_id(data["id"]) assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients end -- cgit v1.2.3 From d93e01137b0682dd97b95b848f7b8656de89e3cf Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 11:43:20 +0200 Subject: ActivityPubControllerTest: Testing changes. --- test/web/activity_pub/activity_pub_controller_test.exs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 6ea50fd96..e5f801b22 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -648,11 +648,14 @@ test "it accepts messages with bcc as string instead of array", %{conn: conn, da test "it accepts announces with to as string instead of array", %{conn: conn} do user = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "hey"}) + announcer = insert(:user, local: false) + data = %{ "@context" => "https://www.w3.org/ns/activitystreams", - "actor" => "http://mastodon.example.org/users/admin", - "id" => "http://mastodon.example.org/users/admin/statuses/19512778738411822/activity", - "object" => "https://mastodon.social/users/emelie/statuses/101849165031453009", + "actor" => announcer.ap_id, + "id" => "#{announcer.ap_id}/statuses/19512778738411822/activity", + "object" => post.data["object"], "to" => "https://www.w3.org/ns/activitystreams#Public", "cc" => [user.ap_id], "type" => "Announce" @@ -665,7 +668,7 @@ test "it accepts announces with to as string instead of array", %{conn: conn} do |> post("/users/#{user.nickname}/inbox", data) assert "ok" == json_response(conn, 200) - ObanHelpers.perform_all() + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) %Activity{} = activity = Activity.get_by_ap_id(data["id"]) assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients end -- cgit v1.2.3 From adc199c6a8932f893bc1098acbf222e64cdb07d9 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 12:04:51 +0200 Subject: ActivityPubControllerTest: Capture error log --- test/web/activity_pub/activity_pub_controller_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index e5f801b22..e722f7c04 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -536,6 +536,7 @@ test "accept follow activity", %{conn: conn} do assert_receive {:mix_shell, :info, ["relay.mastodon.host"]} end + @tag capture_log: true test "without valid signature, " <> "it only accepts Create activities and requires enabled federation", %{conn: conn} do -- cgit v1.2.3 From aee815b478aea5d74959c5a445c6c5d87f25168e Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Tue, 23 Jun 2020 12:37:05 +0200 Subject: ObjectValidator: Clarify type of object. --- lib/pleroma/web/activity_pub/object_validator.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 804a9d06e..2c657b467 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -24,13 +24,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) - def validate(%{"type" => "Update"} = object, meta) do - with {:ok, object} <- - object + def validate(%{"type" => "Update"} = update_activity, meta) do + with {:ok, update_activity} <- + update_activity |> UpdateValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} + update_activity = stringify_keys(update_activity) + {:ok, update_activity, meta} end end -- cgit v1.2.3 From a8d967762ec5436ca9b478fbbedfec39b5d9e35e Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Tue, 23 Jun 2020 15:09:01 +0300 Subject: migrate to oban 2.0-rc1 --- config/config.exs | 3 +- config/test.exs | 4 +-- lib/pleroma/application.ex | 14 ++++++++- lib/pleroma/workers/attachments_cleanup_worker.ex | 11 ++++--- lib/pleroma/workers/background_worker.ex | 34 +++++++++++----------- .../workers/cron/clear_oauth_token_worker.ex | 2 +- lib/pleroma/workers/cron/digest_emails_worker.ex | 2 +- .../workers/cron/new_users_digest_worker.ex | 2 +- .../cron/purge_expired_activities_worker.ex | 2 +- lib/pleroma/workers/cron/stats_worker.ex | 2 +- lib/pleroma/workers/mailer_worker.ex | 2 +- lib/pleroma/workers/publisher_worker.ex | 6 ++-- lib/pleroma/workers/receiver_worker.ex | 2 +- lib/pleroma/workers/remote_fetcher_worker.ex | 8 +---- lib/pleroma/workers/scheduled_activity_worker.ex | 2 +- lib/pleroma/workers/transmogrifier_worker.ex | 2 +- lib/pleroma/workers/web_pusher_worker.ex | 2 +- lib/pleroma/workers/worker_helper.ex | 4 ++- mix.exs | 2 +- mix.lock | 8 ++--- test/activity_expiration_test.exs | 2 +- test/support/oban_helpers.ex | 2 +- test/web/activity_pub/activity_pub_test.exs | 2 +- .../workers/cron/clear_oauth_token_worker_test.exs | 2 +- test/workers/cron/digest_emails_worker_test.exs | 4 +-- test/workers/cron/new_users_digest_worker_test.exs | 4 +-- .../cron/purge_expired_activities_worker_test.exs | 4 +-- test/workers/scheduled_activity_worker_test.exs | 7 ++--- 28 files changed, 72 insertions(+), 69 deletions(-) diff --git a/config/config.exs b/config/config.exs index e0888fa9a..dcf4291d6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -494,8 +494,7 @@ config :pleroma, Oban, repo: Pleroma.Repo, - verbose: false, - prune: {:maxlen, 1500}, + log: false, queues: [ activity_expiration: 10, federator_incoming: 50, diff --git a/config/test.exs b/config/test.exs index e38b9967d..054fac355 100644 --- a/config/test.exs +++ b/config/test.exs @@ -79,8 +79,8 @@ config :pleroma, Oban, queues: false, - prune: :disabled, - crontab: false + crontab: false, + plugins: false config :pleroma, Pleroma.ScheduledActivity, daily_user_limit: 2, diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9615af122..fb2731f97 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -80,7 +80,7 @@ def start(_type, _args) do [ Pleroma.Stats, Pleroma.JobQueueMonitor, - {Oban, Config.get(Oban)} + {Oban, oban_config()} ] ++ task_children(@env) ++ streamer_child(@env) ++ @@ -138,6 +138,18 @@ defp setup_instrumenters do Pleroma.Web.Endpoint.Instrumenter.setup() end + defp oban_config do + config = Config.get(Oban) + + if Code.ensure_loaded?(IEx) and IEx.started?() do + config + |> Keyword.put(:crontab, false) + |> Keyword.put(:queues, false) + else + config + end + end + defp cachex_children do [ build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), diff --git a/lib/pleroma/workers/attachments_cleanup_worker.ex b/lib/pleroma/workers/attachments_cleanup_worker.ex index 8deeabda0..58226b395 100644 --- a/lib/pleroma/workers/attachments_cleanup_worker.ex +++ b/lib/pleroma/workers/attachments_cleanup_worker.ex @@ -11,13 +11,12 @@ defmodule Pleroma.Workers.AttachmentsCleanupWorker do use Pleroma.Workers.WorkerHelper, queue: "attachments_cleanup" @impl Oban.Worker - def perform( - %{ + def perform(%Job{ + args: %{ "op" => "cleanup_attachments", "object" => %{"data" => %{"attachment" => [_ | _] = attachments, "actor" => actor}} - }, - _job - ) do + } + }) do attachments |> Enum.flat_map(fn item -> Enum.map(item["url"], & &1["href"]) end) |> fetch_objects @@ -28,7 +27,7 @@ def perform( {:ok, :success} end - def perform(%{"op" => "cleanup_attachments", "object" => _object}, _job), do: {:ok, :skip} + def perform(%Job{args: %{"op" => "cleanup_attachments", "object" => _object}}), do: {:ok, :skip} defp do_clean({object_ids, attachment_urls}) do uploader = Pleroma.Config.get([Pleroma.Upload, :uploader]) diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 57c3a9c3a..cec5a7462 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -11,59 +11,59 @@ defmodule Pleroma.Workers.BackgroundWorker do @impl Oban.Worker - def perform(%{"op" => "deactivate_user", "user_id" => user_id, "status" => status}, _job) do + def perform(%Job{args: %{"op" => "deactivate_user", "user_id" => user_id, "status" => status}}) do user = User.get_cached_by_id(user_id) User.perform(:deactivate_async, user, status) end - def perform(%{"op" => "delete_user", "user_id" => user_id}, _job) do + def perform(%Job{args: %{"op" => "delete_user", "user_id" => user_id}}) do user = User.get_cached_by_id(user_id) User.perform(:delete, user) end - def perform(%{"op" => "force_password_reset", "user_id" => user_id}, _job) do + def perform(%Job{args: %{"op" => "force_password_reset", "user_id" => user_id}}) do user = User.get_cached_by_id(user_id) User.perform(:force_password_reset, user) end - def perform( - %{ + def perform(%Job{ + args: %{ "op" => "blocks_import", "blocker_id" => blocker_id, "blocked_identifiers" => blocked_identifiers - }, - _job - ) do + } + }) do blocker = User.get_cached_by_id(blocker_id) {:ok, User.perform(:blocks_import, blocker, blocked_identifiers)} end - def perform( - %{ + def perform(%Job{ + args: %{ "op" => "follow_import", "follower_id" => follower_id, "followed_identifiers" => followed_identifiers - }, - _job - ) do + } + }) do follower = User.get_cached_by_id(follower_id) {:ok, User.perform(:follow_import, follower, followed_identifiers)} end - def perform(%{"op" => "media_proxy_preload", "message" => message}, _job) do + def perform(%Job{args: %{"op" => "media_proxy_preload", "message" => message}}) do MediaProxyWarmingPolicy.perform(:preload, message) end - def perform(%{"op" => "media_proxy_prefetch", "url" => url}, _job) do + def perform(%Job{args: %{"op" => "media_proxy_prefetch", "url" => url}}) do MediaProxyWarmingPolicy.perform(:prefetch, url) end - def perform(%{"op" => "fetch_data_for_activity", "activity_id" => activity_id}, _job) do + def perform(%Job{args: %{"op" => "fetch_data_for_activity", "activity_id" => activity_id}}) do activity = Activity.get_by_id(activity_id) Pleroma.Web.RichMedia.Helpers.perform(:fetch, activity) end - def perform(%{"op" => "move_following", "origin_id" => origin_id, "target_id" => target_id}, _) do + def perform(%Job{ + args: %{"op" => "move_following", "origin_id" => origin_id, "target_id" => target_id} + }) do origin = User.get_cached_by_id(origin_id) target = User.get_cached_by_id(target_id) diff --git a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex index a4c3b9516..d41be4e87 100644 --- a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex +++ b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Workers.Cron.ClearOauthTokenWorker do alias Pleroma.Web.OAuth.Token @impl Oban.Worker - def perform(_opts, _job) do + def perform(_job) do if Config.get([:oauth2, :clean_expired_tokens], false) do Token.delete_expired_tokens() else diff --git a/lib/pleroma/workers/cron/digest_emails_worker.ex b/lib/pleroma/workers/cron/digest_emails_worker.ex index 7f09ff3cf..ee646229f 100644 --- a/lib/pleroma/workers/cron/digest_emails_worker.ex +++ b/lib/pleroma/workers/cron/digest_emails_worker.ex @@ -19,7 +19,7 @@ defmodule Pleroma.Workers.Cron.DigestEmailsWorker do require Logger @impl Oban.Worker - def perform(_opts, _job) do + def perform(_job) do config = Config.get([:email_notifications, :digest]) if config[:active] do diff --git a/lib/pleroma/workers/cron/new_users_digest_worker.ex b/lib/pleroma/workers/cron/new_users_digest_worker.ex index 5c816b3fe..abc8a5e95 100644 --- a/lib/pleroma/workers/cron/new_users_digest_worker.ex +++ b/lib/pleroma/workers/cron/new_users_digest_worker.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Workers.Cron.NewUsersDigestWorker do use Pleroma.Workers.WorkerHelper, queue: "new_users_digest" @impl Oban.Worker - def perform(_args, _job) do + def perform(_job) do if Pleroma.Config.get([Pleroma.Emails.NewUsersDigestEmail, :enabled]) do today = NaiveDateTime.utc_now() |> Timex.beginning_of_day() diff --git a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex index 84b3b84de..e926c5dc8 100644 --- a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex +++ b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker do @interval :timer.minutes(1) @impl Oban.Worker - def perform(_opts, _job) do + def perform(_job) do if Config.get([ActivityExpiration, :enabled]) do Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) else diff --git a/lib/pleroma/workers/cron/stats_worker.ex b/lib/pleroma/workers/cron/stats_worker.ex index e9b8d59c4..e54bd9a7f 100644 --- a/lib/pleroma/workers/cron/stats_worker.ex +++ b/lib/pleroma/workers/cron/stats_worker.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Workers.Cron.StatsWorker do use Oban.Worker, queue: "background" @impl Oban.Worker - def perform(_opts, _job) do + def perform(_job) do Pleroma.Stats.do_collect() end end diff --git a/lib/pleroma/workers/mailer_worker.ex b/lib/pleroma/workers/mailer_worker.ex index 6955338a5..32273cfa5 100644 --- a/lib/pleroma/workers/mailer_worker.ex +++ b/lib/pleroma/workers/mailer_worker.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Workers.MailerWorker do use Pleroma.Workers.WorkerHelper, queue: "mailer" @impl Oban.Worker - def perform(%{"op" => "email", "encoded_email" => encoded_email, "config" => config}, _job) do + def perform(%Job{args: %{"op" => "email", "encoded_email" => encoded_email, "config" => config}}) do encoded_email |> Base.decode64!() |> :erlang.binary_to_term() diff --git a/lib/pleroma/workers/publisher_worker.ex b/lib/pleroma/workers/publisher_worker.ex index daf79efc0..e739c3cd0 100644 --- a/lib/pleroma/workers/publisher_worker.ex +++ b/lib/pleroma/workers/publisher_worker.ex @@ -8,17 +8,17 @@ defmodule Pleroma.Workers.PublisherWorker do use Pleroma.Workers.WorkerHelper, queue: "federator_outgoing" - def backoff(attempt) when is_integer(attempt) do + def backoff(%Job{attempt: attempt}) when is_integer(attempt) do Pleroma.Workers.WorkerHelper.sidekiq_backoff(attempt, 5) end @impl Oban.Worker - def perform(%{"op" => "publish", "activity_id" => activity_id}, _job) do + def perform(%Job{args: %{"op" => "publish", "activity_id" => activity_id}}) do activity = Activity.get_by_id(activity_id) Federator.perform(:publish, activity) end - def perform(%{"op" => "publish_one", "module" => module_name, "params" => params}, _job) do + def perform(%Job{args: %{"op" => "publish_one", "module" => module_name, "params" => params}}) do params = Map.new(params, fn {k, v} -> {String.to_atom(k), v} end) Federator.perform(:publish_one, String.to_atom(module_name), params) end diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index f7a7124f3..1b97af1a8 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Workers.ReceiverWorker do use Pleroma.Workers.WorkerHelper, queue: "federator_incoming" @impl Oban.Worker - def perform(%{"op" => "incoming_ap_doc", "params" => params}, _job) do + def perform(%Job{args: %{"op" => "incoming_ap_doc", "params" => params}}) do Federator.perform(:incoming_ap_doc, params) end end diff --git a/lib/pleroma/workers/remote_fetcher_worker.ex b/lib/pleroma/workers/remote_fetcher_worker.ex index ec6534f21..27e2e3386 100644 --- a/lib/pleroma/workers/remote_fetcher_worker.ex +++ b/lib/pleroma/workers/remote_fetcher_worker.ex @@ -8,13 +8,7 @@ defmodule Pleroma.Workers.RemoteFetcherWorker do use Pleroma.Workers.WorkerHelper, queue: "remote_fetcher" @impl Oban.Worker - def perform( - %{ - "op" => "fetch_remote", - "id" => id - } = args, - _job - ) do + def perform(%Job{args: %{"op" => "fetch_remote", "id" => id} = args}) do {:ok, _object} = Fetcher.fetch_object_from_id(id, depth: args["depth"]) end end diff --git a/lib/pleroma/workers/scheduled_activity_worker.ex b/lib/pleroma/workers/scheduled_activity_worker.ex index 97d1efbfb..dd9986fe4 100644 --- a/lib/pleroma/workers/scheduled_activity_worker.ex +++ b/lib/pleroma/workers/scheduled_activity_worker.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Workers.ScheduledActivityWorker do require Logger @impl Oban.Worker - def perform(%{"activity_id" => activity_id}, _job) do + def perform(%Job{args: %{"activity_id" => activity_id}}) do if Config.get([ScheduledActivity, :enabled]) do case Pleroma.Repo.get(ScheduledActivity, activity_id) do %ScheduledActivity{} = scheduled_activity -> diff --git a/lib/pleroma/workers/transmogrifier_worker.ex b/lib/pleroma/workers/transmogrifier_worker.ex index 11239ca5e..15f36375c 100644 --- a/lib/pleroma/workers/transmogrifier_worker.ex +++ b/lib/pleroma/workers/transmogrifier_worker.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Workers.TransmogrifierWorker do use Pleroma.Workers.WorkerHelper, queue: "transmogrifier" @impl Oban.Worker - def perform(%{"op" => "user_upgrade", "user_id" => user_id}, _job) do + def perform(%Job{args: %{"op" => "user_upgrade", "user_id" => user_id}}) do user = User.get_cached_by_id(user_id) Pleroma.Web.ActivityPub.Transmogrifier.perform(:user_upgrade, user) end diff --git a/lib/pleroma/workers/web_pusher_worker.ex b/lib/pleroma/workers/web_pusher_worker.ex index 58ad25e39..0cfdc6a6f 100644 --- a/lib/pleroma/workers/web_pusher_worker.ex +++ b/lib/pleroma/workers/web_pusher_worker.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Workers.WebPusherWorker do use Pleroma.Workers.WorkerHelper, queue: "web_push" @impl Oban.Worker - def perform(%{"op" => "web_push", "notification_id" => notification_id}, _job) do + def perform(%Job{args: %{"op" => "web_push", "notification_id" => notification_id}}) do notification = Notification |> Repo.get(notification_id) diff --git a/lib/pleroma/workers/worker_helper.ex b/lib/pleroma/workers/worker_helper.ex index d1f90c35b..7d1289be2 100644 --- a/lib/pleroma/workers/worker_helper.ex +++ b/lib/pleroma/workers/worker_helper.ex @@ -32,6 +32,8 @@ defmacro __using__(opts) do queue: unquote(queue), max_attempts: 1 + alias Oban.Job + def enqueue(op, params, worker_args \\ []) do params = Map.merge(%{"op" => op}, params) queue_atom = String.to_atom(unquote(queue)) @@ -39,7 +41,7 @@ def enqueue(op, params, worker_args \\ []) do unquote(caller_module) |> apply(:new, [params, worker_args]) - |> Pleroma.Repo.insert() + |> Oban.insert() end end end diff --git a/mix.exs b/mix.exs index 4d13e95d7..e93dc7753 100644 --- a/mix.exs +++ b/mix.exs @@ -124,7 +124,7 @@ defp deps do {:ecto_enum, "~> 1.4"}, {:ecto_sql, "~> 3.3.2"}, {:postgrex, ">= 0.13.5"}, - {:oban, "~> 1.2"}, + {:oban, "~> 2.0.0-rc.1"}, {:gettext, "~> 0.15"}, {:pbkdf2_elixir, "~> 1.0"}, {:bcrypt_elixir, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index 5383c2c6e..705e911f8 100644 --- a/mix.lock +++ b/mix.lock @@ -23,11 +23,11 @@ "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", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, - "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, + "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.4.4", "a2c881e80dc756d648197ae0d936216c0308370332c5e77a2325a10293eef845", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4bd3ad62abc3b21fb629f0f7a3dab23a192fca837d257dd08449fba7373561"}, + "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, @@ -75,7 +75,7 @@ "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, - "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, + "oban": {:hex, :oban, "2.0.0-rc.1", "be0be1769578ff8da1818fd9685838d49bd9c83660cd593c48ac6633638171e0", [: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", "3ae0dacbd39babd82468f290073b5e58618df0cca1b48cc60d8c1ff1757d4c01"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, @@ -90,7 +90,7 @@ "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"}, + "postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [: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", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"}, "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"}, "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"}, diff --git a/test/activity_expiration_test.exs b/test/activity_expiration_test.exs index e899d4509..d75c06cc7 100644 --- a/test/activity_expiration_test.exs +++ b/test/activity_expiration_test.exs @@ -44,7 +44,7 @@ test "deletes an expiration activity" do %{activity_id: activity.id, scheduled_at: naive_datetime} ) - Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid) + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(%Oban.Job{}) refute Pleroma.Repo.get(Pleroma.Activity, activity.id) refute Pleroma.Repo.get(Pleroma.ActivityExpiration, expiration.id) diff --git a/test/support/oban_helpers.ex b/test/support/oban_helpers.ex index e96994c57..9f90a821c 100644 --- a/test/support/oban_helpers.ex +++ b/test/support/oban_helpers.ex @@ -20,7 +20,7 @@ def perform_all do end def perform(%Oban.Job{} = job) do - res = apply(String.to_existing_atom("Elixir." <> job.worker), :perform, [job.args, job]) + res = apply(String.to_existing_atom("Elixir." <> job.worker), :perform, [job]) Repo.delete(job) res end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 7693f6400..8a1cd6f12 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1457,7 +1457,7 @@ test "create" do assert_enqueued(worker: Pleroma.Workers.BackgroundWorker, args: params) - Pleroma.Workers.BackgroundWorker.perform(params, nil) + Pleroma.Workers.BackgroundWorker.perform(%Oban.Job{args: params}) refute User.following?(follower, old_user) assert User.following?(follower, new_user) diff --git a/test/workers/cron/clear_oauth_token_worker_test.exs b/test/workers/cron/clear_oauth_token_worker_test.exs index df82dc75d..67836f34f 100644 --- a/test/workers/cron/clear_oauth_token_worker_test.exs +++ b/test/workers/cron/clear_oauth_token_worker_test.exs @@ -16,7 +16,7 @@ test "deletes expired tokens" do ) Pleroma.Config.put([:oauth2, :clean_expired_tokens], true) - ClearOauthTokenWorker.perform(:opts, :job) + ClearOauthTokenWorker.perform(%Oban.Job{}) assert Pleroma.Repo.all(Pleroma.Web.OAuth.Token) == [] end end diff --git a/test/workers/cron/digest_emails_worker_test.exs b/test/workers/cron/digest_emails_worker_test.exs index f9bc50db5..65887192e 100644 --- a/test/workers/cron/digest_emails_worker_test.exs +++ b/test/workers/cron/digest_emails_worker_test.exs @@ -35,7 +35,7 @@ defmodule Pleroma.Workers.Cron.DigestEmailsWorkerTest do end test "it sends digest emails", %{user2: user2} do - Pleroma.Workers.Cron.DigestEmailsWorker.perform(:opts, :pid) + Pleroma.Workers.Cron.DigestEmailsWorker.perform(%Oban.Job{}) # Performing job(s) enqueued at previous step ObanHelpers.perform_all() @@ -47,7 +47,7 @@ test "it sends digest emails", %{user2: user2} do test "it doesn't fail when a user has no email", %{user2: user2} do {:ok, _} = user2 |> Ecto.Changeset.change(%{email: nil}) |> Pleroma.Repo.update() - Pleroma.Workers.Cron.DigestEmailsWorker.perform(:opts, :pid) + Pleroma.Workers.Cron.DigestEmailsWorker.perform(%Oban.Job{}) # Performing job(s) enqueued at previous step ObanHelpers.perform_all() end diff --git a/test/workers/cron/new_users_digest_worker_test.exs b/test/workers/cron/new_users_digest_worker_test.exs index ee589bb55..129534cb1 100644 --- a/test/workers/cron/new_users_digest_worker_test.exs +++ b/test/workers/cron/new_users_digest_worker_test.exs @@ -17,7 +17,7 @@ test "it sends new users digest emails" do user2 = insert(:user, %{inserted_at: yesterday}) CommonAPI.post(user, %{status: "cofe"}) - NewUsersDigestWorker.perform(nil, nil) + NewUsersDigestWorker.perform(%Oban.Job{}) ObanHelpers.perform_all() assert_received {:email, email} @@ -39,7 +39,7 @@ test "it doesn't fail when admin has no email" do CommonAPI.post(user, %{status: "cofe"}) - NewUsersDigestWorker.perform(nil, nil) + NewUsersDigestWorker.perform(%Oban.Job{}) ObanHelpers.perform_all() end end diff --git a/test/workers/cron/purge_expired_activities_worker_test.exs b/test/workers/cron/purge_expired_activities_worker_test.exs index 6d2991a60..5b2ffbc4c 100644 --- a/test/workers/cron/purge_expired_activities_worker_test.exs +++ b/test/workers/cron/purge_expired_activities_worker_test.exs @@ -33,7 +33,7 @@ test "deletes an expiration activity" do %{activity_id: activity.id, scheduled_at: naive_datetime} ) - Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid) + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(%Oban.Job{}) refute Pleroma.Repo.get(Pleroma.Activity, activity.id) refute Pleroma.Repo.get(Pleroma.ActivityExpiration, expiration.id) @@ -62,7 +62,7 @@ test "works with ActivityExpirationPolicy" do |> Ecto.Changeset.change(%{scheduled_at: past_date}) |> Repo.update!() - Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(:ops, :pid) + Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker.perform(%Oban.Job{}) assert [%{data: %{"type" => "Delete", "deleted_activity_id" => ^id}}] = Pleroma.Repo.all(Pleroma.Activity) diff --git a/test/workers/scheduled_activity_worker_test.exs b/test/workers/scheduled_activity_worker_test.exs index b312d975b..f3eddf7b1 100644 --- a/test/workers/scheduled_activity_worker_test.exs +++ b/test/workers/scheduled_activity_worker_test.exs @@ -32,10 +32,7 @@ test "creates a status from the scheduled activity" do params: %{status: "hi"} ) - ScheduledActivityWorker.perform( - %{"activity_id" => scheduled_activity.id}, - :pid - ) + ScheduledActivityWorker.perform(%Oban.Job{args: %{"activity_id" => scheduled_activity.id}}) refute Repo.get(ScheduledActivity, scheduled_activity.id) activity = Repo.all(Pleroma.Activity) |> Enum.find(&(&1.actor == user.ap_id)) @@ -46,7 +43,7 @@ test "adds log message if ScheduledActivity isn't find" do Pleroma.Config.put([ScheduledActivity, :enabled], true) assert capture_log([level: :error], fn -> - ScheduledActivityWorker.perform(%{"activity_id" => 42}, :pid) + ScheduledActivityWorker.perform(%Oban.Job{args: %{"activity_id" => 42}}) end) =~ "Couldn't find scheduled activity" end end -- cgit v1.2.3 From 54039100fe05e8daf03274ea5c56ca8dab341e9b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 23 Jun 2020 11:17:26 -0500 Subject: Remove reference to defunct distsn.org --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index f9523936a..bc781e4c8 100644 --- a/config/description.exs +++ b/config/description.exs @@ -979,7 +979,7 @@ key: :instance_thumbnail, type: :string, description: - "The instance thumbnail image. It will appear in [Pleroma Instances](http://distsn.org/pleroma-instances.html)", + "The instance thumbnail is the Mastodon landing page image and used by some apps to identify the instance.", suggestions: ["/instance/thumbnail.jpeg"] } ] -- cgit v1.2.3 From cb96c82f70e94e24bdf71e832db4548086f4e7c5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 23 Jun 2020 20:18:27 +0300 Subject: moving to mrf namespace migration fix --- ...22421_mrf_config_move_from_instance_namespace.exs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs index 6f6094613..ef36c4eb7 100644 --- a/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs +++ b/priv/repo/migrations/20200323122421_mrf_config_move_from_instance_namespace.exs @@ -5,13 +5,11 @@ defmodule Pleroma.Repo.Migrations.MrfConfigMoveFromInstanceNamespace do @old_keys [:rewrite_policy, :mrf_transparency, :mrf_transparency_exclusions] def change do - config = ConfigDB.get_by_params(%{group: ":pleroma", key: ":instance"}) + config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) if config do - old_instance = ConfigDB.from_binary(config.value) - mrf = - old_instance + config.value |> Keyword.take(@old_keys) |> Keyword.new(fn {:rewrite_policy, policies} -> {:policies, policies} @@ -21,15 +19,17 @@ def change do if mrf != [] do {:ok, _} = - ConfigDB.create( - %{group: ":pleroma", key: ":mrf", value: ConfigDB.to_binary(mrf)}, - false - ) + %ConfigDB{} + |> ConfigDB.changeset(%{group: :pleroma, key: :mrf, value: mrf}) + |> Pleroma.Repo.insert() - new_instance = Keyword.drop(old_instance, @old_keys) + new_instance = Keyword.drop(config.value, @old_keys) if new_instance != [] do - {:ok, _} = ConfigDB.update(config, %{value: ConfigDB.to_binary(new_instance)}, false) + {:ok, _} = + config + |> ConfigDB.changeset(%{value: new_instance}) + |> Pleroma.Repo.update() else {:ok, _} = ConfigDB.delete(config) end -- cgit v1.2.3 From 71e233268a290dcfba1b1bf1fdcb2eca4840f2d7 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Tue, 23 Jun 2020 21:47:01 +0300 Subject: oban 2.0-rc2 --- mix.exs | 4 ++-- mix.lock | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mix.exs b/mix.exs index e93dc7753..c7a811b9d 100644 --- a/mix.exs +++ b/mix.exs @@ -122,9 +122,9 @@ defp deps do {:phoenix_pubsub, "~> 1.1"}, {:phoenix_ecto, "~> 4.0"}, {:ecto_enum, "~> 1.4"}, - {:ecto_sql, "~> 3.3.2"}, + {:ecto_sql, "~> 3.4.4"}, {:postgrex, ">= 0.13.5"}, - {:oban, "~> 2.0.0-rc.1"}, + {:oban, "~> 2.0.0-rc.2"}, {:gettext, "~> 0.15"}, {:pbkdf2_elixir, "~> 1.0"}, {:bcrypt_elixir, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index 705e911f8..639c54b4a 100644 --- a/mix.lock +++ b/mix.lock @@ -29,7 +29,7 @@ "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, - "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, + "ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, @@ -75,7 +75,7 @@ "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, - "oban": {:hex, :oban, "2.0.0-rc.1", "be0be1769578ff8da1818fd9685838d49bd9c83660cd593c48ac6633638171e0", [: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", "3ae0dacbd39babd82468f290073b5e58618df0cca1b48cc60d8c1ff1757d4c01"}, + "oban": {:hex, :oban, "2.0.0-rc.2", "4a3ba53af98a9aaeee7e53209bbdb18a80972952d4c2ccc6ac61ffd30fa96e8a", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "8a01ace5b6cd142fea547a554b7b752be7ea8fb08e7ffee57405d3b28561560c"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, @@ -106,7 +106,7 @@ "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.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, - "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "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"}, -- cgit v1.2.3 From 721fc7c554425ccc7df693776c282c30e95ae2bb Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 24 Jun 2020 09:12:32 +0300 Subject: added wrapper Pleroma.HTTP for Tzdata.HTTPClient --- config/config.exs | 2 ++ lib/pleroma/http/http.ex | 8 ++++++-- lib/pleroma/http/tzdata.ex | 25 +++++++++++++++++++++++++ mix.exs | 2 +- mix.lock | 2 +- test/http/tzdata_test.exs | 35 +++++++++++++++++++++++++++++++++++ test/http_test.exs | 9 +++++++++ 7 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/http/tzdata.ex create mode 100644 test/http/tzdata_test.exs diff --git a/config/config.exs b/config/config.exs index a81ffcd3b..bd559c835 100644 --- a/config/config.exs +++ b/config/config.exs @@ -695,6 +695,8 @@ transparency: true, transparency_exclusions: [] +config :tzdata, :http_client, Pleroma.HTTP.Tzdata + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 583b56484..66ca75367 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -16,6 +16,7 @@ defmodule Pleroma.HTTP do require Logger @type t :: __MODULE__ + @type method() :: :get | :post | :put | :delete | :head @doc """ Performs GET request. @@ -28,6 +29,9 @@ def get(url, headers \\ [], options \\ []) def get(nil, _, _), do: nil def get(url, headers, options), do: request(:get, url, "", headers, options) + @spec head(Request.url(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} + def head(url, headers \\ [], options \\ []), do: request(:head, url, "", headers, options) + @doc """ Performs POST request. @@ -42,7 +46,7 @@ def post(url, body, headers \\ [], options \\ []), Builds and performs http request. # Arguments: - `method` - :get, :post, :put, :delete + `method` - :get, :post, :put, :delete, :head `url` - full url `body` - request body `headers` - a keyworld list of headers, e.g. `[{"content-type", "text/plain"}]` @@ -52,7 +56,7 @@ def post(url, body, headers \\ [], options \\ []), `{:ok, %Tesla.Env{}}` or `{:error, error}` """ - @spec request(atom(), Request.url(), String.t(), Request.headers(), keyword()) :: + @spec request(method(), Request.url(), String.t(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} def request(method, url, body, headers, options) when is_binary(url) do uri = URI.parse(url) diff --git a/lib/pleroma/http/tzdata.ex b/lib/pleroma/http/tzdata.ex new file mode 100644 index 000000000..34bb253a7 --- /dev/null +++ b/lib/pleroma/http/tzdata.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Tzdata do + @moduledoc false + + @behaviour Tzdata.HTTPClient + + alias Pleroma.HTTP + + @impl true + def get(url, headers, options) do + with {:ok, %Tesla.Env{} = env} <- HTTP.get(url, headers, options) do + {:ok, {env.status, env.headers, env.body}} + end + end + + @impl true + def head(url, headers, options) do + with {:ok, %Tesla.Env{} = env} <- HTTP.head(url, headers, options) do + {:ok, {env.status, env.headers}} + end + end +end diff --git a/mix.exs b/mix.exs index 4d13e95d7..b638be541 100644 --- a/mix.exs +++ b/mix.exs @@ -117,7 +117,7 @@ defp oauth_deps do defp deps do [ {:phoenix, "~> 1.4.8"}, - {:tzdata, "~> 0.5.21"}, + {:tzdata, "~> 1.0.3"}, {:plug_cowboy, "~> 2.0"}, {:phoenix_pubsub, "~> 1.1"}, {:phoenix_ecto, "~> 4.0"}, diff --git a/mix.lock b/mix.lock index 5383c2c6e..5ad49391d 100644 --- a/mix.lock +++ b/mix.lock @@ -110,7 +110,7 @@ "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"}, + "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "db9fbfb5ac707bc4f85a297758406340bf0358b4af737a88113c1a9eee120ac7"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, diff --git a/test/http/tzdata_test.exs b/test/http/tzdata_test.exs new file mode 100644 index 000000000..4b37299cd --- /dev/null +++ b/test/http/tzdata_test.exs @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.TzdaraTest do + use ExUnit.Case + + import Tesla.Mock + alias Pleroma.HTTP + @url "https://data.iana.org/time-zones/tzdata-latest.tar.gz" + + setup do + mock(fn + %{method: :head, url: @url} -> + %Tesla.Env{status: 200, body: ""} + + %{method: :get, url: @url} -> + %Tesla.Env{status: 200, body: "hello"} + end) + + :ok + end + + describe "head/1" do + test "returns successfully result" do + assert HTTP.Tzdata.head(@url, [], []) == {:ok, {200, []}} + end + end + + describe "get/1" do + test "returns successfully result" do + assert HTTP.Tzdata.get(@url, [], []) == {:ok, {200, [], "hello"}} + end + end +end diff --git a/test/http_test.exs b/test/http_test.exs index 618485b55..d394bb942 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -17,6 +17,9 @@ defmodule Pleroma.HTTPTest do } -> json(%{"my" => "data"}) + %{method: :head, url: "http://example.com/hello"} -> + %Tesla.Env{status: 200, body: ""} + %{method: :get, url: "http://example.com/hello"} -> %Tesla.Env{status: 200, body: "hello"} @@ -27,6 +30,12 @@ defmodule Pleroma.HTTPTest do :ok end + describe "head/1" do + test "returns successfully result" do + assert HTTP.head("http://example.com/hello") == {:ok, %Tesla.Env{status: 200, body: ""}} + end + end + describe "get/1" do test "returns successfully result" do assert HTTP.get("http://example.com/hello") == { -- cgit v1.2.3 From 65f3eb333b001586771247ea9949e40bfec0a947 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 08:50:33 +0000 Subject: Apply suggestion to test/http/tzdata_test.exs --- test/http/tzdata_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/http/tzdata_test.exs b/test/http/tzdata_test.exs index 4b37299cd..3e605d33b 100644 --- a/test/http/tzdata_test.exs +++ b/test/http/tzdata_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.HTTP.TzdaraTest do +defmodule Pleroma.HTTP.TzdataTest do use ExUnit.Case import Tesla.Mock -- cgit v1.2.3 From 35f6770436837e2e500971a54d51984bd059adfd Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 13:29:08 +0200 Subject: StatusView: Add pleroma.parent_visible --- lib/pleroma/web/activity_pub/visibility.ex | 6 ++++-- lib/pleroma/web/mastodon_api/views/status_view.ex | 5 +++-- test/web/mastodon_api/views/status_view_test.exs | 19 ++++++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 453a6842e..343f41caa 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -47,6 +47,10 @@ def is_list?(_), do: false @spec visible_for_user?(Activity.t(), User.t() | nil) :: boolean() def visible_for_user?(%{actor: ap_id}, %User{ap_id: ap_id}), do: true + def visible_for_user?(nil, _), do: false + + def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false + def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{} = user) do user.ap_id in activity.data["to"] || list_ap_id @@ -54,8 +58,6 @@ def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{ |> Pleroma.List.member?(user) end - def visible_for_user?(%{data: %{"listMessage" => _}}, nil), do: false - def visible_for_user?(%{local: local} = activity, nil) do cfg_key = if local, diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 2c49bedb3..6ee17f4dd 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy - import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1] + import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] # TODO: Add cached version. defp get_replied_to_activities([]), do: %{} @@ -364,7 +364,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} expires_at: expires_at, direct_conversation_id: direct_conversation_id, thread_muted: thread_muted?, - emoji_reactions: emoji_reactions + emoji_reactions: emoji_reactions, + parent_visible: visible_for_user?(reply_to, opts[:for]) } } end diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 5cbadf0fc..f90a0c273 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -226,7 +226,8 @@ test "a note activity" do expires_at: nil, direct_conversation_id: nil, thread_muted: false, - emoji_reactions: [] + emoji_reactions: [], + parent_visible: false } } @@ -620,4 +621,20 @@ test "visibility/list" do assert status.visibility == "list" end + + test "has a field for parent visibility" do + user = insert(:user) + poster = insert(:user) + + {:ok, invisible} = CommonAPI.post(poster, %{status: "hey", visibility: "private"}) + + {:ok, visible} = + CommonAPI.post(poster, %{status: "hey", visibility: "private", in_reply_to_id: invisible.id}) + + status = StatusView.render("show.json", activity: visible, for: user) + refute status.pleroma.parent_visible + + status = StatusView.render("show.json", activity: visible, for: poster) + assert status.pleroma.parent_visible + end end -- cgit v1.2.3 From 637bae42b4ac59e54164f2b9545017b3f8d2960f Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 13:31:42 +0200 Subject: Docs: Document added parent_visible field. --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index be3c802af..f6e8a6800 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -27,6 +27,7 @@ Has these additional fields under the `pleroma` object: - `expires_at`: a datetime (iso8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire - `thread_muted`: true if the thread the post belongs to is muted - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. +- `parent_visible`: If the parent of this post is visible to the user or not. ## Media Attachments -- cgit v1.2.3 From 79ee914bc2956f005c83046be53dbd38771d29ef Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 13:32:14 +0200 Subject: Changelog: Add info about parent_visible field --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc2231d1..cfffc279b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- StatusView: Add pleroma.parents_visible field. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` -- cgit v1.2.3 From 1702239428ea7e3b49fcf8985f1d2fbbadb020b5 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 14:30:12 +0200 Subject: Changelog: Put info under API header. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfffc279b..f04e12ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- StatusView: Add pleroma.parents_visible field. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` @@ -43,6 +42,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). <details> <summary>API Changes</summary> +- Mastodon API: Add pleroma.parents_visible field to statuses. - Mastodon API: Extended `/api/v1/instance`. - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. -- cgit v1.2.3 From 4c5fb831b3b59309a475a141eb73cc440533d0ff Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 24 Jun 2020 14:33:00 +0200 Subject: Status schema: Add parent_visible. --- lib/pleroma/web/api_spec/schemas/status.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 8b87cb25b..28cde963e 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -184,6 +184,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do thread_muted: %Schema{ type: :boolean, description: "`true` if the thread the post belongs to is muted" + }, + parent_visible: %Schema{ + type: :boolean, + description: "`true` if the parent post is visible to the user" } } }, -- cgit v1.2.3 From aae1af8cf1d860243eb8c8a642682441294967e1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 24 Jun 2020 18:06:30 +0300 Subject: fix for emoji pagination in pack show --- lib/pleroma/emoji/pack.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 787ff8141..d076ae312 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -45,6 +45,7 @@ def show(opts) do shortcodes = pack.files |> Map.keys() + |> Enum.sort() |> paginate(opts[:page], opts[:page_size]) pack = Map.put(pack, :files, Map.take(pack.files, shortcodes)) -- cgit v1.2.3 From cc837f9d157f9d43a015a8908f5e2ee178442041 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Wed, 24 Jun 2020 21:21:33 +0300 Subject: fixed config/descpiption.exs --- config/description.exs | 9 +-------- lib/pleroma/application.ex | 14 +------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/config/description.exs b/config/description.exs index f9523936a..ff777391e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1996,18 +1996,11 @@ """, children: [ %{ - key: :verbose, + key: :log, type: {:dropdown, :atom}, description: "Logs verbose mode", suggestions: [false, :error, :warn, :info, :debug] }, - %{ - key: :prune, - type: [:atom, :tuple], - description: - "Non-retryable jobs [pruning settings](https://github.com/sorentwo/oban#pruning)", - suggestions: [:disabled, {:maxlen, 1500}, {:maxage, 60 * 60}] - }, %{ key: :queues, type: {:keyword, :integer}, diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index fb2731f97..9615af122 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -80,7 +80,7 @@ def start(_type, _args) do [ Pleroma.Stats, Pleroma.JobQueueMonitor, - {Oban, oban_config()} + {Oban, Config.get(Oban)} ] ++ task_children(@env) ++ streamer_child(@env) ++ @@ -138,18 +138,6 @@ defp setup_instrumenters do Pleroma.Web.Endpoint.Instrumenter.setup() end - defp oban_config do - config = Config.get(Oban) - - if Code.ensure_loaded?(IEx) and IEx.started?() do - config - |> Keyword.put(:crontab, false) - |> Keyword.put(:queues, false) - else - config - end - end - defp cachex_children do [ build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), -- cgit v1.2.3 From 67ab5805536ed64ca842998bfd4b3b0e63d13dd3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Wed, 24 Jun 2020 17:18:53 -0500 Subject: Filter outstanding follower requests from deactivated accounts --- lib/pleroma/following_relationship.ex | 1 + test/user_test.exs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 093b1f405..c2020d30a 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -124,6 +124,7 @@ def get_follow_requests(%User{id: id}) do |> join(:inner, [r], f in assoc(r, :follower)) |> where([r], r.state == ^:follow_pending) |> where([r], r.following_id == ^id) + |> where([r, f], f.deactivated != true) |> select([r, f], f) |> Repo.all() end diff --git a/test/user_test.exs b/test/user_test.exs index 311b6c683..9b66f3f51 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -199,6 +199,16 @@ test "doesn't return already accepted or duplicate follow requests" do assert [^pending_follower] = User.get_follow_requests(locked) end + test "doesn't return follow requests for deactivated accounts" do + locked = insert(:user, locked: true) + pending_follower = insert(:user, %{deactivated: true}) + + CommonAPI.follow(pending_follower, locked) + + assert true == pending_follower.deactivated + assert [] = User.get_follow_requests(locked) + end + test "clears follow requests when requester is blocked" do followed = insert(:user, locked: true) follower = insert(:user) -- cgit v1.2.3 From 439a1a0218fe032ac35bb2e84516a8a4bf8563b4 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov <parallel588@gmail.com> Date: Thu, 25 Jun 2020 07:12:29 +0300 Subject: added wrapper Pleroma.HTTP for ExAws.S3 --- config/config.exs | 2 ++ lib/pleroma/http/ex_aws.ex | 22 +++++++++++++++++++ test/http/ex_aws_test.exs | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 lib/pleroma/http/ex_aws.ex create mode 100644 test/http/ex_aws_test.exs diff --git a/config/config.exs b/config/config.exs index bd559c835..5aad26e95 100644 --- a/config/config.exs +++ b/config/config.exs @@ -697,6 +697,8 @@ config :tzdata, :http_client, Pleroma.HTTP.Tzdata +config :ex_aws, http_client: Pleroma.HTTP.ExAws + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/http/ex_aws.ex b/lib/pleroma/http/ex_aws.ex new file mode 100644 index 000000000..e53e64077 --- /dev/null +++ b/lib/pleroma/http/ex_aws.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.ExAws do + @moduledoc false + + @behaviour ExAws.Request.HttpClient + + alias Pleroma.HTTP + + @impl true + def request(method, url, body \\ "", headers \\ [], http_opts \\ []) do + case HTTP.request(method, url, body, headers, http_opts) do + {:ok, env} -> + {:ok, %{status_code: env.status, headers: env.headers, body: env.body}} + + {:error, reason} -> + {:error, %{reason: reason}} + end + end +end diff --git a/test/http/ex_aws_test.exs b/test/http/ex_aws_test.exs new file mode 100644 index 000000000..d0b00ca26 --- /dev/null +++ b/test/http/ex_aws_test.exs @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.ExAwsTest do + use ExUnit.Case + + import Tesla.Mock + alias Pleroma.HTTP + + @url "https://s3.amazonaws.com/test_bucket/test_image.jpg" + + setup do + mock(fn + %{method: :get, url: @url, headers: [{"x-amz-bucket-region", "us-east-1"}]} -> + %Tesla.Env{ + status: 200, + body: "image-content", + headers: [{"x-amz-bucket-region", "us-east-1"}] + } + + %{method: :post, url: @url, body: "image-content-2"} -> + %Tesla.Env{status: 200, body: "image-content-2"} + end) + + :ok + end + + describe "request" do + test "get" do + assert HTTP.ExAws.request(:get, @url, "", [{"x-amz-bucket-region", "us-east-1"}]) == { + :ok, + %{ + body: "image-content", + headers: [{"x-amz-bucket-region", "us-east-1"}], + status_code: 200 + } + } + end + + test "post" do + assert HTTP.ExAws.request(:post, @url, "image-content-2", [ + {"x-amz-bucket-region", "us-east-1"} + ]) == { + :ok, + %{ + body: "image-content-2", + headers: [], + status_code: 200 + } + } + end + end +end -- cgit v1.2.3 From d137f934dfed199141ee7cb4215520b64e3ecb4f Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 10:54:00 +0200 Subject: Transmogrifier Test: Extract block handling. --- .../transmogrifier/block_handling_test.exs | 63 ++++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 50 ----------------- 2 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/block_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/block_handling_test.exs b/test/web/activity_pub/transmogrifier/block_handling_test.exs new file mode 100644 index 000000000..71f1a0ed5 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/block_handling_test.exs @@ -0,0 +1,63 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.BlockHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + + test "it works for incoming blocks" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-block-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + blocker = insert(:user, ap_id: data["actor"]) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Block" + assert data["object"] == user.ap_id + assert data["actor"] == "http://mastodon.example.org/users/admin" + + assert User.blocks?(blocker, user) + end + + test "incoming blocks successfully tear down any follow relationship" do + blocker = insert(:user) + blocked = insert(:user) + + data = + File.read!("test/fixtures/mastodon-block-activity.json") + |> Poison.decode!() + |> Map.put("object", blocked.ap_id) + |> Map.put("actor", blocker.ap_id) + + {:ok, blocker} = User.follow(blocker, blocked) + {:ok, blocked} = User.follow(blocked, blocker) + + assert User.following?(blocker, blocked) + assert User.following?(blocked, blocker) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Block" + assert data["object"] == blocked.ap_id + assert data["actor"] == blocker.ap_id + + blocker = User.get_cached_by_ap_id(data["actor"]) + blocked = User.get_cached_by_ap_id(data["object"]) + + assert User.blocks?(blocker, blocked) + + refute User.following?(blocker, blocked) + refute User.following?(blocked, blocker) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 100821056..6a53fd3f0 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -445,56 +445,6 @@ test "it works for incoming follows to locked account" do assert [^pending_follower] = User.get_follow_requests(user) end - test "it works for incoming blocks" do - user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() - |> Map.put("object", user.ap_id) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Block" - assert data["object"] == user.ap_id - assert data["actor"] == "http://mastodon.example.org/users/admin" - - blocker = User.get_cached_by_ap_id(data["actor"]) - - assert User.blocks?(blocker, user) - end - - test "incoming blocks successfully tear down any follow relationship" do - blocker = insert(:user) - blocked = insert(:user) - - data = - File.read!("test/fixtures/mastodon-block-activity.json") - |> Poison.decode!() - |> Map.put("object", blocked.ap_id) - |> Map.put("actor", blocker.ap_id) - - {:ok, blocker} = User.follow(blocker, blocked) - {:ok, blocked} = User.follow(blocked, blocker) - - assert User.following?(blocker, blocked) - assert User.following?(blocked, blocker) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Block" - assert data["object"] == blocked.ap_id - assert data["actor"] == blocker.ap_id - - blocker = User.get_cached_by_ap_id(data["actor"]) - blocked = User.get_cached_by_ap_id(data["object"]) - - assert User.blocks?(blocker, blocked) - - refute User.following?(blocker, blocked) - refute User.following?(blocked, blocker) - end - test "it works for incoming accepts which were pre-accepted" do follower = insert(:user) followed = insert(:user) -- cgit v1.2.3 From 89e5b2046bd15b3fead7a6194a2b9cecd2fedbd3 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:13:35 +0200 Subject: ObjectValidator: Basic `Block` support. --- lib/pleroma/web/activity_pub/builder.ex | 12 +++++++ lib/pleroma/web/activity_pub/object_validator.ex | 11 ++++++ .../object_validators/block_validator.ex | 42 ++++++++++++++++++++++ test/web/activity_pub/object_validator_test.exs | 27 ++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/block_validator.ex diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 135a5c431..cabc28de9 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -138,6 +138,18 @@ def update(actor, object) do }, []} end + @spec block(User.t(), User.t()) :: {:ok, map(), keyword()} + def block(blocker, blocked) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "type" => "Block", + "actor" => blocker.ap_id, + "object" => blocked.ap_id, + "to" => [blocked.ap_id] + }, []} + end + @spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()} def announce(actor, object, options \\ []) do public? = Keyword.get(options, :public, false) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 2c657b467..737c0fd64 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator @@ -24,6 +25,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Block"} = block_activity, meta) do + with {:ok, block_activity} <- + block_activity + |> BlockValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + block_activity = stringify_keys(block_activity) + {:ok, block_activity, meta} + end + end + def validate(%{"type" => "Update"} = update_activity, meta) do with {:ok, update_activity} <- update_activity diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex new file mode 100644 index 000000000..1dde77198 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:object, ObjectValidators.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Block"]) + |> validate_actor_presence() + |> validate_actor_presence(field_name: :object) + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 770a8dcf8..e96552763 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -654,4 +654,31 @@ test "returns an error if the object can't be updated by the actor", %{ assert {:error, _cng} = ObjectValidator.validate(update, []) end end + + describe "blocks" do + setup do + user = insert(:user) + blocked = insert(:user) + + {:ok, valid_block, []} = Builder.block(user, blocked) + + %{user: user, valid_block: valid_block} + end + + test "validates a basic object", %{ + valid_block: valid_block + } do + assert {:ok, _block, []} = ObjectValidator.validate(valid_block, []) + end + + test "returns an error if we don't know the blocked user", %{ + valid_block: valid_block + } do + block = + valid_block + |> Map.put("object", "https://gensokyo.2hu/users/raymoo") + + assert {:error, _cng} = ObjectValidator.validate(block, []) + end + end end -- cgit v1.2.3 From e38293c8f1adca40447ba39f4919b2b08bf0329a Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:33:54 +0200 Subject: Transmogrifier: Switch to pipeline for Blocks. --- lib/pleroma/web/activity_pub/side_effects.ex | 16 ++++++++++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 22 ++++------------------ test/web/activity_pub/side_effects_test.exs | 25 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index de143b8f0..48350d2b3 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -20,6 +20,22 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) + # Tasks this handles: + # - Unfollow and block + def handle( + %{data: %{"type" => "Block", "object" => blocked_user, "actor" => blocking_user}} = + object, + meta + ) do + with %User{} = blocker <- User.get_cached_by_ap_id(blocking_user), + %User{} = blocked <- User.get_cached_by_ap_id(blocked_user) do + User.unfollow(blocker, blocked) + User.block(blocker, blocked) + end + + {:ok, object, meta} + end + # Tasks this handles: # - Update the user # diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4e318e89c..278fbbeab 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -673,7 +673,7 @@ def handle_incoming( end def handle_incoming(%{"type" => type} = data, _options) - when type in ["Like", "EmojiReact", "Announce"] do + when type in ~w{Like EmojiReact Announce} do with :ok <- ObjectValidator.fetch_actor_and_object(data), {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do @@ -684,9 +684,10 @@ def handle_incoming(%{"type" => type} = data, _options) end def handle_incoming( - %{"type" => "Update"} = data, + %{"type" => type} = data, _options - ) do + ) + when type in ~w{Update Block} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} @@ -765,21 +766,6 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data, - _options - ) do - with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked), - {:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker), - {:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do - User.unfollow(blocker, blocked) - User.block(blocker, blocked) - {:ok, activity} - else - _e -> :error - end - end - def handle_incoming( %{ "type" => "Move", diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 12c9ef1da..5e883bb09 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -64,6 +64,31 @@ test "it streams out notifications and streams" do end end + describe "blocking users" do + setup do + user = insert(:user) + blocked = insert(:user) + User.follow(blocked, user) + User.follow(user, blocked) + + {:ok, block_data, []} = Builder.block(user, blocked) + {:ok, block, _meta} = ActivityPub.persist(block_data, local: true) + + %{user: user, blocked: blocked, block: block} + end + + test "it unfollows and blocks", %{user: user, blocked: blocked, block: block} do + assert User.following?(user, blocked) + assert User.following?(blocked, user) + + {:ok, _, _} = SideEffects.handle(block) + + refute User.following?(user, blocked) + refute User.following?(blocked, user) + assert User.blocks?(user, blocked) + end + end + describe "update users" do setup do user = insert(:user) -- cgit v1.2.3 From 8cfb58a8c0a2ee0c69eb727cc810e8571289f813 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:44:04 +0200 Subject: AccountController: Extract blocking to CommonAPI. --- lib/pleroma/web/common_api/common_api.ex | 7 +++++++ lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 04e081a8e..fd7149079 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -25,6 +25,13 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger + def block(blocker, blocked) do + with {:ok, block_data, _} <- Builder.block(blocker, blocked), + {:ok, block, _} <- Pipeline.common_pipeline(block_data, local: true) do + {:ok, block} + end + end + def post_chat_message(%User{} = user, %User{} = recipient, content, opts \\ []) do with maybe_attachment <- opts[:media_id] && Object.get_by_id(opts[:media_id]), :ok <- validate_chat_content_length(content, !!maybe_attachment), diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7a88a847c..b5008d69b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -385,8 +385,7 @@ def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do @doc "POST /api/v1/accounts/:id/block" def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do - with {:ok, _user_block} <- User.block(blocker, blocked), - {:ok, _activity} <- ActivityPub.block(blocker, blocked) do + with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do render(conn, "relationship.json", user: blocker, target: blocked) else {:error, message} -> json_response(conn, :forbidden, %{error: message}) -- cgit v1.2.3 From 44bb7cfccdf2c25ae641b4cffa8e5c7fdedc3f54 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 11:51:33 +0200 Subject: ActivityPub: Remove `block`. --- lib/pleroma/user.ex | 3 +- lib/pleroma/web/activity_pub/activity_pub.ex | 27 ---------------- test/web/activity_pub/activity_pub_test.exs | 48 ---------------------------- test/web/activity_pub/side_effects_test.exs | 3 +- test/web/activity_pub/utils_test.exs | 16 ++-------- 5 files changed, 5 insertions(+), 92 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1d70a37ef..c3e2a89ad 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1527,8 +1527,7 @@ def perform(:blocks_import, %User{} = blocker, blocked_identifiers) blocked_identifiers, fn blocked_identifier -> with {:ok, %User{} = blocked} <- get_or_fetch(blocked_identifier), - {:ok, _user_block} <- block(blocker, blocked), - {:ok, _} <- ActivityPub.block(blocker, blocked) do + {:ok, _block} <- CommonAPI.block(blocker, blocked) do blocked else err -> diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 7cd3eab39..05bd824f5 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -366,33 +366,6 @@ defp do_unfollow(follower, followed, activity_id, local) do end 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 - with {:ok, result} <- - Repo.transaction(fn -> do_block(blocker, blocked, activity_id, local) end) do - result - end - end - - defp do_block(blocker, blocked, activity_id, local) do - unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) - - if unfollow_blocked and fetch_latest_follow(blocker, blocked) do - unfollow(blocker, blocked, nil, local) - end - - block_data = make_block_data(blocker, blocked, activity_id) - - with {:ok, activity} <- insert(block_data, local), - _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - {:error, error} -> Repo.rollback(error) - end - end - @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} def flag( %{ diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index be7ab2ae4..575e0c5db 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -992,54 +992,6 @@ test "creates an undo activity for a pending follow request" do end end - describe "blocking" do - test "reverts block activity on error" do - [blocker, blocked] = insert_list(2, :user) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.block(blocker, blocked) - end - - assert Repo.aggregate(Activity, :count, :id) == 0 - assert Repo.aggregate(Object, :count, :id) == 0 - end - - test "creates a block activity" do - clear_config([:instance, :federating], true) - blocker = insert(:user) - blocked = insert(:user) - - with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end do - {:ok, activity} = ActivityPub.block(blocker, blocked) - - assert activity.data["type"] == "Block" - assert activity.data["actor"] == blocker.ap_id - assert activity.data["object"] == blocked.ap_id - - assert called(Pleroma.Web.Federator.publish(activity)) - end - end - - test "works with outgoing blocks disabled, but doesn't federate" do - clear_config([:instance, :federating], true) - clear_config([:activitypub, :outgoing_blocks], false) - blocker = insert(:user) - blocked = insert(:user) - - with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end do - {:ok, activity} = ActivityPub.block(blocker, blocked) - - assert activity.data["type"] == "Block" - assert activity.data["actor"] == blocker.ap_id - assert activity.data["object"] == blocked.ap_id - - refute called(Pleroma.Web.Federator.publish(:_)) - end - end - end - describe "timeline post-processing" do test "it filters broken threads" do user1 = insert(:user) diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 5e883bb09..36792f015 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -267,8 +267,7 @@ test "when activation is required", %{delete: delete, user: user} do {:ok, like} = CommonAPI.favorite(user, post.id) {:ok, reaction} = CommonAPI.react_with_emoji(post.id, user, "👍") {:ok, announce} = CommonAPI.repeat(post.id, user) - {:ok, block} = ActivityPub.block(user, poster) - User.block(user, poster) + {:ok, block} = CommonAPI.block(user, poster) {:ok, undo_data, _meta} = Builder.undo(user, like) {:ok, like_undo, _meta} = ActivityPub.persist(undo_data, local: true) diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index 15f03f193..2f9ecb5a3 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -27,16 +27,6 @@ test "fetches the latest Follow activity" do end end - describe "fetch the latest Block" do - test "fetches the latest Block activity" do - blocker = insert(:user) - blocked = insert(:user) - {:ok, activity} = ActivityPub.block(blocker, blocked) - - assert activity == Utils.fetch_latest_block(blocker, blocked) - end - end - describe "determine_explicit_mentions()" do test "works with an object that has mentions" do object = %{ @@ -344,9 +334,9 @@ test "fetches last block activities" do user1 = insert(:user) user2 = insert(:user) - assert {:ok, %Activity{} = _} = ActivityPub.block(user1, user2) - assert {:ok, %Activity{} = _} = ActivityPub.block(user1, user2) - assert {:ok, %Activity{} = activity} = ActivityPub.block(user1, user2) + assert {:ok, %Activity{} = _} = CommonAPI.block(user1, user2) + assert {:ok, %Activity{} = _} = CommonAPI.block(user1, user2) + assert {:ok, %Activity{} = activity} = CommonAPI.block(user1, user2) assert Utils.fetch_latest_block(user1, user2) == activity end -- cgit v1.2.3 From 84f9ca19568777861ff9520cbef09a0259efd536 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 12:03:14 +0200 Subject: Blocking: Don't federate if the options is set. --- lib/pleroma/web/activity_pub/object_validator.ex | 9 +++++ test/web/common_api/common_api_test.exs | 46 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 737c0fd64..bb6324460 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -31,6 +31,15 @@ def validate(%{"type" => "Block"} = block_activity, meta) do |> BlockValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do block_activity = stringify_keys(block_activity) + outgoing_blocks = Pleroma.Config.get([:activitypub, :outgoing_blocks]) + + meta = + if !outgoing_blocks do + Keyword.put(meta, :do_not_federate, true) + else + meta + end + {:ok, block_activity, meta} end end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 6bd26050e..fc3bb845d 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -25,6 +25,52 @@ defmodule Pleroma.Web.CommonAPITest do setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) + describe "blocking" do + setup do + blocker = insert(:user) + blocked = insert(:user) + User.follow(blocker, blocked) + User.follow(blocked, blocker) + %{blocker: blocker, blocked: blocked} + end + + test "it blocks and federates", %{blocker: blocker, blocked: blocked} do + clear_config([:instance, :federating], true) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, block} = CommonAPI.block(blocker, blocked) + + assert block.local + assert User.blocks?(blocker, blocked) + refute User.following?(blocker, blocked) + refute User.following?(blocked, blocker) + + assert called(Pleroma.Web.Federator.publish(block)) + end + end + + test "it blocks and does not federate if outgoing blocks are disabled", %{ + blocker: blocker, + blocked: blocked + } do + clear_config([:instance, :federating], true) + clear_config([:activitypub, :outgoing_blocks], false) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, block} = CommonAPI.block(blocker, blocked) + + assert block.local + assert User.blocks?(blocker, blocked) + refute User.following?(blocker, blocked) + refute User.following?(blocked, blocker) + + refute called(Pleroma.Web.Federator.publish(block)) + end + end + end + describe "posting chat messages" do setup do: clear_config([:instance, :chat_limit]) -- cgit v1.2.3 From f585622f852f4204f8f8dcaa6626ed4cd025edfe Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 10:17:16 +0000 Subject: Apply suggestion to config/description.exs --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index bc781e4c8..5ed7f753e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -979,7 +979,7 @@ key: :instance_thumbnail, type: :string, description: - "The instance thumbnail is the Mastodon landing page image and used by some apps to identify the instance.", + "The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.", suggestions: ["/instance/thumbnail.jpeg"] } ] -- cgit v1.2.3 From 04abee782b8745b21d0f9e58b27a805db6a94aa7 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Thu, 25 Jun 2020 12:40:39 +0200 Subject: AntiSpamLinkPolicy: Exempt local users. --- .../web/activity_pub/mrf/anti_link_spam_policy.ex | 5 ++++- test/web/activity_pub/mrf/anti_link_spam_policy_test.exs | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index 9e7800997..a7e187b5e 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -27,11 +27,14 @@ defp contains_links?(_), do: false @impl true def filter(%{"type" => "Create", "actor" => actor, "object" => object} = message) do - with {:ok, %User{} = u} <- User.get_or_fetch_by_ap_id(actor), + with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor), {:contains_links, true} <- {:contains_links, contains_links?(object)}, {:old_user, true} <- {:old_user, old_user?(u)} do {:ok, message} else + {:ok, %User{local: true}} -> + {:ok, message} + {:contains_links, false} -> {:ok, message} 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 1a13699be..6867c9853 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 @@ -33,7 +33,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do describe "with new user" do test "it allows posts without links" do - user = insert(:user) + user = insert(:user, local: false) assert user.note_count == 0 @@ -45,7 +45,7 @@ test "it allows posts without links" do end test "it disallows posts with links" do - user = insert(:user) + user = insert(:user, local: false) assert user.note_count == 0 @@ -55,6 +55,18 @@ test "it disallows posts with links" do {:reject, _} = AntiLinkSpamPolicy.filter(message) end + + test "it allows posts with links for local users" do + user = insert(:user) + + assert user.note_count == 0 + + message = + @linkful_message + |> Map.put("actor", user.ap_id) + + {:ok, _message} = AntiLinkSpamPolicy.filter(message) + end end describe "with old user" do -- cgit v1.2.3 From 28d4e60f668ec5fa6c5be21ee28612d01b51a6a0 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 25 Jun 2020 12:32:06 -0500 Subject: MastoAPI differences: Document not implemented features --- docs/API/differences_in_mastoapi_responses.md | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index be3c802af..7c3546f4f 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -234,3 +234,43 @@ Has these additional fields under the `pleroma` object: ## Streaming There is an additional `user:pleroma_chat` stream. Incoming chat messages will make the current chat be sent to this `user` stream. The `event` of an incoming chat message is `pleroma:chat_update`. The payload is the updated chat with the incoming chat message in the `last_message` field. + +## Not implemented + +Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer features and non-essential features are omitted. These features usually return an HTTP 200 status code, but with an empty response. While they may be added in the future, they are considered low priority. + +### Suggestions + +*Added in Mastodon 2.4.3* + +- `GET /api/v1/suggestions`: Returns an empty array, `[]` + +### Trends + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/trends`: Returns an empty array, `[]` + +### Identity proofs + +*Added in Mastodon 2.8.0* + +- `GET /api/v1/identity_proofs`: Returns an empty array, `[]` + +### Endorsements + +*Added in Mastodon 2.5.0* + +- `GET /api/v1/endorsements`: Returns an empty array, `[]` + +### Profile directory + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/directory`: Returns HTTP 404 + +### Featured tags + +*Added in Mastodon 3.0.0* + +- `GET /api/v1/featured_tags`: Returns HTTP 404 -- cgit v1.2.3 From d9e462362823c9178e354ebe9b7c6761f94d387f Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 25 Jun 2020 14:55:00 -0500 Subject: Update AdminFE build --- priv/static/adminfe/app.6684eb28.css | Bin 0 -> 12837 bytes priv/static/adminfe/app.796ca6d4.css | Bin 12837 -> 0 bytes priv/static/adminfe/chunk-0558.af0d89cd.css | Bin 4748 -> 0 bytes priv/static/adminfe/chunk-070d.d2dd6533.css | Bin 0 -> 4748 bytes priv/static/adminfe/chunk-0778.d9e7180a.css | Bin 2340 -> 0 bytes priv/static/adminfe/chunk-0961.d3692214.css | Bin 2044 -> 0 bytes priv/static/adminfe/chunk-0cbc.60bba79b.css | Bin 0 -> 3385 bytes priv/static/adminfe/chunk-143c.43ada4fc.css | Bin 0 -> 692 bytes priv/static/adminfe/chunk-1609.408dae86.css | Bin 0 -> 1381 bytes priv/static/adminfe/chunk-176e.5d7d957b.css | Bin 0 -> 2163 bytes priv/static/adminfe/chunk-22d2.813009b9.css | Bin 6282 -> 0 bytes priv/static/adminfe/chunk-3384.d50ed383.css | Bin 5605 -> 0 bytes priv/static/adminfe/chunk-43ca.0de86b6d.css | Bin 0 -> 23710 bytes priv/static/adminfe/chunk-4e7e.5afe1978.css | Bin 0 -> 2044 bytes priv/static/adminfe/chunk-5882.f65db7f2.css | Bin 0 -> 4401 bytes priv/static/adminfe/chunk-6b68.0cc00484.css | Bin 692 -> 0 bytes priv/static/adminfe/chunk-6e81.0e80d020.css | Bin 745 -> 0 bytes priv/static/adminfe/chunk-6e81.ca3b222f.css | Bin 0 -> 745 bytes priv/static/adminfe/chunk-7506.f01f6c2a.css | Bin 0 -> 3290 bytes priv/static/adminfe/chunk-7637.941c4edb.css | Bin 1347 -> 0 bytes priv/static/adminfe/chunk-7c6b.d9e7180a.css | Bin 0 -> 2340 bytes priv/static/adminfe/chunk-7e30.f2b9674a.css | Bin 23982 -> 0 bytes priv/static/adminfe/chunk-970d.f59cca8c.css | Bin 6173 -> 0 bytes priv/static/adminfe/chunk-c5f4.0827b1ce.css | Bin 0 -> 5669 bytes priv/static/adminfe/chunk-commons.7f6d2d11.css | Bin 0 -> 2495 bytes priv/static/adminfe/chunk-d38a.cabdc22e.css | Bin 3332 -> 0 bytes priv/static/adminfe/chunk-e404.a56021ae.css | Bin 0 -> 5063 bytes priv/static/adminfe/chunk-e458.6c0703cb.css | Bin 3863 -> 0 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.0146039c.js | Bin 190274 -> 0 bytes priv/static/adminfe/static/js/app.0146039c.js.map | Bin 421137 -> 0 bytes priv/static/adminfe/static/js/app.3fcec8f6.js | Bin 0 -> 192591 bytes priv/static/adminfe/static/js/app.3fcec8f6.js.map | Bin 0 -> 426204 bytes priv/static/adminfe/static/js/chunk-0558.75954137.js | Bin 7919 -> 0 bytes .../adminfe/static/js/chunk-0558.75954137.js.map | Bin 17438 -> 0 bytes priv/static/adminfe/static/js/chunk-070d.7e10a520.js | Bin 0 -> 7919 bytes .../adminfe/static/js/chunk-070d.7e10a520.js.map | Bin 0 -> 17438 bytes priv/static/adminfe/static/js/chunk-0778.b17650df.js | Bin 9756 -> 0 bytes .../adminfe/static/js/chunk-0778.b17650df.js.map | Bin 32393 -> 0 bytes priv/static/adminfe/static/js/chunk-0961.ef33e81b.js | Bin 5112 -> 0 bytes .../adminfe/static/js/chunk-0961.ef33e81b.js.map | Bin 19744 -> 0 bytes priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js | Bin 0 -> 21585 bytes .../adminfe/static/js/chunk-0cbc.43ff796f.js.map | Bin 0 -> 86326 bytes priv/static/adminfe/static/js/chunk-143c.fc1825bf.js | Bin 0 -> 13814 bytes .../adminfe/static/js/chunk-143c.fc1825bf.js.map | Bin 0 -> 37014 bytes priv/static/adminfe/static/js/chunk-1609.98da6b01.js | Bin 0 -> 10740 bytes .../adminfe/static/js/chunk-1609.98da6b01.js.map | Bin 0 -> 46790 bytes priv/static/adminfe/static/js/chunk-176e.c4995511.js | Bin 0 -> 10092 bytes .../adminfe/static/js/chunk-176e.c4995511.js.map | Bin 0 -> 32132 bytes priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js | Bin 30624 -> 0 bytes .../adminfe/static/js/chunk-22d2.a0cf7976.js.map | Bin 103450 -> 0 bytes priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js | Bin 24591 -> 0 bytes .../adminfe/static/js/chunk-3384.b2ebeeca.js.map | Bin 86706 -> 0 bytes priv/static/adminfe/static/js/chunk-43ca.3debeff7.js | Bin 0 -> 119060 bytes .../adminfe/static/js/chunk-43ca.3debeff7.js.map | Bin 0 -> 402101 bytes priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js | Bin 0 -> 5112 bytes .../adminfe/static/js/chunk-4e7e.91b5e73a.js.map | Bin 0 -> 19744 bytes priv/static/adminfe/static/js/chunk-5118.7c48ad58.js | Bin 0 -> 24606 bytes .../adminfe/static/js/chunk-5118.7c48ad58.js.map | Bin 0 -> 74431 bytes priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js | Bin 0 -> 24347 bytes .../adminfe/static/js/chunk-5882.7cbc4c1b.js.map | Bin 0 -> 81471 bytes priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js | Bin 14790 -> 0 bytes .../adminfe/static/js/chunk-6b68.fbc0f684.js.map | Bin 40172 -> 0 bytes priv/static/adminfe/static/js/chunk-6e81.3733ace2.js | Bin 2080 -> 0 bytes .../adminfe/static/js/chunk-6e81.3733ace2.js.map | Bin 9090 -> 0 bytes priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js | Bin 0 -> 2080 bytes .../adminfe/static/js/chunk-6e81.6efb01f4.js.map | Bin 0 -> 9090 bytes priv/static/adminfe/static/js/chunk-7506.a3364e53.js | Bin 0 -> 17041 bytes .../adminfe/static/js/chunk-7506.a3364e53.js.map | Bin 0 -> 58197 bytes priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js | Bin 10877 -> 0 bytes .../adminfe/static/js/chunk-7637.8f5fb36e.js.map | Bin 44563 -> 0 bytes priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js | Bin 0 -> 8606 bytes .../adminfe/static/js/chunk-7c6b.e63ae1da.js.map | Bin 0 -> 28838 bytes priv/static/adminfe/static/js/chunk-7e30.ec42e302.js | Bin 119434 -> 0 bytes .../adminfe/static/js/chunk-7e30.ec42e302.js.map | Bin 403603 -> 0 bytes priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js | Bin 24606 -> 0 bytes .../adminfe/static/js/chunk-7f9e.c49aa694.js.map | Bin 74431 -> 0 bytes priv/static/adminfe/static/js/chunk-970d.2457e066.js | Bin 26608 -> 0 bytes .../adminfe/static/js/chunk-970d.2457e066.js.map | Bin 100000 -> 0 bytes priv/static/adminfe/static/js/chunk-c5f4.304479e7.js | Bin 0 -> 23657 bytes .../adminfe/static/js/chunk-c5f4.304479e7.js.map | Bin 0 -> 83935 bytes .../static/adminfe/static/js/chunk-commons.5a106955.js | Bin 0 -> 9443 bytes .../adminfe/static/js/chunk-commons.5a106955.js.map | Bin 0 -> 33718 bytes priv/static/adminfe/static/js/chunk-d38a.a851004a.js | Bin 20205 -> 0 bytes .../adminfe/static/js/chunk-d38a.a851004a.js.map | Bin 81345 -> 0 bytes priv/static/adminfe/static/js/chunk-e404.554bc2e3.js | Bin 0 -> 19723 bytes .../adminfe/static/js/chunk-e404.554bc2e3.js.map | Bin 0 -> 75596 bytes priv/static/adminfe/static/js/chunk-e458.bb460d81.js | Bin 17199 -> 0 bytes .../adminfe/static/js/chunk-e458.bb460d81.js.map | Bin 57478 -> 0 bytes priv/static/adminfe/static/js/runtime.5bae86dc.js | Bin 0 -> 4229 bytes priv/static/adminfe/static/js/runtime.5bae86dc.js.map | Bin 0 -> 17240 bytes priv/static/adminfe/static/js/runtime.b08eb412.js | Bin 4032 -> 0 bytes priv/static/adminfe/static/js/runtime.b08eb412.js.map | Bin 16879 -> 0 bytes 93 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/app.6684eb28.css delete mode 100644 priv/static/adminfe/app.796ca6d4.css delete mode 100644 priv/static/adminfe/chunk-0558.af0d89cd.css create mode 100644 priv/static/adminfe/chunk-070d.d2dd6533.css delete mode 100644 priv/static/adminfe/chunk-0778.d9e7180a.css delete mode 100644 priv/static/adminfe/chunk-0961.d3692214.css create mode 100644 priv/static/adminfe/chunk-0cbc.60bba79b.css create mode 100644 priv/static/adminfe/chunk-143c.43ada4fc.css create mode 100644 priv/static/adminfe/chunk-1609.408dae86.css create mode 100644 priv/static/adminfe/chunk-176e.5d7d957b.css delete mode 100644 priv/static/adminfe/chunk-22d2.813009b9.css delete mode 100644 priv/static/adminfe/chunk-3384.d50ed383.css create mode 100644 priv/static/adminfe/chunk-43ca.0de86b6d.css create mode 100644 priv/static/adminfe/chunk-4e7e.5afe1978.css create mode 100644 priv/static/adminfe/chunk-5882.f65db7f2.css delete mode 100644 priv/static/adminfe/chunk-6b68.0cc00484.css delete mode 100644 priv/static/adminfe/chunk-6e81.0e80d020.css create mode 100644 priv/static/adminfe/chunk-6e81.ca3b222f.css create mode 100644 priv/static/adminfe/chunk-7506.f01f6c2a.css delete mode 100644 priv/static/adminfe/chunk-7637.941c4edb.css create mode 100644 priv/static/adminfe/chunk-7c6b.d9e7180a.css delete mode 100644 priv/static/adminfe/chunk-7e30.f2b9674a.css delete mode 100644 priv/static/adminfe/chunk-970d.f59cca8c.css create mode 100644 priv/static/adminfe/chunk-c5f4.0827b1ce.css create mode 100644 priv/static/adminfe/chunk-commons.7f6d2d11.css delete mode 100644 priv/static/adminfe/chunk-d38a.cabdc22e.css create mode 100644 priv/static/adminfe/chunk-e404.a56021ae.css delete mode 100644 priv/static/adminfe/chunk-e458.6c0703cb.css delete mode 100644 priv/static/adminfe/static/js/app.0146039c.js delete mode 100644 priv/static/adminfe/static/js/app.0146039c.js.map create mode 100644 priv/static/adminfe/static/js/app.3fcec8f6.js create mode 100644 priv/static/adminfe/static/js/app.3fcec8f6.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0558.75954137.js delete mode 100644 priv/static/adminfe/static/js/chunk-0558.75954137.js.map create mode 100644 priv/static/adminfe/static/js/chunk-070d.7e10a520.js create mode 100644 priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0778.b17650df.js delete mode 100644 priv/static/adminfe/static/js/chunk-0778.b17650df.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0961.ef33e81b.js delete mode 100644 priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js create mode 100644 priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map create mode 100644 priv/static/adminfe/static/js/chunk-143c.fc1825bf.js create mode 100644 priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map create mode 100644 priv/static/adminfe/static/js/chunk-1609.98da6b01.js create mode 100644 priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map create mode 100644 priv/static/adminfe/static/js/chunk-176e.c4995511.js create mode 100644 priv/static/adminfe/static/js/chunk-176e.c4995511.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js delete mode 100644 priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js delete mode 100644 priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map create mode 100644 priv/static/adminfe/static/js/chunk-43ca.3debeff7.js create mode 100644 priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js create mode 100644 priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-5118.7c48ad58.js create mode 100644 priv/static/adminfe/static/js/chunk-5118.7c48ad58.js.map create mode 100644 priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js create mode 100644 priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js delete mode 100644 priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-6e81.3733ace2.js delete mode 100644 priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map create mode 100644 priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js create mode 100644 priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7506.a3364e53.js create mode 100644 priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js delete mode 100644 priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js create mode 100644 priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7e30.ec42e302.js delete mode 100644 priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js delete mode 100644 priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-970d.2457e066.js delete mode 100644 priv/static/adminfe/static/js/chunk-970d.2457e066.js.map create mode 100644 priv/static/adminfe/static/js/chunk-c5f4.304479e7.js create mode 100644 priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-commons.5a106955.js create mode 100644 priv/static/adminfe/static/js/chunk-commons.5a106955.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-d38a.a851004a.js delete mode 100644 priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-e404.554bc2e3.js create mode 100644 priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-e458.bb460d81.js delete mode 100644 priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map create mode 100644 priv/static/adminfe/static/js/runtime.5bae86dc.js create mode 100644 priv/static/adminfe/static/js/runtime.5bae86dc.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.b08eb412.js delete mode 100644 priv/static/adminfe/static/js/runtime.b08eb412.js.map diff --git a/priv/static/adminfe/app.6684eb28.css b/priv/static/adminfe/app.6684eb28.css new file mode 100644 index 000000000..1b83a8a39 Binary files /dev/null and b/priv/static/adminfe/app.6684eb28.css differ diff --git a/priv/static/adminfe/app.796ca6d4.css b/priv/static/adminfe/app.796ca6d4.css deleted file mode 100644 index 1b83a8a39..000000000 Binary files a/priv/static/adminfe/app.796ca6d4.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0558.af0d89cd.css b/priv/static/adminfe/chunk-0558.af0d89cd.css deleted file mode 100644 index 30bf7de23..000000000 Binary files a/priv/static/adminfe/chunk-0558.af0d89cd.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-070d.d2dd6533.css b/priv/static/adminfe/chunk-070d.d2dd6533.css new file mode 100644 index 000000000..30bf7de23 Binary files /dev/null and b/priv/static/adminfe/chunk-070d.d2dd6533.css differ diff --git a/priv/static/adminfe/chunk-0778.d9e7180a.css b/priv/static/adminfe/chunk-0778.d9e7180a.css deleted file mode 100644 index 9d730019a..000000000 Binary files a/priv/static/adminfe/chunk-0778.d9e7180a.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0961.d3692214.css b/priv/static/adminfe/chunk-0961.d3692214.css deleted file mode 100644 index c0074e6f7..000000000 Binary files a/priv/static/adminfe/chunk-0961.d3692214.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0cbc.60bba79b.css b/priv/static/adminfe/chunk-0cbc.60bba79b.css new file mode 100644 index 000000000..c6280f7ef Binary files /dev/null and b/priv/static/adminfe/chunk-0cbc.60bba79b.css differ diff --git a/priv/static/adminfe/chunk-143c.43ada4fc.css b/priv/static/adminfe/chunk-143c.43ada4fc.css new file mode 100644 index 000000000..b580e0699 Binary files /dev/null and b/priv/static/adminfe/chunk-143c.43ada4fc.css differ diff --git a/priv/static/adminfe/chunk-1609.408dae86.css b/priv/static/adminfe/chunk-1609.408dae86.css new file mode 100644 index 000000000..483d88545 Binary files /dev/null and b/priv/static/adminfe/chunk-1609.408dae86.css differ diff --git a/priv/static/adminfe/chunk-176e.5d7d957b.css b/priv/static/adminfe/chunk-176e.5d7d957b.css new file mode 100644 index 000000000..0bedf3773 Binary files /dev/null and b/priv/static/adminfe/chunk-176e.5d7d957b.css differ diff --git a/priv/static/adminfe/chunk-22d2.813009b9.css b/priv/static/adminfe/chunk-22d2.813009b9.css deleted file mode 100644 index f0a98583e..000000000 Binary files a/priv/static/adminfe/chunk-22d2.813009b9.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-3384.d50ed383.css b/priv/static/adminfe/chunk-3384.d50ed383.css deleted file mode 100644 index 70ae2a26b..000000000 Binary files a/priv/static/adminfe/chunk-3384.d50ed383.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-43ca.0de86b6d.css b/priv/static/adminfe/chunk-43ca.0de86b6d.css new file mode 100644 index 000000000..817a6be44 Binary files /dev/null and b/priv/static/adminfe/chunk-43ca.0de86b6d.css differ diff --git a/priv/static/adminfe/chunk-4e7e.5afe1978.css b/priv/static/adminfe/chunk-4e7e.5afe1978.css new file mode 100644 index 000000000..c0074e6f7 Binary files /dev/null and b/priv/static/adminfe/chunk-4e7e.5afe1978.css differ diff --git a/priv/static/adminfe/chunk-5882.f65db7f2.css b/priv/static/adminfe/chunk-5882.f65db7f2.css new file mode 100644 index 000000000..b5e2a00b0 Binary files /dev/null and b/priv/static/adminfe/chunk-5882.f65db7f2.css differ diff --git a/priv/static/adminfe/chunk-6b68.0cc00484.css b/priv/static/adminfe/chunk-6b68.0cc00484.css deleted file mode 100644 index 7061b3d03..000000000 Binary files a/priv/static/adminfe/chunk-6b68.0cc00484.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-6e81.0e80d020.css b/priv/static/adminfe/chunk-6e81.0e80d020.css deleted file mode 100644 index da819ca09..000000000 Binary files a/priv/static/adminfe/chunk-6e81.0e80d020.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-6e81.ca3b222f.css b/priv/static/adminfe/chunk-6e81.ca3b222f.css new file mode 100644 index 000000000..da819ca09 Binary files /dev/null and b/priv/static/adminfe/chunk-6e81.ca3b222f.css differ diff --git a/priv/static/adminfe/chunk-7506.f01f6c2a.css b/priv/static/adminfe/chunk-7506.f01f6c2a.css new file mode 100644 index 000000000..93d3eac84 Binary files /dev/null and b/priv/static/adminfe/chunk-7506.f01f6c2a.css differ diff --git a/priv/static/adminfe/chunk-7637.941c4edb.css b/priv/static/adminfe/chunk-7637.941c4edb.css deleted file mode 100644 index be1d183a9..000000000 Binary files a/priv/static/adminfe/chunk-7637.941c4edb.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-7c6b.d9e7180a.css b/priv/static/adminfe/chunk-7c6b.d9e7180a.css new file mode 100644 index 000000000..9d730019a Binary files /dev/null and b/priv/static/adminfe/chunk-7c6b.d9e7180a.css differ diff --git a/priv/static/adminfe/chunk-7e30.f2b9674a.css b/priv/static/adminfe/chunk-7e30.f2b9674a.css deleted file mode 100644 index a4a56712e..000000000 Binary files a/priv/static/adminfe/chunk-7e30.f2b9674a.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-970d.f59cca8c.css b/priv/static/adminfe/chunk-970d.f59cca8c.css deleted file mode 100644 index 15511f12f..000000000 Binary files a/priv/static/adminfe/chunk-970d.f59cca8c.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-c5f4.0827b1ce.css b/priv/static/adminfe/chunk-c5f4.0827b1ce.css new file mode 100644 index 000000000..eb59ca31a Binary files /dev/null and b/priv/static/adminfe/chunk-c5f4.0827b1ce.css differ diff --git a/priv/static/adminfe/chunk-commons.7f6d2d11.css b/priv/static/adminfe/chunk-commons.7f6d2d11.css new file mode 100644 index 000000000..42f5e0ee9 Binary files /dev/null and b/priv/static/adminfe/chunk-commons.7f6d2d11.css differ diff --git a/priv/static/adminfe/chunk-d38a.cabdc22e.css b/priv/static/adminfe/chunk-d38a.cabdc22e.css deleted file mode 100644 index 4a2bf472b..000000000 Binary files a/priv/static/adminfe/chunk-d38a.cabdc22e.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-e404.a56021ae.css b/priv/static/adminfe/chunk-e404.a56021ae.css new file mode 100644 index 000000000..7d8596ef6 Binary files /dev/null and b/priv/static/adminfe/chunk-e404.a56021ae.css differ diff --git a/priv/static/adminfe/chunk-e458.6c0703cb.css b/priv/static/adminfe/chunk-e458.6c0703cb.css deleted file mode 100644 index 6d2a5d996..000000000 Binary files a/priv/static/adminfe/chunk-e458.6c0703cb.css and /dev/null differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 73e680115..c8f62d0c7 100644 --- a/priv/static/adminfe/index.html +++ b/priv/static/adminfe/index.html @@ -1 +1 @@ -<!DOCTYPE html><html><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge,chrome=1"><meta name=renderer content=webkit><meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"><title>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.0146039c.js b/priv/static/adminfe/static/js/app.0146039c.js deleted file mode 100644 index ab08475ad..000000000 Binary files a/priv/static/adminfe/static/js/app.0146039c.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.0146039c.js.map b/priv/static/adminfe/static/js/app.0146039c.js.map deleted file mode 100644 index 178715dc6..000000000 Binary files a/priv/static/adminfe/static/js/app.0146039c.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.3fcec8f6.js b/priv/static/adminfe/static/js/app.3fcec8f6.js new file mode 100644 index 000000000..9a6fb1307 Binary files /dev/null and b/priv/static/adminfe/static/js/app.3fcec8f6.js differ diff --git a/priv/static/adminfe/static/js/app.3fcec8f6.js.map b/priv/static/adminfe/static/js/app.3fcec8f6.js.map new file mode 100644 index 000000000..cc4ce87b3 Binary files /dev/null and b/priv/static/adminfe/static/js/app.3fcec8f6.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0558.75954137.js b/priv/static/adminfe/static/js/chunk-0558.75954137.js deleted file mode 100644 index 7b29707fa..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0558.75954137.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0558.75954137.js.map b/priv/static/adminfe/static/js/chunk-0558.75954137.js.map deleted file mode 100644 index e9e2affb6..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0558.75954137.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-070d.7e10a520.js b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js new file mode 100644 index 000000000..8726dbcd3 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js differ diff --git a/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map new file mode 100644 index 000000000..6b75a215e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0778.b17650df.js b/priv/static/adminfe/static/js/chunk-0778.b17650df.js deleted file mode 100644 index 1a174cc1e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0778.b17650df.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map b/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map deleted file mode 100644 index 1f96c3236..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0778.b17650df.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js b/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js deleted file mode 100644 index e090bb93c..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map b/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map deleted file mode 100644 index 97c6a4b54..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0961.ef33e81b.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js new file mode 100644 index 000000000..232f0d447 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map new file mode 100644 index 000000000..dbca0ba8e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js new file mode 100644 index 000000000..6fbc5b1ed Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js differ diff --git a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map new file mode 100644 index 000000000..425a7427a Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-1609.98da6b01.js b/priv/static/adminfe/static/js/chunk-1609.98da6b01.js new file mode 100644 index 000000000..29dbad261 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-1609.98da6b01.js differ diff --git a/priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map b/priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map new file mode 100644 index 000000000..f287a503a Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-176e.c4995511.js b/priv/static/adminfe/static/js/chunk-176e.c4995511.js new file mode 100644 index 000000000..80474b904 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-176e.c4995511.js differ diff --git a/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map b/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map new file mode 100644 index 000000000..f0caa5f62 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js deleted file mode 100644 index 903f553b0..000000000 Binary files a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map b/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map deleted file mode 100644 index 68735ed26..000000000 Binary files a/priv/static/adminfe/static/js/chunk-22d2.a0cf7976.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js deleted file mode 100644 index 6a161a0c6..000000000 Binary files a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map b/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map deleted file mode 100644 index b08db9d6e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-3384.b2ebeeca.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js b/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js new file mode 100644 index 000000000..6d653cf62 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map b/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map new file mode 100644 index 000000000..f7976891f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js new file mode 100644 index 000000000..0fdf0de50 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js differ diff --git a/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map new file mode 100644 index 000000000..7a6751cf8 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js b/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js new file mode 100644 index 000000000..2357e225d Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js differ diff --git a/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js.map b/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js.map new file mode 100644 index 000000000..c29b4b170 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-5118.7c48ad58.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js new file mode 100644 index 000000000..a29b6daab Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js differ diff --git a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map new file mode 100644 index 000000000..d1aa2037f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js deleted file mode 100644 index bfdf936f8..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map b/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map deleted file mode 100644 index d1d728b80..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6b68.fbc0f684.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js b/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js deleted file mode 100644 index c888ce03f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map b/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map deleted file mode 100644 index 63128dd67..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6e81.3733ace2.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js new file mode 100644 index 000000000..f40d31879 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map new file mode 100644 index 000000000..0390c3309 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js new file mode 100644 index 000000000..d4eaa356a Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js differ diff --git a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map new file mode 100644 index 000000000..c8e9db8e0 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js deleted file mode 100644 index b38644b98..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map b/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map deleted file mode 100644 index ddd53f1cd..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7637.8f5fb36e.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js new file mode 100644 index 000000000..27478ddb1 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map new file mode 100644 index 000000000..2114a3c52 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js deleted file mode 100644 index 0a0e1ca34..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map b/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map deleted file mode 100644 index bc47158ea..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7e30.ec42e302.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js b/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js deleted file mode 100644 index 9fb60af23..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js.map b/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js.map deleted file mode 100644 index 241c6cc21..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7f9e.c49aa694.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-970d.2457e066.js b/priv/static/adminfe/static/js/chunk-970d.2457e066.js deleted file mode 100644 index 0f99d835e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-970d.2457e066.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map b/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map deleted file mode 100644 index 6896407b0..000000000 Binary files a/priv/static/adminfe/static/js/chunk-970d.2457e066.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js new file mode 100644 index 000000000..4220621be Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map new file mode 100644 index 000000000..2ab89731d Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-commons.5a106955.js b/priv/static/adminfe/static/js/chunk-commons.5a106955.js new file mode 100644 index 000000000..a6cf2ce52 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-commons.5a106955.js differ diff --git a/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map b/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map new file mode 100644 index 000000000..d924490e5 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js deleted file mode 100644 index c302af310..000000000 Binary files a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map b/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map deleted file mode 100644 index 6779f6dc1..000000000 Binary files a/priv/static/adminfe/static/js/chunk-d38a.a851004a.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js new file mode 100644 index 000000000..769e9f4f9 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js differ diff --git a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map new file mode 100644 index 000000000..e8214adbb Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js deleted file mode 100644 index a08717166..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map b/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map deleted file mode 100644 index 89f05fb99..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e458.bb460d81.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.5bae86dc.js b/priv/static/adminfe/static/js/runtime.5bae86dc.js new file mode 100644 index 000000000..e5fb1554b Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.5bae86dc.js differ diff --git a/priv/static/adminfe/static/js/runtime.5bae86dc.js.map b/priv/static/adminfe/static/js/runtime.5bae86dc.js.map new file mode 100644 index 000000000..46c6380d9 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.5bae86dc.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.b08eb412.js b/priv/static/adminfe/static/js/runtime.b08eb412.js deleted file mode 100644 index 2a9a4e0db..000000000 Binary files a/priv/static/adminfe/static/js/runtime.b08eb412.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.b08eb412.js.map b/priv/static/adminfe/static/js/runtime.b08eb412.js.map deleted file mode 100644 index 62f70ee3e..000000000 Binary files a/priv/static/adminfe/static/js/runtime.b08eb412.js.map and /dev/null differ -- cgit v1.2.3 From d6c958b4c22ee7658ee8b7b11fc6ddede1082cca Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 26 Jun 2020 05:33:59 +0200 Subject: nodeinfo: Fix MRF transparency --- lib/pleroma/web/nodeinfo/nodeinfo.ex | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex index d26b7c938..f7ab6d86a 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -6,30 +6,19 @@ defmodule Pleroma.Web.Nodeinfo.Nodeinfo do alias Pleroma.Config alias Pleroma.Stats alias Pleroma.User - alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.Federator.Publisher + alias Pleroma.Web.MastodonAPI.InstanceView # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field # under software. def get_nodeinfo("2.0") do stats = Stats.get_stats() - quarantined = Config.get([:instance, :quarantined_instances], []) - staff_accounts = User.all_superusers() |> Enum.map(fn u -> u.ap_id end) - federation_response = - if Config.get([:instance, :mrf_transparency]) do - {:ok, data} = MRF.describe() - - data - |> Map.merge(%{quarantined_instances: quarantined}) - else - %{} - end - |> Map.put(:enabled, Config.get([:instance, :federating])) + federation = InstanceView.federation() features = [ @@ -86,7 +75,7 @@ def get_nodeinfo("2.0") do enabled: false }, staffAccounts: staff_accounts, - federation: federation_response, + federation: federation, pollLimits: Config.get([:instance, :poll_limits]), postFormats: Config.get([:instance, :allowed_post_formats]), uploadLimits: %{ -- cgit v1.2.3 From 27c33f216ad250b60d44fe0662c3be3c4cee987e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 26 Jun 2020 05:39:35 +0200 Subject: activity_draft: Add source field --- lib/pleroma/web/common_api/activity_draft.ex | 1 + test/web/common_api/common_api_test.exs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 9bcb9f587..f849b2e01 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -186,6 +186,7 @@ defp object(draft) do draft.poll ) |> Map.put("emoji", emoji) + |> Map.put("source", draft.status) %__MODULE__{draft | object: object} end diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 6bd26050e..cbdd994a9 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -445,6 +445,7 @@ test "it filters out obviously bad tags when accepting a post as HTML" do object = Object.normalize(activity) assert object.data["content"] == "

    2hu

    alert('xss')" + assert object.data["source"] == post end test "it filters out obviously bad tags when accepting a post as Markdown" do @@ -461,6 +462,7 @@ test "it filters out obviously bad tags when accepting a post as Markdown" do object = Object.normalize(activity) assert object.data["content"] == "

    2hu

    alert('xss')" + assert object.data["source"] == post end test "it does not allow replies to direct messages that are not direct messages themselves" do -- cgit v1.2.3 From 91cd023720dfea24cbb0d5a63db92e9773b59a04 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 26 Jun 2020 09:03:07 +0300 Subject: Pleroma.Upload.Filter.Mogrify args description --- config/description.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index f54ac2a2a..b13b7c9dd 100644 --- a/config/description.exs +++ b/config/description.exs @@ -193,7 +193,9 @@ %{ key: :args, type: [:string, {:list, :string}, {:list, :tuple}], - description: "List of actions for the mogrify command", + description: + "List of actions for the mogrify command. It's possible to add self-written settings as string. " <> + "For example `[\"auto-orient\", \"strip\", {\"resize\", \"3840x1080>\"}]` string will be parsed into list of the settings.", suggestions: [ "strip", "auto-orient", -- cgit v1.2.3 From c3383d4fab6181d9f605a6058805333611534398 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 11:58:40 +0200 Subject: BlockValidator: Restore old behavior for incoming blocks. --- .../web/activity_pub/object_validators/block_validator.ex | 13 +++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 1 - test/web/activity_pub/object_validator_test.exs | 8 +++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex index 1dde77198..1989585b7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -32,6 +33,7 @@ def validate_data(cng) do |> validate_inclusion(:type, ["Block"]) |> validate_actor_presence() |> validate_actor_presence(field_name: :object) + |> validate_block_acceptance() end def cast_and_validate(data) do @@ -39,4 +41,15 @@ def cast_and_validate(data) do |> cast_data |> validate_data end + + def validate_block_acceptance(cng) do + actor = get_field(cng, :actor) |> User.get_cached_by_ap_id() + + if actor.local || Pleroma.Config.get([:activitypub, :unfollow_blocked], true) do + cng + else + cng + |> add_error(:actor, "Not accepting remote blocks") + end + end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 48350d2b3..5cc2eb378 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -29,7 +29,6 @@ def handle( ) do with %User{} = blocker <- User.get_cached_by_ap_id(blocking_user), %User{} = blocked <- User.get_cached_by_ap_id(blocked_user) do - User.unfollow(blocker, blocked) User.block(blocker, blocked) end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index e96552763..a3d43ef3c 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -657,7 +657,7 @@ test "returns an error if the object can't be updated by the actor", %{ describe "blocks" do setup do - user = insert(:user) + user = insert(:user, local: false) blocked = insert(:user) {:ok, valid_block, []} = Builder.block(user, blocked) @@ -680,5 +680,11 @@ test "returns an error if we don't know the blocked user", %{ assert {:error, _cng} = ObjectValidator.validate(block, []) end + + test "returns an error if don't accept remote blocks", %{valid_block: valid_block} do + clear_config([:activitypub, :unfollow_blocked], false) + + assert {:error, _cng} = ObjectValidator.validate(valid_block, []) + end end end -- cgit v1.2.3 From 15a8b703185c685fc3d25a381fcb9dee522c78bf Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 12:06:00 +0200 Subject: User: Don't unfollow on block when the relevant setting is set. --- lib/pleroma/user.ex | 3 ++- .../activity_pub/object_validators/block_validator.ex | 13 ------------- test/web/activity_pub/object_validator_test.exs | 6 ------ test/web/activity_pub/side_effects_test.exs | 16 ++++++++++++++++ 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c3e2a89ad..9d5c61e79 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1309,7 +1309,8 @@ def block(%User{} = blocker, %User{} = blocked) do unsubscribe(blocked, blocker) - if following?(blocked, blocker), do: unfollow(blocked, blocker) + unfollowing_blocked = Config.get([:activitypub, :unfollow_blocked], true) + if unfollowing_blocked && following?(blocked, blocker), do: unfollow(blocked, blocker) {:ok, blocker} = update_follower_count(blocker) {:ok, blocker, _} = Participation.mark_all_as_read(blocker, blocked) diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex index 1989585b7..1dde77198 100644 --- a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -33,7 +32,6 @@ def validate_data(cng) do |> validate_inclusion(:type, ["Block"]) |> validate_actor_presence() |> validate_actor_presence(field_name: :object) - |> validate_block_acceptance() end def cast_and_validate(data) do @@ -41,15 +39,4 @@ def cast_and_validate(data) do |> cast_data |> validate_data end - - def validate_block_acceptance(cng) do - actor = get_field(cng, :actor) |> User.get_cached_by_ap_id() - - if actor.local || Pleroma.Config.get([:activitypub, :unfollow_blocked], true) do - cng - else - cng - |> add_error(:actor, "Not accepting remote blocks") - end - end end diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index a3d43ef3c..f38bf7e08 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -680,11 +680,5 @@ test "returns an error if we don't know the blocked user", %{ assert {:error, _cng} = ObjectValidator.validate(block, []) end - - test "returns an error if don't accept remote blocks", %{valid_block: valid_block} do - clear_config([:activitypub, :unfollow_blocked], false) - - assert {:error, _cng} = ObjectValidator.validate(valid_block, []) - end end end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 36792f015..af27c34b4 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -87,6 +87,22 @@ test "it unfollows and blocks", %{user: user, blocked: blocked, block: block} do refute User.following?(blocked, user) assert User.blocks?(user, blocked) end + + test "it blocks but does not unfollow if the relevant setting is set", %{ + user: user, + blocked: blocked, + block: block + } do + clear_config([:activitypub, :unfollow_blocked], false) + assert User.following?(user, blocked) + assert User.following?(blocked, user) + + {:ok, _, _} = SideEffects.handle(block) + + refute User.following?(user, blocked) + assert User.following?(blocked, user) + assert User.blocks?(user, blocked) + end end describe "update users" do -- cgit v1.2.3 From 7ed229641667f52dd82eb7c388ea28e79e09e507 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 13:04:15 +0200 Subject: Nodeinfo: Add chat information back in. --- lib/pleroma/web/nodeinfo/nodeinfo.ex | 30 +----------------------------- test/web/node_info_test.exs | 3 ++- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex index f7ab6d86a..47fa46376 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -19,35 +19,7 @@ def get_nodeinfo("2.0") do |> Enum.map(fn u -> u.ap_id end) federation = InstanceView.federation() - - features = - [ - "pleroma_api", - "mastodon_api", - "mastodon_api_streaming", - "polls", - "pleroma_explicit_addressing", - "shareable_emoji_packs", - "multifetch", - "pleroma:api/v1/notifications:include_types_filter", - if Config.get([:media_proxy, :enabled]) do - "media_proxy" - end, - if Config.get([:gopher, :enabled]) do - "gopher" - end, - if Config.get([:chat, :enabled]) do - "chat" - end, - if Config.get([:instance, :allow_relay]) do - "relay" - end, - if Config.get([:instance, :safe_dm_mentions]) do - "safe_dm_mentions" - end, - "pleroma_emoji_reactions" - ] - |> Enum.filter(& &1) + features = InstanceView.features() %{ version: "2.0", diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index 8b3b6177d..06b33607f 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -142,7 +142,8 @@ test "it shows default features flags", %{conn: conn} do "shareable_emoji_packs", "multifetch", "pleroma_emoji_reactions", - "pleroma:api/v1/notifications:include_types_filter" + "pleroma:api/v1/notifications:include_types_filter", + "pleroma_chat_messages" ] assert MapSet.subset?( -- cgit v1.2.3 From e7bc0273e5bb7fd8f9bafae453a574b1e579dc9c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 26 Jun 2020 15:22:08 +0300 Subject: additional data to MRF policies in descriptions --- config/description.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config/description.exs b/config/description.exs index b13b7c9dd..1fb0c3c41 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1383,6 +1383,7 @@ %{ group: :pleroma, key: :mrf_simple, + tab: :mrf, label: "MRF simple", type: :group, description: "Message Rewrite Facility", @@ -1448,6 +1449,7 @@ %{ group: :pleroma, key: :mrf_activity_expiration, + tab: :mrf, label: "MRF Activity Expiration Policy", type: :group, description: "Adds expiration to all local Create Note activities", @@ -1463,6 +1465,7 @@ %{ group: :pleroma, key: :mrf_subchain, + tab: :mrf, label: "MRF subchain", type: :group, description: @@ -1484,6 +1487,7 @@ %{ group: :pleroma, key: :mrf_rejectnonpublic, + tab: :mrf, description: "MRF RejectNonPublic settings. RejectNonPublic drops posts with non-public visibility settings.", label: "MRF reject non public", @@ -1505,6 +1509,7 @@ %{ group: :pleroma, key: :mrf_hellthread, + tab: :mrf, label: "MRF hellthread", type: :group, description: "Block messages with too much mentions", @@ -1529,6 +1534,7 @@ %{ group: :pleroma, key: :mrf_keyword, + tab: :mrf, label: "MRF keyword", type: :group, description: "Reject or Word-Replace messages with a keyword or regex", @@ -1574,6 +1580,7 @@ %{ group: :pleroma, key: :mrf_vocabulary, + tab: :mrf, label: "MRF vocabulary", type: :group, description: "Filter messages which belong to certain activity vocabularies", @@ -2832,6 +2839,7 @@ }, %{ group: :pleroma, + tab: :mrf, key: :mrf_normalize_markup, label: "MRF normalize markup", description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", @@ -3024,6 +3032,7 @@ %{ group: :pleroma, key: :mrf_object_age, + tab: :mrf, type: :group, description: "Rejects or delists posts based on their age when received.", children: [ @@ -3367,6 +3376,7 @@ %{ group: :pleroma, key: :mrf, + tab: :mrf, type: :group, description: "General MRF settings", children: [ -- cgit v1.2.3 From 4a7a34ae8c2ad12b2b9903c1d70bfe85d10af49e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 14:47:38 +0200 Subject: Preloading: Return correct data for statusnet stuff. --- lib/pleroma/web/preload/status_net.ex | 9 +++++---- test/web/preload/status_net_test.exs | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex index 367442d5c..810ad512b 100644 --- a/lib/pleroma/web/preload/status_net.ex +++ b/lib/pleroma/web/preload/status_net.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNet do alias Pleroma.Web.Preload.Providers.Provider - alias Pleroma.Web.TwitterAPI.UtilView + alias Pleroma.Web.TwitterAPI.UtilController @behaviour Provider @config_url :"/api/statusnet/config.json" @@ -16,9 +16,10 @@ def generate_terms(_params) do end defp build_config_tag(acc) do - instance = Pleroma.Config.get(:instance) - info_data = UtilView.status_net_config(instance) + resp = + Plug.Test.conn(:get, @config_url |> to_string()) + |> UtilController.config(nil) - Map.put(acc, @config_url, info_data) + Map.put(acc, @config_url, resp.resp_body) end end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs index ab6823a7e..2cdc82930 100644 --- a/test/web/preload/status_net_test.exs +++ b/test/web/preload/status_net_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNetTest do setup do: {:ok, StatusNet.generate_terms(nil)} test "it renders the info", %{"/api/statusnet/config.json": info} do - assert info =~ "Pleroma" + assert {:ok, res} = Jason.decode(info) + assert res["site"] end end -- cgit v1.2.3 From a2002ebb6393d53030d5fc565bae90f3fedd48a8 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 14:48:23 +0200 Subject: Preloading: Fix nodeinfo url. --- lib/pleroma/web/preload/instance.ex | 2 +- test/web/preload/instance_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 0b6fd3313..3b95fe403 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Preload.Providers.Instance do @behaviour Provider @instance_url :"/api/v1/instance" @panel_url :"/instance/panel.html" - @nodeinfo_url :"/nodeinfo/2.0" + @nodeinfo_url :"/nodeinfo/2.0.json" @impl Provider def generate_terms(_params) do diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 42a0d87bc..51b9dc549 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -25,7 +25,7 @@ test "it renders the panel", %{"/instance/panel.html": panel} do ) end - test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do + test "it renders the node_info", %{"/nodeinfo/2.0.json": nodeinfo} do %{ metadata: metadata, version: "2.0" -- cgit v1.2.3 From f378e93bf4ca4bc9547f242e76e6258e25852972 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 16:15:27 +0200 Subject: AccountController: Return scope in proper format. --- lib/pleroma/web/api_spec/operations/account_operation.ex | 4 ++-- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- test/web/mastodon_api/controllers/account_controller_test.exs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9bde8fc0d..d94dae374 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -446,13 +446,13 @@ defp create_response do properties: %{ token_type: %Schema{type: :string}, access_token: %Schema{type: :string}, - scope: %Schema{type: :array, items: %Schema{type: :string}}, + scope: %Schema{type: :string}, created_at: %Schema{type: :integer, format: :"date-time"} }, example: %{ "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", "created_at" => 1_585_918_714, - "scope" => ["read", "write", "follow", "push"], + "scope" => "read write follow push", "token_type" => "Bearer" } } diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7a88a847c..a87dddddf 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -104,7 +104,7 @@ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do json(conn, %{ token_type: "Bearer", access_token: token.token, - scope: app.scopes, + scope: app.scopes |> Enum.join(" "), created_at: Token.Utils.format_created_at(token) }) else diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index ebfcedd01..fcc1e792b 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -905,7 +905,7 @@ test "Account registration via Application", %{conn: conn} do %{ "access_token" => token, "created_at" => _created_at, - "scope" => _scope, + "scope" => ^scope, "token_type" => "Bearer" } = json_response_and_validate_schema(conn, 200) @@ -1067,7 +1067,7 @@ test "registration from trusted app" do assert %{ "access_token" => access_token, "created_at" => _, - "scope" => ["read", "write", "follow", "push"], + "scope" => "read write follow push", "token_type" => "Bearer" } = response @@ -1185,7 +1185,7 @@ test "creates an account and returns 200 if captcha is valid", %{conn: conn} do assert %{ "access_token" => access_token, "created_at" => _, - "scope" => ["read"], + "scope" => "read", "token_type" => "Bearer" } = conn -- cgit v1.2.3 From a5bbfa21a1fabe97bfff1cc80348d2944319f3ad Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 26 Jun 2020 16:27:39 +0200 Subject: StaticFE: Prioritize json in requests. --- lib/pleroma/plugs/static_fe_plug.ex | 11 +++++++---- test/web/static_fe/static_fe_controller_test.exs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex index 156e6788e..7c69b2dac 100644 --- a/lib/pleroma/plugs/static_fe_plug.ex +++ b/lib/pleroma/plugs/static_fe_plug.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Plugs.StaticFEPlug do def init(options), do: options def call(conn, _) do - if enabled?() and accepts_html?(conn) do + if enabled?() and requires_html?(conn) do conn |> StaticFEController.call(:show) |> halt() @@ -20,10 +20,13 @@ def call(conn, _) do defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) - defp accepts_html?(conn) do + defp requires_html?(conn) do case get_req_header(conn, "accept") do - [accept | _] -> String.contains?(accept, "text/html") - _ -> false + [accept | _] -> + !String.contains?(accept, "json") && String.contains?(accept, "text/html") + + _ -> + false end end end diff --git a/test/web/static_fe/static_fe_controller_test.exs b/test/web/static_fe/static_fe_controller_test.exs index a49ab002f..1598bf675 100644 --- a/test/web/static_fe/static_fe_controller_test.exs +++ b/test/web/static_fe/static_fe_controller_test.exs @@ -87,6 +87,20 @@ test "single notice page", %{conn: conn, user: user} do assert html =~ "testing a thing!" end + test "redirects to json if requested", %{conn: conn, user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "testing a thing!"}) + + conn = + conn + |> put_req_header( + "accept", + "Accept: application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", text/html" + ) + |> get("/notice/#{activity.id}") + + assert redirected_to(conn, 302) =~ activity.data["object"] + end + test "filters HTML tags", %{conn: conn} do user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{status: ""}) -- cgit v1.2.3 From fd5e797379155e5a85deb88dc79f8fbca483948e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 26 Jun 2020 11:24:28 -0500 Subject: Simplify notification filtering settings further --- CHANGELOG.md | 5 +-- docs/API/pleroma_api.md | 4 +- lib/pleroma/notification.ex | 28 ++------------ lib/pleroma/user/notification_setting.ex | 8 +--- lib/pleroma/web/api_spec/schemas/account.ex | 8 +--- ...28160439_users_update_notification_settings.exs | 43 ---------------------- test/notification_test.exs | 28 +------------- test/web/mastodon_api/views/account_view_test.exs | 4 +- test/web/twitter_api/util_controller_test.exs | 10 ++--- 9 files changed, 15 insertions(+), 123 deletions(-) delete mode 100644 priv/repo/migrations/20200528160439_users_update_notification_settings.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 82915dcfb..1d835fee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). API Changes - **Breaking:** Emoji API: changed methods and renamed routes. -- **Breaking:** Notification Settings API for suppressing notification - now supports the following controls: `from_followers`, `from_following`, - and `from_strangers`. +- **Breaking:** Notification Settings API for suppressing notifications + has been simplified down to `block_from_strangers`.
    diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 9ad1f5c1b..6d8a88a44 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -287,9 +287,7 @@ See [Admin-API](admin_api.md) * Method `PUT` * Authentication: required * Params: - * `from_followers`: BOOLEAN field, receives notifications from followers - * `from_following`: BOOLEAN field, receives notifications from people the user follows - * `from_strangers`: BOOLEAN field, receives notifications from people without an established relationship + * `block_from_strangers`: BOOLEAN field, blocks notifications from accounts you do not follow * `privacy_option`: BOOLEAN field. When set to true, it removes the contents of a message from the push notification. * Response: JSON. Returns `{"status": "success"}` if the update was successful, otherwise returns `{"error": "error_msg"}` diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 9d09cf082..8a28a1821 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -550,9 +550,7 @@ def skip?(%Activity{} = activity, %User{} = user) do [ :self, :invisible, - :from_followers, - :from_following, - :from_strangers, + :block_from_strangers, :recently_followed ] |> Enum.find(&skip?(&1, activity, user)) @@ -572,35 +570,15 @@ def skip?(:invisible, %Activity{} = activity, _) do end def skip?( - :from_followers, + :block_from_strangers, %Activity{} = activity, - %User{notification_settings: %{from_followers: false}} = user - ) do - actor = activity.data["actor"] - follower = User.get_cached_by_ap_id(actor) - User.following?(follower, user) - end - - def skip?( - :from_strangers, - %Activity{} = activity, - %User{notification_settings: %{from_strangers: false}} = user + %User{notification_settings: %{block_from_strangers: true}} = user ) do actor = activity.data["actor"] follower = User.get_cached_by_ap_id(actor) !User.following?(follower, user) end - def skip?( - :from_following, - %Activity{} = activity, - %User{notification_settings: %{from_following: false}} = user - ) do - actor = activity.data["actor"] - followed = User.get_cached_by_ap_id(actor) - User.following?(user, followed) - end - # 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"] diff --git a/lib/pleroma/user/notification_setting.ex b/lib/pleroma/user/notification_setting.ex index e47ac4cab..ffe9860de 100644 --- a/lib/pleroma/user/notification_setting.ex +++ b/lib/pleroma/user/notification_setting.ex @@ -10,18 +10,14 @@ defmodule Pleroma.User.NotificationSetting do @primary_key false embedded_schema do - field(:from_followers, :boolean, default: true) - field(:from_following, :boolean, default: true) - field(:from_strangers, :boolean, default: true) + field(:block_from_strangers, :boolean, default: false) field(:privacy_option, :boolean, default: false) end def changeset(schema, params) do schema |> cast(prepare_attrs(params), [ - :from_followers, - :from_following, - :from_strangers, + :block_from_strangers, :privacy_option ]) end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index ed90ef3db..91bb1ba88 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -57,9 +57,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do notification_settings: %Schema{ type: :object, properties: %{ - from_followers: %Schema{type: :boolean}, - from_following: %Schema{type: :boolean}, - from_strangers: %Schema{type: :boolean}, + block_from_strangers: %Schema{type: :boolean}, privacy_option: %Schema{type: :boolean} } }, @@ -122,9 +120,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "unread_conversation_count" => 0, "tags" => [], "notification_settings" => %{ - "from_followers" => true, - "from_following" => true, - "from_strangers" => true, + "block_from_strangers" => false, "privacy_option" => false }, "relationship" => %{ diff --git a/priv/repo/migrations/20200528160439_users_update_notification_settings.exs b/priv/repo/migrations/20200528160439_users_update_notification_settings.exs deleted file mode 100644 index 561f7a2c4..000000000 --- a/priv/repo/migrations/20200528160439_users_update_notification_settings.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Pleroma.Repo.Migrations.UsersUpdateNotificationSettings do - use Ecto.Migration - - def up do - execute( - "UPDATE users SET notification_settings = notification_settings - 'followers' || jsonb_build_object('from_followers', notification_settings->'followers') -where notification_settings ? 'followers' -and local" - ) - - execute( - "UPDATE users SET notification_settings = notification_settings - 'follows' || jsonb_build_object('from_following', notification_settings->'follows') -where notification_settings ? 'follows' -and local" - ) - - execute( - "UPDATE users SET notification_settings = notification_settings - 'non_followers' || jsonb_build_object('from_strangers', notification_settings->'non_followers') -where notification_settings ? 'non_followers' -and local" - ) - end - - def down do - execute( - "UPDATE users SET notification_settings = notification_settings - 'from_followers' || jsonb_build_object('followers', notification_settings->'from_followers') -where notification_settings ? 'from_followers' -and local" - ) - - execute( - "UPDATE users SET notification_settings = notification_settings - 'from_following' || jsonb_build_object('follows', notification_settings->'from_following') -where notification_settings ? 'from_following' -and local" - ) - - execute( - "UPDATE users SET notification_settings = notification_settings - 'from_strangers' || jsonb_build_object('non_follows', notification_settings->'from_strangers') -where notification_settings ? 'from_strangers' -and local" - ) - end -end diff --git a/test/notification_test.exs b/test/notification_test.exs index d7df9c36c..d8cb9360a 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -236,44 +236,18 @@ test "it creates a notification for an activity from a muted thread" do assert Notification.create_notification(activity, muter) end - test "it disables notifications from followers" do - follower = insert(:user) - - followed = - insert(:user, - notification_settings: %Pleroma.User.NotificationSetting{from_followers: false} - ) - - User.follow(follower, followed) - {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) - refute Notification.create_notification(activity, followed) - end - test "it disables notifications from strangers" do follower = insert(:user) followed = insert(:user, - notification_settings: %Pleroma.User.NotificationSetting{from_strangers: false} + notification_settings: %Pleroma.User.NotificationSetting{block_from_strangers: true} ) {:ok, activity} = CommonAPI.post(follower, %{status: "hey @#{followed.nickname}"}) refute Notification.create_notification(activity, followed) end - test "it disables notifications from people the user follows" do - follower = - insert(:user, - notification_settings: %Pleroma.User.NotificationSetting{from_following: false} - ) - - followed = insert(:user) - User.follow(follower, followed) - follower = Repo.get(User, follower.id) - {:ok, activity} = CommonAPI.post(followed, %{status: "hey @#{follower.nickname}"}) - refute Notification.create_notification(activity, follower) - end - test "it doesn't create a notification for user if he is the activity author" do activity = insert(:note_activity) author = User.get_cached_by_ap_id(activity.data["actor"]) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 572830194..b6d820b3f 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -96,9 +96,7 @@ test "Represent the user account for the account owner" do user = insert(:user) notification_settings = %{ - from_followers: true, - from_following: true, - from_strangers: true, + block_from_strangers: false, privacy_option: false } diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 1133107f4..da3f6fa61 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -191,7 +191,7 @@ test "it imports blocks with different nickname variations", %{conn: conn} do test "it updates notification settings", %{user: user, conn: conn} do conn |> put("/api/pleroma/notification_settings", %{ - "from_followers" => false, + "block_from_strangers" => true, "bar" => 1 }) |> json_response(:ok) @@ -199,9 +199,7 @@ test "it updates notification settings", %{user: user, conn: conn} do user = refresh_record(user) assert %Pleroma.User.NotificationSetting{ - from_followers: false, - from_following: true, - from_strangers: true, + block_from_strangers: true, privacy_option: false } == user.notification_settings end @@ -214,9 +212,7 @@ test "it updates notification privacy option", %{user: user, conn: conn} do user = refresh_record(user) assert %Pleroma.User.NotificationSetting{ - from_followers: true, - from_following: true, - from_strangers: true, + block_from_strangers: false, privacy_option: true } == user.notification_settings end -- cgit v1.2.3 From 69848d5c97c9e5d4c14fb5613eb174cb81d5026d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 26 Jun 2020 12:45:46 -0500 Subject: Rename notification "privacy_option" setting --- docs/API/pleroma_api.md | 2 +- lib/mix/tasks/pleroma/notification_settings.ex | 18 +++++++++--------- lib/pleroma/user/notification_setting.ex | 4 ++-- lib/pleroma/web/api_spec/schemas/account.ex | 4 ++-- lib/pleroma/web/push/impl.ex | 2 +- ...00626163359_rename_notification_privacy_option.exs | 19 +++++++++++++++++++ test/user/notification_setting_test.exs | 4 ++-- test/web/mastodon_api/views/account_view_test.exs | 2 +- test/web/push/impl_test.exs | 8 ++++---- test/web/twitter_api/util_controller_test.exs | 8 ++++---- 10 files changed, 45 insertions(+), 26 deletions(-) create mode 100644 priv/repo/migrations/20200626163359_rename_notification_privacy_option.exs diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 6d8a88a44..5bd38ad36 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -288,7 +288,7 @@ See [Admin-API](admin_api.md) * Authentication: required * Params: * `block_from_strangers`: BOOLEAN field, blocks notifications from accounts you do not follow - * `privacy_option`: BOOLEAN field. When set to true, it removes the contents of a message from the push notification. + * `hide_notification_contents`: BOOLEAN field. When set to true, it removes the contents of a message from the push notification. * Response: JSON. Returns `{"status": "success"}` if the update was successful, otherwise returns `{"error": "error_msg"}` ## `/api/pleroma/healthcheck` diff --git a/lib/mix/tasks/pleroma/notification_settings.ex b/lib/mix/tasks/pleroma/notification_settings.ex index 7d65f0587..00f5ba7bf 100644 --- a/lib/mix/tasks/pleroma/notification_settings.ex +++ b/lib/mix/tasks/pleroma/notification_settings.ex @@ -3,8 +3,8 @@ defmodule Mix.Tasks.Pleroma.NotificationSettings do @moduledoc """ Example: - > mix pleroma.notification_settings --privacy-option=false --nickname-users="parallel588" # set false only for parallel588 user - > mix pleroma.notification_settings --privacy-option=true # set true for all users + > mix pleroma.notification_settings --hide-notification-contents=false --nickname-users="parallel588" # set false only for parallel588 user + > mix pleroma.notification_settings --hide-notification-contents=true # set true for all users """ @@ -19,16 +19,16 @@ def run(args) do OptionParser.parse( args, strict: [ - privacy_option: :boolean, + hide_notification_contents: :boolean, email_users: :string, nickname_users: :string ] ) - privacy_option = Keyword.get(options, :privacy_option) + hide_notification_contents = Keyword.get(options, :hide_notification_contents) - if not is_nil(privacy_option) do - privacy_option + if not is_nil(hide_notification_contents) do + hide_notification_contents |> build_query(options) |> Pleroma.Repo.update_all([]) end @@ -36,15 +36,15 @@ def run(args) do shell_info("Done") end - defp build_query(privacy_option, options) do + defp build_query(hide_notification_contents, options) do query = from(u in Pleroma.User, update: [ set: [ notification_settings: fragment( - "jsonb_set(notification_settings, '{privacy_option}', ?)", - ^privacy_option + "jsonb_set(notification_settings, '{hide_notification_contents}', ?)", + ^hide_notification_contents ) ] ] diff --git a/lib/pleroma/user/notification_setting.ex b/lib/pleroma/user/notification_setting.ex index ffe9860de..7d9e8a000 100644 --- a/lib/pleroma/user/notification_setting.ex +++ b/lib/pleroma/user/notification_setting.ex @@ -11,14 +11,14 @@ defmodule Pleroma.User.NotificationSetting do embedded_schema do field(:block_from_strangers, :boolean, default: false) - field(:privacy_option, :boolean, default: false) + field(:hide_notification_contents, :boolean, default: false) end def changeset(schema, params) do schema |> cast(prepare_attrs(params), [ :block_from_strangers, - :privacy_option + :hide_notification_contents ]) end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 91bb1ba88..71d402b18 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -58,7 +58,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ block_from_strangers: %Schema{type: :boolean}, - privacy_option: %Schema{type: :boolean} + hide_notification_contents: %Schema{type: :boolean} } }, relationship: AccountRelationship, @@ -121,7 +121,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "tags" => [], "notification_settings" => %{ "block_from_strangers" => false, - "privacy_option" => false + "hide_notification_contents" => false }, "relationship" => %{ "blocked_by" => false, diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index cdb827e76..16368485e 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -104,7 +104,7 @@ def build_content(notification, actor, object, mastodon_type \\ nil) def build_content( %{ - user: %{notification_settings: %{privacy_option: true}} + user: %{notification_settings: %{hide_notification_contents: true}} } = notification, _actor, _object, diff --git a/priv/repo/migrations/20200626163359_rename_notification_privacy_option.exs b/priv/repo/migrations/20200626163359_rename_notification_privacy_option.exs new file mode 100644 index 000000000..06d7f7272 --- /dev/null +++ b/priv/repo/migrations/20200626163359_rename_notification_privacy_option.exs @@ -0,0 +1,19 @@ +defmodule Pleroma.Repo.Migrations.RenameNotificationPrivacyOption do + use Ecto.Migration + + def up do + execute( + "UPDATE users SET notification_settings = notification_settings - 'privacy_option' || jsonb_build_object('hide_notification_contents', notification_settings->'privacy_option') +where notification_settings ? 'privacy_option' +and local" + ) + end + + def down do + execute( + "UPDATE users SET notification_settings = notification_settings - 'hide_notification_contents' || jsonb_build_object('privacy_option', notification_settings->'hide_notification_contents') +where notification_settings ? 'hide_notification_contents' +and local" + ) + end +end diff --git a/test/user/notification_setting_test.exs b/test/user/notification_setting_test.exs index 95bca22c4..308da216a 100644 --- a/test/user/notification_setting_test.exs +++ b/test/user/notification_setting_test.exs @@ -8,11 +8,11 @@ defmodule Pleroma.User.NotificationSettingTest do alias Pleroma.User.NotificationSetting describe "changeset/2" do - test "sets valid privacy option" do + test "sets option to hide notification contents" do changeset = NotificationSetting.changeset( %NotificationSetting{}, - %{"privacy_option" => true} + %{"hide_notification_contents" => true} ) assert %Ecto.Changeset{valid?: true} = changeset diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index b6d820b3f..ce45cb9e9 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -97,7 +97,7 @@ test "Represent the user account for the account owner" do notification_settings = %{ block_from_strangers: false, - privacy_option: false + hide_notification_contents: false } privacy = user.default_scope diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index b48952b29..15de5e853 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -238,9 +238,9 @@ test "builds content for chat messages with no content" do } end - test "hides details for notifications when privacy option enabled" do + test "hides contents of notifications when option enabled" do user = insert(:user, nickname: "Bob") - user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: true}) + user2 = insert(:user, nickname: "Rob", notification_settings: %{hide_notification_contents: true}) {:ok, activity} = CommonAPI.post(user, %{ @@ -284,9 +284,9 @@ test "hides details for notifications when privacy option enabled" do } end - test "returns regular content for notifications with privacy option disabled" do + test "returns regular content when hiding contents option disabled" do user = insert(:user, nickname: "Bob") - user2 = insert(:user, nickname: "Rob", notification_settings: %{privacy_option: false}) + user2 = insert(:user, nickname: "Rob", notification_settings: %{hide_notification_contents: false}) {:ok, activity} = CommonAPI.post(user, %{ diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index da3f6fa61..b8ddadb50 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -200,20 +200,20 @@ test "it updates notification settings", %{user: user, conn: conn} do assert %Pleroma.User.NotificationSetting{ block_from_strangers: true, - privacy_option: false + hide_notification_contents: false } == user.notification_settings end - test "it updates notification privacy option", %{user: user, conn: conn} do + test "it updates notification settings to enable hiding contents", %{user: user, conn: conn} do conn - |> put("/api/pleroma/notification_settings", %{"privacy_option" => "1"}) + |> put("/api/pleroma/notification_settings", %{"hide_notification_contents" => "1"}) |> json_response(:ok) user = refresh_record(user) assert %Pleroma.User.NotificationSetting{ block_from_strangers: false, - privacy_option: true + hide_notification_contents: true } == user.notification_settings end end -- cgit v1.2.3 From 76313e81627f4563ba2d3bf9f7bb5e6b8a20975b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 26 Jun 2020 12:48:05 -0500 Subject: Document breaking change of hide_notification_details setting --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d835fee2..1d640f292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** Emoji API: changed methods and renamed routes. - **Breaking:** Notification Settings API for suppressing notifications has been simplified down to `block_from_strangers`. +- **Breaking:** Notification Settings API option for hiding push notification + contents has been renamed to `hide_notification_contents`
    -- cgit v1.2.3 From 244655e884130df6dccabc0d2d78d33857809a36 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 26 Jun 2020 07:16:24 +0200 Subject: MastoAPI: Show source field when deleting --- lib/pleroma/web/api_spec/operations/status_operation.ex | 2 +- lib/pleroma/web/api_spec/schemas/status.ex | 5 +++++ .../web/mastodon_api/controllers/status_controller.ex | 15 +++++++++++---- lib/pleroma/web/mastodon_api/views/status_view.ex | 1 + test/support/factory.ex | 1 + .../mastodon_api/controllers/status_controller_test.exs | 11 ++++++++--- test/web/mastodon_api/views/status_view_test.exs | 1 + 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 0b7fad793..5bd4619d5 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -84,7 +84,7 @@ def delete_operation do operationId: "StatusController.delete", parameters: [id_param()], responses: %{ - 200 => empty_object_response(), + 200 => status_response(), 403 => Operation.response("Forbidden", "application/json", ApiError), 404 => Operation.response("Not Found", "application/json", ApiError) } diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 8b87cb25b..a38b5b40f 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -62,6 +62,11 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do } }, content: %Schema{type: :string, format: :html, description: "HTML-encoded status content"}, + text: %Schema{ + type: :string, + description: "Original unformatted content in plain text", + nullable: true + }, created_at: %Schema{ type: :string, format: "date-time", diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 468b44b67..3f4c53437 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -200,11 +200,18 @@ def show(%{assigns: %{user: user}} = conn, %{id: id}) do @doc "DELETE /api/v1/statuses/:id" def delete(%{assigns: %{user: user}} = conn, %{id: id}) do - with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do - json(conn, %{}) + with %Activity{} = activity <- Activity.get_by_id_with_object(id), + render <- + try_render(conn, "show.json", + activity: activity, + for: user, + with_direct_conversation_id: true, + with_source: true + ), + {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + render else - {:error, :not_found} = e -> e - _e -> render_error(conn, :forbidden, "Can't delete this post") + _e -> {:error, :not_found} 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 2c49bedb3..4df47f584 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -333,6 +333,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} reblog: nil, card: card, content: content_html, + text: opts[:with_source] && object.data["source"], created_at: created_at, reblogs_count: announcement_count, replies_count: object.data["repliesCount"] || 0, diff --git a/test/support/factory.ex b/test/support/factory.ex index 6e22b66a4..af580021c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -67,6 +67,7 @@ def note_factory(attrs \\ %{}) do data = %{ "type" => "Note", "content" => text, + "source" => text, "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), "actor" => user.ap_id, "to" => ["https://www.w3.org/ns/activitystreams#Public"], diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index a98e939e8..fd2de8d80 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -760,13 +760,18 @@ test "if user is authenticated", %{local: local, remote: remote} do test "when you created it" do %{user: author, conn: conn} = oauth_access(["write:statuses"]) activity = insert(:note_activity, user: author) + object = Object.normalize(activity) - conn = + content = object.data["content"] + source = object.data["source"] + + result = conn |> assign(:user, author) |> delete("/api/v1/statuses/#{activity.id}") + |> json_response_and_validate_schema(200) - assert %{} = json_response_and_validate_schema(conn, 200) + assert match?(%{"content" => ^content, "text" => ^source}, result) refute Activity.get_by_id(activity.id) end @@ -789,7 +794,7 @@ test "when you didn't create it" do conn = delete(conn, "/api/v1/statuses/#{activity.id}") - assert %{"error" => _} = json_response_and_validate_schema(conn, 403) + assert %{"error" => "Record not found"} == json_response_and_validate_schema(conn, 404) assert Activity.get_by_id(activity.id) == activity end diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index 5cbadf0fc..b6ae4d343 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -183,6 +183,7 @@ test "a note activity" do card: nil, reblog: nil, content: HTML.filter_tags(object_data["content"]), + text: nil, created_at: created_at, reblogs_count: 0, replies_count: 0, -- cgit v1.2.3 From 1566543bec70e6497df77ed83bf4d3cc39c116eb Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 26 Jun 2020 20:10:47 +0200 Subject: object/fetcher: Pass full Transmogrifier error --- lib/pleroma/object/fetcher.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 263ded5dd..3e2949ee2 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -83,8 +83,8 @@ def fetch_object_from_id(id, options \\ []) do {:transmogrifier, {:error, {:reject, nil}}} -> {:reject, nil} - {:transmogrifier, _} -> - {:error, "Transmogrifier failure."} + {:transmogrifier, _} = e -> + {:error, e} {:object, data, nil} -> reinject_object(%Object{}, data) -- cgit v1.2.3 From ce85db41a30d95555bbd44d8931c4a3a357938d8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 26 Jun 2020 14:35:04 -0500 Subject: Lint --- test/web/push/impl_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/web/push/impl_test.exs b/test/web/push/impl_test.exs index 15de5e853..aeb5c1fbd 100644 --- a/test/web/push/impl_test.exs +++ b/test/web/push/impl_test.exs @@ -240,7 +240,9 @@ test "builds content for chat messages with no content" do test "hides contents of notifications when option enabled" do user = insert(:user, nickname: "Bob") - user2 = insert(:user, nickname: "Rob", notification_settings: %{hide_notification_contents: true}) + + user2 = + insert(:user, nickname: "Rob", notification_settings: %{hide_notification_contents: true}) {:ok, activity} = CommonAPI.post(user, %{ @@ -286,7 +288,9 @@ test "hides contents of notifications when option enabled" do test "returns regular content when hiding contents option disabled" do user = insert(:user, nickname: "Bob") - user2 = insert(:user, nickname: "Rob", notification_settings: %{hide_notification_contents: false}) + + user2 = + insert(:user, nickname: "Rob", notification_settings: %{hide_notification_contents: false}) {:ok, activity} = CommonAPI.post(user, %{ -- cgit v1.2.3 From f89390110b6b601fc505e63c3e36516d7ca96f5c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 27 Jun 2020 12:18:34 +0300 Subject: added tab & labels for mrf policies --- config/description.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/description.exs b/config/description.exs index 1fb0c3c41..e0c07bf78 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1565,6 +1565,7 @@ %{ group: :pleroma, key: :mrf_mention, + tab: :mrf, label: "MRF mention", type: :group, description: "Block messages which mention a user", @@ -3032,6 +3033,7 @@ %{ group: :pleroma, key: :mrf_object_age, + label: "MRF object age", tab: :mrf, type: :group, description: "Rejects or delists posts based on their age when received.", @@ -3377,6 +3379,7 @@ group: :pleroma, key: :mrf, tab: :mrf, + label: "MRF", type: :group, description: "General MRF settings", children: [ -- cgit v1.2.3 From 0313520cd2164e8abe671c7a0663246366ee30e9 Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 27 Jun 2020 12:18:37 +0200 Subject: Config: Reduce default preloaders to configuration endpoints. Fetching the timeline is a bit heavy to do by default. --- config/config.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 5b1c576e7..9b550920c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -437,8 +437,6 @@ config :pleroma, Pleroma.Web.Preload, providers: [ Pleroma.Web.Preload.Providers.Instance, - Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines, Pleroma.Web.Preload.Providers.StatusNet ] -- cgit v1.2.3 From efb5d64e5089ab59d8304f49f7c92fcab6e00b86 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 29 Jun 2020 02:39:26 +0200 Subject: differences_in_mastoapi_responses: Update account fields --- docs/API/differences_in_mastoapi_responses.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 7c3546f4f..c100ae83b 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -51,11 +51,14 @@ The `id` parameter can also be the `nickname` of the user. This only works in th Has these additional fields under the `pleroma` object: +- `ap_id`: nullable URL string, ActivityPub id of the user +- `background_image`: nullable URL string, background image of the user - `tags`: Lists an array of tags for the user -- `relationship{}`: Includes fields as documented for Mastodon API https://docs.joinmastodon.org/entities/relationship/ +- `relationship` (object): Includes fields as documented for Mastodon API https://docs.joinmastodon.org/entities/relationship/ - `is_moderator`: boolean, nullable, true if user is a moderator - `is_admin`: boolean, nullable, true if user is an admin - `confirmation_pending`: boolean, true if a new user account is waiting on email confirmation to be activated +- `hide_favorites`: boolean, true when the user has hiding favorites enabled - `hide_followers`: boolean, true when the user has follower hiding enabled - `hide_follows`: boolean, true when the user has follow hiding enabled - `hide_followers_count`: boolean, true when the user has follower stat hiding enabled @@ -66,6 +69,7 @@ Has these additional fields under the `pleroma` object: - `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. - `unread_notifications_count`: The count of unread notifications. Only returned to the account owner. +- `notification_settings`: object, can be absent. See `/api/pleroma/notification_settings` for the parameters/keys returned. ### Source -- cgit v1.2.3 From 9f51b03eed85d4a3ea24e1d449fcb4969f299096 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 29 Jun 2020 03:31:33 +0200 Subject: ApiSpec.Schemas.Account: import description from differences_in_mastoapi_responses --- lib/pleroma/web/api_spec/schemas/account.ex | 83 +++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index d54e2158d..84f18f1b6 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -40,20 +40,53 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do pleroma: %Schema{ type: :object, properties: %{ - allow_following_move: %Schema{type: :boolean}, - background_image: %Schema{type: :string, nullable: true}, + allow_following_move: %Schema{ + type: :boolean, + description: "whether the user allows automatically follow moved following accounts" + }, + background_image: %Schema{type: :string, nullable: true, format: :uri}, chat_token: %Schema{type: :string}, - confirmation_pending: %Schema{type: :boolean}, + confirmation_pending: %Schema{ + type: :boolean, + description: + "whether the user account is waiting on email confirmation to be activated" + }, hide_favorites: %Schema{type: :boolean}, - hide_followers_count: %Schema{type: :boolean}, - hide_followers: %Schema{type: :boolean}, - hide_follows_count: %Schema{type: :boolean}, - hide_follows: %Schema{type: :boolean}, - is_admin: %Schema{type: :boolean}, - is_moderator: %Schema{type: :boolean}, + hide_followers_count: %Schema{ + type: :boolean, + description: "whether the user has follower stat hiding enabled" + }, + hide_followers: %Schema{ + type: :boolean, + description: "whether the user has follower hiding enabled" + }, + hide_follows_count: %Schema{ + type: :boolean, + description: "whether the user has follow stat hiding enabled" + }, + hide_follows: %Schema{ + type: :boolean, + description: "whether the user has follow hiding enabled" + }, + is_admin: %Schema{ + type: :boolean, + description: "whether the user is an admin of the local instance" + }, + is_moderator: %Schema{ + type: :boolean, + description: "whether the user is a moderator of the local instance" + }, skip_thread_containment: %Schema{type: :boolean}, - tags: %Schema{type: :array, items: %Schema{type: :string}}, - unread_conversation_count: %Schema{type: :integer}, + tags: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "List of tags being used for things like extra roles or moderation(ie. marking all media as nsfw all)." + }, + unread_conversation_count: %Schema{ + type: :integer, + description: "The count of unread conversations. Only returned to the account owner." + }, notification_settings: %Schema{ type: :object, properties: %{ @@ -66,7 +99,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do }, relationship: AccountRelationship, settings_store: %Schema{ - type: :object + type: :object, + description: + "A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials`" } } }, @@ -74,16 +109,32 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, properties: %{ fields: %Schema{type: :array, items: AccountField}, - note: %Schema{type: :string}, + note: %Schema{ + type: :string, + description: + "Plaintext version of the bio without formatting applied by the backend, used for editing the bio." + }, privacy: VisibilityScope, sensitive: %Schema{type: :boolean}, pleroma: %Schema{ type: :object, properties: %{ actor_type: ActorType, - discoverable: %Schema{type: :boolean}, - no_rich_text: %Schema{type: :boolean}, - show_role: %Schema{type: :boolean} + discoverable: %Schema{ + type: :boolean, + description: + "whether the user allows discovery of the account in search results and other services." + }, + no_rich_text: %Schema{ + type: :boolean, + description: + "whether the HTML tags for rich-text formatting are stripped from all statuses requested from the API." + }, + show_role: %Schema{ + type: :boolean, + description: + "whether the user wants their role (e.g admin, moderator) to be shown" + } } } } -- cgit v1.2.3 From a19f8778afddb7f504b08cedde752e37da52dc96 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 11:06:20 +0200 Subject: User preloader: Put user info at correct key --- lib/pleroma/web/preload/user.ex | 11 ++++++----- test/web/preload/user_test.exs | 14 +++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex index 3a244845b..7fef0a4ac 100644 --- a/lib/pleroma/web/preload/user.ex +++ b/lib/pleroma/web/preload/user.ex @@ -3,11 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.User do + alias Pleroma.User alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @account_url :"/api/v1/accounts" + @account_url_base :"/api/v1/accounts" @impl Provider def generate_terms(%{user: user}) do @@ -16,10 +17,10 @@ def generate_terms(%{user: user}) do def generate_terms(_params), do: %{} - def build_accounts_tag(acc, nil), do: acc - - def build_accounts_tag(acc, user) do + def build_accounts_tag(acc, %User{} = user) do account_data = AccountView.render("show.json", %{user: user, for: user}) - Map.put(acc, @account_url, account_data) + Map.put(acc, :"#{@account_url_base}/#{user.id}", account_data) end + + def build_accounts_tag(acc, _), do: acc end diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs index 99232cdfa..68d69d977 100644 --- a/test/web/preload/user_test.exs +++ b/test/web/preload/user_test.exs @@ -9,13 +9,11 @@ defmodule Pleroma.Web.Preload.Providers.UserTest do describe "returns empty when user doesn't exist" do test "nil user specified" do - refute User.generate_terms(%{user: nil}) - |> Map.has_key?("/api/v1/accounts") + assert User.generate_terms(%{user: nil}) == %{} end test "missing user specified" do - refute User.generate_terms(%{user: :not_a_user}) - |> Map.has_key?("/api/v1/accounts") + assert User.generate_terms(%{user: :not_a_user}) == %{} end end @@ -23,11 +21,13 @@ test "missing user specified" do setup do user = insert(:user) - {:ok, User.generate_terms(%{user: user})} + terms = User.generate_terms(%{user: user}) + %{terms: terms, user: user} end - test "account is rendered", %{"/api/v1/accounts": accounts} do - assert %{acct: user, username: user} = accounts + test "account is rendered", %{terms: terms, user: user} do + account = terms[:"/api/v1/accounts/#{user.id}"] + assert %{acct: user, username: user} = account end end end -- cgit v1.2.3 From 8630a6c7f52a68ab32025b1c80a6398599908c68 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 11:41:00 +0200 Subject: Preloaders: Use strings as keys. --- lib/pleroma/web/preload/instance.ex | 6 +++--- lib/pleroma/web/preload/status_net.ex | 2 +- lib/pleroma/web/preload/timelines.ex | 2 +- lib/pleroma/web/preload/user.ex | 4 ++-- test/web/preload/instance_test.exs | 6 +++--- test/web/preload/status_net_test.exs | 2 +- test/web/preload/timeline_test.exs | 2 +- test/web/preload/user_test.exs | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 3b95fe403..b34d7cf37 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -8,9 +8,9 @@ defmodule Pleroma.Web.Preload.Providers.Instance do alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @instance_url :"/api/v1/instance" - @panel_url :"/instance/panel.html" - @nodeinfo_url :"/nodeinfo/2.0.json" + @instance_url "/api/v1/instance" + @panel_url "/instance/panel.html" + @nodeinfo_url "/nodeinfo/2.0.json" @impl Provider def generate_terms(_params) do diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex index 810ad512b..9b62f87a2 100644 --- a/lib/pleroma/web/preload/status_net.ex +++ b/lib/pleroma/web/preload/status_net.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNet do alias Pleroma.Web.TwitterAPI.UtilController @behaviour Provider - @config_url :"/api/statusnet/config.json" + @config_url "/api/statusnet/config.json" @impl Provider def generate_terms(_params) do diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index e531b8960..57de04051 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.Timelines do alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @public_url :"/api/v1/timelines/public" + @public_url "/api/v1/timelines/public" @impl Provider def generate_terms(params) do diff --git a/lib/pleroma/web/preload/user.ex b/lib/pleroma/web/preload/user.ex index 7fef0a4ac..b3d2e9b8d 100644 --- a/lib/pleroma/web/preload/user.ex +++ b/lib/pleroma/web/preload/user.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.User do alias Pleroma.Web.Preload.Providers.Provider @behaviour Provider - @account_url_base :"/api/v1/accounts" + @account_url_base "/api/v1/accounts" @impl Provider def generate_terms(%{user: user}) do @@ -19,7 +19,7 @@ def generate_terms(_params), do: %{} def build_accounts_tag(acc, %User{} = user) do account_data = AccountView.render("show.json", %{user: user, for: user}) - Map.put(acc, :"#{@account_url_base}/#{user.id}", account_data) + Map.put(acc, "#{@account_url_base}/#{user.id}", account_data) end def build_accounts_tag(acc, _), do: acc diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 51b9dc549..5bb6c5981 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.InstanceTest do setup do: {:ok, Instance.generate_terms(nil)} - test "it renders the info", %{"/api/v1/instance": info} do + test "it renders the info", %{"/api/v1/instance" => info} do assert %{ description: description, email: "admin@example.com", @@ -18,14 +18,14 @@ test "it renders the info", %{"/api/v1/instance": info} do assert String.equivalent?(description, "Pleroma: An efficient and flexible fediverse server") end - test "it renders the panel", %{"/instance/panel.html": panel} do + test "it renders the panel", %{"/instance/panel.html" => panel} do assert String.contains?( panel, "

    Welcome to Pleroma!

    " ) end - test "it renders the node_info", %{"/nodeinfo/2.0.json": nodeinfo} do + test "it renders the node_info", %{"/nodeinfo/2.0.json" => nodeinfo} do %{ metadata: metadata, version: "2.0" diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs index 2cdc82930..df7acdb11 100644 --- a/test/web/preload/status_net_test.exs +++ b/test/web/preload/status_net_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.Preload.Providers.StatusNetTest do setup do: {:ok, StatusNet.generate_terms(nil)} - test "it renders the info", %{"/api/statusnet/config.json": info} do + test "it renders the info", %{"/api/statusnet/config.json" => info} do assert {:ok, res} = Jason.decode(info) assert res["site"] end diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index da6a3aded..fea95a6a4 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Web.Preload.Providers.TimelineTest do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Preload.Providers.Timelines - @public_url :"/api/v1/timelines/public" + @public_url "/api/v1/timelines/public" describe "unauthenticated timeliness when restricted" do setup do diff --git a/test/web/preload/user_test.exs b/test/web/preload/user_test.exs index 68d69d977..83f065e27 100644 --- a/test/web/preload/user_test.exs +++ b/test/web/preload/user_test.exs @@ -26,7 +26,7 @@ test "missing user specified" do end test "account is rendered", %{terms: terms, user: user} do - account = terms[:"/api/v1/accounts/#{user.id}"] + account = terms["/api/v1/accounts/#{user.id}"] assert %{acct: user, username: user} = account end end -- cgit v1.2.3 From e64d08439ea171f1090e0bfa927f3c83cb9522b0 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 12:40:23 +0200 Subject: UpdateCredentialsTest: Add test for removing profile images. --- .../account_controller/update_credentials_test.exs | 47 ++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 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 f67d294ba..31f0edf97 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 @@ -216,10 +216,20 @@ test "updates the user's avatar", %{user: user, conn: conn} do filename: "an_image.jpg" } - conn = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["avatar"] != User.avatar_url(user) + + # Also removes it + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"avatar" => nil}) + + assert user_response = json_response_and_validate_schema(res, 200) + assert user_response["avatar"] == User.avatar_url(user) end test "updates the user's banner", %{user: user, conn: conn} do @@ -229,10 +239,21 @@ test "updates the user's banner", %{user: user, conn: conn} do filename: "an_image.jpg" } - conn = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header}) + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"header" => new_header}) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["header"] != User.banner_url(user) + + # Also removes it + + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{"header" => nil}) + + assert user_response = json_response_and_validate_schema(res, 200) + assert user_response["header"] == User.banner_url(user) end test "updates the user's background", %{conn: conn} do @@ -242,13 +263,25 @@ test "updates the user's background", %{conn: conn} do filename: "an_image.jpg" } - conn = - patch(conn, "/api/v1/accounts/update_credentials", %{ + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{ "pleroma_background_image" => new_header }) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["pleroma"]["background_image"] + + # Also removes it + + res = + conn + |> patch("/api/v1/accounts/update_credentials", %{ + "pleroma_background_image" => nil + }) + + assert user_response = json_response_and_validate_schema(res, 200) + refute user_response["pleroma"]["background_image"] end test "requires 'write:accounts' permission" do -- cgit v1.2.3 From bb168ed94a6b4d02879472e30149a494d7b7ebb5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 13:39:09 +0200 Subject: OAuth: Extract view-type functions to a view. --- lib/pleroma/web/oauth/mfa_controller.ex | 3 ++- lib/pleroma/web/oauth/mfa_view.ex | 9 +++++++ lib/pleroma/web/oauth/oauth_controller.ex | 18 +++++++------- lib/pleroma/web/oauth/oauth_view.ex | 22 +++++++++++++++++ lib/pleroma/web/oauth/token/response.ex | 39 ------------------------------- 5 files changed, 41 insertions(+), 50 deletions(-) diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex index 53e19f82e..f102c93e7 100644 --- a/lib/pleroma/web/oauth/mfa_controller.ex +++ b/lib/pleroma/web/oauth/mfa_controller.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.OAuth.MFAController do alias Pleroma.Web.Auth.TOTPAuthenticator alias Pleroma.Web.OAuth.MFAView, as: View alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.OAuth.OAuthView alias Pleroma.Web.OAuth.Token plug(:fetch_session when action in [:show, :verify]) @@ -74,7 +75,7 @@ def challenge(conn, %{"mfa_token" => mfa_token} = params) do {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token), {:ok, _} <- validates_challenge(user, params), {:ok, token} <- Token.exchange_token(app, auth) do - json(conn, Token.Response.build(user, token)) + json(conn, OAuthView.render("token.json", %{user: user, token: token})) else _error -> conn diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex index 41d5578dc..5d87db268 100644 --- a/lib/pleroma/web/oauth/mfa_view.ex +++ b/lib/pleroma/web/oauth/mfa_view.ex @@ -5,4 +5,13 @@ defmodule Pleroma.Web.OAuth.MFAView do use Pleroma.Web, :view import Phoenix.HTML.Form + alias Pleroma.MFA + + def render("mfa_response.json", %{token: token, user: user}) do + %{ + error: "mfa_required", + mfa_token: token.token, + supported_challenge_types: MFA.supported_methods(user) + } + end end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index c557778ca..3da104933 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper - alias Pleroma.Maps alias Pleroma.MFA + alias Pleroma.Maps alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration alias Pleroma.Repo @@ -17,6 +17,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.MFAController + alias Pleroma.Web.OAuth.OAuthView + alias Pleroma.Web.OAuth.MFAView alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken @@ -233,9 +235,7 @@ def token_exchange( with {:ok, app} <- Token.Utils.fetch_app(conn), {:ok, %{user: user} = token} <- Token.get_by_refresh_token(app, token), {:ok, token} <- RefreshToken.grant(token) do - response_attrs = %{created_at: Token.Utils.format_created_at(token)} - - json(conn, Token.Response.build(user, token, response_attrs)) + json(conn, OAuthView.render("token.json", %{user: user, token: token})) else _error -> render_invalid_credentials_error(conn) end @@ -247,9 +247,7 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"} {:ok, auth} <- Authorization.get_by_token(app, fixed_token), %User{} = user <- User.get_cached_by_id(auth.user_id), {:ok, token} <- Token.exchange_token(app, auth) do - response_attrs = %{created_at: Token.Utils.format_created_at(token)} - - json(conn, Token.Response.build(user, token, response_attrs)) + json(conn, OAuthView.render("token.json", %{user: user, token: token})) else error -> handle_token_exchange_error(conn, error) @@ -267,7 +265,7 @@ def token_exchange( {:ok, auth} <- Authorization.create_authorization(app, user, scopes), {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)}, {:ok, token} <- Token.exchange_token(app, auth) do - json(conn, Token.Response.build(user, token)) + json(conn, OAuthView.render("token.json", %{user: user, token: token})) else error -> handle_token_exchange_error(conn, error) @@ -290,7 +288,7 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"} with {:ok, app} <- Token.Utils.fetch_app(conn), {:ok, auth} <- Authorization.create_authorization(app, %User{}), {:ok, token} <- Token.exchange_token(app, auth) do - json(conn, Token.Response.build_for_client_credentials(token)) + json(conn, OAuthView.render("token.json", %{token: token})) else _error -> handle_token_exchange_error(conn, :invalid_credentails) @@ -548,7 +546,7 @@ defp put_session_registration_id(%Plug.Conn{} = conn, registration_id), defp build_and_response_mfa_token(user, auth) do with {:ok, token} <- MFA.Token.create_token(user, auth) do - Token.Response.build_for_mfa_token(user, token) + MFAView.render("mfa_response.json", %{token: token, user: user}) end end diff --git a/lib/pleroma/web/oauth/oauth_view.ex b/lib/pleroma/web/oauth/oauth_view.ex index 94ddaf913..f55247ebd 100644 --- a/lib/pleroma/web/oauth/oauth_view.ex +++ b/lib/pleroma/web/oauth/oauth_view.ex @@ -5,4 +5,26 @@ defmodule Pleroma.Web.OAuth.OAuthView do use Pleroma.Web, :view import Phoenix.HTML.Form + + alias Pleroma.Web.OAuth.Token.Utils + + def render("token.json", %{token: token} = opts) do + response = %{ + token_type: "Bearer", + access_token: token.token, + refresh_token: token.refresh_token, + expires_in: expires_in(), + scope: Enum.join(token.scopes, " "), + created_at: Utils.format_created_at(token) + } + + if user = opts[:user] do + response + |> Map.put(:me, user.ap_id) + else + response + end + end + + defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex index 0e72c31e9..a12a6865c 100644 --- a/lib/pleroma/web/oauth/token/response.ex +++ b/lib/pleroma/web/oauth/token/response.ex @@ -3,43 +3,4 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.OAuth.Token.Response do - @moduledoc false - - alias Pleroma.MFA - alias Pleroma.User - alias Pleroma.Web.OAuth.Token.Utils - - @doc false - def build(%User{} = user, token, opts \\ %{}) do - %{ - token_type: "Bearer", - access_token: token.token, - refresh_token: token.refresh_token, - expires_in: expires_in(), - scope: Enum.join(token.scopes, " "), - me: user.ap_id - } - |> Map.merge(opts) - end - - def build_for_client_credentials(token) do - %{ - token_type: "Bearer", - access_token: token.token, - refresh_token: token.refresh_token, - created_at: Utils.format_created_at(token), - expires_in: expires_in(), - scope: Enum.join(token.scopes, " ") - } - end - - def build_for_mfa_token(user, mfa_token) do - %{ - error: "mfa_required", - mfa_token: mfa_token.token, - supported_challenge_types: MFA.supported_methods(user) - } - end - - defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600) end -- cgit v1.2.3 From e374872fe7d10aa659723ee31003f3e9188edfdd Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 13:49:48 +0200 Subject: AccountOperation: Correctly describe create response. --- lib/pleroma/web/api_spec/operations/account_operation.ex | 11 +++++++++-- .../web/mastodon_api/controllers/account_controller.ex | 8 ++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index d94dae374..f3ffa1ad4 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -438,6 +438,7 @@ defp create_request do } end + # TODO: This is actually a token respone, but there's no oauth operation file yet. defp create_response do %Schema{ title: "AccountCreateResponse", @@ -446,14 +447,20 @@ defp create_response do properties: %{ token_type: %Schema{type: :string}, access_token: %Schema{type: :string}, + refresh_token: %Schema{type: :string}, scope: %Schema{type: :string}, - created_at: %Schema{type: :integer, format: :"date-time"} + created_at: %Schema{type: :integer, format: :"date-time"}, + me: %Schema{type: :string}, + expires_in: %Schema{type: :integer} }, example: %{ + "token_type" => "Bearer", "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", + "refresh_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzz", "created_at" => 1_585_918_714, + "expires_in" => 600, "scope" => "read write follow push", - "token_type" => "Bearer" + "me" => "https://gensokyo.2hu/users/raymoo" } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index a87dddddf..a143675ec 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -28,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.OAuthView alias Pleroma.Web.TwitterAPI.TwitterAPI plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -101,12 +102,7 @@ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do :ok <- TwitterAPI.validate_captcha(app, params), {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do - json(conn, %{ - token_type: "Bearer", - access_token: token.token, - scope: app.scopes |> Enum.join(" "), - created_at: Token.Utils.format_created_at(token) - }) + json(conn, OAuthView.render("token.json", %{user: user, token: token})) else {:error, error} -> json_response(conn, :bad_request, %{error: error}) end -- cgit v1.2.3 From f308196b7528fab92b3cfba12ea71c464e2f9ab0 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 13:52:50 +0200 Subject: Token Response: Remove empty file. --- lib/pleroma/web/oauth/token/response.ex | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 lib/pleroma/web/oauth/token/response.ex diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex deleted file mode 100644 index a12a6865c..000000000 --- a/lib/pleroma/web/oauth/token/response.ex +++ /dev/null @@ -1,6 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.OAuth.Token.Response do -end -- cgit v1.2.3 From 59540131c189afb10faf98d1bfeccf8f94985a90 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 14:09:03 +0200 Subject: Credo fixes. --- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/web/oauth/oauth_controller.ex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index a143675ec..2942ed336 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -27,8 +27,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.OAuthView + alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI plug(Pleroma.Web.ApiSpec.CastAndValidate) diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 3da104933..7683589cf 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do use Pleroma.Web, :controller alias Pleroma.Helpers.UriHelper - alias Pleroma.MFA alias Pleroma.Maps + alias Pleroma.MFA alias Pleroma.Plugs.RateLimiter alias Pleroma.Registration alias Pleroma.Repo @@ -17,8 +17,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.MFAController - alias Pleroma.Web.OAuth.OAuthView alias Pleroma.Web.OAuth.MFAView + alias Pleroma.Web.OAuth.OAuthView alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken -- cgit v1.2.3 From 6512ef6879a5f857f02479da1bad7242e916d918 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 29 Jun 2020 15:25:57 +0300 Subject: excluding attachment links from RichMedia --- lib/pleroma/html.ex | 2 +- test/html_test.exs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index d78c5f202..dc1b9b840 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -109,7 +109,7 @@ def extract_first_external_url(object, content) do result = content |> Floki.parse_fragment!() - |> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"]") + |> Floki.filter_out("a.mention,a.hashtag,a.attachment,a[rel~=\"tag\"]") |> Floki.attribute("a", "href") |> Enum.at(0) diff --git a/test/html_test.exs b/test/html_test.exs index 0a4b4ebbc..f8907c8b4 100644 --- a/test/html_test.exs +++ b/test/html_test.exs @@ -237,5 +237,19 @@ test "does not crash when there is an HTML entity in a link" do assert {:ok, nil} = HTML.extract_first_external_url(object, object.data["content"]) end + + test "skips attachment links" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: + "image.png" + }) + + object = Object.normalize(activity) + + assert {:ok, nil} = HTML.extract_first_external_url(object, object.data["content"]) + end end end -- cgit v1.2.3 From 8693e01799308295011a39c8fab71f8a49d3a9bd Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 29 Jun 2020 16:29:51 +0400 Subject: Fix warning --- lib/pleroma/web/twitter_api/controllers/util_controller.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 4ec523a4e..76f4bb8f4 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -14,7 +14,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.User alias Pleroma.Web.CommonAPI - alias Pleroma.Web.TwitterAPI.UtilView alias Pleroma.Web.WebFinger plug(Pleroma.Web.FederatingPlug when action == :remote_subscribe) -- cgit v1.2.3 From 67d92ac7b7b977debac8f8e580db1f0e1ef3ed52 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 29 Jun 2020 17:00:37 +0400 Subject: Remove `/statusnet/config` --- lib/pleroma/web/router.ex | 3 --- lib/pleroma/web/twitter_api/controllers/util_controller.ex | 12 ------------ 2 files changed, 15 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 930bf7314..9eee74e6c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -517,9 +517,6 @@ defmodule Pleroma.Web.Router do pipe_through(:config) get("/pleroma/frontend_configurations", TwitterAPI.UtilController, :frontend_configurations) - - # Deprecated - get("/statusnet/config", TwitterAPI.UtilController, :config) end scope "/api", Pleroma.Web do diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 76f4bb8f4..8314e75b4 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -81,18 +81,6 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ end end - # Deprecated in favor of `/nodeinfo` - # https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2327 - # https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1084 - def config(conn, _params) do - json(conn, %{ - site: %{ - textlimit: to_string(Config.get([:instance, :limit])), - vapidPublicKey: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) - } - }) - end - def frontend_configurations(conn, _params) do config = Pleroma.Config.get(:frontend_configurations, %{}) -- cgit v1.2.3 From dc60b1ee583e59ab1a6808700b45992a41fecd8f Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Mon, 29 Jun 2020 16:22:54 +0300 Subject: updated swoosh --- mix.exs | 5 ++++- mix.lock | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index b638be541..e2ab53bde 100644 --- a/mix.exs +++ b/mix.exs @@ -159,7 +159,10 @@ defp deps do {:cors_plug, "~> 1.5"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, {:web_push_encryption, "~> 0.2.1"}, - {:swoosh, "~> 0.23.2"}, + {:swoosh, + git: "https://github.com/swoosh/swoosh", + ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", + override: true}, {:phoenix_swoosh, "~> 0.2"}, {:gen_smtp, "~> 0.13"}, {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}, diff --git a/mix.lock b/mix.lock index 5ad49391d..4f2777fa7 100644 --- a/mix.lock +++ b/mix.lock @@ -104,9 +104,9 @@ "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "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"}, + "swoosh": {:git, "https://github.com/swoosh/swoosh", "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", [ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5"]}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, - "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "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"}, -- cgit v1.2.3 From 979f02ec947443835f480d13bd1dbcf521743a71 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 29 Jun 2020 17:33:00 +0400 Subject: Fix CastAndValidate plug --- lib/pleroma/web/api_spec/cast_and_validate.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index bd9026237..fbfc27d6f 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -40,7 +40,7 @@ def call(%{private: %{open_api_spex: private_data}} = conn, %{ |> List.first() _ -> - nil + "application/json" end private_data = Map.put(private_data, :operation_id, operation_id) -- cgit v1.2.3 From 3aa04b81c4d558dfa8d3c35ab7db6041671ac121 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 29 Jun 2020 19:47:04 +0400 Subject: Test default "content-type" for CastAndValidate --- test/web/mastodon_api/controllers/account_controller_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index ebfcedd01..260ad2306 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -780,7 +780,6 @@ test "with notifications", %{conn: conn} do assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = conn - |> put_req_header("content-type", "application/json") |> post("/api/v1/accounts/#{other_user.id}/mute") |> json_response_and_validate_schema(200) -- cgit v1.2.3 From 90083a754dc0bfe0c8a04fbaa3e78f68a848035e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 17:48:18 +0200 Subject: Notifications: Never return `nil` in the notification list. --- lib/pleroma/notification.ex | 1 + test/notification_test.exs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 9ee9606be..58dcf880a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -367,6 +367,7 @@ defp do_create_notifications(%Activity{} = activity, options) do do_send = do_send && user in enabled_receivers create_notification(activity, user, do_send) end) + |> Enum.filter(& &1) {:ok, notifications} end diff --git a/test/notification_test.exs b/test/notification_test.exs index 526f43fab..5389dabca 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -21,7 +21,19 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer + # TODO: Test there's no nil notifications + describe "create_notifications" do + test "never returns nil" do + user = insert(:user) + other_user = insert(:user, %{invisible: true}) + + {:ok, activity} = CommonAPI.post(user, %{status: "yeah"}) + {:ok, activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕") + + refute {:ok, [nil]} == Notification.create_notifications(activity) + end + test "creates a notification for an emoji reaction" do user = insert(:user) other_user = insert(:user) -- cgit v1.2.3 From c01f4ca07f3a3e47fb6532c55128c427fbc1f77e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 17:52:56 +0200 Subject: Notification: Remove TODO. --- test/notification_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 5389dabca..6add3f7eb 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -21,8 +21,6 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.Push alias Pleroma.Web.Streamer - # TODO: Test there's no nil notifications - describe "create_notifications" do test "never returns nil" do user = insert(:user) -- cgit v1.2.3 From 09c5991f82e91878a940f5957ac993e1fca72545 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 29 Jun 2020 16:04:14 +0000 Subject: Apply suggestion to lib/pleroma/notification.ex --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 58dcf880a..2ef1a80c5 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -367,7 +367,7 @@ defp do_create_notifications(%Activity{} = activity, options) do do_send = do_send && user in enabled_receivers create_notification(activity, user, do_send) end) - |> Enum.filter(& &1) + |> Enum.reject(&is_nil/1) {:ok, notifications} end -- cgit v1.2.3 From 27542f19c60589d8deb5d9d7a59d2019b75026fa Mon Sep 17 00:00:00 2001 From: normandy Date: Tue, 30 Jun 2020 03:12:30 +0000 Subject: Use correct PostgreSQL version command in bug template --- .gitlab/issue_templates/Bug.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 66fbc510e..9ce9b6918 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -14,7 +14,7 @@ * Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): * Elixir version (`elixir -v` for from source installations, N/A for OTP): * Operating system: -* PostgreSQL version (`postgres -V`): +* PostgreSQL version (`psql -V`): ### Bug description -- cgit v1.2.3 From 2382a2a1511e1042d960946aacfde7a49fac9dd0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 30 Jun 2020 11:35:54 +0200 Subject: Preload: Load the correct instance panel --- lib/pleroma/web/preload/instance.ex | 3 ++- test/fixtures/preload_static/instance/panel.html | 1 + test/web/preload/instance_test.exs | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/preload_static/instance/panel.html diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 0b6fd3313..5c6e33e47 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.Preload.Providers.Instance do alias Pleroma.Web.MastodonAPI.InstanceView alias Pleroma.Web.Nodeinfo.Nodeinfo alias Pleroma.Web.Preload.Providers.Provider + alias Pleroma.Plugs.InstanceStatic @behaviour Provider @instance_url :"/api/v1/instance" @@ -27,7 +28,7 @@ defp build_info_tag(acc) do end defp build_panel_tag(acc) do - instance_path = Path.join(:code.priv_dir(:pleroma), "static/instance/panel.html") + instance_path = InstanceStatic.file_path(@panel_url |> to_string()) if File.exists?(instance_path) do panel_data = File.read!(instance_path) diff --git a/test/fixtures/preload_static/instance/panel.html b/test/fixtures/preload_static/instance/panel.html new file mode 100644 index 000000000..fc58e4e93 --- /dev/null +++ b/test/fixtures/preload_static/instance/panel.html @@ -0,0 +1 @@ +HEY! diff --git a/test/web/preload/instance_test.exs b/test/web/preload/instance_test.exs index 42a0d87bc..df150d7be 100644 --- a/test/web/preload/instance_test.exs +++ b/test/web/preload/instance_test.exs @@ -25,6 +25,17 @@ test "it renders the panel", %{"/instance/panel.html": panel} do ) end + test "it works with overrides" do + clear_config([:instance, :static_dir], "test/fixtures/preload_static") + + %{"/instance/panel.html": panel} = Instance.generate_terms(nil) + + assert String.contains?( + panel, + "HEY!" + ) + end + test "it renders the node_info", %{"/nodeinfo/2.0": nodeinfo} do %{ metadata: metadata, -- cgit v1.2.3 From 8b7055e25e76565cd3376c0b5dda5e54d24881f0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 30 Jun 2020 11:55:58 +0200 Subject: Credo fixes --- lib/pleroma/web/preload/instance.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/preload/instance.ex b/lib/pleroma/web/preload/instance.ex index 3d16f290b..50d1f3382 100644 --- a/lib/pleroma/web/preload/instance.ex +++ b/lib/pleroma/web/preload/instance.ex @@ -3,10 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Preload.Providers.Instance do + alias Pleroma.Plugs.InstanceStatic alias Pleroma.Web.MastodonAPI.InstanceView alias Pleroma.Web.Nodeinfo.Nodeinfo alias Pleroma.Web.Preload.Providers.Provider - alias Pleroma.Plugs.InstanceStatic @behaviour Provider @instance_url "/api/v1/instance" -- cgit v1.2.3 From d69af7f74290a67c9201782b7d4bafa29b6e0bd8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 30 Jun 2020 11:50:53 -0500 Subject: Rename user.settings column This is used exclusively by MastoFE/GlitchFE now --- lib/pleroma/user.ex | 6 +++--- lib/pleroma/web/views/masto_fe_view.ex | 2 +- .../migrations/20200630162024_rename_user_settings_col.exs | 11 +++++++++++ test/web/masto_fe_controller_test.exs | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 priv/repo/migrations/20200630162024_rename_user_settings_col.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9d5c61e79..8a54546d6 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -115,7 +115,7 @@ defmodule Pleroma.User do field(:is_moderator, :boolean, default: false) field(:is_admin, :boolean, default: false) field(:show_role, :boolean, default: true) - field(:settings, :map, default: nil) + field(:mastofe_settings, :map, default: nil) field(:uri, ObjectValidators.Uri, default: nil) field(:hide_followers_count, :boolean, default: false) field(:hide_follows_count, :boolean, default: false) @@ -2118,8 +2118,8 @@ def mascot_update(user, url) do def mastodon_settings_update(user, settings) do user - |> cast(%{settings: settings}, [:settings]) - |> validate_required([:settings]) + |> cast(%{mastofe_settings: settings}, [:mastofe_settings]) + |> validate_required([:mastofe_settings]) |> update_and_set_cache() end diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex index c3096006e..f739dacb6 100644 --- a/lib/pleroma/web/views/masto_fe_view.ex +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -86,7 +86,7 @@ def initial_state(token, user, custom_emojis) do "video\/mp4" ] }, - settings: user.settings || @default_settings, + settings: user.mastofe_settings || @default_settings, push_subscription: nil, accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)}, custom_emojis: render(CustomEmojiView, "index.json", custom_emojis: custom_emojis), diff --git a/priv/repo/migrations/20200630162024_rename_user_settings_col.exs b/priv/repo/migrations/20200630162024_rename_user_settings_col.exs new file mode 100644 index 000000000..2355eb681 --- /dev/null +++ b/priv/repo/migrations/20200630162024_rename_user_settings_col.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.RenameUserSettingsCol do + use Ecto.Migration + + def up do + rename(table(:users), :settings, to: :mastofe_settings) + end + + def down do + rename(table(:users), :mastofe_settings, to: :settings) + end +end diff --git a/test/web/masto_fe_controller_test.exs b/test/web/masto_fe_controller_test.exs index 1d107d56c..f3b54b5f2 100644 --- a/test/web/masto_fe_controller_test.exs +++ b/test/web/masto_fe_controller_test.exs @@ -24,7 +24,7 @@ test "put settings", %{conn: conn} do assert _result = json_response(conn, 200) user = User.get_cached_by_ap_id(user.ap_id) - assert user.settings == %{"programming" => "socks"} + assert user.mastofe_settings == %{"programming" => "socks"} end describe "index/2 redirections" do -- cgit v1.2.3 From 3d2989278c2f97fb5247d0b58b99b77f400f3185 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 30 Jun 2020 21:26:39 +0300 Subject: [#1892] Excluded bot actors (applications, services) from search results. --- lib/pleroma/user/search.ex | 5 +++++ test/user_search_test.exs | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index cec59c372..0293c6ae7 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -52,6 +52,7 @@ defp search_query(query_string, for_user, following) do |> base_query(following) |> filter_blocked_user(for_user) |> filter_invisible_users() + |> filter_bots() |> filter_blocked_domains(for_user) |> fts_search(query_string) |> trigram_rank(query_string) @@ -109,6 +110,10 @@ defp filter_invisible_users(query) do from(q in query, where: q.invisible == false) end + defp filter_bots(query) do + from(q in query, where: q.actor_type not in ["Application", "Service"]) + end + defp filter_blocked_user(query, %User{} = blocker) do query |> join(:left, [u], b in Pleroma.UserRelationship, diff --git a/test/user_search_test.exs b/test/user_search_test.exs index 17c63322a..9a74b9764 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -17,7 +17,7 @@ defmodule Pleroma.UserSearchTest do describe "User.search" do setup do: clear_config([:instance, :limit_to_local_content]) - test "excluded invisible users from results" do + test "excludes invisible users from results" do user = insert(:user, %{nickname: "john t1000"}) insert(:user, %{invisible: true, nickname: "john t800"}) @@ -25,6 +25,13 @@ test "excluded invisible users from results" do assert found_user.id == user.id end + test "excludes bots from results" do + insert(:user, actor_type: "Service", nickname: "bot1") + insert(:user, actor_type: "Application", nickname: "bot2") + + assert [] = User.search("bot") + end + test "accepts limit parameter" do Enum.each(0..4, &insert(:user, %{nickname: "john#{&1}"})) assert length(User.search("john", limit: 3)) == 3 -- cgit v1.2.3 From 5a8e0208b1cfb1995353b83338f20dc5cca195e1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 30 Jun 2020 15:25:10 -0500 Subject: Add fields limits to instance metadata, add tests --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 12 +++++++++++- .../mastodon_api/controllers/instance_controller_test.exs | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 118678962..347480d49 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -38,7 +38,8 @@ def render("show.json", _) do metadata: %{ account_activation_required: Keyword.get(instance, :account_activation_required), features: features(), - federation: federation() + federation: federation(), + fields_limits: fields_limits() }, vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) } @@ -89,4 +90,13 @@ def federation do end |> Map.put(:enabled, Config.get([:instance, :federating])) end + + def fields_limits do + %{ + maxFields: Config.get([:instance, :max_account_fields]), + maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), + nameLength: Config.get([:instance, :account_field_name_length]), + valueLength: Config.get([:instance, :account_field_value_length]) + } + end end diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 8bdfdddd1..95ee26416 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -35,8 +35,10 @@ test "get instance information", %{conn: conn} do "background_image" => _ } = result + assert result["pleroma"]["metadata"]["account_activation_required"] != nil assert result["pleroma"]["metadata"]["features"] assert result["pleroma"]["metadata"]["federation"] + assert result["pleroma"]["metadata"]["fields_limits"] assert result["pleroma"]["vapid_public_key"] assert email == from_config_email -- cgit v1.2.3 From 8daacc911498d827fd68ea3d34eb1be9ae4a1ffe Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 23 Jun 2020 14:17:23 -0500 Subject: AutoLinker --> Linkify, update to latest version https://git.pleroma.social/pleroma/elixir-libraries/linkify --- CHANGELOG.md | 1 + config/config.exs | 18 ++++++++---------- config/description.exs | 26 +++++++++++++++++--------- docs/configuration/cheatsheet.md | 35 +++++++++++++++++------------------ lib/pleroma/config/config_db.ex | 2 +- lib/pleroma/formatter.ex | 26 +++++++++++++++----------- lib/pleroma/web/rich_media/helpers.ex | 4 ++-- mix.exs | 4 +--- mix.lock | 2 +- 9 files changed, 63 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71963d206..4d3bda99e 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/). - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. +- **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated.
    API Changes diff --git a/config/config.exs b/config/config.exs index 5aad26e95..a74a6a5ba 100644 --- a/config/config.exs +++ b/config/config.exs @@ -520,16 +520,14 @@ federator_outgoing: 5 ] -config :auto_linker, - opts: [ - extra: true, - # TODO: Set to :no_scheme when it works properly - validate_tld: true, - class: false, - strip_prefix: false, - new_window: false, - rel: "ugc" - ] +config :pleroma, Pleroma.Formatter, + class: false, + rel: "ugc", + new_window: false, + truncate: false, + strip_prefix: false, + extra: true, + validate_tld: :no_scheme config :pleroma, :ldap, enabled: System.get_env("LDAP_ENABLED") == "true", diff --git a/config/description.exs b/config/description.exs index f54ac2a2a..204de8324 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2157,44 +2157,52 @@ ] }, %{ - group: :auto_linker, - key: :opts, + group: :pleroma, + key: Pleroma.Formatter, type: :group, - description: "Configuration for the auto_linker library", + description: + "Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs.", children: [ %{ key: :class, type: [:string, false], - description: "Specify the class to be added to the generated link. Disable to clear", + description: "Specify the class to be added to the generated link. Disable to clear.", suggestions: ["auto-linker", false] }, %{ key: :rel, type: [:string, false], - description: "Override the rel attribute. Disable to clear", + description: "Override the rel attribute. Disable to clear.", suggestions: ["ugc", "noopener noreferrer", false] }, %{ key: :new_window, type: :boolean, - description: "Link urls will open in new window/tab" + description: "Link URLs will open in new window/tab." }, %{ key: :truncate, type: [:integer, false], description: - "Set to a number to truncate urls longer then the number. Truncated urls will end in `..`", + "Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...`", suggestions: [15, false] }, %{ key: :strip_prefix, type: :boolean, - description: "Strip the scheme prefix" + description: "Strip the scheme prefix." }, %{ key: :extra, type: :boolean, - description: "Link urls with rarely used schemes (magnet, ipfs, irc, etc.)" + description: "Link URLs with rarely used schemes (magnet, ipfs, irc, etc.)" + }, + %{ + key: :validate_tld, + type: [:atom, :boolean], + description: + "Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for URLs without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't)", + suggestions: [:no_scheme, true] } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6759d5e93..22b28d423 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -908,30 +908,29 @@ Configure OAuth 2 provider capabilities: ### :uri_schemes * `valid_schemes`: List of the scheme part that is considered valid to be an URL. -### :auto_linker +### Pleroma.Formatter -Configuration for the `auto_linker` library: +Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs. -* `class: "auto-linker"` - specify the class to be added to the generated link. false to clear. -* `rel: "noopener noreferrer"` - override the rel attribute. false to clear. -* `new_window: true` - set to false to remove `target='_blank'` attribute. -* `scheme: false` - Set to true to link urls with schema `http://google.com`. -* `truncate: false` - Set to a number to truncate urls longer then the number. Truncated urls will end in `..`. -* `strip_prefix: true` - Strip the scheme prefix. -* `extra: false` - link urls with rarely used schemes (magnet, ipfs, irc, etc.). +* `class` - specify the class to be added to the generated link (default: `false`) +* `rel` - specify the rel attribute (default: `ugc`) +* `new_window` - adds `target="_blank"` attribute (default: `false`) +* `truncate` - Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...` (default: `false`) +* `strip_prefix` - Strip the scheme prefix (default: `false`) +* `extra` - link URLs with rarely used schemes (magnet, ipfs, irc, etc.) (default: `true`) +* `validate_tld` - Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for urls without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't) (default: `:no_scheme`) Example: ```elixir -config :auto_linker, - opts: [ - scheme: true, - extra: true, - class: false, - strip_prefix: false, - new_window: false, - rel: "ugc" - ] +config :pleroma, Pleroma.Formatter, + class: false, + rel: "ugc", + new_window: false, + truncate: false, + strip_prefix: false, + extra: true, + validate_tld: :no_scheme ``` ## Custom Runtime Modules (`:modules`) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 1a89d8895..f8141ced8 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -156,7 +156,7 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do {:quack, :meta}, {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, - {:auto_linker, :opts}, + {:linkify, :opts}, {:swarm, :node_blacklist}, {:logger, :backends} ] diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 02a93a8dc..0c450eae4 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -10,11 +10,15 @@ defmodule Pleroma.Formatter do @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ - @auto_linker_config hashtag: true, - hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, - mention: true, - mention_handler: &Pleroma.Formatter.mention_handler/4, - scheme: true + defp linkify_opts do + Pleroma.Config.get(Pleroma.Formatter) ++ + [ + hashtag: true, + hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, + mention: true, + mention_handler: &Pleroma.Formatter.mention_handler/4 + ] + end def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do case User.get_cached_by_nickname(nickname) do @@ -80,19 +84,19 @@ def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do @spec linkify(String.t(), keyword()) :: {String.t(), [{String.t(), User.t()}], [{String.t(), String.t()}]} def linkify(text, options \\ []) do - options = options ++ @auto_linker_config + options = linkify_opts() ++ options if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) acc = %{mentions: MapSet.new(), tags: MapSet.new()} - {text_mentions, %{mentions: mentions}} = AutoLinker.link_map(mentions, acc, options) - {text_rest, %{tags: tags}} = AutoLinker.link_map(rest, acc, options) + {text_mentions, %{mentions: mentions}} = Linkify.link_map(mentions, acc, options) + {text_rest, %{tags: tags}} = Linkify.link_map(rest, acc, options) {text_mentions <> text_rest, MapSet.to_list(mentions), MapSet.to_list(tags)} else acc = %{mentions: MapSet.new(), tags: MapSet.new()} - {text, %{mentions: mentions, tags: tags}} = AutoLinker.link_map(text, acc, options) + {text, %{mentions: mentions, tags: tags}} = Linkify.link_map(text, acc, options) {text, MapSet.to_list(mentions), MapSet.to_list(tags)} end @@ -111,9 +115,9 @@ def mentions_escape(text, options \\ []) do if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) - AutoLinker.link(mentions, options) <> AutoLinker.link(rest, options) + Linkify.link(mentions, options) <> Linkify.link(rest, options) else - AutoLinker.link(text, options) + Linkify.link(text, options) end end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 1729141e9..747f2dc6b 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Web.RichMedia.Helpers do @spec validate_page_url(URI.t() | binary()) :: :ok | :error defp validate_page_url(page_url) when is_binary(page_url) do - validate_tld = Application.get_env(:auto_linker, :opts)[:validate_tld] + validate_tld = Pleroma.Config.get([Pleroma.Formatter, :validate_tld]) page_url - |> AutoLinker.Parser.url?(scheme: true, validate_tld: validate_tld) + |> Linkify.Parser.url?(validate_tld: validate_tld) |> parse_uri(page_url) end diff --git a/mix.exs b/mix.exs index b638be541..c773a3162 100644 --- a/mix.exs +++ b/mix.exs @@ -167,9 +167,7 @@ defp deps do {:floki, "~> 0.25"}, {:timex, "~> 3.5"}, {:ueberauth, "~> 0.4"}, - {:auto_linker, - git: "https://git.pleroma.social/pleroma/auto_linker.git", - ref: "95e8188490e97505c56636c1379ffdf036c1fdde"}, + {:linkify, "~> 0.1.0"}, {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, diff --git a/mix.lock b/mix.lock index 5ad49391d..458cda6cf 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,5 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, - "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, @@ -62,6 +61,7 @@ "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, + "linkify": {:hex, :linkify, "0.1.0", "a2d35de64271c7fbbc7d8773adb9f595592b7fbaa581271c7733f39d3058bfa4", [:mix], [], "hexpm", "d3140ef8dbdcc53ef93a6a5374c11fffe0189f00d132161e9d020a417780bee7"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, -- cgit v1.2.3 From df2d6564d5cf7ad292a784c69ce17f9f37db993a Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Wed, 1 Jul 2020 03:01:15 +0300 Subject: Add labels, update descriptions and labels --- config/description.exs | 149 ++++++++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 57 deletions(-) diff --git a/config/description.exs b/config/description.exs index ded30e204..b34961c49 100644 --- a/config/description.exs +++ b/config/description.exs @@ -40,12 +40,13 @@ key: :link_name, type: :boolean, description: - "If enabled, a name parameter will be added to the url of the upload. For example `https://instance.tld/media/imagehash.png?name=realname.png`." + "If enabled, a name parameter will be added to the URL of the upload. For example `https://instance.tld/media/imagehash.png?name=realname.png`." }, %{ key: :base_url, + label: "Base URL", type: :string, - description: "Base url for the uploads, needed if you use CDN", + description: "Base URL for the uploads, needed if you use CDN", suggestions: [ "https://cdn-host.com" ] @@ -58,6 +59,7 @@ }, %{ key: :proxy_opts, + label: "Proxy Options", type: :keyword, description: "Options for Pleroma.ReverseProxy", suggestions: [ @@ -85,6 +87,7 @@ }, %{ key: :http, + label: "HTTP", type: :keyword, description: "HTTP options", children: [ @@ -479,6 +482,7 @@ %{ group: :pleroma, key: :uri_schemes, + label: "URI Schemes", type: :group, description: "URI schemes related settings", children: [ @@ -651,17 +655,17 @@ key: :invites_enabled, type: :boolean, description: - "Enable user invitations for admins (depends on `registrations_open` being disabled)." + "Enable user invitations for admins (depends on `registrations_open` being disabled)" }, %{ key: :account_activation_required, type: :boolean, - description: "Require users to confirm their emails before signing in." + description: "Require users to confirm their emails before signing in" }, %{ key: :federating, type: :boolean, - description: "Enable federation with other instances." + description: "Enable federation with other instances" }, %{ key: :federation_incoming_replies_max_depth, @@ -679,7 +683,7 @@ label: "Fed. reachability timeout days", type: :integer, description: - "Timeout (in days) of each external federation target being unreachable prior to pausing federating to it.", + "Timeout (in days) of each external federation target being unreachable prior to pausing federating to it", suggestions: [ 7 ] @@ -801,6 +805,7 @@ }, %{ key: :safe_dm_mentions, + label: "Safe DM mentions", type: :boolean, description: "If enabled, only mentions at the beginning of a post will be used to address people in direct messages." <> @@ -840,7 +845,7 @@ %{ key: :skip_thread_containment, type: :boolean, - description: "Skip filtering out broken threads. Default: enabled" + description: "Skip filtering out broken threads. Default: enabled." }, %{ key: :limit_to_local_content, @@ -904,6 +909,7 @@ children: [ %{ key: :totp, + label: "TOTP settings", type: :keyword, description: "TOTP settings", suggestions: [digits: 6, period: 30], @@ -920,7 +926,7 @@ type: :integer, suggestions: [30], description: - "a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds." + "A period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds." } ] }, @@ -934,7 +940,7 @@ key: :number, type: :integer, suggestions: [5], - description: "number of backup codes to generate." + description: "Number of backup codes to generate." }, %{ key: :length, @@ -974,6 +980,7 @@ group: :logger, type: :group, key: :ex_syslogger, + label: "ExSyslogger", description: "ExSyslogger-related settings", children: [ %{ @@ -992,7 +999,7 @@ %{ key: :format, type: :string, - description: "Default: \"$date $time [$level] $levelpad$node $metadata $message\".", + description: "Default: \"$date $time [$level] $levelpad$node $metadata $message\"", suggestions: ["$metadata[$level] $message"] }, %{ @@ -1006,6 +1013,7 @@ group: :logger, type: :group, key: :console, + label: "Console Logger", description: "Console logger settings", children: [ %{ @@ -1017,7 +1025,7 @@ %{ key: :format, type: :string, - description: "Default: \"$date $time [$level] $levelpad$node $metadata $message\".", + description: "Default: \"$date $time [$level] $levelpad$node $metadata $message\"", suggestions: ["$metadata[$level] $message"] }, %{ @@ -1030,6 +1038,7 @@ %{ group: :quack, type: :group, + label: "Quack Logger", description: "Quack-related settings", children: [ %{ @@ -1140,19 +1149,19 @@ key: :greentext, label: "Greentext", type: :boolean, - description: "Enables green text on lines prefixed with the > character." + description: "Enables green text on lines prefixed with the > character" }, %{ key: :hideFilteredStatuses, label: "Hide Filtered Statuses", type: :boolean, - description: "Hides filtered statuses from timelines." + description: "Hides filtered statuses from timelines" }, %{ key: :hideMutedPosts, label: "Hide Muted Posts", type: :boolean, - description: "Hides muted statuses from timelines." + description: "Hides muted statuses from timelines" }, %{ key: :hidePostStats, @@ -1164,7 +1173,7 @@ key: :hideSitename, label: "Hide Sitename", type: :boolean, - description: "Hides instance name from PleromaFE banner." + description: "Hides instance name from PleromaFE banner" }, %{ key: :hideUserStats, @@ -1209,14 +1218,14 @@ label: "NSFW Censor Image", type: :string, description: - "URL of the image to use for hiding NSFW media attachments in the timeline.", + "URL of the image to use for hiding NSFW media attachments in the timeline", suggestions: ["/static/img/nsfw.74818f9.png"] }, %{ key: :postContentType, label: "Post Content Type", type: {:dropdown, :atom}, - description: "Default post formatting option.", + description: "Default post formatting option", suggestions: ["text/plain", "text/html", "text/markdown", "text/bbcode"] }, %{ @@ -1245,14 +1254,14 @@ key: :sidebarRight, label: "Sidebar on Right", type: :boolean, - description: "Change alignment of sidebar and panels to the right." + description: "Change alignment of sidebar and panels to the right" }, %{ key: :showFeaturesPanel, label: "Show instance features panel", type: :boolean, description: - "Enables panel displaying functionality of the instance on the About page." + "Enables panel displaying functionality of the instance on the About page" }, %{ key: :showInstanceSpecificPanel, @@ -1310,7 +1319,7 @@ key: :mascots, type: {:keyword, :map}, description: - "Keyword of mascots, each element must contain both an url and a mime_type key", + "Keyword of mascots, each element must contain both an URL and a mime_type key", suggestions: [ pleroma_fox_tan: %{ url: "/images/pleroma-fox-tan-smol.png", @@ -1334,7 +1343,7 @@ %{ key: :default_user_avatar, type: :string, - description: "URL of the default user avatar.", + description: "URL of the default user avatar", suggestions: ["/images/avi.png"] } ] @@ -1344,7 +1353,7 @@ key: :manifest, type: :group, description: - "This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE", + "This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE.", children: [ %{ key: :icons, @@ -1381,7 +1390,7 @@ %{ group: :pleroma, key: :mrf_simple, - label: "MRF simple", + label: "MRF Simple", type: :group, description: "Message Rewrite Facility", children: [ @@ -1461,7 +1470,7 @@ %{ group: :pleroma, key: :mrf_subchain, - label: "MRF subchain", + label: "MRF Subchain", type: :group, description: "This policy processes messages through an alternate pipeline when a given message matches certain criteria." <> @@ -1484,7 +1493,7 @@ key: :mrf_rejectnonpublic, description: "MRF RejectNonPublic settings. RejectNonPublic drops posts with non-public visibility settings.", - label: "MRF reject non public", + label: "MRF Reject Non Public", type: :group, children: [ %{ @@ -1503,7 +1512,7 @@ %{ group: :pleroma, key: :mrf_hellthread, - label: "MRF hellthread", + label: "MRF Hellthread", type: :group, description: "Block messages with too much mentions", children: [ @@ -1527,7 +1536,7 @@ %{ group: :pleroma, key: :mrf_keyword, - label: "MRF keyword", + label: "MRF Keyword", type: :group, description: "Reject or Word-Replace messages with a keyword or regex", children: [ @@ -1557,14 +1566,14 @@ %{ group: :pleroma, key: :mrf_mention, - label: "MRF mention", + label: "MRF Mention", type: :group, description: "Block messages which mention a user", children: [ %{ key: :actors, type: {:list, :string}, - description: "A list of actors for which any post mentioning them will be dropped.", + description: "A list of actors for which any post mentioning them will be dropped", suggestions: ["actor1", "actor2"] } ] @@ -1572,7 +1581,7 @@ %{ group: :pleroma, key: :mrf_vocabulary, - label: "MRF vocabulary", + label: "MRF Vocabulary", type: :group, description: "Filter messages which belong to certain activity vocabularies", children: [ @@ -1580,14 +1589,14 @@ key: :accept, type: {:list, :string}, description: - "A list of ActivityStreams terms to accept. If empty, all supported messages are accepted", + "A list of ActivityStreams terms to accept. If empty, all supported messages are accepted.", suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] }, %{ key: :reject, type: {:list, :string}, description: - "A list of ActivityStreams terms to reject. If empty, no messages are rejected", + "A list of ActivityStreams terms to reject. If empty, no messages are rejected.", suggestions: ["Create", "Follow", "Mention", "Announce", "Like"] } ] @@ -1617,6 +1626,7 @@ }, %{ key: :base_url, + label: "Base URL", type: :string, description: "The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.", @@ -1649,6 +1659,7 @@ }, %{ key: :proxy_opts, + label: "Proxy Options", type: :keyword, description: "Options for Pleroma.ReverseProxy", suggestions: [ @@ -1676,6 +1687,7 @@ }, %{ key: :http, + label: "HTTP", type: :keyword, description: "HTTP options", children: [ @@ -1771,6 +1783,7 @@ }, %{ key: :ip, + label: "IP", type: :tuple, description: "IP address to bind to", suggestions: [{0, 0, 0, 0}] @@ -1784,7 +1797,7 @@ %{ key: :dstport, type: :integer, - description: "Port advertised in urls (optional, defaults to port)", + description: "Port advertised in URLs (optional, defaults to port)", suggestions: [9999] } ] @@ -1792,6 +1805,7 @@ %{ group: :pleroma, key: :activitypub, + label: "ActivityPub", type: :group, description: "ActivityPub-related settings", children: [ @@ -1814,7 +1828,7 @@ key: :note_replies_output_limit, type: :integer, description: - "The number of Note replies' URIs to be included with outgoing federation (`5` to match Mastodon hardcoded value, `0` to disable the output)." + "The number of Note replies' URIs to be included with outgoing federation (`5` to match Mastodon hardcoded value, `0` to disable the output)" }, %{ key: :follow_handshake_timeout, @@ -1827,6 +1841,7 @@ %{ group: :pleroma, key: :http_security, + label: "HTTP security", type: :group, description: "HTTP security settings", children: [ @@ -1865,7 +1880,7 @@ key: :report_uri, label: "Report URI", type: :string, - description: "Adds the specified url to report-uri and report-to group in CSP header", + description: "Adds the specified URL to report-uri and report-to group in CSP header", suggestions: ["https://example.com/report-uri"] } ] @@ -1873,9 +1888,10 @@ %{ group: :web_push_encryption, key: :vapid_details, + label: "Vapid Details", type: :group, description: - "Web Push Notifications configuration. You can use the mix task mix web_push.gen.keypair to generate it", + "Web Push Notifications configuration. You can use the mix task mix web_push.gen.keypair to generate it.", children: [ %{ key: :subject, @@ -1942,6 +1958,7 @@ }, %{ group: :pleroma, + label: "Pleroma Admin Token", type: :group, description: "Allows to set a token that can be used to authenticate with the admin api without using an actual user by giving it as the `admin_token` parameter", @@ -1949,7 +1966,7 @@ %{ key: :admin_token, type: :string, - description: "Token", + description: "Admin token", suggestions: ["We recommend a secure random string or UUID"] } ] @@ -2114,24 +2131,24 @@ key: :rich_media, type: :group, description: - "If enabled the instance will parse metadata from attached links to generate link previews.", + "If enabled the instance will parse metadata from attached links to generate link previews", children: [ %{ key: :enabled, type: :boolean, - description: "Enables RichMedia parsing of URLs." + description: "Enables RichMedia parsing of URLs" }, %{ key: :ignore_hosts, type: {:list, :string}, - description: "List of hosts which will be ignored by the metadata parser.", + description: "List of hosts which will be ignored by the metadata parser", suggestions: ["accounts.google.com", "xss.website"] }, %{ key: :ignore_tld, label: "Ignore TLD", type: {:list, :string}, - description: "List TLDs (top-level domains) which will ignore for parse metadata.", + description: "List TLDs (top-level domains) which will ignore for parse metadata", suggestions: ["local", "localdomain", "lan"] }, %{ @@ -2159,31 +2176,32 @@ %{ group: :auto_linker, key: :opts, + label: "Auto Linker", type: :group, description: "Configuration for the auto_linker library", children: [ %{ key: :class, type: [:string, false], - description: "Specify the class to be added to the generated link. Disable to clear", + description: "Specify the class to be added to the generated link. Disable to clear.", suggestions: ["auto-linker", false] }, %{ key: :rel, type: [:string, false], - description: "Override the rel attribute. Disable to clear", + description: "Override the rel attribute. Disable to clear.", suggestions: ["ugc", "noopener noreferrer", false] }, %{ key: :new_window, type: :boolean, - description: "Link urls will open in new window/tab" + description: "Link URLs will open in new window/tab" }, %{ key: :truncate, type: [:integer, false], description: - "Set to a number to truncate urls longer then the number. Truncated urls will end in `..`", + "Set to a number to truncate URLs longer then the number. Truncated URLs will end in `..`", suggestions: [15, false] }, %{ @@ -2194,7 +2212,7 @@ %{ key: :extra, type: :boolean, - description: "Link urls with rarely used schemes (magnet, ipfs, irc, etc.)" + description: "Link URLs with rarely used schemes (magnet, ipfs, irc, etc.)" } ] }, @@ -2240,6 +2258,7 @@ }, %{ group: :pleroma, + label: "Pleroma Authenticator", type: :group, description: "Authenticator", children: [ @@ -2253,6 +2272,7 @@ %{ group: :pleroma, key: :ldap, + label: "LDAP", type: :group, description: "Use LDAP for user authentication. When a user logs in to the Pleroma instance, the name and password" <> @@ -2339,6 +2359,7 @@ }, %{ key: :uid, + label: "UID", type: :string, description: "LDAP attribute name to authenticate the user, e.g. when \"cn\", the filter will be \"cn=username,base\"", @@ -2354,11 +2375,12 @@ children: [ %{ key: :enforce_oauth_admin_scope_usage, + label: "Enforce OAuth admin scope usage", type: :boolean, description: "OAuth admin scope requirement toggle. " <> "If enabled, admin actions explicitly demand admin OAuth scope(s) presence in OAuth token " <> - "(client app must support admin scopes). If disabled and token doesn't have admin scope(s)," <> + "(client app must support admin scopes). If disabled and token doesn't have admin scope(s), " <> "`is_admin` user flag grants access to admin-specific actions." }, %{ @@ -2370,6 +2392,7 @@ }, %{ key: :oauth_consumer_template, + label: "OAuth consumer template", type: :string, description: "OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to" <> @@ -2378,6 +2401,7 @@ }, %{ key: :oauth_consumer_strategies, + label: "OAuth consumer strategies", type: {:list, :string}, description: "The list of enabled OAuth consumer strategies. By default it's set by OAUTH_CONSUMER_STRATEGIES environment variable." <> @@ -2506,7 +2530,7 @@ %{ key: :enabled, type: :boolean, - description: "enables new users admin digest email when `true`", + description: "Enables new users admin digest email when `true`", suggestions: [false] } ] @@ -2514,6 +2538,7 @@ %{ group: :pleroma, key: :oauth2, + label: "OAuth2", type: :group, description: "Configure OAuth 2 provider capabilities", children: [ @@ -2532,7 +2557,7 @@ %{ key: :clean_expired_tokens, type: :boolean, - description: "Enable a background job to clean expired oauth tokens. Default: disabled." + description: "Enable a background job to clean expired OAuth tokens. Default: disabled." } ] }, @@ -2616,6 +2641,7 @@ }, %{ key: :relation_id_action, + label: "Relation ID action", type: [:tuple, {:list, :tuple}], description: "For actions on relation with a specific user (follow, unfollow)", suggestions: [{1000, 10}, [{10_000, 10}, {10_000, 50}]] @@ -2629,6 +2655,7 @@ }, %{ key: :status_id_action, + label: "Status ID action", type: [:tuple, {:list, :tuple}], description: "For fav / unfav or reblog / unreblog actions on the same status by the same user", @@ -2644,6 +2671,7 @@ }, %{ group: :esshd, + label: "ESSHD", type: :group, description: "Before enabling this you must add :esshd to mix.exs as one of the extra_applications " <> @@ -2682,8 +2710,9 @@ }, %{ group: :mime, + label: "Mime Types", type: :group, - description: "Mime types", + description: "Mime Types settings", children: [ %{ key: :types, @@ -2742,6 +2771,7 @@ %{ group: :pleroma, key: :http, + label: "HTTP", type: :group, description: "HTTP settings", children: [ @@ -2790,6 +2820,7 @@ %{ group: :pleroma, key: :markup, + label: "Markup Settings", type: :group, children: [ %{ @@ -2831,7 +2862,7 @@ %{ group: :pleroma, key: :mrf_normalize_markup, - label: "MRF normalize markup", + label: "MRF Normalize Markup", description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", type: :group, children: [ @@ -2887,6 +2918,7 @@ }, %{ group: :cors_plug, + label: "CORS plug config", type: :group, children: [ %{ @@ -2959,6 +2991,7 @@ %{ group: :pleroma, key: :web_cache_ttl, + label: "Web cache TTL", type: :group, description: "The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration.", @@ -2981,9 +3014,10 @@ %{ group: :pleroma, key: :static_fe, + label: "Static FE", type: :group, description: - "Render profiles and posts using server-generated HTML that is viewable without using JavaScript.", + "Render profiles and posts using server-generated HTML that is viewable without using JavaScript", children: [ %{ key: :enabled, @@ -3001,18 +3035,18 @@ %{ key: :post_title, type: :map, - description: "Configure title rendering.", + description: "Configure title rendering", children: [ %{ key: :max_length, type: :integer, - description: "Maximum number of characters before truncating title.", + description: "Maximum number of characters before truncating title", suggestions: [100] }, %{ key: :omission, type: :string, - description: "Replacement which will be used after truncating string.", + description: "Replacement which will be used after truncating string", suggestions: ["..."] } ] @@ -3022,6 +3056,7 @@ %{ group: :pleroma, key: :mrf_object_age, + label: "MRF Object Age", type: :group, description: "Rejects or delists posts based on their age when received.", children: [ @@ -3064,13 +3099,13 @@ %{ key: :workers, type: :integer, - description: "Number of workers to send notifications.", + description: "Number of workers to send notifications", suggestions: [3] }, %{ key: :overflow_workers, type: :integer, - description: "Maximum number of workers created if pool is empty.", + description: "Maximum number of workers created if pool is empty", suggestions: [2] } ] -- cgit v1.2.3 From 691742e62d36831d31ba5623bb0fc5f91d77960a Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 1 Jul 2020 08:51:56 +0000 Subject: Revert "Merge branch 'avatar-removing' into 'develop'" This reverts merge request !2701 --- .../account_controller/update_credentials_test.exs | 47 ++++------------------ 1 file changed, 7 insertions(+), 40 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 31f0edf97..f67d294ba 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 @@ -216,20 +216,10 @@ test "updates the user's avatar", %{user: user, conn: conn} do filename: "an_image.jpg" } - res = - conn - |> patch("/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + conn = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) - assert user_response = json_response_and_validate_schema(res, 200) + assert user_response = json_response_and_validate_schema(conn, 200) assert user_response["avatar"] != User.avatar_url(user) - - # Also removes it - res = - conn - |> patch("/api/v1/accounts/update_credentials", %{"avatar" => nil}) - - assert user_response = json_response_and_validate_schema(res, 200) - assert user_response["avatar"] == User.avatar_url(user) end test "updates the user's banner", %{user: user, conn: conn} do @@ -239,21 +229,10 @@ test "updates the user's banner", %{user: user, conn: conn} do filename: "an_image.jpg" } - res = - conn - |> patch("/api/v1/accounts/update_credentials", %{"header" => new_header}) + conn = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header}) - assert user_response = json_response_and_validate_schema(res, 200) + assert user_response = json_response_and_validate_schema(conn, 200) assert user_response["header"] != User.banner_url(user) - - # Also removes it - - res = - conn - |> patch("/api/v1/accounts/update_credentials", %{"header" => nil}) - - assert user_response = json_response_and_validate_schema(res, 200) - assert user_response["header"] == User.banner_url(user) end test "updates the user's background", %{conn: conn} do @@ -263,25 +242,13 @@ test "updates the user's background", %{conn: conn} do filename: "an_image.jpg" } - res = - conn - |> patch("/api/v1/accounts/update_credentials", %{ + conn = + patch(conn, "/api/v1/accounts/update_credentials", %{ "pleroma_background_image" => new_header }) - assert user_response = json_response_and_validate_schema(res, 200) + assert user_response = json_response_and_validate_schema(conn, 200) assert user_response["pleroma"]["background_image"] - - # Also removes it - - res = - conn - |> patch("/api/v1/accounts/update_credentials", %{ - "pleroma_background_image" => nil - }) - - assert user_response = json_response_and_validate_schema(res, 200) - refute user_response["pleroma"]["background_image"] end test "requires 'write:accounts' permission" do -- cgit v1.2.3 From 8ae572d5aef1fcad87522ae00b431135345dcd73 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 1 Jul 2020 11:47:45 +0200 Subject: Fixtures: Pretty print. --- .../tesla_mock/baptiste.gelex.xyz-article.json | 228 ++++++++++++++++++++- test/fixtures/tesla_mock/peertube.moe-vid.json | 188 ++++++++++++++++- 2 files changed, 414 insertions(+), 2 deletions(-) diff --git a/test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json b/test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json index 3f3f0f4fb..b76ba96a5 100644 --- a/test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json +++ b/test/fixtures/tesla_mock/baptiste.gelex.xyz-article.json @@ -1 +1,227 @@ -{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"Emoji":"toot:Emoji","Hashtag":"as:Hashtag","atomUri":"ostatus:atomUri","conversation":"ostatus:conversation","featured":"toot:featured","focalPoint":{"@container":"@list","@id":"toot:focalPoint"},"inReplyToAtomUri":"ostatus:inReplyToAtomUri","manuallyApprovesFollowers":"as:manuallyApprovesFollowers","movedTo":"as:movedTo","ostatus":"http://ostatus.org#","sensitive":"as:sensitive","toot":"http://joinmastodon.org/ns#"}],"attributedTo":["https://baptiste.gelez.xyz/@/BaptisteGelez"],"cc":[],"content":"

    It has been one month since the last \"This Month in Plume\" article, so it is time for another edition of our monthly changelog!

    \n

    Bug Fixes and Security

    \n

    Let's start with the hidden, but still (very) important changes: bug fixes and security patches.

    \n

    First of all, @Trinity protected us against two major security flaws, called XSS and CSRF. The first one allows the attacker to run malicious code if you visit a Plume page where some of their personal data is present. The second one lets them post data with your Plume account by visiting one of their own website. It is two very common attack, and it is great we are now protected against them!

    \n

    The other big change in this area, is that we are now validating the data you are sending before doing anything with it. It means that, for instance, you will no longer be able to register with an empty username and to break everything.

    \n

    On the federation side, many issues were reported by @kaniini and redmatrix (respectively contributing to Pleroma and Hubzilla). By fixing some of them, we made it possible to federate Plume articles to Pleroma!

    \n

    @Trinity hopefully noticed that there was a bug in our password check code: we were not checking that your password was correct, but only that the verification process went without errors. Concretely, it means that you could login to any account with any password. I wrote this part of the code when I was still the only contributor to the project, so nobody could review my work. We will now be trying to check every change, especially when it deals with critical parts of Plume, to avoid similar issues in the future, and we I'm really sorry this happened (even if I think nobody exploited it).

    \n

    Zanfib and stephenburgess8 also commited some small bugfixes, improving the general experience.

    \n

    New Features

    \n

    Let's now talk about the features that we introduced during this month.

    \n

    One of the most easy to spot is the redesign of Plume, made by @Madeorsk. I personaly love what he did, it really improved the readability and gave Plume a bit more of identity than the previous design. And he is still improving it.

    \n

    We also enabled Mardown in comment, to let you write more structured and nicely formatted responses.

    \n

    As you may have noticed, I have used mentions in this post. Indeed, it is now possible to mention someone in your articles or in comments. It works exactly the same way as in other apps, and you should receive a notification if someone mentionned you.

    \n

    A dashboard to manage your blogs has also been introduced. In the future it may be used to manage your drafts, and eventually to show some statistics. The goal is to have a more specific homepage for authors.

    \n

    The federation with other ActivityPub softwares, like Mastodon or Pleroma is starting to work quite well, but the federation between Plume instances is far from being complete. However, we started to work on it, and it is now possible to view a distant user profile or blog from your instance, even if only basic informations are fetched yet (the articles are not loaded for instance).

    \n

    Another new feature that may not be visible for everyone, is the new NodeInfo endpoint. NodeInfo is a protocol allowing to get informations about a specific federated instance (whatever software it runs). It means that Plume instances can now be listed on sites like fediverse.network.

    \n

    Maybe you wanted to host a Plume instance, but you don't like long install process during which you are just copy/pasting commands that you don't really understand from the documentation. That's why we introduced a setup script: the first you'll launch Plume, it will ask you a few questions and automatically setup your instance in a few minutes. We hope that this feature will help to host small instances, run by non-professional adminsys. You can see a demo of this tool on asciinema.

    \n

    Last but not least, Plume is now translatable! It is already available in English, French, Polish (thanks to @m4sk1n)) and German (thanks to bitkeks). If your browser is configured to display pages in these languages, you should normally see the interface in your language. And if your language is not present yet, feel free to add your translation.

    \n

    Other Changes

    \n

    We also improved the code a lot. We tried to separate each part as much as possible, making it easier to re-use for other projects. For instance, our database code is now isolated from the rest of the app, which means it will be easier to make import tools from other blogging engines. Some parts of the code are even shared with another project, Aardwolf a federated Facebook alternative. For instance, both of our projects use the same internationalization code, and once Aardwolf will implement federation, this part of the code will probably be shared too. Since the WebFinger module (used to find new users and blogs) and the CSRF protection code (see the \"Bug fixes and Security\" section) have been isolated in their own modules, they may be shared by both projects too.

    \n

    We also worked a lot on documentation. We now have articles explaining how to setup your Plume instance on various operating systems, but also documenting the translation process. I want to thank BanjoFox (who imported some documentation from their project, Aardwolf, as the setup is quite similar), Kushal and @gled@plume.mastodon.host for working on this.

    \n

    As you can see, there were many changes this month, but there still a lot to do. Your help will of course be welcome. If you want to contribute to the code, translate Plume in your language, write some documentation, or anything else (or even if you're just curious about the project), feel free to join our Matrix room: #plume:disroot.org. Otherwise, as BanjoFox said on the Aardwolf Team Mastodon account, talking about the project around you is one of the easiest way to help.

    \n","id":"https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/","likes":null,"name":"This Month in Plume: June 2018","published":"2018-07-10T20:16:24.087622Z","shares":null,"source":null,"tag":[{"href":"https://baptiste.gelez.xyz/@/Trinity","name":"@Trinity","type":"Mention"},{"href":"https://baptiste.gelez.xyz/@/kaniini/","name":"@kaniini","type":"Mention"},{"href":"https://baptiste.gelez.xyz/@/Trinity","name":"@Trinity","type":"Mention"}],"to":["https://unixcorn.xyz/users/Bat","https://mastodon.host/users/federationbot","https://social.tcit.fr/users/tcit","https://framapiaf.org/users/qwerty","https://mastodon.social/users/lthms","https://eldritch.cafe/users/Nausicaa","https://imaginair.es/users/Elanndelh","https://framapiaf.org/users/Drulac","https://mastodon.partipirate.org/users/NicolasConstant","https://aleph.land/users/Madeorsk","https://maly.io/users/Troll","https://hostux.social/users/superjey","https://mamot.fr/users/Phigger","https://mastodon.social/users/wakest","https://social.coop/users/wakest","https://unixcorn.xyz/users/Ce_lo","https://social.art-software.fr/users/Electron","https://framapiaf.org/users/Quenti","https://toot.plus.yt/users/Djyp","https://mastodon.social/users/brainblasted","https://social.mochi.academy/users/Ambraven","https://social.hacktivis.me/users/lanodan","https://mastodon.eliotberriot.com/users/eliotberriot","https://edolas.world/users/0x1C3B00DA","https://toot.cafe/users/zack","https://manowar.social/users/zatnosk","https://eldritch.cafe/users/fluffy","https://mastodon.social/users/david_ross","https://kosmos.social/users/xiroux","https://mastodon.art/users/EmergencyBattle","https://mastodon.social/users/trwnh","https://octodon.social/users/pybyte","https://anticapitalist.party/users/Trinity","https://mstdn.mx/users/xavavu","https://baptiste.gelez.xyz/@/m4sk1n","https://eldritch.cafe/users/milia","https://mastodon.zaclys.com/users/arx","https://toot.cafe/users/sivy","https://mastodon.social/users/ortegacmanuel","https://mastodon.observer/users/stephen","https://octodon.social/users/chloe","https://unixcorn.xyz/users/AmauryPi","https://cybre.space/users/rick_777","https://mastodon.social/users/wezm","https://baptiste.gelez.xyz/@/idlesong","https://mamot.fr/users/dr4Ke","https://imaginair.es/users/Phigger","https://mamot.fr/users/dlink","https://anticapitalist.party/users/a000d4f7a91939d0e71df1646d7a48","https://framapiaf.org/users/PhieLaidMignon","https://mastodon.social/users/y6nH","https://crazynoisybizarre.town/users/FederationBot","https://social.weho.st/users/dvn","https://mastodon.art/users/Wolthera","https://diaspodon.fr/users/dada","https://pachyder.me/users/Lanza","https://mastodon.xyz/users/ag","https://aleph.land/users/yahananxie","https://mstdn.io/users/chablis_social","https://mastodon.gougere.fr/users/fabien","https://functional.cafe/users/otini","https://social.coop/users/bhaugen","https://octodon.social/users/donblanco","https://chaos.social/users/astro","https://pachyder.me/users/sibear","https://mamot.fr/users/yohann","https://social.wxcafe.net/users/Bat","https://mastodon.social/users/dansup","https://chaos.social/users/juh","https://scifi.fyi/users/paeneultima","https://hostux.social/users/Deuchnord","https://mstdn.fr/users/taziden","https://mamot.fr/users/PifyZ","https://mastodon.social/users/plantabaja","https://mastodon.social/users/gitzgrog","https://mastodon.social/users/Syluban","https://masto.pt/users/eloisa","https://pleroma.soykaf.com/users/notclacke","https://mastodon.social/users/SiegfriedEhret","https://writing.exchange/users/write_as","https://mstdn.io/users/shellkr","https://mastodon.uy/users/jorge","https://mastodon.technology/users/bobstechsite","https://mastodon.social/users/hinterwaeldler","https://mastodon.xyz/users/mgdelacroix","https://mastodon.cloud/users/jjatria","https://baptiste.gelez.xyz/@/Jade/","https://edolas.world/users/pfm","https://mstdn.io/users/jort","https://mastodon.social/users/andreipetcu","https://mastodon.technology/users/0xf00fc7c8","https://mastodon.social/users/khanate","https://mastodon.technology/users/francois","https://mastodon.social/users/glherrmann","https://mastodon.host/users/gled","https://social.holdmybeer.solutions/users/kemonine","https://scholar.social/users/bgcarlisle","https://mastodon.social/users/oldgun","https://baptiste.gelez.xyz/@/snoe/","https://mastodon.at/users/switchingsocial","https://scifi.fyi/users/BrokenBiscuit","https://dev.glitch.social/users/hoodie","https://todon.nl/users/paulfree14","https://mastodon.social/users/aadilayub","https://social.fsck.club/users/anarchosaurus","https://mastodonten.de/users/GiantG","https://mastodon.technology/users/cj","https://cybre.space/users/sam","https://layer8.space/users/silkevicious","https://mastodon.xyz/users/Jimmyrwx","https://fosstodon.org/users/danyspin97","https://mstdn.io/users/cristhyano","https://mastodon.social/users/vanyok","https://hulvr.com/users/rook","https://niu.moe/users/Lucifer","https://mamot.fr/users/Thibaut","https://mastodont.cat/users/bgta","https://mstdn.io/users/hontoni","https://niu.moe/users/lionirdeadman","https://functional.cafe/users/phoe","https://mastodon.social/users/toontoet","https://mastodon.social/users/danipozo","https://scholar.social/users/robertson","https://mastodon.social/users/aldatsa","https://elekk.xyz/users/maloki","https://kitty.town/users/nursemchurt","https://neigh.horse/users/commagray","https://mastodon.social/users/hirojin","https://mastodon.xyz/users/mareklach","https://chaos.social/users/benthor","https://mastodon.social/users/djperreault","https://mastodon.art/users/eylul","https://mastodon.opportunis.me/users/bob","https://tootplanet.space/users/Shutsumon","https://toot.cat/users/woozle","https://mastodon.social/users/StephenLB","https://sleeping.town/users/oct2pus","https://mastodon.indie.host/users/stragu","https://social.coop/users/gilscottfitzgerald","https://icosahedron.website/users/joeld","https://mastodon.social/users/hellion","https://cybre.space/users/cooler_ranch","https://mastodon.social/users/kelsonv","https://mastodon.lat/users/scalpol","https://writing.exchange/users/hnb","https://hex.bz/users/Horst","https://mastodon.social/users/weddle","https://maly.io/users/sonya","https://social.coop/users/medusa","https://mastodon.social/users/DystopianK","https://mstdn.io/users/d_io","https://fosstodon.org/users/brandon","https://fosstodon.org/users/Cando","https://mastodon.host/users/panina","https://floss.social/users/tuxether","https://social.tchncs.de/users/suitbertmonz","https://mastodon.social/users/jrt","https://mastodon.social/users/sirikon","https://mstdn.io/users/yabirgb","https://mastodon.cloud/users/FerdiZ","https://mastodon.social/users/carlchenet","https://social.polonkai.eu/users/calendar_social","https://social.polonkai.eu/users/gergely","https://mastodon.social/users/Jelv","https://mastodon.social/users/srinicame","https://cybre.space/users/mastoabed","https://mastodon.social/users/tagomago","https://lgbt.io/users/bootblackCub","https://niu.moe/users/Nopplyy","https://mastodon.social/users/bpugh","https://www.w3.org/ns/activitystreams#Public"],"type":"Article","uploadMedia":null,"url":"https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/"} \ No newline at end of file +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "Emoji" : "toot:Emoji", + "Hashtag" : "as:Hashtag", + "atomUri" : "ostatus:atomUri", + "conversation" : "ostatus:conversation", + "featured" : "toot:featured", + "focalPoint" : { + "@container" : "@list", + "@id" : "toot:focalPoint" + }, + "inReplyToAtomUri" : "ostatus:inReplyToAtomUri", + "manuallyApprovesFollowers" : "as:manuallyApprovesFollowers", + "movedTo" : "as:movedTo", + "ostatus" : "http://ostatus.org#", + "sensitive" : "as:sensitive", + "toot" : "http://joinmastodon.org/ns#" + } + ], + "attributedTo" : [ + "https://baptiste.gelez.xyz/@/BaptisteGelez" + ], + "cc" : [], + "content" : "

    It has been one month since the last \"This Month in Plume\" article, so it is time for another edition of our monthly changelog!

    \n

    Bug Fixes and Security

    \n

    Let's start with the hidden, but still (very) important changes: bug fixes and security patches.

    \n

    First of all, @Trinity protected us against two major security flaws, called XSS and CSRF. The first one allows the attacker to run malicious code if you visit a Plume page where some of their personal data is present. The second one lets them post data with your Plume account by visiting one of their own website. It is two very common attack, and it is great we are now protected against them!

    \n

    The other big change in this area, is that we are now validating the data you are sending before doing anything with it. It means that, for instance, you will no longer be able to register with an empty username and to break everything.

    \n

    On the federation side, many issues were reported by @kaniini and redmatrix (respectively contributing to Pleroma and Hubzilla). By fixing some of them, we made it possible to federate Plume articles to Pleroma!

    \n

    @Trinity hopefully noticed that there was a bug in our password check code: we were not checking that your password was correct, but only that the verification process went without errors. Concretely, it means that you could login to any account with any password. I wrote this part of the code when I was still the only contributor to the project, so nobody could review my work. We will now be trying to check every change, especially when it deals with critical parts of Plume, to avoid similar issues in the future, and we I'm really sorry this happened (even if I think nobody exploited it).

    \n

    Zanfib and stephenburgess8 also commited some small bugfixes, improving the general experience.

    \n

    New Features

    \n

    Let's now talk about the features that we introduced during this month.

    \n

    One of the most easy to spot is the redesign of Plume, made by @Madeorsk. I personaly love what he did, it really improved the readability and gave Plume a bit more of identity than the previous design. And he is still improving it.

    \n

    We also enabled Mardown in comment, to let you write more structured and nicely formatted responses.

    \n

    As you may have noticed, I have used mentions in this post. Indeed, it is now possible to mention someone in your articles or in comments. It works exactly the same way as in other apps, and you should receive a notification if someone mentionned you.

    \n

    A dashboard to manage your blogs has also been introduced. In the future it may be used to manage your drafts, and eventually to show some statistics. The goal is to have a more specific homepage for authors.

    \n

    The federation with other ActivityPub softwares, like Mastodon or Pleroma is starting to work quite well, but the federation between Plume instances is far from being complete. However, we started to work on it, and it is now possible to view a distant user profile or blog from your instance, even if only basic informations are fetched yet (the articles are not loaded for instance).

    \n

    Another new feature that may not be visible for everyone, is the new NodeInfo endpoint. NodeInfo is a protocol allowing to get informations about a specific federated instance (whatever software it runs). It means that Plume instances can now be listed on sites like fediverse.network.

    \n

    Maybe you wanted to host a Plume instance, but you don't like long install process during which you are just copy/pasting commands that you don't really understand from the documentation. That's why we introduced a setup script: the first you'll launch Plume, it will ask you a few questions and automatically setup your instance in a few minutes. We hope that this feature will help to host small instances, run by non-professional adminsys. You can see a demo of this tool on asciinema.

    \n

    Last but not least, Plume is now translatable! It is already available in English, French, Polish (thanks to @m4sk1n)) and German (thanks to bitkeks). If your browser is configured to display pages in these languages, you should normally see the interface in your language. And if your language is not present yet, feel free to add your translation.

    \n

    Other Changes

    \n

    We also improved the code a lot. We tried to separate each part as much as possible, making it easier to re-use for other projects. For instance, our database code is now isolated from the rest of the app, which means it will be easier to make import tools from other blogging engines. Some parts of the code are even shared with another project, Aardwolf a federated Facebook alternative. For instance, both of our projects use the same internationalization code, and once Aardwolf will implement federation, this part of the code will probably be shared too. Since the WebFinger module (used to find new users and blogs) and the CSRF protection code (see the \"Bug fixes and Security\" section) have been isolated in their own modules, they may be shared by both projects too.

    \n

    We also worked a lot on documentation. We now have articles explaining how to setup your Plume instance on various operating systems, but also documenting the translation process. I want to thank BanjoFox (who imported some documentation from their project, Aardwolf, as the setup is quite similar), Kushal and @gled@plume.mastodon.host for working on this.

    \n

    As you can see, there were many changes this month, but there still a lot to do. Your help will of course be welcome. If you want to contribute to the code, translate Plume in your language, write some documentation, or anything else (or even if you're just curious about the project), feel free to join our Matrix room: #plume:disroot.org. Otherwise, as BanjoFox said on the Aardwolf Team Mastodon account, talking about the project around you is one of the easiest way to help.

    \n", + "id" : "https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/", + "likes" : null, + "name" : "This Month in Plume: June 2018", + "published" : "2018-07-10T20:16:24.087622Z", + "shares" : null, + "source" : null, + "tag" : [ + { + "href" : "https://baptiste.gelez.xyz/@/Trinity", + "name" : "@Trinity", + "type" : "Mention" + }, + { + "href" : "https://baptiste.gelez.xyz/@/kaniini/", + "name" : "@kaniini", + "type" : "Mention" + }, + { + "href" : "https://baptiste.gelez.xyz/@/Trinity", + "name" : "@Trinity", + "type" : "Mention" + } + ], + "to" : [ + "https://unixcorn.xyz/users/Bat", + "https://mastodon.host/users/federationbot", + "https://social.tcit.fr/users/tcit", + "https://framapiaf.org/users/qwerty", + "https://mastodon.social/users/lthms", + "https://eldritch.cafe/users/Nausicaa", + "https://imaginair.es/users/Elanndelh", + "https://framapiaf.org/users/Drulac", + "https://mastodon.partipirate.org/users/NicolasConstant", + "https://aleph.land/users/Madeorsk", + "https://maly.io/users/Troll", + "https://hostux.social/users/superjey", + "https://mamot.fr/users/Phigger", + "https://mastodon.social/users/wakest", + "https://social.coop/users/wakest", + "https://unixcorn.xyz/users/Ce_lo", + "https://social.art-software.fr/users/Electron", + "https://framapiaf.org/users/Quenti", + "https://toot.plus.yt/users/Djyp", + "https://mastodon.social/users/brainblasted", + "https://social.mochi.academy/users/Ambraven", + "https://social.hacktivis.me/users/lanodan", + "https://mastodon.eliotberriot.com/users/eliotberriot", + "https://edolas.world/users/0x1C3B00DA", + "https://toot.cafe/users/zack", + "https://manowar.social/users/zatnosk", + "https://eldritch.cafe/users/fluffy", + "https://mastodon.social/users/david_ross", + "https://kosmos.social/users/xiroux", + "https://mastodon.art/users/EmergencyBattle", + "https://mastodon.social/users/trwnh", + "https://octodon.social/users/pybyte", + "https://anticapitalist.party/users/Trinity", + "https://mstdn.mx/users/xavavu", + "https://baptiste.gelez.xyz/@/m4sk1n", + "https://eldritch.cafe/users/milia", + "https://mastodon.zaclys.com/users/arx", + "https://toot.cafe/users/sivy", + "https://mastodon.social/users/ortegacmanuel", + "https://mastodon.observer/users/stephen", + "https://octodon.social/users/chloe", + "https://unixcorn.xyz/users/AmauryPi", + "https://cybre.space/users/rick_777", + "https://mastodon.social/users/wezm", + "https://baptiste.gelez.xyz/@/idlesong", + "https://mamot.fr/users/dr4Ke", + "https://imaginair.es/users/Phigger", + "https://mamot.fr/users/dlink", + "https://anticapitalist.party/users/a000d4f7a91939d0e71df1646d7a48", + "https://framapiaf.org/users/PhieLaidMignon", + "https://mastodon.social/users/y6nH", + "https://crazynoisybizarre.town/users/FederationBot", + "https://social.weho.st/users/dvn", + "https://mastodon.art/users/Wolthera", + "https://diaspodon.fr/users/dada", + "https://pachyder.me/users/Lanza", + "https://mastodon.xyz/users/ag", + "https://aleph.land/users/yahananxie", + "https://mstdn.io/users/chablis_social", + "https://mastodon.gougere.fr/users/fabien", + "https://functional.cafe/users/otini", + "https://social.coop/users/bhaugen", + "https://octodon.social/users/donblanco", + "https://chaos.social/users/astro", + "https://pachyder.me/users/sibear", + "https://mamot.fr/users/yohann", + "https://social.wxcafe.net/users/Bat", + "https://mastodon.social/users/dansup", + "https://chaos.social/users/juh", + "https://scifi.fyi/users/paeneultima", + "https://hostux.social/users/Deuchnord", + "https://mstdn.fr/users/taziden", + "https://mamot.fr/users/PifyZ", + "https://mastodon.social/users/plantabaja", + "https://mastodon.social/users/gitzgrog", + "https://mastodon.social/users/Syluban", + "https://masto.pt/users/eloisa", + "https://pleroma.soykaf.com/users/notclacke", + "https://mastodon.social/users/SiegfriedEhret", + "https://writing.exchange/users/write_as", + "https://mstdn.io/users/shellkr", + "https://mastodon.uy/users/jorge", + "https://mastodon.technology/users/bobstechsite", + "https://mastodon.social/users/hinterwaeldler", + "https://mastodon.xyz/users/mgdelacroix", + "https://mastodon.cloud/users/jjatria", + "https://baptiste.gelez.xyz/@/Jade/", + "https://edolas.world/users/pfm", + "https://mstdn.io/users/jort", + "https://mastodon.social/users/andreipetcu", + "https://mastodon.technology/users/0xf00fc7c8", + "https://mastodon.social/users/khanate", + "https://mastodon.technology/users/francois", + "https://mastodon.social/users/glherrmann", + "https://mastodon.host/users/gled", + "https://social.holdmybeer.solutions/users/kemonine", + "https://scholar.social/users/bgcarlisle", + "https://mastodon.social/users/oldgun", + "https://baptiste.gelez.xyz/@/snoe/", + "https://mastodon.at/users/switchingsocial", + "https://scifi.fyi/users/BrokenBiscuit", + "https://dev.glitch.social/users/hoodie", + "https://todon.nl/users/paulfree14", + "https://mastodon.social/users/aadilayub", + "https://social.fsck.club/users/anarchosaurus", + "https://mastodonten.de/users/GiantG", + "https://mastodon.technology/users/cj", + "https://cybre.space/users/sam", + "https://layer8.space/users/silkevicious", + "https://mastodon.xyz/users/Jimmyrwx", + "https://fosstodon.org/users/danyspin97", + "https://mstdn.io/users/cristhyano", + "https://mastodon.social/users/vanyok", + "https://hulvr.com/users/rook", + "https://niu.moe/users/Lucifer", + "https://mamot.fr/users/Thibaut", + "https://mastodont.cat/users/bgta", + "https://mstdn.io/users/hontoni", + "https://niu.moe/users/lionirdeadman", + "https://functional.cafe/users/phoe", + "https://mastodon.social/users/toontoet", + "https://mastodon.social/users/danipozo", + "https://scholar.social/users/robertson", + "https://mastodon.social/users/aldatsa", + "https://elekk.xyz/users/maloki", + "https://kitty.town/users/nursemchurt", + "https://neigh.horse/users/commagray", + "https://mastodon.social/users/hirojin", + "https://mastodon.xyz/users/mareklach", + "https://chaos.social/users/benthor", + "https://mastodon.social/users/djperreault", + "https://mastodon.art/users/eylul", + "https://mastodon.opportunis.me/users/bob", + "https://tootplanet.space/users/Shutsumon", + "https://toot.cat/users/woozle", + "https://mastodon.social/users/StephenLB", + "https://sleeping.town/users/oct2pus", + "https://mastodon.indie.host/users/stragu", + "https://social.coop/users/gilscottfitzgerald", + "https://icosahedron.website/users/joeld", + "https://mastodon.social/users/hellion", + "https://cybre.space/users/cooler_ranch", + "https://mastodon.social/users/kelsonv", + "https://mastodon.lat/users/scalpol", + "https://writing.exchange/users/hnb", + "https://hex.bz/users/Horst", + "https://mastodon.social/users/weddle", + "https://maly.io/users/sonya", + "https://social.coop/users/medusa", + "https://mastodon.social/users/DystopianK", + "https://mstdn.io/users/d_io", + "https://fosstodon.org/users/brandon", + "https://fosstodon.org/users/Cando", + "https://mastodon.host/users/panina", + "https://floss.social/users/tuxether", + "https://social.tchncs.de/users/suitbertmonz", + "https://mastodon.social/users/jrt", + "https://mastodon.social/users/sirikon", + "https://mstdn.io/users/yabirgb", + "https://mastodon.cloud/users/FerdiZ", + "https://mastodon.social/users/carlchenet", + "https://social.polonkai.eu/users/calendar_social", + "https://social.polonkai.eu/users/gergely", + "https://mastodon.social/users/Jelv", + "https://mastodon.social/users/srinicame", + "https://cybre.space/users/mastoabed", + "https://mastodon.social/users/tagomago", + "https://lgbt.io/users/bootblackCub", + "https://niu.moe/users/Nopplyy", + "https://mastodon.social/users/bpugh", + "https://www.w3.org/ns/activitystreams#Public" + ], + "type" : "Article", + "uploadMedia" : null, + "url" : "https://baptiste.gelez.xyz/~/PlumeDevelopment/this-month-in-plume-june-2018/" +} diff --git a/test/fixtures/tesla_mock/peertube.moe-vid.json b/test/fixtures/tesla_mock/peertube.moe-vid.json index 76296eb7d..ceebb90b7 100644 --- a/test/fixtures/tesla_mock/peertube.moe-vid.json +++ b/test/fixtures/tesla_mock/peertube.moe-vid.json @@ -1 +1,187 @@ -{"type":"Video","id":"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3","name":"Friday Night","duration":"PT29S","uuid":"df5f464b-be8d-46fb-ad81-2d4c2d1630e3","tag":[{"type":"Hashtag","name":"feels"}],"views":12,"sensitive":false,"commentsEnabled":true,"published":"2018-03-23T16:43:22.988Z","updated":"2018-03-24T16:28:46.002Z","mediaType":"text/markdown","content":"tfw\r\n\r\n\r\nsong is 'my old piano' by diana ross","support":null,"icon":{"type":"Image","url":"https://peertube.moe/static/thumbnails/df5f464b-be8d-46fb-ad81-2d4c2d1630e3.jpg","mediaType":"image/jpeg","width":200,"height":110},"url":[{"type":"Link","mimeType":"video/mp4","href":"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4","width":480,"size":5015880},{"type":"Link","mimeType":"application/x-bittorrent","href":"https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.torrent","width":480},{"type":"Link","mimeType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.torrent&xt=urn:btih:11d3af6b5c812a376c2b29cdbd46e5fb42ee730e&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4","width":480},{"type":"Link","mimeType":"video/mp4","href":"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.mp4","width":360,"size":3620040},{"type":"Link","mimeType":"application/x-bittorrent","href":"https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.torrent","width":360},{"type":"Link","mimeType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.torrent&xt=urn:btih:1c3885b4d7cdb46193b62b9b76e72b1409cfb297&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.mp4","width":360},{"type":"Link","mimeType":"video/mp4","href":"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.mp4","width":240,"size":2305488},{"type":"Link","mimeType":"application/x-bittorrent","href":"https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.torrent","width":240},{"type":"Link","mimeType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.torrent&xt=urn:btih:ac5773352d9e26f982d2da63acfb244f01ccafa4&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.mp4","width":240},{"type":"Link","mimeType":"video/mp4","href":"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.mp4","width":720,"size":7928231},{"type":"Link","mimeType":"application/x-bittorrent","href":"https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.torrent","width":720},{"type":"Link","mimeType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.torrent&xt=urn:btih:b591068f4533c4e2865bb4cbb89887aecccdc523&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.mp4","width":720},{"type":"Link","mimeType":"text/html","href":"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3"}],"likes":{"id":"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/likes","type":"OrderedCollection","totalItems":0,"orderedItems":[]},"dislikes":{"id":"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/dislikes","type":"OrderedCollection","totalItems":0,"orderedItems":[]},"shares":{"id":"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/announces","type":"OrderedCollection","totalItems":2,"orderedItems":["https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/announces/465","https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/announces/1"]},"comments":{"id":"https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/comments","type":"OrderedCollection","totalItems":0,"orderedItems":[]},"attributedTo":[{"type":"Group","id":"https://peertube.moe/video-channels/5224869f-aa63-4c83-ab3a-87c3a5ac440e"},{"type":"Person","id":"https://peertube.moe/accounts/7even"}],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017","Hashtag":"as:Hashtag","uuid":"http://schema.org/identifier","category":"http://schema.org/category","licence":"http://schema.org/license","sensitive":"as:sensitive","language":"http://schema.org/inLanguage","views":"http://schema.org/Number","size":"http://schema.org/Number","commentsEnabled":"http://schema.org/Boolean","support":"http://schema.org/Text"},{"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}]} \ No newline at end of file +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "Hashtag" : "as:Hashtag", + "RsaSignature2017" : "https://w3id.org/security#RsaSignature2017", + "category" : "http://schema.org/category", + "commentsEnabled" : "http://schema.org/Boolean", + "language" : "http://schema.org/inLanguage", + "licence" : "http://schema.org/license", + "sensitive" : "as:sensitive", + "size" : "http://schema.org/Number", + "support" : "http://schema.org/Text", + "uuid" : "http://schema.org/identifier", + "views" : "http://schema.org/Number" + }, + { + "comments" : { + "@id" : "as:comments", + "@type" : "@id" + }, + "dislikes" : { + "@id" : "as:dislikes", + "@type" : "@id" + }, + "likes" : { + "@id" : "as:likes", + "@type" : "@id" + }, + "shares" : { + "@id" : "as:shares", + "@type" : "@id" + } + } + ], + "attributedTo" : [ + { + "id" : "https://peertube.moe/video-channels/5224869f-aa63-4c83-ab3a-87c3a5ac440e", + "type" : "Group" + }, + { + "id" : "https://peertube.moe/accounts/7even", + "type" : "Person" + } + ], + "cc" : [], + "comments" : { + "id" : "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/comments", + "orderedItems" : [], + "totalItems" : 0, + "type" : "OrderedCollection" + }, + "commentsEnabled" : true, + "content" : "tfw\r\n\r\n\r\nsong is 'my old piano' by diana ross", + "dislikes" : { + "id" : "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/dislikes", + "orderedItems" : [], + "totalItems" : 0, + "type" : "OrderedCollection" + }, + "duration" : "PT29S", + "icon" : { + "height" : 110, + "mediaType" : "image/jpeg", + "type" : "Image", + "url" : "https://peertube.moe/static/thumbnails/df5f464b-be8d-46fb-ad81-2d4c2d1630e3.jpg", + "width" : 200 + }, + "id" : "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3", + "likes" : { + "id" : "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/likes", + "orderedItems" : [], + "totalItems" : 0, + "type" : "OrderedCollection" + }, + "mediaType" : "text/markdown", + "name" : "Friday Night", + "published" : "2018-03-23T16:43:22.988Z", + "sensitive" : false, + "shares" : { + "id" : "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/announces", + "orderedItems" : [ + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/announces/465", + "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3/announces/1" + ], + "totalItems" : 2, + "type" : "OrderedCollection" + }, + "support" : null, + "tag" : [ + { + "name" : "feels", + "type" : "Hashtag" + } + ], + "to" : [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type" : "Video", + "updated" : "2018-03-24T16:28:46.002Z", + "url" : [ + { + "href" : "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", + "mimeType" : "video/mp4", + "size" : 5015880, + "type" : "Link", + "width" : 480 + }, + { + "href" : "https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.torrent", + "mimeType" : "application/x-bittorrent", + "type" : "Link", + "width" : 480 + }, + { + "href" : "magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.torrent&xt=urn:btih:11d3af6b5c812a376c2b29cdbd46e5fb42ee730e&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", + "mimeType" : "application/x-bittorrent;x-scheme-handler/magnet", + "type" : "Link", + "width" : 480 + }, + { + "href" : "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.mp4", + "mimeType" : "video/mp4", + "size" : 3620040, + "type" : "Link", + "width" : 360 + }, + { + "href" : "https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.torrent", + "mimeType" : "application/x-bittorrent", + "type" : "Link", + "width" : 360 + }, + { + "href" : "magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.torrent&xt=urn:btih:1c3885b4d7cdb46193b62b9b76e72b1409cfb297&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-360.mp4", + "mimeType" : "application/x-bittorrent;x-scheme-handler/magnet", + "type" : "Link", + "width" : 360 + }, + { + "href" : "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.mp4", + "mimeType" : "video/mp4", + "size" : 2305488, + "type" : "Link", + "width" : 240 + }, + { + "href" : "https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.torrent", + "mimeType" : "application/x-bittorrent", + "type" : "Link", + "width" : 240 + }, + { + "href" : "magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.torrent&xt=urn:btih:ac5773352d9e26f982d2da63acfb244f01ccafa4&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-240.mp4", + "mimeType" : "application/x-bittorrent;x-scheme-handler/magnet", + "type" : "Link", + "width" : 240 + }, + { + "href" : "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.mp4", + "mimeType" : "video/mp4", + "size" : 7928231, + "type" : "Link", + "width" : 720 + }, + { + "href" : "https://peertube.moe/static/torrents/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.torrent", + "mimeType" : "application/x-bittorrent", + "type" : "Link", + "width" : 720 + }, + { + "href" : "magnet:?xs=https%3A%2F%2Fpeertube.moe%2Fstatic%2Ftorrents%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.torrent&xt=urn:btih:b591068f4533c4e2865bb4cbb89887aecccdc523&dn=Friday+Night&tr=wss%3A%2F%2Fpeertube.moe%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fpeertube.moe%2Ftracker%2Fannounce&ws=https%3A%2F%2Fpeertube.moe%2Fstatic%2Fwebseed%2Fdf5f464b-be8d-46fb-ad81-2d4c2d1630e3-720.mp4", + "mimeType" : "application/x-bittorrent;x-scheme-handler/magnet", + "type" : "Link", + "width" : 720 + }, + { + "href" : "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3", + "mimeType" : "text/html", + "type" : "Link" + } + ], + "uuid" : "df5f464b-be8d-46fb-ad81-2d4c2d1630e3", + "views" : 12 +} -- cgit v1.2.3 From ce92e6e5ce24a68bedd744c01cc1a99f01c4fa91 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 1 Jul 2020 11:48:51 +0200 Subject: Fetcher: Work when we can't get the OP. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 7 +-- test/fixtures/fetch_mocks/104410921027210069.json | 72 +++++++++++++++++++++++ test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json | 59 +++++++++++++++++++ test/fixtures/fetch_mocks/eal.json | 43 ++++++++++++++ test/fixtures/fetch_mocks/tuxcrafting.json | 59 +++++++++++++++++++ test/object/fetcher_test.exs | 40 +++++++++++++ 6 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/fetch_mocks/104410921027210069.json create mode 100644 test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json create mode 100644 test/fixtures/fetch_mocks/eal.json create mode 100644 test/fixtures/fetch_mocks/tuxcrafting.json diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 278fbbeab..bc6fc4bd8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -446,12 +446,9 @@ def handle_incoming( when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer", "Audio"] do actor = Containment.get_actor(data) - data = - Map.put(data, "actor", actor) - |> fix_addressing - with nil <- Activity.get_create_by_object_ap_id(object["id"]), - {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(data["actor"]) do + {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor), + data <- Map.put(data, "actor", actor) |> fix_addressing() do object = fix_object(object, options) params = %{ diff --git a/test/fixtures/fetch_mocks/104410921027210069.json b/test/fixtures/fetch_mocks/104410921027210069.json new file mode 100644 index 000000000..583f7a4dc --- /dev/null +++ b/test/fixtures/fetch_mocks/104410921027210069.json @@ -0,0 +1,72 @@ +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + { + "atomUri" : "ostatus:atomUri", + "conversation" : "ostatus:conversation", + "inReplyToAtomUri" : "ostatus:inReplyToAtomUri", + "ostatus" : "http://ostatus.org#", + "sensitive" : "as:sensitive", + "toot" : "http://joinmastodon.org/ns#", + "votersCount" : "toot:votersCount" + } + ], + "atomUri" : "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069", + "attachment" : [], + "attributedTo" : "https://busshi.moe/users/tuxcrafting", + "cc" : [ + "https://busshi.moe/users/tuxcrafting/followers", + "https://stereophonic.space/users/fixpoint", + "https://blob.cat/users/blobyoumu", + "https://cawfee.club/users/grips", + "https://jaeger.website/users/igel" + ], + "content" : "

    @fixpoint @blobyoumu @grips @igel there's a difference between not liking nukes and not liking nuclear power
    nukes are pretty bad as are all WMDs in general but disliking nuclear power just indicates you are unable of thought

    ", + "contentMap" : { + "en" : "

    @fixpoint @blobyoumu @grips @igel there's a difference between not liking nukes and not liking nuclear power
    nukes are pretty bad as are all WMDs in general but disliking nuclear power just indicates you are unable of thought

    " + }, + "conversation" : "https://cawfee.club/contexts/ad6c73d8-efc2-4e74-84ea-2dacf1a27a5e", + "id" : "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069", + "inReplyTo" : "https://stereophonic.space/objects/02997b83-3ea7-4b63-94af-ef3aa2d4ed17", + "inReplyToAtomUri" : "https://stereophonic.space/objects/02997b83-3ea7-4b63-94af-ef3aa2d4ed17", + "published" : "2020-06-26T15:10:19Z", + "replies" : { + "first" : { + "items" : [], + "next" : "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069/replies?only_other_accounts=true&page=true", + "partOf" : "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069/replies", + "type" : "CollectionPage" + }, + "id" : "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069/replies", + "type" : "Collection" + }, + "sensitive" : false, + "summary" : null, + "tag" : [ + { + "href" : "https://stereophonic.space/users/fixpoint", + "name" : "@fixpoint@stereophonic.space", + "type" : "Mention" + }, + { + "href" : "https://blob.cat/users/blobyoumu", + "name" : "@blobyoumu@blob.cat", + "type" : "Mention" + }, + { + "href" : "https://cawfee.club/users/grips", + "name" : "@grips@cawfee.club", + "type" : "Mention" + }, + { + "href" : "https://jaeger.website/users/igel", + "name" : "@igel@jaeger.website", + "type" : "Mention" + } + ], + "to" : [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type" : "Note", + "url" : "https://busshi.moe/@tuxcrafting/104410921027210069" +} diff --git a/test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json b/test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json new file mode 100644 index 000000000..0226b058a --- /dev/null +++ b/test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json @@ -0,0 +1,59 @@ +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://social.sakamoto.gq/schemas/litepub-0.1.jsonld", + { + "@language" : "und" + } + ], + "actor" : "https://social.sakamoto.gq/users/eal", + "attachment" : [], + "attributedTo" : "https://social.sakamoto.gq/users/eal", + "cc" : [ + "https://social.sakamoto.gq/users/eal/followers" + ], + "content" : "@tuxcrafting @fixpoint @blobyoumu @grips @igel What's bad about nukes?", + "context" : "https://cawfee.club/contexts/ad6c73d8-efc2-4e74-84ea-2dacf1a27a5e", + "conversation" : "https://cawfee.club/contexts/ad6c73d8-efc2-4e74-84ea-2dacf1a27a5e", + "id" : "https://social.sakamoto.gq/objects/f20f2497-66d9-4a52-a2e1-1be2a39c32c1", + "inReplyTo" : "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069", + "published" : "2020-06-26T15:20:15.975737Z", + "sensitive" : false, + "summary" : "", + "tag" : [ + { + "href" : "https://blob.cat/users/blobyoumu", + "name" : "@blobyoumu@blob.cat", + "type" : "Mention" + }, + { + "href" : "https://busshi.moe/users/tuxcrafting", + "name" : "@tuxcrafting@busshi.moe", + "type" : "Mention" + }, + { + "href" : "https://cawfee.club/users/grips", + "name" : "@grips@cawfee.club", + "type" : "Mention" + }, + { + "href" : "https://jaeger.website/users/igel", + "name" : "@igel@jaeger.website", + "type" : "Mention" + }, + { + "href" : "https://stereophonic.space/users/fixpoint", + "name" : "@fixpoint@stereophonic.space", + "type" : "Mention" + } + ], + "to" : [ + "https://busshi.moe/users/tuxcrafting", + "https://www.w3.org/ns/activitystreams#Public", + "https://blob.cat/users/blobyoumu", + "https://stereophonic.space/users/fixpoint", + "https://cawfee.club/users/grips", + "https://jaeger.website/users/igel" + ], + "type" : "Note" +} diff --git a/test/fixtures/fetch_mocks/eal.json b/test/fixtures/fetch_mocks/eal.json new file mode 100644 index 000000000..a605476e6 --- /dev/null +++ b/test/fixtures/fetch_mocks/eal.json @@ -0,0 +1,43 @@ +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://social.sakamoto.gq/schemas/litepub-0.1.jsonld", + { + "@language" : "und" + } + ], + "attachment" : [], + "discoverable" : true, + "endpoints" : { + "oauthAuthorizationEndpoint" : "https://social.sakamoto.gq/oauth/authorize", + "oauthRegistrationEndpoint" : "https://social.sakamoto.gq/api/v1/apps", + "oauthTokenEndpoint" : "https://social.sakamoto.gq/oauth/token", + "sharedInbox" : "https://social.sakamoto.gq/inbox", + "uploadMedia" : "https://social.sakamoto.gq/api/ap/upload_media" + }, + "followers" : "https://social.sakamoto.gq/users/eal/followers", + "following" : "https://social.sakamoto.gq/users/eal/following", + "icon" : { + "type" : "Image", + "url" : "https://social.sakamoto.gq/media/f1cb6f79bf6839f3223ca240441f766056b74ddd23c69bcaf8bb1ba1ecff6eec.jpg" + }, + "id" : "https://social.sakamoto.gq/users/eal", + "image" : { + "type" : "Image", + "url" : "https://social.sakamoto.gq/media/e5cccf26421e8366f4e34be3c9d5042b8bc8dcceccc7c8e89785fa312dd9632c.jpg" + }, + "inbox" : "https://social.sakamoto.gq/users/eal/inbox", + "manuallyApprovesFollowers" : false, + "name" : "에알", + "outbox" : "https://social.sakamoto.gq/users/eal/outbox", + "preferredUsername" : "eal", + "publicKey" : { + "id" : "https://social.sakamoto.gq/users/eal#main-key", + "owner" : "https://social.sakamoto.gq/users/eal", + "publicKeyPem" : "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz3pF85YOhhv2Zaxv9YQ7\nrCe1aEhetCMVHtrK63tUVGoGdsblyKnVeJNbFcr6k3y35OpHS3HXIi6GzgihYcTu\nONLP4eQMHTnLUNAQZi03mjJA4iIq8v/tm8ZkL2mXsQSAbWj6Iq518mHNN7OvCoNt\n3Xjepl/0kgkc2gsund7m8r+Wu0Fusx6UlUyyAk3PexdDRdSSlVLeskqtP8jtdQDo\nL70pMyL+VD+Qb9RKFdtgJ+M4OqYP+7FVzCqXN0QIPhFf/kvHSLr+c4Y3Wm0nAKHU\n9CwXWXz5Xqscpv41KlgnUCOkTXb5eBSt23lNulae5srVzWBiFb6guiCpNzBGa+Sq\nrwIDAQAB\n-----END PUBLIC KEY-----\n\n" + }, + "summary" : "Pizza napoletana supremacist.

    Any artworks posted here that are good are not mine.", + "tag" : [], + "type" : "Person", + "url" : "https://social.sakamoto.gq/users/eal" +} diff --git a/test/fixtures/fetch_mocks/tuxcrafting.json b/test/fixtures/fetch_mocks/tuxcrafting.json new file mode 100644 index 000000000..5dce2a16d --- /dev/null +++ b/test/fixtures/fetch_mocks/tuxcrafting.json @@ -0,0 +1,59 @@ +{ + "@context" : [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "IdentityProof" : "toot:IdentityProof", + "PropertyValue" : "schema:PropertyValue", + "alsoKnownAs" : { + "@id" : "as:alsoKnownAs", + "@type" : "@id" + }, + "discoverable" : "toot:discoverable", + "featured" : { + "@id" : "toot:featured", + "@type" : "@id" + }, + "focalPoint" : { + "@container" : "@list", + "@id" : "toot:focalPoint" + }, + "manuallyApprovesFollowers" : "as:manuallyApprovesFollowers", + "movedTo" : { + "@id" : "as:movedTo", + "@type" : "@id" + }, + "schema" : "http://schema.org#", + "toot" : "http://joinmastodon.org/ns#", + "value" : "schema:value" + } + ], + "attachment" : [], + "discoverable" : true, + "endpoints" : { + "sharedInbox" : "https://busshi.moe/inbox" + }, + "featured" : "https://busshi.moe/users/tuxcrafting/collections/featured", + "followers" : "https://busshi.moe/users/tuxcrafting/followers", + "following" : "https://busshi.moe/users/tuxcrafting/following", + "icon" : { + "mediaType" : "image/jpeg", + "type" : "Image", + "url" : "https://blobcdn.busshi.moe/busshifiles/accounts/avatars/000/046/872/original/054f0806ccb303d0.jpg" + }, + "id" : "https://busshi.moe/users/tuxcrafting", + "inbox" : "https://busshi.moe/users/tuxcrafting/inbox", + "manuallyApprovesFollowers" : true, + "name" : "@tuxcrafting@localhost:8080", + "outbox" : "https://busshi.moe/users/tuxcrafting/outbox", + "preferredUsername" : "tuxcrafting", + "publicKey" : { + "id" : "https://busshi.moe/users/tuxcrafting#main-key", + "owner" : "https://busshi.moe/users/tuxcrafting", + "publicKeyPem" : "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwqWWTBf9OizsBiBhGS/M\nQTT6fB1VvQP6vvxouGZ5cGg1a97V67ouhjJ+nGMuWr++DNYjJYkk2TOynfykk0H/\n8rRSujSe3BNRKYGNzdnRJu/4XxgIE847Fqx5SijSP23JGYcn8TjeSUsN2u2YYVXK\n+Eb3Bu7DjGiqwNon6YB0h5qkGjkMSMVIFn0hZx6Z21bkfYWgra96Ok5OWf7Ck3je\nCuErlCMZcbQcHtFpBueJAxYchjNvm6fqwZxLX/NtaHdr7Fm2kin89mqzliapBlFH\nCXk7Jln6xV5I6ryggPAMzm3fuHzeo0RWlu8lrxLfARBVwaQQZS99bwqp6N9O2aUp\nYwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "summary" : "

    expert procrastinator

    trans(humanist|gender|istorized)

    web: https://tuxcrafting.port0.org
    pronouns: she/they
    languages: french (native)/english (fluent)/hebrew (ok-ish)/esperanto (barely)

    ", + "tag" : [], + "type" : "Person", + "url" : "https://busshi.moe/@tuxcrafting" +} diff --git a/test/object/fetcher_test.exs b/test/object/fetcher_test.exs index c06e91f12..d9098ea1b 100644 --- a/test/object/fetcher_test.exs +++ b/test/object/fetcher_test.exs @@ -26,6 +26,46 @@ defmodule Pleroma.Object.FetcherTest do :ok end + describe "error cases" do + setup do + mock(fn + %{method: :get, url: "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM"} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/fetch_mocks/9wTkLEnuq47B25EehM.json") + } + + %{method: :get, url: "https://social.sakamoto.gq/users/eal"} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/fetch_mocks/eal.json") + } + + %{method: :get, url: "https://busshi.moe/users/tuxcrafting/statuses/104410921027210069"} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/fetch_mocks/104410921027210069.json") + } + + %{method: :get, url: "https://busshi.moe/users/tuxcrafting"} -> + %Tesla.Env{ + status: 500 + } + end) + + :ok + end + + @tag capture_log: true + test "it works when fetching the OP actor errors out" do + # Here we simulate a case where the author of the OP can't be read + assert {:ok, _} = + Fetcher.fetch_object_from_id( + "https://social.sakamoto.gq/notice/9wTkLEnuq47B25EehM" + ) + end + end + describe "max thread distance restriction" do @ap_id "http://mastodon.example.org/@admin/99541947525187367" setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) -- cgit v1.2.3 From fedfe8f7d6f78d77e9cbaf70fa8a9e8df38463f7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 1 Jul 2020 12:26:07 +0200 Subject: ActivityPub: Handle clashing nicknames for the same ap id If we get a new user (identified by ap_id) that would have the same nickname as an existing user, give the existing user a nickname that is prepended with the user id, as this will never clash. This can happen when a user switches server software and that soft- ware generates ap ids in a different way. --- lib/pleroma/web/activity_pub/activity_pub.ex | 12 ++++++++++++ test/user_test.exs | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 05bd824f5..94117202c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1371,6 +1371,16 @@ def fetch_and_prepare_user_from_ap_id(ap_id) do end end + def maybe_handle_clashing_nickname(nickname) do + with %User{} = old_user <- User.get_by_nickname(nickname) do + Logger.info("Found an old user for #{nickname}, ap id is #{old_user.ap_id}, renaming.") + + old_user + |> User.remote_user_changeset(%{nickname: "#{old_user.id}.#{old_user.nickname}"}) + |> User.update_and_set_cache() + end + end + def make_user_from_ap_id(ap_id) do user = User.get_cached_by_ap_id(ap_id) @@ -1383,6 +1393,8 @@ def make_user_from_ap_id(ap_id) do |> User.remote_user_changeset(data) |> User.update_and_set_cache() else + maybe_handle_clashing_nickname(data[:nickname]) + data |> User.remote_user_changeset() |> Repo.insert() diff --git a/test/user_test.exs b/test/user_test.exs index 9b66f3f51..7126bb539 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -597,6 +597,31 @@ test "updates an existing user, if stale" do refute user.last_refreshed_at == orig_user.last_refreshed_at end + test "if nicknames clash, the old user gets a prefix with the old id to the nickname" do + a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800) + + orig_user = + insert( + :user, + local: false, + nickname: "admin@mastodon.example.org", + ap_id: "http://mastodon.example.org/users/harinezumigari", + last_refreshed_at: a_week_ago + ) + + assert orig_user.last_refreshed_at == a_week_ago + + {:ok, user} = User.get_or_fetch_by_ap_id("http://mastodon.example.org/users/admin") + + assert user.inbox + + refute user.id == orig_user.id + + orig_user = User.get_by_id(orig_user.id) + + assert orig_user.nickname == "#{orig_user.id}.admin@mastodon.example.org" + end + @tag capture_log: true test "it returns the old user if stale, but unfetchable" do a_week_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -604_800) -- cgit v1.2.3 From 61fe94d698a6f73e7a3f6224ed4be93b30ba0e54 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 2 Jul 2020 09:33:50 +0200 Subject: SideEffects: Refactor. --- lib/pleroma/web/activity_pub/side_effects.ex | 6 +++++- test/web/activity_pub/side_effects_test.exs | 23 +++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5cc2eb378..c84af68f4 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Activity.Ir.Topics alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Push @@ -97,7 +98,10 @@ def handle(%{data: %{"type" => "Announce"}} = object, meta) do if !User.is_internal_user?(user) do Notification.create_notifications(object) - ActivityPub.stream_out(object) + + object + |> Topics.get_activity_topics() + |> Streamer.stream(object) end {:ok, object, meta} diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index af27c34b4..2649b060a 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -589,10 +589,29 @@ test "creates a notification", %{announce: announce, poster: poster} do end test "it streams out the announce", %{announce: announce} do - with_mock Pleroma.Web.ActivityPub.ActivityPub, [:passthrough], stream_out: fn _ -> nil end do + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + }, + { + Pleroma.Web.Push, + [], + [ + send: fn _ -> nil end + ] + } + ]) do {:ok, announce, _} = SideEffects.handle(announce) - assert called(Pleroma.Web.ActivityPub.ActivityPub.stream_out(announce)) + assert called( + Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], announce) + ) + + assert called(Pleroma.Web.Push.send(:_)) end end end -- cgit v1.2.3 From 311b7c19d0a34654b785116fe22823132d5a9284 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 2 Jul 2020 09:50:26 +0200 Subject: Streamer: Align announce streaming with polling. --- lib/pleroma/web/streamer/streamer.ex | 1 + test/web/streamer/streamer_test.exs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index d1d2c9b9c..73ee3e1e1 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -116,6 +116,7 @@ def filtered_by_user?(%User{} = user, %Activity{} = item) do true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, + true <- !(item.data["type"] == "Announce" && parent.data["actor"] == user.ap_id), true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)), true <- MapSet.disjoint?(recipients, recipient_blocks), %{host: item_host} <- URI.parse(item.actor), diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index 245f6e63f..dfe341b34 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -116,6 +116,18 @@ test "it streams boosts of the user in the 'user' stream", %{user: user} do refute Streamer.filtered_by_user?(user, announce) end + test "it does not stream announces of the user's own posts in the 'user' stream", %{ + user: user + } do + Streamer.get_topic_and_add_socket("user", user) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) + {:ok, announce} = CommonAPI.repeat(activity.id, other_user) + + assert Streamer.filtered_by_user?(user, announce) + end + test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do Streamer.get_topic_and_add_socket("user", user) -- cgit v1.2.3 From ce9b7c0e0f1f4759979fb4690021f2f11c408ac0 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 2 Jul 2020 09:54:48 +0200 Subject: Changelog: Update with stream changes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6928dcd..335d29195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). API Changes - **Breaking:** Emoji API: changed methods and renamed routes. +- Streaming: Repeats of a user's posts will no longer be pushed to the user's stream.
    -- cgit v1.2.3 From bad08f34caf0f037b5d6e724628a222fe65b751b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 2 Jul 2020 09:57:31 +0200 Subject: Credo fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index c84af68f4..61feeae4d 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do collection, and so on. """ alias Pleroma.Activity + alias Pleroma.Activity.Ir.Topics alias Pleroma.Chat alias Pleroma.Chat.MessageReference alias Pleroma.Notification @@ -13,7 +14,6 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Activity.Ir.Topics alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Push -- cgit v1.2.3 From a5d611abc296f23c939086f479c1ef708fcb54a8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 2 Jul 2020 10:16:19 -0500 Subject: Update AdminFE build to utilize new MRF metadata --- priv/static/adminfe/app.01bdb34a.css | Bin 0 -> 12837 bytes priv/static/adminfe/app.6684eb28.css | Bin 12837 -> 0 bytes priv/static/adminfe/chunk-43ca.0de86b6d.css | Bin 23710 -> 0 bytes priv/static/adminfe/chunk-43ca.af749c6c.css | Bin 0 -> 24279 bytes priv/static/adminfe/chunk-c5f4.0827b1ce.css | Bin 5669 -> 0 bytes priv/static/adminfe/chunk-c5f4.b1112f18.css | Bin 0 -> 5842 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.3fcec8f6.js | Bin 192591 -> 0 bytes priv/static/adminfe/static/js/app.3fcec8f6.js.map | Bin 426204 -> 0 bytes priv/static/adminfe/static/js/app.f220ac13.js | Bin 0 -> 194930 bytes priv/static/adminfe/static/js/app.f220ac13.js.map | Bin 0 -> 430912 bytes priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js | Bin 0 -> 21596 bytes .../adminfe/static/js/chunk-0cbc.2b0f8802.js.map | Bin 0 -> 86354 bytes priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js | Bin 21585 -> 0 bytes .../adminfe/static/js/chunk-0cbc.43ff796f.js.map | Bin 86326 -> 0 bytes priv/static/adminfe/static/js/chunk-43ca.3debeff7.js | Bin 119060 -> 0 bytes .../adminfe/static/js/chunk-43ca.3debeff7.js.map | Bin 402101 -> 0 bytes priv/static/adminfe/static/js/chunk-43ca.aceb457c.js | Bin 0 -> 112966 bytes .../adminfe/static/js/chunk-43ca.aceb457c.js.map | Bin 0 -> 386132 bytes priv/static/adminfe/static/js/chunk-c5f4.304479e7.js | Bin 23657 -> 0 bytes .../adminfe/static/js/chunk-c5f4.304479e7.js.map | Bin 83935 -> 0 bytes priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js | Bin 0 -> 26121 bytes .../adminfe/static/js/chunk-c5f4.cf269f9b.js.map | Bin 0 -> 89970 bytes priv/static/adminfe/static/js/runtime.0a70a9f5.js | Bin 0 -> 4229 bytes priv/static/adminfe/static/js/runtime.0a70a9f5.js.map | Bin 0 -> 17240 bytes priv/static/adminfe/static/js/runtime.5bae86dc.js | Bin 4229 -> 0 bytes priv/static/adminfe/static/js/runtime.5bae86dc.js.map | Bin 17240 -> 0 bytes 27 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/app.01bdb34a.css delete mode 100644 priv/static/adminfe/app.6684eb28.css delete mode 100644 priv/static/adminfe/chunk-43ca.0de86b6d.css create mode 100644 priv/static/adminfe/chunk-43ca.af749c6c.css delete mode 100644 priv/static/adminfe/chunk-c5f4.0827b1ce.css create mode 100644 priv/static/adminfe/chunk-c5f4.b1112f18.css delete mode 100644 priv/static/adminfe/static/js/app.3fcec8f6.js delete mode 100644 priv/static/adminfe/static/js/app.3fcec8f6.js.map create mode 100644 priv/static/adminfe/static/js/app.f220ac13.js create mode 100644 priv/static/adminfe/static/js/app.f220ac13.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js create mode 100644 priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js delete mode 100644 priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-43ca.3debeff7.js delete mode 100644 priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-43ca.aceb457c.js create mode 100644 priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-c5f4.304479e7.js delete mode 100644 priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map create mode 100644 priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js create mode 100644 priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map create mode 100644 priv/static/adminfe/static/js/runtime.0a70a9f5.js create mode 100644 priv/static/adminfe/static/js/runtime.0a70a9f5.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.5bae86dc.js delete mode 100644 priv/static/adminfe/static/js/runtime.5bae86dc.js.map diff --git a/priv/static/adminfe/app.01bdb34a.css b/priv/static/adminfe/app.01bdb34a.css new file mode 100644 index 000000000..1b83a8a39 Binary files /dev/null and b/priv/static/adminfe/app.01bdb34a.css differ diff --git a/priv/static/adminfe/app.6684eb28.css b/priv/static/adminfe/app.6684eb28.css deleted file mode 100644 index 1b83a8a39..000000000 Binary files a/priv/static/adminfe/app.6684eb28.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-43ca.0de86b6d.css b/priv/static/adminfe/chunk-43ca.0de86b6d.css deleted file mode 100644 index 817a6be44..000000000 Binary files a/priv/static/adminfe/chunk-43ca.0de86b6d.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-43ca.af749c6c.css b/priv/static/adminfe/chunk-43ca.af749c6c.css new file mode 100644 index 000000000..504affb93 Binary files /dev/null and b/priv/static/adminfe/chunk-43ca.af749c6c.css differ diff --git a/priv/static/adminfe/chunk-c5f4.0827b1ce.css b/priv/static/adminfe/chunk-c5f4.0827b1ce.css deleted file mode 100644 index eb59ca31a..000000000 Binary files a/priv/static/adminfe/chunk-c5f4.0827b1ce.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-c5f4.b1112f18.css b/priv/static/adminfe/chunk-c5f4.b1112f18.css new file mode 100644 index 000000000..d3b7604aa Binary files /dev/null and b/priv/static/adminfe/chunk-c5f4.b1112f18.css differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index c8f62d0c7..22b3143d2 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.3fcec8f6.js b/priv/static/adminfe/static/js/app.3fcec8f6.js deleted file mode 100644 index 9a6fb1307..000000000 Binary files a/priv/static/adminfe/static/js/app.3fcec8f6.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.3fcec8f6.js.map b/priv/static/adminfe/static/js/app.3fcec8f6.js.map deleted file mode 100644 index cc4ce87b3..000000000 Binary files a/priv/static/adminfe/static/js/app.3fcec8f6.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.f220ac13.js b/priv/static/adminfe/static/js/app.f220ac13.js new file mode 100644 index 000000000..e5e1eda91 Binary files /dev/null and b/priv/static/adminfe/static/js/app.f220ac13.js differ diff --git a/priv/static/adminfe/static/js/app.f220ac13.js.map b/priv/static/adminfe/static/js/app.f220ac13.js.map new file mode 100644 index 000000000..90c22121e Binary files /dev/null and b/priv/static/adminfe/static/js/app.f220ac13.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js b/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js new file mode 100644 index 000000000..d29070b62 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map b/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map new file mode 100644 index 000000000..7c99d9d48 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js deleted file mode 100644 index 232f0d447..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map b/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map deleted file mode 100644 index dbca0ba8e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0cbc.43ff796f.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js b/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js deleted file mode 100644 index 6d653cf62..000000000 Binary files a/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map b/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map deleted file mode 100644 index f7976891f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-43ca.3debeff7.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js b/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js new file mode 100644 index 000000000..f9fcbc288 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map b/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map new file mode 100644 index 000000000..3c71ad178 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js deleted file mode 100644 index 4220621be..000000000 Binary files a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map b/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map deleted file mode 100644 index 2ab89731d..000000000 Binary files a/priv/static/adminfe/static/js/chunk-c5f4.304479e7.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js b/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js new file mode 100644 index 000000000..2d5308031 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map b/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map new file mode 100644 index 000000000..d5fc047ee Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.0a70a9f5.js b/priv/static/adminfe/static/js/runtime.0a70a9f5.js new file mode 100644 index 000000000..a99d1d369 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.0a70a9f5.js differ diff --git a/priv/static/adminfe/static/js/runtime.0a70a9f5.js.map b/priv/static/adminfe/static/js/runtime.0a70a9f5.js.map new file mode 100644 index 000000000..62e726b22 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.0a70a9f5.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.5bae86dc.js b/priv/static/adminfe/static/js/runtime.5bae86dc.js deleted file mode 100644 index e5fb1554b..000000000 Binary files a/priv/static/adminfe/static/js/runtime.5bae86dc.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.5bae86dc.js.map b/priv/static/adminfe/static/js/runtime.5bae86dc.js.map deleted file mode 100644 index 46c6380d9..000000000 Binary files a/priv/static/adminfe/static/js/runtime.5bae86dc.js.map and /dev/null differ -- cgit v1.2.3 From d169e51b7e33808a82b314e1ec54d709088a55fb Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 2 Jul 2020 10:27:15 -0500 Subject: Improve descriptions, move primary MRF settings to top for AdminFE ordering --- config/description.exs | 98 +++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/config/description.exs b/config/description.exs index 39c6c5793..5ffef15fc 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1389,13 +1389,51 @@ } ] }, + %{ + group: :pleroma, + key: :mrf, + tab: :mrf, + label: "MRF", + type: :group, + description: "General MRF settings", + children: [ + %{ + key: :policies, + type: [:module, {:list, :module}], + description: + "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", + suggestions: + Generator.list_modules_in_dir( + "lib/pleroma/web/activity_pub/mrf", + "Elixir.Pleroma.Web.ActivityPub.MRF." + ) + }, + %{ + key: :transparency, + label: "MRF transparency", + type: :boolean, + description: + "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" + }, + %{ + key: :transparency_exclusions, + label: "MRF transparency exclusions", + type: {:list, :string}, + description: + "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", + suggestions: [ + "exclusion.com" + ] + } + ] + }, %{ group: :pleroma, key: :mrf_simple, tab: :mrf, label: "MRF Simple", type: :group, - description: "Message Rewrite Facility", + description: "Simple ingress policies", children: [ %{ key: :media_removal, @@ -1414,7 +1452,7 @@ key: :federated_timeline_removal, type: {:list, :string}, description: - "List of instances to remove from Federated (aka The Whole Known Network) Timeline", + "List of instances to remove from the Federated (aka The Whole Known Network) Timeline", suggestions: ["example.com", "*.example.com"] }, %{ @@ -1461,12 +1499,12 @@ tab: :mrf, label: "MRF Activity Expiration Policy", type: :group, - description: "Adds expiration to all local Create Note activities", + description: "Adds automatic expiration to all local activities", children: [ %{ key: :days, type: :integer, - description: "Default global expiration time for all local Create activities (in days)", + description: "Default global expiration time for all local activities (in days)", suggestions: [90, 365] } ] @@ -1498,7 +1536,7 @@ key: :mrf_rejectnonpublic, tab: :mrf, description: - "MRF RejectNonPublic settings. RejectNonPublic drops posts with non-public visibility settings.", + "RejectNonPublic drops posts with non-public visibility settings.", label: "MRF Reject Non Public", type: :group, children: [ @@ -1521,14 +1559,14 @@ tab: :mrf, label: "MRF Hellthread", type: :group, - description: "Block messages with too much mentions", + description: "Block messages with excessive user mentions", children: [ %{ key: :delist_threshold, type: :integer, description: - "Number of mentioned users after which the message gets delisted (the message can still be seen, " <> - " but it will not show up in public timelines and mentioned users won't get notifications about it). Set to 0 to disable.", + "Number of mentioned users after which the message gets removed from timelines and" <> + "disables notifications. Set to 0 to disable.", suggestions: [10] }, %{ @@ -1577,7 +1615,7 @@ tab: :mrf, label: "MRF Mention", type: :group, - description: "Block messages which mention a user", + description: "Block messages which mention a specific user", children: [ %{ key: :actors, @@ -3070,7 +3108,7 @@ label: "MRF Object Age", tab: :mrf, type: :group, - description: "Rejects or delists posts based on their age when received.", + description: "Rejects or delists posts based on their timestamp deviance from your server's clock.", children: [ %{ key: :threshold, @@ -3083,7 +3121,7 @@ type: {:list, :atom}, description: "A list of actions to apply to the post. `:delist` removes the post from public timelines; " <> - "`:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines; " <> + "`:strip_followers` removes followers from the ActivityPub recipient list ensuring they won't be delivered to home timelines; " <> "`:reject` rejects the message entirely", suggestions: [:delist, :strip_followers, :reject] } @@ -3408,43 +3446,5 @@ suggestions: [false] } ] - }, - %{ - group: :pleroma, - key: :mrf, - tab: :mrf, - label: "MRF", - type: :group, - description: "General MRF settings", - children: [ - %{ - key: :policies, - type: [:module, {:list, :module}], - description: - "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/web/activity_pub/mrf", - "Elixir.Pleroma.Web.ActivityPub.MRF." - ) - }, - %{ - key: :transparency, - label: "MRF transparency", - type: :boolean, - description: - "Make the content of your Message Rewrite Facility settings public (via nodeinfo)" - }, - %{ - key: :transparency_exclusions, - label: "MRF transparency exclusions", - type: {:list, :string}, - description: - "Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.", - suggestions: [ - "exclusion.com" - ] - } - ] } ] -- cgit v1.2.3 From 80076f1974f4527bf3914a83465c30274721457c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 2 Jul 2020 10:33:27 -0500 Subject: Lint, long lines --- config/description.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/description.exs b/config/description.exs index 5ffef15fc..370af80a6 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1535,8 +1535,7 @@ group: :pleroma, key: :mrf_rejectnonpublic, tab: :mrf, - description: - "RejectNonPublic drops posts with non-public visibility settings.", + description: "RejectNonPublic drops posts with non-public visibility settings.", label: "MRF Reject Non Public", type: :group, children: [ @@ -3108,7 +3107,8 @@ label: "MRF Object Age", tab: :mrf, type: :group, - description: "Rejects or delists posts based on their timestamp deviance from your server's clock.", + description: + "Rejects or delists posts based on their timestamp deviance from your server's clock.", children: [ %{ key: :threshold, -- cgit v1.2.3 From d44ec2bf4ced0278b8cc5a0b5fa36fcfe38df38b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 2 Jul 2020 12:55:08 -0500 Subject: Remove camelCase from the keys --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 347480d49..89e48fba5 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -93,10 +93,10 @@ def federation do def fields_limits do %{ - maxFields: Config.get([:instance, :max_account_fields]), - maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), - nameLength: Config.get([:instance, :account_field_name_length]), - valueLength: Config.get([:instance, :account_field_value_length]) + max_fields: Config.get([:instance, :max_account_fields]), + max_remote_fields: Config.get([:instance, :max_remote_account_fields]), + name_length: Config.get([:instance, :account_field_name_length]), + value_length: Config.get([:instance, :account_field_value_length]) } end end -- cgit v1.2.3 From 02d855b2b9a4bb88f0ddd7edc9bca30b66dbf241 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 2 Jul 2020 12:59:45 -0500 Subject: Document the new API extension of /api/v1/instance --- CHANGELOG.md | 1 + docs/API/differences_in_mastoapi_responses.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b6928dcd..e21318580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). API Changes - **Breaking:** Emoji API: changed methods and renamed routes. +- Added `pleroma.metadata.fields_limits` to /api/v1/instance
    diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 13920e5f9..72b5984ae 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -228,6 +228,7 @@ Has theses additional parameters (which are the same as in Pleroma-API): - `background_image`: A background image that frontends can use - `pleroma.metadata.features`: A list of supported features - `pleroma.metadata.federation`: The federation restrictions of this instance +- `pleroma.metadata.fields_limits`: A list of values detailing the length and count limitation for various instance-configurable fields. - `vapid_public_key`: The public key needed for push messages ## Markers -- cgit v1.2.3 From 90764670dc83c39c28cd7851f08f77f1e8bcf25a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 3 Jul 2020 11:02:15 +0300 Subject: [#1892] Excluded internal users (applications) from user search results, reinstated service actors in search results. --- lib/pleroma/user/search.ex | 6 +++--- test/user_search_test.exs | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 0293c6ae7..42ff1de78 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -52,7 +52,7 @@ defp search_query(query_string, for_user, following) do |> base_query(following) |> filter_blocked_user(for_user) |> filter_invisible_users() - |> filter_bots() + |> filter_internal_users() |> filter_blocked_domains(for_user) |> fts_search(query_string) |> trigram_rank(query_string) @@ -110,8 +110,8 @@ defp filter_invisible_users(query) do from(q in query, where: q.invisible == false) end - defp filter_bots(query) do - from(q in query, where: q.actor_type not in ["Application", "Service"]) + defp filter_internal_users(query) do + from(q in query, where: q.actor_type != "Application") end defp filter_blocked_user(query, %User{} = blocker) do diff --git a/test/user_search_test.exs b/test/user_search_test.exs index 9a74b9764..f030523d3 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -25,11 +25,13 @@ test "excludes invisible users from results" do assert found_user.id == user.id end - test "excludes bots from results" do - insert(:user, actor_type: "Service", nickname: "bot1") - insert(:user, actor_type: "Application", nickname: "bot2") + test "excludes service actors from results" do + insert(:user, actor_type: "Application", nickname: "user1") + service = insert(:user, actor_type: "Service", nickname: "user2") + person = insert(:user, actor_type: "Person", nickname: "user3") - assert [] = User.search("bot") + assert [found_user1, found_user2] = User.search("user") + assert [found_user1.id, found_user2.id] -- [service.id, person.id] == [] end test "accepts limit parameter" do -- cgit v1.2.3 From 59b426ebefd1881181888a5b0e6abe8338b65d3f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 11:25:12 +0200 Subject: Notification Backfill: Explicitly select the needed fields. Prevents a crashing migration when we change user fields. --- lib/pleroma/migration_helper/notification_backfill.ex | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/migration_helper/notification_backfill.ex b/lib/pleroma/migration_helper/notification_backfill.ex index b3770307a..d260e62ca 100644 --- a/lib/pleroma/migration_helper/notification_backfill.ex +++ b/lib/pleroma/migration_helper/notification_backfill.ex @@ -3,7 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.MigrationHelper.NotificationBackfill do - alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -25,18 +24,27 @@ def fill_in_notification_types do |> type_from_activity() notification - |> Notification.changeset(%{type: type}) + |> Ecto.Changeset.change(%{type: type}) |> Repo.update() end) end + defp get_by_ap_id(ap_id) do + q = + from(u in User, + select: u.id + ) + + Repo.get_by(q, ap_id: ap_id) + end + # This is copied over from Notifications to keep this stable. defp type_from_activity(%{data: %{"type" => type}} = activity) do case type do "Follow" -> accepted_function = fn activity -> - with %User{} = follower <- User.get_by_ap_id(activity.data["actor"]), - %User{} = followed <- User.get_by_ap_id(activity.data["object"]) do + with %User{} = follower <- get_by_ap_id(activity.data["actor"]), + %User{} = followed <- get_by_ap_id(activity.data["object"]) do Pleroma.FollowingRelationship.following?(follower, followed) end end -- cgit v1.2.3 From 8ad166e8e385b7baea79dc3949b438edba25c69f Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 12:46:28 +0200 Subject: Migrations: Add `accepts_chat_messages` to users. --- .../20200703101031_add_chat_acceptance_to_users.exs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs diff --git a/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs b/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs new file mode 100644 index 000000000..4ae3c4201 --- /dev/null +++ b/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs @@ -0,0 +1,12 @@ +defmodule Pleroma.Repo.Migrations.AddChatAcceptanceToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add(:accepts_chat_messages, :boolean, nullable: false, default: false) + end + + # Looks stupid but makes the update much faster + execute("update users set accepts_chat_messages = local where local = true") + end +end -- cgit v1.2.3 From 98bfdba108d4213eea82dc4d63edb8bb834118fb Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 12:47:05 +0200 Subject: User: On registration, set `accepts_chat_messages` to true. --- lib/pleroma/user.ex | 5 ++++- test/user_test.exs | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8a54546d6..79e094a79 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -138,6 +138,7 @@ defmodule Pleroma.User do field(:also_known_as, {:array, :string}, default: []) field(:inbox, :string) field(:shared_inbox, :string) + field(:accepts_chat_messages, :boolean, default: false) embeds_one( :notification_settings, @@ -623,6 +624,7 @@ def force_password_reset(user), do: update_password_reset_pending(user, true) def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + params = Map.put_new(params, :accepts_chat_messages, true) need_confirmation? = if is_nil(opts[:need_confirmation]) do @@ -641,7 +643,8 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do :nickname, :password, :password_confirmation, - :emoji + :emoji, + :accepts_chat_messages ]) |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) diff --git a/test/user_test.exs b/test/user_test.exs index 7126bb539..9788e09d9 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -486,6 +486,15 @@ test "it sets the password_hash and ap_id" do } setup do: clear_config([:instance, :account_activation_required], true) + test "it sets the 'accepts_chat_messages' set to true" do + changeset = User.register_changeset(%User{}, @full_user_data) + assert changeset.valid? + + {:ok, user} = Repo.insert(changeset) + + assert user.accepts_chat_messages + end + test "it creates unconfirmed user" do changeset = User.register_changeset(%User{}, @full_user_data) assert changeset.valid? -- cgit v1.2.3 From 3250228be9719b0afa24c97b64f56d2275c4fe67 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 13:07:33 +0200 Subject: AccountView: Add 'accepts_chat_messages' to view. --- lib/pleroma/web/mastodon_api/views/account_view.ex | 3 ++- test/web/mastodon_api/views/account_view_test.exs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index a6e64b4ab..6a643bfcc 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -245,7 +245,8 @@ defp do_render("show.json", %{user: user} = opts) do hide_favorites: user.hide_favorites, relationship: relationship, skip_thread_containment: user.skip_thread_containment, - background_image: image_url(user.background) |> MediaProxy.url() + background_image: image_url(user.background) |> MediaProxy.url(), + accepts_chat_messages: user.accepts_chat_messages } } |> maybe_put_role(user, opts[:for]) diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 80b1f734c..3234a26a2 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -85,7 +85,8 @@ test "Represent a user account" do hide_followers_count: false, hide_follows_count: false, relationship: %{}, - skip_thread_containment: false + skip_thread_containment: false, + accepts_chat_messages: false } } @@ -162,7 +163,8 @@ test "Represent a Service(bot) account" do hide_followers_count: false, hide_follows_count: false, relationship: %{}, - skip_thread_containment: false + skip_thread_containment: false, + accepts_chat_messages: false } } -- cgit v1.2.3 From 37fdb05058d17abde11fd3e55ce896464c7d22e4 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 13:12:23 +0200 Subject: User, Migration: Change `accepts_chat_messages` to be nullable This is to model the ambiguous state of most users. --- lib/pleroma/user.ex | 2 +- .../20200703101031_add_chat_acceptance_to_users.exs | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 79e094a79..7a684b192 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -138,7 +138,7 @@ defmodule Pleroma.User do field(:also_known_as, {:array, :string}, default: []) field(:inbox, :string) field(:shared_inbox, :string) - field(:accepts_chat_messages, :boolean, default: false) + field(:accepts_chat_messages, :boolean, default: nil) embeds_one( :notification_settings, diff --git a/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs b/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs index 4ae3c4201..8dfda89f1 100644 --- a/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs +++ b/priv/repo/migrations/20200703101031_add_chat_acceptance_to_users.exs @@ -1,12 +1,17 @@ defmodule Pleroma.Repo.Migrations.AddChatAcceptanceToUsers do use Ecto.Migration - def change do + def up do alter table(:users) do - add(:accepts_chat_messages, :boolean, nullable: false, default: false) + add(:accepts_chat_messages, :boolean, nullable: true) end - # Looks stupid but makes the update much faster - execute("update users set accepts_chat_messages = local where local = true") + execute("update users set accepts_chat_messages = true where local = true") + end + + def down do + alter table(:users) do + remove(:accepts_chat_messages) + end end end -- cgit v1.2.3 From db76c26469f234ca36e9c16deb01de63055535ae Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 13:24:16 +0200 Subject: AccountViewTest: Fix test. --- test/web/mastodon_api/views/account_view_test.exs | 4 ++-- 1 file changed, 2 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 3234a26a2..4aba6aaf1 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -86,7 +86,7 @@ test "Represent a user account" do hide_follows_count: false, relationship: %{}, skip_thread_containment: false, - accepts_chat_messages: false + accepts_chat_messages: nil } } @@ -164,7 +164,7 @@ test "Represent a Service(bot) account" do hide_follows_count: false, relationship: %{}, skip_thread_containment: false, - accepts_chat_messages: false + accepts_chat_messages: nil } } -- cgit v1.2.3 From 26a7cc3f003d79d6026d67a3a8370516b13c2c90 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 13:38:59 +0200 Subject: UserView: Add acceptsChatMessages field --- lib/pleroma/web/activity_pub/views/user_view.ex | 10 ++++++++++ test/web/activity_pub/views/user_view_test.exs | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 4a02b09a1..d062d6230 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -81,6 +81,15 @@ def render("user.json", %{user: user}) do fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue")) + chat_message_acceptance = + if is_boolean(user.accepts_chat_messages) do + %{ + "acceptsChatMessages" => user.accepts_chat_messages + } + else + %{} + end + %{ "id" => user.ap_id, "type" => user.actor_type, @@ -103,6 +112,7 @@ def render("user.json", %{user: user}) do "tag" => emoji_tags, "discoverable" => user.discoverable } + |> Map.merge(chat_message_acceptance) |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) |> Map.merge(Utils.make_json_ld_header()) diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index bec15a996..3b4a1bcde 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -158,4 +158,16 @@ test "sets correct totalItems when follows are hidden but the follow counter is assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user}) end end + + describe "acceptsChatMessages" do + test "it returns this value if it is set" do + true_user = insert(:user, accepts_chat_messages: true) + false_user = insert(:user, accepts_chat_messages: false) + nil_user = insert(:user, accepts_chat_messages: nil) + + assert %{"acceptsChatMessages" => true} = UserView.render("user.json", user: true_user) + assert %{"acceptsChatMessages" => false} = UserView.render("user.json", user: false_user) + refute Map.has_key?(UserView.render("user.json", user: nil_user), "acceptsChatMessages") + end + end end -- cgit v1.2.3 From 8289ec67a80697a1a4843c0ea50e66b01bf3bb00 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 13:39:21 +0200 Subject: Litepub: Add acceptsChatMessages to schema. --- priv/static/schemas/litepub-0.1.jsonld | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index 7cc3fee40..c1bcad0f8 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -13,6 +13,7 @@ }, "discoverable": "toot:discoverable", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "acceptsChatMessages": "litepub:acceptsChatMessages", "ostatus": "http://ostatus.org#", "schema": "http://schema.org#", "toot": "http://joinmastodon.org/ns#", -- cgit v1.2.3 From 5c0bf4c4721f03bd854d4466e77aa08e260c9299 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 13:58:34 +0200 Subject: ActivityPub: Ingest information about chat acceptance. --- lib/pleroma/user.ex | 3 +- lib/pleroma/web/activity_pub/activity_pub.ex | 4 +- .../tesla_mock/admin@mastdon.example.org.json | 1 + test/web/activity_pub/activity_pub_test.exs | 55 ++++++++++++---------- 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7a684b192..a4130c89f 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -437,7 +437,8 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :discoverable, :invisible, :actor_type, - :also_known_as + :also_known_as, + :accepts_chat_messages ] ) |> validate_required([:name, :ap_id]) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 94117202c..86428b861 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1224,6 +1224,7 @@ defp object_to_user_data(data) do end) locked = data["manuallyApprovesFollowers"] || false + accepts_chat_messages = data["acceptsChatMessages"] data = Transmogrifier.maybe_fix_user_object(data) discoverable = data["discoverable"] || false invisible = data["invisible"] || false @@ -1262,7 +1263,8 @@ defp object_to_user_data(data) do also_known_as: Map.get(data, "alsoKnownAs", []), public_key: public_key, inbox: data["inbox"], - shared_inbox: shared_inbox + shared_inbox: shared_inbox, + accepts_chat_messages: accepts_chat_messages } # nickname can be nil because of virtual actors diff --git a/test/fixtures/tesla_mock/admin@mastdon.example.org.json b/test/fixtures/tesla_mock/admin@mastdon.example.org.json index 9fdd6557c..f5cf174be 100644 --- a/test/fixtures/tesla_mock/admin@mastdon.example.org.json +++ b/test/fixtures/tesla_mock/admin@mastdon.example.org.json @@ -26,6 +26,7 @@ "summary": "\u003cp\u003e\u003c/p\u003e", "url": "http://mastodon.example.org/@admin", "manuallyApprovesFollowers": false, + "acceptsChatMessages": true, "publicKey": { "id": "http://mastodon.example.org/users/admin#main-key", "owner": "http://mastodon.example.org/users/admin", diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 575e0c5db..ef69f3d91 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -184,36 +184,43 @@ test "it returns a user that is invisible" do assert User.invisible?(user) end - test "it fetches the appropriate tag-restricted posts" do - user = insert(:user) + test "it returns a user that accepts chat messages" do + user_id = "http://mastodon.example.org/users/admin" + {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) - {:ok, status_one} = CommonAPI.post(user, %{status: ". #test"}) - {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) - {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) + assert user.accepts_chat_messages + end + end - fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) + test "it fetches the appropriate tag-restricted posts" do + user = insert(:user) - fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) + {:ok, status_one} = CommonAPI.post(user, %{status: ". #test"}) + {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) + {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) - fetch_three = - ActivityPub.fetch_activities([], %{ - type: "Create", - tag: ["test", "essais"], - tag_reject: ["reject"] - }) + fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) - fetch_four = - ActivityPub.fetch_activities([], %{ - type: "Create", - tag: ["test"], - tag_all: ["test", "reject"] - }) + fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) - assert fetch_one == [status_one, status_three] - assert fetch_two == [status_one, status_two, status_three] - assert fetch_three == [status_one, status_two] - assert fetch_four == [status_three] - end + fetch_three = + ActivityPub.fetch_activities([], %{ + type: "Create", + tag: ["test", "essais"], + tag_reject: ["reject"] + }) + + fetch_four = + ActivityPub.fetch_activities([], %{ + type: "Create", + tag: ["test"], + tag_all: ["test", "reject"] + }) + + assert fetch_one == [status_one, status_three] + assert fetch_two == [status_one, status_two, status_three] + assert fetch_three == [status_one, status_two] + assert fetch_four == [status_three] end describe "insertion" do -- cgit v1.2.3 From b374fd622b120668bb828155e32f9b4f4a142911 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 14:24:54 +0200 Subject: Docs: Document the added `accepts_chat_messages` user property. --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 72b5984ae..755db0e65 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -71,6 +71,7 @@ Has these additional fields under the `pleroma` object: - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. - `unread_notifications_count`: The count of unread notifications. Only returned to the account owner. - `notification_settings`: object, can be absent. See `/api/pleroma/notification_settings` for the parameters/keys returned. +- `accepts_chat_messages`: boolean, but can be null if we don't have that information about a user ### Source -- cgit v1.2.3 From 3ca9af1f9fef081830820b5bea90f789e460b83a Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 14:31:04 +0200 Subject: Account Schema: Add `accepts_chat_messages` --- lib/pleroma/web/api_spec/schemas/account.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 84f18f1b6..3a84a1593 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -102,7 +102,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, description: "A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials`" - } + }, + accepts_chat_messages: %Schema{type: :boolean, nullable: true} } }, source: %Schema{ @@ -169,6 +170,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "is_admin" => false, "is_moderator" => false, "skip_thread_containment" => false, + "accepts_chat_messages" => true, "chat_token" => "SFMyNTY.g3QAAAACZAAEZGF0YW0AAAASOXRLaTNlc2JHN09RZ1oyOTIwZAAGc2lnbmVkbgYARNplS3EB.Mb_Iaqew2bN1I1o79B_iP7encmVCpTKC4OtHZRxdjKc", "unread_conversation_count" => 0, -- cgit v1.2.3 From 4a7b89e37217af4d98746bb934b8264d7a8de51d Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 15:13:27 +0200 Subject: ChatMessageValidator: Additional validation. --- .../activity_pub/object_validators/chat_message_validator.ex | 6 ++++++ test/web/activity_pub/object_validator_test.exs | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index c481d79e0..91b475393 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -93,12 +93,14 @@ def validate_content_or_attachment(cng) do - If both users are in our system - If at least one of the users in this ChatMessage is a local user - If the recipient is not blocking the actor + - If the recipient is explicitly not accepting chat messages """ def validate_local_concern(cng) do with actor_ap <- get_field(cng, :actor), {_, %User{} = actor} <- {:find_actor, User.get_cached_by_ap_id(actor_ap)}, {_, %User{} = recipient} <- {:find_recipient, User.get_cached_by_ap_id(get_field(cng, :to) |> hd())}, + {_, false} <- {:not_accepting_chats?, recipient.accepts_chat_messages == false}, {_, false} <- {:blocking_actor?, User.blocks?(recipient, actor)}, {_, true} <- {:local?, Enum.any?([actor, recipient], & &1.local)} do cng @@ -107,6 +109,10 @@ def validate_local_concern(cng) do cng |> add_error(:actor, "actor is blocked by recipient") + {:not_accepting_chats?, true} -> + cng + |> add_error(:to, "recipient does not accept chat messages") + {:local?, false} -> cng |> add_error(:actor, "actor and recipient are both remote") diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index f38bf7e08..c1a872297 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -223,6 +223,17 @@ test "does not validate if the recipient is blocking the actor", %{ refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) end + test "does not validate if the recipient is not accepting chat messages", %{ + valid_chat_message: valid_chat_message, + recipient: recipient + } do + recipient + |> Ecto.Changeset.change(%{accepts_chat_messages: false}) + |> Pleroma.Repo.update!() + + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + test "does not validate if the actor or the recipient is not in our system", %{ valid_chat_message: valid_chat_message } do -- cgit v1.2.3 From e3b5559780f798945eea59170afa9ef41bbf59b3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 15:54:25 +0200 Subject: AccountController: Make setting accepts_chat_messages possible. --- lib/pleroma/user.ex | 3 ++- lib/pleroma/web/api_spec/operations/account_operation.ex | 9 +++++++-- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 3 ++- .../controllers/account_controller/update_credentials_test.exs | 7 +++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a4130c89f..712bc3047 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -483,7 +483,8 @@ def update_changeset(struct, params \\ %{}) do :pleroma_settings_store, :discoverable, :actor_type, - :also_known_as + :also_known_as, + :accepts_chat_messages ] ) |> unique_constraint(:nickname) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9bde8fc0d..3c05fa55f 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -61,7 +61,7 @@ def update_credentials_operation do description: "Update the user's display and preferences.", operationId: "AccountController.update_credentials", security: [%{"oAuth" => ["write:accounts"]}], - requestBody: request_body("Parameters", update_creadentials_request(), required: true), + requestBody: request_body("Parameters", update_credentials_request(), required: true), responses: %{ 200 => Operation.response("Account", "application/json", Account), 403 => Operation.response("Error", "application/json", ApiError) @@ -458,7 +458,7 @@ defp create_response do } end - defp update_creadentials_request do + defp update_credentials_request do %Schema{ title: "AccountUpdateCredentialsRequest", description: "POST body for creating an account", @@ -492,6 +492,11 @@ defp update_creadentials_request do nullable: true, description: "Whether manual approval of follow requests is required." }, + accepts_chat_messages: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: "Whether the user accepts receiving chat messages." + }, fields_attributes: %Schema{ nullable: true, oneOf: [ diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index b5008d69b..7ff767db6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -160,7 +160,8 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p :show_role, :skip_thread_containment, :allow_following_move, - :discoverable + :discoverable, + :accepts_chat_messages ] |> Enum.reduce(%{}, fn key, acc -> Maps.put_if_present(acc, key, params[key], &{:ok, truthy_param?(&1)}) 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 f67d294ba..37e33bc33 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 @@ -108,6 +108,13 @@ test "updates the user's locking status", %{conn: conn} do assert user_data["locked"] == true end + test "updates the user's chat acceptance status", %{conn: conn} do + conn = patch(conn, "/api/v1/accounts/update_credentials", %{accepts_chat_messages: "false"}) + + assert user_data = json_response_and_validate_schema(conn, 200) + assert user_data["pleroma"]["accepts_chat_messages"] == false + end + test "updates the user's allow_following_move", %{user: user, conn: conn} do assert user.allow_following_move == true -- cgit v1.2.3 From 01695716c8d8916e8a9ddc3c07edfd45c7d5c8f2 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 15:55:18 +0200 Subject: Docs: Document `accepts_chat_messages` setting. --- docs/API/differences_in_mastoapi_responses.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 755db0e65..4514a7d59 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -186,6 +186,7 @@ Additional parameters can be added to the JSON body/Form data: - `pleroma_background_image` - sets the background image of the user. - `discoverable` - if true, discovery of this account in search results and other services is allowed. - `actor_type` - the type of this account. +- `accepts_chat_messages` - if false, this account will reject all chat messages. ### Pleroma Settings Store -- cgit v1.2.3 From ef4c16f6f19c0544ed22972c78195547b4cf3f5d Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 15:59:42 +0200 Subject: Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f878a76..81265a7a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Chats: Added `accepts_chat_messages` field to user, exposed in APIs and federation. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. - Instance: Add `background_image` to configuration and `/api/v1/instance` -- cgit v1.2.3 From 945e75c8e8f05fadd669c7aa084dd6ba7e9b5ab2 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 16:36:55 +0200 Subject: SearchController: Trim query. --- lib/pleroma/web/mastodon_api/controllers/search_controller.ex | 1 + test/web/mastodon_api/controllers/search_controller_test.exs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index e50980122..29affa7d5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -44,6 +44,7 @@ def search2(conn, params), do: do_search(:v2, conn, params) def search(conn, params), do: do_search(:v1, conn, params) defp do_search(version, %{assigns: %{user: user}} = conn, %{q: query} = params) do + query = String.trim(query) options = search_options(params, user) timeout = Keyword.get(Repo.config(), :timeout, 15_000) default_values = %{"statuses" => [], "accounts" => [], "hashtags" => []} diff --git a/test/web/mastodon_api/controllers/search_controller_test.exs b/test/web/mastodon_api/controllers/search_controller_test.exs index 826f37fbc..24d1959f8 100644 --- a/test/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/web/mastodon_api/controllers/search_controller_test.exs @@ -79,6 +79,7 @@ test "search", %{conn: conn} do assert status["id"] == to_string(activity.id) end + @tag capture_log: true test "constructs hashtags from search query", %{conn: conn} do results = conn @@ -318,11 +319,13 @@ test "search doesn't show statuses that it shouldn't", %{conn: conn} do test "search fetches remote accounts", %{conn: conn} do user = insert(:user) + query = URI.encode_query(%{q: " mike@osada.macgirvin.com ", resolve: true}) + results = conn |> assign(:user, user) |> assign(:token, insert(:oauth_token, user: user, scopes: ["read"])) - |> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=true") + |> get("/api/v1/search?#{query}") |> json_response_and_validate_schema(200) [account] = results["accounts"] -- cgit v1.2.3 From cbf2fe9649da34e78ddbc0f11c3fcc2599aa1c7a Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 3 Jul 2020 16:46:11 +0200 Subject: Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f878a76..85401809a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** Emoji API: changed methods and renamed routes. - Streaming: Repeats of a user's posts will no longer be pushed to the user's stream. - Mastodon API: Added `pleroma.metadata.fields_limits` to /api/v1/instance +- Mastodon API: On deletion, returns the original post text.
    -- cgit v1.2.3 From 4d3d867f10779bd4804acdb8ff398d41daafc4ea Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 3 Jul 2020 10:37:07 -0500 Subject: Update Oban to 2.0-rc3 --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index a82e7d53b..69d9f8632 100644 --- a/mix.exs +++ b/mix.exs @@ -124,7 +124,7 @@ defp deps do {:ecto_enum, "~> 1.4"}, {:ecto_sql, "~> 3.4.4"}, {:postgrex, ">= 0.13.5"}, - {:oban, "~> 2.0.0-rc.2"}, + {:oban, "~> 2.0.0-rc.3"}, {:gettext, "~> 0.15"}, {:pbkdf2_elixir, "~> 1.0"}, {:bcrypt_elixir, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index 781b7f2f2..88005451a 100644 --- a/mix.lock +++ b/mix.lock @@ -75,7 +75,7 @@ "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, - "oban": {:hex, :oban, "2.0.0-rc.2", "4a3ba53af98a9aaeee7e53209bbdb18a80972952d4c2ccc6ac61ffd30fa96e8a", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "8a01ace5b6cd142fea547a554b7b752be7ea8fb08e7ffee57405d3b28561560c"}, + "oban": {:hex, :oban, "2.0.0-rc.3", "964629fabc21939d7258a05a38f74b676bd4eebcf4932389e8ad9f1a18431bd2", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "82c9688e066610a88776aac527022a320faed9b5918093061caf2767863cc3c5"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, -- cgit v1.2.3 From bc956d0c419f156915dbf0185d6dd9c98dd7a6fe Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 3 Jul 2020 11:29:17 -0500 Subject: Document Oban update --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85401809a..f1c0209fa 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/). - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. +- Update Oban to version 2.0
    API Changes -- cgit v1.2.3 From e8710a3f8730be85443344640d2e46cd74667d6b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 3 Jul 2020 13:49:02 -0500 Subject: Revert "Document Oban update" This reverts commit bc956d0c419f156915dbf0185d6dd9c98dd7a6fe. --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c0209fa..85401809a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. -- Update Oban to version 2.0
    API Changes -- cgit v1.2.3 From eaa59daa4c229bf47e30ac389563c82b11378e07 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 3 Jul 2020 17:06:20 -0500 Subject: Add Captcha endpoint to CSP headers when MediaProxy is enabled. Our CSP rules are lax when MediaProxy enabled, but lenient otherwise. This fixes broken captcha on instances not using MediaProxy. --- lib/pleroma/plugs/http_security_plug.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 1420a9611..f7192ebfc 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -125,11 +125,19 @@ defp get_proxy_and_attachment_sources do if Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.S3, do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host + captcha_method = Config.get([Pleroma.Captcha, :method]) + + captcha_endpoint = + if Config.get([Pleroma.Captcha, :enabled]) && + captcha_method != "Pleroma.Captcha.Native", + do: Config.get([captcha_method, :endpoint]) + [] |> add_source(media_proxy_base_url) |> add_source(upload_base_url) |> add_source(s3_endpoint) |> add_source(media_proxy_whitelist) + |> add_source(captcha_endpoint) end defp add_source(iodata, nil), do: iodata -- cgit v1.2.3 From e9a28078ad969204faae600df3ddff8e75ed2f8a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 3 Jul 2020 17:18:22 -0500 Subject: Rename function and clarify that CSP is only strict with MediaProxy enabled --- lib/pleroma/plugs/http_security_plug.ex | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index f7192ebfc..23a641faf 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -69,10 +69,11 @@ defp csp_string do img_src = "img-src 'self' data: blob:" media_src = "media-src 'self'" + # Strict multimedia CSP enforcement only when MediaProxy is enabled {img_src, media_src} = if Config.get([:media_proxy, :enabled]) && !Config.get([:media_proxy, :proxy_opts, :redirect_on_failure]) do - sources = get_proxy_and_attachment_sources() + sources = build_csp_multimedia_source_list() {[img_src, sources], [media_src, sources]} else {[img_src, " https:"], [media_src, " https:"]} @@ -107,7 +108,7 @@ defp csp_string do |> :erlang.iolist_to_binary() end - defp get_proxy_and_attachment_sources do + defp build_csp_multimedia_source_list do media_proxy_whitelist = Enum.reduce(Config.get([:media_proxy, :whitelist]), [], fn host, acc -> add_source(acc, host) -- cgit v1.2.3 From 991bd78ddad74641f8032c7b373771a5acb10da9 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 3 Jul 2020 17:19:43 -0500 Subject: Document the Captcha CSP fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85401809a..4b74d064c 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/). - Resolving Peertube accounts with Webfinger - `blob:` urls not being allowed by connect-src CSP - Mastodon API: fix `GET /api/v1/notifications` not returning the full result set +- Fix CSP policy generation to include remote Captcha services ## [Unreleased (patch)] -- cgit v1.2.3 From cf566556147975d45958d2d87a5ce23831eb91df Mon Sep 17 00:00:00 2001 From: lain Date: Sat, 4 Jul 2020 17:11:37 +0200 Subject: Streamer: Don't filter out announce notifications. --- lib/pleroma/web/streamer/streamer.ex | 12 ++++++++---- test/web/streamer/streamer_test.exs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/streamer/streamer.ex b/lib/pleroma/web/streamer/streamer.ex index 73ee3e1e1..d1d70e556 100644 --- a/lib/pleroma/web/streamer/streamer.ex +++ b/lib/pleroma/web/streamer/streamer.ex @@ -104,7 +104,9 @@ def stream(topics, items) do :ok end - def filtered_by_user?(%User{} = user, %Activity{} = item) do + def filtered_by_user?(user, item, streamed_type \\ :activity) + + def filtered_by_user?(%User{} = user, %Activity{} = item, streamed_type) do %{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} = User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute]) @@ -116,7 +118,9 @@ def filtered_by_user?(%User{} = user, %Activity{} = item) do true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)), true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids, - true <- !(item.data["type"] == "Announce" && parent.data["actor"] == user.ap_id), + true <- + !(streamed_type == :activity && item.data["type"] == "Announce" && + parent.data["actor"] == user.ap_id), true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)), true <- MapSet.disjoint?(recipients, recipient_blocks), %{host: item_host} <- URI.parse(item.actor), @@ -131,8 +135,8 @@ def filtered_by_user?(%User{} = user, %Activity{} = item) do end end - def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do - filtered_by_user?(user, activity) + def filtered_by_user?(%User{} = user, %Notification{activity: activity}, _) do + filtered_by_user?(user, activity, :notification) end defp do_stream("direct", item) do diff --git a/test/web/streamer/streamer_test.exs b/test/web/streamer/streamer_test.exs index dfe341b34..d56d74464 100644 --- a/test/web/streamer/streamer_test.exs +++ b/test/web/streamer/streamer_test.exs @@ -128,6 +128,23 @@ test "it does not stream announces of the user's own posts in the 'user' stream" assert Streamer.filtered_by_user?(user, announce) end + test "it does stream notifications announces of the user's own posts in the 'user' stream", %{ + user: user + } do + Streamer.get_topic_and_add_socket("user", user) + + other_user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) + {:ok, announce} = CommonAPI.repeat(activity.id, other_user) + + notification = + Pleroma.Notification + |> Repo.get_by(%{user_id: user.id, activity_id: announce.id}) + |> Repo.preload(:activity) + + refute Streamer.filtered_by_user?(user, notification) + end + test "it streams boosts of mastodon user in the 'user' stream", %{user: user} do Streamer.get_topic_and_add_socket("user", user) -- cgit v1.2.3 From af612bd006a2792e27f9b995c0c86e010cc77e6c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 5 Jul 2020 10:11:43 -0500 Subject: Ensure all CSP parameters for remote hosts have a scheme --- lib/pleroma/plugs/http_security_plug.ex | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 23a641faf..3bf0b8ce7 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -116,22 +116,22 @@ defp build_csp_multimedia_source_list do media_proxy_base_url = if Config.get([:media_proxy, :base_url]), - do: URI.parse(Config.get([:media_proxy, :base_url])).host + do: build_csp_param(Config.get([:media_proxy, :base_url])) upload_base_url = if Config.get([Pleroma.Upload, :base_url]), - do: URI.parse(Config.get([Pleroma.Upload, :base_url])).host + do: build_csp_param(Config.get([Pleroma.Upload, :base_url])) s3_endpoint = if Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.S3, - do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host + do: build_csp_param(Config.get([Pleroma.Uploaders.S3, :public_endpoint])) captcha_method = Config.get([Pleroma.Captcha, :method]) captcha_endpoint = if Config.get([Pleroma.Captcha, :enabled]) && captcha_method != "Pleroma.Captcha.Native", - do: Config.get([captcha_method, :endpoint]) + do: build_csp_param(Config.get([captcha_method, :endpoint])) [] |> add_source(media_proxy_base_url) @@ -148,6 +148,14 @@ defp add_csp_param(csp_iodata, nil), do: csp_iodata defp add_csp_param(csp_iodata, param), do: [[param, ?;] | csp_iodata] + defp build_csp_param(url) when is_binary(url) do + %{host: host, scheme: scheme} = URI.parse(url) + + if scheme do + scheme <> "://" <> host + end + end + def warn_if_disabled do unless Config.get([:http_security, :enabled]) do Logger.warn(" -- cgit v1.2.3 From fc1f34b85125b24a8094aaa963acb46acacd8eee Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Mon, 6 Jul 2020 00:01:25 +0300 Subject: Delete activity before sending response to client --- .../web/mastodon_api/controllers/status_controller.ex | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 3f4c53437..12be530c9 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -201,15 +201,13 @@ def show(%{assigns: %{user: user}} = conn, %{id: id}) do @doc "DELETE /api/v1/statuses/:id" def delete(%{assigns: %{user: user}} = conn, %{id: id}) do with %Activity{} = activity <- Activity.get_by_id_with_object(id), - render <- - try_render(conn, "show.json", - activity: activity, - for: user, - with_direct_conversation_id: true, - with_source: true - ), {:ok, %Activity{}} <- CommonAPI.delete(id, user) do - render + try_render(conn, "show.json", + activity: activity, + for: user, + with_direct_conversation_id: true, + with_source: true + ) else _e -> {:error, :not_found} end -- cgit v1.2.3 From 480dfafa831245976a5c21940adca6f2a73c1213 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Jul 2020 08:48:20 +0300 Subject: don't save tesla settings into db --- lib/pleroma/config/loader.ex | 8 +++++++- test/config/holder_test.exs | 5 +---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 0f3ecf1ed..64e7de6df 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -12,6 +12,11 @@ defmodule Pleroma.Config.Loader do :swarm ] + @reject_groups [ + :postgrex, + :tesla + ] + if Code.ensure_loaded?(Config.Reader) do @reader Config.Reader @@ -47,7 +52,8 @@ defp filter(configs) do @spec filter_group(atom(), keyword()) :: keyword() def filter_group(group, configs) do Enum.reject(configs[group], fn {key, _v} -> - key in @reject_keys or (group == :phoenix and key == :serve_endpoints) or group == :postgrex + key in @reject_keys or group in @reject_groups or + (group == :phoenix and key == :serve_endpoints) end) end end diff --git a/test/config/holder_test.exs b/test/config/holder_test.exs index 15d48b5c7..abcaa27dd 100644 --- a/test/config/holder_test.exs +++ b/test/config/holder_test.exs @@ -10,7 +10,6 @@ defmodule Pleroma.Config.HolderTest do test "default_config/0" do config = Holder.default_config() assert config[:pleroma][Pleroma.Uploaders.Local][:uploads] == "test/uploads" - assert config[:tesla][:adapter] == Tesla.Mock refute config[:pleroma][Pleroma.Repo] refute config[:pleroma][Pleroma.Web.Endpoint] @@ -18,17 +17,15 @@ test "default_config/0" do refute config[:pleroma][:configurable_from_database] refute config[:pleroma][:database] refute config[:phoenix][:serve_endpoints] + refute config[:tesla][:adapter] end test "default_config/1" do pleroma_config = Holder.default_config(:pleroma) assert pleroma_config[Pleroma.Uploaders.Local][:uploads] == "test/uploads" - tesla_config = Holder.default_config(:tesla) - assert tesla_config[:adapter] == Tesla.Mock end test "default_config/2" do assert Holder.default_config(:pleroma, Pleroma.Uploaders.Local) == [uploads: "test/uploads"] - assert Holder.default_config(:tesla, :adapter) == Tesla.Mock end end -- cgit v1.2.3 From 465ddcfd2090abbb18afd7f1f7f1a4ee30105668 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Mon, 6 Jul 2020 09:12:29 +0300 Subject: migration to delete migrated tesla setting --- .../migrations/20200706060258_remove_tesla_from_config.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 priv/repo/migrations/20200706060258_remove_tesla_from_config.exs diff --git a/priv/repo/migrations/20200706060258_remove_tesla_from_config.exs b/priv/repo/migrations/20200706060258_remove_tesla_from_config.exs new file mode 100644 index 000000000..798687f8a --- /dev/null +++ b/priv/repo/migrations/20200706060258_remove_tesla_from_config.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.RemoveTeslaFromConfig do + use Ecto.Migration + + def up do + execute("DELETE FROM config WHERE config.group = ':tesla'") + end + + def down do + end +end -- cgit v1.2.3 From 4a8c26654eb7ca7ce049dd4c485c16672b5837a6 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Sat, 16 Nov 2019 22:54:13 +0100 Subject: Restrict statuses that contain user's irreversible filters --- lib/pleroma/filter.ex | 42 ++++++++++++- lib/pleroma/web/activity_pub/activity_pub.ex | 22 +++++++ .../mastodon_api/controllers/filter_controller.ex | 2 +- test/filter_test.exs | 2 +- test/support/factory.ex | 8 +++ test/web/activity_pub/activity_pub_test.exs | 69 ++++++++++++++++++++++ 6 files changed, 141 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 4d61b3650..91884c6b3 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -34,10 +34,18 @@ def get(id, %{id: user_id} = _user) do Repo.one(query) end - def get_filters(%User{id: user_id} = _user) do + def get_active(query) do + from(f in query, where: is_nil(f.expires_at) or f.expires_at > ^NaiveDateTime.utc_now()) + end + + def get_irreversible(query) do + from(f in query, where: f.hide) + end + + def get_by_user(query, %User{id: user_id} = _user) do query = from( - f in Pleroma.Filter, + f in query, where: f.user_id == ^user_id, order_by: [desc: :id] ) @@ -95,4 +103,34 @@ def update(%Pleroma.Filter{} = filter, params) do |> validate_required([:phrase, :context]) |> Repo.update() end + + def compose_regex(user_or_filters, format \\ :postgres) + + def compose_regex(%User{} = user, format) do + __MODULE__ + |> get_active() + |> get_irreversible() + |> get_by_user(user) + |> compose_regex(format) + end + + def compose_regex([_ | _] = filters, format) do + phrases = + filters + |> Enum.map(& &1.phrase) + |> Enum.join("|") + + case format do + :postgres -> + "\\y(#{phrases})\\y" + + :re -> + ~r/\b#{phrases}\b/i + + _ -> + nil + end + end + + def compose_regex(_, _), do: nil end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 94117202c..31353c866 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Constants alias Pleroma.Conversation alias Pleroma.Conversation.Participation + alias Pleroma.Filter alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object @@ -961,6 +962,26 @@ defp restrict_instance(query, %{instance: instance}) do defp restrict_instance(query, _), do: query + defp restrict_filtered(query, %{user: %User{} = user}) do + case Filter.compose_regex(user) do + nil -> + query + + regex -> + from([activity, object] in query, + where: + fragment("not(?->>'content' ~* ?)", object.data, ^regex) or + activity.actor == ^user.ap_id + ) + end + end + + defp restrict_filtered(query, %{blocking_user: %User{} = user}) do + restrict_filtered(query, %{user: user}) + end + + defp restrict_filtered(query, _), do: query + defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query defp exclude_poll_votes(query, _) do @@ -1099,6 +1120,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_muted_reblogs(restrict_muted_reblogs_opts) |> restrict_instance(opts) |> restrict_announce_object_actor(opts) + |> restrict_filtered(opts) |> Activity.restrict_deactivated_users() |> exclude_poll_votes(opts) |> exclude_chat_messages(opts) diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index abbf0ce02..db1ff3189 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do @doc "GET /api/v1/filters" def index(%{assigns: %{user: user}} = conn, _) do - filters = Filter.get_filters(user) + filters = Filter.get_by_user(Filter, user) render(conn, "index.json", filters: filters) end diff --git a/test/filter_test.exs b/test/filter_test.exs index 63a30c736..061a95ad0 100644 --- a/test/filter_test.exs +++ b/test/filter_test.exs @@ -126,7 +126,7 @@ test "getting all filters by an user" do {:ok, filter_one} = Pleroma.Filter.create(query_one) {:ok, filter_two} = Pleroma.Filter.create(query_two) - filters = Pleroma.Filter.get_filters(user) + filters = Pleroma.Filter.get_by_user(Pleroma.Filter, user) assert filter_one in filters assert filter_two in filters end diff --git a/test/support/factory.ex b/test/support/factory.ex index af580021c..635d83650 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -428,4 +428,12 @@ def mfa_token_factory do user: build(:user) } end + + def filter_factory do + %Pleroma.Filter{ + user: build(:user), + filter_id: sequence(:filter_id, & &1), + phrase: "cofe" + } + end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 575e0c5db..4968403dc 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -785,6 +785,75 @@ test "excludes reblogs on request" do assert activity == expected_activity end + describe "irreversible filters" do + setup do + user = insert(:user) + user_two = insert(:user) + + insert(:filter, user: user_two, phrase: "cofe", hide: true) + insert(:filter, user: user_two, phrase: "ok boomer", hide: true) + insert(:filter, user: user_two, phrase: "test", hide: false) + + params = %{ + "type" => ["Create", "Announce"], + "user" => user_two + } + + {:ok, %{user: user, user_two: user_two, params: params}} + end + + test "it returns statuses if they don't contain exact filter words", %{ + user: user, + params: params + } do + {:ok, _} = CommonAPI.post(user, %{"status" => "hey"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "got cofefe?"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "I am not a boomer"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "ok boomers"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "ccofee is not a word"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "this is a test"}) + + activities = ActivityPub.fetch_activities([], params) + + assert Enum.count(activities) == 6 + end + + test "it does not filter user's own statuses", %{user_two: user_two, params: params} do + {:ok, _} = CommonAPI.post(user_two, %{"status" => "Give me some cofe!"}) + {:ok, _} = CommonAPI.post(user_two, %{"status" => "ok boomer"}) + + activities = ActivityPub.fetch_activities([], params) + + assert Enum.count(activities) == 2 + end + + test "it excludes statuses with filter words", %{user: user, params: params} do + {:ok, _} = CommonAPI.post(user, %{"status" => "Give me some cofe!"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "ok boomer"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "is it a cOfE?"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "cofe is all I need"}) + {:ok, _} = CommonAPI.post(user, %{"status" => "— ok BOOMER\n"}) + + activities = ActivityPub.fetch_activities([], params) + + assert Enum.empty?(activities) + end + + test "it returns all statuses if user does not have any filters" do + another_user = insert(:user) + {:ok, _} = CommonAPI.post(another_user, %{"status" => "got cofe?"}) + {:ok, _} = CommonAPI.post(another_user, %{"status" => "test!"}) + + activities = + ActivityPub.fetch_activities([], %{ + "type" => ["Create", "Announce"], + "user" => another_user + }) + + assert Enum.count(activities) == 2 + end + end + describe "public fetch activities" do test "doesn't retrieve unlisted activities" do user = insert(:user) -- cgit v1.2.3 From 5af1bf443dfd21a6b0be9efc1f55a73e590f6ba3 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Fri, 22 Nov 2019 19:52:50 +0100 Subject: Skip notifications for statuses that contain an irreversible filtered word --- lib/pleroma/notification.ex | 36 +++++++++++++++- test/notification_test.exs | 101 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 2ef1a80c5..3f749cace 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -130,6 +130,7 @@ def for_user_query(user, opts \\ %{}) do |> preload([n, a, o], activity: {a, object: o}) |> exclude_notification_muted(user, exclude_notification_muted_opts) |> exclude_blocked(user, exclude_blocked_opts) + |> exclude_filtered(user) |> exclude_visibility(opts) end @@ -158,6 +159,20 @@ defp exclude_notification_muted(query, user, opts) do |> where([n, a, o, tm], is_nil(tm.user_id)) end + defp exclude_filtered(query, user) do + case Pleroma.Filter.compose_regex(user) do + nil -> + query + + regex -> + from([_n, a, o] in query, + where: + fragment("not(?->>'content' ~* ?)", o.data, ^regex) or + fragment("?->>'actor' = ?", o.data, ^user.ap_id) + ) + end + end + @valid_visibilities ~w[direct unlisted public private] defp exclude_visibility(query, %{exclude_visibilities: visibility}) @@ -555,7 +570,8 @@ def skip?(%Activity{} = activity, %User{} = user) do :follows, :non_followers, :non_follows, - :recently_followed + :recently_followed, + :filtered ] |> Enum.find(&skip?(&1, activity, user)) end @@ -624,6 +640,24 @@ def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, end) end + def skip?(:filtered, activity, user) do + object = Object.normalize(activity) + + cond do + is_nil(object) -> + false + + object.data["actor"] == user.ap_id -> + false + + not is_nil(regex = Pleroma.Filter.compose_regex(user, :re)) -> + Regex.match?(regex, object.data["content"]) + + true -> + false + end + end + def skip?(_, _, _), do: false def for_user_and_activity(user, activity) do diff --git a/test/notification_test.exs b/test/notification_test.exs index 6add3f7eb..9ac6925c3 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -324,6 +324,44 @@ test "it disables notifications from people who are invisible" do {:ok, status} = CommonAPI.post(author, %{status: "hey @#{user.nickname}"}) refute Notification.create_notification(status, user) end + + test "it doesn't create notifications if content matches with an irreversible filter" do + user = insert(:user) + subscriber = insert(:user) + + User.subscribe(subscriber, user) + insert(:filter, user: subscriber, phrase: "cofe", hide: true) + + {:ok, status} = CommonAPI.post(user, %{"status" => "got cofe?"}) + + assert {:ok, [nil]} == Notification.create_notifications(status) + end + + test "it creates notifications if content matches with a not irreversible filter" do + user = insert(:user) + subscriber = insert(:user) + + User.subscribe(subscriber, user) + insert(:filter, user: subscriber, phrase: "cofe", hide: false) + + {:ok, status} = CommonAPI.post(user, %{"status" => "got cofe?"}) + {:ok, [notification]} = Notification.create_notifications(status) + + assert notification + end + + test "it creates notifications when someone likes user's status with a filtered word" do + user = insert(:user) + other_user = insert(:user) + insert(:filter, user: user, phrase: "tesla", hide: true) + + {:ok, activity_one} = CommonAPI.post(user, %{"status" => "wow tesla"}) + {:ok, activity_two, _} = CommonAPI.favorite(activity_one.id, other_user) + + {:ok, [notification]} = Notification.create_notifications(activity_two) + + assert notification + end end describe "follow / follow_request notifications" do @@ -990,8 +1028,13 @@ test "move activity generates a notification" do end describe "for_user" do - test "it returns notifications for muted user without notifications" do + setup do user = insert(:user) + + {:ok, %{user: user}} + end + + test "it returns notifications for muted user without notifications", %{user: user} do muted = insert(:user) {:ok, _user_relationships} = User.mute(user, muted, false) @@ -1002,8 +1045,7 @@ test "it returns notifications for muted user without notifications" do assert notification.activity.object end - test "it doesn't return notifications for muted user with notifications" do - user = insert(:user) + test "it doesn't return notifications for muted user with notifications", %{user: user} do muted = insert(:user) {:ok, _user_relationships} = User.mute(user, muted) @@ -1012,8 +1054,7 @@ test "it doesn't return notifications for muted user with notifications" do assert Notification.for_user(user) == [] end - test "it doesn't return notifications for blocked user" do - user = insert(:user) + test "it doesn't return notifications for blocked user", %{user: user} do blocked = insert(:user) {:ok, _user_relationship} = User.block(user, blocked) @@ -1022,8 +1063,7 @@ test "it doesn't return notifications for blocked user" do assert Notification.for_user(user) == [] end - test "it doesn't return notifications for domain-blocked non-followed user" do - user = insert(:user) + test "it doesn't return notifications for domain-blocked non-followed user", %{user: user} do blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") @@ -1044,8 +1084,7 @@ test "it returns notifications for domain-blocked but followed user" do assert length(Notification.for_user(user)) == 1 end - test "it doesn't return notifications for muted thread" do - user = insert(:user) + test "it doesn't return notifications for muted thread", %{user: user} do another_user = insert(:user) {:ok, activity} = CommonAPI.post(another_user, %{status: "hey @#{user.nickname}"}) @@ -1054,8 +1093,7 @@ test "it doesn't return notifications for muted thread" do assert Notification.for_user(user) == [] end - test "it returns notifications from a muted user when with_muted is set" do - user = insert(:user) + test "it returns notifications from a muted user when with_muted is set", %{user: user} do muted = insert(:user) {:ok, _user_relationships} = User.mute(user, muted) @@ -1064,8 +1102,9 @@ test "it returns notifications from a muted user when with_muted is set" do assert length(Notification.for_user(user, %{with_muted: true})) == 1 end - test "it doesn't return notifications from a blocked user when with_muted is set" do - user = insert(:user) + test "it doesn't return notifications from a blocked user when with_muted is set", %{ + user: user + } do blocked = insert(:user) {:ok, _user_relationship} = User.block(user, blocked) @@ -1075,8 +1114,8 @@ test "it doesn't return notifications from a blocked user when with_muted is set end test "when with_muted is set, " <> - "it doesn't return notifications from a domain-blocked non-followed user" do - user = insert(:user) + "it doesn't return notifications from a domain-blocked non-followed user", + %{user: user} do blocked = insert(:user, ap_id: "http://some-domain.com") {:ok, user} = User.block_domain(user, "some-domain.com") @@ -1085,8 +1124,7 @@ test "when with_muted is set, " <> assert Enum.empty?(Notification.for_user(user, %{with_muted: true})) end - test "it returns notifications from muted threads when with_muted is set" do - user = insert(:user) + test "it returns notifications from muted threads when with_muted is set", %{user: user} do another_user = insert(:user) {:ok, activity} = CommonAPI.post(another_user, %{status: "hey @#{user.nickname}"}) @@ -1094,5 +1132,34 @@ test "it returns notifications from muted threads when with_muted is set" do {:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"]) assert length(Notification.for_user(user, %{with_muted: true})) == 1 end + + test "it doesn't return notifications about mentiones with filtered word", %{user: user} do + insert(:filter, user: user, phrase: "cofe", hide: true) + another_user = insert(:user) + + {:ok, _activity} = + CommonAPI.post(another_user, %{"status" => "@#{user.nickname} got cofe?"}) + + assert Enum.empty?(Notification.for_user(user)) + end + + test "it returns notifications about mentiones with not hidden filtered word", %{user: user} do + insert(:filter, user: user, phrase: "test", hide: false) + another_user = insert(:user) + + {:ok, _activity} = CommonAPI.post(another_user, %{"status" => "@#{user.nickname} test"}) + + assert length(Notification.for_user(user)) == 1 + end + + test "it returns notifications about favorites with filtered word", %{user: user} do + insert(:filter, user: user, phrase: "cofe", hide: true) + another_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{"status" => "Give me my cofe!"}) + {:ok, _, _} = CommonAPI.favorite(activity.id, another_user) + + assert length(Notification.for_user(user)) == 1 + end end end -- cgit v1.2.3 From 8277b29790dfd283d94b995539dcb28e51131150 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Mon, 25 Nov 2019 16:59:55 +0100 Subject: Restrict thread statuses that contain user's irreversible filters --- CHANGELOG.md | 7 ++++--- lib/pleroma/web/activity_pub/activity_pub.ex | 2 ++ test/notification_test.exs | 2 +- test/web/activity_pub/activity_pub_test.exs | 28 ++++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85401809a..0d31e7928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Streaming: Repeats of a user's posts will no longer be pushed to the user's stream. - Mastodon API: Added `pleroma.metadata.fields_limits` to /api/v1/instance - Mastodon API: On deletion, returns the original post text. +- Mastodon API: Add `pleroma.unread_count` to the Marker entity.
    @@ -58,8 +59,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Extended `/api/v1/instance`. - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. -- Mastodon API: Add support for filtering replies in public and home timelines -- Mastodon API: Support for `bot` field in `/api/v1/accounts/update_credentials` +- Mastodon API: Add support for filtering replies in public and home timelines. +- Mastodon API: Support for `bot` field in `/api/v1/accounts/update_credentials`. +- Mastodon API: Support irreversible property for filters. - Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoint for status view. - OTP: Add command to reload emoji packs @@ -214,7 +216,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: `pleroma.thread_muted` to the Status entity - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message - Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload. -- Mastodon API: Add `pleroma.unread_count` to the Marker entity - Admin API: Render whole status in grouped reports - 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. diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 31353c866..8abbef487 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -447,6 +447,7 @@ def fetch_activities_for_context_query(context, opts) do |> maybe_set_thread_muted_field(opts) |> restrict_blocked(opts) |> restrict_recipients(recipients, opts[:user]) + |> restrict_filtered(opts) |> where( [activity], fragment( @@ -1112,6 +1113,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_favorited_by(opts) |> restrict_blocked(restrict_blocked_opts) |> restrict_muted(restrict_muted_opts) + |> restrict_filtered(opts) |> restrict_media(opts) |> restrict_visibility(opts) |> restrict_thread_visibility(opts, config) diff --git a/test/notification_test.exs b/test/notification_test.exs index 9ac6925c3..abaafd60e 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -1147,7 +1147,7 @@ test "it returns notifications about mentiones with not hidden filtered word", % insert(:filter, user: user, phrase: "test", hide: false) another_user = insert(:user) - {:ok, _activity} = CommonAPI.post(another_user, %{"status" => "@#{user.nickname} test"}) + {:ok, _} = CommonAPI.post(another_user, %{"status" => "@#{user.nickname} test"}) assert length(Notification.for_user(user)) == 1 end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 4968403dc..2190ff808 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -507,6 +507,34 @@ test "retrieves activities that have a given context" do activities = ActivityPub.fetch_activities_for_context("2hu", %{blocking_user: user}) assert activities == [activity_two, activity] end + + test "doesn't return activities with filtered words" do + user = insert(:user) + user_two = insert(:user) + insert(:filter, user: user, phrase: "test", hide: true) + + {:ok, %{id: id1, data: %{"context" => context}}} = CommonAPI.post(user, %{"status" => "1"}) + + {:ok, %{id: id2}} = + CommonAPI.post(user_two, %{"status" => "2", "in_reply_to_status_id" => id1}) + + {:ok, %{id: id3} = user_activity} = + CommonAPI.post(user, %{"status" => "3 test?", "in_reply_to_status_id" => id2}) + + {:ok, %{id: id4} = filtered_activity} = + CommonAPI.post(user_two, %{"status" => "4 test!", "in_reply_to_status_id" => id3}) + + {:ok, _} = CommonAPI.post(user, %{"status" => "5", "in_reply_to_status_id" => id4}) + + activities = + context + |> ActivityPub.fetch_activities_for_context(%{"user" => user}) + |> Enum.map(& &1.id) + + assert length(activities) == 4 + assert user_activity.id in activities + refute filtered_activity.id in activities + end end test "doesn't return blocked activities" do -- cgit v1.2.3 From 6558f31cda07b8472ed99823ed0f46deffa584cc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 7 Feb 2020 18:16:39 +0300 Subject: don't filter notifications for follow and move types --- lib/pleroma/notification.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 3f749cace..d439f51bc 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -640,6 +640,8 @@ def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, end) end + def skip?(:filtered, %{data: %{"type" => type}}, _) when type in ["Follow", "Move"], do: false + def skip?(:filtered, activity, user) do object = Object.normalize(activity) -- cgit v1.2.3 From 771748db1fa01a71c52c20b890e1b80bfcf1e230 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 26 Feb 2020 13:59:07 +0000 Subject: Apply suggestion to lib/pleroma/filter.ex --- lib/pleroma/filter.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 91884c6b3..98cb575a9 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -42,7 +42,7 @@ def get_irreversible(query) do from(f in query, where: f.hide) end - def get_by_user(query, %User{id: user_id} = _user) do + def get_filters(query \\ __MODULE__, %User{id: user_id}) do query = from( f in query, -- cgit v1.2.3 From 086a260c04185623065a97e0ba5277585d4fd49a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 26 Feb 2020 14:00:28 +0000 Subject: Apply suggestion to test/notification_test.exs --- test/notification_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index abaafd60e..8679f52a5 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -1133,7 +1133,7 @@ test "it returns notifications from muted threads when with_muted is set", %{use assert length(Notification.for_user(user, %{with_muted: true})) == 1 end - test "it doesn't return notifications about mentiones with filtered word", %{user: user} do + test "it doesn't return notifications about mentions with filtered word", %{user: user} do insert(:filter, user: user, phrase: "cofe", hide: true) another_user = insert(:user) -- cgit v1.2.3 From 52ff75413a5a73f045c7b515a06ae40eb568dfa8 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 26 Feb 2020 14:00:38 +0000 Subject: Apply suggestion to test/notification_test.exs --- test/notification_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 8679f52a5..3279ea61e 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -1143,7 +1143,7 @@ test "it doesn't return notifications about mentions with filtered word", %{user assert Enum.empty?(Notification.for_user(user)) end - test "it returns notifications about mentiones with not hidden filtered word", %{user: user} do + test "it returns notifications about mentions with not hidden filtered word", %{user: user} do insert(:filter, user: user, phrase: "test", hide: false) another_user = insert(:user) -- cgit v1.2.3 From 20c27bef4083330a2415f1c0a04e4cad128b267a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 26 Feb 2020 17:50:56 +0300 Subject: renaming back and reject nil on create --- lib/pleroma/filter.ex | 2 +- lib/pleroma/notification.ex | 1 + .../mastodon_api/controllers/filter_controller.ex | 2 +- test/filter_test.exs | 59 ++++++++++++---------- test/notification_test.exs | 2 +- 5 files changed, 35 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/filter.ex b/lib/pleroma/filter.ex index 98cb575a9..5d6df9530 100644 --- a/lib/pleroma/filter.ex +++ b/lib/pleroma/filter.ex @@ -110,7 +110,7 @@ def compose_regex(%User{} = user, format) do __MODULE__ |> get_active() |> get_irreversible() - |> get_by_user(user) + |> get_filters(user) |> compose_regex(format) end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index d439f51bc..fcb2144ae 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -352,6 +352,7 @@ def dismiss(%{id: user_id} = _user, id) do end end + @spec create_notifications(Activity.t(), keyword()) :: {:ok, [Notification.t()] | []} def create_notifications(activity, options \\ []) def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity, options) do diff --git a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex index db1ff3189..abbf0ce02 100644 --- a/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/filter_controller.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do @doc "GET /api/v1/filters" def index(%{assigns: %{user: user}} = conn, _) do - filters = Filter.get_by_user(Filter, user) + filters = Filter.get_filters(user) render(conn, "index.json", filters: filters) end diff --git a/test/filter_test.exs b/test/filter_test.exs index 061a95ad0..0a5c4426a 100644 --- a/test/filter_test.exs +++ b/test/filter_test.exs @@ -3,37 +3,39 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.FilterTest do - alias Pleroma.Repo use Pleroma.DataCase import Pleroma.Factory + alias Pleroma.Filter + alias Pleroma.Repo + describe "creating filters" do test "creating one filter" do user = insert(:user) - query = %Pleroma.Filter{ + query = %Filter{ user_id: user.id, filter_id: 42, phrase: "knights", context: ["home"] } - {:ok, %Pleroma.Filter{} = filter} = Pleroma.Filter.create(query) - result = Pleroma.Filter.get(filter.filter_id, user) + {:ok, %Filter{} = filter} = Filter.create(query) + result = Filter.get(filter.filter_id, user) assert query.phrase == result.phrase end test "creating one filter without a pre-defined filter_id" do user = insert(:user) - query = %Pleroma.Filter{ + query = %Filter{ user_id: user.id, phrase: "knights", context: ["home"] } - {:ok, %Pleroma.Filter{} = filter} = Pleroma.Filter.create(query) + {:ok, %Filter{} = filter} = Filter.create(query) # Should start at 1 assert filter.filter_id == 1 end @@ -41,23 +43,23 @@ test "creating one filter without a pre-defined filter_id" do test "creating additional filters uses previous highest filter_id + 1" do user = insert(:user) - query_one = %Pleroma.Filter{ + query_one = %Filter{ user_id: user.id, filter_id: 42, phrase: "knights", context: ["home"] } - {:ok, %Pleroma.Filter{} = filter_one} = Pleroma.Filter.create(query_one) + {:ok, %Filter{} = filter_one} = Filter.create(query_one) - query_two = %Pleroma.Filter{ + query_two = %Filter{ user_id: user.id, # No filter_id phrase: "who", context: ["home"] } - {:ok, %Pleroma.Filter{} = filter_two} = Pleroma.Filter.create(query_two) + {:ok, %Filter{} = filter_two} = Filter.create(query_two) assert filter_two.filter_id == filter_one.filter_id + 1 end @@ -65,29 +67,29 @@ test "filter_id is unique per user" do user_one = insert(:user) user_two = insert(:user) - query_one = %Pleroma.Filter{ + query_one = %Filter{ user_id: user_one.id, phrase: "knights", context: ["home"] } - {:ok, %Pleroma.Filter{} = filter_one} = Pleroma.Filter.create(query_one) + {:ok, %Filter{} = filter_one} = Filter.create(query_one) - query_two = %Pleroma.Filter{ + query_two = %Filter{ user_id: user_two.id, phrase: "who", context: ["home"] } - {:ok, %Pleroma.Filter{} = filter_two} = Pleroma.Filter.create(query_two) + {:ok, %Filter{} = filter_two} = Filter.create(query_two) assert filter_one.filter_id == 1 assert filter_two.filter_id == 1 - result_one = Pleroma.Filter.get(filter_one.filter_id, user_one) + result_one = Filter.get(filter_one.filter_id, user_one) assert result_one.phrase == filter_one.phrase - result_two = Pleroma.Filter.get(filter_two.filter_id, user_two) + result_two = Filter.get(filter_two.filter_id, user_two) assert result_two.phrase == filter_two.phrase end end @@ -95,38 +97,38 @@ test "filter_id is unique per user" do test "deleting a filter" do user = insert(:user) - query = %Pleroma.Filter{ + query = %Filter{ user_id: user.id, filter_id: 0, phrase: "knights", context: ["home"] } - {:ok, _filter} = Pleroma.Filter.create(query) - {:ok, filter} = Pleroma.Filter.delete(query) - assert is_nil(Repo.get(Pleroma.Filter, filter.filter_id)) + {:ok, _filter} = Filter.create(query) + {:ok, filter} = Filter.delete(query) + assert is_nil(Repo.get(Filter, filter.filter_id)) end test "getting all filters by an user" do user = insert(:user) - query_one = %Pleroma.Filter{ + query_one = %Filter{ user_id: user.id, filter_id: 1, phrase: "knights", context: ["home"] } - query_two = %Pleroma.Filter{ + query_two = %Filter{ user_id: user.id, filter_id: 2, phrase: "who", context: ["home"] } - {:ok, filter_one} = Pleroma.Filter.create(query_one) - {:ok, filter_two} = Pleroma.Filter.create(query_two) - filters = Pleroma.Filter.get_by_user(Pleroma.Filter, user) + {:ok, filter_one} = Filter.create(query_one) + {:ok, filter_two} = Filter.create(query_two) + filters = Filter.get_filters(user) assert filter_one in filters assert filter_two in filters end @@ -134,7 +136,7 @@ test "getting all filters by an user" do test "updating a filter" do user = insert(:user) - query_one = %Pleroma.Filter{ + query_one = %Filter{ user_id: user.id, filter_id: 1, phrase: "knights", @@ -146,8 +148,9 @@ test "updating a filter" do context: ["home", "timeline"] } - {:ok, filter_one} = Pleroma.Filter.create(query_one) - {:ok, filter_two} = Pleroma.Filter.update(filter_one, changes) + {:ok, filter_one} = Filter.create(query_one) + {:ok, filter_two} = Filter.update(filter_one, changes) + assert filter_one != filter_two assert filter_two.phrase == changes.phrase assert filter_two.context == changes.context diff --git a/test/notification_test.exs b/test/notification_test.exs index 3279ea61e..898c804cb 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -334,7 +334,7 @@ test "it doesn't create notifications if content matches with an irreversible fi {:ok, status} = CommonAPI.post(user, %{"status" => "got cofe?"}) - assert {:ok, [nil]} == Notification.create_notifications(status) + assert {:ok, []} == Notification.create_notifications(status) end test "it creates notifications if content matches with a not irreversible filter" do -- cgit v1.2.3 From da509487b21bbb627e5fdac6815ad9b3e4e4728b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 31 Mar 2020 16:53:11 +0300 Subject: adding benchmarks in new format --- benchmarks/load_testing/activities.ex | 10 ++++++++++ benchmarks/load_testing/fetcher.ex | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index 074ded457..f5c7bfce8 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -24,6 +24,7 @@ defmodule Pleroma.LoadTesting.Activities do @visibility ~w(public private direct unlisted) @types [ :simple, + :simple_filtered, :emoji, :mentions, :hell_thread, @@ -242,6 +243,15 @@ defp insert_activity(:simple, visibility, group, users, _opts) do insert_local_activity(visibility, group, users, "Simple status") end + defp insert_activity(:simple_filtered, visibility, group, users, _opts) + when group in @remote_groups do + insert_remote_activity(visibility, group, users, "Remote status which must be filtered") + end + + defp insert_activity(:simple_filtered, visibility, group, users, _opts) do + insert_local_activity(visibility, group, users, "Simple status which must be filtered") + end + defp insert_activity(:emoji, visibility, group, users, _opts) when group in @remote_groups do insert_remote_activity(visibility, group, users, "Remote status with emoji :firefox:") diff --git a/benchmarks/load_testing/fetcher.ex b/benchmarks/load_testing/fetcher.ex index 15fd06c3d..dfbd916be 100644 --- a/benchmarks/load_testing/fetcher.ex +++ b/benchmarks/load_testing/fetcher.ex @@ -32,10 +32,22 @@ defp fetch_user(user) do ) end + defp create_filter(user) do + Pleroma.Filter.create(%Pleroma.Filter{ + user_id: user.id, + phrase: "must be filtered", + hide: true + }) + end + + defp delete_filter(filter), do: Repo.delete(filter) + defp fetch_timelines(user) do fetch_home_timeline(user) + fetch_home_timeline_with_filter(user) fetch_direct_timeline(user) fetch_public_timeline(user) + fetch_public_timeline_with_filter(user) fetch_public_timeline(user, :with_blocks) fetch_public_timeline(user, :local) fetch_public_timeline(user, :tag) @@ -61,7 +73,7 @@ defp opts_for_home_timeline(user) do } end - defp fetch_home_timeline(user) do + defp fetch_home_timeline(user, title_end \\ "") do opts = opts_for_home_timeline(user) recipients = [user.ap_id | User.following(user)] @@ -84,9 +96,11 @@ defp fetch_home_timeline(user) do |> Enum.reverse() |> List.last() + title = "home timeline " <> title_end + Benchee.run( %{ - "home timeline" => fn opts -> ActivityPub.fetch_activities(recipients, opts) end + title => fn opts -> ActivityPub.fetch_activities(recipients, opts) end }, inputs: %{ "1 page" => opts, @@ -108,6 +122,14 @@ defp fetch_home_timeline(user) do ) end + defp fetch_home_timeline_with_filter(user) do + {:ok, filter} = create_filter(user) + + fetch_home_timeline(user, "with filters") + + delete_filter(filter) + end + defp opts_for_direct_timeline(user) do %{ visibility: "direct", @@ -210,6 +232,14 @@ defp fetch_public_timeline(user) do fetch_public_timeline(opts, "public timeline") end + defp fetch_public_timeline_with_filter(user) do + {:ok, filter} = create_filter(user) + opts = opts_for_public_timeline(user) + + fetch_public_timeline(opts, "public timeline with filters") + delete_filter(filter) + end + defp fetch_public_timeline(user, :local) do opts = opts_for_public_timeline(user, :local) -- cgit v1.2.3 From 028a241b7dc45e31161e29ca24a34be8740a4656 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 1 May 2020 09:20:54 +0300 Subject: tests fixes --- test/notification_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 898c804cb..366dc176c 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -356,7 +356,7 @@ test "it creates notifications when someone likes user's status with a filtered insert(:filter, user: user, phrase: "tesla", hide: true) {:ok, activity_one} = CommonAPI.post(user, %{"status" => "wow tesla"}) - {:ok, activity_two, _} = CommonAPI.favorite(activity_one.id, other_user) + {:ok, activity_two} = CommonAPI.favorite(other_user, activity_one.id) {:ok, [notification]} = Notification.create_notifications(activity_two) @@ -1157,7 +1157,7 @@ test "it returns notifications about favorites with filtered word", %{user: user another_user = insert(:user) {:ok, activity} = CommonAPI.post(user, %{"status" => "Give me my cofe!"}) - {:ok, _, _} = CommonAPI.favorite(activity.id, another_user) + {:ok, _} = CommonAPI.favorite(another_user, activity.id) assert length(Notification.for_user(user)) == 1 end -- cgit v1.2.3 From 818f3c2393fb428997f783e599b0d629dcd5a842 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 17 Jun 2020 12:34:27 +0300 Subject: test fixes --- test/notification_test.exs | 13 ++++---- test/web/activity_pub/activity_pub_test.exs | 51 ++++++++++++++--------------- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/test/notification_test.exs b/test/notification_test.exs index 366dc176c..13e82ab2a 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -332,7 +332,7 @@ test "it doesn't create notifications if content matches with an irreversible fi User.subscribe(subscriber, user) insert(:filter, user: subscriber, phrase: "cofe", hide: true) - {:ok, status} = CommonAPI.post(user, %{"status" => "got cofe?"}) + {:ok, status} = CommonAPI.post(user, %{status: "got cofe?"}) assert {:ok, []} == Notification.create_notifications(status) end @@ -344,7 +344,7 @@ test "it creates notifications if content matches with a not irreversible filter User.subscribe(subscriber, user) insert(:filter, user: subscriber, phrase: "cofe", hide: false) - {:ok, status} = CommonAPI.post(user, %{"status" => "got cofe?"}) + {:ok, status} = CommonAPI.post(user, %{status: "got cofe?"}) {:ok, [notification]} = Notification.create_notifications(status) assert notification @@ -355,7 +355,7 @@ test "it creates notifications when someone likes user's status with a filtered other_user = insert(:user) insert(:filter, user: user, phrase: "tesla", hide: true) - {:ok, activity_one} = CommonAPI.post(user, %{"status" => "wow tesla"}) + {:ok, activity_one} = CommonAPI.post(user, %{status: "wow tesla"}) {:ok, activity_two} = CommonAPI.favorite(other_user, activity_one.id) {:ok, [notification]} = Notification.create_notifications(activity_two) @@ -1137,8 +1137,7 @@ test "it doesn't return notifications about mentions with filtered word", %{user insert(:filter, user: user, phrase: "cofe", hide: true) another_user = insert(:user) - {:ok, _activity} = - CommonAPI.post(another_user, %{"status" => "@#{user.nickname} got cofe?"}) + {:ok, _activity} = CommonAPI.post(another_user, %{status: "@#{user.nickname} got cofe?"}) assert Enum.empty?(Notification.for_user(user)) end @@ -1147,7 +1146,7 @@ test "it returns notifications about mentions with not hidden filtered word", %{ insert(:filter, user: user, phrase: "test", hide: false) another_user = insert(:user) - {:ok, _} = CommonAPI.post(another_user, %{"status" => "@#{user.nickname} test"}) + {:ok, _} = CommonAPI.post(another_user, %{status: "@#{user.nickname} test"}) assert length(Notification.for_user(user)) == 1 end @@ -1156,7 +1155,7 @@ test "it returns notifications about favorites with filtered word", %{user: user insert(:filter, user: user, phrase: "cofe", hide: true) another_user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "Give me my cofe!"}) + {:ok, activity} = CommonAPI.post(user, %{status: "Give me my cofe!"}) {:ok, _} = CommonAPI.favorite(another_user, activity.id) assert length(Notification.for_user(user)) == 1 diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 2190ff808..17e12a1a7 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -513,22 +513,21 @@ test "doesn't return activities with filtered words" do user_two = insert(:user) insert(:filter, user: user, phrase: "test", hide: true) - {:ok, %{id: id1, data: %{"context" => context}}} = CommonAPI.post(user, %{"status" => "1"}) + {:ok, %{id: id1, data: %{"context" => context}}} = CommonAPI.post(user, %{status: "1"}) - {:ok, %{id: id2}} = - CommonAPI.post(user_two, %{"status" => "2", "in_reply_to_status_id" => id1}) + {:ok, %{id: id2}} = CommonAPI.post(user_two, %{status: "2", in_reply_to_status_id: id1}) {:ok, %{id: id3} = user_activity} = - CommonAPI.post(user, %{"status" => "3 test?", "in_reply_to_status_id" => id2}) + CommonAPI.post(user, %{status: "3 test?", in_reply_to_status_id: id2}) {:ok, %{id: id4} = filtered_activity} = - CommonAPI.post(user_two, %{"status" => "4 test!", "in_reply_to_status_id" => id3}) + CommonAPI.post(user_two, %{status: "4 test!", in_reply_to_status_id: id3}) - {:ok, _} = CommonAPI.post(user, %{"status" => "5", "in_reply_to_status_id" => id4}) + {:ok, _} = CommonAPI.post(user, %{status: "5", in_reply_to_status_id: id4}) activities = context - |> ActivityPub.fetch_activities_for_context(%{"user" => user}) + |> ActivityPub.fetch_activities_for_context(%{user: user}) |> Enum.map(& &1.id) assert length(activities) == 4 @@ -823,8 +822,8 @@ test "excludes reblogs on request" do insert(:filter, user: user_two, phrase: "test", hide: false) params = %{ - "type" => ["Create", "Announce"], - "user" => user_two + type: ["Create", "Announce"], + user: user_two } {:ok, %{user: user, user_two: user_two, params: params}} @@ -834,12 +833,12 @@ test "it returns statuses if they don't contain exact filter words", %{ user: user, params: params } do - {:ok, _} = CommonAPI.post(user, %{"status" => "hey"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "got cofefe?"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "I am not a boomer"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "ok boomers"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "ccofee is not a word"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "this is a test"}) + {:ok, _} = CommonAPI.post(user, %{status: "hey"}) + {:ok, _} = CommonAPI.post(user, %{status: "got cofefe?"}) + {:ok, _} = CommonAPI.post(user, %{status: "I am not a boomer"}) + {:ok, _} = CommonAPI.post(user, %{status: "ok boomers"}) + {:ok, _} = CommonAPI.post(user, %{status: "ccofee is not a word"}) + {:ok, _} = CommonAPI.post(user, %{status: "this is a test"}) activities = ActivityPub.fetch_activities([], params) @@ -847,8 +846,8 @@ test "it returns statuses if they don't contain exact filter words", %{ end test "it does not filter user's own statuses", %{user_two: user_two, params: params} do - {:ok, _} = CommonAPI.post(user_two, %{"status" => "Give me some cofe!"}) - {:ok, _} = CommonAPI.post(user_two, %{"status" => "ok boomer"}) + {:ok, _} = CommonAPI.post(user_two, %{status: "Give me some cofe!"}) + {:ok, _} = CommonAPI.post(user_two, %{status: "ok boomer"}) activities = ActivityPub.fetch_activities([], params) @@ -856,11 +855,11 @@ test "it does not filter user's own statuses", %{user_two: user_two, params: par end test "it excludes statuses with filter words", %{user: user, params: params} do - {:ok, _} = CommonAPI.post(user, %{"status" => "Give me some cofe!"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "ok boomer"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "is it a cOfE?"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "cofe is all I need"}) - {:ok, _} = CommonAPI.post(user, %{"status" => "— ok BOOMER\n"}) + {:ok, _} = CommonAPI.post(user, %{status: "Give me some cofe!"}) + {:ok, _} = CommonAPI.post(user, %{status: "ok boomer"}) + {:ok, _} = CommonAPI.post(user, %{status: "is it a cOfE?"}) + {:ok, _} = CommonAPI.post(user, %{status: "cofe is all I need"}) + {:ok, _} = CommonAPI.post(user, %{status: "— ok BOOMER\n"}) activities = ActivityPub.fetch_activities([], params) @@ -869,13 +868,13 @@ test "it excludes statuses with filter words", %{user: user, params: params} do test "it returns all statuses if user does not have any filters" do another_user = insert(:user) - {:ok, _} = CommonAPI.post(another_user, %{"status" => "got cofe?"}) - {:ok, _} = CommonAPI.post(another_user, %{"status" => "test!"}) + {:ok, _} = CommonAPI.post(another_user, %{status: "got cofe?"}) + {:ok, _} = CommonAPI.post(another_user, %{status: "test!"}) activities = ActivityPub.fetch_activities([], %{ - "type" => ["Create", "Announce"], - "user" => another_user + type: ["Create", "Announce"], + user: another_user }) assert Enum.count(activities) == 2 -- cgit v1.2.3 From af7720237b448341932a4a0b53d94b006114e915 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 11:08:13 +0200 Subject: Upload: Restrict description length --- config/config.exs | 1 + lib/pleroma/upload.ex | 9 ++++++++- test/upload_test.exs | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 9b550920c..d28a359b2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -188,6 +188,7 @@ background_image: "/images/city.jpg", instance_thumbnail: "/instance/thumbnail.jpeg", limit: 5_000, + description_limit: 5_000, chat_limit: 5_000, remote_limit: 100_000, upload_limit: 16_000_000, diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 797555bff..0fa6b89dc 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -63,6 +63,10 @@ def store(upload, opts \\ []) do with {:ok, upload} <- prepare_upload(upload, opts), upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"}, {:ok, upload} <- Pleroma.Upload.Filter.filter(opts.filters, upload), + description = Map.get(opts, :description) || upload.name, + {_, true} <- + {:description_limit, + String.length(description) <= Pleroma.Config.get([:instance, :description_limit])}, {:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do {:ok, %{ @@ -75,9 +79,12 @@ def store(upload, opts \\ []) do "href" => url_from_spec(upload, opts.base_url, url_spec) } ], - "name" => Map.get(opts, :description) || upload.name + "name" => description }} else + {:description_limit, _} -> + {:error, :description_too_long} + {:error, error} -> Logger.error( "#{__MODULE__} store (using #{inspect(opts.uploader)}) failed: #{inspect(error)}" diff --git a/test/upload_test.exs b/test/upload_test.exs index 2abf0edec..b06b54487 100644 --- a/test/upload_test.exs +++ b/test/upload_test.exs @@ -107,6 +107,19 @@ test "it returns error" do describe "Storing a file with the Local uploader" do setup [:ensure_local_uploader] + test "does not allow descriptions longer than the post limit" do + clear_config([:instance, :description_limit], 2) + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image_tmp.jpg"), + filename: "image.jpg" + } + + {:error, :description_too_long} = Upload.store(file, description: "123") + end + test "returns a media url" do File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") -- cgit v1.2.3 From 2e21ae1b6df807d6937d9d2c49f15242ef268903 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 11:08:53 +0200 Subject: Docs: Add description limits to cheat sheet --- docs/configuration/cheatsheet.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6759d5e93..6b640cebc 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -18,6 +18,7 @@ To add configuration to your config file, you can copy it from the base config. * `notify_email`: Email used for notifications. * `description`: The instance’s description, can be seen in nodeinfo and ``/api/v1/instance``. * `limit`: Posts character limit (CW/Subject included in the counter). +* `discription_limit`: The character limit for image descriptions. * `chat_limit`: Character limit of the instance chat messages. * `remote_limit`: Hard character limit beyond which remote posts will be dropped. * `upload_limit`: File size limit of uploads (except for avatar, background, banner). -- cgit v1.2.3 From cc8b4e48d966211fdad43121850ac1ecfbb73c74 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 11:12:37 +0200 Subject: InstanceView: Add chat limit, description limit --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 2 ++ test/web/mastodon_api/controllers/instance_controller_test.exs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 89e48fba5..5deb0d7ed 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -34,6 +34,8 @@ def render("show.json", _) do background_upload_limit: Keyword.get(instance, :background_upload_limit), banner_upload_limit: Keyword.get(instance, :banner_upload_limit), background_image: Keyword.get(instance, :background_image), + chat_limit: Keyword.get(instance, :chat_limit), + description_limit: Keyword.get(instance, :description_limit), pleroma: %{ metadata: %{ account_activation_required: Keyword.get(instance, :account_activation_required), diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 95ee26416..cc880d82c 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -32,7 +32,9 @@ test "get instance information", %{conn: conn} do "avatar_upload_limit" => _, "background_upload_limit" => _, "banner_upload_limit" => _, - "background_image" => _ + "background_image" => _, + "chat_limit" => _, + "description_limit" => _ } = result assert result["pleroma"]["metadata"]["account_activation_required"] != nil -- cgit v1.2.3 From 729506c56a176c725edbbadf0c42b1ac648a37dd Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 11:16:58 +0200 Subject: Docs: document instance differences --- docs/API/differences_in_mastoapi_responses.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 72b5984ae..d2455d5d7 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -220,6 +220,8 @@ Has theses additional parameters (which are the same as in Pleroma-API): `GET /api/v1/instance` has additional fields - `max_toot_chars`: The maximum characters per post +- `chat_limit`: The maximum characters per chat message +- `description_limit`: The maximum characters per image description - `poll_limits`: The limits of polls - `upload_limit`: The maximum upload file size - `avatar_upload_limit`: The same for avatars -- cgit v1.2.3 From 58da575935f19b86c614717f4fe0d4b8508f395d Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 11:18:01 +0200 Subject: Changelog: Document description limits. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85401809a..c4077c85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    API Changes +- **Breaking:** Image description length is limited now. - **Breaking:** Emoji API: changed methods and renamed routes. - Streaming: Repeats of a user's posts will no longer be pushed to the user's stream. - Mastodon API: Added `pleroma.metadata.fields_limits` to /api/v1/instance -- cgit v1.2.3 From 208baf157ad0c8be470566d5d51d0214c229e6a5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 11:38:40 +0200 Subject: ActivityPub: Add new 'capabilities' to user. --- lib/pleroma/web/activity_pub/activity_pub.ex | 3 ++- lib/pleroma/web/activity_pub/views/user_view.ex | 6 +++--- priv/static/schemas/litepub-0.1.jsonld | 2 +- test/fixtures/tesla_mock/admin@mastdon.example.org.json | 4 +++- test/web/activity_pub/views/user_view_test.exs | 13 ++++++++++--- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 86428b861..17c9d8f21 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1224,7 +1224,8 @@ defp object_to_user_data(data) do end) locked = data["manuallyApprovesFollowers"] || false - accepts_chat_messages = data["acceptsChatMessages"] + capabilities = data["capabilities"] || %{} + accepts_chat_messages = capabilities["acceptsChatMessages"] data = Transmogrifier.maybe_fix_user_object(data) discoverable = data["discoverable"] || false invisible = data["invisible"] || false diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index d062d6230..3a4564912 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -81,7 +81,7 @@ def render("user.json", %{user: user}) do fields = Enum.map(user.fields, &Map.put(&1, "type", "PropertyValue")) - chat_message_acceptance = + capabilities = if is_boolean(user.accepts_chat_messages) do %{ "acceptsChatMessages" => user.accepts_chat_messages @@ -110,9 +110,9 @@ def render("user.json", %{user: user}) do "endpoints" => endpoints, "attachment" => fields, "tag" => emoji_tags, - "discoverable" => user.discoverable + "discoverable" => user.discoverable, + "capabilities" => capabilities } - |> Map.merge(chat_message_acceptance) |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) |> Map.merge(Utils.make_json_ld_header()) diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld index c1bcad0f8..e7722cf72 100644 --- a/priv/static/schemas/litepub-0.1.jsonld +++ b/priv/static/schemas/litepub-0.1.jsonld @@ -13,7 +13,7 @@ }, "discoverable": "toot:discoverable", "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", - "acceptsChatMessages": "litepub:acceptsChatMessages", + "capabilities": "litepub:capabilities", "ostatus": "http://ostatus.org#", "schema": "http://schema.org#", "toot": "http://joinmastodon.org/ns#", diff --git a/test/fixtures/tesla_mock/admin@mastdon.example.org.json b/test/fixtures/tesla_mock/admin@mastdon.example.org.json index f5cf174be..a911b979a 100644 --- a/test/fixtures/tesla_mock/admin@mastdon.example.org.json +++ b/test/fixtures/tesla_mock/admin@mastdon.example.org.json @@ -26,7 +26,9 @@ "summary": "\u003cp\u003e\u003c/p\u003e", "url": "http://mastodon.example.org/@admin", "manuallyApprovesFollowers": false, - "acceptsChatMessages": true, + "capabilities": { + "acceptsChatMessages": true + }, "publicKey": { "id": "http://mastodon.example.org/users/admin#main-key", "owner": "http://mastodon.example.org/users/admin", diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index 3b4a1bcde..98c7c9d09 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -165,9 +165,16 @@ test "it returns this value if it is set" do false_user = insert(:user, accepts_chat_messages: false) nil_user = insert(:user, accepts_chat_messages: nil) - assert %{"acceptsChatMessages" => true} = UserView.render("user.json", user: true_user) - assert %{"acceptsChatMessages" => false} = UserView.render("user.json", user: false_user) - refute Map.has_key?(UserView.render("user.json", user: nil_user), "acceptsChatMessages") + assert %{"capabilities" => %{"acceptsChatMessages" => true}} = + UserView.render("user.json", user: true_user) + + assert %{"capabilities" => %{"acceptsChatMessages" => false}} = + UserView.render("user.json", user: false_user) + + refute Map.has_key?( + UserView.render("user.json", user: nil_user)["capabilities"], + "acceptsChatMessages" + ) end end end -- cgit v1.2.3 From 158c26d7ddb3c77dc99a6298114929faf6a2915a Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 12:11:10 +0200 Subject: StaticFE Plug: Use phoenix helper to get the requested format. --- lib/pleroma/plugs/static_fe_plug.ex | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/pleroma/plugs/static_fe_plug.ex b/lib/pleroma/plugs/static_fe_plug.ex index 7c69b2dac..143665c71 100644 --- a/lib/pleroma/plugs/static_fe_plug.ex +++ b/lib/pleroma/plugs/static_fe_plug.ex @@ -21,12 +21,6 @@ def call(conn, _) do defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) defp requires_html?(conn) do - case get_req_header(conn, "accept") do - [accept | _] -> - !String.contains?(accept, "json") && String.contains?(accept, "text/html") - - _ -> - false - end + Phoenix.Controller.get_format(conn) == "html" end end -- cgit v1.2.3 From 30d0df8e2f1340583b1413154dc4ad76d165b234 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 12:17:08 +0200 Subject: Update frontend --- priv/static/index.html | 2 +- priv/static/static/config.json | 2 +- .../static/static/css/app.613cef07981cd95ccceb.css | Bin 2007 -> 0 bytes .../static/css/app.613cef07981cd95ccceb.css.map | 1 - .../css/vendors~app.18fea621d430000acc27.css | Bin 4695 -> 0 bytes .../css/vendors~app.18fea621d430000acc27.css.map | 1 - priv/static/static/font/fontello.1589385935077.eot | Bin 22976 -> 0 bytes priv/static/static/font/fontello.1589385935077.svg | 124 --------------------- priv/static/static/font/fontello.1589385935077.ttf | Bin 22808 -> 0 bytes .../static/static/font/fontello.1589385935077.woff | Bin 13988 -> 0 bytes .../static/font/fontello.1589385935077.woff2 | Bin 11796 -> 0 bytes priv/static/static/fontello.json | 24 ++++ priv/static/static/img/nsfw.74818f9.png | Bin 35104 -> 0 bytes priv/static/static/js/2.18e4adec273c4ce867a8.js | Bin 2190 -> 0 bytes .../static/static/js/2.18e4adec273c4ce867a8.js.map | Bin 7763 -> 0 bytes priv/static/static/js/app.838ffa9aecf210c7d744.js | Bin 1079319 -> 0 bytes .../static/js/app.838ffa9aecf210c7d744.js.map | Bin 1643789 -> 0 bytes .../static/js/vendors~app.561a1c605d1dfb0e6f74.js | Bin 411235 -> 0 bytes .../js/vendors~app.561a1c605d1dfb0e6f74.js.map | Bin 1737881 -> 0 bytes priv/static/static/terms-of-service.html | 7 +- priv/static/static/themes/redmond-xx-se.json | 4 +- priv/static/static/themes/redmond-xx.json | 4 +- priv/static/static/themes/redmond-xxi.json | 4 +- priv/static/sw-pleroma-workbox.js | Bin 0 -> 674622 bytes priv/static/sw-pleroma-workbox.js.map | Bin 0 -> 642762 bytes priv/static/sw-pleroma.js | Bin 31752 -> 11597 bytes priv/static/sw-pleroma.js.map | Bin 143966 -> 45212 bytes priv/static/sw.js | Bin 69965 -> 30941 bytes 28 files changed, 41 insertions(+), 132 deletions(-) delete mode 100644 priv/static/static/css/app.613cef07981cd95ccceb.css delete mode 100644 priv/static/static/css/app.613cef07981cd95ccceb.css.map delete mode 100644 priv/static/static/css/vendors~app.18fea621d430000acc27.css delete mode 100644 priv/static/static/css/vendors~app.18fea621d430000acc27.css.map delete mode 100644 priv/static/static/font/fontello.1589385935077.eot delete mode 100644 priv/static/static/font/fontello.1589385935077.svg delete mode 100644 priv/static/static/font/fontello.1589385935077.ttf delete mode 100644 priv/static/static/font/fontello.1589385935077.woff delete mode 100644 priv/static/static/font/fontello.1589385935077.woff2 delete mode 100644 priv/static/static/img/nsfw.74818f9.png delete mode 100644 priv/static/static/js/2.18e4adec273c4ce867a8.js delete mode 100644 priv/static/static/js/2.18e4adec273c4ce867a8.js.map delete mode 100644 priv/static/static/js/app.838ffa9aecf210c7d744.js delete mode 100644 priv/static/static/js/app.838ffa9aecf210c7d744.js.map delete mode 100644 priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js delete mode 100644 priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map create mode 100644 priv/static/sw-pleroma-workbox.js create mode 100644 priv/static/sw-pleroma-workbox.js.map diff --git a/priv/static/index.html b/priv/static/index.html index ddd4ec4eb..279deb8b6 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/config.json b/priv/static/static/config.json index 727dde73b..0030f78f1 100644 --- a/priv/static/static/config.json +++ b/priv/static/static/config.json @@ -14,7 +14,6 @@ "logoMargin": ".1em", "logoMask": true, "minimalScopesMode": false, - "noAttachmentLinks": false, "nsfwCensorImage": "", "postContentType": "text/plain", "redirectRootLogin": "/main/friends", @@ -22,6 +21,7 @@ "scopeCopy": true, "showFeaturesPanel": true, "showInstanceSpecificPanel": false, + "sidebarRight": false, "subjectLineBehavior": "email", "theme": "pleroma-dark", "webPushNotifications": false diff --git a/priv/static/static/css/app.613cef07981cd95ccceb.css b/priv/static/static/css/app.613cef07981cd95ccceb.css deleted file mode 100644 index c1d5f8188..000000000 Binary files a/priv/static/static/css/app.613cef07981cd95ccceb.css and /dev/null differ diff --git a/priv/static/static/css/app.613cef07981cd95ccceb.css.map b/priv/static/static/css/app.613cef07981cd95ccceb.css.map deleted file mode 100644 index 556e0bb0b..000000000 --- a/priv/static/static/css/app.613cef07981cd95ccceb.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./src/hocs/with_load_more/with_load_more.scss","webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_subscription/with_subscription.scss"],"names":[],"mappings":"AAAA,uBAAuB,aAAa,kBAAkB,qBAAqB,sBAAsB,qCAAqC,8BAA8B,e;ACApK,cAAc,oBAAoB,aAAa,0BAA0B,sBAAsB,wBAAwB,kBAAkB,cAAc,eAAe,gCAAgC,aAAa,wCAAwC,0BAA0B,aAAa,gBAAgB,oBAAoB,oBAAoB,aAAa,kBAAkB,WAAW,kBAAkB,gBAAgB,gBAAgB,sBAAsB,uDAAuD,cAAc,WAAW,kBAAkB,cAAc,wBAAwB,yBAAyB,wCAAwC,iCAAiC,YAAY,kBAAkB,oBAAoB,aAAa,kBAAkB,cAAc,sCAAsC,WAAW,cAAc,kBAAkB,4BAA4B,6BAA6B,gBAAgB,oBAAoB,oBAAoB,mBAAmB,cAAc,8BAA8B,yBAAyB,qCAAqC,mDAAmD,UAAU,yDAAyD,UAAU,6CAA6C,uBAAuB,UAAU,cAAc,oCAAoC,0CAA0C,gBAAgB,mBAAmB,gBAAgB,qDAAqD,WAAW,kBAAkB,OAAO,QAAQ,SAAS,UAAU,wBAAwB,yBAAyB,wC;ACAtlD,2BAA2B,aAAa,kBAAkB,kCAAkC,e","file":"static/css/app.613cef07981cd95ccceb.css","sourcesContent":[".with-load-more-footer{padding:10px;text-align:center;border-top:1px solid;border-top-color:#222;border-top-color:var(--border, #222)}.with-load-more-footer .error{font-size:14px}",".tab-switcher{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.tab-switcher .contents{-ms-flex:1 0 auto;flex:1 0 auto;min-height:0px}.tab-switcher .contents .hidden{display:none}.tab-switcher .contents.scrollable-tabs{-ms-flex-preferred-size:0;flex-basis:0;overflow-y:auto}.tab-switcher .tabs{display:-ms-flexbox;display:flex;position:relative;width:100%;overflow-y:hidden;overflow-x:auto;padding-top:5px;box-sizing:border-box}.tab-switcher .tabs::after,.tab-switcher .tabs::before{display:block;content:\"\";-ms-flex:1 1 auto;flex:1 1 auto;border-bottom:1px solid;border-bottom-color:#222;border-bottom-color:var(--border, #222)}.tab-switcher .tabs .tab-wrapper{height:28px;position:relative;display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto}.tab-switcher .tabs .tab-wrapper .tab{width:100%;min-width:1px;position:relative;border-bottom-left-radius:0;border-bottom-right-radius:0;padding:6px 1em;padding-bottom:99px;margin-bottom:-93px;white-space:nowrap;color:#b9b9ba;color:var(--tabText, #b9b9ba);background-color:#182230;background-color:var(--tab, #182230)}.tab-switcher .tabs .tab-wrapper .tab:not(.active){z-index:4}.tab-switcher .tabs .tab-wrapper .tab:not(.active):hover{z-index:6}.tab-switcher .tabs .tab-wrapper .tab.active{background:transparent;z-index:5;color:#b9b9ba;color:var(--tabActiveText, #b9b9ba)}.tab-switcher .tabs .tab-wrapper .tab img{max-height:26px;vertical-align:top;margin-top:-5px}.tab-switcher .tabs .tab-wrapper:not(.active)::after{content:\"\";position:absolute;left:0;right:0;bottom:0;z-index:7;border-bottom:1px solid;border-bottom-color:#222;border-bottom-color:var(--border, #222)}",".with-subscription-loading{padding:10px;text-align:center}.with-subscription-loading .error{font-size:14px}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/vendors~app.18fea621d430000acc27.css b/priv/static/static/css/vendors~app.18fea621d430000acc27.css deleted file mode 100644 index ef783cbb3..000000000 Binary files a/priv/static/static/css/vendors~app.18fea621d430000acc27.css and /dev/null differ diff --git a/priv/static/static/css/vendors~app.18fea621d430000acc27.css.map b/priv/static/static/css/vendors~app.18fea621d430000acc27.css.map deleted file mode 100644 index 057d67d6a..000000000 --- a/priv/static/static/css/vendors~app.18fea621d430000acc27.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./node_modules/cropperjs/dist/cropper.css"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA,wCAAwC;AACxC;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA","file":"static/css/vendors~app.18fea621d430000acc27.css","sourcesContent":["/*!\n * Cropper.js v1.5.6\n * https://fengyuanchen.github.io/cropperjs\n *\n * Copyright 2015-present Chen Fengyuan\n * Released under the MIT license\n *\n * Date: 2019-10-04T04:33:44.164Z\n */\n\n.cropper-container {\n direction: ltr;\n font-size: 0;\n line-height: 0;\n position: relative;\n -ms-touch-action: none;\n touch-action: none;\n -webkit-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.cropper-container img {\n display: block;\n height: 100%;\n image-orientation: 0deg;\n max-height: none !important;\n max-width: none !important;\n min-height: 0 !important;\n min-width: 0 !important;\n width: 100%;\n}\n\n.cropper-wrap-box,\n.cropper-canvas,\n.cropper-drag-box,\n.cropper-crop-box,\n.cropper-modal {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n}\n\n.cropper-wrap-box,\n.cropper-canvas {\n overflow: hidden;\n}\n\n.cropper-drag-box {\n background-color: #fff;\n opacity: 0;\n}\n\n.cropper-modal {\n background-color: #000;\n opacity: 0.5;\n}\n\n.cropper-view-box {\n display: block;\n height: 100%;\n outline: 1px solid #39f;\n outline-color: rgba(51, 153, 255, 0.75);\n overflow: hidden;\n width: 100%;\n}\n\n.cropper-dashed {\n border: 0 dashed #eee;\n display: block;\n opacity: 0.5;\n position: absolute;\n}\n\n.cropper-dashed.dashed-h {\n border-bottom-width: 1px;\n border-top-width: 1px;\n height: calc(100% / 3);\n left: 0;\n top: calc(100% / 3);\n width: 100%;\n}\n\n.cropper-dashed.dashed-v {\n border-left-width: 1px;\n border-right-width: 1px;\n height: 100%;\n left: calc(100% / 3);\n top: 0;\n width: calc(100% / 3);\n}\n\n.cropper-center {\n display: block;\n height: 0;\n left: 50%;\n opacity: 0.75;\n position: absolute;\n top: 50%;\n width: 0;\n}\n\n.cropper-center::before,\n.cropper-center::after {\n background-color: #eee;\n content: ' ';\n display: block;\n position: absolute;\n}\n\n.cropper-center::before {\n height: 1px;\n left: -3px;\n top: 0;\n width: 7px;\n}\n\n.cropper-center::after {\n height: 7px;\n left: 0;\n top: -3px;\n width: 1px;\n}\n\n.cropper-face,\n.cropper-line,\n.cropper-point {\n display: block;\n height: 100%;\n opacity: 0.1;\n position: absolute;\n width: 100%;\n}\n\n.cropper-face {\n background-color: #fff;\n left: 0;\n top: 0;\n}\n\n.cropper-line {\n background-color: #39f;\n}\n\n.cropper-line.line-e {\n cursor: ew-resize;\n right: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-n {\n cursor: ns-resize;\n height: 5px;\n left: 0;\n top: -3px;\n}\n\n.cropper-line.line-w {\n cursor: ew-resize;\n left: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-s {\n bottom: -3px;\n cursor: ns-resize;\n height: 5px;\n left: 0;\n}\n\n.cropper-point {\n background-color: #39f;\n height: 5px;\n opacity: 0.75;\n width: 5px;\n}\n\n.cropper-point.point-e {\n cursor: ew-resize;\n margin-top: -3px;\n right: -3px;\n top: 50%;\n}\n\n.cropper-point.point-n {\n cursor: ns-resize;\n left: 50%;\n margin-left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-w {\n cursor: ew-resize;\n left: -3px;\n margin-top: -3px;\n top: 50%;\n}\n\n.cropper-point.point-s {\n bottom: -3px;\n cursor: s-resize;\n left: 50%;\n margin-left: -3px;\n}\n\n.cropper-point.point-ne {\n cursor: nesw-resize;\n right: -3px;\n top: -3px;\n}\n\n.cropper-point.point-nw {\n cursor: nwse-resize;\n left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-sw {\n bottom: -3px;\n cursor: nesw-resize;\n left: -3px;\n}\n\n.cropper-point.point-se {\n bottom: -3px;\n cursor: nwse-resize;\n height: 20px;\n opacity: 1;\n right: -3px;\n width: 20px;\n}\n\n@media (min-width: 768px) {\n .cropper-point.point-se {\n height: 15px;\n width: 15px;\n }\n}\n\n@media (min-width: 992px) {\n .cropper-point.point-se {\n height: 10px;\n width: 10px;\n }\n}\n\n@media (min-width: 1200px) {\n .cropper-point.point-se {\n height: 5px;\n opacity: 0.75;\n width: 5px;\n }\n}\n\n.cropper-point.point-se::before {\n background-color: #39f;\n bottom: -50%;\n content: ' ';\n display: block;\n height: 200%;\n opacity: 0;\n position: absolute;\n right: -50%;\n width: 200%;\n}\n\n.cropper-invisible {\n opacity: 0;\n}\n\n.cropper-bg {\n background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');\n}\n\n.cropper-hide {\n display: block;\n height: 0;\n position: absolute;\n width: 0;\n}\n\n.cropper-hidden {\n display: none !important;\n}\n\n.cropper-move {\n cursor: move;\n}\n\n.cropper-crop {\n cursor: crosshair;\n}\n\n.cropper-disabled .cropper-drag-box,\n.cropper-disabled .cropper-face,\n.cropper-disabled .cropper-line,\n.cropper-disabled .cropper-point {\n cursor: not-allowed;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1589385935077.eot b/priv/static/static/font/fontello.1589385935077.eot deleted file mode 100644 index e5f37013a..000000000 Binary files a/priv/static/static/font/fontello.1589385935077.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1589385935077.svg b/priv/static/static/font/fontello.1589385935077.svg deleted file mode 100644 index e63fb7529..000000000 --- a/priv/static/static/font/fontello.1589385935077.svg +++ /dev/null @@ -1,124 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1589385935077.ttf b/priv/static/static/font/fontello.1589385935077.ttf deleted file mode 100644 index 0fde96cea..000000000 Binary files a/priv/static/static/font/fontello.1589385935077.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1589385935077.woff b/priv/static/static/font/fontello.1589385935077.woff deleted file mode 100644 index f48488a77..000000000 Binary files a/priv/static/static/font/fontello.1589385935077.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1589385935077.woff2 b/priv/static/static/font/fontello.1589385935077.woff2 deleted file mode 100644 index 012eb9305..000000000 Binary files a/priv/static/static/font/fontello.1589385935077.woff2 and /dev/null differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json index 7f0e7cdd5..6083c0bfa 100755 --- a/priv/static/static/fontello.json +++ b/priv/static/static/fontello.json @@ -363,6 +363,30 @@ "css": "ok", "code": 59431, "src": "fontawesome" + }, + { + "uid": "4109c474ff99cad28fd5a2c38af2ec6f", + "css": "filter", + "code": 61616, + "src": "fontawesome" + }, + { + "uid": "9a76bc135eac17d2c8b8ad4a5774fc87", + "css": "download", + "code": 59429, + "src": "fontawesome" + }, + { + "uid": "f04a5d24e9e659145b966739c4fde82a", + "css": "bookmark", + "code": 59430, + "src": "fontawesome" + }, + { + "uid": "2f5ef6f6b7aaebc56458ab4e865beff5", + "css": "bookmark-empty", + "code": 61591, + "src": "fontawesome" } ] } \ No newline at end of file diff --git a/priv/static/static/img/nsfw.74818f9.png b/priv/static/static/img/nsfw.74818f9.png deleted file mode 100644 index d25137767..000000000 Binary files a/priv/static/static/img/nsfw.74818f9.png and /dev/null differ diff --git a/priv/static/static/js/2.18e4adec273c4ce867a8.js b/priv/static/static/js/2.18e4adec273c4ce867a8.js deleted file mode 100644 index d191aa852..000000000 Binary files a/priv/static/static/js/2.18e4adec273c4ce867a8.js and /dev/null differ diff --git a/priv/static/static/js/2.18e4adec273c4ce867a8.js.map b/priv/static/static/js/2.18e4adec273c4ce867a8.js.map deleted file mode 100644 index a7f98bfef..000000000 Binary files a/priv/static/static/js/2.18e4adec273c4ce867a8.js.map and /dev/null differ diff --git a/priv/static/static/js/app.838ffa9aecf210c7d744.js b/priv/static/static/js/app.838ffa9aecf210c7d744.js deleted file mode 100644 index 7e224748e..000000000 Binary files a/priv/static/static/js/app.838ffa9aecf210c7d744.js and /dev/null differ diff --git a/priv/static/static/js/app.838ffa9aecf210c7d744.js.map b/priv/static/static/js/app.838ffa9aecf210c7d744.js.map deleted file mode 100644 index 4c2835cb4..000000000 Binary files a/priv/static/static/js/app.838ffa9aecf210c7d744.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js b/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js deleted file mode 100644 index d1f1a1830..000000000 Binary files a/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map b/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map deleted file mode 100644 index 0d4a859ea..000000000 Binary files a/priv/static/static/js/vendors~app.561a1c605d1dfb0e6f74.js.map and /dev/null differ diff --git a/priv/static/static/terms-of-service.html b/priv/static/static/terms-of-service.html index a6da539e4..b2c668151 100644 --- a/priv/static/static/terms-of-service.html +++ b/priv/static/static/terms-of-service.html @@ -1,4 +1,9 @@

    Terms of Service

    -

    This is a placeholder ToS. Edit "/static/terms-of-service.html" to make it fit the needs of your instance.

    +

    This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.

    + +

    To do so, place a file at "/instance/static/terms-of-service.html" in your + Pleroma install containing the real ToS for your instance.

    +

    See the Pleroma documentation for more information.

    +
    diff --git a/priv/static/static/themes/redmond-xx-se.json b/priv/static/static/themes/redmond-xx-se.json index 7a4a29da3..24480d2c7 100644 --- a/priv/static/static/themes/redmond-xx-se.json +++ b/priv/static/static/themes/redmond-xx-se.json @@ -286,7 +286,9 @@ "cGreen": "#008000", "cOrange": "#808000", "highlight": "--accent", - "selectedPost": "--bg,-10" + "selectedPost": "--bg,-10", + "selectedMenu": "--accent", + "selectedMenuPopover": "--accent" }, "radii": { "btn": "0", diff --git a/priv/static/static/themes/redmond-xx.json b/priv/static/static/themes/redmond-xx.json index ff95b1e06..cf9010fe2 100644 --- a/priv/static/static/themes/redmond-xx.json +++ b/priv/static/static/themes/redmond-xx.json @@ -277,7 +277,9 @@ "cGreen": "#008000", "cOrange": "#808000", "highlight": "--accent", - "selectedPost": "--bg,-10" + "selectedPost": "--bg,-10", + "selectedMenu": "--accent", + "selectedMenuPopover": "--accent" }, "radii": { "btn": "0", diff --git a/priv/static/static/themes/redmond-xxi.json b/priv/static/static/themes/redmond-xxi.json index f788bdb83..7fdc4a6d6 100644 --- a/priv/static/static/themes/redmond-xxi.json +++ b/priv/static/static/themes/redmond-xxi.json @@ -259,7 +259,9 @@ "cGreen": "#669966", "cOrange": "#cc6633", "highlight": "--accent", - "selectedPost": "--bg,-10" + "selectedPost": "--bg,-10", + "selectedMenu": "--accent", + "selectedMenuPopover": "--accent" }, "radii": { "btn": "0", diff --git a/priv/static/sw-pleroma-workbox.js b/priv/static/sw-pleroma-workbox.js new file mode 100644 index 000000000..0b39d0963 Binary files /dev/null and b/priv/static/sw-pleroma-workbox.js differ diff --git a/priv/static/sw-pleroma-workbox.js.map b/priv/static/sw-pleroma-workbox.js.map new file mode 100644 index 000000000..e35c07e72 Binary files /dev/null and b/priv/static/sw-pleroma-workbox.js.map differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 4d73c414e..f6579fdd7 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index c704cb951..37a17be47 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ diff --git a/priv/static/sw.js b/priv/static/sw.js index 0fde0f440..5605bb05e 100644 Binary files a/priv/static/sw.js and b/priv/static/sw.js differ -- cgit v1.2.3 From f787eb1acffb5b577071db5a41625d067d552e93 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 12:20:20 +0200 Subject: Update frontend --- priv/static/index.html | 2 +- priv/static/static/css/2.0778a6a864a1307a6c41.css | Bin 0 -> 181 bytes .../static/css/2.0778a6a864a1307a6c41.css.map | 1 + priv/static/static/css/3.b2603a50868c68a1c192.css | Bin 0 -> 4700 bytes .../static/css/3.b2603a50868c68a1c192.css.map | 1 + .../static/static/css/app.493b9b5acee37ba97824.css | Bin 0 -> 5568 bytes .../static/css/app.493b9b5acee37ba97824.css.map | 1 + priv/static/static/font/fontello.1594030805019.eot | Bin 0 -> 23832 bytes priv/static/static/font/fontello.1594030805019.svg | 132 +++++++++++++++++++++ priv/static/static/font/fontello.1594030805019.ttf | Bin 0 -> 23664 bytes .../static/static/font/fontello.1594030805019.woff | Bin 0 -> 14464 bytes .../static/font/fontello.1594030805019.woff2 | Bin 0 -> 12272 bytes priv/static/static/fontello.1594030805019.css | Bin 0 -> 3609 bytes priv/static/static/img/nsfw.74818f9.png | Bin 0 -> 35104 bytes priv/static/static/js/10.0f1994ddc34cfbc08609.js | Bin 0 -> 23120 bytes .../static/js/10.0f1994ddc34cfbc08609.js.map | Bin 0 -> 113 bytes priv/static/static/js/11.1e7cd81617d5fdd53e6e.js | Bin 0 -> 16564 bytes .../static/js/11.1e7cd81617d5fdd53e6e.js.map | Bin 0 -> 113 bytes priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js | Bin 0 -> 22582 bytes .../static/js/12.d9989f5b5d0f8d4aa8a1.js.map | Bin 0 -> 113 bytes priv/static/static/js/13.01dcbbeee7fc697d5dff.js | Bin 0 -> 26142 bytes .../static/js/13.01dcbbeee7fc697d5dff.js.map | Bin 0 -> 113 bytes priv/static/static/js/14.4355245d20f818121839.js | Bin 0 -> 28652 bytes .../static/js/14.4355245d20f818121839.js.map | Bin 0 -> 113 bytes priv/static/static/js/15.cad89660cbeef1f1f737.js | Bin 0 -> 7939 bytes .../static/js/15.cad89660cbeef1f1f737.js.map | Bin 0 -> 113 bytes priv/static/static/js/16.0f8c0529208576f8d8f1.js | Bin 0 -> 15892 bytes .../static/js/16.0f8c0529208576f8d8f1.js.map | Bin 0 -> 113 bytes priv/static/static/js/17.102667c39eaf1f3da16f.js | Bin 0 -> 2234 bytes .../static/js/17.102667c39eaf1f3da16f.js.map | Bin 0 -> 113 bytes priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js | Bin 0 -> 20453 bytes .../static/js/18.0a9dfc8a06dfcc8f0e29.js.map | Bin 0 -> 113 bytes priv/static/static/js/19.031e07a59c2ec00e163f.js | Bin 0 -> 32200 bytes .../static/js/19.031e07a59c2ec00e163f.js.map | Bin 0 -> 113 bytes priv/static/static/js/2.ca205c0a35e5f6a21711.js | Bin 0 -> 174070 bytes .../static/static/js/2.ca205c0a35e5f6a21711.js.map | Bin 0 -> 450037 bytes priv/static/static/js/20.4211860717a159173685.js | Bin 0 -> 26951 bytes .../static/js/20.4211860717a159173685.js.map | Bin 0 -> 113 bytes priv/static/static/js/21.f1d1ea794ca98abd7c8f.js | Bin 0 -> 13310 bytes .../static/js/21.f1d1ea794ca98abd7c8f.js.map | Bin 0 -> 113 bytes priv/static/static/js/22.be0989993d98819df69a.js | Bin 0 -> 20130 bytes .../static/js/22.be0989993d98819df69a.js.map | Bin 0 -> 113 bytes priv/static/static/js/23.353fb2474276b7d9d8ab.js | Bin 0 -> 28187 bytes .../static/js/23.353fb2474276b7d9d8ab.js.map | Bin 0 -> 113 bytes priv/static/static/js/24.222c48387222e8bc7c84.js | Bin 0 -> 18949 bytes .../static/js/24.222c48387222e8bc7c84.js.map | Bin 0 -> 113 bytes priv/static/static/js/25.59d04b82ff45f25b44ef.js | Bin 0 -> 27408 bytes .../static/js/25.59d04b82ff45f25b44ef.js.map | Bin 0 -> 113 bytes priv/static/static/js/26.d4910001c228c31abe61.js | Bin 0 -> 14415 bytes .../static/js/26.d4910001c228c31abe61.js.map | Bin 0 -> 113 bytes priv/static/static/js/27.68d319e0867f9e35d5d3.js | Bin 0 -> 2175 bytes .../static/js/27.68d319e0867f9e35d5d3.js.map | Bin 0 -> 113 bytes priv/static/static/js/28.580f1c09759e4dabced9.js | Bin 0 -> 25778 bytes .../static/js/28.580f1c09759e4dabced9.js.map | Bin 0 -> 113 bytes priv/static/static/js/29.ea54402e3fbd16f17eb7.js | Bin 0 -> 24135 bytes .../static/js/29.ea54402e3fbd16f17eb7.js.map | Bin 0 -> 113 bytes priv/static/static/js/3.23de974e1235c91ea803.js | Bin 0 -> 78761 bytes .../static/static/js/3.23de974e1235c91ea803.js.map | Bin 0 -> 332972 bytes priv/static/static/js/30.b657503bf18858a9b282.js | Bin 0 -> 21494 bytes .../static/js/30.b657503bf18858a9b282.js.map | Bin 0 -> 113 bytes priv/static/static/js/4.4fe9f0677ec54321f659.js | Bin 0 -> 2177 bytes .../static/static/js/4.4fe9f0677ec54321f659.js.map | Bin 0 -> 7940 bytes priv/static/static/js/5.74ace591a96fca58ee48.js | Bin 0 -> 7028 bytes .../static/static/js/5.74ace591a96fca58ee48.js.map | Bin 0 -> 112 bytes priv/static/static/js/6.67ff41bfc9476902b9de.js | Bin 0 -> 7955 bytes .../static/static/js/6.67ff41bfc9476902b9de.js.map | Bin 0 -> 112 bytes priv/static/static/js/7.c0d55831c37350a90aee.js | Bin 0 -> 15765 bytes .../static/static/js/7.c0d55831c37350a90aee.js.map | Bin 0 -> 112 bytes priv/static/static/js/8.83dbefa1dc25a2e61b92.js | Bin 0 -> 21966 bytes .../static/static/js/8.83dbefa1dc25a2e61b92.js.map | Bin 0 -> 112 bytes priv/static/static/js/9.aa8acb3e28bf30fdefc7.js | Bin 0 -> 13880 bytes .../static/static/js/9.aa8acb3e28bf30fdefc7.js.map | Bin 0 -> 112 bytes priv/static/static/js/app.7db8116851a0fe6eb807.js | Bin 0 -> 514189 bytes .../static/js/app.7db8116851a0fe6eb807.js.map | Bin 0 -> 1329621 bytes .../static/js/vendors~app.fbb3f5304df245971d96.js | Bin 0 -> 303822 bytes .../js/vendors~app.fbb3f5304df245971d96.js.map | Bin 0 -> 1271967 bytes priv/static/sw-pleroma.js | Bin 11597 -> 181342 bytes priv/static/sw-pleroma.js.map | Bin 45212 -> 694047 bytes 78 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 priv/static/static/css/2.0778a6a864a1307a6c41.css create mode 100644 priv/static/static/css/2.0778a6a864a1307a6c41.css.map create mode 100644 priv/static/static/css/3.b2603a50868c68a1c192.css create mode 100644 priv/static/static/css/3.b2603a50868c68a1c192.css.map create mode 100644 priv/static/static/css/app.493b9b5acee37ba97824.css create mode 100644 priv/static/static/css/app.493b9b5acee37ba97824.css.map create mode 100644 priv/static/static/font/fontello.1594030805019.eot create mode 100644 priv/static/static/font/fontello.1594030805019.svg create mode 100644 priv/static/static/font/fontello.1594030805019.ttf create mode 100644 priv/static/static/font/fontello.1594030805019.woff create mode 100644 priv/static/static/font/fontello.1594030805019.woff2 create mode 100644 priv/static/static/fontello.1594030805019.css create mode 100644 priv/static/static/img/nsfw.74818f9.png create mode 100644 priv/static/static/js/10.0f1994ddc34cfbc08609.js create mode 100644 priv/static/static/js/10.0f1994ddc34cfbc08609.js.map create mode 100644 priv/static/static/js/11.1e7cd81617d5fdd53e6e.js create mode 100644 priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map create mode 100644 priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js create mode 100644 priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map create mode 100644 priv/static/static/js/13.01dcbbeee7fc697d5dff.js create mode 100644 priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map create mode 100644 priv/static/static/js/14.4355245d20f818121839.js create mode 100644 priv/static/static/js/14.4355245d20f818121839.js.map create mode 100644 priv/static/static/js/15.cad89660cbeef1f1f737.js create mode 100644 priv/static/static/js/15.cad89660cbeef1f1f737.js.map create mode 100644 priv/static/static/js/16.0f8c0529208576f8d8f1.js create mode 100644 priv/static/static/js/16.0f8c0529208576f8d8f1.js.map create mode 100644 priv/static/static/js/17.102667c39eaf1f3da16f.js create mode 100644 priv/static/static/js/17.102667c39eaf1f3da16f.js.map create mode 100644 priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js create mode 100644 priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map create mode 100644 priv/static/static/js/19.031e07a59c2ec00e163f.js create mode 100644 priv/static/static/js/19.031e07a59c2ec00e163f.js.map create mode 100644 priv/static/static/js/2.ca205c0a35e5f6a21711.js create mode 100644 priv/static/static/js/2.ca205c0a35e5f6a21711.js.map create mode 100644 priv/static/static/js/20.4211860717a159173685.js create mode 100644 priv/static/static/js/20.4211860717a159173685.js.map create mode 100644 priv/static/static/js/21.f1d1ea794ca98abd7c8f.js create mode 100644 priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map create mode 100644 priv/static/static/js/22.be0989993d98819df69a.js create mode 100644 priv/static/static/js/22.be0989993d98819df69a.js.map create mode 100644 priv/static/static/js/23.353fb2474276b7d9d8ab.js create mode 100644 priv/static/static/js/23.353fb2474276b7d9d8ab.js.map create mode 100644 priv/static/static/js/24.222c48387222e8bc7c84.js create mode 100644 priv/static/static/js/24.222c48387222e8bc7c84.js.map create mode 100644 priv/static/static/js/25.59d04b82ff45f25b44ef.js create mode 100644 priv/static/static/js/25.59d04b82ff45f25b44ef.js.map create mode 100644 priv/static/static/js/26.d4910001c228c31abe61.js create mode 100644 priv/static/static/js/26.d4910001c228c31abe61.js.map create mode 100644 priv/static/static/js/27.68d319e0867f9e35d5d3.js create mode 100644 priv/static/static/js/27.68d319e0867f9e35d5d3.js.map create mode 100644 priv/static/static/js/28.580f1c09759e4dabced9.js create mode 100644 priv/static/static/js/28.580f1c09759e4dabced9.js.map create mode 100644 priv/static/static/js/29.ea54402e3fbd16f17eb7.js create mode 100644 priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map create mode 100644 priv/static/static/js/3.23de974e1235c91ea803.js create mode 100644 priv/static/static/js/3.23de974e1235c91ea803.js.map create mode 100644 priv/static/static/js/30.b657503bf18858a9b282.js create mode 100644 priv/static/static/js/30.b657503bf18858a9b282.js.map create mode 100644 priv/static/static/js/4.4fe9f0677ec54321f659.js create mode 100644 priv/static/static/js/4.4fe9f0677ec54321f659.js.map create mode 100644 priv/static/static/js/5.74ace591a96fca58ee48.js create mode 100644 priv/static/static/js/5.74ace591a96fca58ee48.js.map create mode 100644 priv/static/static/js/6.67ff41bfc9476902b9de.js create mode 100644 priv/static/static/js/6.67ff41bfc9476902b9de.js.map create mode 100644 priv/static/static/js/7.c0d55831c37350a90aee.js create mode 100644 priv/static/static/js/7.c0d55831c37350a90aee.js.map create mode 100644 priv/static/static/js/8.83dbefa1dc25a2e61b92.js create mode 100644 priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map create mode 100644 priv/static/static/js/9.aa8acb3e28bf30fdefc7.js create mode 100644 priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map create mode 100644 priv/static/static/js/app.7db8116851a0fe6eb807.js create mode 100644 priv/static/static/js/app.7db8116851a0fe6eb807.js.map create mode 100644 priv/static/static/js/vendors~app.fbb3f5304df245971d96.js create mode 100644 priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map diff --git a/priv/static/index.html b/priv/static/index.html index 279deb8b6..ef7be091b 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/css/2.0778a6a864a1307a6c41.css b/priv/static/static/css/2.0778a6a864a1307a6c41.css new file mode 100644 index 000000000..a33585ef1 Binary files /dev/null and b/priv/static/static/css/2.0778a6a864a1307a6c41.css differ diff --git a/priv/static/static/css/2.0778a6a864a1307a6c41.css.map b/priv/static/static/css/2.0778a6a864a1307a6c41.css.map new file mode 100644 index 000000000..28cd8ba54 --- /dev/null +++ b/priv/static/static/css/2.0778a6a864a1307a6c41.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/hocs/with_subscription/with_subscription.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/2.0778a6a864a1307a6c41.css","sourcesContent":[".with-subscription-loading {\n padding: 10px;\n text-align: center;\n}\n.with-subscription-loading .error {\n font-size: 14px;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/3.b2603a50868c68a1c192.css b/priv/static/static/css/3.b2603a50868c68a1c192.css new file mode 100644 index 000000000..4cec5785b Binary files /dev/null and b/priv/static/static/css/3.b2603a50868c68a1c192.css differ diff --git a/priv/static/static/css/3.b2603a50868c68a1c192.css.map b/priv/static/static/css/3.b2603a50868c68a1c192.css.map new file mode 100644 index 000000000..805e7dc04 --- /dev/null +++ b/priv/static/static/css/3.b2603a50868c68a1c192.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./node_modules/cropperjs/dist/cropper.css"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA,wCAAwC;AACxC;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA","file":"static/css/3.b2603a50868c68a1c192.css","sourcesContent":["/*!\n * Cropper.js v1.4.3\n * https://fengyuanchen.github.io/cropperjs\n *\n * Copyright 2015-present Chen Fengyuan\n * Released under the MIT license\n *\n * Date: 2018-10-24T13:07:11.429Z\n */\n\n.cropper-container {\n direction: ltr;\n font-size: 0;\n line-height: 0;\n position: relative;\n -ms-touch-action: none;\n touch-action: none;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n\n.cropper-container img {\n display: block;\n height: 100%;\n image-orientation: 0deg;\n max-height: none !important;\n max-width: none !important;\n min-height: 0 !important;\n min-width: 0 !important;\n width: 100%;\n}\n\n.cropper-wrap-box,\n.cropper-canvas,\n.cropper-drag-box,\n.cropper-crop-box,\n.cropper-modal {\n bottom: 0;\n left: 0;\n position: absolute;\n right: 0;\n top: 0;\n}\n\n.cropper-wrap-box,\n.cropper-canvas {\n overflow: hidden;\n}\n\n.cropper-drag-box {\n background-color: #fff;\n opacity: 0;\n}\n\n.cropper-modal {\n background-color: #000;\n opacity: .5;\n}\n\n.cropper-view-box {\n display: block;\n height: 100%;\n outline-color: rgba(51, 153, 255, 0.75);\n outline: 1px solid #39f;\n overflow: hidden;\n width: 100%;\n}\n\n.cropper-dashed {\n border: 0 dashed #eee;\n display: block;\n opacity: .5;\n position: absolute;\n}\n\n.cropper-dashed.dashed-h {\n border-bottom-width: 1px;\n border-top-width: 1px;\n height: calc(100% / 3);\n left: 0;\n top: calc(100% / 3);\n width: 100%;\n}\n\n.cropper-dashed.dashed-v {\n border-left-width: 1px;\n border-right-width: 1px;\n height: 100%;\n left: calc(100% / 3);\n top: 0;\n width: calc(100% / 3);\n}\n\n.cropper-center {\n display: block;\n height: 0;\n left: 50%;\n opacity: .75;\n position: absolute;\n top: 50%;\n width: 0;\n}\n\n.cropper-center:before,\n.cropper-center:after {\n background-color: #eee;\n content: ' ';\n display: block;\n position: absolute;\n}\n\n.cropper-center:before {\n height: 1px;\n left: -3px;\n top: 0;\n width: 7px;\n}\n\n.cropper-center:after {\n height: 7px;\n left: 0;\n top: -3px;\n width: 1px;\n}\n\n.cropper-face,\n.cropper-line,\n.cropper-point {\n display: block;\n height: 100%;\n opacity: .1;\n position: absolute;\n width: 100%;\n}\n\n.cropper-face {\n background-color: #fff;\n left: 0;\n top: 0;\n}\n\n.cropper-line {\n background-color: #39f;\n}\n\n.cropper-line.line-e {\n cursor: ew-resize;\n right: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-n {\n cursor: ns-resize;\n height: 5px;\n left: 0;\n top: -3px;\n}\n\n.cropper-line.line-w {\n cursor: ew-resize;\n left: -3px;\n top: 0;\n width: 5px;\n}\n\n.cropper-line.line-s {\n bottom: -3px;\n cursor: ns-resize;\n height: 5px;\n left: 0;\n}\n\n.cropper-point {\n background-color: #39f;\n height: 5px;\n opacity: .75;\n width: 5px;\n}\n\n.cropper-point.point-e {\n cursor: ew-resize;\n margin-top: -3px;\n right: -3px;\n top: 50%;\n}\n\n.cropper-point.point-n {\n cursor: ns-resize;\n left: 50%;\n margin-left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-w {\n cursor: ew-resize;\n left: -3px;\n margin-top: -3px;\n top: 50%;\n}\n\n.cropper-point.point-s {\n bottom: -3px;\n cursor: s-resize;\n left: 50%;\n margin-left: -3px;\n}\n\n.cropper-point.point-ne {\n cursor: nesw-resize;\n right: -3px;\n top: -3px;\n}\n\n.cropper-point.point-nw {\n cursor: nwse-resize;\n left: -3px;\n top: -3px;\n}\n\n.cropper-point.point-sw {\n bottom: -3px;\n cursor: nesw-resize;\n left: -3px;\n}\n\n.cropper-point.point-se {\n bottom: -3px;\n cursor: nwse-resize;\n height: 20px;\n opacity: 1;\n right: -3px;\n width: 20px;\n}\n\n@media (min-width: 768px) {\n .cropper-point.point-se {\n height: 15px;\n width: 15px;\n }\n}\n\n@media (min-width: 992px) {\n .cropper-point.point-se {\n height: 10px;\n width: 10px;\n }\n}\n\n@media (min-width: 1200px) {\n .cropper-point.point-se {\n height: 5px;\n opacity: .75;\n width: 5px;\n }\n}\n\n.cropper-point.point-se:before {\n background-color: #39f;\n bottom: -50%;\n content: ' ';\n display: block;\n height: 200%;\n opacity: 0;\n position: absolute;\n right: -50%;\n width: 200%;\n}\n\n.cropper-invisible {\n opacity: 0;\n}\n\n.cropper-bg {\n background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');\n}\n\n.cropper-hide {\n display: block;\n height: 0;\n position: absolute;\n width: 0;\n}\n\n.cropper-hidden {\n display: none !important;\n}\n\n.cropper-move {\n cursor: move;\n}\n\n.cropper-crop {\n cursor: crosshair;\n}\n\n.cropper-disabled .cropper-drag-box,\n.cropper-disabled .cropper-face,\n.cropper-disabled .cropper-line,\n.cropper-disabled .cropper-point {\n cursor: not-allowed;\n}\n"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/app.493b9b5acee37ba97824.css b/priv/static/static/css/app.493b9b5acee37ba97824.css new file mode 100644 index 000000000..f30033af6 Binary files /dev/null and b/priv/static/static/css/app.493b9b5acee37ba97824.css differ diff --git a/priv/static/static/css/app.493b9b5acee37ba97824.css.map b/priv/static/static/css/app.493b9b5acee37ba97824.css.map new file mode 100644 index 000000000..91399d605 --- /dev/null +++ b/priv/static/static/css/app.493b9b5acee37ba97824.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.493b9b5acee37ba97824.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594030805019.eot b/priv/static/static/font/fontello.1594030805019.eot new file mode 100644 index 000000000..f6155180f Binary files /dev/null and b/priv/static/static/font/fontello.1594030805019.eot differ diff --git a/priv/static/static/font/fontello.1594030805019.svg b/priv/static/static/font/fontello.1594030805019.svg new file mode 100644 index 000000000..8da206aa8 --- /dev/null +++ b/priv/static/static/font/fontello.1594030805019.svg @@ -0,0 +1,132 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594030805019.ttf b/priv/static/static/font/fontello.1594030805019.ttf new file mode 100644 index 000000000..7bedaf7ab Binary files /dev/null and b/priv/static/static/font/fontello.1594030805019.ttf differ diff --git a/priv/static/static/font/fontello.1594030805019.woff b/priv/static/static/font/fontello.1594030805019.woff new file mode 100644 index 000000000..e61bf68d0 Binary files /dev/null and b/priv/static/static/font/fontello.1594030805019.woff differ diff --git a/priv/static/static/font/fontello.1594030805019.woff2 b/priv/static/static/font/fontello.1594030805019.woff2 new file mode 100644 index 000000000..db0fc1fc6 Binary files /dev/null and b/priv/static/static/font/fontello.1594030805019.woff2 differ diff --git a/priv/static/static/fontello.1594030805019.css b/priv/static/static/fontello.1594030805019.css new file mode 100644 index 000000000..9251070fe Binary files /dev/null and b/priv/static/static/fontello.1594030805019.css differ diff --git a/priv/static/static/img/nsfw.74818f9.png b/priv/static/static/img/nsfw.74818f9.png new file mode 100644 index 000000000..d25137767 Binary files /dev/null and b/priv/static/static/img/nsfw.74818f9.png differ diff --git a/priv/static/static/js/10.0f1994ddc34cfbc08609.js b/priv/static/static/js/10.0f1994ddc34cfbc08609.js new file mode 100644 index 000000000..707e0ad56 Binary files /dev/null and b/priv/static/static/js/10.0f1994ddc34cfbc08609.js differ diff --git a/priv/static/static/js/10.0f1994ddc34cfbc08609.js.map b/priv/static/static/js/10.0f1994ddc34cfbc08609.js.map new file mode 100644 index 000000000..7de298aa8 Binary files /dev/null and b/priv/static/static/js/10.0f1994ddc34cfbc08609.js.map differ diff --git a/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js b/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js new file mode 100644 index 000000000..c2558c013 Binary files /dev/null and b/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js differ diff --git a/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map b/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map new file mode 100644 index 000000000..aaf753771 Binary files /dev/null and b/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map differ diff --git a/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js b/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js new file mode 100644 index 000000000..f80c9ab5b Binary files /dev/null and b/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js differ diff --git a/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map b/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map new file mode 100644 index 000000000..586805e73 Binary files /dev/null and b/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map differ diff --git a/priv/static/static/js/13.01dcbbeee7fc697d5dff.js b/priv/static/static/js/13.01dcbbeee7fc697d5dff.js new file mode 100644 index 000000000..de75e44a8 Binary files /dev/null and b/priv/static/static/js/13.01dcbbeee7fc697d5dff.js differ diff --git a/priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map b/priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map new file mode 100644 index 000000000..940f51e94 Binary files /dev/null and b/priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map differ diff --git a/priv/static/static/js/14.4355245d20f818121839.js b/priv/static/static/js/14.4355245d20f818121839.js new file mode 100644 index 000000000..5fcccbcd0 Binary files /dev/null and b/priv/static/static/js/14.4355245d20f818121839.js differ diff --git a/priv/static/static/js/14.4355245d20f818121839.js.map b/priv/static/static/js/14.4355245d20f818121839.js.map new file mode 100644 index 000000000..4be3205e1 Binary files /dev/null and b/priv/static/static/js/14.4355245d20f818121839.js.map differ diff --git a/priv/static/static/js/15.cad89660cbeef1f1f737.js b/priv/static/static/js/15.cad89660cbeef1f1f737.js new file mode 100644 index 000000000..075046760 Binary files /dev/null and b/priv/static/static/js/15.cad89660cbeef1f1f737.js differ diff --git a/priv/static/static/js/15.cad89660cbeef1f1f737.js.map b/priv/static/static/js/15.cad89660cbeef1f1f737.js.map new file mode 100644 index 000000000..fe0e2248b Binary files /dev/null and b/priv/static/static/js/15.cad89660cbeef1f1f737.js.map differ diff --git a/priv/static/static/js/16.0f8c0529208576f8d8f1.js b/priv/static/static/js/16.0f8c0529208576f8d8f1.js new file mode 100644 index 000000000..896e258ea Binary files /dev/null and b/priv/static/static/js/16.0f8c0529208576f8d8f1.js differ diff --git a/priv/static/static/js/16.0f8c0529208576f8d8f1.js.map b/priv/static/static/js/16.0f8c0529208576f8d8f1.js.map new file mode 100644 index 000000000..67396d925 Binary files /dev/null and b/priv/static/static/js/16.0f8c0529208576f8d8f1.js.map differ diff --git a/priv/static/static/js/17.102667c39eaf1f3da16f.js b/priv/static/static/js/17.102667c39eaf1f3da16f.js new file mode 100644 index 000000000..26fae6b3a Binary files /dev/null and b/priv/static/static/js/17.102667c39eaf1f3da16f.js differ diff --git a/priv/static/static/js/17.102667c39eaf1f3da16f.js.map b/priv/static/static/js/17.102667c39eaf1f3da16f.js.map new file mode 100644 index 000000000..778feac3a Binary files /dev/null and b/priv/static/static/js/17.102667c39eaf1f3da16f.js.map differ diff --git a/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js b/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js new file mode 100644 index 000000000..89f4b767f Binary files /dev/null and b/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js differ diff --git a/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map b/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map new file mode 100644 index 000000000..19ec95cb2 Binary files /dev/null and b/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map differ diff --git a/priv/static/static/js/19.031e07a59c2ec00e163f.js b/priv/static/static/js/19.031e07a59c2ec00e163f.js new file mode 100644 index 000000000..6cc262c5e Binary files /dev/null and b/priv/static/static/js/19.031e07a59c2ec00e163f.js differ diff --git a/priv/static/static/js/19.031e07a59c2ec00e163f.js.map b/priv/static/static/js/19.031e07a59c2ec00e163f.js.map new file mode 100644 index 000000000..7773510cf Binary files /dev/null and b/priv/static/static/js/19.031e07a59c2ec00e163f.js.map differ diff --git a/priv/static/static/js/2.ca205c0a35e5f6a21711.js b/priv/static/static/js/2.ca205c0a35e5f6a21711.js new file mode 100644 index 000000000..b7dc1dd25 Binary files /dev/null and b/priv/static/static/js/2.ca205c0a35e5f6a21711.js differ diff --git a/priv/static/static/js/2.ca205c0a35e5f6a21711.js.map b/priv/static/static/js/2.ca205c0a35e5f6a21711.js.map new file mode 100644 index 000000000..7bcb25f26 Binary files /dev/null and b/priv/static/static/js/2.ca205c0a35e5f6a21711.js.map differ diff --git a/priv/static/static/js/20.4211860717a159173685.js b/priv/static/static/js/20.4211860717a159173685.js new file mode 100644 index 000000000..e1a75a1d5 Binary files /dev/null and b/priv/static/static/js/20.4211860717a159173685.js differ diff --git a/priv/static/static/js/20.4211860717a159173685.js.map b/priv/static/static/js/20.4211860717a159173685.js.map new file mode 100644 index 000000000..2b7e634db Binary files /dev/null and b/priv/static/static/js/20.4211860717a159173685.js.map differ diff --git a/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js b/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js new file mode 100644 index 000000000..9b07a0d14 Binary files /dev/null and b/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js differ diff --git a/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map b/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map new file mode 100644 index 000000000..f100b694c Binary files /dev/null and b/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map differ diff --git a/priv/static/static/js/22.be0989993d98819df69a.js b/priv/static/static/js/22.be0989993d98819df69a.js new file mode 100644 index 000000000..a316ea486 Binary files /dev/null and b/priv/static/static/js/22.be0989993d98819df69a.js differ diff --git a/priv/static/static/js/22.be0989993d98819df69a.js.map b/priv/static/static/js/22.be0989993d98819df69a.js.map new file mode 100644 index 000000000..efc93ddb4 Binary files /dev/null and b/priv/static/static/js/22.be0989993d98819df69a.js.map differ diff --git a/priv/static/static/js/23.353fb2474276b7d9d8ab.js b/priv/static/static/js/23.353fb2474276b7d9d8ab.js new file mode 100644 index 000000000..6e01d740b Binary files /dev/null and b/priv/static/static/js/23.353fb2474276b7d9d8ab.js differ diff --git a/priv/static/static/js/23.353fb2474276b7d9d8ab.js.map b/priv/static/static/js/23.353fb2474276b7d9d8ab.js.map new file mode 100644 index 000000000..8b5a39727 Binary files /dev/null and b/priv/static/static/js/23.353fb2474276b7d9d8ab.js.map differ diff --git a/priv/static/static/js/24.222c48387222e8bc7c84.js b/priv/static/static/js/24.222c48387222e8bc7c84.js new file mode 100644 index 000000000..3f04dfbb9 Binary files /dev/null and b/priv/static/static/js/24.222c48387222e8bc7c84.js differ diff --git a/priv/static/static/js/24.222c48387222e8bc7c84.js.map b/priv/static/static/js/24.222c48387222e8bc7c84.js.map new file mode 100644 index 000000000..86a3d5c52 Binary files /dev/null and b/priv/static/static/js/24.222c48387222e8bc7c84.js.map differ diff --git a/priv/static/static/js/25.59d04b82ff45f25b44ef.js b/priv/static/static/js/25.59d04b82ff45f25b44ef.js new file mode 100644 index 000000000..f778b411e Binary files /dev/null and b/priv/static/static/js/25.59d04b82ff45f25b44ef.js differ diff --git a/priv/static/static/js/25.59d04b82ff45f25b44ef.js.map b/priv/static/static/js/25.59d04b82ff45f25b44ef.js.map new file mode 100644 index 000000000..43e3eaae5 Binary files /dev/null and b/priv/static/static/js/25.59d04b82ff45f25b44ef.js.map differ diff --git a/priv/static/static/js/26.d4910001c228c31abe61.js b/priv/static/static/js/26.d4910001c228c31abe61.js new file mode 100644 index 000000000..b479fe465 Binary files /dev/null and b/priv/static/static/js/26.d4910001c228c31abe61.js differ diff --git a/priv/static/static/js/26.d4910001c228c31abe61.js.map b/priv/static/static/js/26.d4910001c228c31abe61.js.map new file mode 100644 index 000000000..507d16f44 Binary files /dev/null and b/priv/static/static/js/26.d4910001c228c31abe61.js.map differ diff --git a/priv/static/static/js/27.68d319e0867f9e35d5d3.js b/priv/static/static/js/27.68d319e0867f9e35d5d3.js new file mode 100644 index 000000000..5e8606a5b Binary files /dev/null and b/priv/static/static/js/27.68d319e0867f9e35d5d3.js differ diff --git a/priv/static/static/js/27.68d319e0867f9e35d5d3.js.map b/priv/static/static/js/27.68d319e0867f9e35d5d3.js.map new file mode 100644 index 000000000..5221aadf3 Binary files /dev/null and b/priv/static/static/js/27.68d319e0867f9e35d5d3.js.map differ diff --git a/priv/static/static/js/28.580f1c09759e4dabced9.js b/priv/static/static/js/28.580f1c09759e4dabced9.js new file mode 100644 index 000000000..c524c023a Binary files /dev/null and b/priv/static/static/js/28.580f1c09759e4dabced9.js differ diff --git a/priv/static/static/js/28.580f1c09759e4dabced9.js.map b/priv/static/static/js/28.580f1c09759e4dabced9.js.map new file mode 100644 index 000000000..5aa14985c Binary files /dev/null and b/priv/static/static/js/28.580f1c09759e4dabced9.js.map differ diff --git a/priv/static/static/js/29.ea54402e3fbd16f17eb7.js b/priv/static/static/js/29.ea54402e3fbd16f17eb7.js new file mode 100644 index 000000000..cb6e468b2 Binary files /dev/null and b/priv/static/static/js/29.ea54402e3fbd16f17eb7.js differ diff --git a/priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map b/priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map new file mode 100644 index 000000000..e6f6b21c4 Binary files /dev/null and b/priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map differ diff --git a/priv/static/static/js/3.23de974e1235c91ea803.js b/priv/static/static/js/3.23de974e1235c91ea803.js new file mode 100644 index 000000000..84044f051 Binary files /dev/null and b/priv/static/static/js/3.23de974e1235c91ea803.js differ diff --git a/priv/static/static/js/3.23de974e1235c91ea803.js.map b/priv/static/static/js/3.23de974e1235c91ea803.js.map new file mode 100644 index 000000000..880fe7abb Binary files /dev/null and b/priv/static/static/js/3.23de974e1235c91ea803.js.map differ diff --git a/priv/static/static/js/30.b657503bf18858a9b282.js b/priv/static/static/js/30.b657503bf18858a9b282.js new file mode 100644 index 000000000..256a37f17 Binary files /dev/null and b/priv/static/static/js/30.b657503bf18858a9b282.js differ diff --git a/priv/static/static/js/30.b657503bf18858a9b282.js.map b/priv/static/static/js/30.b657503bf18858a9b282.js.map new file mode 100644 index 000000000..fbbedf45a Binary files /dev/null and b/priv/static/static/js/30.b657503bf18858a9b282.js.map differ diff --git a/priv/static/static/js/4.4fe9f0677ec54321f659.js b/priv/static/static/js/4.4fe9f0677ec54321f659.js new file mode 100644 index 000000000..7b1cf6cb1 Binary files /dev/null and b/priv/static/static/js/4.4fe9f0677ec54321f659.js differ diff --git a/priv/static/static/js/4.4fe9f0677ec54321f659.js.map b/priv/static/static/js/4.4fe9f0677ec54321f659.js.map new file mode 100644 index 000000000..d0f07cf83 Binary files /dev/null and b/priv/static/static/js/4.4fe9f0677ec54321f659.js.map differ diff --git a/priv/static/static/js/5.74ace591a96fca58ee48.js b/priv/static/static/js/5.74ace591a96fca58ee48.js new file mode 100644 index 000000000..6724317eb Binary files /dev/null and b/priv/static/static/js/5.74ace591a96fca58ee48.js differ diff --git a/priv/static/static/js/5.74ace591a96fca58ee48.js.map b/priv/static/static/js/5.74ace591a96fca58ee48.js.map new file mode 100644 index 000000000..915549af2 Binary files /dev/null and b/priv/static/static/js/5.74ace591a96fca58ee48.js.map differ diff --git a/priv/static/static/js/6.67ff41bfc9476902b9de.js b/priv/static/static/js/6.67ff41bfc9476902b9de.js new file mode 100644 index 000000000..a9fbe6a15 Binary files /dev/null and b/priv/static/static/js/6.67ff41bfc9476902b9de.js differ diff --git a/priv/static/static/js/6.67ff41bfc9476902b9de.js.map b/priv/static/static/js/6.67ff41bfc9476902b9de.js.map new file mode 100644 index 000000000..40632f54a Binary files /dev/null and b/priv/static/static/js/6.67ff41bfc9476902b9de.js.map differ diff --git a/priv/static/static/js/7.c0d55831c37350a90aee.js b/priv/static/static/js/7.c0d55831c37350a90aee.js new file mode 100644 index 000000000..e69e74788 Binary files /dev/null and b/priv/static/static/js/7.c0d55831c37350a90aee.js differ diff --git a/priv/static/static/js/7.c0d55831c37350a90aee.js.map b/priv/static/static/js/7.c0d55831c37350a90aee.js.map new file mode 100644 index 000000000..df62653a9 Binary files /dev/null and b/priv/static/static/js/7.c0d55831c37350a90aee.js.map differ diff --git a/priv/static/static/js/8.83dbefa1dc25a2e61b92.js b/priv/static/static/js/8.83dbefa1dc25a2e61b92.js new file mode 100644 index 000000000..96417ee38 Binary files /dev/null and b/priv/static/static/js/8.83dbefa1dc25a2e61b92.js differ diff --git a/priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map b/priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map new file mode 100644 index 000000000..1c3d977be Binary files /dev/null and b/priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map differ diff --git a/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js b/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js new file mode 100644 index 000000000..2487774ef Binary files /dev/null and b/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js differ diff --git a/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map b/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map new file mode 100644 index 000000000..e265af977 Binary files /dev/null and b/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map differ diff --git a/priv/static/static/js/app.7db8116851a0fe6eb807.js b/priv/static/static/js/app.7db8116851a0fe6eb807.js new file mode 100644 index 000000000..ce0461c10 Binary files /dev/null and b/priv/static/static/js/app.7db8116851a0fe6eb807.js differ diff --git a/priv/static/static/js/app.7db8116851a0fe6eb807.js.map b/priv/static/static/js/app.7db8116851a0fe6eb807.js.map new file mode 100644 index 000000000..a7f058c16 Binary files /dev/null and b/priv/static/static/js/app.7db8116851a0fe6eb807.js.map differ diff --git a/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js b/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js new file mode 100644 index 000000000..491dbed0b Binary files /dev/null and b/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js differ diff --git a/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map b/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map new file mode 100644 index 000000000..9ad947b26 Binary files /dev/null and b/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index f6579fdd7..323816ab6 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index 37a17be47..c45ac40a8 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ -- cgit v1.2.3 From 28feba8af4f51871a3ac1ffd3826e798c2265d43 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 12:24:45 +0200 Subject: Preloaders: Remove status_net preloader --- lib/pleroma/web/preload/status_net.ex | 25 ------------------------- test/web/preload/status_net_test.exs | 15 --------------- 2 files changed, 40 deletions(-) delete mode 100644 lib/pleroma/web/preload/status_net.ex delete mode 100644 test/web/preload/status_net_test.exs diff --git a/lib/pleroma/web/preload/status_net.ex b/lib/pleroma/web/preload/status_net.ex deleted file mode 100644 index 9b62f87a2..000000000 --- a/lib/pleroma/web/preload/status_net.ex +++ /dev/null @@ -1,25 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Preload.Providers.StatusNet do - alias Pleroma.Web.Preload.Providers.Provider - alias Pleroma.Web.TwitterAPI.UtilController - - @behaviour Provider - @config_url "/api/statusnet/config.json" - - @impl Provider - def generate_terms(_params) do - %{} - |> build_config_tag() - end - - defp build_config_tag(acc) do - resp = - Plug.Test.conn(:get, @config_url |> to_string()) - |> UtilController.config(nil) - - Map.put(acc, @config_url, resp.resp_body) - end -end diff --git a/test/web/preload/status_net_test.exs b/test/web/preload/status_net_test.exs deleted file mode 100644 index df7acdb11..000000000 --- a/test/web/preload/status_net_test.exs +++ /dev/null @@ -1,15 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Preload.Providers.StatusNetTest do - use Pleroma.DataCase - alias Pleroma.Web.Preload.Providers.StatusNet - - setup do: {:ok, StatusNet.generate_terms(nil)} - - test "it renders the info", %{"/api/statusnet/config.json" => info} do - assert {:ok, res} = Jason.decode(info) - assert res["site"] - end -end -- cgit v1.2.3 From 65fd28e0c4f7322d14035d8537990ed12326220e Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 12:25:25 +0200 Subject: Config: Remove Statusnet preloader. --- config/config.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 9b550920c..fd355867c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -436,8 +436,7 @@ config :pleroma, Pleroma.Web.Preload, providers: [ - Pleroma.Web.Preload.Providers.Instance, - Pleroma.Web.Preload.Providers.StatusNet + Pleroma.Web.Preload.Providers.Instance ] config :pleroma, :http_security, -- cgit v1.2.3 From 0aa4c20d78b683a2d897e7f6629fb7f5d848d7ba Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 12:59:06 +0200 Subject: ObjectValidator Test: Extract attachments. --- test/web/activity_pub/object_validator_test.exs | 67 ++------------------ .../attachment_validator_test.exs | 74 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 63 deletions(-) create mode 100644 test/web/activity_pub/object_validators/attachment_validator_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index f38bf7e08..361ec5526 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -1,3 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase @@ -5,75 +9,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory - describe "attachments" do - test "works with honkerific attachments" do - attachment = %{ - "mediaType" => "", - "name" => "", - "summary" => "298p3RG7j27tfsZ9RQ.jpg", - "type" => "Document", - "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" - } - - assert {:ok, attachment} = - AttachmentValidator.cast_and_validate(attachment) - |> Ecto.Changeset.apply_action(:insert) - - assert attachment.mediaType == "application/octet-stream" - end - - test "it turns mastodon attachments into our attachments" do - attachment = %{ - "url" => - "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", - "type" => "Document", - "name" => nil, - "mediaType" => "image/jpeg" - } - - {:ok, attachment} = - AttachmentValidator.cast_and_validate(attachment) - |> Ecto.Changeset.apply_action(:insert) - - assert [ - %{ - href: - "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", - type: "Link", - mediaType: "image/jpeg" - } - ] = attachment.url - - assert attachment.mediaType == "image/jpeg" - end - - test "it handles our own uploads" do - user = insert(:user) - - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) - - {:ok, attachment} = - attachment.data - |> AttachmentValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) - - assert attachment.mediaType == "image/jpeg" - end - end - describe "chat message create activities" do test "it is invalid if the object already exists" do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/attachment_validator_test.exs b/test/web/activity_pub/object_validators/attachment_validator_test.exs new file mode 100644 index 000000000..558bb3131 --- /dev/null +++ b/test/web/activity_pub/object_validators/attachment_validator_test.exs @@ -0,0 +1,74 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + + import Pleroma.Factory + + describe "attachments" do + test "works with honkerific attachments" do + attachment = %{ + "mediaType" => "", + "name" => "", + "summary" => "298p3RG7j27tfsZ9RQ.jpg", + "type" => "Document", + "url" => "https://honk.tedunangst.com/d/298p3RG7j27tfsZ9RQ.jpg" + } + + assert {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "application/octet-stream" + end + + test "it turns mastodon attachments into our attachments" do + attachment = %{ + "url" => + "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", + "type" => "Document", + "name" => nil, + "mediaType" => "image/jpeg" + } + + {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + + assert [ + %{ + href: + "http://mastodon.example.org/system/media_attachments/files/000/000/002/original/334ce029e7bfb920.jpg", + type: "Link", + mediaType: "image/jpeg" + } + ] = attachment.url + + assert attachment.mediaType == "image/jpeg" + end + + test "it handles our own uploads" do + user = insert(:user) + + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + {:ok, attachment} = + attachment.data + |> AttachmentValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) + + assert attachment.mediaType == "image/jpeg" + end + end +end -- cgit v1.2.3 From e0baaa967ccf11d65a493e8e74073b1473c14a6c Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 13:01:44 +0200 Subject: ObjectValidator tests: Extract chat tests --- test/web/activity_pub/object_validator_test.exs | 186 ------------------- .../object_validators/chat_validation_test.exs | 200 +++++++++++++++++++++ 2 files changed, 200 insertions(+), 186 deletions(-) create mode 100644 test/web/activity_pub/object_validators/chat_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 361ec5526..b052c0a9e 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -15,192 +15,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory - describe "chat message create activities" do - test "it is invalid if the object already exists" do - user = insert(:user) - recipient = insert(:user) - {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") - object = Object.normalize(activity, false) - - {:ok, create_data, _} = Builder.create(user, object.data, [recipient.ap_id]) - - {:error, cng} = ObjectValidator.validate(create_data, []) - - assert {:object, {"The object to create already exists", []}} in cng.errors - end - - test "it is invalid if the object data has a different `to` or `actor` field" do - user = insert(:user) - recipient = insert(:user) - {:ok, object_data, _} = Builder.chat_message(recipient, user.ap_id, "Hey") - - {:ok, create_data, _} = Builder.create(user, object_data, [recipient.ap_id]) - - {:error, cng} = ObjectValidator.validate(create_data, []) - - assert {:to, {"Recipients don't match with object recipients", []}} in cng.errors - assert {:actor, {"Actor doesn't match with object actor", []}} in cng.errors - end - end - - describe "chat messages" do - setup do - clear_config([:instance, :remote_limit]) - user = insert(:user) - recipient = insert(:user, local: false) - - {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey :firefox:") - - %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} - end - - test "let's through some basic html", %{user: user, recipient: recipient} do - {:ok, valid_chat_message, _} = - Builder.chat_message( - user, - recipient.ap_id, - "hey example " - ) - - assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - - assert object["content"] == - "hey example alert('uguu')" - end - - test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do - assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - - assert Map.put(valid_chat_message, "attachment", nil) == object - end - - test "validates for a basic object with an attachment", %{ - valid_chat_message: valid_chat_message, - user: user - } do - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) - - valid_chat_message = - valid_chat_message - |> Map.put("attachment", attachment.data) - - assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - - assert object["attachment"] - end - - test "validates for a basic object with an attachment in an array", %{ - valid_chat_message: valid_chat_message, - user: user - } do - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) - - valid_chat_message = - valid_chat_message - |> Map.put("attachment", [attachment.data]) - - assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - - assert object["attachment"] - end - - test "validates for a basic object with an attachment but without content", %{ - valid_chat_message: valid_chat_message, - user: user - } do - file = %Plug.Upload{ - content_type: "image/jpg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) - - valid_chat_message = - valid_chat_message - |> Map.put("attachment", attachment.data) - |> Map.delete("content") - - assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) - - assert object["attachment"] - end - - test "does not validate if the message has no content", %{ - valid_chat_message: valid_chat_message - } do - contentless = - valid_chat_message - |> Map.delete("content") - - refute match?({:ok, _object, _meta}, ObjectValidator.validate(contentless, [])) - end - - test "does not validate if the message is longer than the remote_limit", %{ - valid_chat_message: valid_chat_message - } do - Pleroma.Config.put([:instance, :remote_limit], 2) - refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) - end - - test "does not validate if the recipient is blocking the actor", %{ - valid_chat_message: valid_chat_message, - user: user, - recipient: recipient - } do - Pleroma.User.block(recipient, user) - refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) - end - - test "does not validate if the actor or the recipient is not in our system", %{ - valid_chat_message: valid_chat_message - } do - chat_message = - valid_chat_message - |> Map.put("actor", "https://raymoo.com/raymoo") - - {:error, _} = ObjectValidator.validate(chat_message, []) - - chat_message = - valid_chat_message - |> Map.put("to", ["https://raymoo.com/raymoo"]) - - {:error, _} = ObjectValidator.validate(chat_message, []) - end - - test "does not validate for a message with multiple recipients", %{ - valid_chat_message: valid_chat_message, - user: user, - recipient: recipient - } do - chat_message = - valid_chat_message - |> Map.put("to", [user.ap_id, recipient.ap_id]) - - assert {:error, _} = ObjectValidator.validate(chat_message, []) - end - - test "does not validate if it doesn't concern local users" do - user = insert(:user, local: false) - recipient = insert(:user, local: false) - - {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") - assert {:error, _} = ObjectValidator.validate(valid_chat_message, []) - end - end - describe "EmojiReacts" do setup do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/chat_validation_test.exs b/test/web/activity_pub/object_validators/chat_validation_test.exs new file mode 100644 index 000000000..ec1e497fa --- /dev/null +++ b/test/web/activity_pub/object_validators/chat_validation_test.exs @@ -0,0 +1,200 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ChatValidationTest do + use Pleroma.DataCase + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "chat message create activities" do + test "it is invalid if the object already exists" do + user = insert(:user) + recipient = insert(:user) + {:ok, activity} = CommonAPI.post_chat_message(user, recipient, "hey") + object = Object.normalize(activity, false) + + {:ok, create_data, _} = Builder.create(user, object.data, [recipient.ap_id]) + + {:error, cng} = ObjectValidator.validate(create_data, []) + + assert {:object, {"The object to create already exists", []}} in cng.errors + end + + test "it is invalid if the object data has a different `to` or `actor` field" do + user = insert(:user) + recipient = insert(:user) + {:ok, object_data, _} = Builder.chat_message(recipient, user.ap_id, "Hey") + + {:ok, create_data, _} = Builder.create(user, object_data, [recipient.ap_id]) + + {:error, cng} = ObjectValidator.validate(create_data, []) + + assert {:to, {"Recipients don't match with object recipients", []}} in cng.errors + assert {:actor, {"Actor doesn't match with object actor", []}} in cng.errors + end + end + + describe "chat messages" do + setup do + clear_config([:instance, :remote_limit]) + user = insert(:user) + recipient = insert(:user, local: false) + + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey :firefox:") + + %{user: user, recipient: recipient, valid_chat_message: valid_chat_message} + end + + test "let's through some basic html", %{user: user, recipient: recipient} do + {:ok, valid_chat_message, _} = + Builder.chat_message( + user, + recipient.ap_id, + "hey example " + ) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["content"] == + "hey example alert('uguu')" + end + + test "validates for a basic object we build", %{valid_chat_message: valid_chat_message} do + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert Map.put(valid_chat_message, "attachment", nil) == object + end + + test "validates for a basic object with an attachment", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", attachment.data) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + + test "validates for a basic object with an attachment in an array", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", [attachment.data]) + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + + test "validates for a basic object with an attachment but without content", %{ + valid_chat_message: valid_chat_message, + user: user + } do + file = %Plug.Upload{ + content_type: "image/jpg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + {:ok, attachment} = ActivityPub.upload(file, actor: user.ap_id) + + valid_chat_message = + valid_chat_message + |> Map.put("attachment", attachment.data) + |> Map.delete("content") + + assert {:ok, object, _meta} = ObjectValidator.validate(valid_chat_message, []) + + assert object["attachment"] + end + + test "does not validate if the message has no content", %{ + valid_chat_message: valid_chat_message + } do + contentless = + valid_chat_message + |> Map.delete("content") + + refute match?({:ok, _object, _meta}, ObjectValidator.validate(contentless, [])) + end + + test "does not validate if the message is longer than the remote_limit", %{ + valid_chat_message: valid_chat_message + } do + Pleroma.Config.put([:instance, :remote_limit], 2) + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + + test "does not validate if the recipient is blocking the actor", %{ + valid_chat_message: valid_chat_message, + user: user, + recipient: recipient + } do + Pleroma.User.block(recipient, user) + refute match?({:ok, _object, _meta}, ObjectValidator.validate(valid_chat_message, [])) + end + + test "does not validate if the actor or the recipient is not in our system", %{ + valid_chat_message: valid_chat_message + } do + chat_message = + valid_chat_message + |> Map.put("actor", "https://raymoo.com/raymoo") + + {:error, _} = ObjectValidator.validate(chat_message, []) + + chat_message = + valid_chat_message + |> Map.put("to", ["https://raymoo.com/raymoo"]) + + {:error, _} = ObjectValidator.validate(chat_message, []) + end + + test "does not validate for a message with multiple recipients", %{ + valid_chat_message: valid_chat_message, + user: user, + recipient: recipient + } do + chat_message = + valid_chat_message + |> Map.put("to", [user.ap_id, recipient.ap_id]) + + assert {:error, _} = ObjectValidator.validate(chat_message, []) + end + + test "does not validate if it doesn't concern local users" do + user = insert(:user, local: false) + recipient = insert(:user, local: false) + + {:ok, valid_chat_message, _} = Builder.chat_message(user, recipient.ap_id, "hey") + assert {:error, _} = ObjectValidator.validate(valid_chat_message, []) + end + end +end -- cgit v1.2.3 From 60d4c6c91d390633a6e765b51295e8b300ee9efe Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:02:35 +0200 Subject: ObjectValidator tests: Extract emoji react testing --- test/web/activity_pub/object_validator_test.exs | 41 ----------------- .../emoji_react_validation_test.exs | 53 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 41 deletions(-) create mode 100644 test/web/activity_pub/object_validators/emoji_react_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index b052c0a9e..4a8e1a0fb 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator @@ -15,46 +14,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory - describe "EmojiReacts" do - setup do - user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) - - object = Pleroma.Object.get_by_ap_id(post_activity.data["object"]) - - {:ok, valid_emoji_react, []} = Builder.emoji_react(user, object, "👌") - - %{user: user, post_activity: post_activity, valid_emoji_react: valid_emoji_react} - end - - test "it validates a valid EmojiReact", %{valid_emoji_react: valid_emoji_react} do - assert {:ok, _, _} = ObjectValidator.validate(valid_emoji_react, []) - end - - test "it is not valid without a 'content' field", %{valid_emoji_react: valid_emoji_react} do - without_content = - valid_emoji_react - |> Map.delete("content") - - {:error, cng} = ObjectValidator.validate(without_content, []) - - refute cng.valid? - assert {:content, {"can't be blank", [validation: :required]}} in cng.errors - end - - test "it is not valid with a non-emoji content field", %{valid_emoji_react: valid_emoji_react} do - without_emoji_content = - valid_emoji_react - |> Map.put("content", "x") - - {:error, cng} = ObjectValidator.validate(without_emoji_content, []) - - refute cng.valid? - - assert {:content, {"must be a single character emoji", []}} in cng.errors - end - end - describe "Undos" do setup do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/emoji_react_validation_test.exs b/test/web/activity_pub/object_validators/emoji_react_validation_test.exs new file mode 100644 index 000000000..582e6d785 --- /dev/null +++ b/test/web/activity_pub/object_validators/emoji_react_validation_test.exs @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "EmojiReacts" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) + + object = Pleroma.Object.get_by_ap_id(post_activity.data["object"]) + + {:ok, valid_emoji_react, []} = Builder.emoji_react(user, object, "👌") + + %{user: user, post_activity: post_activity, valid_emoji_react: valid_emoji_react} + end + + test "it validates a valid EmojiReact", %{valid_emoji_react: valid_emoji_react} do + assert {:ok, _, _} = ObjectValidator.validate(valid_emoji_react, []) + end + + test "it is not valid without a 'content' field", %{valid_emoji_react: valid_emoji_react} do + without_content = + valid_emoji_react + |> Map.delete("content") + + {:error, cng} = ObjectValidator.validate(without_content, []) + + refute cng.valid? + assert {:content, {"can't be blank", [validation: :required]}} in cng.errors + end + + test "it is not valid with a non-emoji content field", %{valid_emoji_react: valid_emoji_react} do + without_emoji_content = + valid_emoji_react + |> Map.put("content", "x") + + {:error, cng} = ObjectValidator.validate(without_emoji_content, []) + + refute cng.valid? + + assert {:content, {"must be a single character emoji", []}} in cng.errors + end + end +end -- cgit v1.2.3 From e6a13d97d0f9715980a12a2b73d961aefc52289a Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:05:02 +0200 Subject: ObjectValidation tests: Extract delete validation tests. --- test/web/activity_pub/object_validator_test.exs | 92 ------------------ .../object_validators/delete_validation_test.exs | 106 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 test/web/activity_pub/object_validators/delete_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 4a8e1a0fb..699cb8bf8 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -54,98 +54,6 @@ test "it does not validate if the object is missing", %{valid_like_undo: valid_l end end - describe "deletes" do - setup do - user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{status: "cancel me daddy"}) - - {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"]) - {:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id) - - %{user: user, valid_post_delete: valid_post_delete, valid_user_delete: valid_user_delete} - end - - test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do - {:ok, valid_post_delete, _} = ObjectValidator.validate(valid_post_delete, []) - - assert valid_post_delete["deleted_activity_id"] - end - - test "it is invalid if the object isn't in a list of certain types", %{ - valid_post_delete: valid_post_delete - } do - object = Object.get_by_ap_id(valid_post_delete["object"]) - - data = - object.data - |> Map.put("type", "Like") - - {:ok, _object} = - object - |> Ecto.Changeset.change(%{data: data}) - |> Object.update_and_set_cache() - - {:error, cng} = ObjectValidator.validate(valid_post_delete, []) - assert {:object, {"object not in allowed types", []}} in cng.errors - end - - test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do - assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, [])) - end - - test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do - no_id = - valid_post_delete - |> Map.delete("id") - - {:error, cng} = ObjectValidator.validate(no_id, []) - - assert {:id, {"can't be blank", [validation: :required]}} in cng.errors - end - - test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post_delete} do - missing_object = - valid_post_delete - |> Map.put("object", "http://does.not/exist") - - {:error, cng} = ObjectValidator.validate(missing_object, []) - - assert {:object, {"can't find object", []}} in cng.errors - end - - test "it's invalid if the actor of the object and the actor of delete are from different domains", - %{valid_post_delete: valid_post_delete} do - valid_user = insert(:user) - - valid_other_actor = - valid_post_delete - |> Map.put("actor", valid_user.ap_id) - - assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, [])) - - invalid_other_actor = - valid_post_delete - |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") - - {:error, cng} = ObjectValidator.validate(invalid_other_actor, []) - - assert {:actor, {"is not allowed to delete object", []}} in cng.errors - end - - test "it's valid if the actor of the object is a local superuser", - %{valid_post_delete: valid_post_delete} do - user = - insert(:user, local: true, is_moderator: true, ap_id: "https://gensokyo.2hu/users/raymoo") - - valid_other_actor = - valid_post_delete - |> Map.put("actor", user.ap_id) - - {:ok, _, meta} = ObjectValidator.validate(valid_other_actor, []) - assert meta[:do_not_federate] - end - end - describe "likes" do setup do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/delete_validation_test.exs b/test/web/activity_pub/object_validators/delete_validation_test.exs new file mode 100644 index 000000000..42cd18298 --- /dev/null +++ b/test/web/activity_pub/object_validators/delete_validation_test.exs @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidationTest do + use Pleroma.DataCase + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "deletes" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{status: "cancel me daddy"}) + + {:ok, valid_post_delete, _} = Builder.delete(user, post_activity.data["object"]) + {:ok, valid_user_delete, _} = Builder.delete(user, user.ap_id) + + %{user: user, valid_post_delete: valid_post_delete, valid_user_delete: valid_user_delete} + end + + test "it is valid for a post deletion", %{valid_post_delete: valid_post_delete} do + {:ok, valid_post_delete, _} = ObjectValidator.validate(valid_post_delete, []) + + assert valid_post_delete["deleted_activity_id"] + end + + test "it is invalid if the object isn't in a list of certain types", %{ + valid_post_delete: valid_post_delete + } do + object = Object.get_by_ap_id(valid_post_delete["object"]) + + data = + object.data + |> Map.put("type", "Like") + + {:ok, _object} = + object + |> Ecto.Changeset.change(%{data: data}) + |> Object.update_and_set_cache() + + {:error, cng} = ObjectValidator.validate(valid_post_delete, []) + assert {:object, {"object not in allowed types", []}} in cng.errors + end + + test "it is valid for a user deletion", %{valid_user_delete: valid_user_delete} do + assert match?({:ok, _, _}, ObjectValidator.validate(valid_user_delete, [])) + end + + test "it's invalid if the id is missing", %{valid_post_delete: valid_post_delete} do + no_id = + valid_post_delete + |> Map.delete("id") + + {:error, cng} = ObjectValidator.validate(no_id, []) + + assert {:id, {"can't be blank", [validation: :required]}} in cng.errors + end + + test "it's invalid if the object doesn't exist", %{valid_post_delete: valid_post_delete} do + missing_object = + valid_post_delete + |> Map.put("object", "http://does.not/exist") + + {:error, cng} = ObjectValidator.validate(missing_object, []) + + assert {:object, {"can't find object", []}} in cng.errors + end + + test "it's invalid if the actor of the object and the actor of delete are from different domains", + %{valid_post_delete: valid_post_delete} do + valid_user = insert(:user) + + valid_other_actor = + valid_post_delete + |> Map.put("actor", valid_user.ap_id) + + assert match?({:ok, _, _}, ObjectValidator.validate(valid_other_actor, [])) + + invalid_other_actor = + valid_post_delete + |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") + + {:error, cng} = ObjectValidator.validate(invalid_other_actor, []) + + assert {:actor, {"is not allowed to delete object", []}} in cng.errors + end + + test "it's valid if the actor of the object is a local superuser", + %{valid_post_delete: valid_post_delete} do + user = + insert(:user, local: true, is_moderator: true, ap_id: "https://gensokyo.2hu/users/raymoo") + + valid_other_actor = + valid_post_delete + |> Map.put("actor", user.ap_id) + + {:ok, _, meta} = ObjectValidator.validate(valid_other_actor, []) + assert meta[:do_not_federate] + end + end +end -- cgit v1.2.3 From 168256dce98d22179e0efc27659dbcb7b61a5fdf Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:08:11 +0200 Subject: ObjectValidation tests: Extract like validation tests. --- test/web/activity_pub/object_validator_test.exs | 101 ------------------ .../object_validators/like_validation_test.exs | 113 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 101 deletions(-) create mode 100644 test/web/activity_pub/object_validators/like_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 699cb8bf8..2b5d6e9fe 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -8,8 +8,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator - alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -54,105 +52,6 @@ test "it does not validate if the object is missing", %{valid_like_undo: valid_l end end - describe "likes" do - setup do - user = insert(:user) - {: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"], - "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 "sets the 'to' field to the object actor if no recipients are given", %{ - valid_like: valid_like, - user: user - } do - without_recipients = - valid_like - |> Map.delete("to") - - {:ok, object, _meta} = ObjectValidator.validate(without_recipients, []) - - assert object["to"] == [user.ap_id] - end - - test "sets the context field to the context of the object if no context is given", %{ - valid_like: valid_like, - post_activity: post_activity - } do - without_context = - valid_like - |> Map.delete("context") - - {:ok, object, _meta} = ObjectValidator.validate(without_context, []) - - assert object["context"] == post_activity.data["context"] - 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 - describe "announces" do setup do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/like_validation_test.exs b/test/web/activity_pub/object_validators/like_validation_test.exs new file mode 100644 index 000000000..2c033b7e2 --- /dev/null +++ b/test/web/activity_pub/object_validators/like_validation_test.exs @@ -0,0 +1,113 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidationTest do + use Pleroma.DataCase + + 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 + setup do + user = insert(:user) + {: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"], + "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 "sets the 'to' field to the object actor if no recipients are given", %{ + valid_like: valid_like, + user: user + } do + without_recipients = + valid_like + |> Map.delete("to") + + {:ok, object, _meta} = ObjectValidator.validate(without_recipients, []) + + assert object["to"] == [user.ap_id] + end + + test "sets the context field to the context of the object if no context is given", %{ + valid_like: valid_like, + post_activity: post_activity + } do + without_context = + valid_like + |> Map.delete("context") + + {:ok, object, _meta} = ObjectValidator.validate(without_context, []) + + assert object["context"] == post_activity.data["context"] + 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 -- cgit v1.2.3 From bbaf108aee28b038675e3b782c12307763624b2b Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:09:41 +0200 Subject: ObjectValidator tests: Extract undo validation tests. --- test/web/activity_pub/object_validator_test.exs | 40 ---------------- .../object_validators/undo_validation_test.exs | 53 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 40 deletions(-) create mode 100644 test/web/activity_pub/object_validators/undo_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index 2b5d6e9fe..d41d9d73e 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -12,46 +12,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory - describe "Undos" do - setup do - user = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) - {:ok, like} = CommonAPI.favorite(user, post_activity.id) - {:ok, valid_like_undo, []} = Builder.undo(user, like) - - %{user: user, like: like, valid_like_undo: valid_like_undo} - end - - test "it validates a basic like undo", %{valid_like_undo: valid_like_undo} do - assert {:ok, _, _} = ObjectValidator.validate(valid_like_undo, []) - end - - test "it does not validate if the actor of the undo is not the actor of the object", %{ - valid_like_undo: valid_like_undo - } do - other_user = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo") - - bad_actor = - valid_like_undo - |> Map.put("actor", other_user.ap_id) - - {:error, cng} = ObjectValidator.validate(bad_actor, []) - - assert {:actor, {"not the same as object actor", []}} in cng.errors - end - - test "it does not validate if the object is missing", %{valid_like_undo: valid_like_undo} do - missing_object = - valid_like_undo - |> Map.put("object", "https://gensokyo.2hu/objects/1") - - {:error, cng} = ObjectValidator.validate(missing_object, []) - - assert {:object, {"can't find object", []}} in cng.errors - assert length(cng.errors) == 1 - end - end - describe "announces" do setup do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/undo_validation_test.exs b/test/web/activity_pub/object_validators/undo_validation_test.exs new file mode 100644 index 000000000..75bbcc4b6 --- /dev/null +++ b/test/web/activity_pub/object_validators/undo_validation_test.exs @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "Undos" do + setup do + user = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) + {:ok, like} = CommonAPI.favorite(user, post_activity.id) + {:ok, valid_like_undo, []} = Builder.undo(user, like) + + %{user: user, like: like, valid_like_undo: valid_like_undo} + end + + test "it validates a basic like undo", %{valid_like_undo: valid_like_undo} do + assert {:ok, _, _} = ObjectValidator.validate(valid_like_undo, []) + end + + test "it does not validate if the actor of the undo is not the actor of the object", %{ + valid_like_undo: valid_like_undo + } do + other_user = insert(:user, ap_id: "https://gensokyo.2hu/users/raymoo") + + bad_actor = + valid_like_undo + |> Map.put("actor", other_user.ap_id) + + {:error, cng} = ObjectValidator.validate(bad_actor, []) + + assert {:actor, {"not the same as object actor", []}} in cng.errors + end + + test "it does not validate if the object is missing", %{valid_like_undo: valid_like_undo} do + missing_object = + valid_like_undo + |> Map.put("object", "https://gensokyo.2hu/objects/1") + + {:error, cng} = ObjectValidator.validate(missing_object, []) + + assert {:object, {"can't find object", []}} in cng.errors + assert length(cng.errors) == 1 + end + end +end -- cgit v1.2.3 From b2e1ea9226a8f84cc83c33311a71f941c11c0d68 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:11:49 +0200 Subject: ObjectValidation tests: Extract announce validation tests. --- test/web/activity_pub/object_validator_test.exs | 94 ------------------ .../object_validators/announce_validation_test.exs | 106 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 94 deletions(-) create mode 100644 test/web/activity_pub/object_validators/announce_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index d41d9d73e..cb365e409 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -5,105 +5,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do use Pleroma.DataCase - alias Pleroma.Object alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.CommonAPI import Pleroma.Factory - describe "announces" do - setup do - user = insert(:user) - announcer = insert(:user) - {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) - - object = Object.normalize(post_activity, false) - {:ok, valid_announce, []} = Builder.announce(announcer, object) - - %{ - valid_announce: valid_announce, - user: user, - post_activity: post_activity, - announcer: announcer - } - end - - test "returns ok for a valid announce", %{valid_announce: valid_announce} do - assert {:ok, _object, _meta} = ObjectValidator.validate(valid_announce, []) - end - - test "returns an error if the object can't be found", %{valid_announce: valid_announce} do - without_object = - valid_announce - |> Map.delete("object") - - {:error, cng} = ObjectValidator.validate(without_object, []) - - assert {:object, {"can't be blank", [validation: :required]}} in cng.errors - - nonexisting_object = - valid_announce - |> Map.put("object", "https://gensokyo.2hu/objects/99999999") - - {:error, cng} = ObjectValidator.validate(nonexisting_object, []) - - assert {:object, {"can't find object", []}} in cng.errors - end - - test "returns an error if we don't have the actor", %{valid_announce: valid_announce} do - nonexisting_actor = - valid_announce - |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") - - {:error, cng} = ObjectValidator.validate(nonexisting_actor, []) - - assert {:actor, {"can't find user", []}} in cng.errors - end - - test "returns an error if the actor already announced the object", %{ - valid_announce: valid_announce, - announcer: announcer, - post_activity: post_activity - } do - _announce = CommonAPI.repeat(post_activity.id, announcer) - - {:error, cng} = ObjectValidator.validate(valid_announce, []) - - assert {:actor, {"already announced this object", []}} in cng.errors - assert {:object, {"already announced by this actor", []}} in cng.errors - end - - test "returns an error if the actor can't announce the object", %{ - announcer: announcer, - user: user - } do - {:ok, post_activity} = - CommonAPI.post(user, %{status: "a secret post", visibility: "private"}) - - object = Object.normalize(post_activity, false) - - # Another user can't announce it - {:ok, announce, []} = Builder.announce(announcer, object, public: false) - - {:error, cng} = ObjectValidator.validate(announce, []) - - assert {:actor, {"can not announce this object", []}} in cng.errors - - # The actor of the object can announce it - {:ok, announce, []} = Builder.announce(user, object, public: false) - - assert {:ok, _, _} = ObjectValidator.validate(announce, []) - - # The actor of the object can not announce it publicly - {:ok, announce, []} = Builder.announce(user, object, public: true) - - {:error, cng} = ObjectValidator.validate(announce, []) - - assert {:actor, {"can not announce this object publicly", []}} in cng.errors - end - end - describe "updates" do setup do user = insert(:user) diff --git a/test/web/activity_pub/object_validators/announce_validation_test.exs b/test/web/activity_pub/object_validators/announce_validation_test.exs new file mode 100644 index 000000000..623342f76 --- /dev/null +++ b/test/web/activity_pub/object_validators/announce_validation_test.exs @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnouncValidationTest do + use Pleroma.DataCase + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + describe "announces" do + setup do + user = insert(:user) + announcer = insert(:user) + {:ok, post_activity} = CommonAPI.post(user, %{status: "uguu"}) + + object = Object.normalize(post_activity, false) + {:ok, valid_announce, []} = Builder.announce(announcer, object) + + %{ + valid_announce: valid_announce, + user: user, + post_activity: post_activity, + announcer: announcer + } + end + + test "returns ok for a valid announce", %{valid_announce: valid_announce} do + assert {:ok, _object, _meta} = ObjectValidator.validate(valid_announce, []) + end + + test "returns an error if the object can't be found", %{valid_announce: valid_announce} do + without_object = + valid_announce + |> Map.delete("object") + + {:error, cng} = ObjectValidator.validate(without_object, []) + + assert {:object, {"can't be blank", [validation: :required]}} in cng.errors + + nonexisting_object = + valid_announce + |> Map.put("object", "https://gensokyo.2hu/objects/99999999") + + {:error, cng} = ObjectValidator.validate(nonexisting_object, []) + + assert {:object, {"can't find object", []}} in cng.errors + end + + test "returns an error if we don't have the actor", %{valid_announce: valid_announce} do + nonexisting_actor = + valid_announce + |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") + + {:error, cng} = ObjectValidator.validate(nonexisting_actor, []) + + assert {:actor, {"can't find user", []}} in cng.errors + end + + test "returns an error if the actor already announced the object", %{ + valid_announce: valid_announce, + announcer: announcer, + post_activity: post_activity + } do + _announce = CommonAPI.repeat(post_activity.id, announcer) + + {:error, cng} = ObjectValidator.validate(valid_announce, []) + + assert {:actor, {"already announced this object", []}} in cng.errors + assert {:object, {"already announced by this actor", []}} in cng.errors + end + + test "returns an error if the actor can't announce the object", %{ + announcer: announcer, + user: user + } do + {:ok, post_activity} = + CommonAPI.post(user, %{status: "a secret post", visibility: "private"}) + + object = Object.normalize(post_activity, false) + + # Another user can't announce it + {:ok, announce, []} = Builder.announce(announcer, object, public: false) + + {:error, cng} = ObjectValidator.validate(announce, []) + + assert {:actor, {"can not announce this object", []}} in cng.errors + + # The actor of the object can announce it + {:ok, announce, []} = Builder.announce(user, object, public: false) + + assert {:ok, _, _} = ObjectValidator.validate(announce, []) + + # The actor of the object can not announce it publicly + {:ok, announce, []} = Builder.announce(user, object, public: true) + + {:error, cng} = ObjectValidator.validate(announce, []) + + assert {:actor, {"can not announce this object publicly", []}} in cng.errors + end + end +end -- cgit v1.2.3 From 410c1fab312194bde83c9cf1c3e741875110a9ad Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:13:11 +0200 Subject: ObjectValidator tests: Extract update validation tests. --- test/web/activity_pub/object_validator_test.exs | 32 ---------------- .../object_validators/update_validation_test.exs | 44 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 32 deletions(-) create mode 100644 test/web/activity_pub/object_validators/update_validation_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs index cb365e409..ba24a5a1c 100644 --- a/test/web/activity_pub/object_validator_test.exs +++ b/test/web/activity_pub/object_validator_test.exs @@ -10,38 +10,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do import Pleroma.Factory - describe "updates" do - setup do - user = insert(:user) - - object = %{ - "id" => user.ap_id, - "name" => "A new name", - "summary" => "A new bio" - } - - {:ok, valid_update, []} = Builder.update(user, object) - - %{user: user, valid_update: valid_update} - end - - test "validates a basic object", %{valid_update: valid_update} do - assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) - end - - test "returns an error if the object can't be updated by the actor", %{ - valid_update: valid_update - } do - other_user = insert(:user) - - update = - valid_update - |> Map.put("actor", other_user.ap_id) - - assert {:error, _cng} = ObjectValidator.validate(update, []) - end - end - describe "blocks" do setup do user = insert(:user, local: false) diff --git a/test/web/activity_pub/object_validators/update_validation_test.exs b/test/web/activity_pub/object_validators/update_validation_test.exs new file mode 100644 index 000000000..5e80cf731 --- /dev/null +++ b/test/web/activity_pub/object_validators/update_validation_test.exs @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + + import Pleroma.Factory + + describe "updates" do + setup do + user = insert(:user) + + object = %{ + "id" => user.ap_id, + "name" => "A new name", + "summary" => "A new bio" + } + + {:ok, valid_update, []} = Builder.update(user, object) + + %{user: user, valid_update: valid_update} + end + + test "validates a basic object", %{valid_update: valid_update} do + assert {:ok, _update, []} = ObjectValidator.validate(valid_update, []) + end + + test "returns an error if the object can't be updated by the actor", %{ + valid_update: valid_update + } do + other_user = insert(:user) + + update = + valid_update + |> Map.put("actor", other_user.ap_id) + + assert {:error, _cng} = ObjectValidator.validate(update, []) + end + end +end -- cgit v1.2.3 From eb87430803592e6dc0b6293489ea9bde303ac534 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:14:58 +0200 Subject: ObjectValidator tests: Extract block validation tests. --- test/web/activity_pub/object_validator_test.exs | 39 ---------------------- .../object_validators/block_handling_test.exs | 39 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 39 deletions(-) delete mode 100644 test/web/activity_pub/object_validator_test.exs create mode 100644 test/web/activity_pub/object_validators/block_handling_test.exs diff --git a/test/web/activity_pub/object_validator_test.exs b/test/web/activity_pub/object_validator_test.exs deleted file mode 100644 index ba24a5a1c..000000000 --- a/test/web/activity_pub/object_validator_test.exs +++ /dev/null @@ -1,39 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidatorTest do - use Pleroma.DataCase - - alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.ObjectValidator - - import Pleroma.Factory - - describe "blocks" do - setup do - user = insert(:user, local: false) - blocked = insert(:user) - - {:ok, valid_block, []} = Builder.block(user, blocked) - - %{user: user, valid_block: valid_block} - end - - test "validates a basic object", %{ - valid_block: valid_block - } do - assert {:ok, _block, []} = ObjectValidator.validate(valid_block, []) - end - - test "returns an error if we don't know the blocked user", %{ - valid_block: valid_block - } do - block = - valid_block - |> Map.put("object", "https://gensokyo.2hu/users/raymoo") - - assert {:error, _cng} = ObjectValidator.validate(block, []) - end - end -end diff --git a/test/web/activity_pub/object_validators/block_handling_test.exs b/test/web/activity_pub/object_validators/block_handling_test.exs new file mode 100644 index 000000000..8860f4abe --- /dev/null +++ b/test/web/activity_pub/object_validators/block_handling_test.exs @@ -0,0 +1,39 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + + import Pleroma.Factory + + describe "blocks" do + setup do + user = insert(:user, local: false) + blocked = insert(:user) + + {:ok, valid_block, []} = Builder.block(user, blocked) + + %{user: user, valid_block: valid_block} + end + + test "validates a basic object", %{ + valid_block: valid_block + } do + assert {:ok, _block, []} = ObjectValidator.validate(valid_block, []) + end + + test "returns an error if we don't know the blocked user", %{ + valid_block: valid_block + } do + block = + valid_block + |> Map.put("object", "https://gensokyo.2hu/users/raymoo") + + assert {:error, _cng} = ObjectValidator.validate(block, []) + end + end +end -- cgit v1.2.3 From 4e3b3998ad849943fbae3590ce7e892d2dfc54d3 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:44:35 +0200 Subject: BlockValidation test: Rename. --- .../object_validators/block_handling_test.exs | 39 ---------------------- .../object_validators/block_validation_test.exs | 39 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 39 deletions(-) delete mode 100644 test/web/activity_pub/object_validators/block_handling_test.exs create mode 100644 test/web/activity_pub/object_validators/block_validation_test.exs diff --git a/test/web/activity_pub/object_validators/block_handling_test.exs b/test/web/activity_pub/object_validators/block_handling_test.exs deleted file mode 100644 index 8860f4abe..000000000 --- a/test/web/activity_pub/object_validators/block_handling_test.exs +++ /dev/null @@ -1,39 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockHandlingTest do - use Pleroma.DataCase - - alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.ObjectValidator - - import Pleroma.Factory - - describe "blocks" do - setup do - user = insert(:user, local: false) - blocked = insert(:user) - - {:ok, valid_block, []} = Builder.block(user, blocked) - - %{user: user, valid_block: valid_block} - end - - test "validates a basic object", %{ - valid_block: valid_block - } do - assert {:ok, _block, []} = ObjectValidator.validate(valid_block, []) - end - - test "returns an error if we don't know the blocked user", %{ - valid_block: valid_block - } do - block = - valid_block - |> Map.put("object", "https://gensokyo.2hu/users/raymoo") - - assert {:error, _cng} = ObjectValidator.validate(block, []) - end - end -end diff --git a/test/web/activity_pub/object_validators/block_validation_test.exs b/test/web/activity_pub/object_validators/block_validation_test.exs new file mode 100644 index 000000000..c08d4b2e8 --- /dev/null +++ b/test/web/activity_pub/object_validators/block_validation_test.exs @@ -0,0 +1,39 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidationTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + + import Pleroma.Factory + + describe "blocks" do + setup do + user = insert(:user, local: false) + blocked = insert(:user) + + {:ok, valid_block, []} = Builder.block(user, blocked) + + %{user: user, valid_block: valid_block} + end + + test "validates a basic object", %{ + valid_block: valid_block + } do + assert {:ok, _block, []} = ObjectValidator.validate(valid_block, []) + end + + test "returns an error if we don't know the blocked user", %{ + valid_block: valid_block + } do + block = + valid_block + |> Map.put("object", "https://gensokyo.2hu/users/raymoo") + + assert {:error, _cng} = ObjectValidator.validate(block, []) + end + end +end -- cgit v1.2.3 From a6a12b241fbacd3ff35cd901190e62d14aaac3c2 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 6 Jul 2020 15:57:19 +0200 Subject: FollowValidator: Add basic validation. --- lib/pleroma/web/activity_pub/builder.ex | 13 +++++++ lib/pleroma/web/activity_pub/object_validator.ex | 11 ++++++ .../object_validators/follow_validator.ex | 42 ++++++++++++++++++++++ .../object_validators/follow_validation_test.exs | 26 ++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/follow_validator.ex create mode 100644 test/web/activity_pub/object_validators/follow_validation_test.exs diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index cabc28de9..d5f3610ed 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -14,6 +14,19 @@ defmodule Pleroma.Web.ActivityPub.Builder do require Pleroma.Constants + @spec follow(User.t(), User.t()) :: {:ok, map(), keyword()} + def follow(follower, followed) do + data = %{ + "id" => Utils.generate_activity_id(), + "actor" => follower.ap_id, + "type" => "Follow", + "object" => followed.ap_id, + "to" => [followed.ap_id] + } + + {:ok, data, []} + end + @spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()} def emoji_react(actor, object, emoji) do with {:ok, data, meta} <- object_action(actor, object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index bb6324460..df926829c 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -18,6 +18,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator @@ -25,6 +26,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Follow"} = object, meta) do + with {:ok, object} <- + object + |> FollowValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Block"} = block_activity, meta) do with {:ok, block_activity} <- block_activity diff --git a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex new file mode 100644 index 000000000..2035ad9ba --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:object, ObjectValidators.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Follow"]) + |> validate_actor_presence() + |> validate_actor_presence(field_name: :object) + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validators/follow_validation_test.exs b/test/web/activity_pub/object_validators/follow_validation_test.exs new file mode 100644 index 000000000..6e1378be2 --- /dev/null +++ b/test/web/activity_pub/object_validators/follow_validation_test.exs @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidationTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + + import Pleroma.Factory + + describe "Follows" do + setup do + follower = insert(:user) + followed = insert(:user) + + {:ok, valid_follow, []} = Builder.follow(follower, followed) + %{follower: follower, followed: followed, valid_follow: valid_follow} + end + + test "validates a basic follow object", %{valid_follow: valid_follow} do + assert {:ok, _follow, []} = ObjectValidator.validate(valid_follow, []) + end + end +end -- cgit v1.2.3 From 65843d92c4d3999f727d00500dcf8943cfe7bbc0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 6 Jul 2020 10:59:41 -0500 Subject: Simplify the logic --- lib/pleroma/plugs/http_security_plug.ex | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 3bf0b8ce7..13423ca3f 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -114,24 +114,15 @@ defp build_csp_multimedia_source_list do add_source(acc, host) end) - media_proxy_base_url = - if Config.get([:media_proxy, :base_url]), - do: build_csp_param(Config.get([:media_proxy, :base_url])) + media_proxy_base_url = build_csp_param(Config.get([:media_proxy, :base_url])) - upload_base_url = - if Config.get([Pleroma.Upload, :base_url]), - do: build_csp_param(Config.get([Pleroma.Upload, :base_url])) + upload_base_url = build_csp_param(Config.get([Pleroma.Upload, :base_url])) - s3_endpoint = - if Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.S3, - do: build_csp_param(Config.get([Pleroma.Uploaders.S3, :public_endpoint])) + s3_endpoint = build_csp_param(Config.get([Pleroma.Uploaders.S3, :public_endpoint])) captcha_method = Config.get([Pleroma.Captcha, :method]) - captcha_endpoint = - if Config.get([Pleroma.Captcha, :enabled]) && - captcha_method != "Pleroma.Captcha.Native", - do: build_csp_param(Config.get([captcha_method, :endpoint])) + captcha_endpoint = build_csp_param(Config.get([captcha_method, :endpoint])) [] |> add_source(media_proxy_base_url) @@ -148,6 +139,8 @@ defp add_csp_param(csp_iodata, nil), do: csp_iodata defp add_csp_param(csp_iodata, param), do: [[param, ?;] | csp_iodata] + defp build_csp_param(nil), do: nil + defp build_csp_param(url) when is_binary(url) do %{host: host, scheme: scheme} = URI.parse(url) -- cgit v1.2.3 From da4029391d217b1e2c151e69479de42e2221e02f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 6 Jul 2020 11:28:08 -0500 Subject: IO list, not concatenation --- lib/pleroma/plugs/http_security_plug.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 13423ca3f..472a3ff42 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -145,7 +145,7 @@ defp build_csp_param(url) when is_binary(url) do %{host: host, scheme: scheme} = URI.parse(url) if scheme do - scheme <> "://" <> host + [scheme, "://", host] end end -- cgit v1.2.3 From a784c09db861c097557fe62f7e451fcd457c9740 Mon Sep 17 00:00:00 2001 From: DYM Date: Tue, 7 Jul 2020 09:05:54 +0200 Subject: added hyper:// to default protocols --- config/config.exs | 1 + config/description.exs | 1 + 2 files changed, 2 insertions(+) diff --git a/config/config.exs b/config/config.exs index d28a359b2..6c2707f26 100644 --- a/config/config.exs +++ b/config/config.exs @@ -97,6 +97,7 @@ "dat", "dweb", "gopher", + "hyper", "ipfs", "ipns", "irc", diff --git a/config/description.exs b/config/description.exs index 370af80a6..650610fbe 100644 --- a/config/description.exs +++ b/config/description.exs @@ -498,6 +498,7 @@ "dat", "dweb", "gopher", + "hyper", "ipfs", "ipns", "irc", -- cgit v1.2.3 From fbb9743a7058e8a7ace69804b79eb032e03da078 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 25 May 2020 13:13:42 +0200 Subject: Fix getting videos from peertube --- lib/pleroma/web/activity_pub/transmogrifier.ex | 34 ++++++++++----- test/fixtures/tesla_mock/framatube.org-video.json | 1 + .../https___framatube.org_accounts_framasoft.json | 1 + test/support/http_request_mock.ex | 16 ++++++++ test/web/activity_pub/transmogrifier_test.exs | 48 ++++++++++++++++------ 5 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 test/fixtures/tesla_mock/framatube.org-video.json create mode 100644 test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index bc6fc4bd8..117e930b3 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -233,18 +233,24 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm is_map(url) && is_binary(url["href"]) -> url["href"] is_binary(data["url"]) -> data["url"] is_binary(data["href"]) -> data["href"] + true -> nil end - attachment_url = - %{"href" => href} - |> Maps.put_if_present("mediaType", media_type) - |> Maps.put_if_present("type", Map.get(url || %{}, "type")) + if href do + attachment_url = + %{"href" => href} + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("type", Map.get(url || %{}, "type")) - %{"url" => [attachment_url]} - |> Maps.put_if_present("mediaType", media_type) - |> Maps.put_if_present("type", data["type"]) - |> Maps.put_if_present("name", data["name"]) + %{"url" => [attachment_url]} + |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("type", data["type"]) + |> Maps.put_if_present("name", data["name"]) + else + nil + end end) + |> Enum.filter(& &1) Map.put(object, "attachment", attachments) end @@ -263,12 +269,18 @@ def fix_url(%{"url" => url} = object) when is_map(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) + attachment = + Enum.find(url, fn x -> + media_type = x["mediaType"] || x["mimeType"] || "" + + is_map(x) and String.starts_with?(media_type, ["audio/", "video/"]) + end) - link_element = Enum.find(url, fn x -> is_map(x) and x["mimeType"] == "text/html" end) + link_element = + Enum.find(url, fn x -> is_map(x) and (x["mediaType"] || x["mimeType"]) == "text/html" end) object - |> Map.put("attachment", [first_element]) + |> Map.put("attachment", [attachment]) |> Map.put("url", link_element["href"]) end diff --git a/test/fixtures/tesla_mock/framatube.org-video.json b/test/fixtures/tesla_mock/framatube.org-video.json new file mode 100644 index 000000000..3d53f0c97 --- /dev/null +++ b/test/fixtures/tesla_mock/framatube.org-video.json @@ -0,0 +1 @@ +{"type":"Video","id":"https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206","name":"Déframasoftisons Internet [Framasoft]","duration":"PT3622S","uuid":"6050732a-8a7a-43d4-a6cd-809525a1d206","tag":[{"type":"Hashtag","name":"déframasoftisons"},{"type":"Hashtag","name":"EPN23"},{"type":"Hashtag","name":"framaconf"},{"type":"Hashtag","name":"Framasoft"},{"type":"Hashtag","name":"pyg"}],"category":{"identifier":"15","name":"Science & Technology"},"views":122,"sensitive":false,"waitTranscoding":false,"state":1,"commentsEnabled":true,"downloadEnabled":true,"published":"2020-05-24T18:34:31.569Z","originallyPublishedAt":"2019-11-30T23:00:00.000Z","updated":"2020-07-05T09:01:01.720Z","mediaType":"text/markdown","content":"Après avoir mené avec un certain succès la campagne « Dégooglisons Internet » en 2014, l’association Framasoft annonce fin 2019 arrêter progressivement un certain nombre de ses services alternatifs aux GAFAM. Pourquoi ?\r\n\r\nTranscription par @april...","support":null,"subtitleLanguage":[],"icon":{"type":"Image","url":"https://framatube.org/static/thumbnails/6050732a-8a7a-43d4-a6cd-809525a1d206.jpg","mediaType":"image/jpeg","width":223,"height":122},"url":[{"type":"Link","mediaType":"text/html","href":"https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206"},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4","height":1080,"size":1157359410,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309939","height":1080,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-1080.torrent","height":1080},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080.torrent&xt=urn:btih:381c9429900552e23a4eb506318f1fa01e4d63a8&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fwebseed%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4&ws=https%3A%2F%2Fpeertube.iselfhost.com%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4&ws=https%3A%2F%2Ftube.privacytools.io%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4&ws=https%3A%2F%2Fpeertube.live%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4","height":1080},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-480.mp4","height":480,"size":250095131,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309941","height":480,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-480.torrent","height":480},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480.torrent&xt=urn:btih:a181dcbb5368ab5c31cc9ff07634becb72c344ee&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fwebseed%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480.mp4&ws=https%3A%2F%2Fpeertube.iselfhost.com%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480.mp4&ws=https%3A%2F%2Ftube.privacytools.io%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480.mp4&ws=https%3A%2F%2Fpeertube.live%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480.mp4","height":480},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-360.mp4","height":360,"size":171357733,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309942","height":360,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-360.torrent","height":360},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360.torrent&xt=urn:btih:aedfa9479ea04a175eee0b0bd0bda64076308746&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fwebseed%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360.mp4&ws=https%3A%2F%2Fpeertube.iselfhost.com%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360.mp4&ws=https%3A%2F%2Ftube.privacytools.io%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360.mp4&ws=https%3A%2F%2Fpeertube.live%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360.mp4","height":360},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-720.mp4","height":720,"size":497100839,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309943","height":720,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-720.torrent","height":720},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720.torrent&xt=urn:btih:71971668f82a3b24ac71bc3a982848dd8dc5a5f5&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fwebseed%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720.mp4&ws=https%3A%2F%2Fpeertube.iselfhost.com%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720.mp4&ws=https%3A%2F%2Ftube.privacytools.io%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720.mp4&ws=https%3A%2F%2Fpeertube.live%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720.mp4","height":720},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-240.mp4","height":240,"size":113038439,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309944","height":240,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-240.torrent","height":240},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-240.torrent&xt=urn:btih:c42aa6c95efb28d9f114ebd98537f7b00fa72246&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fwebseed%2F6050732a-8a7a-43d4-a6cd-809525a1d206-240.mp4&ws=https%3A%2F%2Fpeertube.iselfhost.com%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-240.mp4&ws=https%3A%2F%2Ftube.privacytools.io%2Fstatic%2Fredundancy%2F6050732a-8a7a-43d4-a6cd-809525a1d206-240.mp4","height":240},{"type":"Link","mediaType":"application/x-mpegURL","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/master.m3u8","tag":[{"type":"Infohash","name":"f7428214539626e062f300f2ca4cf9154575144e"},{"type":"Infohash","name":"46e236dffb1ea6b9123a5396cbe88e97dd94cc6c"},{"type":"Infohash","name":"11f1045830b5d786c788f2594d19f128764e7d87"},{"type":"Infohash","name":"4327ad3e0d84de100130a27e9ab6fe40c4284f0e"},{"type":"Infohash","name":"41e2eee8e7b23a63c23a77c40a46de11492a4831"},{"type":"Link","name":"sha256","mediaType":"application/json","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/segments-sha256.json"},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/6050732a-8a7a-43d4-a6cd-809525a1d206-1080-fragmented.mp4","height":1080,"size":1156777472,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309940","height":1080,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-1080-hls.torrent","height":1080},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080-hls.torrent&xt=urn:btih:0204d780ebfab0d5d9d3476a038e812ad792deeb&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fstreaming-playlists%2Fhls%2F6050732a-8a7a-43d4-a6cd-809525a1d206%2F6050732a-8a7a-43d4-a6cd-809525a1d206-1080-fragmented.mp4","height":1080},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/6050732a-8a7a-43d4-a6cd-809525a1d206-480-fragmented.mp4","height":480,"size":249562889,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309945","height":480,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-480-hls.torrent","height":480},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480-hls.torrent&xt=urn:btih:5d14f38ded29de629668fe1cfc61a75f4cce2628&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fstreaming-playlists%2Fhls%2F6050732a-8a7a-43d4-a6cd-809525a1d206%2F6050732a-8a7a-43d4-a6cd-809525a1d206-480-fragmented.mp4","height":480},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/6050732a-8a7a-43d4-a6cd-809525a1d206-360-fragmented.mp4","height":360,"size":170836415,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309946","height":360,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-360-hls.torrent","height":360},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360-hls.torrent&xt=urn:btih:30125488789080ad405ebcee6c214945f31b8f30&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fstreaming-playlists%2Fhls%2F6050732a-8a7a-43d4-a6cd-809525a1d206%2F6050732a-8a7a-43d4-a6cd-809525a1d206-360-fragmented.mp4","height":360},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/6050732a-8a7a-43d4-a6cd-809525a1d206-720-fragmented.mp4","height":720,"size":496533741,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309947","height":720,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-720-hls.torrent","height":720},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720-hls.torrent&xt=urn:btih:8ed1e8bccde709901c26e315fc8f53bfd26d1ba6&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fstreaming-playlists%2Fhls%2F6050732a-8a7a-43d4-a6cd-809525a1d206%2F6050732a-8a7a-43d4-a6cd-809525a1d206-720-fragmented.mp4","height":720},{"type":"Link","mediaType":"video/mp4","href":"https://framatube.org/static/streaming-playlists/hls/6050732a-8a7a-43d4-a6cd-809525a1d206/6050732a-8a7a-43d4-a6cd-809525a1d206-240-fragmented.mp4","height":240,"size":112529249,"fps":25},{"type":"Link","rel":["metadata","video/mp4"],"mediaType":"application/json","href":"https://framatube.org/api/v1/videos/6050732a-8a7a-43d4-a6cd-809525a1d206/metadata/1309948","height":240,"fps":25},{"type":"Link","mediaType":"application/x-bittorrent","href":"https://framatube.org/static/torrents/6050732a-8a7a-43d4-a6cd-809525a1d206-240-hls.torrent","height":240},{"type":"Link","mediaType":"application/x-bittorrent;x-scheme-handler/magnet","href":"magnet:?xs=https%3A%2F%2Fframatube.org%2Fstatic%2Ftorrents%2F6050732a-8a7a-43d4-a6cd-809525a1d206-240-hls.torrent&xt=urn:btih:8b452bf4e70b9078d4e74ca8b5523cc9dc70d10a&dn=D%C3%A9framasoftisons+Internet+%5BFramasoft%5D&tr=wss%3A%2F%2Fframatube.org%3A443%2Ftracker%2Fsocket&tr=https%3A%2F%2Fframatube.org%2Ftracker%2Fannounce&ws=https%3A%2F%2Fframatube.org%2Fstatic%2Fstreaming-playlists%2Fhls%2F6050732a-8a7a-43d4-a6cd-809525a1d206%2F6050732a-8a7a-43d4-a6cd-809525a1d206-240-fragmented.mp4","height":240}]}],"likes":"https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206/likes","dislikes":"https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206/dislikes","shares":"https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206/announces","comments":"https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206/comments","attributedTo":[{"type":"Person","id":"https://framatube.org/accounts/framasoft"},{"type":"Group","id":"https://framatube.org/video-channels/bf54d359-cfad-4935-9d45-9d6be93f63e8"}],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://framatube.org/accounts/framasoft/followers"],"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017"},{"pt":"https://joinpeertube.org/ns#","sc":"http://schema.org#","Hashtag":"as:Hashtag","uuid":"sc:identifier","category":"sc:category","licence":"sc:license","subtitleLanguage":"sc:subtitleLanguage","sensitive":"as:sensitive","language":"sc:inLanguage","Infohash":"pt:Infohash","Playlist":"pt:Playlist","PlaylistElement":"pt:PlaylistElement","originallyPublishedAt":"sc:datePublished","views":{"@type":"sc:Number","@id":"pt:views"},"state":{"@type":"sc:Number","@id":"pt:state"},"size":{"@type":"sc:Number","@id":"pt:size"},"fps":{"@type":"sc:Number","@id":"pt:fps"},"startTimestamp":{"@type":"sc:Number","@id":"pt:startTimestamp"},"stopTimestamp":{"@type":"sc:Number","@id":"pt:stopTimestamp"},"position":{"@type":"sc:Number","@id":"pt:position"},"commentsEnabled":{"@type":"sc:Boolean","@id":"pt:commentsEnabled"},"downloadEnabled":{"@type":"sc:Boolean","@id":"pt:downloadEnabled"},"waitTranscoding":{"@type":"sc:Boolean","@id":"pt:waitTranscoding"},"support":{"@type":"sc:Text","@id":"pt:support"},"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"playlists":{"@id":"pt:playlists","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}]} \ No newline at end of file diff --git a/test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json b/test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json new file mode 100644 index 000000000..1c3f779b3 --- /dev/null +++ b/test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json @@ -0,0 +1 @@ +{"type":"Person","id":"https://framatube.org/accounts/framasoft","following":"https://framatube.org/accounts/framasoft/following","followers":"https://framatube.org/accounts/framasoft/followers","playlists":"https://framatube.org/accounts/framasoft/playlists","inbox":"https://framatube.org/accounts/framasoft/inbox","outbox":"https://framatube.org/accounts/framasoft/outbox","preferredUsername":"framasoft","url":"https://framatube.org/accounts/framasoft","name":"Framasoft","endpoints":{"sharedInbox":"https://framatube.org/inbox"},"publicKey":{"id":"https://framatube.org/accounts/framasoft#main-key","owner":"https://framatube.org/accounts/framasoft","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuRh3frgIg866D0y0FThp\nSUkJImMcHGkUvpYQYv2iUgarZZtEbwT8PfQf0bJazy+cP8KqQmMDf5PBhT7dfdny\nf/GKGMw9Olc+QISeKDj3sqZ3Csrm4KV4avMGCfth6eSU7LozojeSGCXdUFz/8UgE\nfhV4mJjEX/FbwRYoKlagv5rY9mkX5XomzZU+z9j6ZVXyofwOwJvmI1hq0SYDv2bc\neB/RgIh/H0nyMtF8o+0CT42FNEET9j9m1BKOBtPzwZHmitKRkEmui5cK256s1laB\nT61KHpcD9gQKkQ+I3sFEzCBUJYfVo6fUe+GehBZuAfq4qDhd15SfE4K9veDscDFI\nTwIDAQAB\n-----END PUBLIC KEY-----"},"icon":{"type":"Image","mediaType":"image/png","url":"https://framatube.org/lazy-static/avatars/f73876f5-1d45-4f8a-942a-d3d5d5ac5dc1.png"},"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"RsaSignature2017":"https://w3id.org/security#RsaSignature2017","pt":"https://joinpeertube.org/ns#","sc":"http://schema.org#","Hashtag":"as:Hashtag","uuid":"sc:identifier","category":"sc:category","licence":"sc:license","subtitleLanguage":"sc:subtitleLanguage","sensitive":"as:sensitive","language":"sc:inLanguage","expires":"sc:expires","CacheFile":"pt:CacheFile","Infohash":"pt:Infohash","originallyPublishedAt":"sc:datePublished","views":{"@type":"sc:Number","@id":"pt:views"},"state":{"@type":"sc:Number","@id":"pt:state"},"size":{"@type":"sc:Number","@id":"pt:size"},"fps":{"@type":"sc:Number","@id":"pt:fps"},"startTimestamp":{"@type":"sc:Number","@id":"pt:startTimestamp"},"stopTimestamp":{"@type":"sc:Number","@id":"pt:stopTimestamp"},"position":{"@type":"sc:Number","@id":"pt:position"},"commentsEnabled":{"@type":"sc:Boolean","@id":"pt:commentsEnabled"},"downloadEnabled":{"@type":"sc:Boolean","@id":"pt:downloadEnabled"},"waitTranscoding":{"@type":"sc:Boolean","@id":"pt:waitTranscoding"},"support":{"@type":"sc:Text","@id":"pt:support"}},{"likes":{"@id":"as:likes","@type":"@id"},"dislikes":{"@id":"as:dislikes","@type":"@id"},"playlists":{"@id":"pt:playlists","@type":"@id"},"shares":{"@id":"as:shares","@type":"@id"},"comments":{"@id":"as:comments","@type":"@id"}}],"summary":null} \ No newline at end of file diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 3d5128835..da04ac6f1 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -308,6 +308,22 @@ def get("https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" }} end + def get("https://framatube.org/accounts/framasoft", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/https___framatube.org_accounts_framasoft.json") + }} + end + + def get("https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/framatube.org-video.json") + }} + end + def get("https://peertube.social/accounts/craigmaloney", _, _, _) do {:ok, %Tesla.Env{ diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 6a53fd3f0..01179206c 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -659,22 +659,44 @@ test "it remaps video URLs as attachments if necessary" do "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" ) - attachment = %{ - "type" => "Link", - "mediaType" => "video/mp4", - "url" => [ - %{ - "href" => - "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", - "mediaType" => "video/mp4" - } - ] - } - assert object.data["url"] == "https://peertube.moe/videos/watch/df5f464b-be8d-46fb-ad81-2d4c2d1630e3" - assert object.data["attachment"] == [attachment] + assert object.data["attachment"] == [ + %{ + "type" => "Link", + "mediaType" => "video/mp4", + "url" => [ + %{ + "href" => + "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", + "mediaType" => "video/mp4" + } + ] + } + ] + + {:ok, object} = + Fetcher.fetch_object_from_id( + "https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206" + ) + + assert object.data["attachment"] == [ + %{ + "type" => "Link", + "mediaType" => "video/mp4", + "url" => [ + %{ + "href" => + "https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4", + "mediaType" => "video/mp4" + } + ] + } + ] + + assert object.data["url"] == + "https://framatube.org/videos/watch/6050732a-8a7a-43d4-a6cd-809525a1d206" end test "it accepts Flag activities" do -- cgit v1.2.3 From 59cf78e41236a527b21befaadd329e882a62b40a Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 7 Jul 2020 16:20:50 +0200 Subject: AccountController: Allow removal / reset of user images. --- lib/pleroma/user.ex | 13 +++----- .../mastodon_api/controllers/account_controller.ex | 13 ++++++-- .../account_controller/update_credentials_test.exs | 38 ++++++++++++++++++---- 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8a54546d6..e98332744 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -89,7 +89,7 @@ defmodule Pleroma.User do field(:keys, :string) field(:public_key, :string) field(:ap_id, :string) - field(:avatar, :map) + field(:avatar, :map, default: %{}) field(:local, :boolean, default: true) field(:follower_address, :string) field(:following_address, :string) @@ -539,14 +539,11 @@ defp put_emoji(changeset) do 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 + with {:ok, value} <- fetch_change(changeset, map_field), + {:ok, new_value} <- value_function.(value) do + put_change(changeset, map_field, new_value) else - changeset + _ -> changeset end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index b5008d69b..d4532258c 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -148,6 +148,13 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Enum.filter(fn {_, value} -> not is_nil(value) end) |> Enum.into(%{}) + # We use an empty string as a special value to reset + # avatars, banners, backgrounds + user_image_value = fn + "" -> {:ok, nil} + value -> {:ok, value} + end + user_params = [ :no_rich_text, @@ -168,9 +175,9 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:name, params[:display_name]) |> Maps.put_if_present(:bio, params[:note]) |> Maps.put_if_present(:raw_bio, params[:note]) - |> Maps.put_if_present(:avatar, params[:avatar]) - |> Maps.put_if_present(:banner, params[:header]) - |> Maps.put_if_present(:background, params[:pleroma_background_image]) + |> Maps.put_if_present(:avatar, params[:avatar], user_image_value) + |> Maps.put_if_present(:banner, params[:header], user_image_value) + |> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value) |> Maps.put_if_present( :raw_fields, params[:fields_attributes], 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 f67d294ba..b55bb76a7 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 @@ -216,10 +216,21 @@ test "updates the user's avatar", %{user: user, conn: conn} do filename: "an_image.jpg" } - conn = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + assert user.avatar == %{} - assert user_response = json_response_and_validate_schema(conn, 200) + res = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["avatar"] != User.avatar_url(user) + + user = User.get_by_id(user.id) + refute user.avatar == %{} + + # Also resets it + _res = patch(conn, "/api/v1/accounts/update_credentials", %{"avatar" => ""}) + + user = User.get_by_id(user.id) + assert user.avatar == nil end test "updates the user's banner", %{user: user, conn: conn} do @@ -229,26 +240,39 @@ test "updates the user's banner", %{user: user, conn: conn} do filename: "an_image.jpg" } - conn = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header}) + res = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => new_header}) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["header"] != User.banner_url(user) + + # Also resets it + _res = patch(conn, "/api/v1/accounts/update_credentials", %{"header" => ""}) + + user = User.get_by_id(user.id) + assert user.banner == nil end - test "updates the user's background", %{conn: conn} do + test "updates the user's background", %{conn: conn, user: user} do new_header = %Plug.Upload{ content_type: "image/jpg", path: Path.absname("test/fixtures/image.jpg"), filename: "an_image.jpg" } - conn = + res = patch(conn, "/api/v1/accounts/update_credentials", %{ "pleroma_background_image" => new_header }) - assert user_response = json_response_and_validate_schema(conn, 200) + assert user_response = json_response_and_validate_schema(res, 200) assert user_response["pleroma"]["background_image"] + # + # Also resets it + _res = + patch(conn, "/api/v1/accounts/update_credentials", %{"pleroma_background_image" => ""}) + + user = User.get_by_id(user.id) + assert user.background == nil end test "requires 'write:accounts' permission" do -- cgit v1.2.3 From 1adda637d3898e0313f3d2108052b4ffd9e88a3a Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 7 Jul 2020 16:26:57 +0200 Subject: Docs: Document resetting of images --- docs/API/differences_in_mastoapi_responses.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index d2455d5d7..29141ed0c 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -182,10 +182,12 @@ Additional parameters can be added to the JSON body/Form data: - `pleroma_settings_store` - Opaque user settings to be saved on the backend. - `skip_thread_containment` - if true, skip filtering out broken threads - `allow_following_move` - if true, allows automatically follow moved following accounts -- `pleroma_background_image` - sets the background image of the user. +- `pleroma_background_image` - sets the background image of the user. Can be set to "" (an empty string) to reset. - `discoverable` - if true, discovery of this account in search results and other services is allowed. - `actor_type` - the type of this account. +All images (avatar, banner and background) can be reset to the default by sending an empty string ("") instead of a file. + ### 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. -- cgit v1.2.3 From c8dd973af5241547beb8c2207a0c13b933745cf6 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 7 Jul 2020 16:48:47 +0200 Subject: AccountController: Remove unused `update_?` routes. These were not documented and are also not used anymore. --- .../operations/pleroma_account_operation.ex | 91 -------------------- .../pleroma_api/controllers/account_controller.ex | 62 -------------- lib/pleroma/web/router.ex | 4 - .../controllers/account_controller_test.exs | 99 ---------------------- 4 files changed, 256 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index 90922c064..97836b2eb 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -4,7 +4,6 @@ defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do alias OpenApiSpex.Operation - alias OpenApiSpex.Schema alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.FlakeID @@ -40,48 +39,6 @@ def confirmation_resend_operation do } end - def update_avatar_operation do - %Operation{ - tags: ["Accounts"], - summary: "Set/clear user avatar image", - operationId: "PleromaAPI.AccountController.update_avatar", - requestBody: - request_body("Parameters", update_avatar_or_background_request(), required: true), - security: [%{"oAuth" => ["write:accounts"]}], - responses: %{ - 200 => update_response(), - 403 => Operation.response("Forbidden", "application/json", ApiError) - } - } - end - - def update_banner_operation do - %Operation{ - tags: ["Accounts"], - summary: "Set/clear user banner image", - operationId: "PleromaAPI.AccountController.update_banner", - requestBody: request_body("Parameters", update_banner_request(), required: true), - security: [%{"oAuth" => ["write:accounts"]}], - responses: %{ - 200 => update_response() - } - } - end - - def update_background_operation do - %Operation{ - tags: ["Accounts"], - summary: "Set/clear user background image", - operationId: "PleromaAPI.AccountController.update_background", - security: [%{"oAuth" => ["write:accounts"]}], - requestBody: - request_body("Parameters", update_avatar_or_background_request(), required: true), - responses: %{ - 200 => update_response() - } - } - end - def favourites_operation do %Operation{ tags: ["Accounts"], @@ -136,52 +93,4 @@ defp id_param do required: true ) end - - defp update_avatar_or_background_request do - %Schema{ - title: "PleromaAccountUpdateAvatarOrBackgroundRequest", - type: :object, - properties: %{ - img: %Schema{ - nullable: true, - type: :string, - format: :binary, - description: "Image encoded using `multipart/form-data` or an empty string to clear" - } - } - } - end - - defp update_banner_request do - %Schema{ - title: "PleromaAccountUpdateBannerRequest", - type: :object, - properties: %{ - banner: %Schema{ - type: :string, - nullable: true, - format: :binary, - description: "Image encoded using `multipart/form-data` or an empty string to clear" - } - } - } - end - - defp update_response do - Operation.response("PleromaAccountUpdateResponse", "application/json", %Schema{ - type: :object, - properties: %{ - url: %Schema{ - type: :string, - format: :uri, - nullable: true, - description: "Image URL" - } - }, - example: %{ - "url" => - "https://cofe.party/media/9d0add56-bcb6-4c0f-8225-cbbd0b6dd773/13eadb6972c9ccd3f4ffa3b8196f0e0d38b4d2f27594457c52e52946c054cd9a.gif" - } - }) - end end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index f3554d919..563edded7 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do import Pleroma.Web.ControllerHelper, only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] - alias Ecto.Changeset alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.RateLimiter @@ -35,17 +34,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do %{scopes: ["follow", "write:follows"]} when action in [:subscribe, :unsubscribe] ) - plug( - OAuthScopesPlug, - %{scopes: ["write:accounts"]} - # Note: the following actions are not permission-secured in Mastodon: - when action in [ - :update_avatar, - :update_banner, - :update_background - ] - ) - plug( OAuthScopesPlug, %{scopes: ["read:favourites"], fallback: :proceed_unauthenticated} when action == :favourites @@ -68,56 +56,6 @@ def confirmation_resend(conn, params) do end end - @doc "PATCH /api/v1/pleroma/accounts/update_avatar" - def update_avatar(%{assigns: %{user: user}, body_params: %{img: ""}} = conn, _) do - {:ok, _user} = - user - |> Changeset.change(%{avatar: nil}) - |> User.update_and_set_cache() - - json(conn, %{url: nil}) - end - - def update_avatar(%{assigns: %{user: user}, body_params: params} = conn, _params) do - {:ok, %{data: data}} = ActivityPub.upload(params, type: :avatar) - {:ok, _user} = user |> Changeset.change(%{avatar: data}) |> User.update_and_set_cache() - %{"url" => [%{"href" => href} | _]} = data - - json(conn, %{url: href}) - end - - @doc "PATCH /api/v1/pleroma/accounts/update_banner" - def update_banner(%{assigns: %{user: user}, body_params: %{banner: ""}} = conn, _) do - with {:ok, _user} <- User.update_banner(user, %{}) do - json(conn, %{url: nil}) - end - end - - def update_banner(%{assigns: %{user: user}, body_params: params} = conn, _) do - with {:ok, object} <- ActivityPub.upload(%{img: params[:banner]}, type: :banner), - {:ok, _user} <- User.update_banner(user, object.data) do - %{"url" => [%{"href" => href} | _]} = object.data - - json(conn, %{url: href}) - end - end - - @doc "PATCH /api/v1/pleroma/accounts/update_background" - def update_background(%{assigns: %{user: user}, body_params: %{img: ""}} = conn, _) do - with {:ok, _user} <- User.update_background(user, %{}) do - json(conn, %{url: nil}) - end - end - - def update_background(%{assigns: %{user: user}, body_params: params} = conn, _) do - with {:ok, object} <- ActivityPub.upload(params, type: :background), - {:ok, _user} <- User.update_background(user, object.data) do - %{"url" => [%{"href" => href} | _]} = object.data - - json(conn, %{url: href}) - end - end - @doc "GET /api/v1/pleroma/accounts/:id/favourites" def favourites(%{assigns: %{account: %{hide_favorites: true}}} = conn, _params) do render_error(conn, :forbidden, "Can't get favorites") diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 9e457848e..74e940f8e 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -328,10 +328,6 @@ defmodule Pleroma.Web.Router do delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete) post("/notifications/read", NotificationController, :mark_as_read) - patch("/accounts/update_avatar", AccountController, :update_avatar) - patch("/accounts/update_banner", AccountController, :update_banner) - patch("/accounts/update_background", AccountController, :update_background) - get("/mascot", MascotController, :show) put("/mascot", MascotController, :update) diff --git a/test/web/pleroma_api/controllers/account_controller_test.exs b/test/web/pleroma_api/controllers/account_controller_test.exs index 103997c31..07909d48b 100644 --- a/test/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/web/pleroma_api/controllers/account_controller_test.exs @@ -13,8 +13,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do import Pleroma.Factory import Swoosh.TestAssertions - @image "data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7" - describe "POST /api/v1/pleroma/accounts/confirmation_resend" do setup do {:ok, user} = @@ -68,103 +66,6 @@ test "resend account confirmation email (with nickname)", %{conn: conn, user: us end end - describe "PATCH /api/v1/pleroma/accounts/update_avatar" do - setup do: oauth_access(["write:accounts"]) - - test "user avatar can be set", %{user: user, conn: conn} do - avatar_image = File.read!("test/fixtures/avatar_data_uri") - - conn = - conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image}) - - user = refresh_record(user) - - assert %{ - "name" => _, - "type" => _, - "url" => [ - %{ - "href" => _, - "mediaType" => _, - "type" => _ - } - ] - } = user.avatar - - assert %{"url" => _} = json_response_and_validate_schema(conn, 200) - end - - test "user avatar can be reset", %{user: user, conn: conn} do - conn = - conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: ""}) - - user = User.get_cached_by_id(user.id) - - assert user.avatar == nil - - assert %{"url" => nil} = json_response_and_validate_schema(conn, 200) - end - end - - describe "PATCH /api/v1/pleroma/accounts/update_banner" do - setup do: oauth_access(["write:accounts"]) - - test "can set profile banner", %{user: user, conn: conn} do - conn = - conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => @image}) - - user = refresh_record(user) - assert user.banner["type"] == "Image" - - assert %{"url" => _} = json_response_and_validate_schema(conn, 200) - end - - test "can reset profile banner", %{user: user, conn: conn} do - conn = - conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => ""}) - - user = refresh_record(user) - assert user.banner == %{} - - assert %{"url" => nil} = json_response_and_validate_schema(conn, 200) - end - end - - describe "PATCH /api/v1/pleroma/accounts/update_background" do - setup do: oauth_access(["write:accounts"]) - - test "background image can be set", %{user: user, conn: conn} do - conn = - conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => @image}) - - user = refresh_record(user) - assert user.background["type"] == "Image" - # assert %{"url" => _} = json_response(conn, 200) - assert %{"url" => _} = json_response_and_validate_schema(conn, 200) - end - - test "background image can be reset", %{user: user, conn: conn} do - conn = - conn - |> put_req_header("content-type", "multipart/form-data") - |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => ""}) - - user = refresh_record(user) - assert user.background == %{} - assert %{"url" => nil} = json_response_and_validate_schema(conn, 200) - end - end - describe "getting favorites timeline of specified user" do setup do [current_user, user] = insert_pair(:user, hide_favorites: false) -- cgit v1.2.3 From 92e6801c179080e833575226fdf291d9393286c5 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 7 Jul 2020 16:51:44 +0200 Subject: Changelog: Add info about avatar removal --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2b54916..304c9027f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    API Changes +- **Breaking:** Pleroma API: The routes to update avatar, banner and background have been removed. - **Breaking:** Image description length is limited now. - **Breaking:** Emoji API: changed methods and renamed routes. +- MastodonAPI: Allow removal of avatar, banner and background. - Streaming: Repeats of a user's posts will no longer be pushed to the user's stream. - Mastodon API: Added `pleroma.metadata.fields_limits` to /api/v1/instance - Mastodon API: On deletion, returns the original post text. -- cgit v1.2.3 From 4c9295adccdc4b89baec55860ffe2c31091491c9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 7 Jul 2020 17:13:23 +0200 Subject: Update frontend --- priv/static/index.html | 2 +- priv/static/static/font/fontello.1594030805019.eot | Bin 23832 -> 0 bytes priv/static/static/font/fontello.1594030805019.svg | 132 -------------------- priv/static/static/font/fontello.1594030805019.ttf | Bin 23664 -> 0 bytes .../static/static/font/fontello.1594030805019.woff | Bin 14464 -> 0 bytes .../static/font/fontello.1594030805019.woff2 | Bin 12272 -> 0 bytes priv/static/static/font/fontello.1594134783339.eot | Bin 0 -> 24332 bytes priv/static/static/font/fontello.1594134783339.svg | 136 +++++++++++++++++++++ priv/static/static/font/fontello.1594134783339.ttf | Bin 0 -> 24164 bytes .../static/static/font/fontello.1594134783339.woff | Bin 0 -> 14772 bytes .../static/font/fontello.1594134783339.woff2 | Bin 0 -> 12416 bytes priv/static/static/fontello.1594134783339.css | Bin 0 -> 3693 bytes priv/static/static/fontello.json | 12 ++ priv/static/static/js/10.0f1994ddc34cfbc08609.js | Bin 23120 -> 0 bytes .../static/js/10.0f1994ddc34cfbc08609.js.map | Bin 113 -> 0 bytes priv/static/static/js/10.4a22c77e34edcd678d2f.js | Bin 0 -> 23120 bytes .../static/js/10.4a22c77e34edcd678d2f.js.map | Bin 0 -> 113 bytes priv/static/static/js/11.1e7cd81617d5fdd53e6e.js | Bin 16564 -> 0 bytes .../static/js/11.1e7cd81617d5fdd53e6e.js.map | Bin 113 -> 0 bytes priv/static/static/js/11.787aa24e4fd5caef9adb.js | Bin 0 -> 16564 bytes .../static/js/11.787aa24e4fd5caef9adb.js.map | Bin 0 -> 113 bytes priv/static/static/js/12.35a510cf14233f0c6e1f.js | Bin 0 -> 22582 bytes .../static/js/12.35a510cf14233f0c6e1f.js.map | Bin 0 -> 113 bytes priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js | Bin 22582 -> 0 bytes .../static/js/12.d9989f5b5d0f8d4aa8a1.js.map | Bin 113 -> 0 bytes priv/static/static/js/13.01dcbbeee7fc697d5dff.js | Bin 26142 -> 0 bytes .../static/js/13.01dcbbeee7fc697d5dff.js.map | Bin 113 -> 0 bytes priv/static/static/js/13.7931a609d62a42678085.js | Bin 0 -> 26143 bytes .../static/js/13.7931a609d62a42678085.js.map | Bin 0 -> 113 bytes priv/static/static/js/14.4355245d20f818121839.js | Bin 28652 -> 0 bytes .../static/js/14.4355245d20f818121839.js.map | Bin 113 -> 0 bytes priv/static/static/js/14.cc092634462fd2a4cfbc.js | Bin 0 -> 28652 bytes .../static/js/14.cc092634462fd2a4cfbc.js.map | Bin 0 -> 113 bytes priv/static/static/js/15.cad89660cbeef1f1f737.js | Bin 7939 -> 0 bytes .../static/js/15.cad89660cbeef1f1f737.js.map | Bin 113 -> 0 bytes priv/static/static/js/15.e9ddc5dfd38426398e00.js | Bin 0 -> 7939 bytes .../static/js/15.e9ddc5dfd38426398e00.js.map | Bin 0 -> 113 bytes priv/static/static/js/16.0f8c0529208576f8d8f1.js | Bin 15892 -> 0 bytes .../static/js/16.0f8c0529208576f8d8f1.js.map | Bin 113 -> 0 bytes priv/static/static/js/16.476e7809b8593264469e.js | Bin 0 -> 15892 bytes .../static/js/16.476e7809b8593264469e.js.map | Bin 0 -> 113 bytes priv/static/static/js/17.102667c39eaf1f3da16f.js | Bin 2234 -> 0 bytes .../static/js/17.102667c39eaf1f3da16f.js.map | Bin 113 -> 0 bytes priv/static/static/js/17.acbe4c09f05ae56c76a2.js | Bin 0 -> 2234 bytes .../static/js/17.acbe4c09f05ae56c76a2.js.map | Bin 0 -> 113 bytes priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js | Bin 20453 -> 0 bytes .../static/js/18.0a9dfc8a06dfcc8f0e29.js.map | Bin 113 -> 0 bytes priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js | Bin 0 -> 23585 bytes .../static/js/18.a8ccd7f2a47c5c94b3b9.js.map | Bin 0 -> 113 bytes priv/static/static/js/19.031e07a59c2ec00e163f.js | Bin 32200 -> 0 bytes .../static/js/19.031e07a59c2ec00e163f.js.map | Bin 113 -> 0 bytes priv/static/static/js/19.5894e9c12b4fd5e45872.js | Bin 0 -> 32200 bytes .../static/js/19.5894e9c12b4fd5e45872.js.map | Bin 0 -> 113 bytes priv/static/static/js/2.ca205c0a35e5f6a21711.js | Bin 174070 -> 0 bytes .../static/static/js/2.ca205c0a35e5f6a21711.js.map | Bin 450037 -> 0 bytes priv/static/static/js/2.f8dee9318a6f84ea92c3.js | Bin 0 -> 174070 bytes .../static/static/js/2.f8dee9318a6f84ea92c3.js.map | Bin 0 -> 450037 bytes priv/static/static/js/20.4211860717a159173685.js | Bin 26951 -> 0 bytes .../static/js/20.4211860717a159173685.js.map | Bin 113 -> 0 bytes priv/static/static/js/20.43b5b27b0f68474f3b72.js | Bin 0 -> 26951 bytes .../static/js/20.43b5b27b0f68474f3b72.js.map | Bin 0 -> 113 bytes priv/static/static/js/21.72b45b01be9d0f4c62ce.js | Bin 0 -> 13310 bytes .../static/js/21.72b45b01be9d0f4c62ce.js.map | Bin 0 -> 113 bytes priv/static/static/js/21.f1d1ea794ca98abd7c8f.js | Bin 13310 -> 0 bytes .../static/js/21.f1d1ea794ca98abd7c8f.js.map | Bin 113 -> 0 bytes priv/static/static/js/22.26f13a22ad57a0d14670.js | Bin 0 -> 20130 bytes .../static/js/22.26f13a22ad57a0d14670.js.map | Bin 0 -> 113 bytes priv/static/static/js/22.be0989993d98819df69a.js | Bin 20130 -> 0 bytes .../static/js/22.be0989993d98819df69a.js.map | Bin 113 -> 0 bytes priv/static/static/js/23.353fb2474276b7d9d8ab.js | Bin 28187 -> 0 bytes .../static/js/23.353fb2474276b7d9d8ab.js.map | Bin 113 -> 0 bytes priv/static/static/js/23.91a60b775352a806f887.js | Bin 0 -> 28187 bytes .../static/js/23.91a60b775352a806f887.js.map | Bin 0 -> 113 bytes priv/static/static/js/24.222c48387222e8bc7c84.js | Bin 18949 -> 0 bytes .../static/js/24.222c48387222e8bc7c84.js.map | Bin 113 -> 0 bytes priv/static/static/js/24.c8d8438aac954d4707ac.js | Bin 0 -> 18949 bytes .../static/js/24.c8d8438aac954d4707ac.js.map | Bin 0 -> 113 bytes priv/static/static/js/25.59d04b82ff45f25b44ef.js | Bin 27408 -> 0 bytes .../static/js/25.59d04b82ff45f25b44ef.js.map | Bin 113 -> 0 bytes priv/static/static/js/25.79ac9e020d571b67f02a.js | Bin 0 -> 27408 bytes .../static/js/25.79ac9e020d571b67f02a.js.map | Bin 0 -> 113 bytes priv/static/static/js/26.3af8f54349f672f2c7c8.js | Bin 0 -> 14415 bytes .../static/js/26.3af8f54349f672f2c7c8.js.map | Bin 0 -> 113 bytes priv/static/static/js/26.d4910001c228c31abe61.js | Bin 14415 -> 0 bytes .../static/js/26.d4910001c228c31abe61.js.map | Bin 113 -> 0 bytes priv/static/static/js/27.51287d408313da67b0b8.js | Bin 0 -> 2175 bytes .../static/js/27.51287d408313da67b0b8.js.map | Bin 0 -> 113 bytes priv/static/static/js/27.68d319e0867f9e35d5d3.js | Bin 2175 -> 0 bytes .../static/js/27.68d319e0867f9e35d5d3.js.map | Bin 113 -> 0 bytes priv/static/static/js/28.580f1c09759e4dabced9.js | Bin 25778 -> 0 bytes .../static/js/28.580f1c09759e4dabced9.js.map | Bin 113 -> 0 bytes priv/static/static/js/28.be5118beb1098a81332d.js | Bin 0 -> 25778 bytes .../static/js/28.be5118beb1098a81332d.js.map | Bin 0 -> 113 bytes priv/static/static/js/29.084f6fb0987d3862d410.js | Bin 0 -> 24135 bytes .../static/js/29.084f6fb0987d3862d410.js.map | Bin 0 -> 113 bytes priv/static/static/js/29.ea54402e3fbd16f17eb7.js | Bin 24135 -> 0 bytes .../static/js/29.ea54402e3fbd16f17eb7.js.map | Bin 113 -> 0 bytes priv/static/static/js/3.23de974e1235c91ea803.js | Bin 78761 -> 0 bytes .../static/static/js/3.23de974e1235c91ea803.js.map | Bin 332972 -> 0 bytes priv/static/static/js/3.e1f7d368d5840e12e850.js | Bin 0 -> 78761 bytes .../static/static/js/3.e1f7d368d5840e12e850.js.map | Bin 0 -> 332972 bytes priv/static/static/js/30.6e6d63411def2e175d11.js | Bin 0 -> 21485 bytes .../static/js/30.6e6d63411def2e175d11.js.map | Bin 0 -> 113 bytes priv/static/static/js/30.b657503bf18858a9b282.js | Bin 21494 -> 0 bytes .../static/js/30.b657503bf18858a9b282.js.map | Bin 113 -> 0 bytes priv/static/static/js/4.4fe9f0677ec54321f659.js | Bin 2177 -> 0 bytes .../static/static/js/4.4fe9f0677ec54321f659.js.map | Bin 7940 -> 0 bytes priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js | Bin 0 -> 2177 bytes .../static/static/js/4.c3f92d0b6ff90b36e3f5.js.map | Bin 0 -> 7940 bytes priv/static/static/js/5.74ace591a96fca58ee48.js | Bin 7028 -> 0 bytes .../static/static/js/5.74ace591a96fca58ee48.js.map | Bin 112 -> 0 bytes priv/static/static/js/5.d30e50cd5c52d54ffdc9.js | Bin 0 -> 7028 bytes .../static/static/js/5.d30e50cd5c52d54ffdc9.js.map | Bin 0 -> 112 bytes priv/static/static/js/6.67ff41bfc9476902b9de.js | Bin 7955 -> 0 bytes .../static/static/js/6.67ff41bfc9476902b9de.js.map | Bin 112 -> 0 bytes priv/static/static/js/6.fa6d5c2d85d44f0ba121.js | Bin 0 -> 7955 bytes .../static/static/js/6.fa6d5c2d85d44f0ba121.js.map | Bin 0 -> 112 bytes priv/static/static/js/7.c0d55831c37350a90aee.js | Bin 15765 -> 0 bytes .../static/static/js/7.c0d55831c37350a90aee.js.map | Bin 112 -> 0 bytes priv/static/static/js/7.d558a086622f668601a6.js | Bin 0 -> 15765 bytes .../static/static/js/7.d558a086622f668601a6.js.map | Bin 0 -> 112 bytes priv/static/static/js/8.615136ce6c34a6b96a29.js | Bin 0 -> 21966 bytes .../static/static/js/8.615136ce6c34a6b96a29.js.map | Bin 0 -> 112 bytes priv/static/static/js/8.83dbefa1dc25a2e61b92.js | Bin 21966 -> 0 bytes .../static/static/js/8.83dbefa1dc25a2e61b92.js.map | Bin 112 -> 0 bytes priv/static/static/js/9.aa8acb3e28bf30fdefc7.js | Bin 13880 -> 0 bytes .../static/static/js/9.aa8acb3e28bf30fdefc7.js.map | Bin 112 -> 0 bytes priv/static/static/js/9.ef4eb9703f9aee67515e.js | Bin 0 -> 13880 bytes .../static/static/js/9.ef4eb9703f9aee67515e.js.map | Bin 0 -> 112 bytes priv/static/static/js/app.53001fa190f37cf2743e.js | Bin 0 -> 517071 bytes .../static/js/app.53001fa190f37cf2743e.js.map | Bin 0 -> 1335479 bytes priv/static/static/js/app.7db8116851a0fe6eb807.js | Bin 514189 -> 0 bytes .../static/js/app.7db8116851a0fe6eb807.js.map | Bin 1329621 -> 0 bytes .../static/js/vendors~app.8837fb59589d1dd6acda.js | Bin 0 -> 303823 bytes .../js/vendors~app.8837fb59589d1dd6acda.js.map | Bin 0 -> 1271967 bytes .../static/js/vendors~app.fbb3f5304df245971d96.js | Bin 303822 -> 0 bytes .../js/vendors~app.fbb3f5304df245971d96.js.map | Bin 1271967 -> 0 bytes priv/static/sw-pleroma.js | Bin 181342 -> 181342 bytes priv/static/sw-pleroma.js.map | Bin 694047 -> 694047 bytes 139 files changed, 149 insertions(+), 133 deletions(-) delete mode 100644 priv/static/static/font/fontello.1594030805019.eot delete mode 100644 priv/static/static/font/fontello.1594030805019.svg delete mode 100644 priv/static/static/font/fontello.1594030805019.ttf delete mode 100644 priv/static/static/font/fontello.1594030805019.woff delete mode 100644 priv/static/static/font/fontello.1594030805019.woff2 create mode 100644 priv/static/static/font/fontello.1594134783339.eot create mode 100644 priv/static/static/font/fontello.1594134783339.svg create mode 100644 priv/static/static/font/fontello.1594134783339.ttf create mode 100644 priv/static/static/font/fontello.1594134783339.woff create mode 100644 priv/static/static/font/fontello.1594134783339.woff2 create mode 100644 priv/static/static/fontello.1594134783339.css delete mode 100644 priv/static/static/js/10.0f1994ddc34cfbc08609.js delete mode 100644 priv/static/static/js/10.0f1994ddc34cfbc08609.js.map create mode 100644 priv/static/static/js/10.4a22c77e34edcd678d2f.js create mode 100644 priv/static/static/js/10.4a22c77e34edcd678d2f.js.map delete mode 100644 priv/static/static/js/11.1e7cd81617d5fdd53e6e.js delete mode 100644 priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map create mode 100644 priv/static/static/js/11.787aa24e4fd5caef9adb.js create mode 100644 priv/static/static/js/11.787aa24e4fd5caef9adb.js.map create mode 100644 priv/static/static/js/12.35a510cf14233f0c6e1f.js create mode 100644 priv/static/static/js/12.35a510cf14233f0c6e1f.js.map delete mode 100644 priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js delete mode 100644 priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map delete mode 100644 priv/static/static/js/13.01dcbbeee7fc697d5dff.js delete mode 100644 priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map create mode 100644 priv/static/static/js/13.7931a609d62a42678085.js create mode 100644 priv/static/static/js/13.7931a609d62a42678085.js.map delete mode 100644 priv/static/static/js/14.4355245d20f818121839.js delete mode 100644 priv/static/static/js/14.4355245d20f818121839.js.map create mode 100644 priv/static/static/js/14.cc092634462fd2a4cfbc.js create mode 100644 priv/static/static/js/14.cc092634462fd2a4cfbc.js.map delete mode 100644 priv/static/static/js/15.cad89660cbeef1f1f737.js delete mode 100644 priv/static/static/js/15.cad89660cbeef1f1f737.js.map create mode 100644 priv/static/static/js/15.e9ddc5dfd38426398e00.js create mode 100644 priv/static/static/js/15.e9ddc5dfd38426398e00.js.map delete mode 100644 priv/static/static/js/16.0f8c0529208576f8d8f1.js delete mode 100644 priv/static/static/js/16.0f8c0529208576f8d8f1.js.map create mode 100644 priv/static/static/js/16.476e7809b8593264469e.js create mode 100644 priv/static/static/js/16.476e7809b8593264469e.js.map delete mode 100644 priv/static/static/js/17.102667c39eaf1f3da16f.js delete mode 100644 priv/static/static/js/17.102667c39eaf1f3da16f.js.map create mode 100644 priv/static/static/js/17.acbe4c09f05ae56c76a2.js create mode 100644 priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map delete mode 100644 priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js delete mode 100644 priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map create mode 100644 priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js create mode 100644 priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map delete mode 100644 priv/static/static/js/19.031e07a59c2ec00e163f.js delete mode 100644 priv/static/static/js/19.031e07a59c2ec00e163f.js.map create mode 100644 priv/static/static/js/19.5894e9c12b4fd5e45872.js create mode 100644 priv/static/static/js/19.5894e9c12b4fd5e45872.js.map delete mode 100644 priv/static/static/js/2.ca205c0a35e5f6a21711.js delete mode 100644 priv/static/static/js/2.ca205c0a35e5f6a21711.js.map create mode 100644 priv/static/static/js/2.f8dee9318a6f84ea92c3.js create mode 100644 priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map delete mode 100644 priv/static/static/js/20.4211860717a159173685.js delete mode 100644 priv/static/static/js/20.4211860717a159173685.js.map create mode 100644 priv/static/static/js/20.43b5b27b0f68474f3b72.js create mode 100644 priv/static/static/js/20.43b5b27b0f68474f3b72.js.map create mode 100644 priv/static/static/js/21.72b45b01be9d0f4c62ce.js create mode 100644 priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map delete mode 100644 priv/static/static/js/21.f1d1ea794ca98abd7c8f.js delete mode 100644 priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map create mode 100644 priv/static/static/js/22.26f13a22ad57a0d14670.js create mode 100644 priv/static/static/js/22.26f13a22ad57a0d14670.js.map delete mode 100644 priv/static/static/js/22.be0989993d98819df69a.js delete mode 100644 priv/static/static/js/22.be0989993d98819df69a.js.map delete mode 100644 priv/static/static/js/23.353fb2474276b7d9d8ab.js delete mode 100644 priv/static/static/js/23.353fb2474276b7d9d8ab.js.map create mode 100644 priv/static/static/js/23.91a60b775352a806f887.js create mode 100644 priv/static/static/js/23.91a60b775352a806f887.js.map delete mode 100644 priv/static/static/js/24.222c48387222e8bc7c84.js delete mode 100644 priv/static/static/js/24.222c48387222e8bc7c84.js.map create mode 100644 priv/static/static/js/24.c8d8438aac954d4707ac.js create mode 100644 priv/static/static/js/24.c8d8438aac954d4707ac.js.map delete mode 100644 priv/static/static/js/25.59d04b82ff45f25b44ef.js delete mode 100644 priv/static/static/js/25.59d04b82ff45f25b44ef.js.map create mode 100644 priv/static/static/js/25.79ac9e020d571b67f02a.js create mode 100644 priv/static/static/js/25.79ac9e020d571b67f02a.js.map create mode 100644 priv/static/static/js/26.3af8f54349f672f2c7c8.js create mode 100644 priv/static/static/js/26.3af8f54349f672f2c7c8.js.map delete mode 100644 priv/static/static/js/26.d4910001c228c31abe61.js delete mode 100644 priv/static/static/js/26.d4910001c228c31abe61.js.map create mode 100644 priv/static/static/js/27.51287d408313da67b0b8.js create mode 100644 priv/static/static/js/27.51287d408313da67b0b8.js.map delete mode 100644 priv/static/static/js/27.68d319e0867f9e35d5d3.js delete mode 100644 priv/static/static/js/27.68d319e0867f9e35d5d3.js.map delete mode 100644 priv/static/static/js/28.580f1c09759e4dabced9.js delete mode 100644 priv/static/static/js/28.580f1c09759e4dabced9.js.map create mode 100644 priv/static/static/js/28.be5118beb1098a81332d.js create mode 100644 priv/static/static/js/28.be5118beb1098a81332d.js.map create mode 100644 priv/static/static/js/29.084f6fb0987d3862d410.js create mode 100644 priv/static/static/js/29.084f6fb0987d3862d410.js.map delete mode 100644 priv/static/static/js/29.ea54402e3fbd16f17eb7.js delete mode 100644 priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map delete mode 100644 priv/static/static/js/3.23de974e1235c91ea803.js delete mode 100644 priv/static/static/js/3.23de974e1235c91ea803.js.map create mode 100644 priv/static/static/js/3.e1f7d368d5840e12e850.js create mode 100644 priv/static/static/js/3.e1f7d368d5840e12e850.js.map create mode 100644 priv/static/static/js/30.6e6d63411def2e175d11.js create mode 100644 priv/static/static/js/30.6e6d63411def2e175d11.js.map delete mode 100644 priv/static/static/js/30.b657503bf18858a9b282.js delete mode 100644 priv/static/static/js/30.b657503bf18858a9b282.js.map delete mode 100644 priv/static/static/js/4.4fe9f0677ec54321f659.js delete mode 100644 priv/static/static/js/4.4fe9f0677ec54321f659.js.map create mode 100644 priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js create mode 100644 priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map delete mode 100644 priv/static/static/js/5.74ace591a96fca58ee48.js delete mode 100644 priv/static/static/js/5.74ace591a96fca58ee48.js.map create mode 100644 priv/static/static/js/5.d30e50cd5c52d54ffdc9.js create mode 100644 priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map delete mode 100644 priv/static/static/js/6.67ff41bfc9476902b9de.js delete mode 100644 priv/static/static/js/6.67ff41bfc9476902b9de.js.map create mode 100644 priv/static/static/js/6.fa6d5c2d85d44f0ba121.js create mode 100644 priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map delete mode 100644 priv/static/static/js/7.c0d55831c37350a90aee.js delete mode 100644 priv/static/static/js/7.c0d55831c37350a90aee.js.map create mode 100644 priv/static/static/js/7.d558a086622f668601a6.js create mode 100644 priv/static/static/js/7.d558a086622f668601a6.js.map create mode 100644 priv/static/static/js/8.615136ce6c34a6b96a29.js create mode 100644 priv/static/static/js/8.615136ce6c34a6b96a29.js.map delete mode 100644 priv/static/static/js/8.83dbefa1dc25a2e61b92.js delete mode 100644 priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map delete mode 100644 priv/static/static/js/9.aa8acb3e28bf30fdefc7.js delete mode 100644 priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map create mode 100644 priv/static/static/js/9.ef4eb9703f9aee67515e.js create mode 100644 priv/static/static/js/9.ef4eb9703f9aee67515e.js.map create mode 100644 priv/static/static/js/app.53001fa190f37cf2743e.js create mode 100644 priv/static/static/js/app.53001fa190f37cf2743e.js.map delete mode 100644 priv/static/static/js/app.7db8116851a0fe6eb807.js delete mode 100644 priv/static/static/js/app.7db8116851a0fe6eb807.js.map create mode 100644 priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js create mode 100644 priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map delete mode 100644 priv/static/static/js/vendors~app.fbb3f5304df245971d96.js delete mode 100644 priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map diff --git a/priv/static/index.html b/priv/static/index.html index ef7be091b..3ef4baa26 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594030805019.eot b/priv/static/static/font/fontello.1594030805019.eot deleted file mode 100644 index f6155180f..000000000 Binary files a/priv/static/static/font/fontello.1594030805019.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1594030805019.svg b/priv/static/static/font/fontello.1594030805019.svg deleted file mode 100644 index 8da206aa8..000000000 --- a/priv/static/static/font/fontello.1594030805019.svg +++ /dev/null @@ -1,132 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594030805019.ttf b/priv/static/static/font/fontello.1594030805019.ttf deleted file mode 100644 index 7bedaf7ab..000000000 Binary files a/priv/static/static/font/fontello.1594030805019.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1594030805019.woff b/priv/static/static/font/fontello.1594030805019.woff deleted file mode 100644 index e61bf68d0..000000000 Binary files a/priv/static/static/font/fontello.1594030805019.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1594030805019.woff2 b/priv/static/static/font/fontello.1594030805019.woff2 deleted file mode 100644 index db0fc1fc6..000000000 Binary files a/priv/static/static/font/fontello.1594030805019.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1594134783339.eot b/priv/static/static/font/fontello.1594134783339.eot new file mode 100644 index 000000000..bc98d606d Binary files /dev/null and b/priv/static/static/font/fontello.1594134783339.eot differ diff --git a/priv/static/static/font/fontello.1594134783339.svg b/priv/static/static/font/fontello.1594134783339.svg new file mode 100644 index 000000000..a5342209d --- /dev/null +++ b/priv/static/static/font/fontello.1594134783339.svg @@ -0,0 +1,136 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594134783339.ttf b/priv/static/static/font/fontello.1594134783339.ttf new file mode 100644 index 000000000..458e88f9e Binary files /dev/null and b/priv/static/static/font/fontello.1594134783339.ttf differ diff --git a/priv/static/static/font/fontello.1594134783339.woff b/priv/static/static/font/fontello.1594134783339.woff new file mode 100644 index 000000000..89a337131 Binary files /dev/null and b/priv/static/static/font/fontello.1594134783339.woff differ diff --git a/priv/static/static/font/fontello.1594134783339.woff2 b/priv/static/static/font/fontello.1594134783339.woff2 new file mode 100644 index 000000000..054169bd2 Binary files /dev/null and b/priv/static/static/font/fontello.1594134783339.woff2 differ diff --git a/priv/static/static/fontello.1594134783339.css b/priv/static/static/fontello.1594134783339.css new file mode 100644 index 000000000..ff35edaba Binary files /dev/null and b/priv/static/static/fontello.1594134783339.css differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json index 6083c0bfa..5ef8544e2 100755 --- a/priv/static/static/fontello.json +++ b/priv/static/static/fontello.json @@ -387,6 +387,18 @@ "css": "bookmark-empty", "code": 61591, "src": "fontawesome" + }, + { + "uid": "9ea0a737ccc45d6c510dcbae56058849", + "css": "music", + "code": 59432, + "src": "fontawesome" + }, + { + "uid": "1b5a5d7b7e3c71437f5a26befdd045ed", + "css": "doc", + "code": 59433, + "src": "fontawesome" } ] } \ No newline at end of file diff --git a/priv/static/static/js/10.0f1994ddc34cfbc08609.js b/priv/static/static/js/10.0f1994ddc34cfbc08609.js deleted file mode 100644 index 707e0ad56..000000000 Binary files a/priv/static/static/js/10.0f1994ddc34cfbc08609.js and /dev/null differ diff --git a/priv/static/static/js/10.0f1994ddc34cfbc08609.js.map b/priv/static/static/js/10.0f1994ddc34cfbc08609.js.map deleted file mode 100644 index 7de298aa8..000000000 Binary files a/priv/static/static/js/10.0f1994ddc34cfbc08609.js.map and /dev/null differ diff --git a/priv/static/static/js/10.4a22c77e34edcd678d2f.js b/priv/static/static/js/10.4a22c77e34edcd678d2f.js new file mode 100644 index 000000000..a1c395c42 Binary files /dev/null and b/priv/static/static/js/10.4a22c77e34edcd678d2f.js differ diff --git a/priv/static/static/js/10.4a22c77e34edcd678d2f.js.map b/priv/static/static/js/10.4a22c77e34edcd678d2f.js.map new file mode 100644 index 000000000..9c8b5e658 Binary files /dev/null and b/priv/static/static/js/10.4a22c77e34edcd678d2f.js.map differ diff --git a/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js b/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js deleted file mode 100644 index c2558c013..000000000 Binary files a/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js and /dev/null differ diff --git a/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map b/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map deleted file mode 100644 index aaf753771..000000000 Binary files a/priv/static/static/js/11.1e7cd81617d5fdd53e6e.js.map and /dev/null differ diff --git a/priv/static/static/js/11.787aa24e4fd5caef9adb.js b/priv/static/static/js/11.787aa24e4fd5caef9adb.js new file mode 100644 index 000000000..938cbb64e Binary files /dev/null and b/priv/static/static/js/11.787aa24e4fd5caef9adb.js differ diff --git a/priv/static/static/js/11.787aa24e4fd5caef9adb.js.map b/priv/static/static/js/11.787aa24e4fd5caef9adb.js.map new file mode 100644 index 000000000..e376a0bbc Binary files /dev/null and b/priv/static/static/js/11.787aa24e4fd5caef9adb.js.map differ diff --git a/priv/static/static/js/12.35a510cf14233f0c6e1f.js b/priv/static/static/js/12.35a510cf14233f0c6e1f.js new file mode 100644 index 000000000..fe4799a2d Binary files /dev/null and b/priv/static/static/js/12.35a510cf14233f0c6e1f.js differ diff --git a/priv/static/static/js/12.35a510cf14233f0c6e1f.js.map b/priv/static/static/js/12.35a510cf14233f0c6e1f.js.map new file mode 100644 index 000000000..21cc55e6f Binary files /dev/null and b/priv/static/static/js/12.35a510cf14233f0c6e1f.js.map differ diff --git a/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js b/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js deleted file mode 100644 index f80c9ab5b..000000000 Binary files a/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js and /dev/null differ diff --git a/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map b/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map deleted file mode 100644 index 586805e73..000000000 Binary files a/priv/static/static/js/12.d9989f5b5d0f8d4aa8a1.js.map and /dev/null differ diff --git a/priv/static/static/js/13.01dcbbeee7fc697d5dff.js b/priv/static/static/js/13.01dcbbeee7fc697d5dff.js deleted file mode 100644 index de75e44a8..000000000 Binary files a/priv/static/static/js/13.01dcbbeee7fc697d5dff.js and /dev/null differ diff --git a/priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map b/priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map deleted file mode 100644 index 940f51e94..000000000 Binary files a/priv/static/static/js/13.01dcbbeee7fc697d5dff.js.map and /dev/null differ diff --git a/priv/static/static/js/13.7931a609d62a42678085.js b/priv/static/static/js/13.7931a609d62a42678085.js new file mode 100644 index 000000000..3fe95c23c Binary files /dev/null and b/priv/static/static/js/13.7931a609d62a42678085.js differ diff --git a/priv/static/static/js/13.7931a609d62a42678085.js.map b/priv/static/static/js/13.7931a609d62a42678085.js.map new file mode 100644 index 000000000..8448af376 Binary files /dev/null and b/priv/static/static/js/13.7931a609d62a42678085.js.map differ diff --git a/priv/static/static/js/14.4355245d20f818121839.js b/priv/static/static/js/14.4355245d20f818121839.js deleted file mode 100644 index 5fcccbcd0..000000000 Binary files a/priv/static/static/js/14.4355245d20f818121839.js and /dev/null differ diff --git a/priv/static/static/js/14.4355245d20f818121839.js.map b/priv/static/static/js/14.4355245d20f818121839.js.map deleted file mode 100644 index 4be3205e1..000000000 Binary files a/priv/static/static/js/14.4355245d20f818121839.js.map and /dev/null differ diff --git a/priv/static/static/js/14.cc092634462fd2a4cfbc.js b/priv/static/static/js/14.cc092634462fd2a4cfbc.js new file mode 100644 index 000000000..42a179970 Binary files /dev/null and b/priv/static/static/js/14.cc092634462fd2a4cfbc.js differ diff --git a/priv/static/static/js/14.cc092634462fd2a4cfbc.js.map b/priv/static/static/js/14.cc092634462fd2a4cfbc.js.map new file mode 100644 index 000000000..97e151b4e Binary files /dev/null and b/priv/static/static/js/14.cc092634462fd2a4cfbc.js.map differ diff --git a/priv/static/static/js/15.cad89660cbeef1f1f737.js b/priv/static/static/js/15.cad89660cbeef1f1f737.js deleted file mode 100644 index 075046760..000000000 Binary files a/priv/static/static/js/15.cad89660cbeef1f1f737.js and /dev/null differ diff --git a/priv/static/static/js/15.cad89660cbeef1f1f737.js.map b/priv/static/static/js/15.cad89660cbeef1f1f737.js.map deleted file mode 100644 index fe0e2248b..000000000 Binary files a/priv/static/static/js/15.cad89660cbeef1f1f737.js.map and /dev/null differ diff --git a/priv/static/static/js/15.e9ddc5dfd38426398e00.js b/priv/static/static/js/15.e9ddc5dfd38426398e00.js new file mode 100644 index 000000000..f03e74897 Binary files /dev/null and b/priv/static/static/js/15.e9ddc5dfd38426398e00.js differ diff --git a/priv/static/static/js/15.e9ddc5dfd38426398e00.js.map b/priv/static/static/js/15.e9ddc5dfd38426398e00.js.map new file mode 100644 index 000000000..6c0c32949 Binary files /dev/null and b/priv/static/static/js/15.e9ddc5dfd38426398e00.js.map differ diff --git a/priv/static/static/js/16.0f8c0529208576f8d8f1.js b/priv/static/static/js/16.0f8c0529208576f8d8f1.js deleted file mode 100644 index 896e258ea..000000000 Binary files a/priv/static/static/js/16.0f8c0529208576f8d8f1.js and /dev/null differ diff --git a/priv/static/static/js/16.0f8c0529208576f8d8f1.js.map b/priv/static/static/js/16.0f8c0529208576f8d8f1.js.map deleted file mode 100644 index 67396d925..000000000 Binary files a/priv/static/static/js/16.0f8c0529208576f8d8f1.js.map and /dev/null differ diff --git a/priv/static/static/js/16.476e7809b8593264469e.js b/priv/static/static/js/16.476e7809b8593264469e.js new file mode 100644 index 000000000..2cd6c9c3e Binary files /dev/null and b/priv/static/static/js/16.476e7809b8593264469e.js differ diff --git a/priv/static/static/js/16.476e7809b8593264469e.js.map b/priv/static/static/js/16.476e7809b8593264469e.js.map new file mode 100644 index 000000000..b62e1e0f4 Binary files /dev/null and b/priv/static/static/js/16.476e7809b8593264469e.js.map differ diff --git a/priv/static/static/js/17.102667c39eaf1f3da16f.js b/priv/static/static/js/17.102667c39eaf1f3da16f.js deleted file mode 100644 index 26fae6b3a..000000000 Binary files a/priv/static/static/js/17.102667c39eaf1f3da16f.js and /dev/null differ diff --git a/priv/static/static/js/17.102667c39eaf1f3da16f.js.map b/priv/static/static/js/17.102667c39eaf1f3da16f.js.map deleted file mode 100644 index 778feac3a..000000000 Binary files a/priv/static/static/js/17.102667c39eaf1f3da16f.js.map and /dev/null differ diff --git a/priv/static/static/js/17.acbe4c09f05ae56c76a2.js b/priv/static/static/js/17.acbe4c09f05ae56c76a2.js new file mode 100644 index 000000000..8e4d6181e Binary files /dev/null and b/priv/static/static/js/17.acbe4c09f05ae56c76a2.js differ diff --git a/priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map b/priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map new file mode 100644 index 000000000..92bc141e5 Binary files /dev/null and b/priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map differ diff --git a/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js b/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js deleted file mode 100644 index 89f4b767f..000000000 Binary files a/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js and /dev/null differ diff --git a/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map b/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map deleted file mode 100644 index 19ec95cb2..000000000 Binary files a/priv/static/static/js/18.0a9dfc8a06dfcc8f0e29.js.map and /dev/null differ diff --git a/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js b/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js new file mode 100644 index 000000000..d52319d30 Binary files /dev/null and b/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js differ diff --git a/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map b/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map new file mode 100644 index 000000000..e751cf19c Binary files /dev/null and b/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map differ diff --git a/priv/static/static/js/19.031e07a59c2ec00e163f.js b/priv/static/static/js/19.031e07a59c2ec00e163f.js deleted file mode 100644 index 6cc262c5e..000000000 Binary files a/priv/static/static/js/19.031e07a59c2ec00e163f.js and /dev/null differ diff --git a/priv/static/static/js/19.031e07a59c2ec00e163f.js.map b/priv/static/static/js/19.031e07a59c2ec00e163f.js.map deleted file mode 100644 index 7773510cf..000000000 Binary files a/priv/static/static/js/19.031e07a59c2ec00e163f.js.map and /dev/null differ diff --git a/priv/static/static/js/19.5894e9c12b4fd5e45872.js b/priv/static/static/js/19.5894e9c12b4fd5e45872.js new file mode 100644 index 000000000..f30cebacf Binary files /dev/null and b/priv/static/static/js/19.5894e9c12b4fd5e45872.js differ diff --git a/priv/static/static/js/19.5894e9c12b4fd5e45872.js.map b/priv/static/static/js/19.5894e9c12b4fd5e45872.js.map new file mode 100644 index 000000000..3e00e0045 Binary files /dev/null and b/priv/static/static/js/19.5894e9c12b4fd5e45872.js.map differ diff --git a/priv/static/static/js/2.ca205c0a35e5f6a21711.js b/priv/static/static/js/2.ca205c0a35e5f6a21711.js deleted file mode 100644 index b7dc1dd25..000000000 Binary files a/priv/static/static/js/2.ca205c0a35e5f6a21711.js and /dev/null differ diff --git a/priv/static/static/js/2.ca205c0a35e5f6a21711.js.map b/priv/static/static/js/2.ca205c0a35e5f6a21711.js.map deleted file mode 100644 index 7bcb25f26..000000000 Binary files a/priv/static/static/js/2.ca205c0a35e5f6a21711.js.map and /dev/null differ diff --git a/priv/static/static/js/2.f8dee9318a6f84ea92c3.js b/priv/static/static/js/2.f8dee9318a6f84ea92c3.js new file mode 100644 index 000000000..b9f190615 Binary files /dev/null and b/priv/static/static/js/2.f8dee9318a6f84ea92c3.js differ diff --git a/priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map b/priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map new file mode 100644 index 000000000..8f4e8920a Binary files /dev/null and b/priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map differ diff --git a/priv/static/static/js/20.4211860717a159173685.js b/priv/static/static/js/20.4211860717a159173685.js deleted file mode 100644 index e1a75a1d5..000000000 Binary files a/priv/static/static/js/20.4211860717a159173685.js and /dev/null differ diff --git a/priv/static/static/js/20.4211860717a159173685.js.map b/priv/static/static/js/20.4211860717a159173685.js.map deleted file mode 100644 index 2b7e634db..000000000 Binary files a/priv/static/static/js/20.4211860717a159173685.js.map and /dev/null differ diff --git a/priv/static/static/js/20.43b5b27b0f68474f3b72.js b/priv/static/static/js/20.43b5b27b0f68474f3b72.js new file mode 100644 index 000000000..2b2b5bf60 Binary files /dev/null and b/priv/static/static/js/20.43b5b27b0f68474f3b72.js differ diff --git a/priv/static/static/js/20.43b5b27b0f68474f3b72.js.map b/priv/static/static/js/20.43b5b27b0f68474f3b72.js.map new file mode 100644 index 000000000..224627821 Binary files /dev/null and b/priv/static/static/js/20.43b5b27b0f68474f3b72.js.map differ diff --git a/priv/static/static/js/21.72b45b01be9d0f4c62ce.js b/priv/static/static/js/21.72b45b01be9d0f4c62ce.js new file mode 100644 index 000000000..87292772b Binary files /dev/null and b/priv/static/static/js/21.72b45b01be9d0f4c62ce.js differ diff --git a/priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map b/priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map new file mode 100644 index 000000000..f7c2b5352 Binary files /dev/null and b/priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map differ diff --git a/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js b/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js deleted file mode 100644 index 9b07a0d14..000000000 Binary files a/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js and /dev/null differ diff --git a/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map b/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map deleted file mode 100644 index f100b694c..000000000 Binary files a/priv/static/static/js/21.f1d1ea794ca98abd7c8f.js.map and /dev/null differ diff --git a/priv/static/static/js/22.26f13a22ad57a0d14670.js b/priv/static/static/js/22.26f13a22ad57a0d14670.js new file mode 100644 index 000000000..a12b55b1f Binary files /dev/null and b/priv/static/static/js/22.26f13a22ad57a0d14670.js differ diff --git a/priv/static/static/js/22.26f13a22ad57a0d14670.js.map b/priv/static/static/js/22.26f13a22ad57a0d14670.js.map new file mode 100644 index 000000000..fa09661dc Binary files /dev/null and b/priv/static/static/js/22.26f13a22ad57a0d14670.js.map differ diff --git a/priv/static/static/js/22.be0989993d98819df69a.js b/priv/static/static/js/22.be0989993d98819df69a.js deleted file mode 100644 index a316ea486..000000000 Binary files a/priv/static/static/js/22.be0989993d98819df69a.js and /dev/null differ diff --git a/priv/static/static/js/22.be0989993d98819df69a.js.map b/priv/static/static/js/22.be0989993d98819df69a.js.map deleted file mode 100644 index efc93ddb4..000000000 Binary files a/priv/static/static/js/22.be0989993d98819df69a.js.map and /dev/null differ diff --git a/priv/static/static/js/23.353fb2474276b7d9d8ab.js b/priv/static/static/js/23.353fb2474276b7d9d8ab.js deleted file mode 100644 index 6e01d740b..000000000 Binary files a/priv/static/static/js/23.353fb2474276b7d9d8ab.js and /dev/null differ diff --git a/priv/static/static/js/23.353fb2474276b7d9d8ab.js.map b/priv/static/static/js/23.353fb2474276b7d9d8ab.js.map deleted file mode 100644 index 8b5a39727..000000000 Binary files a/priv/static/static/js/23.353fb2474276b7d9d8ab.js.map and /dev/null differ diff --git a/priv/static/static/js/23.91a60b775352a806f887.js b/priv/static/static/js/23.91a60b775352a806f887.js new file mode 100644 index 000000000..c4f18071c Binary files /dev/null and b/priv/static/static/js/23.91a60b775352a806f887.js differ diff --git a/priv/static/static/js/23.91a60b775352a806f887.js.map b/priv/static/static/js/23.91a60b775352a806f887.js.map new file mode 100644 index 000000000..656b87b51 Binary files /dev/null and b/priv/static/static/js/23.91a60b775352a806f887.js.map differ diff --git a/priv/static/static/js/24.222c48387222e8bc7c84.js b/priv/static/static/js/24.222c48387222e8bc7c84.js deleted file mode 100644 index 3f04dfbb9..000000000 Binary files a/priv/static/static/js/24.222c48387222e8bc7c84.js and /dev/null differ diff --git a/priv/static/static/js/24.222c48387222e8bc7c84.js.map b/priv/static/static/js/24.222c48387222e8bc7c84.js.map deleted file mode 100644 index 86a3d5c52..000000000 Binary files a/priv/static/static/js/24.222c48387222e8bc7c84.js.map and /dev/null differ diff --git a/priv/static/static/js/24.c8d8438aac954d4707ac.js b/priv/static/static/js/24.c8d8438aac954d4707ac.js new file mode 100644 index 000000000..0029d5b8a Binary files /dev/null and b/priv/static/static/js/24.c8d8438aac954d4707ac.js differ diff --git a/priv/static/static/js/24.c8d8438aac954d4707ac.js.map b/priv/static/static/js/24.c8d8438aac954d4707ac.js.map new file mode 100644 index 000000000..1a2bb1dfd Binary files /dev/null and b/priv/static/static/js/24.c8d8438aac954d4707ac.js.map differ diff --git a/priv/static/static/js/25.59d04b82ff45f25b44ef.js b/priv/static/static/js/25.59d04b82ff45f25b44ef.js deleted file mode 100644 index f778b411e..000000000 Binary files a/priv/static/static/js/25.59d04b82ff45f25b44ef.js and /dev/null differ diff --git a/priv/static/static/js/25.59d04b82ff45f25b44ef.js.map b/priv/static/static/js/25.59d04b82ff45f25b44ef.js.map deleted file mode 100644 index 43e3eaae5..000000000 Binary files a/priv/static/static/js/25.59d04b82ff45f25b44ef.js.map and /dev/null differ diff --git a/priv/static/static/js/25.79ac9e020d571b67f02a.js b/priv/static/static/js/25.79ac9e020d571b67f02a.js new file mode 100644 index 000000000..7798e9e7e Binary files /dev/null and b/priv/static/static/js/25.79ac9e020d571b67f02a.js differ diff --git a/priv/static/static/js/25.79ac9e020d571b67f02a.js.map b/priv/static/static/js/25.79ac9e020d571b67f02a.js.map new file mode 100644 index 000000000..5cd7d6b0c Binary files /dev/null and b/priv/static/static/js/25.79ac9e020d571b67f02a.js.map differ diff --git a/priv/static/static/js/26.3af8f54349f672f2c7c8.js b/priv/static/static/js/26.3af8f54349f672f2c7c8.js new file mode 100644 index 000000000..ea37ad7d1 Binary files /dev/null and b/priv/static/static/js/26.3af8f54349f672f2c7c8.js differ diff --git a/priv/static/static/js/26.3af8f54349f672f2c7c8.js.map b/priv/static/static/js/26.3af8f54349f672f2c7c8.js.map new file mode 100644 index 000000000..b30d820f8 Binary files /dev/null and b/priv/static/static/js/26.3af8f54349f672f2c7c8.js.map differ diff --git a/priv/static/static/js/26.d4910001c228c31abe61.js b/priv/static/static/js/26.d4910001c228c31abe61.js deleted file mode 100644 index b479fe465..000000000 Binary files a/priv/static/static/js/26.d4910001c228c31abe61.js and /dev/null differ diff --git a/priv/static/static/js/26.d4910001c228c31abe61.js.map b/priv/static/static/js/26.d4910001c228c31abe61.js.map deleted file mode 100644 index 507d16f44..000000000 Binary files a/priv/static/static/js/26.d4910001c228c31abe61.js.map and /dev/null differ diff --git a/priv/static/static/js/27.51287d408313da67b0b8.js b/priv/static/static/js/27.51287d408313da67b0b8.js new file mode 100644 index 000000000..bbed0b854 Binary files /dev/null and b/priv/static/static/js/27.51287d408313da67b0b8.js differ diff --git a/priv/static/static/js/27.51287d408313da67b0b8.js.map b/priv/static/static/js/27.51287d408313da67b0b8.js.map new file mode 100644 index 000000000..074c63e2e Binary files /dev/null and b/priv/static/static/js/27.51287d408313da67b0b8.js.map differ diff --git a/priv/static/static/js/27.68d319e0867f9e35d5d3.js b/priv/static/static/js/27.68d319e0867f9e35d5d3.js deleted file mode 100644 index 5e8606a5b..000000000 Binary files a/priv/static/static/js/27.68d319e0867f9e35d5d3.js and /dev/null differ diff --git a/priv/static/static/js/27.68d319e0867f9e35d5d3.js.map b/priv/static/static/js/27.68d319e0867f9e35d5d3.js.map deleted file mode 100644 index 5221aadf3..000000000 Binary files a/priv/static/static/js/27.68d319e0867f9e35d5d3.js.map and /dev/null differ diff --git a/priv/static/static/js/28.580f1c09759e4dabced9.js b/priv/static/static/js/28.580f1c09759e4dabced9.js deleted file mode 100644 index c524c023a..000000000 Binary files a/priv/static/static/js/28.580f1c09759e4dabced9.js and /dev/null differ diff --git a/priv/static/static/js/28.580f1c09759e4dabced9.js.map b/priv/static/static/js/28.580f1c09759e4dabced9.js.map deleted file mode 100644 index 5aa14985c..000000000 Binary files a/priv/static/static/js/28.580f1c09759e4dabced9.js.map and /dev/null differ diff --git a/priv/static/static/js/28.be5118beb1098a81332d.js b/priv/static/static/js/28.be5118beb1098a81332d.js new file mode 100644 index 000000000..30a6546eb Binary files /dev/null and b/priv/static/static/js/28.be5118beb1098a81332d.js differ diff --git a/priv/static/static/js/28.be5118beb1098a81332d.js.map b/priv/static/static/js/28.be5118beb1098a81332d.js.map new file mode 100644 index 000000000..57e1d7124 Binary files /dev/null and b/priv/static/static/js/28.be5118beb1098a81332d.js.map differ diff --git a/priv/static/static/js/29.084f6fb0987d3862d410.js b/priv/static/static/js/29.084f6fb0987d3862d410.js new file mode 100644 index 000000000..0a92f928a Binary files /dev/null and b/priv/static/static/js/29.084f6fb0987d3862d410.js differ diff --git a/priv/static/static/js/29.084f6fb0987d3862d410.js.map b/priv/static/static/js/29.084f6fb0987d3862d410.js.map new file mode 100644 index 000000000..c977b4c84 Binary files /dev/null and b/priv/static/static/js/29.084f6fb0987d3862d410.js.map differ diff --git a/priv/static/static/js/29.ea54402e3fbd16f17eb7.js b/priv/static/static/js/29.ea54402e3fbd16f17eb7.js deleted file mode 100644 index cb6e468b2..000000000 Binary files a/priv/static/static/js/29.ea54402e3fbd16f17eb7.js and /dev/null differ diff --git a/priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map b/priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map deleted file mode 100644 index e6f6b21c4..000000000 Binary files a/priv/static/static/js/29.ea54402e3fbd16f17eb7.js.map and /dev/null differ diff --git a/priv/static/static/js/3.23de974e1235c91ea803.js b/priv/static/static/js/3.23de974e1235c91ea803.js deleted file mode 100644 index 84044f051..000000000 Binary files a/priv/static/static/js/3.23de974e1235c91ea803.js and /dev/null differ diff --git a/priv/static/static/js/3.23de974e1235c91ea803.js.map b/priv/static/static/js/3.23de974e1235c91ea803.js.map deleted file mode 100644 index 880fe7abb..000000000 Binary files a/priv/static/static/js/3.23de974e1235c91ea803.js.map and /dev/null differ diff --git a/priv/static/static/js/3.e1f7d368d5840e12e850.js b/priv/static/static/js/3.e1f7d368d5840e12e850.js new file mode 100644 index 000000000..18212aa8f Binary files /dev/null and b/priv/static/static/js/3.e1f7d368d5840e12e850.js differ diff --git a/priv/static/static/js/3.e1f7d368d5840e12e850.js.map b/priv/static/static/js/3.e1f7d368d5840e12e850.js.map new file mode 100644 index 000000000..1d1dd7f3f Binary files /dev/null and b/priv/static/static/js/3.e1f7d368d5840e12e850.js.map differ diff --git a/priv/static/static/js/30.6e6d63411def2e175d11.js b/priv/static/static/js/30.6e6d63411def2e175d11.js new file mode 100644 index 000000000..df379aaa7 Binary files /dev/null and b/priv/static/static/js/30.6e6d63411def2e175d11.js differ diff --git a/priv/static/static/js/30.6e6d63411def2e175d11.js.map b/priv/static/static/js/30.6e6d63411def2e175d11.js.map new file mode 100644 index 000000000..ebd9270dc Binary files /dev/null and b/priv/static/static/js/30.6e6d63411def2e175d11.js.map differ diff --git a/priv/static/static/js/30.b657503bf18858a9b282.js b/priv/static/static/js/30.b657503bf18858a9b282.js deleted file mode 100644 index 256a37f17..000000000 Binary files a/priv/static/static/js/30.b657503bf18858a9b282.js and /dev/null differ diff --git a/priv/static/static/js/30.b657503bf18858a9b282.js.map b/priv/static/static/js/30.b657503bf18858a9b282.js.map deleted file mode 100644 index fbbedf45a..000000000 Binary files a/priv/static/static/js/30.b657503bf18858a9b282.js.map and /dev/null differ diff --git a/priv/static/static/js/4.4fe9f0677ec54321f659.js b/priv/static/static/js/4.4fe9f0677ec54321f659.js deleted file mode 100644 index 7b1cf6cb1..000000000 Binary files a/priv/static/static/js/4.4fe9f0677ec54321f659.js and /dev/null differ diff --git a/priv/static/static/js/4.4fe9f0677ec54321f659.js.map b/priv/static/static/js/4.4fe9f0677ec54321f659.js.map deleted file mode 100644 index d0f07cf83..000000000 Binary files a/priv/static/static/js/4.4fe9f0677ec54321f659.js.map and /dev/null differ diff --git a/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js b/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js new file mode 100644 index 000000000..98ea02539 Binary files /dev/null and b/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js differ diff --git a/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map b/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map new file mode 100644 index 000000000..261abbb00 Binary files /dev/null and b/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map differ diff --git a/priv/static/static/js/5.74ace591a96fca58ee48.js b/priv/static/static/js/5.74ace591a96fca58ee48.js deleted file mode 100644 index 6724317eb..000000000 Binary files a/priv/static/static/js/5.74ace591a96fca58ee48.js and /dev/null differ diff --git a/priv/static/static/js/5.74ace591a96fca58ee48.js.map b/priv/static/static/js/5.74ace591a96fca58ee48.js.map deleted file mode 100644 index 915549af2..000000000 Binary files a/priv/static/static/js/5.74ace591a96fca58ee48.js.map and /dev/null differ diff --git a/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js b/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js new file mode 100644 index 000000000..ce3eb2018 Binary files /dev/null and b/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js differ diff --git a/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map b/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map new file mode 100644 index 000000000..1eb455744 Binary files /dev/null and b/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map differ diff --git a/priv/static/static/js/6.67ff41bfc9476902b9de.js b/priv/static/static/js/6.67ff41bfc9476902b9de.js deleted file mode 100644 index a9fbe6a15..000000000 Binary files a/priv/static/static/js/6.67ff41bfc9476902b9de.js and /dev/null differ diff --git a/priv/static/static/js/6.67ff41bfc9476902b9de.js.map b/priv/static/static/js/6.67ff41bfc9476902b9de.js.map deleted file mode 100644 index 40632f54a..000000000 Binary files a/priv/static/static/js/6.67ff41bfc9476902b9de.js.map and /dev/null differ diff --git a/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js b/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js new file mode 100644 index 000000000..a80504f6e Binary files /dev/null and b/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js differ diff --git a/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map b/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map new file mode 100644 index 000000000..074cf0fe2 Binary files /dev/null and b/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map differ diff --git a/priv/static/static/js/7.c0d55831c37350a90aee.js b/priv/static/static/js/7.c0d55831c37350a90aee.js deleted file mode 100644 index e69e74788..000000000 Binary files a/priv/static/static/js/7.c0d55831c37350a90aee.js and /dev/null differ diff --git a/priv/static/static/js/7.c0d55831c37350a90aee.js.map b/priv/static/static/js/7.c0d55831c37350a90aee.js.map deleted file mode 100644 index df62653a9..000000000 Binary files a/priv/static/static/js/7.c0d55831c37350a90aee.js.map and /dev/null differ diff --git a/priv/static/static/js/7.d558a086622f668601a6.js b/priv/static/static/js/7.d558a086622f668601a6.js new file mode 100644 index 000000000..c948ae6d1 Binary files /dev/null and b/priv/static/static/js/7.d558a086622f668601a6.js differ diff --git a/priv/static/static/js/7.d558a086622f668601a6.js.map b/priv/static/static/js/7.d558a086622f668601a6.js.map new file mode 100644 index 000000000..cd515dac0 Binary files /dev/null and b/priv/static/static/js/7.d558a086622f668601a6.js.map differ diff --git a/priv/static/static/js/8.615136ce6c34a6b96a29.js b/priv/static/static/js/8.615136ce6c34a6b96a29.js new file mode 100644 index 000000000..255f924d3 Binary files /dev/null and b/priv/static/static/js/8.615136ce6c34a6b96a29.js differ diff --git a/priv/static/static/js/8.615136ce6c34a6b96a29.js.map b/priv/static/static/js/8.615136ce6c34a6b96a29.js.map new file mode 100644 index 000000000..f2620b135 Binary files /dev/null and b/priv/static/static/js/8.615136ce6c34a6b96a29.js.map differ diff --git a/priv/static/static/js/8.83dbefa1dc25a2e61b92.js b/priv/static/static/js/8.83dbefa1dc25a2e61b92.js deleted file mode 100644 index 96417ee38..000000000 Binary files a/priv/static/static/js/8.83dbefa1dc25a2e61b92.js and /dev/null differ diff --git a/priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map b/priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map deleted file mode 100644 index 1c3d977be..000000000 Binary files a/priv/static/static/js/8.83dbefa1dc25a2e61b92.js.map and /dev/null differ diff --git a/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js b/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js deleted file mode 100644 index 2487774ef..000000000 Binary files a/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js and /dev/null differ diff --git a/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map b/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map deleted file mode 100644 index e265af977..000000000 Binary files a/priv/static/static/js/9.aa8acb3e28bf30fdefc7.js.map and /dev/null differ diff --git a/priv/static/static/js/9.ef4eb9703f9aee67515e.js b/priv/static/static/js/9.ef4eb9703f9aee67515e.js new file mode 100644 index 000000000..2d1e741d9 Binary files /dev/null and b/priv/static/static/js/9.ef4eb9703f9aee67515e.js differ diff --git a/priv/static/static/js/9.ef4eb9703f9aee67515e.js.map b/priv/static/static/js/9.ef4eb9703f9aee67515e.js.map new file mode 100644 index 000000000..3491916ca Binary files /dev/null and b/priv/static/static/js/9.ef4eb9703f9aee67515e.js.map differ diff --git a/priv/static/static/js/app.53001fa190f37cf2743e.js b/priv/static/static/js/app.53001fa190f37cf2743e.js new file mode 100644 index 000000000..45f3a8373 Binary files /dev/null and b/priv/static/static/js/app.53001fa190f37cf2743e.js differ diff --git a/priv/static/static/js/app.53001fa190f37cf2743e.js.map b/priv/static/static/js/app.53001fa190f37cf2743e.js.map new file mode 100644 index 000000000..105b669c9 Binary files /dev/null and b/priv/static/static/js/app.53001fa190f37cf2743e.js.map differ diff --git a/priv/static/static/js/app.7db8116851a0fe6eb807.js b/priv/static/static/js/app.7db8116851a0fe6eb807.js deleted file mode 100644 index ce0461c10..000000000 Binary files a/priv/static/static/js/app.7db8116851a0fe6eb807.js and /dev/null differ diff --git a/priv/static/static/js/app.7db8116851a0fe6eb807.js.map b/priv/static/static/js/app.7db8116851a0fe6eb807.js.map deleted file mode 100644 index a7f058c16..000000000 Binary files a/priv/static/static/js/app.7db8116851a0fe6eb807.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js b/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js new file mode 100644 index 000000000..365dc3dc4 Binary files /dev/null and b/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js differ diff --git a/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map b/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map new file mode 100644 index 000000000..da281465a Binary files /dev/null and b/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map differ diff --git a/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js b/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js deleted file mode 100644 index 491dbed0b..000000000 Binary files a/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map b/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map deleted file mode 100644 index 9ad947b26..000000000 Binary files a/priv/static/static/js/vendors~app.fbb3f5304df245971d96.js.map and /dev/null differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 323816ab6..22b99ea22 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index c45ac40a8..55846489e 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ -- cgit v1.2.3 From 18438a9bf0added295b119de2838fcea0f28b701 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 7 Jul 2020 11:21:05 -0500 Subject: Add "Bot" to User Agent to coerce Twitter into serving OGP tags. --- CHANGELOG.md | 1 + lib/pleroma/web/rich_media/parser.ex | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2b54916..be0bf4f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Resolving Peertube accounts with Webfinger - `blob:` urls not being allowed by connect-src CSP - Mastodon API: fix `GET /api/v1/notifications` not returning the full result set +- Rich Media Previews for Twitter links ## [Unreleased (patch)] diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index ef5ead2da..c8a767935 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -86,7 +86,10 @@ defp parse_url(url) do end try do - {:ok, %Tesla.Env{body: html}} = Pleroma.HTTP.get(url, [], adapter: opts) + rich_media_agent = Pleroma.Application.user_agent() <> "; Bot" + + {:ok, %Tesla.Env{body: html}} = + Pleroma.HTTP.get(url, [{"user-agent", rich_media_agent}], adapter: opts) html |> parse_html() -- cgit v1.2.3 From 3e08e7715126ca1f3bfaf7dddf4806e76d9bd993 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 7 Jul 2020 20:37:11 +0300 Subject: [#1895] Made hashtag timeline respect `:restrict_unauthenticated` instance setting. --- docs/configuration/cheatsheet.md | 5 +- .../controllers/timeline_controller.ex | 43 ++++++++----- .../controllers/timeline_controller_test.exs | 74 ++++++++++++++++++++++ 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6b640cebc..1de51e72d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -971,11 +971,11 @@ config :pleroma, :database_config_whitelist, [ ### :restrict_unauthenticated -Restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. +Restrict access for unauthenticated users to timelines (public and federated), user profiles and statuses. * `timelines`: public and federated timelines * `local`: public timeline - * `federated` + * `federated`: federated timeline (includes public timeline) * `profiles`: user profiles * `local` * `remote` @@ -983,6 +983,7 @@ Restrict access for unauthenticated users to timelines (public and federate), us * `local` * `remote` +Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). ## Pleroma.Web.ApiSpec.CastAndValidate diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 4bdd46d7e..4bbb82c23 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -88,21 +88,23 @@ def direct(%{assigns: %{user: user}} = conn, params) do ) end - # GET /api/v1/timelines/public - def public(%{assigns: %{user: user}} = conn, params) do - local_only = params[:local] - - cfg_key = + defp restrict_unauthenticated?(local_only) do + config_key = if local_only do :local else :federated end - restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key]) + Pleroma.Config.get([:restrict_unauthenticated, :timelines, config_key]) + end + + # GET /api/v1/timelines/public + def public(%{assigns: %{user: user}} = conn, params) do + local_only = params[:local] - if restrict? and is_nil(user) do - render_error(conn, :unauthorized, "authorization required for timeline view") + if is_nil(user) and restrict_unauthenticated?(local_only) do + fail_on_bad_auth(conn) else activities = params @@ -123,6 +125,10 @@ def public(%{assigns: %{user: user}} = conn, params) do end end + defp fail_on_bad_auth(conn) do + render_error(conn, :unauthorized, "authorization required for timeline view") + end + defp hashtag_fetching(params, user, local_only) do tags = [params[:tag], params[:any]] @@ -157,15 +163,20 @@ defp hashtag_fetching(params, user, local_only) do # GET /api/v1/timelines/tag/:tag def hashtag(%{assigns: %{user: user}} = conn, params) do local_only = params[:local] - activities = hashtag_fetching(params, user, local_only) - conn - |> add_link_headers(activities, %{"local" => local_only}) - |> render("index.json", - activities: activities, - for: user, - as: :activity - ) + if is_nil(user) and restrict_unauthenticated?(local_only) do + fail_on_bad_auth(conn) + else + activities = hashtag_fetching(params, user, local_only) + + conn + |> add_link_headers(activities, %{"local" => local_only}) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + end end # GET /api/v1/timelines/list/:list_id diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index f069390c1..50e0d783d 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -418,4 +418,78 @@ test "multi-hashtag timeline", %{conn: conn} do assert [status_none] == json_response_and_validate_schema(all_test, :ok) end end + + describe "hashtag timeline handling of :restrict_unauthenticated setting" do + setup do + user = insert(:user) + {:ok, activity1} = CommonAPI.post(user, %{status: "test #tag1"}) + {:ok, _activity2} = CommonAPI.post(user, %{status: "test #tag1"}) + + activity1 + |> Ecto.Changeset.change(%{local: false}) + |> Pleroma.Repo.update() + + base_uri = "/api/v1/timelines/tag/tag1" + error_response = %{"error" => "authorization required for timeline view"} + + %{base_uri: base_uri, error_response: error_response} + end + + defp ensure_authenticated_access(base_uri) do + %{conn: auth_conn} = oauth_access(["read:statuses"]) + + res_conn = get(auth_conn, "#{base_uri}?local=true") + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(auth_conn, "#{base_uri}?local=false") + assert length(json_response(res_conn, 200)) == 2 + end + + test "with `%{local: true, federated: true}`, returns 403 for unauthenticated users", %{ + conn: conn, + base_uri: base_uri, + error_response: error_response + } do + clear_config([:restrict_unauthenticated, :timelines, :local], true) + clear_config([:restrict_unauthenticated, :timelines, :federated], true) + + for local <- [true, false] do + res_conn = get(conn, "#{base_uri}?local=#{local}") + + assert json_response(res_conn, :unauthorized) == error_response + end + + ensure_authenticated_access(base_uri) + end + + test "with `%{local: false, federated: true}`, forbids unauthenticated access to federated timeline", + %{conn: conn, base_uri: base_uri, error_response: error_response} do + clear_config([:restrict_unauthenticated, :timelines, :local], false) + clear_config([:restrict_unauthenticated, :timelines, :federated], true) + + res_conn = get(conn, "#{base_uri}?local=true") + assert length(json_response(res_conn, 200)) == 1 + + res_conn = get(conn, "#{base_uri}?local=false") + assert json_response(res_conn, :unauthorized) == error_response + + ensure_authenticated_access(base_uri) + end + + test "with `%{local: true, federated: false}`, forbids unauthenticated access to public timeline" <> + "(but not to local public activities which are delivered as part of federated timeline)", + %{conn: conn, base_uri: base_uri, error_response: error_response} do + clear_config([:restrict_unauthenticated, :timelines, :local], true) + clear_config([:restrict_unauthenticated, :timelines, :federated], false) + + res_conn = get(conn, "#{base_uri}?local=true") + assert json_response(res_conn, :unauthorized) == error_response + + # Note: local activities get delivered as part of federated timeline + res_conn = get(conn, "#{base_uri}?local=false") + assert length(json_response(res_conn, 200)) == 2 + + ensure_authenticated_access(base_uri) + end + end end -- cgit v1.2.3 From 20461137a37968ee4dc8e3a38f7d9c63714702d3 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 7 Jul 2020 20:44:16 +0300 Subject: [#1895] Documentation hints on private instances and instance/restrict_unauthenticated setting. --- config/description.exs | 5 +++-- docs/configuration/cheatsheet.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/description.exs b/config/description.exs index 650610fbe..705ba83d0 100644 --- a/config/description.exs +++ b/config/description.exs @@ -700,8 +700,9 @@ key: :public, type: :boolean, description: - "Makes the client API in authentificated mode-only except for user-profiles." <> - " Useful for disabling the Local Timeline and The Whole Known Network." + "Makes the client API in authenticated mode-only except for user-profiles." <> + " Useful for disabling the Local Timeline and The Whole Known Network. " <> + " Note: when setting to `false`, please also check `:restrict_unauthenticated` setting." }, %{ key: :quarantined_instances, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 1de51e72d..f6529b940 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -37,7 +37,7 @@ To add configuration to your config file, you can copy it from the base config. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. * `allow_relay`: Enable Pleroma’s Relay, which makes it possible to follow a whole instance. -* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. +* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. See also: `restrict_unauthenticated`. * `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). -- cgit v1.2.3 From f6d09fafee83514889bbcf6531e0bc01e33b0b16 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 1 Mar 2020 09:48:32 +0100 Subject: Add support for remote favicons --- lib/pleroma/user.ex | 30 ++++++++++++++++++++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 3 ++- test/support/http_request_mock.ex | 4 +++ test/web/mastodon_api/views/account_view_test.exs | 2 ++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index e98332744..25ea112a2 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -2253,4 +2253,34 @@ def sanitize_html(%User{} = user, filter) do |> Map.put(:bio, HTML.filter_tags(user.bio, filter)) |> Map.put(:fields, fields) end + + def get_cached_favicon(%User{} = user) do + key = "favicon:#{user.ap_id}" + Cachex.fetch!(:user_cache, key, fn _ -> get_favicon(user) end) + end + + def get_cached_favicon(_user) do + nil + end + + def get_favicon(user) do + try do + with url <- user.ap_id, + true <- is_binary(url), + {:ok, %Tesla.Env{body: html}} <- Pleroma.HTTP.get(url), + favicon_rel <- + html + |> Floki.parse_document!() + |> Floki.attribute("link[rel=icon]", "href") + |> List.first(), + favicon_url <- URI.merge(URI.parse(url), favicon_rel) |> to_string(), + true <- is_binary(favicon_url) do + favicon_url + else + _ -> nil + end + rescue + _ -> nil + end + 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 a6e64b4ab..efe835e3c 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -245,7 +245,8 @@ defp do_render("show.json", %{user: user} = opts) do hide_favorites: user.hide_favorites, relationship: relationship, skip_thread_containment: user.skip_thread_containment, - background_image: image_url(user.background) |> MediaProxy.url() + background_image: image_url(user.background) |> MediaProxy.url(), + favicon: User.get_cached_favicon(user) |> MediaProxy.url() } } |> maybe_put_role(user, opts[:for]) diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index da04ac6f1..4d33c6250 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1342,6 +1342,10 @@ def get("https://relay.mastodon.host/actor", _, _, _) do {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/relay/relay.json")}} end + def get("http://localhost:4001/users/" <> _, _, _, _) do + {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/7369654.html")}} + end + def get(url, query, body, headers) do {:error, "Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{ diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 80b1f734c..e01a7c1ee 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -75,6 +75,7 @@ test "Represent a user account" do pleroma: %{ ap_id: user.ap_id, background_image: "https://example.com/images/asuka_hospital.png", + favicon: nil, confirmation_pending: false, tags: [], is_admin: false, @@ -152,6 +153,7 @@ test "Represent a Service(bot) account" do pleroma: %{ ap_id: user.ap_id, background_image: nil, + favicon: nil, confirmation_pending: false, tags: [], is_admin: false, -- cgit v1.2.3 From 6a679d80c9030afa8327377928f8ac2fcf1a4a0e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 2 Mar 2020 05:38:25 +0100 Subject: Move get_favicon to Pleroma.Instances, use / --- lib/pleroma/application.ex | 1 + lib/pleroma/instances.ex | 28 ++ lib/pleroma/user.ex | 30 -- lib/pleroma/web/mastodon_api/views/account_view.ex | 11 +- .../tesla_mock/https___osada.macgirvin.com.html | 301 +++++++++++++++++++++ test/support/http_request_mock.ex | 10 +- test/web/mastodon_api/views/account_view_test.exs | 6 +- 7 files changed, 353 insertions(+), 34 deletions(-) create mode 100644 test/fixtures/tesla_mock/https___osada.macgirvin.com.html diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9615af122..c7fc95f75 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -150,6 +150,7 @@ defp cachex_children do build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) + build_cachex("instances", default_ttl: 25_000, ttl_interval: 1000, limit: 2500) ] end diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index 557e8decf..c9b1ed4ce 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -37,4 +37,32 @@ def host(url_or_host) when is_binary(url_or_host) do url_or_host end end + + def get_cached_favicon(instance_url) when is_binary(instance_url) do + Cachex.fetch!(:instances_cache, instance_url, fn _ -> get_favicon(instance_url) end) + end + + def get_cached_favicon(_instance_url) do + nil + end + + def get_favicon(instance_url) when is_binary(instance_url) do + try do + with {:ok, %Tesla.Env{body: html}} <- + Pleroma.HTTP.get(instance_url, [{:Accept, "text/html"}]), + favicon_rel <- + html + |> Floki.parse_document!() + |> Floki.attribute("link[rel=icon]", "href") + |> List.first(), + favicon_url <- URI.merge(URI.parse(instance_url), favicon_rel) |> to_string(), + true <- is_binary(favicon_url) do + favicon_url + else + _ -> nil + end + rescue + _ -> nil + end + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 25ea112a2..e98332744 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -2253,34 +2253,4 @@ def sanitize_html(%User{} = user, filter) do |> Map.put(:bio, HTML.filter_tags(user.bio, filter)) |> Map.put(:fields, fields) end - - def get_cached_favicon(%User{} = user) do - key = "favicon:#{user.ap_id}" - Cachex.fetch!(:user_cache, key, fn _ -> get_favicon(user) end) - end - - def get_cached_favicon(_user) do - nil - end - - def get_favicon(user) do - try do - with url <- user.ap_id, - true <- is_binary(url), - {:ok, %Tesla.Env{body: html}} <- Pleroma.HTTP.get(url), - favicon_rel <- - html - |> Floki.parse_document!() - |> Floki.attribute("link[rel=icon]", "href") - |> List.first(), - favicon_url <- URI.merge(URI.parse(url), favicon_rel) |> to_string(), - true <- is_binary(favicon_url) do - favicon_url - else - _ -> nil - end - rescue - _ -> nil - end - 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 efe835e3c..3ee50dfd0 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -204,6 +204,15 @@ defp do_render("show.json", %{user: user} = opts) do %{} end + favicon = + user + |> Map.get(:ap_id, "") + |> URI.parse() + |> URI.merge("/") + |> to_string() + |> Pleroma.Instances.get_cached_favicon() + |> MediaProxy.url() + %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -246,7 +255,7 @@ defp do_render("show.json", %{user: user} = opts) do relationship: relationship, skip_thread_containment: user.skip_thread_containment, background_image: image_url(user.background) |> MediaProxy.url(), - favicon: User.get_cached_favicon(user) |> MediaProxy.url() + favicon: favicon } } |> maybe_put_role(user, opts[:for]) diff --git a/test/fixtures/tesla_mock/https___osada.macgirvin.com.html b/test/fixtures/tesla_mock/https___osada.macgirvin.com.html new file mode 100644 index 000000000..880273d74 --- /dev/null +++ b/test/fixtures/tesla_mock/https___osada.macgirvin.com.html @@ -0,0 +1,301 @@ + + + + Osada + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + +

    Welcome to Osada

    + +
    +
    +
    + + + +
    +
    + + +
    +
    + +
    + +
    + +
    + +
    + Remote Authentication +
    + +
    + + + +
    +
    + +
    +
    + + + + \ No newline at end of file diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 4d33c6250..19a202654 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1342,10 +1342,18 @@ def get("https://relay.mastodon.host/actor", _, _, _) do {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/relay/relay.json")}} end - def get("http://localhost:4001/users/" <> _, _, _, _) do + def get("http://localhost:4001/", _, "", Accept: "text/html") do {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/7369654.html")}} end + def get("https://osada.macgirvin.com/", _, "", Accept: "text/html") do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/https___osada.macgirvin.com.html") + }} + end + def get(url, query, body, headers) do {:error, "Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{ diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index e01a7c1ee..c4341cb28 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -75,7 +75,8 @@ test "Represent a user account" do pleroma: %{ ap_id: user.ap_id, background_image: "https://example.com/images/asuka_hospital.png", - favicon: nil, + favicon: + "https://shitposter.club/plugins/Qvitter/img/gnusocial-favicons/favicon-16x16.png", confirmation_pending: false, tags: [], is_admin: false, @@ -153,7 +154,8 @@ test "Represent a Service(bot) account" do pleroma: %{ ap_id: user.ap_id, background_image: nil, - favicon: nil, + favicon: + "https://shitposter.club/plugins/Qvitter/img/gnusocial-favicons/favicon-16x16.png", confirmation_pending: false, tags: [], is_admin: false, -- cgit v1.2.3 From 013e2c505786dff311bcc8bf23631d6a1a1636ef Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 7 Jul 2020 11:13:38 +0200 Subject: Use instances table instead of Cachex --- lib/pleroma/application.ex | 1 - lib/pleroma/instances.ex | 28 ----------- lib/pleroma/instances/instance.ex | 55 +++++++++++++++++++++- lib/pleroma/web/mastodon_api/views/account_view.ex | 3 +- .../20200707112859_instances_add_favicon.exs | 10 ++++ 5 files changed, 65 insertions(+), 32 deletions(-) create mode 100644 priv/repo/migrations/20200707112859_instances_add_favicon.exs diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index c7fc95f75..9615af122 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -150,7 +150,6 @@ defp cachex_children do build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000) - build_cachex("instances", default_ttl: 25_000, ttl_interval: 1000, limit: 2500) ] end diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index c9b1ed4ce..557e8decf 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -37,32 +37,4 @@ def host(url_or_host) when is_binary(url_or_host) do url_or_host end end - - def get_cached_favicon(instance_url) when is_binary(instance_url) do - Cachex.fetch!(:instances_cache, instance_url, fn _ -> get_favicon(instance_url) end) - end - - def get_cached_favicon(_instance_url) do - nil - end - - def get_favicon(instance_url) when is_binary(instance_url) do - try do - with {:ok, %Tesla.Env{body: html}} <- - Pleroma.HTTP.get(instance_url, [{:Accept, "text/html"}]), - favicon_rel <- - html - |> Floki.parse_document!() - |> Floki.attribute("link[rel=icon]", "href") - |> List.first(), - favicon_url <- URI.merge(URI.parse(instance_url), favicon_rel) |> to_string(), - true <- is_binary(favicon_url) do - favicon_url - else - _ -> nil - end - rescue - _ -> nil - end - end end diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 74458c09a..b97e229e5 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -17,6 +17,8 @@ defmodule Pleroma.Instances.Instance do schema "instances" do field(:host, :string) field(:unreachable_since, :naive_datetime_usec) + field(:favicon, :string) + field(:favicon_updated_at, :naive_datetime) timestamps() end @@ -25,7 +27,7 @@ defmodule Pleroma.Instances.Instance do def changeset(struct, params \\ %{}) do struct - |> cast(params, [:host, :unreachable_since]) + |> cast(params, [:host, :unreachable_since, :favicon, :favicon_updated_at]) |> validate_required([:host]) |> unique_constraint(:host) end @@ -120,4 +122,55 @@ defp parse_datetime(datetime) when is_binary(datetime) do end defp parse_datetime(datetime), do: datetime + + def get_or_update_favicon(%URI{host: host} = instance_uri) do + existing_record = Repo.get_by(Instance, %{host: host}) + now = NaiveDateTime.utc_now() + + if existing_record && existing_record.favicon && + NaiveDateTime.diff(now, existing_record.favicon_updated_at) < 86_400 do + existing_record.favicon + else + favicon = scrape_favicon(instance_uri) + + cond do + is_binary(favicon) && existing_record -> + existing_record + |> changeset(%{favicon: favicon, favicon_updated_at: now}) + |> Repo.update() + + favicon + + is_binary(favicon) -> + %Instance{} + |> changeset(%{host: host, favicon: favicon, favicon_updated_at: now}) + |> Repo.insert() + + favicon + + true -> + nil + end + end + end + + defp scrape_favicon(%URI{} = instance_uri) do + try do + with {:ok, %Tesla.Env{body: html}} <- + Pleroma.HTTP.get(to_string(instance_uri), [{:Accept, "text/html"}]), + favicon_rel <- + html + |> Floki.parse_document!() + |> Floki.attribute("link[rel=icon]", "href") + |> List.first(), + favicon <- URI.merge(instance_uri, favicon_rel) |> to_string(), + true <- is_binary(favicon) do + favicon + else + _ -> nil + end + rescue + _ -> nil + end + 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 3ee50dfd0..db5739254 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -209,8 +209,7 @@ defp do_render("show.json", %{user: user} = opts) do |> Map.get(:ap_id, "") |> URI.parse() |> URI.merge("/") - |> to_string() - |> Pleroma.Instances.get_cached_favicon() + |> Pleroma.Instances.Instance.get_or_update_favicon() |> MediaProxy.url() %{ diff --git a/priv/repo/migrations/20200707112859_instances_add_favicon.exs b/priv/repo/migrations/20200707112859_instances_add_favicon.exs new file mode 100644 index 000000000..5538749dc --- /dev/null +++ b/priv/repo/migrations/20200707112859_instances_add_favicon.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.InstancesAddFavicon do + use Ecto.Migration + + def change do + alter table(:instances) do + add(:favicon, :string) + add(:favicon_updated_at, :naive_datetime) + end + end +end -- cgit v1.2.3 From 8c9df2d2e6a5a3639f6b411cd3e9b57248a0650c Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 7 Jul 2020 12:07:30 +0200 Subject: instance: Prevent loop of updates --- lib/pleroma/instances/instance.ex | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index b97e229e5..a1f935232 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -127,30 +127,23 @@ def get_or_update_favicon(%URI{host: host} = instance_uri) do existing_record = Repo.get_by(Instance, %{host: host}) now = NaiveDateTime.utc_now() - if existing_record && existing_record.favicon && + if existing_record && existing_record.favicon_updated_at && NaiveDateTime.diff(now, existing_record.favicon_updated_at) < 86_400 do existing_record.favicon else favicon = scrape_favicon(instance_uri) - cond do - is_binary(favicon) && existing_record -> - existing_record - |> changeset(%{favicon: favicon, favicon_updated_at: now}) - |> Repo.update() - - favicon - - is_binary(favicon) -> - %Instance{} - |> changeset(%{host: host, favicon: favicon, favicon_updated_at: now}) - |> Repo.insert() - - favicon - - true -> - nil + if existing_record do + existing_record + |> changeset(%{favicon: favicon, favicon_updated_at: now}) + |> Repo.update() + else + %Instance{} + |> changeset(%{host: host, favicon: favicon, favicon_updated_at: now}) + |> Repo.insert() end + + favicon end end -- cgit v1.2.3 From 312fc55f14e1b7f88ec43b72c577bf5df595beac Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 8 Jul 2020 05:56:24 +0200 Subject: Add [:instances_favicons, :enabled] setting, defaults to false --- config/config.exs | 2 ++ config/description.exs | 13 +++++++++++++ config/test.exs | 2 ++ docs/configuration/cheatsheet.md | 6 ++++++ lib/pleroma/web/mastodon_api/views/account_view.ex | 16 ++++++++++------ test/web/mastodon_api/views/account_view_test.exs | 20 ++++++++++++++++++++ 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/config/config.exs b/config/config.exs index 458d3a99a..3577cd101 100644 --- a/config/config.exs +++ b/config/config.exs @@ -706,6 +706,8 @@ config :ex_aws, http_client: Pleroma.HTTP.ExAws +config :pleroma, :instances_favicons, enabled: false + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/description.exs b/config/description.exs index 650610fbe..7c432d67d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3447,5 +3447,18 @@ suggestions: [false] } ] + }, + %{ + group: :pleroma, + key: :instances_favicons, + type: :group, + description: "Control favicons for instances", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Allow/disallow displaying and getting instances favicons" + } + ] } ] diff --git a/config/test.exs b/config/test.exs index e38b9967d..e6596e0bc 100644 --- a/config/test.exs +++ b/config/test.exs @@ -111,6 +111,8 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true +config :pleroma, :instances_favicons, enabled: true + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6b640cebc..7a3200e01 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -987,3 +987,9 @@ Restrict access for unauthenticated users to timelines (public and federate), us ## Pleroma.Web.ApiSpec.CastAndValidate * `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`. + +## :instances_favicons + +Control favicons for instances. + +* `enabled`: Allow/disallow displaying and getting instances favicons diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index db5739254..2feba4778 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -205,12 +205,16 @@ defp do_render("show.json", %{user: user} = opts) do end favicon = - user - |> Map.get(:ap_id, "") - |> URI.parse() - |> URI.merge("/") - |> Pleroma.Instances.Instance.get_or_update_favicon() - |> MediaProxy.url() + if Pleroma.Config.get([:instances_favicons, :enabled]) do + user + |> Map.get(:ap_id, "") + |> URI.parse() + |> URI.merge("/") + |> Pleroma.Instances.Instance.get_or_update_favicon() + |> MediaProxy.url() + else + nil + end %{ id: to_string(user.id), diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index c4341cb28..ac6d50e3a 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -5,6 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do use Pleroma.DataCase + alias Pleroma.Config alias Pleroma.User alias Pleroma.UserRelationship alias Pleroma.Web.CommonAPI @@ -18,6 +19,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do :ok end + setup do: clear_config([:instances_favicons, :enabled]) + test "Represent a user account" do background_image = %{ "url" => [%{"href" => "https://example.com/images/asuka_hospital.png"}] @@ -94,6 +97,23 @@ test "Represent a user account" do assert expected == AccountView.render("show.json", %{user: user}) end + test "Favicon is nil when :instances_favicons is disabled" do + user = insert(:user) + + Config.put([:instances_favicons, :enabled], true) + + assert %{ + pleroma: %{ + favicon: + "https://shitposter.club/plugins/Qvitter/img/gnusocial-favicons/favicon-16x16.png" + } + } = AccountView.render("show.json", %{user: user}) + + Config.put([:instances_favicons, :enabled], false) + + assert %{pleroma: %{favicon: nil}} = AccountView.render("show.json", %{user: user}) + end + test "Represent the user account for the account owner" do user = insert(:user) -- cgit v1.2.3 From 31fef95e35d3cbc2d65c76a57ba8f4047809ce1b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 8 Jul 2020 06:09:39 +0200 Subject: Add changelog and documentation --- CHANGELOG.md | 2 ++ docs/API/differences_in_mastoapi_responses.md | 1 + lib/pleroma/web/api_spec/schemas/account.ex | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d8c3d8e..0f3447069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added `:reject_deletes` group to SimplePolicy - MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances - Support pagination in emoji packs API (for packs and for files in pack) +- Support for viewing instances favicons next to posts and accounts
    API Changes @@ -65,6 +66,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Add support for filtering replies in public and home timelines. - Mastodon API: Support for `bot` field in `/api/v1/accounts/update_credentials`. - Mastodon API: Support irreversible property for filters. +- Mastodon API: Add pleroma.favicon field to accounts. - Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoint for status view. - OTP: Add command to reload emoji packs diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 29141ed0c..03c7f4608 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -71,6 +71,7 @@ Has these additional fields under the `pleroma` object: - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. - `unread_notifications_count`: The count of unread notifications. Only returned to the account owner. - `notification_settings`: object, can be absent. See `/api/pleroma/notification_settings` for the parameters/keys returned. +- `favicon`: nullable URL string, Favicon image of the user's instance ### Source diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 84f18f1b6..e6f163cb7 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -102,6 +102,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do type: :object, description: "A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials`" + }, + favicon: %Schema{ + type: :string, + format: :uri, + nullable: true, + description: "Favicon image of the user's instance" } } }, -- cgit v1.2.3 From e341f817850e51a29ec45607563323b6660f8da4 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 7 Jul 2020 09:10:02 +0300 Subject: fixed delete `Like` activity in remove user --- lib/pleroma/web/activity_pub/side_effects.ex | 21 ++++++++++++++++----- test/tasks/user_test.exs | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 61feeae4d..70746f341 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -209,14 +209,20 @@ def handle_object_creation(object) do {:ok, object} end - def handle_undoing(%{data: %{"type" => "Like"}} = object) do - with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]), - {:ok, _} <- Utils.remove_like_from_object(object, liked_object), - {:ok, _} <- Repo.delete(object) do - :ok + defp undo_like(nil, object), do: delete_object(object) + + defp undo_like(%Object{} = liked_object, object) do + with {:ok, _} <- Utils.remove_like_from_object(object, liked_object) do + delete_object(object) end end + def handle_undoing(%{data: %{"type" => "Like"}} = object) do + object.data["object"] + |> Object.get_by_ap_id() + |> undo_like(object) + end + def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]), {:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object), @@ -246,6 +252,11 @@ def handle_undoing( def handle_undoing(object), do: {:error, ["don't know how to handle", object]} + @spec delete_object(Object.t()) :: :ok | {:error, Ecto.Changeset.t()} + defp delete_object(object) do + with {:ok, _} <- Repo.delete(object), do: :ok + end + defp send_notifications(meta) do Keyword.get(meta, :notifications, []) |> Enum.each(fn notification -> diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 9220d23fc..c962819db 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -110,7 +110,30 @@ test "user is deleted" do test "a remote user's create activity is deleted when the object has been pruned" do user = insert(:user) + user2 = insert(:user) + {:ok, post} = CommonAPI.post(user, %{status: "uguu"}) + {:ok, post2} = CommonAPI.post(user2, %{status: "test"}) + obj = Object.normalize(post2) + + {:ok, like_object, meta} = Pleroma.Web.ActivityPub.Builder.like(user, obj) + + {:ok, like_activity, _meta} = + Pleroma.Web.ActivityPub.Pipeline.common_pipeline( + like_object, + Keyword.put(meta, :local, true) + ) + + like_obj = Pleroma.Object.get_by_ap_id(like_activity.data["object"]) + + data = + Map.merge(like_activity.data, %{"object" => "tag:gnusocial.cc,2019-01-09:noticeId=210716"}) + + like_activity + |> Ecto.Changeset.change(data: data) + |> Repo.update() + + Repo.delete(like_obj) clear_config([:instance, :federating], true) @@ -127,6 +150,7 @@ test "a remote user's create activity is deleted when the object has been pruned assert %{deactivated: true} = User.get_by_nickname(user.nickname) assert called(Pleroma.Web.Federator.publish(:_)) + refute Pleroma.Repo.get(Pleroma.Activity, like_activity.id) end refute Activity.get_by_id(post.id) -- cgit v1.2.3 From 3f8370a285d1ee705e4899656b88c442c50cb99b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 8 Jul 2020 12:36:44 +0300 Subject: [#1895] Applied code review suggestion. --- .../web/mastodon_api/controllers/timeline_controller.ex | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 4bbb82c23..a23f47e54 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -88,15 +88,12 @@ def direct(%{assigns: %{user: user}} = conn, params) do ) end - defp restrict_unauthenticated?(local_only) do - config_key = - if local_only do - :local - else - :federated - end - - Pleroma.Config.get([:restrict_unauthenticated, :timelines, config_key]) + defp restrict_unauthenticated?(_local_only = true) do + Pleroma.Config.get([:restrict_unauthenticated, :timelines, :local]) + end + + defp restrict_unauthenticated?(_) do + Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated]) end # GET /api/v1/timelines/public -- cgit v1.2.3 From c0385cf47ae9c2dac527387225dee7d45dd33d8c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Jul 2020 11:52:29 +0200 Subject: AccountController: Fix muting / unmuting reblogs. --- .../web/api_spec/operations/account_operation.ex | 23 ++++++++++---- .../mastodon_api/controllers/account_controller.ex | 2 +- .../controllers/account_controller_test.exs | 37 ++++++++++++++++++++-- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9bde8fc0d..989bab122 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -203,14 +203,23 @@ def follow_operation do security: [%{"oAuth" => ["follow", "write:follows"]}], description: "Follow the given account", parameters: [ - %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, - Operation.parameter( - :reblogs, - :query, - BooleanLike, - "Receive this account's reblogs in home timeline? Defaults to true." - ) + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} ], + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + reblogs: %Schema{ + type: :boolean, + description: "Receive this account's reblogs in home timeline? Defaults to true.", + default: true + } + } + }, + required: false + ), responses: %{ 200 => Operation.response("Relationship", "application/json", AccountRelationship), 400 => Operation.response("Error", "application/json", ApiError), diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index d4532258c..fd89faf02 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -353,7 +353,7 @@ def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, "Can not follow yourself"} end - def follow(%{assigns: %{user: follower, account: followed}} = conn, params) do + def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do render(conn, "relationship.json", user: follower, target: followed) else diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 260ad2306..f102c0cd2 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -708,7 +708,10 @@ test "following without reblogs" do followed = insert(:user) other_user = insert(:user) - ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow?reblogs=false") + ret_conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/accounts/#{followed.id}/follow", %{reblogs: false}) assert %{"showing_reblogs" => false} = json_response_and_validate_schema(ret_conn, 200) @@ -722,7 +725,8 @@ test "following without reblogs" do assert %{"showing_reblogs" => true} = conn - |> post("/api/v1/accounts/#{followed.id}/follow?reblogs=true") + |> put_req_header("content-type", "application/json") + |> post("/api/v1/accounts/#{followed.id}/follow", %{reblogs: true}) |> json_response_and_validate_schema(200) assert [%{"id" => ^reblog_id}] = @@ -731,6 +735,35 @@ test "following without reblogs" do |> json_response(200) end + test "following with reblogs" do + %{conn: conn} = oauth_access(["follow", "read:statuses"]) + followed = insert(:user) + other_user = insert(:user) + + ret_conn = post(conn, "/api/v1/accounts/#{followed.id}/follow") + + assert %{"showing_reblogs" => true} = json_response_and_validate_schema(ret_conn, 200) + + {:ok, activity} = CommonAPI.post(other_user, %{status: "hey"}) + {:ok, %{id: reblog_id}} = CommonAPI.repeat(activity.id, followed) + + assert [%{"id" => ^reblog_id}] = + conn + |> get("/api/v1/timelines/home") + |> json_response(200) + + assert %{"showing_reblogs" => false} = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/accounts/#{followed.id}/follow", %{reblogs: false}) + |> json_response_and_validate_schema(200) + + assert [] == + conn + |> get("/api/v1/timelines/home") + |> json_response(200) + end + test "following / unfollowing errors", %{user: user, conn: conn} do # self follow conn_res = post(conn, "/api/v1/accounts/#{user.id}/follow") -- cgit v1.2.3 From a6495f4a686feb3d4728432dd075f425c04c32dd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 8 Jul 2020 12:54:23 +0300 Subject: [#1895] credo fix. --- lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index a23f47e54..ab7b1d6aa 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -88,7 +88,7 @@ def direct(%{assigns: %{user: user}} = conn, params) do ) end - defp restrict_unauthenticated?(_local_only = true) do + defp restrict_unauthenticated?(true = _local_only) do Pleroma.Config.get([:restrict_unauthenticated, :timelines, :local]) end -- cgit v1.2.3 From 704a3830556d94e0dbc39873480e9ba95a143be9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 8 Jul 2020 13:14:18 +0300 Subject: Improved search results for localized nickname match. Tweaked user search to rank nickname matches higher than name matches. --- lib/pleroma/user/search.ex | 8 +++++++- test/tasks/user_test.exs | 14 +++++++------- test/user_search_test.exs | 35 +++++++++++++++++++++++++++++------ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 42ff1de78..7ff1c7e24 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -88,15 +88,21 @@ defp to_tsquery(query_string) do |> Enum.join(" | ") end + # Considers nickname match, localized nickname match, name match; preferences nickname match defp trigram_rank(query, query_string) do from( u in query, select_merge: %{ search_rank: fragment( - "similarity(?, trim(? || ' ' || coalesce(?, '')))", + "similarity(?, ?) + \ + similarity(?, regexp_replace(?, '@.+', '')) + \ + similarity(?, trim(coalesce(?, '')))", ^query_string, u.nickname, + ^query_string, + u.nickname, + ^query_string, u.name ) } diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 9220d23fc..7bb49b038 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -464,17 +464,17 @@ test "it returns users matching" do moot = insert(:user, nickname: "moot") kawen = insert(:user, nickname: "kawen", name: "fediverse expert moon") - {:ok, user} = User.follow(user, kawen) + {:ok, user} = User.follow(user, moon) assert [moon.id, kawen.id] == User.Search.search("moon") |> Enum.map(& &1.id) + res = User.search("moo") |> Enum.map(& &1.id) - assert moon.id in res - assert moot.id in res - assert kawen.id in res - assert [moon.id, kawen.id] == User.Search.search("moon fediverse") |> Enum.map(& &1.id) + assert Enum.sort([moon.id, moot.id, kawen.id]) == Enum.sort(res) + + assert [kawen.id, moon.id] == User.Search.search("expert fediverse") |> Enum.map(& &1.id) - assert [kawen.id, moon.id] == - User.Search.search("moon fediverse", for_user: user) |> Enum.map(& &1.id) + assert [moon.id, kawen.id] == + User.Search.search("expert fediverse", for_user: user) |> Enum.map(& &1.id) end end diff --git a/test/user_search_test.exs b/test/user_search_test.exs index f030523d3..758822072 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -46,30 +46,53 @@ test "accepts offset parameter" do assert length(User.search("john", limit: 3, offset: 3)) == 2 end - test "finds a user by full or partial nickname" do + defp clear_virtual_fields(user) do + Map.merge(user, %{search_rank: nil, search_type: nil}) + end + + test "finds a user by full nickname or its leading fragment" do user = insert(:user, %{nickname: "john"}) Enum.each(["john", "jo", "j"], fn query -> assert user == User.search(query) |> List.first() - |> Map.put(:search_rank, nil) - |> Map.put(:search_type, nil) + |> clear_virtual_fields() end) end - test "finds a user by full or partial name" do + test "finds a user by full name or leading fragment(s) of its words" do user = insert(:user, %{name: "John Doe"}) Enum.each(["John Doe", "JOHN", "doe", "j d", "j", "d"], fn query -> assert user == User.search(query) |> List.first() - |> Map.put(:search_rank, nil) - |> Map.put(:search_type, nil) + |> clear_virtual_fields() end) end + test "is not [yet] capable of matching by non-leading fragments (e.g. by domain)" do + user1 = insert(:user, %{nickname: "iamthedude"}) + insert(:user, %{nickname: "arandom@dude.com"}) + + assert [] == User.search("dude") + + # Matching by leading fragment works, though + user1_id = user1.id + assert ^user1_id = User.search("iam") |> List.first() |> Map.get(:id) + end + + test "ranks full nickname match higher than full name match" do + nicknamed_user = insert(:user, %{nickname: "hj@shigusegubu.club"}) + named_user = insert(:user, %{nickname: "xyz@sample.com", name: "HJ"}) + + results = User.search("hj") + + assert [nicknamed_user.id, named_user.id] == Enum.map(results, & &1.id) + assert Enum.at(results, 0).search_rank > Enum.at(results, 1).search_rank + end + test "finds users, considering density of matched tokens" do u1 = insert(:user, %{name: "Bar Bar plus Word Word"}) u2 = insert(:user, %{name: "Word Word Bar Bar Bar"}) -- cgit v1.2.3 From 29fa75d00d1f550461b2ab1e59554e134208d419 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Jul 2020 14:29:29 +0200 Subject: Notification: For follows, notify the followed. --- lib/pleroma/notification.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index fcb2144ae..32bcfcaba 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -497,6 +497,10 @@ def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_i end end + def get_potential_receiver_ap_ids(%{data: %{"type" => "Follow", "object" => object_id}}) do + [object_id] + end + def get_potential_receiver_ap_ids(activity) do [] |> Utils.maybe_notify_to_recipients(activity) -- cgit v1.2.3 From 172f4aff8ef573c54902dc8fa135d69f50fea47c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Jul 2020 14:30:53 +0200 Subject: Transmogrifier: Move following to the pipeline. --- .../object_validators/follow_validator.ex | 2 + lib/pleroma/web/activity_pub/side_effects.ex | 60 +++++++++++++++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 62 +--------------------- .../transmogrifier/follow_handling_test.exs | 2 +- 4 files changed, 64 insertions(+), 62 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex index 2035ad9ba..ca2724616 100644 --- a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex @@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator do field(:to, ObjectValidators.Recipients, default: []) field(:cc, ObjectValidators.Recipients, default: []) field(:object, ObjectValidators.ObjectID) + field(:state, :string, default: "pending") end def cast_data(data) do @@ -30,6 +31,7 @@ def validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Follow"]) + |> validate_inclusion(:state, ~w{pending reject accept}) |> validate_actor_presence() |> validate_actor_presence(field_name: :object) end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 61feeae4d..284560913 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Activity.Ir.Topics alias Pleroma.Chat alias Pleroma.Chat.MessageReference + alias Pleroma.FollowingRelationship alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Repo @@ -21,6 +22,65 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(object, meta \\ []) + # Tasks this handle + # - Follows if possible + # - Sends a notification + # - Generates accept or reject if appropriate + def handle( + %{ + data: %{ + "id" => follow_id, + "type" => "Follow", + "object" => followed_user, + "actor" => following_user + } + } = object, + meta + ) do + with %User{} = follower <- User.get_cached_by_ap_id(following_user), + %User{} = followed <- User.get_cached_by_ap_id(followed_user), + {_, {:ok, _}, _, _} <- + {:following, User.follow(follower, followed, :follow_pending), follower, followed} do + if followed.local && !followed.locked do + Utils.update_follow_state_for_all(object, "accept") + FollowingRelationship.update(follower, followed, :follow_accept) + + %{ + to: [following_user], + actor: followed, + object: follow_id, + local: true + } + |> ActivityPub.accept() + end + else + {:following, {:error, _}, follower, followed} -> + Utils.update_follow_state_for_all(object, "reject") + FollowingRelationship.update(follower, followed, :follow_reject) + + if followed.local do + %{ + to: [follower.ap_id], + actor: followed, + object: follow_id, + local: true + } + |> ActivityPub.reject() + end + + _ -> + nil + end + + {:ok, notifications} = Notification.create_notifications(object, do_send: false) + + meta = + meta + |> add_notifications(notifications) + + {:ok, object, meta} + end + # Tasks this handles: # - Unfollow and block def handle( diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 117e930b3..884646ceb 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -529,66 +529,6 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data, - _options - ) do - with %User{local: true} = followed <- - User.get_cached_by_ap_id(Containment.get_actor(%{"actor" => followed})), - {:ok, %User{} = follower} <- - User.get_or_fetch_by_ap_id(Containment.get_actor(%{"actor" => follower})), - {:ok, activity} <- - ActivityPub.follow(follower, followed, id, false, skip_notify_and_stream: true) do - with deny_follow_blocked <- Pleroma.Config.get([:user, :deny_follow_blocked]), - {_, false} <- {:user_blocked, User.blocks?(followed, follower) && deny_follow_blocked}, - {_, false} <- {:user_locked, User.locked?(followed)}, - {_, {:ok, follower}} <- {:follow, User.follow(follower, followed)}, - {_, {:ok, _}} <- - {:follow_state_update, Utils.update_follow_state_for_all(activity, "accept")}, - {:ok, _relationship} <- - FollowingRelationship.update(follower, followed, :follow_accept) do - ActivityPub.accept(%{ - to: [follower.ap_id], - actor: followed, - object: data, - local: true - }) - else - {:user_blocked, true} -> - {:ok, _} = Utils.update_follow_state_for_all(activity, "reject") - {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_reject) - - ActivityPub.reject(%{ - to: [follower.ap_id], - actor: followed, - object: data, - local: true - }) - - {:follow, {:error, _}} -> - {:ok, _} = Utils.update_follow_state_for_all(activity, "reject") - {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_reject) - - ActivityPub.reject(%{ - to: [follower.ap_id], - actor: followed, - object: data, - local: true - }) - - {:user_locked, true} -> - {:ok, _relationship} = FollowingRelationship.update(follower, followed, :follow_pending) - :noop - end - - ActivityPub.notify_and_stream(activity) - {:ok, activity} - else - _e -> - :error - end - end - def handle_incoming( %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => id} = data, _options @@ -696,7 +636,7 @@ def handle_incoming( %{"type" => type} = data, _options ) - when type in ~w{Update Block} do + when type in ~w{Update Block Follow} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 06c39eed6..17e764ca1 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -160,7 +160,7 @@ test "it rejects incoming follow requests if the following errors for some reaso |> Poison.decode!() |> Map.put("object", user.ap_id) - with_mock Pleroma.User, [:passthrough], follow: fn _, _ -> {:error, :testing} end do + with_mock Pleroma.User, [:passthrough], follow: fn _, _, _ -> {:error, :testing} end do {:ok, %Activity{data: %{"id" => id}}} = Transmogrifier.handle_incoming(data) %Activity{} = activity = Activity.get_by_ap_id(id) -- cgit v1.2.3 From 72ad3a66f48d4500be1f25dd7b02b834399d3bbe Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 3 Jul 2020 19:18:08 +0300 Subject: don't fully start pleroma in mix tasks --- lib/mix/pleroma.ex | 20 +++++++++++++++++++- lib/mix/tasks/pleroma/digest.ex | 2 ++ lib/mix/tasks/pleroma/email.ex | 1 + lib/mix/tasks/pleroma/relay.ex | 3 +++ lib/pleroma/application.ex | 3 ++- 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 3ad6edbfb..553c74c25 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Pleroma do + @apps [:restarter, :ecto, :ecto_sql, :postgrex, :db_connection, :cachex] + @cachex_childs ["object", "user"] @doc "Common functions to be reused in mix tasks" def start_pleroma do Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) @@ -11,7 +13,23 @@ def start_pleroma do Application.put_env(:logger, :console, level: :debug) end - {:ok, _} = Application.ensure_all_started(:pleroma) + apps = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Gun do + [:gun | @apps] + else + [:hackney | @apps] + end + + Enum.each(apps, &Application.ensure_all_started/1) + + childs = [Pleroma.Repo, Pleroma.Config.TransferTask, Pleroma.Web.Endpoint] + + cachex_childs = Enum.map(@cachex_childs, &Pleroma.Application.build_cachex(&1, [])) + + Supervisor.start_link(childs ++ cachex_childs, + strategy: :one_for_one, + name: Pleroma.Supervisor + ) if Pleroma.Config.get(:env) not in [:test, :benchmark] do pleroma_rebooted?() diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 3595f912d..8bde2d4f2 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -7,6 +7,8 @@ defmodule Mix.Tasks.Pleroma.Digest do def run(["test", nickname | opts]) do Mix.Pleroma.start_pleroma() + Application.ensure_all_started(:timex) + Application.ensure_all_started(:swoosh) user = Pleroma.User.get_by_nickname(nickname) diff --git a/lib/mix/tasks/pleroma/email.ex b/lib/mix/tasks/pleroma/email.ex index d3fac6ec8..16fe31431 100644 --- a/lib/mix/tasks/pleroma/email.ex +++ b/lib/mix/tasks/pleroma/email.ex @@ -7,6 +7,7 @@ defmodule Mix.Tasks.Pleroma.Email do def run(["test" | args]) do Mix.Pleroma.start_pleroma() + Application.ensure_all_started(:swoosh) {options, [], []} = OptionParser.parse( diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index c3312507e..b67d256c3 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -12,6 +12,7 @@ defmodule Mix.Tasks.Pleroma.Relay do def run(["follow", target]) do start_pleroma() + Application.ensure_all_started(:flake_id) with {:ok, _activity} <- Relay.follow(target) do # put this task to sleep to allow the genserver to push out the messages @@ -23,6 +24,7 @@ def run(["follow", target]) do def run(["unfollow", target]) do start_pleroma() + Application.ensure_all_started(:flake_id) with {:ok, _activity} <- Relay.unfollow(target) do # put this task to sleep to allow the genserver to push out the messages @@ -34,6 +36,7 @@ def run(["unfollow", target]) do def run(["list"]) do start_pleroma() + Application.ensure_all_started(:flake_id) with {:ok, list} <- Relay.list(true) do list |> Enum.each(&shell_info(&1)) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9615af122..7eb629abf 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -162,7 +162,8 @@ defp idempotency_expiration, defp seconds_valid_interval, do: :timer.seconds(Config.get!([Pleroma.Captcha, :seconds_valid])) - defp build_cachex(type, opts), + @spec build_cachex(String.t(), keyword()) :: map() + def build_cachex(type, opts), do: %{ id: String.to_atom("cachex_" <> type), start: {Cachex, :start_link, [String.to_atom(type <> "_cache"), opts]}, -- cgit v1.2.3 From b28cc154596ad9cbf5ef9708a5967672c61ddbdc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 3 Jul 2020 20:12:00 +0300 Subject: don't restart pleroma in mix tasks --- lib/mix/pleroma.ex | 6 +++++- lib/pleroma/config/transfer_task.ex | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 553c74c25..0fbb6f1cd 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -22,7 +22,11 @@ def start_pleroma do Enum.each(apps, &Application.ensure_all_started/1) - childs = [Pleroma.Repo, Pleroma.Config.TransferTask, Pleroma.Web.Endpoint] + childs = [ + Pleroma.Repo, + {Pleroma.Config.TransferTask, false}, + Pleroma.Web.Endpoint + ] cachex_childs = Enum.map(@cachex_childs, &Pleroma.Application.build_cachex(&1, [])) diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index eb86b8ff4..a0d7b7d71 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -31,8 +31,8 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, :gopher, [:enabled]} ] - def start_link(_) do - load_and_update_env() + def start_link(restart_pleroma? \\ true) do + load_and_update_env([], restart_pleroma?) if Config.get(:env) == :test, do: Ecto.Adapters.SQL.Sandbox.checkin(Repo) :ignore end -- cgit v1.2.3 From 9dda8b542723afae8dd5493b4082b8524873a14d Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Jul 2020 15:40:56 +0200 Subject: CommonAPI: Switch to pipeline for following. --- lib/pleroma/web/activity_pub/side_effects.ex | 6 +++++- lib/pleroma/web/common_api/common_api.ex | 10 +++++++--- test/web/mastodon_api/mastodon_api_test.exs | 2 +- test/web/mastodon_api/views/account_view_test.exs | 3 +++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 284560913..de02baf0f 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -44,6 +44,8 @@ def handle( if followed.local && !followed.locked do Utils.update_follow_state_for_all(object, "accept") FollowingRelationship.update(follower, followed, :follow_accept) + User.update_follower_count(followed) + User.update_following_count(follower) %{ to: [following_user], @@ -78,7 +80,9 @@ def handle( meta |> add_notifications(notifications) - {:ok, object, meta} + updated_object = Activity.get_by_ap_id(follow_id) + + {:ok, updated_object, meta} end # Tasks this handles: diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index fd7149079..4d5b0decf 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -101,10 +101,14 @@ def unblock(blocker, blocked) do def follow(follower, followed) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) - with {:ok, follower} <- User.maybe_direct_follow(follower, followed), - {:ok, activity} <- ActivityPub.follow(follower, followed), + with {:ok, follow_data, _} <- Builder.follow(follower, followed), + {:ok, activity, _} <- Pipeline.common_pipeline(follow_data, local: true), {:ok, follower, followed} <- User.wait_and_refresh(timeout, follower, followed) do - {:ok, follower, followed, activity} + if activity.data["state"] == "reject" do + {:error, :rejected} + else + {:ok, follower, followed, activity} + end end end diff --git a/test/web/mastodon_api/mastodon_api_test.exs b/test/web/mastodon_api/mastodon_api_test.exs index a7f9c5205..c08be37d4 100644 --- a/test/web/mastodon_api/mastodon_api_test.exs +++ b/test/web/mastodon_api/mastodon_api_test.exs @@ -18,7 +18,7 @@ test "returns error when followed user is deactivated" do follower = insert(:user) user = insert(:user, local: true, deactivated: true) {:error, error} = MastodonAPI.follow(follower, user) - assert error == "Could not follow user: #{user.nickname} is deactivated." + assert error == :rejected end test "following for user" do diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 80b1f734c..3e2e780e3 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -372,6 +372,9 @@ test "shows actual follower/following count to the account owner" do user = insert(:user, hide_followers: true, hide_follows: true) other_user = insert(:user) {:ok, user, other_user, _activity} = CommonAPI.follow(user, other_user) + + assert User.following?(user, other_user) + assert Pleroma.FollowingRelationship.follower_count(other_user) == 1 {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) assert %{ -- cgit v1.2.3 From 00e54f8fe7af098ba829f7f7cd5511569dcd1c0a Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 8 Jul 2020 17:07:24 +0200 Subject: ActivityPub: Remove `follow` and fix issues. --- lib/pleroma/user.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 22 --------------- lib/pleroma/web/activity_pub/relay.ex | 2 +- test/tasks/relay_test.exs | 7 +++-- test/web/activity_pub/activity_pub_test.exs | 32 ++++------------------ test/web/activity_pub/relay_test.exs | 6 ++-- test/web/activity_pub/transmogrifier_test.exs | 11 ++++---- test/web/activity_pub/utils_test.exs | 9 +++--- test/web/common_api/common_api_test.exs | 21 ++++++++++---- .../controllers/follow_request_controller_test.exs | 8 +++--- 10 files changed, 43 insertions(+), 77 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index e98332744..9d1314f81 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1543,7 +1543,7 @@ def perform(:follow_import, %User{} = follower, followed_identifiers) fn followed_identifier -> with {:ok, %User{} = followed} <- get_or_fetch(followed_identifier), {:ok, follower} <- maybe_direct_follow(follower, followed), - {:ok, _} <- ActivityPub.follow(follower, followed) do + {:ok, _, _, _} <- CommonAPI.follow(follower, followed) do followed else err -> diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8abbef487..1c2908805 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -322,28 +322,6 @@ defp accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do end end - @spec follow(User.t(), User.t(), String.t() | nil, boolean(), keyword()) :: - {:ok, Activity.t()} | {:error, any()} - def follow(follower, followed, activity_id \\ nil, local \\ true, opts \\ []) do - with {:ok, result} <- - Repo.transaction(fn -> do_follow(follower, followed, activity_id, local, opts) end) do - result - end - end - - defp do_follow(follower, followed, activity_id, local, opts) do - skip_notify_and_stream = Keyword.get(opts, :skip_notify_and_stream, false) - data = make_follow_data(follower, followed, activity_id) - - with {:ok, activity} <- insert(data, local), - _ <- skip_notify_and_stream || notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity} - else - {:error, error} -> Repo.rollback(error) - end - end - @spec unfollow(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | nil | {:error, any()} def unfollow(follower, followed, activity_id \\ nil, local \\ true) do diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index 484178edd..b09764d2b 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -28,7 +28,7 @@ def relay_ap_id do def follow(target_instance) do with %User{} = local_user <- get_actor(), {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_instance), - {:ok, activity} <- ActivityPub.follow(local_user, target_user) do + {:ok, _, _, activity} <- CommonAPI.follow(local_user, target_user) do Logger.info("relay: followed instance: #{target_instance}; id=#{activity.data["id"]}") {:ok, activity} else diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index a8ba0658d..79ab72002 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -10,6 +10,8 @@ defmodule Mix.Tasks.Pleroma.RelayTest do alias Pleroma.Web.ActivityPub.Utils use Pleroma.DataCase + import Pleroma.Factory + setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -46,7 +48,8 @@ test "relay is followed" do describe "running unfollow" do test "relay is unfollowed" do - target_instance = "http://mastodon.example.org/users/admin" + user = insert(:user) + target_instance = user.ap_id Mix.Tasks.Pleroma.Relay.run(["follow", target_instance]) @@ -71,7 +74,7 @@ test "relay is unfollowed" do assert undo_activity.data["type"] == "Undo" assert undo_activity.data["actor"] == local_user.ap_id - assert undo_activity.data["object"] == cancelled_activity.data + assert undo_activity.data["object"]["id"] == cancelled_activity.data["id"] refute "#{target_instance}/followers" in User.following(local_user) end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 17e12a1a7..38c98f658 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -669,7 +669,7 @@ test "doesn't return activities from blocked domains" do refute activity in activities followed_user = insert(:user) - ActivityPub.follow(user, followed_user) + CommonAPI.follow(user, followed_user) {:ok, repeat_activity} = CommonAPI.repeat(activity.id, followed_user) activities = ActivityPub.fetch_activities([], %{blocking_user: user, skip_preload: true}) @@ -1013,24 +1013,12 @@ test "fetches the latest Follow activity" do end end - describe "following / unfollowing" do - test "it reverts follow activity" do - follower = insert(:user) - followed = insert(:user) - - with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do - assert {:error, :reverted} = ActivityPub.follow(follower, followed) - end - - assert Repo.aggregate(Activity, :count, :id) == 0 - assert Repo.aggregate(Object, :count, :id) == 0 - end - + describe "unfollowing" do test "it reverts unfollow activity" do follower = insert(:user) followed = insert(:user) - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) with_mock(Utils, [:passthrough], maybe_federate: fn _ -> {:error, :reverted} end) do assert {:error, :reverted} = ActivityPub.unfollow(follower, followed) @@ -1043,21 +1031,11 @@ test "it reverts unfollow activity" do assert activity.data["object"] == followed.ap_id end - test "creates a follow activity" do - follower = insert(:user) - followed = insert(:user) - - {:ok, activity} = ActivityPub.follow(follower, followed) - assert activity.data["type"] == "Follow" - assert activity.data["actor"] == follower.ap_id - assert activity.data["object"] == followed.ap_id - end - test "creates an undo activity for the last follow" do follower = insert(:user) followed = insert(:user) - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) {:ok, activity} = ActivityPub.unfollow(follower, followed) assert activity.data["type"] == "Undo" @@ -1074,7 +1052,7 @@ test "creates an undo activity for a pending follow request" do follower = insert(:user) followed = insert(:user, %{locked: true}) - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) {:ok, activity} = ActivityPub.unfollow(follower, followed) assert activity.data["type"] == "Undo" diff --git a/test/web/activity_pub/relay_test.exs b/test/web/activity_pub/relay_test.exs index b3b573c9b..9d657ac4f 100644 --- a/test/web/activity_pub/relay_test.exs +++ b/test/web/activity_pub/relay_test.exs @@ -7,8 +7,8 @@ defmodule Pleroma.Web.ActivityPub.RelayTest do alias Pleroma.Activity alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Relay + alias Pleroma.Web.CommonAPI import ExUnit.CaptureLog import Pleroma.Factory @@ -53,8 +53,7 @@ test "returns errors when user not found" do test "returns activity" do user = insert(:user) service_actor = Relay.get_actor() - ActivityPub.follow(service_actor, user) - Pleroma.User.follow(service_actor, user) + CommonAPI.follow(service_actor, user) assert "#{user.ap_id}/followers" in User.following(service_actor) assert {:ok, %Activity{} = activity} = Relay.unfollow(user.ap_id) assert activity.actor == "#{Pleroma.Web.Endpoint.url()}/relay" @@ -74,6 +73,7 @@ test "returns error when activity not `Create` type" do assert Relay.publish(activity) == {:error, "Not implemented"} end + @tag capture_log: true test "returns error when activity not public" do activity = insert(:direct_note_activity) assert Relay.publish(activity) == {:error, false} diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 01179206c..f7b7d1a9f 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -11,7 +11,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do alias Pleroma.Object.Fetcher alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.CommonAPI @@ -452,7 +451,7 @@ test "it works for incoming accepts which were pre-accepted" do {:ok, follower} = User.follow(follower, followed) assert User.following?(follower, followed) == true - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") @@ -482,7 +481,7 @@ test "it works for incoming accepts which were orphaned" do follower = insert(:user) followed = insert(:user, locked: true) - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") @@ -504,7 +503,7 @@ test "it works for incoming accepts which are referenced by IRI only" do follower = insert(:user) followed = insert(:user, locked: true) - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) accept_data = File.read!("test/fixtures/mastodon-accept-activity.json") @@ -569,7 +568,7 @@ test "it works for incoming rejects which are orphaned" do followed = insert(:user, locked: true) {:ok, follower} = User.follow(follower, followed) - {:ok, _follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, _follow_activity} = CommonAPI.follow(follower, followed) assert User.following?(follower, followed) == true @@ -595,7 +594,7 @@ test "it works for incoming rejects which are referenced by IRI only" do followed = insert(:user, locked: true) {:ok, follower} = User.follow(follower, followed) - {:ok, follow_activity} = ActivityPub.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) assert User.following?(follower, followed) == true diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index 2f9ecb5a3..361dc5a41 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.CommonAPI @@ -197,8 +196,8 @@ test "updates the state of all Follow activities with the same actor and object" user = insert(:user, locked: true) follower = insert(:user) - {:ok, follow_activity} = ActivityPub.follow(follower, user) - {:ok, follow_activity_two} = ActivityPub.follow(follower, user) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, user) + {:ok, _, _, follow_activity_two} = CommonAPI.follow(follower, user) data = follow_activity_two.data @@ -221,8 +220,8 @@ test "updates the state of the given follow activity" do user = insert(:user, locked: true) follower = insert(:user) - {:ok, follow_activity} = ActivityPub.follow(follower, user) - {:ok, follow_activity_two} = ActivityPub.follow(follower, user) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, user) + {:ok, _, _, follow_activity_two} = CommonAPI.follow(follower, user) data = follow_activity_two.data diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 908ee5484..7e11fede3 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -934,6 +934,15 @@ test "remove a reblog mute", %{muter: muter, muted: muted} do end end + describe "follow/2" do + test "directly follows a non-locked local user" do + [follower, followed] = insert_pair(:user) + {:ok, follower, followed, _} = CommonAPI.follow(follower, followed) + + assert User.following?(follower, followed) + end + end + describe "unfollow/2" do test "also unsubscribes a user" do [follower, followed] = insert_pair(:user) @@ -998,9 +1007,9 @@ test "after acceptance, it sets all existing pending follow request states to 'a follower = insert(:user) follower_two = insert(:user) - {:ok, follow_activity} = ActivityPub.follow(follower, user) - {:ok, follow_activity_two} = ActivityPub.follow(follower, user) - {:ok, follow_activity_three} = ActivityPub.follow(follower_two, user) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, user) + {:ok, _, _, follow_activity_two} = CommonAPI.follow(follower, user) + {:ok, _, _, follow_activity_three} = CommonAPI.follow(follower_two, user) assert follow_activity.data["state"] == "pending" assert follow_activity_two.data["state"] == "pending" @@ -1018,9 +1027,9 @@ test "after rejection, it sets all existing pending follow request states to 're follower = insert(:user) follower_two = insert(:user) - {:ok, follow_activity} = ActivityPub.follow(follower, user) - {:ok, follow_activity_two} = ActivityPub.follow(follower, user) - {:ok, follow_activity_three} = ActivityPub.follow(follower_two, user) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, user) + {:ok, _, _, follow_activity_two} = CommonAPI.follow(follower, user) + {:ok, _, _, follow_activity_three} = CommonAPI.follow(follower_two, user) assert follow_activity.data["state"] == "pending" assert follow_activity_two.data["state"] == "pending" diff --git a/test/web/mastodon_api/controllers/follow_request_controller_test.exs b/test/web/mastodon_api/controllers/follow_request_controller_test.exs index 44e12d15a..6749e0e83 100644 --- a/test/web/mastodon_api/controllers/follow_request_controller_test.exs +++ b/test/web/mastodon_api/controllers/follow_request_controller_test.exs @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do use Pleroma.Web.ConnCase alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -20,7 +20,7 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestControllerTest do test "/api/v1/follow_requests works", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, _activity} = ActivityPub.follow(other_user, user) + {:ok, _, _, _activity} = CommonAPI.follow(other_user, user) {:ok, other_user} = User.follow(other_user, user, :follow_pending) assert User.following?(other_user, user) == false @@ -34,7 +34,7 @@ test "/api/v1/follow_requests works", %{user: user, conn: conn} do test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, _activity} = ActivityPub.follow(other_user, user) + {:ok, _, _, _activity} = CommonAPI.follow(other_user, user) {:ok, other_user} = User.follow(other_user, user, :follow_pending) user = User.get_cached_by_id(user.id) @@ -56,7 +56,7 @@ test "/api/v1/follow_requests/:id/authorize works", %{user: user, conn: conn} do test "/api/v1/follow_requests/:id/reject works", %{user: user, conn: conn} do other_user = insert(:user) - {:ok, _activity} = ActivityPub.follow(other_user, user) + {:ok, _, _, _activity} = CommonAPI.follow(other_user, user) user = User.get_cached_by_id(user.id) -- cgit v1.2.3 From af9eb7a2b10d2994f915f8a33c2f5f7a7db1128e Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 8 Jul 2020 11:32:04 -0400 Subject: re-enable federation tests --- .gitlab-ci.yml | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b4bd59b43..6a2be879e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -63,21 +63,19 @@ unit-testing: - mix ecto.migrate - mix coveralls --preload-modules -# Removed to fix CI issue. In this early state it wasn't adding much value anyway. -# TODO Fix and reinstate federated testing -# federated-testing: -# stage: test -# cache: *testing_cache_policy -# services: -# - name: minibikini/postgres-with-rum:12 -# alias: postgres -# command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] -# script: -# - mix deps.get -# - mix ecto.create -# - mix ecto.migrate -# - epmd -daemon -# - mix test --trace --only federated +federated-testing: + stage: test + cache: *testing_cache_policy + services: + - name: minibikini/postgres-with-rum:12 + alias: postgres + command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] + script: + - mix deps.get + - mix ecto.create + - mix ecto.migrate + - epmd -daemon + - mix test --trace --only federated unit-testing-rum: stage: test -- cgit v1.2.3 From 123352ffa1c80aab658fca0c2276d1c06de43a02 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 8 Jul 2020 22:50:15 +0300 Subject: Removed unused trigram index on `users`. Fixed `users_fts_index` usage. --- lib/pleroma/user/search.ex | 16 +++++++++++----- .../20200708193702_drop_user_trigram_index.exs | 18 ++++++++++++++++++ test/user_search_test.exs | 12 ++++-------- 3 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 priv/repo/migrations/20200708193702_drop_user_trigram_index.exs diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 7ff1c7e24..d4fd31069 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -69,11 +69,15 @@ defp fts_search(query, query_string) do u in query, where: fragment( + # The fragment must _exactly_ match `users_fts_index`, otherwise the index won't work """ - (to_tsvector('simple', ?) || to_tsvector('simple', ?)) @@ to_tsquery('simple', ?) + ( + setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B') + ) @@ to_tsquery('simple', ?) """, - u.name, u.nickname, + u.name, ^query_string ) ) @@ -95,9 +99,11 @@ defp trigram_rank(query, query_string) do select_merge: %{ search_rank: fragment( - "similarity(?, ?) + \ - similarity(?, regexp_replace(?, '@.+', '')) + \ - similarity(?, trim(coalesce(?, '')))", + """ + similarity(?, ?) + + similarity(?, regexp_replace(?, '@.+', '')) + + similarity(?, trim(coalesce(?, ''))) + """, ^query_string, u.nickname, ^query_string, diff --git a/priv/repo/migrations/20200708193702_drop_user_trigram_index.exs b/priv/repo/migrations/20200708193702_drop_user_trigram_index.exs new file mode 100644 index 000000000..94efe323a --- /dev/null +++ b/priv/repo/migrations/20200708193702_drop_user_trigram_index.exs @@ -0,0 +1,18 @@ +defmodule Pleroma.Repo.Migrations.DropUserTrigramIndex do + @moduledoc "Drops unused trigram index on `users` (FTS index is being used instead)" + + use Ecto.Migration + + def up do + drop_if_exists(index(:users, [], name: :users_trigram_index)) + end + + def down do + create_if_not_exists( + index(:users, ["(trim(nickname || ' ' || coalesce(name, ''))) gist_trgm_ops"], + name: :users_trigram_index, + using: :gist + ) + ) + end +end diff --git a/test/user_search_test.exs b/test/user_search_test.exs index 758822072..559ba5966 100644 --- a/test/user_search_test.exs +++ b/test/user_search_test.exs @@ -72,15 +72,11 @@ test "finds a user by full name or leading fragment(s) of its words" do end) end - test "is not [yet] capable of matching by non-leading fragments (e.g. by domain)" do - user1 = insert(:user, %{nickname: "iamthedude"}) - insert(:user, %{nickname: "arandom@dude.com"}) + test "matches by leading fragment of user domain" do + user = insert(:user, %{nickname: "arandom@dude.com"}) + insert(:user, %{nickname: "iamthedude"}) - assert [] == User.search("dude") - - # Matching by leading fragment works, though - user1_id = user1.id - assert ^user1_id = User.search("iam") |> List.first() |> Map.get(:id) + assert [user.id] == User.search("dud") |> Enum.map(& &1.id) end test "ranks full nickname match higher than full name match" do -- cgit v1.2.3 From 33e62856367b2789fa287830676edd843ad0e5d4 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Thu, 9 Jul 2020 01:33:23 +0300 Subject: Update types for :headers and :options settings in MediaProxy Invalidation group --- config/description.exs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/config/description.exs b/config/description.exs index 370af80a6..337f0d307 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1791,15 +1791,20 @@ }, %{ key: :headers, - type: {:list, :tuple}, - description: "HTTP headers of request.", + type: {:keyword, :string}, + description: "HTTP headers of request", suggestions: [{"x-refresh", 1}] }, %{ key: :options, type: :keyword, - description: "Request options.", - suggestions: [params: %{ts: "xxx"}] + description: "Request options", + children: [ + %{ + key: :params, + type: {:keyword, :string} + } + ] } ] }, -- cgit v1.2.3 From 6e5497225280e8400462b3135112b3ce80b145db Mon Sep 17 00:00:00 2001 From: Dym Sohin Date: Thu, 9 Jul 2020 03:15:51 +0000 Subject: added link to changelog, removed repetition --- docs/administration/updating.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/administration/updating.md b/docs/administration/updating.md index 2a08dac1f..c994f3f16 100644 --- a/docs/administration/updating.md +++ b/docs/administration/updating.md @@ -1,6 +1,6 @@ # Updating your instance -You should **always check the release notes/changelog** in case there are config deprecations, special update special update steps, etc. +You should **always check the [release notes/changelog](https://git.pleroma.social/pleroma/pleroma/-/releases)** in case there are config deprecations, special update steps, etc. Besides that, doing the following is generally enough: -- cgit v1.2.3 From 31259cabcc1423b8ea23b01bcc9f425d0b99b547 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 9 Jul 2020 07:16:52 +0300 Subject: fix test --- test/tasks/user_test.exs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index c962819db..2a3e62e26 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -124,16 +124,9 @@ test "a remote user's create activity is deleted when the object has been pruned Keyword.put(meta, :local, true) ) - like_obj = Pleroma.Object.get_by_ap_id(like_activity.data["object"]) - - data = - Map.merge(like_activity.data, %{"object" => "tag:gnusocial.cc,2019-01-09:noticeId=210716"}) - - like_activity - |> Ecto.Changeset.change(data: data) - |> Repo.update() - - Repo.delete(like_obj) + like_activity.data["object"] + |> Pleroma.Object.get_by_ap_id() + |> Repo.delete() clear_config([:instance, :federating], true) -- cgit v1.2.3 From 4cbafcef0c3a0ce9ddf888b558c6691afb96ad94 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Jul 2020 11:17:43 +0300 Subject: load default config in mix tasks --- lib/mix/pleroma.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 0fbb6f1cd..de16cc52c 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -7,6 +7,7 @@ defmodule Mix.Pleroma do @cachex_childs ["object", "user"] @doc "Common functions to be reused in mix tasks" def start_pleroma do + Pleroma.Config.Holder.save_default() Application.put_env(:phoenix, :serve_endpoints, false, persistent: true) if Pleroma.Config.get(:env) != :test do -- cgit v1.2.3 From 8eecc708efcb405a477d4b2478635f8522c04668 Mon Sep 17 00:00:00 2001 From: Dym Sohin Date: Thu, 9 Jul 2020 09:03:24 +0000 Subject: fix wide2x emojis within nicknames --- priv/static/static-fe/static-fe.css | Bin 2715 -> 2776 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/priv/static/static-fe/static-fe.css b/priv/static/static-fe/static-fe.css index db61ff266..7623b9832 100644 Binary files a/priv/static/static-fe/static-fe.css and b/priv/static/static-fe/static-fe.css differ -- cgit v1.2.3 From 6465257e1fafd8c4c2ad8fead10ac76bf781fedd Mon Sep 17 00:00:00 2001 From: Dym Sohin Date: Thu, 9 Jul 2020 09:23:21 +0000 Subject: missed `:` (colon) before mrf_steal_emoji --- 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 f6529b940..d0a57928c 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -155,7 +155,7 @@ config :pleroma, :mrf_user_allowlist, %{ * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines * `:reject` rejects the message entirely -#### mrf_steal_emoji +#### :mrf_steal_emoji * `hosts`: List of hosts to steal emojis from * `rejected_shortcodes`: Regex-list of shortcodes to reject * `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk -- cgit v1.2.3 From 8594181597106ebb4ad091990e82293f4e58a933 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 9 Jul 2020 09:30:15 +0000 Subject: Update static-fe.css --- priv/static/static-fe/static-fe.css | Bin 2776 -> 2759 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/priv/static/static-fe/static-fe.css b/priv/static/static-fe/static-fe.css index 7623b9832..308388abc 100644 Binary files a/priv/static/static-fe/static-fe.css and b/priv/static/static-fe/static-fe.css differ -- cgit v1.2.3 From ab773fa5c946f74b0f42106ba2e72c092566743a Mon Sep 17 00:00:00 2001 From: Dym Sohin Date: Thu, 9 Jul 2020 09:37:50 +0000 Subject: [static-fe] limit according to- and within- existing ruleset --- priv/static/static-fe/static-fe.css | Bin 2759 -> 2735 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/priv/static/static-fe/static-fe.css b/priv/static/static-fe/static-fe.css index 308388abc..89e9f4877 100644 Binary files a/priv/static/static-fe/static-fe.css and b/priv/static/static-fe/static-fe.css differ -- cgit v1.2.3 From c2be0da79fcc95399647b292e0b9a4119d3dcdf1 Mon Sep 17 00:00:00 2001 From: eugenijm Date: Mon, 18 May 2020 22:56:09 +0300 Subject: Admin API: fix `GET /api/pleroma/admin/users/:nickname/credentials` returning 404 when getting the credentials of a remote user while `:instance, :limit_to_local_content` is set to `:unauthenticated` --- CHANGELOG.md | 1 + lib/pleroma/web/admin_api/controllers/admin_api_controller.ex | 10 +++++----- test/web/admin_api/controllers/admin_api_controller_test.exs | 9 +++++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d8c3d8e..c713f1970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `blob:` urls not being allowed by connect-src CSP - Mastodon API: fix `GET /api/v1/notifications` not returning the full result set - Rich Media Previews for Twitter links +- Admin API: fix `GET /api/pleroma/admin/users/:nickname/credentials` returning 404 when getting the credentials of a remote user while `:instance, :limit_to_local_content` is set to `:unauthenticated` ## [Unreleased (patch)] diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index f9545d895..e5f14269a 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -206,8 +206,8 @@ def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do end end - def user_show(conn, %{"nickname" => nickname}) do - with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + def user_show(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do conn |> put_view(AccountView) |> render("show.json", %{user: user}) @@ -233,11 +233,11 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do |> render("index.json", %{activities: activities, as: :activity}) end - def list_user_statuses(conn, %{"nickname" => nickname} = params) do + def list_user_statuses(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params) do with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true godmode = params["godmode"] == "true" || params["godmode"] == true - with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do {_, page_size} = page_params(params) activities = @@ -526,7 +526,7 @@ def disable_mfa(conn, %{"nickname" => nickname}) 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 + with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do conn |> put_view(AccountView) |> render("credentials.json", %{user: user, for: admin}) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 48fb108ec..c2433f23c 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1514,6 +1514,15 @@ test "returns log filtered by search", %{conn: conn, moderator: moderator} do end end + test "gets a remote users when [:instance, :limit_to_local_content] is set to :unauthenticated", + %{conn: conn} do + clear_config(Pleroma.Config.get([:instance, :limit_to_local_content]), :unauthenticated) + user = insert(:user, %{local: false, nickname: "u@peer1.com"}) + conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials") + + assert json_response(conn, 200) + end + describe "GET /users/:nickname/credentials" do test "gets the user credentials", %{conn: conn} do user = insert(:user) -- cgit v1.2.3 From d23804f191eb9e14cfb087863320ae90653c9544 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 9 Jul 2020 10:53:51 -0500 Subject: Use the Pleroma.Config alias --- lib/mix/tasks/pleroma/instance.ex | 4 +- lib/pleroma/application.ex | 2 +- lib/pleroma/emails/admin_email.ex | 2 +- lib/pleroma/emoji/loader.ex | 2 +- lib/pleroma/plugs/http_security_plug.ex | 4 +- lib/pleroma/user.ex | 52 +++++++++++----------- .../web/activity_pub/mrf/object_age_policy.ex | 2 +- .../web/activity_pub/mrf/reject_non_public.ex | 2 +- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 6 +-- lib/pleroma/web/media_proxy/media_proxy.ex | 2 +- .../web/twitter_api/controllers/util_controller.ex | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 86409738a..91440b453 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -145,7 +145,7 @@ def run(["gen" | rest]) do options, :uploads_dir, "What directory should media uploads go in (when using the local uploader)?", - Pleroma.Config.get([Pleroma.Uploaders.Local, :uploads]) + Config.get([Pleroma.Uploaders.Local, :uploads]) ) |> Path.expand() @@ -154,7 +154,7 @@ def run(["gen" | rest]) do options, :static_dir, "What directory should custom public files be read from (custom emojis, frontend bundle overrides, robots.txt, etc.)?", - Pleroma.Config.get([:instance, :static_dir]) + Config.get([:instance, :static_dir]) ) |> Path.expand() diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 9615af122..32773d3c9 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -35,7 +35,7 @@ def user_agent do # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do - Pleroma.Config.Holder.save_default() + Config.Holder.save_default() Pleroma.HTML.compile_scrubbers() Config.DeprecationWarnings.warn() Pleroma.Plugs.HTTPSecurityPlug.warn_if_disabled() diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index 55f61024e..c67ba63ad 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Emails.AdminEmail do alias Pleroma.Config alias Pleroma.Web.Router.Helpers - defp instance_config, do: Pleroma.Config.get(:instance) + defp instance_config, do: Config.get(:instance) defp instance_name, do: instance_config()[:name] defp instance_notify_email do diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex index 3de2dc762..03a6bca0b 100644 --- a/lib/pleroma/emoji/loader.ex +++ b/lib/pleroma/emoji/loader.ex @@ -108,7 +108,7 @@ defp load_pack(pack_dir, emoji_groups) do if File.exists?(emoji_txt) do load_from_file(emoji_txt, emoji_groups) else - extensions = Pleroma.Config.get([:emoji, :pack_extensions]) + extensions = Config.get([:emoji, :pack_extensions]) Logger.info( "No emoji.txt found for pack \"#{pack_name}\", assuming all #{ diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 472a3ff42..7d65cf078 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -82,14 +82,14 @@ defp csp_string do connect_src = ["connect-src 'self' blob: ", static_url, ?\s, websocket_url] connect_src = - if Pleroma.Config.get(:env) == :dev do + if Config.get(:env) == :dev do [connect_src, " http://localhost:3035/"] else connect_src end script_src = - if Pleroma.Config.get(:env) == :dev do + if Config.get(:env) == :dev do "script-src 'self' 'unsafe-eval'" else "script-src 'self'" diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9d1314f81..0078f9831 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -388,8 +388,8 @@ defp fix_follower_address(%{nickname: nickname} = params), defp fix_follower_address(params), do: params def remote_user_changeset(struct \\ %User{local: false}, params) do - bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) - name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + bio_limit = Config.get([:instance, :user_bio_length], 5000) + name_limit = Config.get([:instance, :user_name_length], 100) name = case params[:name] do @@ -448,8 +448,8 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do end def update_changeset(struct, params \\ %{}) do - bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) - name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + bio_limit = Config.get([:instance, :user_bio_length], 5000) + name_limit = Config.get([:instance, :user_name_length], 100) struct |> cast( @@ -618,12 +618,12 @@ def force_password_reset_async(user) do def force_password_reset(user), do: update_password_reset_pending(user, true) def register_changeset(struct, params \\ %{}, opts \\ []) do - bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) - name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + bio_limit = Config.get([:instance, :user_bio_length], 5000) + name_limit = Config.get([:instance, :user_name_length], 100) need_confirmation? = if is_nil(opts[:need_confirmation]) do - Pleroma.Config.get([:instance, :account_activation_required]) + Config.get([:instance, :account_activation_required]) else opts[:need_confirmation] end @@ -644,7 +644,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do |> validate_confirmation(:password) |> unique_constraint(:email) |> unique_constraint(:nickname) - |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames])) + |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) |> validate_format(:nickname, local_nickname_regex()) |> validate_format(:email, @email_regex) |> validate_length(:bio, max: bio_limit) @@ -659,7 +659,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do def maybe_validate_required_email(changeset, true), do: changeset def maybe_validate_required_email(changeset, _) do - if Pleroma.Config.get([:instance, :account_activation_required]) do + if Config.get([:instance, :account_activation_required]) do validate_required(changeset, [:email]) else changeset @@ -679,7 +679,7 @@ defp put_following_and_follower_address(changeset) do end defp autofollow_users(user) do - candidates = Pleroma.Config.get([:instance, :autofollowed_nicknames]) + candidates = Config.get([:instance, :autofollowed_nicknames]) autofollowed_users = User.Query.build(%{nickname: candidates, local: true, deactivated: false}) @@ -706,7 +706,7 @@ def post_register_action(%User{} = user) do def try_send_confirmation_email(%User{} = user) do if user.confirmation_pending && - Pleroma.Config.get([:instance, :account_activation_required]) do + Config.get([:instance, :account_activation_required]) do user |> Pleroma.Emails.UserEmail.account_confirmation_email() |> Pleroma.Emails.Mailer.deliver_async() @@ -763,7 +763,7 @@ def follow_all(follower, followeds) do defdelegate following(user), to: FollowingRelationship def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do - deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) + deny_follow_blocked = Config.get([:user, :deny_follow_blocked]) cond do followed.deactivated -> @@ -964,7 +964,7 @@ def get_cached_by_nickname(nickname) do end def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do - restrict_to_local = Pleroma.Config.get([:instance, :limit_to_local_content]) + restrict_to_local = Config.get([:instance, :limit_to_local_content]) cond do is_integer(nickname_or_id) or FlakeId.flake_id?(nickname_or_id) -> @@ -1160,7 +1160,7 @@ defp follow_information_changeset(user, params) do @spec update_follower_count(User.t()) :: {:ok, User.t()} def update_follower_count(%User{} = user) do - if user.local or !Pleroma.Config.get([:instance, :external_user_synchronization]) do + if user.local or !Config.get([:instance, :external_user_synchronization]) do follower_count = FollowingRelationship.follower_count(user) user @@ -1173,7 +1173,7 @@ def update_follower_count(%User{} = user) do @spec update_following_count(User.t()) :: {:ok, User.t()} def update_following_count(%User{local: false} = user) do - if Pleroma.Config.get([:instance, :external_user_synchronization]) do + if Config.get([:instance, :external_user_synchronization]) do {:ok, maybe_fetch_follow_information(user)} else {:ok, user} @@ -1260,7 +1260,7 @@ def unmute(%User{} = muter, %User{} = mutee) do end def subscribe(%User{} = subscriber, %User{} = target) do - deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) + deny_follow_blocked = Config.get([:user, :deny_follow_blocked]) if blocks?(target, subscriber) and deny_follow_blocked do {:error, "Could not subscribe: #{target.nickname} is blocking you"} @@ -1651,7 +1651,7 @@ def html_filter_policy(%User{no_rich_text: true}) do Pleroma.HTML.Scrubber.TwitterText end - def html_filter_policy(_), do: Pleroma.Config.get([:markup, :scrub_policy]) + def html_filter_policy(_), do: Config.get([:markup, :scrub_policy]) def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) @@ -1833,7 +1833,7 @@ defp normalize_tags(tags) do end defp local_nickname_regex do - if Pleroma.Config.get([:instance, :extended_nickname_format]) do + if Config.get([:instance, :extended_nickname_format]) do @extended_local_nickname_regex else @strict_local_nickname_regex @@ -1961,8 +1961,8 @@ def get_mascot(%{mascot: %{} = mascot}) when not is_nil(mascot) do def get_mascot(%{mascot: mascot}) when is_nil(mascot) do # use instance-default - config = Pleroma.Config.get([:assets, :mascots]) - default_mascot = Pleroma.Config.get([:assets, :default_mascot]) + config = Config.get([:assets, :mascots]) + default_mascot = Config.get([:assets, :default_mascot]) mascot = Keyword.get(config, default_mascot) %{ @@ -2057,7 +2057,7 @@ def roles(%{is_moderator: is_moderator, is_admin: is_admin}) do 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) + limit = Config.get([:instance, limit_name], 0) changeset |> validate_length(:fields, max: limit) @@ -2071,8 +2071,8 @@ def validate_fields(changeset, remote? \\ false) do end defp valid_field?(%{"name" => name, "value" => value}) do - name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255) - value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255) + name_limit = Config.get([:instance, :account_field_name_length], 255) + value_limit = Config.get([:instance, :account_field_value_length], 255) is_binary(name) && is_binary(value) && String.length(name) <= name_limit && String.length(value) <= value_limit @@ -2082,10 +2082,10 @@ defp valid_field?(_), do: false defp truncate_field(%{"name" => name, "value" => value}) do {name, _chopped} = - String.split_at(name, Pleroma.Config.get([:instance, :account_field_name_length], 255)) + String.split_at(name, Config.get([:instance, :account_field_name_length], 255)) {value, _chopped} = - String.split_at(value, Pleroma.Config.get([:instance, :account_field_value_length], 255)) + String.split_at(value, Config.get([:instance, :account_field_value_length], 255)) %{"name" => name, "value" => value} end @@ -2140,7 +2140,7 @@ def confirmation_changeset(user, need_confirmation: need_confirmation?) do def add_pinnned_activity(user, %Pleroma.Activity{id: id}) do if id not in user.pinned_activities do - max_pinned_statuses = Pleroma.Config.get([:instance, :max_pinned_statuses], 0) + max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) params = %{pinned_activities: user.pinned_activities ++ [id]} user diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index b0ccb63c8..a62914135 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -98,7 +98,7 @@ def filter(message), do: {:ok, message} @impl true def describe do mrf_object_age = - Pleroma.Config.get(:mrf_object_age) + Config.get(:mrf_object_age) |> Enum.into(%{}) {:ok, %{mrf_object_age: mrf_object_age}} diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex index 3092f3272..4fd63106d 100644 --- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -47,5 +47,5 @@ def filter(object), do: {:ok, object} @impl true def describe, - do: {:ok, %{mrf_rejectnonpublic: Pleroma.Config.get(:mrf_rejectnonpublic) |> Enum.into(%{})}} + do: {:ok, %{mrf_rejectnonpublic: Config.get(:mrf_rejectnonpublic) |> Enum.into(%{})}} end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 9cea6bcf9..70a2ca053 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -155,7 +155,7 @@ def filter(%{"type" => "Delete", "actor" => actor} = object) do %{host: actor_host} = URI.parse(actor) reject_deletes = - Pleroma.Config.get([:mrf_simple, :reject_deletes]) + Config.get([:mrf_simple, :reject_deletes]) |> MRF.subdomains_regex() if MRF.subdomain_match?(reject_deletes, actor_host) do diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 15594125f..9c38b73eb 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -143,7 +143,7 @@ def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data) def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data) when is_list(options) do - limits = Pleroma.Config.get([:instance, :poll_limits]) + limits = Config.get([:instance, :poll_limits]) with :ok <- validate_poll_expiration(expires_in, limits), :ok <- validate_poll_options_amount(options, limits), @@ -502,7 +502,7 @@ def maybe_extract_mentions(_), do: [] def make_report_content_html(nil), do: {:ok, {nil, [], []}} def make_report_content_html(comment) do - max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000) + max_size = Config.get([:instance, :max_report_comment_size], 1000) if String.length(comment) <= max_size do {:ok, format_input(comment, "text/plain")} @@ -564,7 +564,7 @@ def validate_character_limit("" = _full_payload, [] = _attachments) do end def validate_character_limit(full_payload, _attachments) do - limit = Pleroma.Config.get([:instance, :limit]) + limit = Config.get([:instance, :limit]) length = String.length(full_payload) if length <= limit do diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 077fabe47..6f35826da 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -106,7 +106,7 @@ def filename(url_or_path) do def build_url(sig_base64, url_base64, filename \\ nil) do [ - Pleroma.Config.get([:media_proxy, :base_url], Web.base_url()), + Config.get([:media_proxy, :base_url], Web.base_url()), "proxy", sig_base64, url_base64, diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 8314e75b4..f02c4075c 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -83,7 +83,7 @@ def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_ def frontend_configurations(conn, _params) do config = - Pleroma.Config.get(:frontend_configurations, %{}) + Config.get(:frontend_configurations, %{}) |> Enum.into(%{}) json(conn, config) -- cgit v1.2.3 From c2edfd16e063cda117181da66d9b4fec87c121ac Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Jul 2020 18:59:15 +0300 Subject: fix for user revoke invite task --- docs/administration/CLI_tasks/user.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index 1e6f4a8b4..3b4c421a7 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -57,11 +57,11 @@ mix pleroma.user invites ## Revoke invite ```sh tab="OTP" - ./bin/pleroma_ctl user revoke_invite + ./bin/pleroma_ctl user revoke_invite ``` ```sh tab="From Source" -mix pleroma.user revoke_invite +mix pleroma.user revoke_invite ``` -- cgit v1.2.3 From d5fcec8315a5f48d94a083b38abcc615834dc532 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Jul 2020 18:59:48 +0300 Subject: fix for info after tag/untag user --- lib/mix/tasks/pleroma/user.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index bca7e87bf..a9370b5e7 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -15,6 +15,8 @@ defmodule Mix.Tasks.Pleroma.User do @moduledoc File.read!("docs/administration/CLI_tasks/user.md") def run(["new", nickname, email | rest]) do + Application.ensure_all_started(:flake_id) + {options, [], []} = OptionParser.parse( rest, @@ -97,6 +99,7 @@ def run(["new", nickname, email | rest]) do def run(["rm", nickname]) do start_pleroma() + Application.ensure_all_started(:flake_id) with %User{local: true} = user <- User.get_cached_by_nickname(nickname), {:ok, delete_data, _} <- Builder.delete(user, user.ap_id), @@ -232,7 +235,7 @@ def run(["tag", nickname | tags]) do with %User{} = user <- User.get_cached_by_nickname(nickname) do user = user |> User.tag(tags) - shell_info("Tags of #{user.nickname}: #{inspect(tags)}") + shell_info("Tags of #{user.nickname}: #{inspect(user.tags)}") else _ -> shell_error("Could not change user tags for #{nickname}") @@ -245,7 +248,7 @@ def run(["untag", nickname | tags]) do with %User{} = user <- User.get_cached_by_nickname(nickname) do user = user |> User.untag(tags) - shell_info("Tags of #{user.nickname}: #{inspect(tags)}") + shell_info("Tags of #{user.nickname}: #{inspect(user.tags)}") else _ -> shell_error("Could not change user tags for #{nickname}") @@ -328,6 +331,7 @@ def run(["revoke_invite", token]) do def run(["delete_activities", nickname]) do start_pleroma() + Application.ensure_all_started(:flake_id) with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do User.delete_user_activities(user) -- cgit v1.2.3 From 79707e879d3af359be9e1f6ac10717cc9cb72b2c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Thu, 9 Jul 2020 19:13:16 +0300 Subject: cleap up --- lib/mix/pleroma.ex | 20 +++++++++++++++----- lib/mix/tasks/pleroma/digest.ex | 2 -- lib/mix/tasks/pleroma/email.ex | 1 - lib/mix/tasks/pleroma/relay.ex | 3 --- lib/mix/tasks/pleroma/user.ex | 4 ---- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index de16cc52c..9f0bf6ecb 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -3,8 +3,18 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Pleroma do - @apps [:restarter, :ecto, :ecto_sql, :postgrex, :db_connection, :cachex] - @cachex_childs ["object", "user"] + @apps [ + :restarter, + :ecto, + :ecto_sql, + :postgrex, + :db_connection, + :cachex, + :flake_id, + :swoosh, + :timex + ] + @cachex_children ["object", "user"] @doc "Common functions to be reused in mix tasks" def start_pleroma do Pleroma.Config.Holder.save_default() @@ -23,15 +33,15 @@ def start_pleroma do Enum.each(apps, &Application.ensure_all_started/1) - childs = [ + children = [ Pleroma.Repo, {Pleroma.Config.TransferTask, false}, Pleroma.Web.Endpoint ] - cachex_childs = Enum.map(@cachex_childs, &Pleroma.Application.build_cachex(&1, [])) + cachex_children = Enum.map(@cachex_children, &Pleroma.Application.build_cachex(&1, [])) - Supervisor.start_link(childs ++ cachex_childs, + Supervisor.start_link(children ++ cachex_children, strategy: :one_for_one, name: Pleroma.Supervisor ) diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 8bde2d4f2..3595f912d 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -7,8 +7,6 @@ defmodule Mix.Tasks.Pleroma.Digest do def run(["test", nickname | opts]) do Mix.Pleroma.start_pleroma() - Application.ensure_all_started(:timex) - Application.ensure_all_started(:swoosh) user = Pleroma.User.get_by_nickname(nickname) diff --git a/lib/mix/tasks/pleroma/email.ex b/lib/mix/tasks/pleroma/email.ex index 16fe31431..d3fac6ec8 100644 --- a/lib/mix/tasks/pleroma/email.ex +++ b/lib/mix/tasks/pleroma/email.ex @@ -7,7 +7,6 @@ defmodule Mix.Tasks.Pleroma.Email do def run(["test" | args]) do Mix.Pleroma.start_pleroma() - Application.ensure_all_started(:swoosh) {options, [], []} = OptionParser.parse( diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index b67d256c3..c3312507e 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -12,7 +12,6 @@ defmodule Mix.Tasks.Pleroma.Relay do def run(["follow", target]) do start_pleroma() - Application.ensure_all_started(:flake_id) with {:ok, _activity} <- Relay.follow(target) do # put this task to sleep to allow the genserver to push out the messages @@ -24,7 +23,6 @@ def run(["follow", target]) do def run(["unfollow", target]) do start_pleroma() - Application.ensure_all_started(:flake_id) with {:ok, _activity} <- Relay.unfollow(target) do # put this task to sleep to allow the genserver to push out the messages @@ -36,7 +34,6 @@ def run(["unfollow", target]) do def run(["list"]) do start_pleroma() - Application.ensure_all_started(:flake_id) with {:ok, list} <- Relay.list(true) do list |> Enum.each(&shell_info(&1)) diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index a9370b5e7..01824aa18 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -15,8 +15,6 @@ defmodule Mix.Tasks.Pleroma.User do @moduledoc File.read!("docs/administration/CLI_tasks/user.md") def run(["new", nickname, email | rest]) do - Application.ensure_all_started(:flake_id) - {options, [], []} = OptionParser.parse( rest, @@ -99,7 +97,6 @@ def run(["new", nickname, email | rest]) do def run(["rm", nickname]) do start_pleroma() - Application.ensure_all_started(:flake_id) with %User{local: true} = user <- User.get_cached_by_nickname(nickname), {:ok, delete_data, _} <- Builder.delete(user, user.ap_id), @@ -331,7 +328,6 @@ def run(["revoke_invite", token]) do def run(["delete_activities", nickname]) do start_pleroma() - Application.ensure_all_started(:flake_id) with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do User.delete_user_activities(user) -- cgit v1.2.3 From 2b979cc90c4e466a8d0a83706e642b325cc24d0e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 9 Jul 2020 11:55:40 -0500 Subject: Add AdminFE reports URL to report emails --- lib/pleroma/emails/admin_email.ex | 2 ++ test/emails/admin_email_test.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index c67ba63ad..aa0b2a66b 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -72,6 +72,8 @@ def report(to, reporter, account, statuses, comment) do

    Reported Account: #{account.nickname}

    #{comment_html} #{statuses_html} +

    + View Reports in AdminFE """ new() diff --git a/test/emails/admin_email_test.exs b/test/emails/admin_email_test.exs index bc871a0a9..9082ae5a7 100644 --- a/test/emails/admin_email_test.exs +++ b/test/emails/admin_email_test.exs @@ -31,7 +31,7 @@ test "build report email" do account_url }\">#{account.nickname}

    \n

    Comment: Test comment\n

    Statuses:\n

    \n

    \n\n" + }\">#{status_url}\n \n

    \n\n

    \nView Reports in AdminFE\n" end test "it works when the reporter is a remote user without email" do -- cgit v1.2.3 From cc7153cd828afef1564b58649875b5529c078054 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 9 Jul 2020 19:07:07 +0200 Subject: user: Add support for custom emojis in profile fields --- lib/pleroma/user.ex | 18 ++++++++++++---- .../account_controller/update_credentials_test.exs | 24 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9d1314f81..19b361b88 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -527,11 +527,21 @@ defp parse_fields(value) do end defp put_emoji(changeset) do - bio = get_change(changeset, :bio) - name = get_change(changeset, :name) + emojified_fields = [:bio, :name, :raw_fields] + + if Enum.any?(changeset.changes, fn {k, _} -> k in emojified_fields end) do + bio = Emoji.Formatter.get_emoji_map(get_field(changeset, :bio)) + name = Emoji.Formatter.get_emoji_map(get_field(changeset, :name)) + + emoji = Map.merge(bio, name) + + emoji = + changeset + |> get_field(:raw_fields) + |> Enum.reduce(emoji, fn x, acc -> + Map.merge(acc, Emoji.Formatter.get_emoji_map(x["name"] <> x["value"])) + end) - if bio || name do - emoji = Map.merge(Emoji.Formatter.get_emoji_map(bio), Emoji.Formatter.get_emoji_map(name)) put_change(changeset, :emoji, emoji) else changeset 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 b55bb76a7..ee5ec9053 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 @@ -344,6 +344,30 @@ test "update fields", %{conn: conn} do ] end + test "emojis in fields labels", %{conn: conn} do + fields = [ + %{"name" => ":firefox:", "value" => "is best 2hu"}, + %{"name" => "they wins", "value" => ":blank:"} + ] + + account_data = + conn + |> patch("/api/v1/accounts/update_credentials", %{"fields_attributes" => fields}) + |> json_response_and_validate_schema(200) + + assert account_data["fields"] == [ + %{"name" => ":firefox:", "value" => "is best 2hu"}, + %{"name" => "they wins", "value" => ":blank:"} + ] + + assert account_data["source"]["fields"] == [ + %{"name" => ":firefox:", "value" => "is best 2hu"}, + %{"name" => "they wins", "value" => ":blank:"} + ] + + assert [%{"shortcode" => "blank"}, %{"shortcode" => "firefox"}] = account_data["emojis"] + end + test "update fields via x-www-form-urlencoded", %{conn: conn} do fields = [ -- cgit v1.2.3 From 08211eff22d4aab8ee73dbe16212d2aed1f6789b Mon Sep 17 00:00:00 2001 From: stwf Date: Wed, 8 Jul 2020 15:56:03 -0400 Subject: Re-enable the federated tests, increase timeout --- test/support/cluster.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/support/cluster.ex b/test/support/cluster.ex index deb37f361..524194cf4 100644 --- a/test/support/cluster.ex +++ b/test/support/cluster.ex @@ -97,7 +97,7 @@ def spawn_cluster(node_configs) do silence_logger_warnings(fn -> node_configs |> Enum.map(&Task.async(fn -> start_slave(&1) end)) - |> Enum.map(&Task.await(&1, 60_000)) + |> Enum.map(&Task.await(&1, 90_000)) end) end -- cgit v1.2.3 From 6b9210e886e16f806563f20ac82c0fe56f12a615 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Fri, 10 Jul 2020 03:07:55 +0300 Subject: Update type for :groups setting --- config/description.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 337f0d307..c2cd40587 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2527,7 +2527,7 @@ %{ key: :styling, type: :map, - description: "a map with color settings for email templates.", + description: "A map with color settings for email templates.", suggestions: [ %{ link_color: "#d8a070", @@ -2633,7 +2633,7 @@ }, %{ key: :groups, - type: {:keyword, :string, {:list, :string}}, + type: {:keyword, {:list, :string}}, description: "Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the group name" <> " and the value is the location or array of locations. * can be used as a wildcard.", -- cgit v1.2.3 From ac9f18de11d3d0583dfae3c6b25c56828357624a Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Fri, 10 Jul 2020 03:32:53 +0300 Subject: Update type for :replace settings --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index c2cd40587..0a0a8e95c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1601,7 +1601,7 @@ }, %{ key: :replace, - type: [{:tuple, :string, :string}, {:tuple, :regex, :string}], + type: {:keyword, :string, :regex}, description: "A list of tuples containing {pattern, replacement}. Each pattern can be a string or a regular expression.", suggestions: [{"foo", "bar"}, {~r/foo/iu, "bar"}] -- cgit v1.2.3 From b1b8f5f11a6b74c09490235b30d1b31a54909437 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 10 Jul 2020 09:16:53 +0300 Subject: docs and descriptions for s3 settings --- config/description.exs | 32 ++++++++++++++++++++++++++++---- docs/configuration/cheatsheet.md | 25 ++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/config/description.exs b/config/description.exs index 03b84bfc8..f461feb04 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2579,8 +2579,7 @@ %{ key: :enabled, type: :boolean, - description: "Enables new users admin digest email when `true`", - suggestions: [false] + description: "Enables new users admin digest email when `true`" } ] }, @@ -3444,8 +3443,7 @@ key: :strict, type: :boolean, description: - "Enables strict input validation (useful in development, not recommended in production)", - suggestions: [false] + "Enables strict input validation (useful in development, not recommended in production)" } ] }, @@ -3461,5 +3459,31 @@ description: "Allow/disallow displaying and getting instances favicons" } ] + }, + %{ + group: :ex_aws, + key: :s3, + type: :group, + descriptions: "S3 service related settings", + children: [ + %{ + key: :access_key_id, + type: :string, + description: "S3 access key ID", + suggestions: ["AKIAQ8UKHTGIYN7DMWWJ"] + }, + %{ + key: :secret_access_key, + type: :string, + description: "Secret access key", + suggestions: ["JFGt+fgH1UQ7vLUQjpW+WvjTdV/UNzVxcwn7DkaeFKtBS5LvoXvIiME4NQBsT6ZZ"] + }, + %{ + key: :host, + type: :string, + description: "S3 host", + suggestions: ["s3.eu-central-1.amazonaws.com"] + } + ] } ] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index d775534b6..1a0603892 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -476,7 +476,6 @@ For each pool, the options are: * `:timeout` - timeout while `gun` will wait for response * `:max_overflow` - additional workers if pool is under load - ## Captcha ### Pleroma.Captcha @@ -494,7 +493,7 @@ A built-in captcha provider. Enabled by default. #### Pleroma.Captcha.Kocaptcha Kocaptcha is a very simple captcha service with a single API endpoint, -the source code is here: https://github.com/koto-bank/kocaptcha. The default endpoint +the source code is here: [kocaptcha](https://github.com/koto-bank/kocaptcha). The default endpoint `https://captcha.kotobank.ch` is hosted by the developer. * `endpoint`: the Kocaptcha endpoint to use. @@ -502,6 +501,7 @@ the source code is here: https://github.com/koto-bank/kocaptcha. The default end ## Uploads ### Pleroma.Upload + * `uploader`: Which one of the [uploaders](#uploaders) to use. * `filters`: List of [upload filters](#upload-filters) to use. * `link_name`: When enabled Pleroma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers when using filters like `Pleroma.Upload.Filter.Dedupe` @@ -514,10 +514,15 @@ the source code is here: https://github.com/koto-bank/kocaptcha. The default end `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. ### Uploaders + #### Pleroma.Uploaders.Local + * `uploads`: Which directory to store the user-uploads in, relative to pleroma’s working directory. #### Pleroma.Uploaders.S3 + +Don't forget to configure [Ex AWS S3](#ex-aws-s3-settings) + * `bucket`: S3 bucket name. * `bucket_namespace`: S3 bucket namespace. * `public_endpoint`: S3 endpoint that the user finally accesses(ex. "https://s3.dualstack.ap-northeast-1.amazonaws.com") @@ -526,6 +531,20 @@ For example, when using CDN to S3 virtual host format, set "". At this time, write CNAME to CDN in public_endpoint. * `streaming_enabled`: Enable streaming uploads, when enabled the file will be sent to the server in chunks as it's being read. This may be unsupported by some providers, try disabling this if you have upload problems. +#### Ex AWS S3 settings + +* `access_key_id`: Access key ID +* `secret_access_key`: Secret access key +* `host`: S3 host + +Example: + +```elixir +config :ex_aws, :s3, + access_key_id: "xxxxxxxxxx", + secret_access_key: "yyyyyyyyyy", + host: "s3.eu-central-1.amazonaws.com" +``` ### Upload filters @@ -983,7 +1002,7 @@ Restrict access for unauthenticated users to timelines (public and federated), u * `local` * `remote` -Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). +Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). ## Pleroma.Web.ApiSpec.CastAndValidate -- cgit v1.2.3 From b6688030fad62cc8be32faf928ff6ec418940efc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 10 Jul 2020 10:35:59 +0300 Subject: prometheus update for OTP 23 --- mix.exs | 1 + mix.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index e2ab53bde..126aa4709 100644 --- a/mix.exs +++ b/mix.exs @@ -178,6 +178,7 @@ defp deps do ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, {:telemetry, "~> 0.3"}, {:poolboy, "~> 1.5"}, + {:prometheus, "~> 4.6"}, {:prometheus_ex, "~> 3.0"}, {:prometheus_plugs, "~> 1.1"}, {:prometheus_phoenix, "~> 1.3"}, diff --git a/mix.lock b/mix.lock index 4f2777fa7..30464ddf2 100644 --- a/mix.lock +++ b/mix.lock @@ -92,7 +92,7 @@ "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"}, - "prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"}, + "prometheus": {:hex, :prometheus, "4.6.0", "20510f381db1ccab818b4cf2fac5fa6ab5cc91bc364a154399901c001465f46f", [:mix, :rebar3], [], "hexpm", "4905fd2992f8038eccd7aa0cd22f40637ed618c0bed1f75c05aacec15b7545de"}, "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"}, -- cgit v1.2.3 From 11c9654a32830b2e36efd42324069c637719555e Mon Sep 17 00:00:00 2001 From: Ben Is Date: Wed, 8 Jul 2020 22:51:39 +0000 Subject: Translated using Weblate (Polish) Currently translated at 66.0% (70 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/pl/ --- priv/gettext/pl/LC_MESSAGES/errors.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/priv/gettext/pl/LC_MESSAGES/errors.po b/priv/gettext/pl/LC_MESSAGES/errors.po index 7bc39c52a..7241d8a0a 100644 --- a/priv/gettext/pl/LC_MESSAGES/errors.po +++ b/priv/gettext/pl/LC_MESSAGES/errors.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-13 16:37+0000\n" -"PO-Revision-Date: 2020-05-16 17:13+0000\n" -"Last-Translator: Jędrzej Tomaszewski \n" +"PO-Revision-Date: 2020-07-09 14:40+0000\n" +"Last-Translator: Ben Is \n" "Language-Team: Polish \n" "Language: pl\n" @@ -50,7 +50,7 @@ msgstr "jest zarezerwowany" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "nie pasuje do potwierdzenia" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -- cgit v1.2.3 From b6de9b1987438a3b790c3bc1cd687a7575206e9d Mon Sep 17 00:00:00 2001 From: Ben Is Date: Wed, 8 Jul 2020 00:00:40 +0000 Subject: Translated using Weblate (Italian) Currently translated at 100.0% (106 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/it/ --- priv/gettext/it/LC_MESSAGES/errors.po | 223 +++++++++++++++++----------------- 1 file changed, 114 insertions(+), 109 deletions(-) diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po index 726be628b..406a297d1 100644 --- a/priv/gettext/it/LC_MESSAGES/errors.po +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-06-19 14:33+0000\n" -"PO-Revision-Date: 2020-06-19 20:38+0000\n" +"PO-Revision-Date: 2020-07-09 14:40+0000\n" "Last-Translator: Ben Is \n" "Language-Team: Italian \n" @@ -29,258 +29,258 @@ msgstr "non può essere nullo" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "è stato già creato" ## From Ecto.Changeset.put_change/3 msgid "is invalid" -msgstr "" +msgstr "non è valido" ## From Ecto.Changeset.validate_format/3 msgid "has invalid format" -msgstr "" +msgstr "è in un formato invalido" ## From Ecto.Changeset.validate_subset/3 msgid "has an invalid entry" -msgstr "" +msgstr "ha una voce invalida" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "è vietato" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" -msgstr "" +msgstr "non corrisponde alla verifica" ## From Ecto.Changeset.no_assoc_constraint/3 msgid "is still associated with this entry" -msgstr "" +msgstr "è ancora associato con questa voce" msgid "are still associated with this entry" -msgstr "" +msgstr "sono ancora associati con questa voce" ## From Ecto.Changeset.validate_length/3 msgid "should be %{count} character(s)" msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dovrebbe essere %{count} carattere" +msgstr[1] "dovrebbero essere %{count} caratteri" msgid "should have %{count} item(s)" msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dovrebbe avere %{count} voce" +msgstr[1] "dovrebbe avere %{count} voci" msgid "should be at least %{count} character(s)" msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dovrebbe contenere almeno %{count} carattere" +msgstr[1] "dovrebbe contenere almeno %{count} caratteri" msgid "should have at least %{count} item(s)" msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dovrebbe avere almeno %{count} voce" +msgstr[1] "dovrebbe avere almeno %{count} voci" msgid "should be at most %{count} character(s)" msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dovrebbe avere al massimo %{count} carattere" +msgstr[1] "dovrebbe avere al massimo %{count} caratteri" msgid "should have at most %{count} item(s)" msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "dovrebbe avere al massimo %{count} voce" +msgstr[1] "dovrebbe avere al massimo %{count} voci" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" -msgstr "" +msgstr "dev'essere minore di %{number}" msgid "must be greater than %{number}" -msgstr "" +msgstr "dev'essere maggiore di %{number}" msgid "must be less than or equal to %{number}" -msgstr "" +msgstr "dev'essere minore o uguale a %{number}" msgid "must be greater than or equal to %{number}" -msgstr "" +msgstr "dev'essere maggiore o uguale a %{number}" msgid "must be equal to %{number}" -msgstr "" +msgstr "dev'essere uguale a %{number}" #: lib/pleroma/web/common_api/common_api.ex:421 #, elixir-format msgid "Account not found" -msgstr "" +msgstr "Profilo non trovato" #: lib/pleroma/web/common_api/common_api.ex:249 #, elixir-format msgid "Already voted" -msgstr "" +msgstr "Hai già votato" #: lib/pleroma/web/oauth/oauth_controller.ex:360 #, elixir-format msgid "Bad request" -msgstr "" +msgstr "Richiesta invalida" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 #, elixir-format msgid "Can't delete object" -msgstr "" +msgstr "Non puoi eliminare quest'oggetto" #: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 #, elixir-format msgid "Can't delete this post" -msgstr "" +msgstr "Non puoi eliminare questo messaggio" #: lib/pleroma/web/controller_helper.ex:95 #: lib/pleroma/web/controller_helper.ex:101 #, elixir-format msgid "Can't display this activity" -msgstr "" +msgstr "Non puoi vedere questo elemento" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 #, elixir-format msgid "Can't find user" -msgstr "" +msgstr "Non trovo questo utente" #: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 #, elixir-format msgid "Can't get favorites" -msgstr "" +msgstr "Non posso ricevere i gradimenti" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 #, elixir-format msgid "Can't like object" -msgstr "" +msgstr "Non posso gradire quest'oggetto" #: lib/pleroma/web/common_api/utils.ex:556 #, elixir-format msgid "Cannot post an empty status without attachments" -msgstr "" +msgstr "Non puoi pubblicare un messaggio vuoto senza allegati" #: lib/pleroma/web/common_api/utils.ex:504 #, elixir-format msgid "Comment must be up to %{max_size} characters" -msgstr "" +msgstr "I commenti posso al massimo consistere di %{max_size} caratteri" #: lib/pleroma/config/config_db.ex:222 #, elixir-format msgid "Config with params %{params} not found" -msgstr "" +msgstr "Configurazione con parametri %{max_size} non trovata" #: lib/pleroma/web/common_api/common_api.ex:95 #, elixir-format msgid "Could not delete" -msgstr "" +msgstr "Non eliminato" #: lib/pleroma/web/common_api/common_api.ex:141 #, elixir-format msgid "Could not favorite" -msgstr "" +msgstr "Non gradito" #: lib/pleroma/web/common_api/common_api.ex:370 #, elixir-format msgid "Could not pin" -msgstr "" +msgstr "Non intestato" #: lib/pleroma/web/common_api/common_api.ex:112 #, elixir-format msgid "Could not repeat" -msgstr "" +msgstr "Non ripetuto" #: lib/pleroma/web/common_api/common_api.ex:188 #, elixir-format msgid "Could not unfavorite" -msgstr "" +msgstr "Non sgradito" #: lib/pleroma/web/common_api/common_api.ex:380 #, elixir-format msgid "Could not unpin" -msgstr "" +msgstr "Non de-intestato" #: lib/pleroma/web/common_api/common_api.ex:126 #, elixir-format msgid "Could not unrepeat" -msgstr "" +msgstr "Non de-ripetuto" #: lib/pleroma/web/common_api/common_api.ex:428 #: lib/pleroma/web/common_api/common_api.ex:437 #, elixir-format msgid "Could not update state" -msgstr "" +msgstr "Non aggiornato" #: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 #, elixir-format msgid "Error." -msgstr "" +msgstr "Errore." #: lib/pleroma/web/twitter_api/twitter_api.ex:106 #, elixir-format msgid "Invalid CAPTCHA" -msgstr "" +msgstr "CAPTCHA invalido" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 #: lib/pleroma/web/oauth/oauth_controller.ex:569 #, elixir-format msgid "Invalid credentials" -msgstr "" +msgstr "Credenziali invalide" #: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 #, elixir-format msgid "Invalid credentials." -msgstr "" +msgstr "Credenziali invalide." #: lib/pleroma/web/common_api/common_api.ex:265 #, elixir-format msgid "Invalid indices" -msgstr "" +msgstr "Indici invalidi" #: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 #, elixir-format msgid "Invalid parameters" -msgstr "" +msgstr "Parametri invalidi" #: lib/pleroma/web/common_api/utils.ex:411 #, elixir-format msgid "Invalid password." -msgstr "" +msgstr "Parola d'ordine invalida." #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 #, elixir-format msgid "Invalid request" -msgstr "" +msgstr "Richiesta invalida" #: lib/pleroma/web/twitter_api/twitter_api.ex:109 #, elixir-format msgid "Kocaptcha service unavailable" -msgstr "" +msgstr "Servizio Kocaptcha non disponibile" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 #, elixir-format msgid "Missing parameters" -msgstr "" +msgstr "Parametri mancanti" #: lib/pleroma/web/common_api/utils.ex:540 #, elixir-format msgid "No such conversation" -msgstr "" +msgstr "Conversazione inesistente" #: lib/pleroma/web/admin_api/admin_api_controller.ex:439 #: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 #, elixir-format msgid "No such permission_group" -msgstr "" +msgstr "permission_group non esistente" #: lib/pleroma/plugs/uploaded_media.ex:74 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 #: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 #, elixir-format msgid "Not found" -msgstr "" +msgstr "Non trovato" #: lib/pleroma/web/common_api/common_api.ex:241 #, elixir-format msgid "Poll's author can't vote" -msgstr "" +msgstr "L'autore del sondaggio non può votare" #: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 #: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 @@ -288,215 +288,215 @@ msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 #, elixir-format msgid "Record not found" -msgstr "" +msgstr "Voce non trovata" #: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 #: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 #: lib/pleroma/web/ostatus/ostatus_controller.ex:149 #, elixir-format msgid "Something went wrong" -msgstr "" +msgstr "C'è stato un problema" #: lib/pleroma/web/common_api/activity_draft.ex:107 #, elixir-format msgid "The message visibility must be direct" -msgstr "" +msgstr "Il messaggio dev'essere privato" #: lib/pleroma/web/common_api/utils.ex:566 #, elixir-format msgid "The status is over the character limit" -msgstr "" +msgstr "Il messaggio ha superato la lunghezza massima" #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 #, elixir-format msgid "This resource requires authentication." -msgstr "" +msgstr "Accedi per leggere." #: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 #, elixir-format msgid "Throttled" -msgstr "" +msgstr "Strozzato" #: lib/pleroma/web/common_api/common_api.ex:266 #, elixir-format msgid "Too many choices" -msgstr "" +msgstr "Troppe alternative" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 #, elixir-format msgid "Unhandled activity type" -msgstr "" +msgstr "Tipo di attività non gestibile" #: lib/pleroma/web/admin_api/admin_api_controller.ex:536 #, elixir-format msgid "You can't revoke your own admin status." -msgstr "" +msgstr "Non puoi divestirti da solo." #: lib/pleroma/web/oauth/oauth_controller.ex:218 #: lib/pleroma/web/oauth/oauth_controller.ex:309 #, elixir-format msgid "Your account is currently disabled" -msgstr "" +msgstr "Il tuo profilo è attualmente disabilitato" #: lib/pleroma/web/oauth/oauth_controller.ex:180 #: lib/pleroma/web/oauth/oauth_controller.ex:332 #, elixir-format msgid "Your login is missing a confirmed e-mail address" -msgstr "" +msgstr "Devi aggiungere un indirizzo email valido" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 #, elixir-format msgid "can't read inbox of %{nickname} as %{as_nickname}" -msgstr "" +msgstr "non puoi leggere i messaggi privati di %{nickname} come %{as_nickname}" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 #, elixir-format msgid "can't update outbox of %{nickname} as %{as_nickname}" -msgstr "" +msgstr "non puoi aggiornare gli inviati di %{nickname} come %{as_nickname}" #: lib/pleroma/web/common_api/common_api.ex:388 #, elixir-format msgid "conversation is already muted" -msgstr "" +msgstr "la conversazione è già zittita" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 #, elixir-format msgid "error" -msgstr "" +msgstr "errore" #: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 #, elixir-format msgid "mascots can only be images" -msgstr "" +msgstr "le mascotte possono solo essere immagini" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 #, elixir-format msgid "not found" -msgstr "" +msgstr "non trovato" #: lib/pleroma/web/oauth/oauth_controller.ex:395 #, elixir-format msgid "Bad OAuth request." -msgstr "" +msgstr "Richiesta OAuth malformata." #: lib/pleroma/web/twitter_api/twitter_api.ex:115 #, elixir-format msgid "CAPTCHA already used" -msgstr "" +msgstr "CAPTCHA già utilizzato" #: lib/pleroma/web/twitter_api/twitter_api.ex:112 #, elixir-format msgid "CAPTCHA expired" -msgstr "" +msgstr "CAPTCHA scaduto" #: lib/pleroma/plugs/uploaded_media.ex:55 #, elixir-format msgid "Failed" -msgstr "" +msgstr "Fallito" #: lib/pleroma/web/oauth/oauth_controller.ex:411 #, elixir-format msgid "Failed to authenticate: %{message}." -msgstr "" +msgstr "Autenticazione fallita per: %{message}." #: lib/pleroma/web/oauth/oauth_controller.ex:442 #, elixir-format msgid "Failed to set up user account." -msgstr "" +msgstr "Profilo utente non creato." #: lib/pleroma/plugs/oauth_scopes_plug.ex:38 #, elixir-format msgid "Insufficient permissions: %{permissions}." -msgstr "" +msgstr "Permessi insufficienti: %{permissions}." #: lib/pleroma/plugs/uploaded_media.ex:94 #, elixir-format msgid "Internal Error" -msgstr "" +msgstr "Errore interno" #: lib/pleroma/web/oauth/fallback_controller.ex:22 #: lib/pleroma/web/oauth/fallback_controller.ex:29 #, elixir-format msgid "Invalid Username/Password" -msgstr "" +msgstr "Nome utente/parola d'ordine invalidi" #: lib/pleroma/web/twitter_api/twitter_api.ex:118 #, elixir-format msgid "Invalid answer data" -msgstr "" +msgstr "Risposta malformata" #: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 #, elixir-format msgid "Nodeinfo schema version not handled" -msgstr "" +msgstr "Versione schema nodeinfo non compatibile" #: lib/pleroma/web/oauth/oauth_controller.ex:169 #, elixir-format msgid "This action is outside the authorized scopes" -msgstr "" +msgstr "Quest'azione non è consentita in questa visibilità" #: lib/pleroma/web/oauth/fallback_controller.ex:14 #, elixir-format msgid "Unknown error, please check the details and try again." -msgstr "" +msgstr "Errore sconosciuto, controlla i dettagli e riprova." #: lib/pleroma/web/oauth/oauth_controller.ex:116 #: lib/pleroma/web/oauth/oauth_controller.ex:155 #, elixir-format msgid "Unlisted redirect_uri." -msgstr "" +msgstr "redirect_uri nascosto." #: lib/pleroma/web/oauth/oauth_controller.ex:391 #, elixir-format msgid "Unsupported OAuth provider: %{provider}." -msgstr "" +msgstr "Gestore OAuth non supportato: %{provider}." #: lib/pleroma/uploaders/uploader.ex:72 #, elixir-format msgid "Uploader callback timeout" -msgstr "" +msgstr "Callback caricatmento scaduta" #: lib/pleroma/web/uploader_controller.ex:23 #, elixir-format msgid "bad request" -msgstr "" +msgstr "richiesta malformata" #: lib/pleroma/web/twitter_api/twitter_api.ex:103 #, elixir-format msgid "CAPTCHA Error" -msgstr "" +msgstr "Errore CAPTCHA" #: lib/pleroma/web/common_api/common_api.ex:200 #, elixir-format msgid "Could not add reaction emoji" -msgstr "" +msgstr "Reazione emoji non riuscita" #: lib/pleroma/web/common_api/common_api.ex:211 #, elixir-format msgid "Could not remove reaction emoji" -msgstr "" +msgstr "Rimozione reazione non riuscita" #: lib/pleroma/web/twitter_api/twitter_api.ex:129 #, elixir-format msgid "Invalid CAPTCHA (Missing parameter: %{name})" -msgstr "" +msgstr "CAPTCHA invalido (Parametro mancante: %{name})" #: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 #, elixir-format msgid "List not found" -msgstr "" +msgstr "Lista non trovata" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 #, elixir-format msgid "Missing parameter: %{name}" -msgstr "" +msgstr "Parametro mancante: %{name}" #: lib/pleroma/web/oauth/oauth_controller.ex:207 #: lib/pleroma/web/oauth/oauth_controller.ex:322 #, elixir-format msgid "Password reset is required" -msgstr "" +msgstr "Necessario reimpostare parola d'ordine" #: lib/pleroma/tests/auth_test_controller.ex:9 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 @@ -528,53 +528,58 @@ msgstr "" #, elixir-format msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." msgstr "" +"Sicurezza violata: il controllo autorizzazioni di OAuth non è stato svolto " +"né saltato." #: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 #, elixir-format msgid "Two-factor authentication enabled, you must use a access token." msgstr "" +"Autenticazione bifattoriale abilitata, devi utilizzare una chiave d'accesso." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 #, elixir-format msgid "Unexpected error occurred while adding file to pack." -msgstr "" +msgstr "Errore inaspettato durante l'aggiunta del file al pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 #, elixir-format msgid "Unexpected error occurred while creating pack." -msgstr "" +msgstr "Errore inaspettato durante la creazione del pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 #, elixir-format msgid "Unexpected error occurred while removing file from pack." -msgstr "" +msgstr "Errore inaspettato durante la rimozione del file dal pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 #, elixir-format msgid "Unexpected error occurred while updating file in pack." -msgstr "" +msgstr "Errore inaspettato durante l'aggiornamento del file nel pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 #, elixir-format msgid "Unexpected error occurred while updating pack metadata." -msgstr "" +msgstr "Errore inaspettato durante l'aggiornamento dei metadati del pacchetto." #: lib/pleroma/plugs/user_is_admin_plug.ex:40 #, elixir-format msgid "User is not an admin or OAuth admin scope is not granted." msgstr "" +"L'utente non è un amministratore o non ha ricevuto questa autorizzazione " +"OAuth." #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 #, elixir-format msgid "Web push subscription is disabled on this Pleroma instance" -msgstr "" +msgstr "Gli aggiornamenti web push non sono disponibili in questa stanza" #: lib/pleroma/web/admin_api/admin_api_controller.ex:502 #, elixir-format msgid "You can't revoke your own admin/moderator status." -msgstr "" +msgstr "Non puoi divestire te stesso." #: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 #, elixir-format msgid "authorization required for timeline view" -msgstr "" +msgstr "autorizzazione richiesta per vedere la sequenza" -- cgit v1.2.3 From 328062308a2cfbed151a63d8166853a1965c59db Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 10 Jul 2020 11:41:10 +0200 Subject: Update frontend --- priv/static/index.html | 2 +- .../static/static/css/app.493b9b5acee37ba97824.css | Bin 5568 -> 0 bytes .../static/css/app.493b9b5acee37ba97824.css.map | 1 - .../static/static/css/app.77b1644622e3bae24b6b.css | Bin 0 -> 5616 bytes .../static/css/app.77b1644622e3bae24b6b.css.map | 1 + priv/static/static/font/fontello.1594134783339.eot | Bin 24332 -> 0 bytes priv/static/static/font/fontello.1594134783339.svg | 136 -------------------- priv/static/static/font/fontello.1594134783339.ttf | Bin 24164 -> 0 bytes .../static/static/font/fontello.1594134783339.woff | Bin 14772 -> 0 bytes .../static/font/fontello.1594134783339.woff2 | Bin 12416 -> 0 bytes priv/static/static/font/fontello.1594374054351.eot | Bin 0 -> 24524 bytes priv/static/static/font/fontello.1594374054351.svg | 138 +++++++++++++++++++++ priv/static/static/font/fontello.1594374054351.ttf | Bin 0 -> 24356 bytes .../static/static/font/fontello.1594374054351.woff | Bin 0 -> 14912 bytes .../static/font/fontello.1594374054351.woff2 | Bin 0 -> 12540 bytes priv/static/static/fontello.1594374054351.css | Bin 0 -> 3736 bytes priv/static/static/fontello.json | 6 + priv/static/static/js/10.2823375ec309b971aaea.js | Bin 0 -> 23120 bytes .../static/js/10.2823375ec309b971aaea.js.map | Bin 0 -> 113 bytes priv/static/static/js/10.4a22c77e34edcd678d2f.js | Bin 23120 -> 0 bytes .../static/js/10.4a22c77e34edcd678d2f.js.map | Bin 113 -> 0 bytes priv/static/static/js/11.2cb4b0f72a4654070a58.js | Bin 0 -> 16564 bytes .../static/js/11.2cb4b0f72a4654070a58.js.map | Bin 0 -> 113 bytes priv/static/static/js/11.787aa24e4fd5caef9adb.js | Bin 16564 -> 0 bytes .../static/js/11.787aa24e4fd5caef9adb.js.map | Bin 113 -> 0 bytes priv/static/static/js/12.35a510cf14233f0c6e1f.js | Bin 22582 -> 0 bytes .../static/js/12.35a510cf14233f0c6e1f.js.map | Bin 113 -> 0 bytes priv/static/static/js/12.500b3e4676dd47599a58.js | Bin 0 -> 22582 bytes .../static/js/12.500b3e4676dd47599a58.js.map | Bin 0 -> 113 bytes priv/static/static/js/13.3ef79a2643680080d28f.js | Bin 0 -> 26143 bytes .../static/js/13.3ef79a2643680080d28f.js.map | Bin 0 -> 113 bytes priv/static/static/js/13.7931a609d62a42678085.js | Bin 26143 -> 0 bytes .../static/js/13.7931a609d62a42678085.js.map | Bin 113 -> 0 bytes priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js | Bin 0 -> 28652 bytes .../static/js/14.b7f6eb3ea71d2ac2bb41.js.map | Bin 0 -> 113 bytes priv/static/static/js/14.cc092634462fd2a4cfbc.js | Bin 28652 -> 0 bytes .../static/js/14.cc092634462fd2a4cfbc.js.map | Bin 113 -> 0 bytes priv/static/static/js/15.d814a29a970070494722.js | Bin 0 -> 7939 bytes .../static/js/15.d814a29a970070494722.js.map | Bin 0 -> 113 bytes priv/static/static/js/15.e9ddc5dfd38426398e00.js | Bin 7939 -> 0 bytes .../static/js/15.e9ddc5dfd38426398e00.js.map | Bin 113 -> 0 bytes priv/static/static/js/16.017fa510b293035ac370.js | Bin 0 -> 15892 bytes .../static/js/16.017fa510b293035ac370.js.map | Bin 0 -> 113 bytes priv/static/static/js/16.476e7809b8593264469e.js | Bin 15892 -> 0 bytes .../static/js/16.476e7809b8593264469e.js.map | Bin 113 -> 0 bytes priv/static/static/js/17.acbe4c09f05ae56c76a2.js | Bin 2234 -> 0 bytes .../static/js/17.acbe4c09f05ae56c76a2.js.map | Bin 113 -> 0 bytes priv/static/static/js/17.c63932b65417ee7346a3.js | Bin 0 -> 2234 bytes .../static/js/17.c63932b65417ee7346a3.js.map | Bin 0 -> 113 bytes priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js | Bin 23585 -> 0 bytes .../static/js/18.a8ccd7f2a47c5c94b3b9.js.map | Bin 113 -> 0 bytes priv/static/static/js/18.fd12f9746a55aa24a8b7.js | Bin 0 -> 23585 bytes .../static/js/18.fd12f9746a55aa24a8b7.js.map | Bin 0 -> 113 bytes priv/static/static/js/19.3adebd64964c92700074.js | Bin 0 -> 32200 bytes .../static/js/19.3adebd64964c92700074.js.map | Bin 0 -> 113 bytes priv/static/static/js/19.5894e9c12b4fd5e45872.js | Bin 32200 -> 0 bytes .../static/js/19.5894e9c12b4fd5e45872.js.map | Bin 113 -> 0 bytes priv/static/static/js/2.d81ca020d6885c6c3b03.js | Bin 0 -> 179851 bytes .../static/static/js/2.d81ca020d6885c6c3b03.js.map | Bin 0 -> 461434 bytes priv/static/static/js/2.f8dee9318a6f84ea92c3.js | Bin 174070 -> 0 bytes .../static/static/js/2.f8dee9318a6f84ea92c3.js.map | Bin 450037 -> 0 bytes priv/static/static/js/20.43b5b27b0f68474f3b72.js | Bin 26951 -> 0 bytes .../static/js/20.43b5b27b0f68474f3b72.js.map | Bin 113 -> 0 bytes priv/static/static/js/20.e0c3ad29d59470506c04.js | Bin 0 -> 26951 bytes .../static/js/20.e0c3ad29d59470506c04.js.map | Bin 0 -> 113 bytes priv/static/static/js/21.72b45b01be9d0f4c62ce.js | Bin 13310 -> 0 bytes .../static/js/21.72b45b01be9d0f4c62ce.js.map | Bin 113 -> 0 bytes priv/static/static/js/21.849ecc09a1d58bdc64c6.js | Bin 0 -> 13310 bytes .../static/js/21.849ecc09a1d58bdc64c6.js.map | Bin 0 -> 113 bytes priv/static/static/js/22.26f13a22ad57a0d14670.js | Bin 20130 -> 0 bytes .../static/js/22.26f13a22ad57a0d14670.js.map | Bin 113 -> 0 bytes priv/static/static/js/22.8782f133c9f66d3f2bbe.js | Bin 0 -> 20130 bytes .../static/js/22.8782f133c9f66d3f2bbe.js.map | Bin 0 -> 113 bytes priv/static/static/js/23.2653bf91bc77c2ed0160.js | Bin 0 -> 28187 bytes .../static/js/23.2653bf91bc77c2ed0160.js.map | Bin 0 -> 113 bytes priv/static/static/js/23.91a60b775352a806f887.js | Bin 28187 -> 0 bytes .../static/js/23.91a60b775352a806f887.js.map | Bin 113 -> 0 bytes priv/static/static/js/24.c8d8438aac954d4707ac.js | Bin 18949 -> 0 bytes .../static/js/24.c8d8438aac954d4707ac.js.map | Bin 113 -> 0 bytes priv/static/static/js/24.f931d864a2297d880a9a.js | Bin 0 -> 18949 bytes .../static/js/24.f931d864a2297d880a9a.js.map | Bin 0 -> 113 bytes priv/static/static/js/25.79ac9e020d571b67f02a.js | Bin 27408 -> 0 bytes .../static/js/25.79ac9e020d571b67f02a.js.map | Bin 113 -> 0 bytes priv/static/static/js/25.886acc9ba83c64659279.js | Bin 0 -> 27408 bytes .../static/js/25.886acc9ba83c64659279.js.map | Bin 0 -> 113 bytes priv/static/static/js/26.3af8f54349f672f2c7c8.js | Bin 14415 -> 0 bytes .../static/js/26.3af8f54349f672f2c7c8.js.map | Bin 113 -> 0 bytes priv/static/static/js/26.e15b1645079c72c60586.js | Bin 0 -> 14415 bytes .../static/js/26.e15b1645079c72c60586.js.map | Bin 0 -> 113 bytes priv/static/static/js/27.51287d408313da67b0b8.js | Bin 2175 -> 0 bytes .../static/js/27.51287d408313da67b0b8.js.map | Bin 113 -> 0 bytes priv/static/static/js/27.7b41e5953f74af7fddd1.js | Bin 0 -> 2175 bytes .../static/js/27.7b41e5953f74af7fddd1.js.map | Bin 0 -> 113 bytes priv/static/static/js/28.4f39e562aaceaa01e883.js | Bin 0 -> 25778 bytes .../static/js/28.4f39e562aaceaa01e883.js.map | Bin 0 -> 113 bytes priv/static/static/js/28.be5118beb1098a81332d.js | Bin 25778 -> 0 bytes .../static/js/28.be5118beb1098a81332d.js.map | Bin 113 -> 0 bytes priv/static/static/js/29.084f6fb0987d3862d410.js | Bin 24135 -> 0 bytes .../static/js/29.084f6fb0987d3862d410.js.map | Bin 113 -> 0 bytes priv/static/static/js/29.137e2a68b558eed58152.js | Bin 0 -> 24135 bytes .../static/js/29.137e2a68b558eed58152.js.map | Bin 0 -> 113 bytes priv/static/static/js/3.56898c1005d9ba1b8d4a.js | Bin 0 -> 78761 bytes .../static/static/js/3.56898c1005d9ba1b8d4a.js.map | Bin 0 -> 332972 bytes priv/static/static/js/3.e1f7d368d5840e12e850.js | Bin 78761 -> 0 bytes .../static/static/js/3.e1f7d368d5840e12e850.js.map | Bin 332972 -> 0 bytes priv/static/static/js/30.6e6d63411def2e175d11.js | Bin 21485 -> 0 bytes .../static/js/30.6e6d63411def2e175d11.js.map | Bin 113 -> 0 bytes priv/static/static/js/30.73e09f3b43617410dec7.js | Bin 0 -> 21485 bytes .../static/js/30.73e09f3b43617410dec7.js.map | Bin 0 -> 113 bytes priv/static/static/js/4.2d3bef896b463484e6eb.js | Bin 0 -> 2177 bytes .../static/static/js/4.2d3bef896b463484e6eb.js.map | Bin 0 -> 7940 bytes priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js | Bin 2177 -> 0 bytes .../static/static/js/4.c3f92d0b6ff90b36e3f5.js.map | Bin 7940 -> 0 bytes priv/static/static/js/5.2b4a2787bacdd3d910db.js | Bin 0 -> 7028 bytes .../static/static/js/5.2b4a2787bacdd3d910db.js.map | Bin 0 -> 112 bytes priv/static/static/js/5.d30e50cd5c52d54ffdc9.js | Bin 7028 -> 0 bytes .../static/static/js/5.d30e50cd5c52d54ffdc9.js.map | Bin 112 -> 0 bytes priv/static/static/js/6.9c94bc0cc78979694cf4.js | Bin 0 -> 7955 bytes .../static/static/js/6.9c94bc0cc78979694cf4.js.map | Bin 0 -> 112 bytes priv/static/static/js/6.fa6d5c2d85d44f0ba121.js | Bin 7955 -> 0 bytes .../static/static/js/6.fa6d5c2d85d44f0ba121.js.map | Bin 112 -> 0 bytes priv/static/static/js/7.b4ac57fd946a3a189047.js | Bin 0 -> 15765 bytes .../static/static/js/7.b4ac57fd946a3a189047.js.map | Bin 0 -> 112 bytes priv/static/static/js/7.d558a086622f668601a6.js | Bin 15765 -> 0 bytes .../static/static/js/7.d558a086622f668601a6.js.map | Bin 112 -> 0 bytes priv/static/static/js/8.615136ce6c34a6b96a29.js | Bin 21966 -> 0 bytes .../static/static/js/8.615136ce6c34a6b96a29.js.map | Bin 112 -> 0 bytes priv/static/static/js/8.e03e32ca713d01db0433.js | Bin 0 -> 21966 bytes .../static/static/js/8.e03e32ca713d01db0433.js.map | Bin 0 -> 112 bytes priv/static/static/js/9.72d903ca8e0c5a532b87.js | Bin 0 -> 13880 bytes .../static/static/js/9.72d903ca8e0c5a532b87.js.map | Bin 0 -> 112 bytes priv/static/static/js/9.ef4eb9703f9aee67515e.js | Bin 13880 -> 0 bytes .../static/static/js/9.ef4eb9703f9aee67515e.js.map | Bin 112 -> 0 bytes priv/static/static/js/app.1e68e208590653dab5aa.js | Bin 0 -> 571655 bytes .../static/js/app.1e68e208590653dab5aa.js.map | Bin 0 -> 1463721 bytes priv/static/static/js/app.53001fa190f37cf2743e.js | Bin 517071 -> 0 bytes .../static/js/app.53001fa190f37cf2743e.js.map | Bin 1335479 -> 0 bytes .../static/js/vendors~app.247dc52c7abe6a0dab87.js | Bin 0 -> 304076 bytes .../js/vendors~app.247dc52c7abe6a0dab87.js.map | Bin 0 -> 1274957 bytes .../static/js/vendors~app.8837fb59589d1dd6acda.js | Bin 303823 -> 0 bytes .../js/vendors~app.8837fb59589d1dd6acda.js.map | Bin 1271967 -> 0 bytes priv/static/static/terms-of-service.html | 2 +- priv/static/sw-pleroma.js | Bin 181342 -> 181435 bytes priv/static/sw-pleroma.js.map | Bin 694047 -> 695166 bytes 144 files changed, 147 insertions(+), 139 deletions(-) delete mode 100644 priv/static/static/css/app.493b9b5acee37ba97824.css delete mode 100644 priv/static/static/css/app.493b9b5acee37ba97824.css.map create mode 100644 priv/static/static/css/app.77b1644622e3bae24b6b.css create mode 100644 priv/static/static/css/app.77b1644622e3bae24b6b.css.map delete mode 100644 priv/static/static/font/fontello.1594134783339.eot delete mode 100644 priv/static/static/font/fontello.1594134783339.svg delete mode 100644 priv/static/static/font/fontello.1594134783339.ttf delete mode 100644 priv/static/static/font/fontello.1594134783339.woff delete mode 100644 priv/static/static/font/fontello.1594134783339.woff2 create mode 100644 priv/static/static/font/fontello.1594374054351.eot create mode 100644 priv/static/static/font/fontello.1594374054351.svg create mode 100644 priv/static/static/font/fontello.1594374054351.ttf create mode 100644 priv/static/static/font/fontello.1594374054351.woff create mode 100644 priv/static/static/font/fontello.1594374054351.woff2 create mode 100644 priv/static/static/fontello.1594374054351.css create mode 100644 priv/static/static/js/10.2823375ec309b971aaea.js create mode 100644 priv/static/static/js/10.2823375ec309b971aaea.js.map delete mode 100644 priv/static/static/js/10.4a22c77e34edcd678d2f.js delete mode 100644 priv/static/static/js/10.4a22c77e34edcd678d2f.js.map create mode 100644 priv/static/static/js/11.2cb4b0f72a4654070a58.js create mode 100644 priv/static/static/js/11.2cb4b0f72a4654070a58.js.map delete mode 100644 priv/static/static/js/11.787aa24e4fd5caef9adb.js delete mode 100644 priv/static/static/js/11.787aa24e4fd5caef9adb.js.map delete mode 100644 priv/static/static/js/12.35a510cf14233f0c6e1f.js delete mode 100644 priv/static/static/js/12.35a510cf14233f0c6e1f.js.map create mode 100644 priv/static/static/js/12.500b3e4676dd47599a58.js create mode 100644 priv/static/static/js/12.500b3e4676dd47599a58.js.map create mode 100644 priv/static/static/js/13.3ef79a2643680080d28f.js create mode 100644 priv/static/static/js/13.3ef79a2643680080d28f.js.map delete mode 100644 priv/static/static/js/13.7931a609d62a42678085.js delete mode 100644 priv/static/static/js/13.7931a609d62a42678085.js.map create mode 100644 priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js create mode 100644 priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map delete mode 100644 priv/static/static/js/14.cc092634462fd2a4cfbc.js delete mode 100644 priv/static/static/js/14.cc092634462fd2a4cfbc.js.map create mode 100644 priv/static/static/js/15.d814a29a970070494722.js create mode 100644 priv/static/static/js/15.d814a29a970070494722.js.map delete mode 100644 priv/static/static/js/15.e9ddc5dfd38426398e00.js delete mode 100644 priv/static/static/js/15.e9ddc5dfd38426398e00.js.map create mode 100644 priv/static/static/js/16.017fa510b293035ac370.js create mode 100644 priv/static/static/js/16.017fa510b293035ac370.js.map delete mode 100644 priv/static/static/js/16.476e7809b8593264469e.js delete mode 100644 priv/static/static/js/16.476e7809b8593264469e.js.map delete mode 100644 priv/static/static/js/17.acbe4c09f05ae56c76a2.js delete mode 100644 priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map create mode 100644 priv/static/static/js/17.c63932b65417ee7346a3.js create mode 100644 priv/static/static/js/17.c63932b65417ee7346a3.js.map delete mode 100644 priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js delete mode 100644 priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map create mode 100644 priv/static/static/js/18.fd12f9746a55aa24a8b7.js create mode 100644 priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map create mode 100644 priv/static/static/js/19.3adebd64964c92700074.js create mode 100644 priv/static/static/js/19.3adebd64964c92700074.js.map delete mode 100644 priv/static/static/js/19.5894e9c12b4fd5e45872.js delete mode 100644 priv/static/static/js/19.5894e9c12b4fd5e45872.js.map create mode 100644 priv/static/static/js/2.d81ca020d6885c6c3b03.js create mode 100644 priv/static/static/js/2.d81ca020d6885c6c3b03.js.map delete mode 100644 priv/static/static/js/2.f8dee9318a6f84ea92c3.js delete mode 100644 priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map delete mode 100644 priv/static/static/js/20.43b5b27b0f68474f3b72.js delete mode 100644 priv/static/static/js/20.43b5b27b0f68474f3b72.js.map create mode 100644 priv/static/static/js/20.e0c3ad29d59470506c04.js create mode 100644 priv/static/static/js/20.e0c3ad29d59470506c04.js.map delete mode 100644 priv/static/static/js/21.72b45b01be9d0f4c62ce.js delete mode 100644 priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map create mode 100644 priv/static/static/js/21.849ecc09a1d58bdc64c6.js create mode 100644 priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map delete mode 100644 priv/static/static/js/22.26f13a22ad57a0d14670.js delete mode 100644 priv/static/static/js/22.26f13a22ad57a0d14670.js.map create mode 100644 priv/static/static/js/22.8782f133c9f66d3f2bbe.js create mode 100644 priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map create mode 100644 priv/static/static/js/23.2653bf91bc77c2ed0160.js create mode 100644 priv/static/static/js/23.2653bf91bc77c2ed0160.js.map delete mode 100644 priv/static/static/js/23.91a60b775352a806f887.js delete mode 100644 priv/static/static/js/23.91a60b775352a806f887.js.map delete mode 100644 priv/static/static/js/24.c8d8438aac954d4707ac.js delete mode 100644 priv/static/static/js/24.c8d8438aac954d4707ac.js.map create mode 100644 priv/static/static/js/24.f931d864a2297d880a9a.js create mode 100644 priv/static/static/js/24.f931d864a2297d880a9a.js.map delete mode 100644 priv/static/static/js/25.79ac9e020d571b67f02a.js delete mode 100644 priv/static/static/js/25.79ac9e020d571b67f02a.js.map create mode 100644 priv/static/static/js/25.886acc9ba83c64659279.js create mode 100644 priv/static/static/js/25.886acc9ba83c64659279.js.map delete mode 100644 priv/static/static/js/26.3af8f54349f672f2c7c8.js delete mode 100644 priv/static/static/js/26.3af8f54349f672f2c7c8.js.map create mode 100644 priv/static/static/js/26.e15b1645079c72c60586.js create mode 100644 priv/static/static/js/26.e15b1645079c72c60586.js.map delete mode 100644 priv/static/static/js/27.51287d408313da67b0b8.js delete mode 100644 priv/static/static/js/27.51287d408313da67b0b8.js.map create mode 100644 priv/static/static/js/27.7b41e5953f74af7fddd1.js create mode 100644 priv/static/static/js/27.7b41e5953f74af7fddd1.js.map create mode 100644 priv/static/static/js/28.4f39e562aaceaa01e883.js create mode 100644 priv/static/static/js/28.4f39e562aaceaa01e883.js.map delete mode 100644 priv/static/static/js/28.be5118beb1098a81332d.js delete mode 100644 priv/static/static/js/28.be5118beb1098a81332d.js.map delete mode 100644 priv/static/static/js/29.084f6fb0987d3862d410.js delete mode 100644 priv/static/static/js/29.084f6fb0987d3862d410.js.map create mode 100644 priv/static/static/js/29.137e2a68b558eed58152.js create mode 100644 priv/static/static/js/29.137e2a68b558eed58152.js.map create mode 100644 priv/static/static/js/3.56898c1005d9ba1b8d4a.js create mode 100644 priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map delete mode 100644 priv/static/static/js/3.e1f7d368d5840e12e850.js delete mode 100644 priv/static/static/js/3.e1f7d368d5840e12e850.js.map delete mode 100644 priv/static/static/js/30.6e6d63411def2e175d11.js delete mode 100644 priv/static/static/js/30.6e6d63411def2e175d11.js.map create mode 100644 priv/static/static/js/30.73e09f3b43617410dec7.js create mode 100644 priv/static/static/js/30.73e09f3b43617410dec7.js.map create mode 100644 priv/static/static/js/4.2d3bef896b463484e6eb.js create mode 100644 priv/static/static/js/4.2d3bef896b463484e6eb.js.map delete mode 100644 priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js delete mode 100644 priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map create mode 100644 priv/static/static/js/5.2b4a2787bacdd3d910db.js create mode 100644 priv/static/static/js/5.2b4a2787bacdd3d910db.js.map delete mode 100644 priv/static/static/js/5.d30e50cd5c52d54ffdc9.js delete mode 100644 priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map create mode 100644 priv/static/static/js/6.9c94bc0cc78979694cf4.js create mode 100644 priv/static/static/js/6.9c94bc0cc78979694cf4.js.map delete mode 100644 priv/static/static/js/6.fa6d5c2d85d44f0ba121.js delete mode 100644 priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map create mode 100644 priv/static/static/js/7.b4ac57fd946a3a189047.js create mode 100644 priv/static/static/js/7.b4ac57fd946a3a189047.js.map delete mode 100644 priv/static/static/js/7.d558a086622f668601a6.js delete mode 100644 priv/static/static/js/7.d558a086622f668601a6.js.map delete mode 100644 priv/static/static/js/8.615136ce6c34a6b96a29.js delete mode 100644 priv/static/static/js/8.615136ce6c34a6b96a29.js.map create mode 100644 priv/static/static/js/8.e03e32ca713d01db0433.js create mode 100644 priv/static/static/js/8.e03e32ca713d01db0433.js.map create mode 100644 priv/static/static/js/9.72d903ca8e0c5a532b87.js create mode 100644 priv/static/static/js/9.72d903ca8e0c5a532b87.js.map delete mode 100644 priv/static/static/js/9.ef4eb9703f9aee67515e.js delete mode 100644 priv/static/static/js/9.ef4eb9703f9aee67515e.js.map create mode 100644 priv/static/static/js/app.1e68e208590653dab5aa.js create mode 100644 priv/static/static/js/app.1e68e208590653dab5aa.js.map delete mode 100644 priv/static/static/js/app.53001fa190f37cf2743e.js delete mode 100644 priv/static/static/js/app.53001fa190f37cf2743e.js.map create mode 100644 priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js create mode 100644 priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map delete mode 100644 priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js delete mode 100644 priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map diff --git a/priv/static/index.html b/priv/static/index.html index 3ef4baa26..80820166a 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma

    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/css/app.493b9b5acee37ba97824.css b/priv/static/static/css/app.493b9b5acee37ba97824.css deleted file mode 100644 index f30033af6..000000000 Binary files a/priv/static/static/css/app.493b9b5acee37ba97824.css and /dev/null differ diff --git a/priv/static/static/css/app.493b9b5acee37ba97824.css.map b/priv/static/static/css/app.493b9b5acee37ba97824.css.map deleted file mode 100644 index 91399d605..000000000 --- a/priv/static/static/css/app.493b9b5acee37ba97824.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.493b9b5acee37ba97824.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css b/priv/static/static/css/app.77b1644622e3bae24b6b.css new file mode 100644 index 000000000..8038882c0 Binary files /dev/null and b/priv/static/static/css/app.77b1644622e3bae24b6b.css differ diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css.map b/priv/static/static/css/app.77b1644622e3bae24b6b.css.map new file mode 100644 index 000000000..4b042ef35 --- /dev/null +++ b/priv/static/static/css/app.77b1644622e3bae24b6b.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.77b1644622e3bae24b6b.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594134783339.eot b/priv/static/static/font/fontello.1594134783339.eot deleted file mode 100644 index bc98d606d..000000000 Binary files a/priv/static/static/font/fontello.1594134783339.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1594134783339.svg b/priv/static/static/font/fontello.1594134783339.svg deleted file mode 100644 index a5342209d..000000000 --- a/priv/static/static/font/fontello.1594134783339.svg +++ /dev/null @@ -1,136 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594134783339.ttf b/priv/static/static/font/fontello.1594134783339.ttf deleted file mode 100644 index 458e88f9e..000000000 Binary files a/priv/static/static/font/fontello.1594134783339.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1594134783339.woff b/priv/static/static/font/fontello.1594134783339.woff deleted file mode 100644 index 89a337131..000000000 Binary files a/priv/static/static/font/fontello.1594134783339.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1594134783339.woff2 b/priv/static/static/font/fontello.1594134783339.woff2 deleted file mode 100644 index 054169bd2..000000000 Binary files a/priv/static/static/font/fontello.1594134783339.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1594374054351.eot b/priv/static/static/font/fontello.1594374054351.eot new file mode 100644 index 000000000..62b619386 Binary files /dev/null and b/priv/static/static/font/fontello.1594374054351.eot differ diff --git a/priv/static/static/font/fontello.1594374054351.svg b/priv/static/static/font/fontello.1594374054351.svg new file mode 100644 index 000000000..71b5d70af --- /dev/null +++ b/priv/static/static/font/fontello.1594374054351.svg @@ -0,0 +1,138 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594374054351.ttf b/priv/static/static/font/fontello.1594374054351.ttf new file mode 100644 index 000000000..be55bef81 Binary files /dev/null and b/priv/static/static/font/fontello.1594374054351.ttf differ diff --git a/priv/static/static/font/fontello.1594374054351.woff b/priv/static/static/font/fontello.1594374054351.woff new file mode 100644 index 000000000..115945f70 Binary files /dev/null and b/priv/static/static/font/fontello.1594374054351.woff differ diff --git a/priv/static/static/font/fontello.1594374054351.woff2 b/priv/static/static/font/fontello.1594374054351.woff2 new file mode 100644 index 000000000..cb214aab3 Binary files /dev/null and b/priv/static/static/font/fontello.1594374054351.woff2 differ diff --git a/priv/static/static/fontello.1594374054351.css b/priv/static/static/fontello.1594374054351.css new file mode 100644 index 000000000..6dea8ee3e Binary files /dev/null and b/priv/static/static/fontello.1594374054351.css differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json index 5ef8544e2..706800cdb 100755 --- a/priv/static/static/fontello.json +++ b/priv/static/static/fontello.json @@ -399,6 +399,12 @@ "css": "doc", "code": 59433, "src": "fontawesome" + }, + { + "uid": "98d9c83c1ee7c2c25af784b518c522c5", + "css": "block", + "code": 59434, + "src": "fontawesome" } ] } \ No newline at end of file diff --git a/priv/static/static/js/10.2823375ec309b971aaea.js b/priv/static/static/js/10.2823375ec309b971aaea.js new file mode 100644 index 000000000..8f34c42ea Binary files /dev/null and b/priv/static/static/js/10.2823375ec309b971aaea.js differ diff --git a/priv/static/static/js/10.2823375ec309b971aaea.js.map b/priv/static/static/js/10.2823375ec309b971aaea.js.map new file mode 100644 index 000000000..8933e2336 Binary files /dev/null and b/priv/static/static/js/10.2823375ec309b971aaea.js.map differ diff --git a/priv/static/static/js/10.4a22c77e34edcd678d2f.js b/priv/static/static/js/10.4a22c77e34edcd678d2f.js deleted file mode 100644 index a1c395c42..000000000 Binary files a/priv/static/static/js/10.4a22c77e34edcd678d2f.js and /dev/null differ diff --git a/priv/static/static/js/10.4a22c77e34edcd678d2f.js.map b/priv/static/static/js/10.4a22c77e34edcd678d2f.js.map deleted file mode 100644 index 9c8b5e658..000000000 Binary files a/priv/static/static/js/10.4a22c77e34edcd678d2f.js.map and /dev/null differ diff --git a/priv/static/static/js/11.2cb4b0f72a4654070a58.js b/priv/static/static/js/11.2cb4b0f72a4654070a58.js new file mode 100644 index 000000000..03b0234f2 Binary files /dev/null and b/priv/static/static/js/11.2cb4b0f72a4654070a58.js differ diff --git a/priv/static/static/js/11.2cb4b0f72a4654070a58.js.map b/priv/static/static/js/11.2cb4b0f72a4654070a58.js.map new file mode 100644 index 000000000..b53e5e23a Binary files /dev/null and b/priv/static/static/js/11.2cb4b0f72a4654070a58.js.map differ diff --git a/priv/static/static/js/11.787aa24e4fd5caef9adb.js b/priv/static/static/js/11.787aa24e4fd5caef9adb.js deleted file mode 100644 index 938cbb64e..000000000 Binary files a/priv/static/static/js/11.787aa24e4fd5caef9adb.js and /dev/null differ diff --git a/priv/static/static/js/11.787aa24e4fd5caef9adb.js.map b/priv/static/static/js/11.787aa24e4fd5caef9adb.js.map deleted file mode 100644 index e376a0bbc..000000000 Binary files a/priv/static/static/js/11.787aa24e4fd5caef9adb.js.map and /dev/null differ diff --git a/priv/static/static/js/12.35a510cf14233f0c6e1f.js b/priv/static/static/js/12.35a510cf14233f0c6e1f.js deleted file mode 100644 index fe4799a2d..000000000 Binary files a/priv/static/static/js/12.35a510cf14233f0c6e1f.js and /dev/null differ diff --git a/priv/static/static/js/12.35a510cf14233f0c6e1f.js.map b/priv/static/static/js/12.35a510cf14233f0c6e1f.js.map deleted file mode 100644 index 21cc55e6f..000000000 Binary files a/priv/static/static/js/12.35a510cf14233f0c6e1f.js.map and /dev/null differ diff --git a/priv/static/static/js/12.500b3e4676dd47599a58.js b/priv/static/static/js/12.500b3e4676dd47599a58.js new file mode 100644 index 000000000..52dfbde92 Binary files /dev/null and b/priv/static/static/js/12.500b3e4676dd47599a58.js differ diff --git a/priv/static/static/js/12.500b3e4676dd47599a58.js.map b/priv/static/static/js/12.500b3e4676dd47599a58.js.map new file mode 100644 index 000000000..700da90b0 Binary files /dev/null and b/priv/static/static/js/12.500b3e4676dd47599a58.js.map differ diff --git a/priv/static/static/js/13.3ef79a2643680080d28f.js b/priv/static/static/js/13.3ef79a2643680080d28f.js new file mode 100644 index 000000000..4070d1f3f Binary files /dev/null and b/priv/static/static/js/13.3ef79a2643680080d28f.js differ diff --git a/priv/static/static/js/13.3ef79a2643680080d28f.js.map b/priv/static/static/js/13.3ef79a2643680080d28f.js.map new file mode 100644 index 000000000..bb2f24e3a Binary files /dev/null and b/priv/static/static/js/13.3ef79a2643680080d28f.js.map differ diff --git a/priv/static/static/js/13.7931a609d62a42678085.js b/priv/static/static/js/13.7931a609d62a42678085.js deleted file mode 100644 index 3fe95c23c..000000000 Binary files a/priv/static/static/js/13.7931a609d62a42678085.js and /dev/null differ diff --git a/priv/static/static/js/13.7931a609d62a42678085.js.map b/priv/static/static/js/13.7931a609d62a42678085.js.map deleted file mode 100644 index 8448af376..000000000 Binary files a/priv/static/static/js/13.7931a609d62a42678085.js.map and /dev/null differ diff --git a/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js b/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js new file mode 100644 index 000000000..316ba1291 Binary files /dev/null and b/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js differ diff --git a/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map b/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map new file mode 100644 index 000000000..07a26f298 Binary files /dev/null and b/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map differ diff --git a/priv/static/static/js/14.cc092634462fd2a4cfbc.js b/priv/static/static/js/14.cc092634462fd2a4cfbc.js deleted file mode 100644 index 42a179970..000000000 Binary files a/priv/static/static/js/14.cc092634462fd2a4cfbc.js and /dev/null differ diff --git a/priv/static/static/js/14.cc092634462fd2a4cfbc.js.map b/priv/static/static/js/14.cc092634462fd2a4cfbc.js.map deleted file mode 100644 index 97e151b4e..000000000 Binary files a/priv/static/static/js/14.cc092634462fd2a4cfbc.js.map and /dev/null differ diff --git a/priv/static/static/js/15.d814a29a970070494722.js b/priv/static/static/js/15.d814a29a970070494722.js new file mode 100644 index 000000000..17eaf5218 Binary files /dev/null and b/priv/static/static/js/15.d814a29a970070494722.js differ diff --git a/priv/static/static/js/15.d814a29a970070494722.js.map b/priv/static/static/js/15.d814a29a970070494722.js.map new file mode 100644 index 000000000..9792088bf Binary files /dev/null and b/priv/static/static/js/15.d814a29a970070494722.js.map differ diff --git a/priv/static/static/js/15.e9ddc5dfd38426398e00.js b/priv/static/static/js/15.e9ddc5dfd38426398e00.js deleted file mode 100644 index f03e74897..000000000 Binary files a/priv/static/static/js/15.e9ddc5dfd38426398e00.js and /dev/null differ diff --git a/priv/static/static/js/15.e9ddc5dfd38426398e00.js.map b/priv/static/static/js/15.e9ddc5dfd38426398e00.js.map deleted file mode 100644 index 6c0c32949..000000000 Binary files a/priv/static/static/js/15.e9ddc5dfd38426398e00.js.map and /dev/null differ diff --git a/priv/static/static/js/16.017fa510b293035ac370.js b/priv/static/static/js/16.017fa510b293035ac370.js new file mode 100644 index 000000000..387cfc9c7 Binary files /dev/null and b/priv/static/static/js/16.017fa510b293035ac370.js differ diff --git a/priv/static/static/js/16.017fa510b293035ac370.js.map b/priv/static/static/js/16.017fa510b293035ac370.js.map new file mode 100644 index 000000000..2886028bd Binary files /dev/null and b/priv/static/static/js/16.017fa510b293035ac370.js.map differ diff --git a/priv/static/static/js/16.476e7809b8593264469e.js b/priv/static/static/js/16.476e7809b8593264469e.js deleted file mode 100644 index 2cd6c9c3e..000000000 Binary files a/priv/static/static/js/16.476e7809b8593264469e.js and /dev/null differ diff --git a/priv/static/static/js/16.476e7809b8593264469e.js.map b/priv/static/static/js/16.476e7809b8593264469e.js.map deleted file mode 100644 index b62e1e0f4..000000000 Binary files a/priv/static/static/js/16.476e7809b8593264469e.js.map and /dev/null differ diff --git a/priv/static/static/js/17.acbe4c09f05ae56c76a2.js b/priv/static/static/js/17.acbe4c09f05ae56c76a2.js deleted file mode 100644 index 8e4d6181e..000000000 Binary files a/priv/static/static/js/17.acbe4c09f05ae56c76a2.js and /dev/null differ diff --git a/priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map b/priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map deleted file mode 100644 index 92bc141e5..000000000 Binary files a/priv/static/static/js/17.acbe4c09f05ae56c76a2.js.map and /dev/null differ diff --git a/priv/static/static/js/17.c63932b65417ee7346a3.js b/priv/static/static/js/17.c63932b65417ee7346a3.js new file mode 100644 index 000000000..e3172472a Binary files /dev/null and b/priv/static/static/js/17.c63932b65417ee7346a3.js differ diff --git a/priv/static/static/js/17.c63932b65417ee7346a3.js.map b/priv/static/static/js/17.c63932b65417ee7346a3.js.map new file mode 100644 index 000000000..f4c55d0cc Binary files /dev/null and b/priv/static/static/js/17.c63932b65417ee7346a3.js.map differ diff --git a/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js b/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js deleted file mode 100644 index d52319d30..000000000 Binary files a/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js and /dev/null differ diff --git a/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map b/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map deleted file mode 100644 index e751cf19c..000000000 Binary files a/priv/static/static/js/18.a8ccd7f2a47c5c94b3b9.js.map and /dev/null differ diff --git a/priv/static/static/js/18.fd12f9746a55aa24a8b7.js b/priv/static/static/js/18.fd12f9746a55aa24a8b7.js new file mode 100644 index 000000000..be1ecbba5 Binary files /dev/null and b/priv/static/static/js/18.fd12f9746a55aa24a8b7.js differ diff --git a/priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map b/priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map new file mode 100644 index 000000000..c98c107b3 Binary files /dev/null and b/priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map differ diff --git a/priv/static/static/js/19.3adebd64964c92700074.js b/priv/static/static/js/19.3adebd64964c92700074.js new file mode 100644 index 000000000..9d5adbe4e Binary files /dev/null and b/priv/static/static/js/19.3adebd64964c92700074.js differ diff --git a/priv/static/static/js/19.3adebd64964c92700074.js.map b/priv/static/static/js/19.3adebd64964c92700074.js.map new file mode 100644 index 000000000..d113a66dc Binary files /dev/null and b/priv/static/static/js/19.3adebd64964c92700074.js.map differ diff --git a/priv/static/static/js/19.5894e9c12b4fd5e45872.js b/priv/static/static/js/19.5894e9c12b4fd5e45872.js deleted file mode 100644 index f30cebacf..000000000 Binary files a/priv/static/static/js/19.5894e9c12b4fd5e45872.js and /dev/null differ diff --git a/priv/static/static/js/19.5894e9c12b4fd5e45872.js.map b/priv/static/static/js/19.5894e9c12b4fd5e45872.js.map deleted file mode 100644 index 3e00e0045..000000000 Binary files a/priv/static/static/js/19.5894e9c12b4fd5e45872.js.map and /dev/null differ diff --git a/priv/static/static/js/2.d81ca020d6885c6c3b03.js b/priv/static/static/js/2.d81ca020d6885c6c3b03.js new file mode 100644 index 000000000..f751a05da Binary files /dev/null and b/priv/static/static/js/2.d81ca020d6885c6c3b03.js differ diff --git a/priv/static/static/js/2.d81ca020d6885c6c3b03.js.map b/priv/static/static/js/2.d81ca020d6885c6c3b03.js.map new file mode 100644 index 000000000..9a675dbc5 Binary files /dev/null and b/priv/static/static/js/2.d81ca020d6885c6c3b03.js.map differ diff --git a/priv/static/static/js/2.f8dee9318a6f84ea92c3.js b/priv/static/static/js/2.f8dee9318a6f84ea92c3.js deleted file mode 100644 index b9f190615..000000000 Binary files a/priv/static/static/js/2.f8dee9318a6f84ea92c3.js and /dev/null differ diff --git a/priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map b/priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map deleted file mode 100644 index 8f4e8920a..000000000 Binary files a/priv/static/static/js/2.f8dee9318a6f84ea92c3.js.map and /dev/null differ diff --git a/priv/static/static/js/20.43b5b27b0f68474f3b72.js b/priv/static/static/js/20.43b5b27b0f68474f3b72.js deleted file mode 100644 index 2b2b5bf60..000000000 Binary files a/priv/static/static/js/20.43b5b27b0f68474f3b72.js and /dev/null differ diff --git a/priv/static/static/js/20.43b5b27b0f68474f3b72.js.map b/priv/static/static/js/20.43b5b27b0f68474f3b72.js.map deleted file mode 100644 index 224627821..000000000 Binary files a/priv/static/static/js/20.43b5b27b0f68474f3b72.js.map and /dev/null differ diff --git a/priv/static/static/js/20.e0c3ad29d59470506c04.js b/priv/static/static/js/20.e0c3ad29d59470506c04.js new file mode 100644 index 000000000..ddedbd1ff Binary files /dev/null and b/priv/static/static/js/20.e0c3ad29d59470506c04.js differ diff --git a/priv/static/static/js/20.e0c3ad29d59470506c04.js.map b/priv/static/static/js/20.e0c3ad29d59470506c04.js.map new file mode 100644 index 000000000..83a9fbc98 Binary files /dev/null and b/priv/static/static/js/20.e0c3ad29d59470506c04.js.map differ diff --git a/priv/static/static/js/21.72b45b01be9d0f4c62ce.js b/priv/static/static/js/21.72b45b01be9d0f4c62ce.js deleted file mode 100644 index 87292772b..000000000 Binary files a/priv/static/static/js/21.72b45b01be9d0f4c62ce.js and /dev/null differ diff --git a/priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map b/priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map deleted file mode 100644 index f7c2b5352..000000000 Binary files a/priv/static/static/js/21.72b45b01be9d0f4c62ce.js.map and /dev/null differ diff --git a/priv/static/static/js/21.849ecc09a1d58bdc64c6.js b/priv/static/static/js/21.849ecc09a1d58bdc64c6.js new file mode 100644 index 000000000..ef58a3da1 Binary files /dev/null and b/priv/static/static/js/21.849ecc09a1d58bdc64c6.js differ diff --git a/priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map b/priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map new file mode 100644 index 000000000..9447b7ce3 Binary files /dev/null and b/priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map differ diff --git a/priv/static/static/js/22.26f13a22ad57a0d14670.js b/priv/static/static/js/22.26f13a22ad57a0d14670.js deleted file mode 100644 index a12b55b1f..000000000 Binary files a/priv/static/static/js/22.26f13a22ad57a0d14670.js and /dev/null differ diff --git a/priv/static/static/js/22.26f13a22ad57a0d14670.js.map b/priv/static/static/js/22.26f13a22ad57a0d14670.js.map deleted file mode 100644 index fa09661dc..000000000 Binary files a/priv/static/static/js/22.26f13a22ad57a0d14670.js.map and /dev/null differ diff --git a/priv/static/static/js/22.8782f133c9f66d3f2bbe.js b/priv/static/static/js/22.8782f133c9f66d3f2bbe.js new file mode 100644 index 000000000..82692acdb Binary files /dev/null and b/priv/static/static/js/22.8782f133c9f66d3f2bbe.js differ diff --git a/priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map b/priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map new file mode 100644 index 000000000..41e527ff6 Binary files /dev/null and b/priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map differ diff --git a/priv/static/static/js/23.2653bf91bc77c2ed0160.js b/priv/static/static/js/23.2653bf91bc77c2ed0160.js new file mode 100644 index 000000000..2aad331b4 Binary files /dev/null and b/priv/static/static/js/23.2653bf91bc77c2ed0160.js differ diff --git a/priv/static/static/js/23.2653bf91bc77c2ed0160.js.map b/priv/static/static/js/23.2653bf91bc77c2ed0160.js.map new file mode 100644 index 000000000..4f031922e Binary files /dev/null and b/priv/static/static/js/23.2653bf91bc77c2ed0160.js.map differ diff --git a/priv/static/static/js/23.91a60b775352a806f887.js b/priv/static/static/js/23.91a60b775352a806f887.js deleted file mode 100644 index c4f18071c..000000000 Binary files a/priv/static/static/js/23.91a60b775352a806f887.js and /dev/null differ diff --git a/priv/static/static/js/23.91a60b775352a806f887.js.map b/priv/static/static/js/23.91a60b775352a806f887.js.map deleted file mode 100644 index 656b87b51..000000000 Binary files a/priv/static/static/js/23.91a60b775352a806f887.js.map and /dev/null differ diff --git a/priv/static/static/js/24.c8d8438aac954d4707ac.js b/priv/static/static/js/24.c8d8438aac954d4707ac.js deleted file mode 100644 index 0029d5b8a..000000000 Binary files a/priv/static/static/js/24.c8d8438aac954d4707ac.js and /dev/null differ diff --git a/priv/static/static/js/24.c8d8438aac954d4707ac.js.map b/priv/static/static/js/24.c8d8438aac954d4707ac.js.map deleted file mode 100644 index 1a2bb1dfd..000000000 Binary files a/priv/static/static/js/24.c8d8438aac954d4707ac.js.map and /dev/null differ diff --git a/priv/static/static/js/24.f931d864a2297d880a9a.js b/priv/static/static/js/24.f931d864a2297d880a9a.js new file mode 100644 index 000000000..0362730e0 Binary files /dev/null and b/priv/static/static/js/24.f931d864a2297d880a9a.js differ diff --git a/priv/static/static/js/24.f931d864a2297d880a9a.js.map b/priv/static/static/js/24.f931d864a2297d880a9a.js.map new file mode 100644 index 000000000..2fb375e79 Binary files /dev/null and b/priv/static/static/js/24.f931d864a2297d880a9a.js.map differ diff --git a/priv/static/static/js/25.79ac9e020d571b67f02a.js b/priv/static/static/js/25.79ac9e020d571b67f02a.js deleted file mode 100644 index 7798e9e7e..000000000 Binary files a/priv/static/static/js/25.79ac9e020d571b67f02a.js and /dev/null differ diff --git a/priv/static/static/js/25.79ac9e020d571b67f02a.js.map b/priv/static/static/js/25.79ac9e020d571b67f02a.js.map deleted file mode 100644 index 5cd7d6b0c..000000000 Binary files a/priv/static/static/js/25.79ac9e020d571b67f02a.js.map and /dev/null differ diff --git a/priv/static/static/js/25.886acc9ba83c64659279.js b/priv/static/static/js/25.886acc9ba83c64659279.js new file mode 100644 index 000000000..4ff4c331b Binary files /dev/null and b/priv/static/static/js/25.886acc9ba83c64659279.js differ diff --git a/priv/static/static/js/25.886acc9ba83c64659279.js.map b/priv/static/static/js/25.886acc9ba83c64659279.js.map new file mode 100644 index 000000000..c39f71238 Binary files /dev/null and b/priv/static/static/js/25.886acc9ba83c64659279.js.map differ diff --git a/priv/static/static/js/26.3af8f54349f672f2c7c8.js b/priv/static/static/js/26.3af8f54349f672f2c7c8.js deleted file mode 100644 index ea37ad7d1..000000000 Binary files a/priv/static/static/js/26.3af8f54349f672f2c7c8.js and /dev/null differ diff --git a/priv/static/static/js/26.3af8f54349f672f2c7c8.js.map b/priv/static/static/js/26.3af8f54349f672f2c7c8.js.map deleted file mode 100644 index b30d820f8..000000000 Binary files a/priv/static/static/js/26.3af8f54349f672f2c7c8.js.map and /dev/null differ diff --git a/priv/static/static/js/26.e15b1645079c72c60586.js b/priv/static/static/js/26.e15b1645079c72c60586.js new file mode 100644 index 000000000..303170088 Binary files /dev/null and b/priv/static/static/js/26.e15b1645079c72c60586.js differ diff --git a/priv/static/static/js/26.e15b1645079c72c60586.js.map b/priv/static/static/js/26.e15b1645079c72c60586.js.map new file mode 100644 index 000000000..e62345884 Binary files /dev/null and b/priv/static/static/js/26.e15b1645079c72c60586.js.map differ diff --git a/priv/static/static/js/27.51287d408313da67b0b8.js b/priv/static/static/js/27.51287d408313da67b0b8.js deleted file mode 100644 index bbed0b854..000000000 Binary files a/priv/static/static/js/27.51287d408313da67b0b8.js and /dev/null differ diff --git a/priv/static/static/js/27.51287d408313da67b0b8.js.map b/priv/static/static/js/27.51287d408313da67b0b8.js.map deleted file mode 100644 index 074c63e2e..000000000 Binary files a/priv/static/static/js/27.51287d408313da67b0b8.js.map and /dev/null differ diff --git a/priv/static/static/js/27.7b41e5953f74af7fddd1.js b/priv/static/static/js/27.7b41e5953f74af7fddd1.js new file mode 100644 index 000000000..769fba11b Binary files /dev/null and b/priv/static/static/js/27.7b41e5953f74af7fddd1.js differ diff --git a/priv/static/static/js/27.7b41e5953f74af7fddd1.js.map b/priv/static/static/js/27.7b41e5953f74af7fddd1.js.map new file mode 100644 index 000000000..078f5ff9a Binary files /dev/null and b/priv/static/static/js/27.7b41e5953f74af7fddd1.js.map differ diff --git a/priv/static/static/js/28.4f39e562aaceaa01e883.js b/priv/static/static/js/28.4f39e562aaceaa01e883.js new file mode 100644 index 000000000..629359bda Binary files /dev/null and b/priv/static/static/js/28.4f39e562aaceaa01e883.js differ diff --git a/priv/static/static/js/28.4f39e562aaceaa01e883.js.map b/priv/static/static/js/28.4f39e562aaceaa01e883.js.map new file mode 100644 index 000000000..24c675a4c Binary files /dev/null and b/priv/static/static/js/28.4f39e562aaceaa01e883.js.map differ diff --git a/priv/static/static/js/28.be5118beb1098a81332d.js b/priv/static/static/js/28.be5118beb1098a81332d.js deleted file mode 100644 index 30a6546eb..000000000 Binary files a/priv/static/static/js/28.be5118beb1098a81332d.js and /dev/null differ diff --git a/priv/static/static/js/28.be5118beb1098a81332d.js.map b/priv/static/static/js/28.be5118beb1098a81332d.js.map deleted file mode 100644 index 57e1d7124..000000000 Binary files a/priv/static/static/js/28.be5118beb1098a81332d.js.map and /dev/null differ diff --git a/priv/static/static/js/29.084f6fb0987d3862d410.js b/priv/static/static/js/29.084f6fb0987d3862d410.js deleted file mode 100644 index 0a92f928a..000000000 Binary files a/priv/static/static/js/29.084f6fb0987d3862d410.js and /dev/null differ diff --git a/priv/static/static/js/29.084f6fb0987d3862d410.js.map b/priv/static/static/js/29.084f6fb0987d3862d410.js.map deleted file mode 100644 index c977b4c84..000000000 Binary files a/priv/static/static/js/29.084f6fb0987d3862d410.js.map and /dev/null differ diff --git a/priv/static/static/js/29.137e2a68b558eed58152.js b/priv/static/static/js/29.137e2a68b558eed58152.js new file mode 100644 index 000000000..50cb11ffd Binary files /dev/null and b/priv/static/static/js/29.137e2a68b558eed58152.js differ diff --git a/priv/static/static/js/29.137e2a68b558eed58152.js.map b/priv/static/static/js/29.137e2a68b558eed58152.js.map new file mode 100644 index 000000000..0ac2f7fd3 Binary files /dev/null and b/priv/static/static/js/29.137e2a68b558eed58152.js.map differ diff --git a/priv/static/static/js/3.56898c1005d9ba1b8d4a.js b/priv/static/static/js/3.56898c1005d9ba1b8d4a.js new file mode 100644 index 000000000..6b20ecb04 Binary files /dev/null and b/priv/static/static/js/3.56898c1005d9ba1b8d4a.js differ diff --git a/priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map b/priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map new file mode 100644 index 000000000..594d9047b Binary files /dev/null and b/priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map differ diff --git a/priv/static/static/js/3.e1f7d368d5840e12e850.js b/priv/static/static/js/3.e1f7d368d5840e12e850.js deleted file mode 100644 index 18212aa8f..000000000 Binary files a/priv/static/static/js/3.e1f7d368d5840e12e850.js and /dev/null differ diff --git a/priv/static/static/js/3.e1f7d368d5840e12e850.js.map b/priv/static/static/js/3.e1f7d368d5840e12e850.js.map deleted file mode 100644 index 1d1dd7f3f..000000000 Binary files a/priv/static/static/js/3.e1f7d368d5840e12e850.js.map and /dev/null differ diff --git a/priv/static/static/js/30.6e6d63411def2e175d11.js b/priv/static/static/js/30.6e6d63411def2e175d11.js deleted file mode 100644 index df379aaa7..000000000 Binary files a/priv/static/static/js/30.6e6d63411def2e175d11.js and /dev/null differ diff --git a/priv/static/static/js/30.6e6d63411def2e175d11.js.map b/priv/static/static/js/30.6e6d63411def2e175d11.js.map deleted file mode 100644 index ebd9270dc..000000000 Binary files a/priv/static/static/js/30.6e6d63411def2e175d11.js.map and /dev/null differ diff --git a/priv/static/static/js/30.73e09f3b43617410dec7.js b/priv/static/static/js/30.73e09f3b43617410dec7.js new file mode 100644 index 000000000..0c3d03cfa Binary files /dev/null and b/priv/static/static/js/30.73e09f3b43617410dec7.js differ diff --git a/priv/static/static/js/30.73e09f3b43617410dec7.js.map b/priv/static/static/js/30.73e09f3b43617410dec7.js.map new file mode 100644 index 000000000..cb546de17 Binary files /dev/null and b/priv/static/static/js/30.73e09f3b43617410dec7.js.map differ diff --git a/priv/static/static/js/4.2d3bef896b463484e6eb.js b/priv/static/static/js/4.2d3bef896b463484e6eb.js new file mode 100644 index 000000000..4a611feb4 Binary files /dev/null and b/priv/static/static/js/4.2d3bef896b463484e6eb.js differ diff --git a/priv/static/static/js/4.2d3bef896b463484e6eb.js.map b/priv/static/static/js/4.2d3bef896b463484e6eb.js.map new file mode 100644 index 000000000..ebcc883e5 Binary files /dev/null and b/priv/static/static/js/4.2d3bef896b463484e6eb.js.map differ diff --git a/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js b/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js deleted file mode 100644 index 98ea02539..000000000 Binary files a/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js and /dev/null differ diff --git a/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map b/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map deleted file mode 100644 index 261abbb00..000000000 Binary files a/priv/static/static/js/4.c3f92d0b6ff90b36e3f5.js.map and /dev/null differ diff --git a/priv/static/static/js/5.2b4a2787bacdd3d910db.js b/priv/static/static/js/5.2b4a2787bacdd3d910db.js new file mode 100644 index 000000000..18c059380 Binary files /dev/null and b/priv/static/static/js/5.2b4a2787bacdd3d910db.js differ diff --git a/priv/static/static/js/5.2b4a2787bacdd3d910db.js.map b/priv/static/static/js/5.2b4a2787bacdd3d910db.js.map new file mode 100644 index 000000000..e9e78632d Binary files /dev/null and b/priv/static/static/js/5.2b4a2787bacdd3d910db.js.map differ diff --git a/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js b/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js deleted file mode 100644 index ce3eb2018..000000000 Binary files a/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js and /dev/null differ diff --git a/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map b/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map deleted file mode 100644 index 1eb455744..000000000 Binary files a/priv/static/static/js/5.d30e50cd5c52d54ffdc9.js.map and /dev/null differ diff --git a/priv/static/static/js/6.9c94bc0cc78979694cf4.js b/priv/static/static/js/6.9c94bc0cc78979694cf4.js new file mode 100644 index 000000000..415938f67 Binary files /dev/null and b/priv/static/static/js/6.9c94bc0cc78979694cf4.js differ diff --git a/priv/static/static/js/6.9c94bc0cc78979694cf4.js.map b/priv/static/static/js/6.9c94bc0cc78979694cf4.js.map new file mode 100644 index 000000000..948368f60 Binary files /dev/null and b/priv/static/static/js/6.9c94bc0cc78979694cf4.js.map differ diff --git a/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js b/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js deleted file mode 100644 index a80504f6e..000000000 Binary files a/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js and /dev/null differ diff --git a/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map b/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map deleted file mode 100644 index 074cf0fe2..000000000 Binary files a/priv/static/static/js/6.fa6d5c2d85d44f0ba121.js.map and /dev/null differ diff --git a/priv/static/static/js/7.b4ac57fd946a3a189047.js b/priv/static/static/js/7.b4ac57fd946a3a189047.js new file mode 100644 index 000000000..18b6ab76c Binary files /dev/null and b/priv/static/static/js/7.b4ac57fd946a3a189047.js differ diff --git a/priv/static/static/js/7.b4ac57fd946a3a189047.js.map b/priv/static/static/js/7.b4ac57fd946a3a189047.js.map new file mode 100644 index 000000000..054d52650 Binary files /dev/null and b/priv/static/static/js/7.b4ac57fd946a3a189047.js.map differ diff --git a/priv/static/static/js/7.d558a086622f668601a6.js b/priv/static/static/js/7.d558a086622f668601a6.js deleted file mode 100644 index c948ae6d1..000000000 Binary files a/priv/static/static/js/7.d558a086622f668601a6.js and /dev/null differ diff --git a/priv/static/static/js/7.d558a086622f668601a6.js.map b/priv/static/static/js/7.d558a086622f668601a6.js.map deleted file mode 100644 index cd515dac0..000000000 Binary files a/priv/static/static/js/7.d558a086622f668601a6.js.map and /dev/null differ diff --git a/priv/static/static/js/8.615136ce6c34a6b96a29.js b/priv/static/static/js/8.615136ce6c34a6b96a29.js deleted file mode 100644 index 255f924d3..000000000 Binary files a/priv/static/static/js/8.615136ce6c34a6b96a29.js and /dev/null differ diff --git a/priv/static/static/js/8.615136ce6c34a6b96a29.js.map b/priv/static/static/js/8.615136ce6c34a6b96a29.js.map deleted file mode 100644 index f2620b135..000000000 Binary files a/priv/static/static/js/8.615136ce6c34a6b96a29.js.map and /dev/null differ diff --git a/priv/static/static/js/8.e03e32ca713d01db0433.js b/priv/static/static/js/8.e03e32ca713d01db0433.js new file mode 100644 index 000000000..4d5894322 Binary files /dev/null and b/priv/static/static/js/8.e03e32ca713d01db0433.js differ diff --git a/priv/static/static/js/8.e03e32ca713d01db0433.js.map b/priv/static/static/js/8.e03e32ca713d01db0433.js.map new file mode 100644 index 000000000..d1385c203 Binary files /dev/null and b/priv/static/static/js/8.e03e32ca713d01db0433.js.map differ diff --git a/priv/static/static/js/9.72d903ca8e0c5a532b87.js b/priv/static/static/js/9.72d903ca8e0c5a532b87.js new file mode 100644 index 000000000..ce0f066c5 Binary files /dev/null and b/priv/static/static/js/9.72d903ca8e0c5a532b87.js differ diff --git a/priv/static/static/js/9.72d903ca8e0c5a532b87.js.map b/priv/static/static/js/9.72d903ca8e0c5a532b87.js.map new file mode 100644 index 000000000..4cf79de5b Binary files /dev/null and b/priv/static/static/js/9.72d903ca8e0c5a532b87.js.map differ diff --git a/priv/static/static/js/9.ef4eb9703f9aee67515e.js b/priv/static/static/js/9.ef4eb9703f9aee67515e.js deleted file mode 100644 index 2d1e741d9..000000000 Binary files a/priv/static/static/js/9.ef4eb9703f9aee67515e.js and /dev/null differ diff --git a/priv/static/static/js/9.ef4eb9703f9aee67515e.js.map b/priv/static/static/js/9.ef4eb9703f9aee67515e.js.map deleted file mode 100644 index 3491916ca..000000000 Binary files a/priv/static/static/js/9.ef4eb9703f9aee67515e.js.map and /dev/null differ diff --git a/priv/static/static/js/app.1e68e208590653dab5aa.js b/priv/static/static/js/app.1e68e208590653dab5aa.js new file mode 100644 index 000000000..27cc3d910 Binary files /dev/null and b/priv/static/static/js/app.1e68e208590653dab5aa.js differ diff --git a/priv/static/static/js/app.1e68e208590653dab5aa.js.map b/priv/static/static/js/app.1e68e208590653dab5aa.js.map new file mode 100644 index 000000000..71636d936 Binary files /dev/null and b/priv/static/static/js/app.1e68e208590653dab5aa.js.map differ diff --git a/priv/static/static/js/app.53001fa190f37cf2743e.js b/priv/static/static/js/app.53001fa190f37cf2743e.js deleted file mode 100644 index 45f3a8373..000000000 Binary files a/priv/static/static/js/app.53001fa190f37cf2743e.js and /dev/null differ diff --git a/priv/static/static/js/app.53001fa190f37cf2743e.js.map b/priv/static/static/js/app.53001fa190f37cf2743e.js.map deleted file mode 100644 index 105b669c9..000000000 Binary files a/priv/static/static/js/app.53001fa190f37cf2743e.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js b/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js new file mode 100644 index 000000000..bf6671e4b Binary files /dev/null and b/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js differ diff --git a/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map b/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map new file mode 100644 index 000000000..2a3bf1b99 Binary files /dev/null and b/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map differ diff --git a/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js b/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js deleted file mode 100644 index 365dc3dc4..000000000 Binary files a/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map b/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map deleted file mode 100644 index da281465a..000000000 Binary files a/priv/static/static/js/vendors~app.8837fb59589d1dd6acda.js.map and /dev/null differ diff --git a/priv/static/static/terms-of-service.html b/priv/static/static/terms-of-service.html index b2c668151..3b6bbb36b 100644 --- a/priv/static/static/terms-of-service.html +++ b/priv/static/static/terms-of-service.html @@ -2,7 +2,7 @@

    This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.

    -

    To do so, place a file at "/instance/static/terms-of-service.html" in your +

    To do so, place a file at "/instance/static/static/terms-of-service.html" in your Pleroma install containing the real ToS for your instance.

    See the Pleroma documentation for more information.


    diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 22b99ea22..098f58d49 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index 55846489e..5749809d5 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ -- cgit v1.2.3 From 93e494ec212b5bb6aae31a3b43304ed230d095e2 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 10 Jul 2020 14:10:44 +0200 Subject: ActivityPub: Don't rename a clashing nickname with the same ap id. --- lib/pleroma/web/activity_pub/activity_pub.ex | 23 ++++++++++++--- test/web/activity_pub/activity_pub_test.exs | 42 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8da5cf938..bc7b5d95a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1376,13 +1376,28 @@ def fetch_and_prepare_user_from_ap_id(ap_id) do end end - def maybe_handle_clashing_nickname(nickname) do - with %User{} = old_user <- User.get_by_nickname(nickname) do - Logger.info("Found an old user for #{nickname}, ap id is #{old_user.ap_id}, renaming.") + def maybe_handle_clashing_nickname(data) do + nickname = data[:nickname] + + with %User{} = old_user <- User.get_by_nickname(nickname), + {_, false} <- {:ap_id_comparison, data[:ap_id] == old_user.ap_id} do + Logger.info( + "Found an old user for #{nickname}, the old ap id is #{old_user.ap_id}, new one is #{ + data[:ap_id] + }, renaming." + ) old_user |> User.remote_user_changeset(%{nickname: "#{old_user.id}.#{old_user.nickname}"}) |> User.update_and_set_cache() + else + {:ap_id_comparison, true} -> + Logger.info( + "Found an old user for #{nickname}, but the ap id #{data[:ap_id]} is the same as the new user. Race condition? Not changing anything." + ) + + _ -> + nil end end @@ -1398,7 +1413,7 @@ def make_user_from_ap_id(ap_id) do |> User.remote_user_changeset(data) |> User.update_and_set_cache() else - maybe_handle_clashing_nickname(data[:nickname]) + maybe_handle_clashing_nickname(data) data |> User.remote_user_changeset() diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index b988e4437..1658f20da 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -2056,4 +2056,46 @@ test "creates an activity expiration for local Create activities" do assert [%{activity_id: ^id_create}] = Pleroma.ActivityExpiration |> Repo.all() end end + + describe "handling of clashing nicknames" do + test "renames an existing user with a clashing nickname and a different ap id" do + orig_user = + insert( + :user, + local: false, + nickname: "admin@mastodon.example.org", + ap_id: "http://mastodon.example.org/users/harinezumigari" + ) + + %{ + nickname: orig_user.nickname, + ap_id: orig_user.ap_id <> "part_2" + } + |> ActivityPub.maybe_handle_clashing_nickname() + + user = User.get_by_id(orig_user.id) + + assert user.nickname == "#{orig_user.id}.admin@mastodon.example.org" + end + + test "does nothing with a clashing nickname and the same ap id" do + orig_user = + insert( + :user, + local: false, + nickname: "admin@mastodon.example.org", + ap_id: "http://mastodon.example.org/users/harinezumigari" + ) + + %{ + nickname: orig_user.nickname, + ap_id: orig_user.ap_id + } + |> ActivityPub.maybe_handle_clashing_nickname() + + user = User.get_by_id(orig_user.id) + + assert user.nickname == orig_user.nickname + end + end end -- cgit v1.2.3 From a1dace088cb8d17e074e38689196793aa2c46a57 Mon Sep 17 00:00:00 2001 From: href Date: Fri, 10 Jul 2020 17:10:48 +0200 Subject: ReverseProxy: Streaming and disable encoding if Range Fixes #1823 Fixes #1860 --- lib/pleroma/reverse_proxy/reverse_proxy.ex | 54 ++++++++++++++++++++---------- test/reverse_proxy/reverse_proxy_test.exs | 4 +-- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 4bbeb493c..76a321c3a 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -3,11 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy do + @range_headers ~w(range if-range) @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++ - ~w(if-unmodified-since if-none-match if-range range) + ~w(if-unmodified-since if-none-match) ++ @range_headers @resp_cache_headers ~w(etag date last-modified) @keep_resp_headers @resp_cache_headers ++ - ~w(content-type content-disposition content-encoding content-range) ++ + ~w(content-length content-type content-disposition content-encoding content-range) ++ ~w(accept-ranges vary) @default_cache_control_header "public, max-age=1209600" @valid_resp_codes [200, 206, 304] @@ -170,6 +171,8 @@ defp request(method, url, headers, opts) do end defp response(conn, client, url, status, headers, opts) do + Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}") + result = conn |> put_resp_headers(build_resp_headers(headers, opts)) @@ -220,7 +223,9 @@ defp chunk_reply(conn, client, opts, sent_so_far, duration) do end end - defp head_response(conn, _url, code, headers, opts) do + defp head_response(conn, url, code, headers, opts) do + Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}") + conn |> put_resp_headers(build_resp_headers(headers, opts)) |> send_resp(code, "") @@ -262,20 +267,33 @@ defp build_req_headers(headers, opts) do headers |> downcase_headers() |> Enum.filter(fn {k, _} -> k in @keep_req_headers end) - |> (fn headers -> - headers = headers ++ Keyword.get(opts, :req_headers, []) - - if Keyword.get(opts, :keep_user_agent, false) do - List.keystore( - headers, - "user-agent", - 0, - {"user-agent", Pleroma.Application.user_agent()} - ) - else - headers - end - end).() + |> build_req_range_or_encoding_header(opts) + |> build_req_user_agent_header(opts) + |> Keyword.merge(Keyword.get(opts, :req_headers, [])) + end + + # Disable content-encoding if any @range_headers are requested (see #1823). + defp build_req_range_or_encoding_header(headers, _opts) do + range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end) + + if range? && List.keymember?(headers, "accept-encoding", 0) do + List.keydelete(headers, "accept-encoding", 0) + else + headers + end + end + + defp build_req_user_agent_header(headers, opts) do + if Keyword.get(opts, :keep_user_agent, false) do + List.keystore( + headers, + "user-agent", + 0, + {"user-agent", Pleroma.Application.user_agent()} + ) + else + headers + end end defp build_resp_headers(headers, opts) do @@ -283,7 +301,7 @@ defp build_resp_headers(headers, opts) do |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) |> build_resp_cache_headers(opts) |> build_resp_content_disposition_header(opts) - |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).() + |> Keyword.merge(Keyword.get(opts, :resp_headers, [])) end defp build_resp_cache_headers(headers, _opts) do diff --git a/test/reverse_proxy/reverse_proxy_test.exs b/test/reverse_proxy/reverse_proxy_test.exs index c677066b3..8df63de65 100644 --- a/test/reverse_proxy/reverse_proxy_test.exs +++ b/test/reverse_proxy/reverse_proxy_test.exs @@ -314,7 +314,7 @@ defp disposition_headers_mock(headers) do test "not atachment", %{conn: conn} do disposition_headers_mock([ {"content-type", "image/gif"}, - {"content-length", 0} + {"content-length", "0"} ]) conn = ReverseProxy.call(conn, "/disposition") @@ -325,7 +325,7 @@ test "not atachment", %{conn: conn} do test "with content-disposition header", %{conn: conn} do disposition_headers_mock([ {"content-disposition", "attachment; filename=\"filename.jpg\""}, - {"content-length", 0} + {"content-length", "0"} ]) conn = ReverseProxy.call(conn, "/disposition") -- cgit v1.2.3 From 72b3dbf4d1c457747c362eda3dddada6e02f1568 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Jul 2020 11:04:19 -0500 Subject: Credo line length complaint --- lib/pleroma/reverse_proxy/reverse_proxy.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 76a321c3a..28ad4c846 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -8,8 +8,8 @@ defmodule Pleroma.ReverseProxy do ~w(if-unmodified-since if-none-match) ++ @range_headers @resp_cache_headers ~w(etag date last-modified) @keep_resp_headers @resp_cache_headers ++ - ~w(content-length content-type content-disposition content-encoding content-range) ++ - ~w(accept-ranges vary) + ~w(content-length content-type content-disposition content-encoding) ++ + ~w(content-range accept-ranges vary) @default_cache_control_header "public, max-age=1209600" @valid_resp_codes [200, 206, 304] @max_read_duration :timer.seconds(30) -- cgit v1.2.3 From 0517d3252e7684ec44a892c66372a706fe220f30 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Jul 2020 11:22:29 -0500 Subject: Probably worth documenting the MediaProxy fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c005a4dc..9e928528a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Rich Media Previews for Twitter links - Admin API: fix `GET /api/pleroma/admin/users/:nickname/credentials` returning 404 when getting the credentials of a remote user while `:instance, :limit_to_local_content` is set to `:unauthenticated` - Fix CSP policy generation to include remote Captcha services +- Fix edge case where MediaProxy truncates media, usually caused when Caddy is serving content for the other Federated instance. ## [Unreleased (patch)] -- cgit v1.2.3 From d7a37fddd1fc5169ae8c714d0baf8e5372a5f1d5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Jul 2020 11:33:08 -0500 Subject: Switch to the official Oban 2.0.0 release --- mix.exs | 2 +- mix.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 69d9f8632..de00f1298 100644 --- a/mix.exs +++ b/mix.exs @@ -124,7 +124,7 @@ defp deps do {:ecto_enum, "~> 1.4"}, {:ecto_sql, "~> 3.4.4"}, {:postgrex, ">= 0.13.5"}, - {:oban, "~> 2.0.0-rc.3"}, + {:oban, "~> 2.0.0"}, {:gettext, "~> 0.15"}, {:pbkdf2_elixir, "~> 1.0"}, {:bcrypt_elixir, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index 88005451a..761b76589 100644 --- a/mix.lock +++ b/mix.lock @@ -29,7 +29,7 @@ "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "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.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"}, + "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, @@ -75,7 +75,7 @@ "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, - "oban": {:hex, :oban, "2.0.0-rc.3", "964629fabc21939d7258a05a38f74b676bd4eebcf4932389e8ad9f1a18431bd2", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "82c9688e066610a88776aac527022a320faed9b5918093061caf2767863cc3c5"}, + "oban": {:hex, :oban, "2.0.0", "e6ce70d94dd46815ec0882a1ffb7356df9a9d5b8a40a64ce5c2536617a447379", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "cf574813bd048b98a698aa587c21367d2e06842d4e1b1993dcd6a696e9e633bd"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, -- cgit v1.2.3 From 61675938811a8f2121c857f285d31bd4c7ef3336 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Jul 2020 16:46:26 -0500 Subject: Support Exiftool for stripping EXIF data We really only want to strip location data anyway, and mogrify strips color profiles. --- .gitignore | 1 + .gitlab-ci.yml | 2 ++ CHANGELOG.md | 1 + Dockerfile | 2 +- docs/configuration/cheatsheet.md | 18 ++++++++++++------ lib/pleroma/upload/filter/exiftool.ex | 17 +++++++++++++++++ test/fixtures/DSCN0010.jpg | Bin 0 -> 161713 bytes test/upload/filter/exiftool_test.exs | 31 +++++++++++++++++++++++++++++++ 8 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 lib/pleroma/upload/filter/exiftool.ex create mode 100644 test/fixtures/DSCN0010.jpg create mode 100644 test/upload/filter/exiftool_test.exs diff --git a/.gitignore b/.gitignore index 198e80139..599b52b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /*.ez /test/uploads /.elixir_ls +/test/fixtures/DSCN0010_tmp.jpg /test/fixtures/test_tmp.txt /test/fixtures/image_tmp.jpg /test/tmp/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6a2be879e..c9ab84892 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -58,6 +58,7 @@ unit-testing: alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] script: + - apt-get update && apt-get install -y libimage-exiftool-perl - mix deps.get - mix ecto.create - mix ecto.migrate @@ -89,6 +90,7 @@ unit-testing-rum: <<: *global_variables RUM_ENABLED: "true" script: + - apt-get update && apt-get install -y libimage-exiftool-perl - mix deps.get - mix ecto.create - mix ecto.migrate diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e928528a..5fed80a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances - Support pagination in emoji packs API (for packs and for files in pack) - Support for viewing instances favicons next to posts and accounts +- Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata.
    API Changes diff --git a/Dockerfile b/Dockerfile index 29931a5e3..0f4fcd0bb 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 imagemagick ncurses postgresql-client &&\ + apk add exiftool imagemagick ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 1a0603892..f796330f1 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -548,20 +548,26 @@ config :ex_aws, :s3, ### Upload filters -#### Pleroma.Upload.Filter.Mogrify +#### Pleroma.Upload.Filter.AnonymizeFilename -* `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", "auto-orient", {"implode", "1"}]`. +This filter replaces the filename (not the path) of an upload. For complete obfuscation, add +`Pleroma.Upload.Filter.Dedupe` before AnonymizeFilename. + +* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`. #### Pleroma.Upload.Filter.Dedupe No specific configuration. -#### Pleroma.Upload.Filter.AnonymizeFilename +#### Pleroma.Upload.Filter.Exiftool -This filter replaces the filename (not the path) of an upload. For complete obfuscation, add -`Pleroma.Upload.Filter.Dedupe` before AnonymizeFilename. +This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact. -* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`. +No specific configuration. + +#### Pleroma.Upload.Filter.Mogrify + +* `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", "auto-orient", {"implode", "1"}]`. ## Email diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex new file mode 100644 index 000000000..833d8cab4 --- /dev/null +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exiftool do + @behaviour Pleroma.Upload.Filter + + @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()} + @type conversions :: conversion() | [conversion()] + + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do + System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) + :ok + end + + def filter(_), do: :ok +end diff --git a/test/fixtures/DSCN0010.jpg b/test/fixtures/DSCN0010.jpg new file mode 100644 index 000000000..4a2c1552b Binary files /dev/null and b/test/fixtures/DSCN0010.jpg differ diff --git a/test/upload/filter/exiftool_test.exs b/test/upload/filter/exiftool_test.exs new file mode 100644 index 000000000..a1b7e46cd --- /dev/null +++ b/test/upload/filter/exiftool_test.exs @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.ExiftoolTest do + use Pleroma.DataCase + alias Pleroma.Upload.Filter + + test "apply exiftool filter" do + File.cp!( + "test/fixtures/DSCN0010.jpg", + "test/fixtures/DSCN0010_tmp.jpg" + ) + + upload = %Pleroma.Upload{ + name: "image_with_GPS_data.jpg", + content_type: "image/jpg", + path: Path.absname("test/fixtures/DSCN0010.jpg"), + tempfile: Path.absname("test/fixtures/DSCN0010_tmp.jpg") + } + + assert Filter.Exiftool.filter(upload) == :ok + + {exif_original, 0} = System.cmd("exiftool", ["test/fixtures/DSCN0010.jpg"]) + {exif_filtered, 0} = System.cmd("exiftool", ["test/fixtures/DSCN0010_tmp.jpg"]) + + refute exif_original == exif_filtered + assert String.match?(exif_original, ~r/GPS/) + refute String.match?(exif_filtered, ~r/GPS/) + end +end -- cgit v1.2.3 From 9e4567267443b801ab2f4f58f3897b2834387a80 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Jul 2020 17:07:28 -0500 Subject: Add a moduledoc --- lib/pleroma/upload/filter/exiftool.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex index 833d8cab4..eb199709a 100644 --- a/lib/pleroma/upload/filter/exiftool.ex +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -3,6 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Upload.Filter.Exiftool do + @moduledoc """ + Strips GPS related EXIF tags and overwrites the file in place. + Also strips or replaces filesystem metadata e.g., timestamps. + """ @behaviour Pleroma.Upload.Filter @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()} -- cgit v1.2.3 From b329f05ed63657aa19b227b9b919662dad56ecae Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 10 Jul 2020 17:08:54 -0500 Subject: Remove unused @types --- lib/pleroma/upload/filter/exiftool.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex index eb199709a..c7fb6aefa 100644 --- a/lib/pleroma/upload/filter/exiftool.ex +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -9,9 +9,6 @@ defmodule Pleroma.Upload.Filter.Exiftool do """ @behaviour Pleroma.Upload.Filter - @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()} - @type conversions :: conversion() | [conversion()] - def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) :ok -- cgit v1.2.3 From 02b2747d420962445691d4bdbe171d95e7656e89 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Sat, 11 Jul 2020 04:17:21 +0300 Subject: Update types for :params, :match_actor and :replace settings --- config/description.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/description.exs b/config/description.exs index 0a0a8e95c..7f3ef535c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1521,7 +1521,7 @@ children: [ %{ key: :match_actor, - type: :map, + type: {:map, {:list, :string}}, description: "Matches a series of regular expressions against the actor field", suggestions: [ %{ @@ -1601,7 +1601,7 @@ }, %{ key: :replace, - type: {:keyword, :string, :regex}, + type: {:list, :tuple}, description: "A list of tuples containing {pattern, replacement}. Each pattern can be a string or a regular expression.", suggestions: [{"foo", "bar"}, {~r/foo/iu, "bar"}] @@ -1802,7 +1802,7 @@ children: [ %{ key: :params, - type: {:keyword, :string} + type: {:map, :string} } ] } -- cgit v1.2.3 From 98c56ff4771cba8037fd28d412337bee7a60bc95 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Sat, 11 Jul 2020 04:32:44 +0300 Subject: Remove :regex from types --- config/description.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 7f3ef535c..6ef329807 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1587,14 +1587,14 @@ children: [ %{ key: :reject, - type: [:string, :regex], + type: [:string], description: "A list of patterns which result in message being rejected. Each pattern can be a string or a regular expression.", suggestions: ["foo", ~r/foo/iu] }, %{ key: :federated_timeline_removal, - type: [:string, :regex], + type: [:string], description: "A list of patterns which result in message being removed from federated timelines (a.k.a unlisted). Each pattern can be a string or a regular expression.", suggestions: ["foo", ~r/foo/iu] -- cgit v1.2.3 From 62fc8eab0dfd3f4c60c8f36fd3a544d6785ff2c6 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Sat, 11 Jul 2020 07:20:35 +0300 Subject: fix reset confirmation email in admin section --- lib/pleroma/application_requirements.ex | 18 +++++++++++ lib/pleroma/user.ex | 22 +++++++------ .../admin_api/controllers/admin_api_controller.ex | 23 ++++++-------- test/application_requirements_test.exs | 36 ++++++++++++++++++++++ test/user_test.exs | 12 +++++++- .../controllers/admin_api_controller_test.exs | 4 +++ 6 files changed, 91 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 88575a498..f0f34734e 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -16,6 +16,7 @@ defmodule VerifyError, do: defexception([:message]) @spec verify!() :: :ok | VerifyError.t() def verify! do :ok + |> check_confirmation_accounts! |> check_migrations_applied!() |> check_rum!() |> handle_result() @@ -24,6 +25,23 @@ def verify! do defp handle_result(:ok), do: :ok defp handle_result({:error, message}), do: raise(VerifyError, message: message) + # Checks account confirmation email + # + def check_confirmation_accounts!(:ok) do + if Pleroma.Config.get([:instance, :account_activation_required]) && + not Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do + Logger.error( + "To use confirmation an user account need to enable and setting mailer.\nIf you want to start Pleroma anyway, set\nconfig :pleroma, :instance, account_activation_required: false\nOtherwise setup and enable mailer." + ) + + {:error, "Confirmation account: Mailer is disabled"} + else + :ok + end + end + + def check_confirmation_accounts!(result), do: result + # Checks for pending migrations. # def check_migrations_applied!(:ok) do diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b9989f901..711258ac7 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -709,21 +709,25 @@ def post_register_action(%User{} = user) do end end - def try_send_confirmation_email(%User{} = user) do - if user.confirmation_pending && - Config.get([:instance, :account_activation_required]) do - user - |> Pleroma.Emails.UserEmail.account_confirmation_email() - |> Pleroma.Emails.Mailer.deliver_async() - + @spec try_send_confirmation_email(User.t()) :: {:ok, :enqueued | :noop} + def try_send_confirmation_email(%User{confirmation_pending: true} = user) do + if Config.get([:instance, :account_activation_required]) do + send_confirmation_email(user) {:ok, :enqueued} else {:ok, :noop} end end - def try_send_confirmation_email(users) do - Enum.each(users, &try_send_confirmation_email/1) + def try_send_confirmation_email(_), do: {:ok, :noop} + + @spec send_confirmation_email(Uset.t()) :: User.t() + def send_confirmation_email(%User{} = user) do + user + |> Pleroma.Emails.UserEmail.account_confirmation_email() + |> Pleroma.Emails.Mailer.deliver_async() + + user end def needs_update?(%User{local: true}), do: false diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index e5f14269a..c10181bae 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -616,29 +616,24 @@ def reload_emoji(conn, _params) do end def confirm_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) User.toggle_confirmation(users) - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "confirm_email" - }) + ModerationLog.insert_log(%{actor: admin, subject: users, action: "confirm_email"}) json(conn, "") end def resend_confirmation_email(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do - users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) - - User.try_send_confirmation_email(users) + users = + Enum.map(nicknames, fn nickname -> + nickname + |> User.get_cached_by_nickname() + |> User.send_confirmation_email() + end) - ModerationLog.insert_log(%{ - actor: admin, - subject: users, - action: "resend_confirmation_email" - }) + ModerationLog.insert_log(%{actor: admin, subject: users, action: "resend_confirmation_email"}) json(conn, "") end diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index 481cdfd73..8c92be290 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -9,6 +9,42 @@ defmodule Pleroma.ApplicationRequirementsTest do alias Pleroma.Repo + describe "check_confirmation_accounts!" do + setup_with_mocks([ + {Pleroma.ApplicationRequirements, [:passthrough], + [ + check_migrations_applied!: fn _ -> :ok end + ]} + ]) do + :ok + end + + setup do: clear_config([:instance, :account_activation_required]) + + test "raises if account confirmation is required but mailer isn't enable" do + Pleroma.Config.put([:instance, :account_activation_required], true) + Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) + + assert_raise Pleroma.ApplicationRequirements.VerifyError, + "Confirmation account: Mailer is disabled", + fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + + test "doesn't do anything if account confirmation is disabled" do + Pleroma.Config.put([:instance, :account_activation_required], false) + Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) + assert Pleroma.ApplicationRequirements.verify!() == :ok + end + + test "doesn't do anything if account confirmation is required and mailer is enabled" do + Pleroma.Config.put([:instance, :account_activation_required], true) + Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], true) + assert Pleroma.ApplicationRequirements.verify!() == :ok + end + end + describe "check_rum!" do setup_with_mocks([ {Pleroma.ApplicationRequirements, [:passthrough], diff --git a/test/user_test.exs b/test/user_test.exs index 9788e09d9..21c03b470 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -17,6 +17,7 @@ defmodule Pleroma.UserTest do import Pleroma.Factory import ExUnit.CaptureLog + import Swoosh.TestAssertions setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -385,9 +386,11 @@ test "fetches correct profile for nickname beginning with number" do password_confirmation: "test", email: "email@example.com" } + setup do: clear_config([:instance, :autofollowed_nicknames]) setup do: clear_config([:instance, :welcome_message]) setup do: clear_config([:instance, :welcome_user_nickname]) + setup do: clear_config([:instance, :account_activation_required]) test "it autofollows accounts that are set for it" do user = insert(:user) @@ -421,7 +424,14 @@ test "it sends a welcome message if it is set" do assert activity.actor == welcome_user.ap_id end - setup do: clear_config([:instance, :account_activation_required]) + test "it sends a confirm email" do + Pleroma.Config.put([:instance, :account_activation_required], true) + + cng = User.register_changeset(%User{}, @full_user_data) + {:ok, registered_user} = User.register(cng) + ObanHelpers.perform_all() + assert_email_sent(Pleroma.Emails.UserEmail.account_confirmation_email(registered_user)) + end test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do Pleroma.Config.put([:instance, :account_activation_required], true) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index c2433f23c..b734a34a5 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do import ExUnit.CaptureLog import Mock import Pleroma.Factory + import Swoosh.TestAssertions alias Pleroma.Activity alias Pleroma.Config @@ -1721,6 +1722,9 @@ test "it resend emails for two users", %{conn: conn, admin: admin} do "@#{admin.nickname} re-sent confirmation email for users: @#{first_user.nickname}, @#{ second_user.nickname }" + + ObanHelpers.perform_all() + assert_email_sent(Pleroma.Emails.UserEmail.account_confirmation_email(first_user)) end end -- cgit v1.2.3 From d8855405902b57980ce1f7bc65f25daba6b565e2 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Sat, 11 Jul 2020 11:02:13 +0000 Subject: docs: API: fix update_credentials endpoints path, clarify update/verify_credentials endpoints paths --- docs/API/differences_in_mastoapi_responses.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index 65f9f1aef..c4a9c6dad 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -64,8 +64,8 @@ Has these additional fields under the `pleroma` object: - `hide_follows`: boolean, true when the user has follow hiding enabled - `hide_followers_count`: boolean, true when the user has follower stat hiding enabled - `hide_follows_count`: boolean, true when the user has follow stat hiding enabled -- `settings_store`: A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials` -- `chat_token`: The token needed for Pleroma chat. Only returned in `verify_credentials` +- `settings_store`: A generic map of settings for frontends. Opaque to the backend. Only returned in `/api/v1/accounts/verify_credentials` and `/api/v1/accounts/update_credentials` +- `chat_token`: The token needed for Pleroma chat. Only returned in `/api/v1/accounts/verify_credentials` - `deactivated`: boolean, true when the user is deactivated - `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. @@ -169,7 +169,7 @@ Returns: array of Status. The maximum number of statuses is limited to 100 per request. -## PATCH `/api/v1/update_credentials` +## PATCH `/api/v1/accounts/update_credentials` Additional parameters can be added to the JSON body/Form data: @@ -197,7 +197,7 @@ Pleroma has mechanism that allows frontends to save blobs of json for each user 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. -This information is returned in the `verify_credentials` endpoint. +This information is returned in the `/api/v1/accounts/verify_credentials` endpoint. ## Authentication -- cgit v1.2.3 From aedbbec88aa0a9a38e588eabfbecb8058652002b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 11 Jul 2020 15:48:45 +0300 Subject: Add Pleroma.Utils.command_available?/1 and use where appropriate --- lib/pleroma/upload/filter/exiftool.ex | 9 ++++++++- lib/pleroma/utils.ex | 15 +++++++++++++++ mix.exs | 6 +++--- test/upload/filter/exiftool_test.exs | 2 ++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex index c7fb6aefa..94622acd0 100644 --- a/lib/pleroma/upload/filter/exiftool.ex +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -9,8 +9,15 @@ defmodule Pleroma.Upload.Filter.Exiftool do """ @behaviour Pleroma.Upload.Filter + require Logger + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) + if Pleroma.Utils.command_available?("exiftool") do + System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) + else + Logger.warn("exiftool is not available, filter #{__MODULE__} skipped") + end + :ok end diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex index 6b8e3accf..21d1159be 100644 --- a/lib/pleroma/utils.ex +++ b/lib/pleroma/utils.ex @@ -9,4 +9,19 @@ def compile_dir(dir) when is_binary(dir) do |> Enum.map(&Path.join(dir, &1)) |> Kernel.ParallelCompiler.compile() end + + @doc """ + POSIX-compliant check if command is available in the system + + ## Examples + iex> command_available?("git") + true + iex> command_available?("wrongcmd") + false + + """ + @spec command_available?(String.t()) :: boolean() + def command_available?(command) do + match?({_output, 0}, System.cmd("sh", ["-c", "command -v #{command}"])) + end end diff --git a/mix.exs b/mix.exs index d7992ee37..5cd06e8fd 100644 --- a/mix.exs +++ b/mix.exs @@ -234,10 +234,10 @@ defp aliases do defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i - {_cmdgit, cmdgit_err} = System.cmd("sh", ["-c", "command -v git"]) + git_available? = Pleroma.Utils.command_available?("git") git_pre_release = - if cmdgit_err == 0 do + if git_available? do {tag, tag_err} = System.cmd("git", ["describe", "--tags", "--abbrev=0"], stderr_to_stdout: true) @@ -263,7 +263,7 @@ defp version(version) do # Branch name as pre-release version component, denoted with a dot branch_name = - with 0 <- cmdgit_err, + with true <- git_available?, {branch_name, 0} <- System.cmd("git", ["rev-parse", "--abbrev-ref", "HEAD"]), branch_name <- String.trim(branch_name), branch_name <- System.get_env("PLEROMA_BUILD_BRANCH") || branch_name, diff --git a/test/upload/filter/exiftool_test.exs b/test/upload/filter/exiftool_test.exs index a1b7e46cd..8ed7d650b 100644 --- a/test/upload/filter/exiftool_test.exs +++ b/test/upload/filter/exiftool_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.Upload.Filter.ExiftoolTest do alias Pleroma.Upload.Filter test "apply exiftool filter" do + assert Pleroma.Utils.command_available?("exiftool") + File.cp!( "test/fixtures/DSCN0010.jpg", "test/fixtures/DSCN0010_tmp.jpg" -- cgit v1.2.3 From 05187d497da1844005eaf96fbcab65840a578bb1 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 11 Jul 2020 16:09:46 +0300 Subject: One can not simply call application modules from mix.exs --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 5cd06e8fd..a775e54a4 100644 --- a/mix.exs +++ b/mix.exs @@ -234,7 +234,7 @@ defp aliases do defp version(version) do identifier_filter = ~r/[^0-9a-z\-]+/i - git_available? = Pleroma.Utils.command_available?("git") + git_available? = match?({_output, 0}, System.cmd("sh", ["-c", "command -v git"])) git_pre_release = if git_available? do -- cgit v1.2.3 From 45bd64e2a7a08377e260e93c8e1744166bfc133a Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 11 Jul 2020 18:11:23 +0300 Subject: Error in Filter.Exiftool if exiftool not found --- lib/pleroma/upload/filter/exiftool.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex index 94622acd0..6a40e152f 100644 --- a/lib/pleroma/upload/filter/exiftool.ex +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -14,11 +14,10 @@ defmodule Pleroma.Upload.Filter.Exiftool do def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do if Pleroma.Utils.command_available?("exiftool") do System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) + :ok else - Logger.warn("exiftool is not available, filter #{__MODULE__} skipped") + {:error, "exiftool command not found"} end - - :ok end def filter(_), do: :ok -- cgit v1.2.3 From 523f1b93a48d88ef8aa04ca17d51d1d0916b6093 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 11 Jul 2020 18:15:51 +0300 Subject: Remove Logger requirement --- lib/pleroma/upload/filter/exiftool.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex index 6a40e152f..e1b976c98 100644 --- a/lib/pleroma/upload/filter/exiftool.ex +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -9,8 +9,6 @@ defmodule Pleroma.Upload.Filter.Exiftool do """ @behaviour Pleroma.Upload.Filter - require Logger - def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do if Pleroma.Utils.command_available?("exiftool") do System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) -- cgit v1.2.3 From 3116a75e80144dff79232c8676bd28ed285a14d9 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 11 Jul 2020 18:22:03 +0300 Subject: Check if mogrify available before calling it --- lib/pleroma/upload/filter/mogrifun.ex | 9 ++++++--- lib/pleroma/upload/filter/mogrify.ex | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex index 7d95577a4..8f362333d 100644 --- a/lib/pleroma/upload/filter/mogrifun.ex +++ b/lib/pleroma/upload/filter/mogrifun.ex @@ -35,9 +35,12 @@ defmodule Pleroma.Upload.Filter.Mogrifun do ] def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - Filter.Mogrify.do_filter(file, [Enum.random(@filters)]) - - :ok + if Pleroma.Utils.command_available?("mogrify") do + Filter.Mogrify.do_filter(file, [Enum.random(@filters)]) + :ok + else + {:error, "mogrify command not found"} + end end def filter(_), do: :ok diff --git a/lib/pleroma/upload/filter/mogrify.ex b/lib/pleroma/upload/filter/mogrify.ex index 2eb758006..4bd0c2eb4 100644 --- a/lib/pleroma/upload/filter/mogrify.ex +++ b/lib/pleroma/upload/filter/mogrify.ex @@ -9,10 +9,14 @@ defmodule Pleroma.Upload.Filter.Mogrify do @type conversions :: conversion() | [conversion()] def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - filters = Pleroma.Config.get!([__MODULE__, :args]) - - do_filter(file, filters) - :ok + if Pleroma.Utils.command_available?("mogrify") do + filters = Pleroma.Config.get!([__MODULE__, :args]) + + do_filter(file, filters) + :ok + else + {:error, "mogrify command not found"} + end end def filter(_), do: :ok -- cgit v1.2.3 From 0eeeaa37e80f82025658b30455bde45ece0f9c0b Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Sun, 12 Jul 2020 01:38:16 +0300 Subject: Update types in MRF Keyword group --- config/description.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 6ef329807..90fa9e8e4 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1069,6 +1069,7 @@ }, %{ key: :webhook_url, + label: "Webhook URL", type: :string, description: "Configure the Slack incoming webhook", suggestions: ["https://hooks.slack.com/services/YOUR-KEY-HERE"] @@ -1587,14 +1588,14 @@ children: [ %{ key: :reject, - type: [:string], + type: {:list, :string}, description: "A list of patterns which result in message being rejected. Each pattern can be a string or a regular expression.", suggestions: ["foo", ~r/foo/iu] }, %{ key: :federated_timeline_removal, - type: [:string], + type: {:list, :string}, description: "A list of patterns which result in message being removed from federated timelines (a.k.a unlisted). Each pattern can be a string or a regular expression.", suggestions: ["foo", ~r/foo/iu] -- cgit v1.2.3 From b3764423251c963a5ca007517189f556bfe95155 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sat, 11 Jul 2020 10:36:36 +0300 Subject: MediaProxy whitelist setting now supports hosts with scheme added deprecation warning about using bare domains --- CHANGELOG.md | 1 + config/description.exs | 4 +- config/test.exs | 5 + docs/configuration/cheatsheet.md | 9 +- lib/pleroma/config/deprecation_warnings.ex | 15 ++- lib/pleroma/plugs/http_security_plug.ex | 47 ++++--- lib/pleroma/web/media_proxy/media_proxy.ex | 26 ++-- test/config/deprecation_warnings_test.exs | 8 ++ test/plugs/http_security_plug_test.exs | 90 ++++++++++--- .../media_proxy/media_proxy_controller_test.exs | 138 ++++++++++++-------- test/web/media_proxy/media_proxy_test.exs | 142 +++++++-------------- 11 files changed, 284 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e928528a..42149a2d2 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/). - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. +- Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated.
    API Changes diff --git a/config/description.exs b/config/description.exs index b0cc8d527..432705307 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1775,8 +1775,8 @@ %{ key: :whitelist, type: {:list, :string}, - description: "List of domains to bypass the mediaproxy", - suggestions: ["example.com"] + description: "List of hosts with scheme to bypass the mediaproxy", + suggestions: ["http://example.com"] } ] }, diff --git a/config/test.exs b/config/test.exs index d45c36b7b..abcf793e5 100644 --- a/config/test.exs +++ b/config/test.exs @@ -113,6 +113,11 @@ config :pleroma, :instances_favicons, enabled: true +config :pleroma, Pleroma.Uploaders.S3, + bucket: nil, + streaming_enabled: true, + public_endpoint: nil + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 1a0603892..f7885c11d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -252,6 +252,7 @@ This section describe PWA manifest instance-specific values. Currently this opti * `background_color`: Describe the background color of the app. (Example: `"#191b22"`, `"aliceblue"`). ## :emoji + * `shortcode_globs`: Location of custom emoji files. `*` can be used as a wildcard. Example `["/emoji/custom/**/*.png"]` * `pack_extensions`: A list of file extensions for emojis, when no emoji.txt for a pack is present. Example `[".png", ".gif"]` * `groups`: Emojis are ordered in groups (tags). This is an array of key-value pairs where the key is the groupname and the value the location or array of locations. `*` can be used as a wildcard. Example `[Custom: ["/emoji/*.png", "/emoji/custom/*.png"]]` @@ -260,13 +261,14 @@ This section describe PWA manifest instance-specific values. Currently this opti memory for this amount of seconds multiplied by the number of files. ## :media_proxy + * `enabled`: Enables proxying of remote media to the instance’s proxy * `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts. * `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`. -* `whitelist`: List of domains to bypass the mediaproxy +* `whitelist`: List of hosts with scheme to bypass the mediaproxy (e.g. `https://example.com`) * `invalidation`: options for remove media from cache after delete object: - * `enabled`: Enables purge cache - * `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use. + * `enabled`: Enables purge cache + * `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use. ### Purge cache strategy @@ -278,6 +280,7 @@ Urls of attachments pass to script as arguments. * `script_path`: path to external script. Example: + ```elixir config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script, script_path: "./installation/nginx-cache-purge.example" diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 0a6c724fb..026871c4f 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -54,6 +54,7 @@ def warn do check_hellthread_threshold() mrf_user_allowlist() check_old_mrf_config() + check_media_proxy_whitelist_config() end def check_old_mrf_config do @@ -65,7 +66,7 @@ def check_old_mrf_config do move_namespace_and_warn(@mrf_config_map, warning_preface) end - @spec move_namespace_and_warn([config_map()], String.t()) :: :ok + @spec move_namespace_and_warn([config_map()], String.t()) :: :ok | nil def move_namespace_and_warn(config_map, warning_preface) do warning = Enum.reduce(config_map, "", fn @@ -84,4 +85,16 @@ def move_namespace_and_warn(config_map, warning_preface) do Logger.warn(warning_preface <> warning) end end + + @spec check_media_proxy_whitelist_config() :: :ok | nil + def check_media_proxy_whitelist_config do + whitelist = Config.get([:media_proxy, :whitelist]) + + if Enum.any?(whitelist, &(not String.starts_with?(&1, "http"))) do + Logger.warn(""" + !!!DEPRECATION WARNING!!! + Your config is using old format (only domain) for MediaProxy whitelist option. Setting should work for now, but you are advised to change format to scheme with port to prevent possible issues later. + """) + end + end end diff --git a/lib/pleroma/plugs/http_security_plug.ex b/lib/pleroma/plugs/http_security_plug.ex index 7d65cf078..c363b193b 100644 --- a/lib/pleroma/plugs/http_security_plug.ex +++ b/lib/pleroma/plugs/http_security_plug.ex @@ -108,31 +108,48 @@ defp csp_string do |> :erlang.iolist_to_binary() end - defp build_csp_multimedia_source_list do - media_proxy_whitelist = - Enum.reduce(Config.get([:media_proxy, :whitelist]), [], fn host, acc -> - add_source(acc, host) - end) + defp build_csp_from_whitelist([], acc), do: acc - media_proxy_base_url = build_csp_param(Config.get([:media_proxy, :base_url])) + defp build_csp_from_whitelist([last], acc) do + [build_csp_param_from_whitelist(last) | acc] + end - upload_base_url = build_csp_param(Config.get([Pleroma.Upload, :base_url])) + defp build_csp_from_whitelist([head | tail], acc) do + build_csp_from_whitelist(tail, [[?\s, build_csp_param_from_whitelist(head)] | acc]) + end - s3_endpoint = build_csp_param(Config.get([Pleroma.Uploaders.S3, :public_endpoint])) + # TODO: use `build_csp_param/1` after removing support bare domains for media proxy whitelist + defp build_csp_param_from_whitelist("http" <> _ = url) do + build_csp_param(url) + end - captcha_method = Config.get([Pleroma.Captcha, :method]) + defp build_csp_param_from_whitelist(url), do: url - captcha_endpoint = build_csp_param(Config.get([captcha_method, :endpoint])) + defp build_csp_multimedia_source_list do + media_proxy_whitelist = + [:media_proxy, :whitelist] + |> Config.get() + |> build_csp_from_whitelist([]) - [] - |> add_source(media_proxy_base_url) - |> add_source(upload_base_url) - |> add_source(s3_endpoint) + captcha_method = Config.get([Pleroma.Captcha, :method]) + captcha_endpoint = Config.get([captcha_method, :endpoint]) + + base_endpoints = + [ + [:media_proxy, :base_url], + [Pleroma.Upload, :base_url], + [Pleroma.Uploaders.S3, :public_endpoint] + ] + |> Enum.map(&Config.get/1) + + [captcha_endpoint | base_endpoints] + |> Enum.map(&build_csp_param/1) + |> Enum.reduce([], &add_source(&2, &1)) |> add_source(media_proxy_whitelist) - |> add_source(captcha_endpoint) end defp add_source(iodata, nil), do: iodata + defp add_source(iodata, []), do: iodata defp add_source(iodata, source), do: [[?\s, source] | iodata] defp add_csp_param(csp_iodata, nil), do: csp_iodata diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index 6f35826da..dfbfcea6b 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -60,22 +60,28 @@ defp local?(url), do: String.starts_with?(url, Pleroma.Web.base_url()) defp whitelisted?(url) do %{host: domain} = URI.parse(url) - mediaproxy_whitelist = Config.get([:media_proxy, :whitelist]) - - upload_base_url_domain = - if !is_nil(Config.get([Upload, :base_url])) do - [URI.parse(Config.get([Upload, :base_url])).host] + mediaproxy_whitelist_domains = + [:media_proxy, :whitelist] + |> Config.get() + |> Enum.map(&maybe_get_domain_from_url/1) + + whitelist_domains = + if base_url = Config.get([Upload, :base_url]) do + %{host: base_domain} = URI.parse(base_url) + [base_domain | mediaproxy_whitelist_domains] else - [] + mediaproxy_whitelist_domains end - whitelist = mediaproxy_whitelist ++ upload_base_url_domain + domain in whitelist_domains + end - Enum.any?(whitelist, fn pattern -> - String.equivalent?(domain, pattern) - end) + defp maybe_get_domain_from_url("http" <> _ = url) do + URI.parse(url).host end + defp maybe_get_domain_from_url(domain), do: domain + def encode_url(url) do base64 = Base.url_encode64(url, @base64_opts) diff --git a/test/config/deprecation_warnings_test.exs b/test/config/deprecation_warnings_test.exs index 548ee87b0..555661a71 100644 --- a/test/config/deprecation_warnings_test.exs +++ b/test/config/deprecation_warnings_test.exs @@ -54,4 +54,12 @@ test "move_namespace_and_warn/2" do assert Pleroma.Config.get(new_group2) == 2 assert Pleroma.Config.get(new_group3) == 3 end + + test "check_media_proxy_whitelist_config/0" do + clear_config([:media_proxy, :whitelist], ["https://example.com", "example2.com"]) + + assert capture_log(fn -> + Pleroma.Config.DeprecationWarnings.check_media_proxy_whitelist_config() + end) =~ "Your config is using old format (only domain) for MediaProxy whitelist option" + end end diff --git a/test/plugs/http_security_plug_test.exs b/test/plugs/http_security_plug_test.exs index 63b4d3f31..2297e3dac 100644 --- a/test/plugs/http_security_plug_test.exs +++ b/test/plugs/http_security_plug_test.exs @@ -4,17 +4,12 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlugTest do use Pleroma.Web.ConnCase + alias Pleroma.Config alias Plug.Conn - setup do: clear_config([:http_securiy, :enabled]) - setup do: clear_config([:http_security, :sts]) - setup do: clear_config([:http_security, :referrer_policy]) - describe "http security enabled" do - setup do - Config.put([:http_security, :enabled], true) - end + setup do: clear_config([:http_security, :enabled], true) test "it sends CSP headers when enabled", %{conn: conn} do conn = get(conn, "/api/v1/instance") @@ -29,7 +24,7 @@ test "it sends CSP headers when enabled", %{conn: conn} do end test "it sends STS headers when enabled", %{conn: conn} do - Config.put([:http_security, :sts], true) + clear_config([:http_security, :sts], true) conn = get(conn, "/api/v1/instance") @@ -38,7 +33,7 @@ test "it sends STS headers when enabled", %{conn: conn} do end test "it does not send STS headers when disabled", %{conn: conn} do - Config.put([:http_security, :sts], false) + clear_config([:http_security, :sts], false) conn = get(conn, "/api/v1/instance") @@ -47,23 +42,19 @@ test "it does not send STS headers when disabled", %{conn: conn} do end test "referrer-policy header reflects configured value", %{conn: conn} do - conn = get(conn, "/api/v1/instance") + resp = get(conn, "/api/v1/instance") - assert Conn.get_resp_header(conn, "referrer-policy") == ["same-origin"] + assert Conn.get_resp_header(resp, "referrer-policy") == ["same-origin"] - Config.put([:http_security, :referrer_policy], "no-referrer") + clear_config([:http_security, :referrer_policy], "no-referrer") - conn = - build_conn() - |> get("/api/v1/instance") + resp = get(conn, "/api/v1/instance") - assert Conn.get_resp_header(conn, "referrer-policy") == ["no-referrer"] + assert Conn.get_resp_header(resp, "referrer-policy") == ["no-referrer"] end - test "it sends `report-to` & `report-uri` CSP response headers" do - conn = - build_conn() - |> get("/api/v1/instance") + test "it sends `report-to` & `report-uri` CSP response headers", %{conn: conn} do + conn = get(conn, "/api/v1/instance") [csp] = Conn.get_resp_header(conn, "content-security-policy") @@ -74,10 +65,67 @@ test "it sends `report-to` & `report-uri` CSP response headers" do assert reply_to == "{\"endpoints\":[{\"url\":\"https://endpoint.com\"}],\"group\":\"csp-endpoint\",\"max-age\":10886400}" end + + test "default values for img-src and media-src with disabled media proxy", %{conn: conn} do + conn = get(conn, "/api/v1/instance") + + [csp] = Conn.get_resp_header(conn, "content-security-policy") + assert csp =~ "media-src 'self' https:;" + assert csp =~ "img-src 'self' data: blob: https:;" + end + end + + describe "img-src and media-src" do + setup do + clear_config([:http_security, :enabled], true) + clear_config([:media_proxy, :enabled], true) + clear_config([:media_proxy, :proxy_opts, :redirect_on_failure], false) + end + + test "media_proxy with base_url", %{conn: conn} do + url = "https://example.com" + clear_config([:media_proxy, :base_url], url) + assert_media_img_src(conn, url) + end + + test "upload with base url", %{conn: conn} do + url = "https://example2.com" + clear_config([Pleroma.Upload, :base_url], url) + assert_media_img_src(conn, url) + end + + test "with S3 public endpoint", %{conn: conn} do + url = "https://example3.com" + clear_config([Pleroma.Uploaders.S3, :public_endpoint], url) + assert_media_img_src(conn, url) + end + + test "with captcha endpoint", %{conn: conn} do + clear_config([Pleroma.Captcha.Mock, :endpoint], "https://captcha.com") + assert_media_img_src(conn, "https://captcha.com") + end + + test "with media_proxy whitelist", %{conn: conn} do + clear_config([:media_proxy, :whitelist], ["https://example6.com", "https://example7.com"]) + assert_media_img_src(conn, "https://example7.com https://example6.com") + end + + # TODO: delete after removing support bare domains for media proxy whitelist + test "with media_proxy bare domains whitelist (deprecated)", %{conn: conn} do + clear_config([:media_proxy, :whitelist], ["example4.com", "example5.com"]) + assert_media_img_src(conn, "example5.com example4.com") + end + end + + defp assert_media_img_src(conn, url) do + conn = get(conn, "/api/v1/instance") + [csp] = Conn.get_resp_header(conn, "content-security-policy") + assert csp =~ "media-src 'self' #{url};" + assert csp =~ "img-src 'self' data: blob: #{url};" end test "it does not send CSP headers when disabled", %{conn: conn} do - Config.put([:http_security, :enabled], false) + clear_config([:http_security, :enabled], false) conn = get(conn, "/api/v1/instance") diff --git a/test/web/media_proxy/media_proxy_controller_test.exs b/test/web/media_proxy/media_proxy_controller_test.exs index d61cef83b..d4db44c63 100644 --- a/test/web/media_proxy/media_proxy_controller_test.exs +++ b/test/web/media_proxy/media_proxy_controller_test.exs @@ -4,82 +4,118 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do use Pleroma.Web.ConnCase + import Mock - alias Pleroma.Config - setup do: clear_config(:media_proxy) - setup do: clear_config([Pleroma.Web.Endpoint, :secret_key_base]) + alias Pleroma.Web.MediaProxy + alias Pleroma.Web.MediaProxy.MediaProxyController + alias Plug.Conn setup do on_exit(fn -> Cachex.clear(:banned_urls_cache) end) end test "it returns 404 when MediaProxy disabled", %{conn: conn} do - Config.put([:media_proxy, :enabled], false) + clear_config([:media_proxy, :enabled], false) - assert %Plug.Conn{ + assert %Conn{ status: 404, resp_body: "Not Found" } = get(conn, "/proxy/hhgfh/eeeee") - assert %Plug.Conn{ + assert %Conn{ status: 404, resp_body: "Not Found" } = get(conn, "/proxy/hhgfh/eeee/fff") end - test "it returns 403 when signature invalidated", %{conn: conn} do - Config.put([:media_proxy, :enabled], true) - Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") - path = URI.parse(Pleroma.Web.MediaProxy.encode_url("https://google.fn")).path - Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000") - - assert %Plug.Conn{ - status: 403, - resp_body: "Forbidden" - } = get(conn, path) - - assert %Plug.Conn{ - status: 403, - resp_body: "Forbidden" - } = get(conn, "/proxy/hhgfh/eeee") - - assert %Plug.Conn{ - status: 403, - resp_body: "Forbidden" - } = get(conn, "/proxy/hhgfh/eeee/fff") - end + describe "" do + setup do + clear_config([:media_proxy, :enabled], true) + clear_config([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") + [url: MediaProxy.encode_url("https://google.fn/test.png")] + end - test "redirects on valid url when filename invalidated", %{conn: conn} do - Config.put([:media_proxy, :enabled], true) - Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") - url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") - invalid_url = String.replace(url, "test.png", "test-file.png") - response = get(conn, invalid_url) - assert response.status == 302 - assert redirected_to(response) == url - end + test "it returns 403 for invalid signature", %{conn: conn, url: url} do + Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], "000") + %{path: path} = URI.parse(url) + + assert %Conn{ + status: 403, + resp_body: "Forbidden" + } = get(conn, path) + + assert %Conn{ + status: 403, + resp_body: "Forbidden" + } = get(conn, "/proxy/hhgfh/eeee") + + assert %Conn{ + status: 403, + resp_body: "Forbidden" + } = get(conn, "/proxy/hhgfh/eeee/fff") + end - test "it performs ReverseProxy.call when signature valid", %{conn: conn} do - Config.put([:media_proxy, :enabled], true) - Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") - url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") + test "redirects on valid url when filename is invalidated", %{conn: conn, url: url} do + invalid_url = String.replace(url, "test.png", "test-file.png") + response = get(conn, invalid_url) + assert response.status == 302 + assert redirected_to(response) == url + end - with_mock Pleroma.ReverseProxy, - call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do - assert %Plug.Conn{status: :success} = get(conn, url) + test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do + with_mock Pleroma.ReverseProxy, + call: fn _conn, _url, _opts -> %Conn{status: :success} end do + assert %Conn{status: :success} = get(conn, url) + end + end + + test "it returns 404 when url is in banned_urls cache", %{conn: conn, url: url} do + MediaProxy.put_in_banned_urls("https://google.fn/test.png") + + with_mock Pleroma.ReverseProxy, + call: fn _conn, _url, _opts -> %Conn{status: :success} end do + assert %Conn{status: 404, resp_body: "Not Found"} = get(conn, url) + end end end - test "it returns 404 when url contains in banned_urls cache", %{conn: conn} do - Config.put([:media_proxy, :enabled], true) - Config.put([Pleroma.Web.Endpoint, :secret_key_base], "00000000000") - url = Pleroma.Web.MediaProxy.encode_url("https://google.fn/test.png") - Pleroma.Web.MediaProxy.put_in_banned_urls("https://google.fn/test.png") + describe "filename_matches/3" do + test "preserves the encoded or decoded path" do + assert MediaProxyController.filename_matches( + %{"filename" => "/Hello world.jpg"}, + "/Hello world.jpg", + "http://pleroma.social/Hello world.jpg" + ) == :ok + + assert MediaProxyController.filename_matches( + %{"filename" => "/Hello%20world.jpg"}, + "/Hello%20world.jpg", + "http://pleroma.social/Hello%20world.jpg" + ) == :ok + + assert MediaProxyController.filename_matches( + %{"filename" => "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg"}, + "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg", + "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg" + ) == :ok + + assert MediaProxyController.filename_matches( + %{"filename" => "/my%2Flong%2Furl%2F2019%2F07%2FS.jp"}, + "/my%2Flong%2Furl%2F2019%2F07%2FS.jp", + "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg" + ) == {:wrong_filename, "my%2Flong%2Furl%2F2019%2F07%2FS.jpg"} + end + + test "encoded url are tried to match for proxy as `conn.request_path` encodes the url" do + # conn.request_path will return encoded url + request_path = "/ANALYSE-DAI-_-LE-STABLECOIN-100-D%C3%89CENTRALIS%C3%89-BQ.jpg" - with_mock Pleroma.ReverseProxy, - call: fn _conn, _url, _opts -> %Plug.Conn{status: :success} end do - assert %Plug.Conn{status: 404, resp_body: "Not Found"} = get(conn, url) + assert MediaProxyController.filename_matches( + true, + request_path, + "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg" + ) == :ok end end end diff --git a/test/web/media_proxy/media_proxy_test.exs b/test/web/media_proxy/media_proxy_test.exs index 69d2a71a6..72885cfdd 100644 --- a/test/web/media_proxy/media_proxy_test.exs +++ b/test/web/media_proxy/media_proxy_test.exs @@ -5,38 +5,33 @@ defmodule Pleroma.Web.MediaProxyTest do use ExUnit.Case use Pleroma.Tests.Helpers - import Pleroma.Web.MediaProxy - alias Pleroma.Web.MediaProxy.MediaProxyController - setup do: clear_config([:media_proxy, :enabled]) - setup do: clear_config(Pleroma.Upload) + alias Pleroma.Web.Endpoint + alias Pleroma.Web.MediaProxy describe "when enabled" do - setup do - Pleroma.Config.put([:media_proxy, :enabled], true) - :ok - end + setup do: clear_config([:media_proxy, :enabled], true) test "ignores invalid url" do - assert url(nil) == nil - assert url("") == nil + assert MediaProxy.url(nil) == nil + assert MediaProxy.url("") == nil end test "ignores relative url" do - assert url("/local") == "/local" - assert url("/") == "/" + assert MediaProxy.url("/local") == "/local" + assert MediaProxy.url("/") == "/" end test "ignores local url" do - local_url = Pleroma.Web.Endpoint.url() <> "/hello" - local_root = Pleroma.Web.Endpoint.url() - assert url(local_url) == local_url - assert url(local_root) == local_root + local_url = Endpoint.url() <> "/hello" + local_root = Endpoint.url() + assert MediaProxy.url(local_url) == local_url + assert MediaProxy.url(local_root) == local_root end test "encodes and decodes URL" do url = "https://pleroma.soykaf.com/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.starts_with?( encoded, @@ -50,86 +45,44 @@ test "encodes and decodes URL" do test "encodes and decodes URL without a path" do url = "https://pleroma.soykaf.com" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end test "encodes and decodes URL without an extension" do url = "https://pleroma.soykaf.com/path/" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.ends_with?(encoded, "/path") assert decode_result(encoded) == url end test "encodes and decodes URL and ignores query params for the path" do url = "https://pleroma.soykaf.com/static/logo.png?93939393939&bunny=true" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.ends_with?(encoded, "/logo.png") assert decode_result(encoded) == url end test "validates signature" do - secret_key_base = Pleroma.Config.get([Pleroma.Web.Endpoint, :secret_key_base]) - - on_exit(fn -> - Pleroma.Config.put([Pleroma.Web.Endpoint, :secret_key_base], secret_key_base) - end) - - encoded = url("https://pleroma.social") + encoded = MediaProxy.url("https://pleroma.social") - Pleroma.Config.put( - [Pleroma.Web.Endpoint, :secret_key_base], + clear_config( + [Endpoint, :secret_key_base], "00000000000000000000000000000000000000000000000" ) [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") - assert decode_url(sig, base64) == {:error, :invalid_signature} - end - - test "filename_matches preserves the encoded or decoded path" do - assert MediaProxyController.filename_matches( - %{"filename" => "/Hello world.jpg"}, - "/Hello world.jpg", - "http://pleroma.social/Hello world.jpg" - ) == :ok - - assert MediaProxyController.filename_matches( - %{"filename" => "/Hello%20world.jpg"}, - "/Hello%20world.jpg", - "http://pleroma.social/Hello%20world.jpg" - ) == :ok - - assert MediaProxyController.filename_matches( - %{"filename" => "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg"}, - "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg", - "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg" - ) == :ok - - assert MediaProxyController.filename_matches( - %{"filename" => "/my%2Flong%2Furl%2F2019%2F07%2FS.jp"}, - "/my%2Flong%2Furl%2F2019%2F07%2FS.jp", - "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg" - ) == {:wrong_filename, "my%2Flong%2Furl%2F2019%2F07%2FS.jpg"} - end - - test "encoded url are tried to match for proxy as `conn.request_path` encodes the url" do - # conn.request_path will return encoded url - request_path = "/ANALYSE-DAI-_-LE-STABLECOIN-100-D%C3%89CENTRALIS%C3%89-BQ.jpg" - - assert MediaProxyController.filename_matches( - true, - request_path, - "https://mydomain.com/uploads/2019/07/ANALYSE-DAI-_-LE-STABLECOIN-100-DÉCENTRALISÉ-BQ.jpg" - ) == :ok + assert MediaProxy.decode_url(sig, base64) == {:error, :invalid_signature} end test "uses the configured base_url" do - clear_config([:media_proxy, :base_url], "https://cache.pleroma.social") + base_url = "https://cache.pleroma.social" + clear_config([:media_proxy, :base_url], base_url) url = "https://pleroma.soykaf.com/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) - assert String.starts_with?(encoded, Pleroma.Config.get([:media_proxy, :base_url])) + assert String.starts_with?(encoded, base_url) end # Some sites expect ASCII encoded characters in the URL to be preserved even if @@ -140,7 +93,7 @@ test "preserve ASCII encoding" do url = "https://pleroma.com/%20/%21/%22/%23/%24/%25/%26/%27/%28/%29/%2A/%2B/%2C/%2D/%2E/%2F/%30/%31/%32/%33/%34/%35/%36/%37/%38/%39/%3A/%3B/%3C/%3D/%3E/%3F/%40/%41/%42/%43/%44/%45/%46/%47/%48/%49/%4A/%4B/%4C/%4D/%4E/%4F/%50/%51/%52/%53/%54/%55/%56/%57/%58/%59/%5A/%5B/%5C/%5D/%5E/%5F/%60/%61/%62/%63/%64/%65/%66/%67/%68/%69/%6A/%6B/%6C/%6D/%6E/%6F/%70/%71/%72/%73/%74/%75/%76/%77/%78/%79/%7A/%7B/%7C/%7D/%7E/%7F/%80/%81/%82/%83/%84/%85/%86/%87/%88/%89/%8A/%8B/%8C/%8D/%8E/%8F/%90/%91/%92/%93/%94/%95/%96/%97/%98/%99/%9A/%9B/%9C/%9D/%9E/%9F/%C2%A0/%A1/%A2/%A3/%A4/%A5/%A6/%A7/%A8/%A9/%AA/%AB/%AC/%C2%AD/%AE/%AF/%B0/%B1/%B2/%B3/%B4/%B5/%B6/%B7/%B8/%B9/%BA/%BB/%BC/%BD/%BE/%BF/%C0/%C1/%C2/%C3/%C4/%C5/%C6/%C7/%C8/%C9/%CA/%CB/%CC/%CD/%CE/%CF/%D0/%D1/%D2/%D3/%D4/%D5/%D6/%D7/%D8/%D9/%DA/%DB/%DC/%DD/%DE/%DF/%E0/%E1/%E2/%E3/%E4/%E5/%E6/%E7/%E8/%E9/%EA/%EB/%EC/%ED/%EE/%EF/%F0/%F1/%F2/%F3/%F4/%F5/%F6/%F7/%F8/%F9/%FA/%FB/%FC/%FD/%FE/%FF" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end @@ -151,56 +104,49 @@ test "preserve non-unicode characters per RFC3986" do url = "https://pleroma.com/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890-._~:/?#[]@!$&'()*+,;=|^`{}" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end test "preserve unicode characters" do url = "https://ko.wikipedia.org/wiki/위키백과:대문" - encoded = url(url) + encoded = MediaProxy.url(url) assert decode_result(encoded) == url end end describe "when disabled" do - setup do - enabled = Pleroma.Config.get([:media_proxy, :enabled]) - - if enabled do - Pleroma.Config.put([:media_proxy, :enabled], false) - - on_exit(fn -> - Pleroma.Config.put([:media_proxy, :enabled], enabled) - :ok - end) - end - - :ok - end + setup do: clear_config([:media_proxy, :enabled], false) test "does not encode remote urls" do - assert url("https://google.fr") == "https://google.fr" + assert MediaProxy.url("https://google.fr") == "https://google.fr" end end defp decode_result(encoded) do [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") - {:ok, decoded} = decode_url(sig, base64) + {:ok, decoded} = MediaProxy.decode_url(sig, base64) decoded end describe "whitelist" do - setup do - Pleroma.Config.put([:media_proxy, :enabled], true) - :ok - end + setup do: clear_config([:media_proxy, :enabled], true) test "mediaproxy whitelist" do - Pleroma.Config.put([:media_proxy, :whitelist], ["google.com", "feld.me"]) + clear_config([:media_proxy, :whitelist], ["https://google.com", "https://feld.me"]) + url = "https://feld.me/foo.png" + + unencoded = MediaProxy.url(url) + assert unencoded == url + end + + # TODO: delete after removing support bare domains for media proxy whitelist + test "mediaproxy whitelist bare domains whitelist (deprecated)" do + clear_config([:media_proxy, :whitelist], ["google.com", "feld.me"]) url = "https://feld.me/foo.png" - unencoded = url(url) + unencoded = MediaProxy.url(url) assert unencoded == url end @@ -211,17 +157,17 @@ test "does not change whitelisted urls" do media_url = "https://mycdn.akamai.com" url = "#{media_url}/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.starts_with?(encoded, media_url) end test "ensure Pleroma.Upload base_url is always whitelisted" do media_url = "https://media.pleroma.social" - Pleroma.Config.put([Pleroma.Upload, :base_url], media_url) + clear_config([Pleroma.Upload, :base_url], media_url) url = "#{media_url}/static/logo.png" - encoded = url(url) + encoded = MediaProxy.url(url) assert String.starts_with?(encoded, media_url) end -- cgit v1.2.3 From 4347d2de5eb609bbfa1a206a5de5df925d3a0696 Mon Sep 17 00:00:00 2001 From: href Date: Sun, 12 Jul 2020 17:23:33 +0200 Subject: Config/Docs: Expand behaviour suggestions at runtime --- config/description.exs | 14 +++------- lib/pleroma/application.ex | 1 + lib/pleroma/docs/generator.ex | 31 +++++++++++++++------- lib/pleroma/docs/json.ex | 21 ++++++++++----- lib/pleroma/docs/markdown.ex | 5 ++++ .../web/admin_api/controllers/config_controller.ex | 4 +-- test/docs/generator_test.exs | 12 ++------- 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/config/description.exs b/config/description.exs index b0cc8d527..61d1d055e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -23,18 +23,14 @@ key: :uploader, type: :module, description: "Module which will be used for uploads", - suggestions: [Pleroma.Uploaders.Local, Pleroma.Uploaders.S3] + suggestions: {:list_behaviour_implementations, Pleroma.Uploaders.Uploader} }, %{ key: :filters, type: {:list, :module}, description: "List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom module you need to use full name.", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/upload/filter", - "Elixir.Pleroma.Upload.Filter." - ) + suggestions: {:list_behaviour_implementations, Pleroma.Upload.Filter} }, %{ key: :link_name, @@ -1404,11 +1400,7 @@ type: [:module, {:list, :module}], description: "A list of MRF policies enabled. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/web/activity_pub/mrf", - "Elixir.Pleroma.Web.ActivityPub.MRF." - ) + suggestions: {:list_behaviour_implementations, Pleroma.Web.ActivityPub.MRF} }, %{ key: :transparency, diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 84f3aa82d..b68a373a4 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -42,6 +42,7 @@ def start(_type, _args) do Pleroma.ApplicationRequirements.verify!() setup_instrumenters() load_custom_modules() + Pleroma.Docs.JSON.compile() adapter = Application.get_env(:tesla, :adapter) diff --git a/lib/pleroma/docs/generator.ex b/lib/pleroma/docs/generator.ex index e0fc8cd02..a671a6278 100644 --- a/lib/pleroma/docs/generator.ex +++ b/lib/pleroma/docs/generator.ex @@ -6,16 +6,21 @@ def process(implementation, descriptions) do implementation.process(descriptions) end - @spec list_modules_in_dir(String.t(), String.t()) :: [module()] - def list_modules_in_dir(dir, start) do - with {:ok, files} <- File.ls(dir) do - files - |> Enum.filter(&String.ends_with?(&1, ".ex")) - |> Enum.map(fn filename -> - module = filename |> String.trim_trailing(".ex") |> Macro.camelize() - String.to_atom(start <> module) - end) - end + @spec list_behaviour_implementations(behaviour :: module()) :: [module()] + def list_behaviour_implementations(behaviour) do + :code.all_loaded() + |> Enum.filter(fn {module, _} -> + # This shouldn't be needed as all modules are expected to have module_info/1, + # but in test enviroments some transient modules `:elixir_compiler_XX` + # are loaded for some reason (where XX is a random integer). + if function_exported?(module, :module_info, 1) do + module.module_info(:attributes) + |> Keyword.get_values(:behaviour) + |> List.flatten() + |> Enum.member?(behaviour) + end + end) + |> Enum.map(fn {module, _} -> module end) end @doc """ @@ -87,6 +92,12 @@ defp humanize(entity) do else: string end + defp format_suggestions({:list_behaviour_implementations, behaviour}) do + behaviour + |> list_behaviour_implementations() + |> format_suggestions() + end + defp format_suggestions([]), do: [] defp format_suggestions([suggestion | tail]) do diff --git a/lib/pleroma/docs/json.ex b/lib/pleroma/docs/json.ex index d1cf1f487..feeb4320e 100644 --- a/lib/pleroma/docs/json.ex +++ b/lib/pleroma/docs/json.ex @@ -1,5 +1,19 @@ defmodule Pleroma.Docs.JSON do @behaviour Pleroma.Docs.Generator + @external_resource "config/description.exs" + @raw_config Pleroma.Config.Loader.read("config/description.exs") + @raw_descriptions @raw_config[:pleroma][:config_description] + @term __MODULE__.Compiled + + @spec compile :: :ok + def compile do + :persistent_term.put(@term, Pleroma.Docs.Generator.convert_to_strings(@raw_descriptions)) + end + + @spec compiled_descriptions :: Map.t() + def compiled_descriptions do + :persistent_term.get(@term) + end @spec process(keyword()) :: {:ok, String.t()} def process(descriptions) do @@ -13,11 +27,4 @@ def process(descriptions) do {:ok, path} end end - - def compile do - with config <- Pleroma.Config.Loader.read("config/description.exs") do - config[:pleroma][:config_description] - |> Pleroma.Docs.Generator.convert_to_strings() - end - end end diff --git a/lib/pleroma/docs/markdown.ex b/lib/pleroma/docs/markdown.ex index 68b106499..da3f20f43 100644 --- a/lib/pleroma/docs/markdown.ex +++ b/lib/pleroma/docs/markdown.ex @@ -68,6 +68,11 @@ defp print_suggestion(file, suggestion, as_list \\ false) do IO.write(file, " #{list_mark}`#{inspect(suggestion)}`\n") end + defp print_suggestions(file, {:list_behaviour_implementations, behaviour}) do + suggestions = Pleroma.Docs.Generator.list_behaviour_implementations(behaviour) + print_suggestions(file, suggestions) + end + defp print_suggestions(_file, nil), do: nil defp print_suggestions(_file, ""), do: nil diff --git a/lib/pleroma/web/admin_api/controllers/config_controller.ex b/lib/pleroma/web/admin_api/controllers/config_controller.ex index 7f60470cb..0df13007f 100644 --- a/lib/pleroma/web/admin_api/controllers/config_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/config_controller.ex @@ -9,8 +9,6 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do alias Pleroma.ConfigDB alias Pleroma.Plugs.OAuthScopesPlug - @descriptions Pleroma.Docs.JSON.compile() - plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["write"], admin: true} when action == :update) @@ -25,7 +23,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.ConfigOperation def descriptions(conn, _params) do - descriptions = Enum.filter(@descriptions, &whitelisted_config?/1) + descriptions = Enum.filter(Pleroma.Docs.JSON.compiled_descriptions(), &whitelisted_config?/1) json(conn, descriptions) end diff --git a/test/docs/generator_test.exs b/test/docs/generator_test.exs index 9c9f4357b..b32918a69 100644 --- a/test/docs/generator_test.exs +++ b/test/docs/generator_test.exs @@ -13,21 +13,13 @@ defmodule Pleroma.Docs.GeneratorTest do key: :uploader, type: :module, description: "", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/upload/filter", - "Elixir.Pleroma.Upload.Filter." - ) + suggestions: {:list_behaviour_implementations, Pleroma.Upload.Filter} }, %{ key: :filters, type: {:list, :module}, description: "", - suggestions: - Generator.list_modules_in_dir( - "lib/pleroma/web/activity_pub/mrf", - "Elixir.Pleroma.Web.ActivityPub.MRF." - ) + suggestions: {:list_behaviour_implementations, Pleroma.Web.ActivityPub.MRF} }, %{ key: Pleroma.Upload, -- cgit v1.2.3 From 133004e22d74e7cdfd13a69f88b509b395985a5d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 12 Jul 2020 10:38:07 -0500 Subject: Improve database config migration and add documentation --- docs/configuration/howto_database_config.md | 127 ++++++++++++++++++++++++++++ lib/mix/tasks/pleroma/config.ex | 5 +- 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 docs/configuration/howto_database_config.md diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md new file mode 100644 index 000000000..b39b75bd4 --- /dev/null +++ b/docs/configuration/howto_database_config.md @@ -0,0 +1,127 @@ +# How to activate Pleroma in-database configuration +## Explanation + +The configuration of Pleroma has traditionally been managed with a config file, e.g. `config/prod.secret.exs`. This method requires a restart of the application for any configuration changes to take effect. We have made it possible to control most settings in the AdminFE interface after running a migration script. + +## Migration to database config + +1. Stop your Pleroma instance and edit your Pleroma config to enable database configuration: + + ``` + config :pleroma, configurable_from_database: true + ``` + +2. Run the mix task to migrate to the database. You'll receive some debugging output and a few messages informing you of what happened. + + ``` + $ mix pleroma.config migrate_to_db + + 10:04:34.155 [debug] QUERY OK source="config" db=1.6ms decode=2.0ms queue=33.5ms idle=0.0ms +SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] +Migrating settings from file: /home/pleroma/config/dev.secret.exs + + 10:04:34.240 [debug] QUERY OK db=4.5ms queue=0.3ms idle=92.2ms +TRUNCATE config; [] + + 10:04:34.244 [debug] QUERY OK db=2.8ms queue=0.3ms idle=97.2ms +ALTER SEQUENCE config_id_seq RESTART; [] + + 10:04:34.256 [debug] QUERY OK source="config" db=0.8ms queue=1.4ms idle=109.8ms +SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 WHERE ((c0."group" = $1) AND (c0."key" = $2)) [":pleroma", ":instance"] + + 10:04:34.292 [debug] QUERY OK db=2.6ms queue=1.7ms idle=137.7ms +INSERT INTO "config" ("group","key","value","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [":pleroma", ":instance", <<131, 108, 0, 0, 0, 1, 104, 2, 100, 0, 4, 110, 97, 109, 101, 109, 0, 0, 0, 7, 66, 108, 101, 114, 111, 109, 97, 106>>, ~N[2020-07-12 15:04:34], ~N[2020-07-12 15:04:34]] + Settings for key instance migrated. + Settings for group :pleroma migrated. + ``` + +3. It is recommended to backup your config file now. + ``` + cp config/dev.secret.exs config/dev.secret.exs.orig + ``` + +4. Now you can edit your config file and strip it down to the only settings which are not possible to control in the database. e.g., the Postgres and webserver (Endpoint) settings cannot be controlled in the database because the application needs the settings to start up and access the database. + + ⚠️ **THIS IS NOT REQUIRED** + + Any settings in the database will override those in the config file, but you may find it less confusing if the setting is only declared in one place. + + A non-exhaustive list of settings that are only possible in the config file include the following: + +* config :pleroma, Pleroma.Web.Endpoint +* config :pleroma, Pleroma.Repo +* config :pleroma, configurable_from_database +* config :pleroma, :database, rum_enabled +* config :pleroma, :connections_pool + +Here is an example of a server config stripped down after migration: + +``` +use Mix.Config + +config :pleroma, Pleroma.Web.Endpoint, + url: [host: "cool.pleroma.site", scheme: "https", port: 443] + + +config :pleroma, Pleroma.Repo, + adapter: Ecto.Adapters.Postgres, + username: "pleroma", + password: "MySecretPassword", + database: "pleroma_prod", + hostname: "localhost" + +config :pleroma, configurable_from_database: true +``` + +5. Start your instance back up and you can now access the Settings tab in AdminFE. + + +## Reverting back from database config + +1. Stop your Pleroma instance. + +2. Run the mix task to migrate back from the database. You'll receive some debugging output and a few messages informing you of what happened. + + ``` + $ mix pleroma.config migrate_from_db + + 10:26:30.593 [debug] QUERY OK source="config" db=9.8ms decode=1.2ms queue=26.0ms idle=0.0ms +SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] + + 10:26:30.659 [debug] QUERY OK source="config" db=1.1ms idle=80.7ms +SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] +Database configuration settings have been saved to config/dev.exported_from_db.secret.exs +``` + +3. The in-database configuration still exists, but it will not be used if you remove `config :pleroma, configurable_from_database: true` from your config. + +## Debugging + +### Clearing database config +You can clear the database config by truncating the `config` table in the database. e.g., + +``` +psql -d pleroma_dev +pleroma_dev=# TRUNCATE config; +TRUNCATE TABLE +``` + +Additionally, every time you migrate the configuration to the database the config table is automatically truncated to ensure a clean migration. + +### Manually removing a setting +If you encounter a situation where the server cannot run properly because of an invalid setting in the database and this is preventing you from accessing AdminFE, you can manually remove the offending setting if you know which one it is. + +e.g., here is an example showing a minimal configuration in the database. Only the `config :pleroma, :instance` settings are in the table: + +``` +psql -d pleroma_dev +pleroma_dev=# select * from config; + id | key | value | inserted_at | updated_at | group +----+-----------+------------------------------------------------------------+---------------------+---------------------+---------- + 1 | :instance | \x836c0000000168026400046e616d656d00000007426c65726f6d616a | 2020-07-12 15:33:29 | 2020-07-12 15:33:29 | :pleroma +(1 row) +pleroma_dev=# delete from config where key = ':instance'; +DELETE 1 +``` + +Now the `config :pleroma, :instance` settings have been removed from the database. \ No newline at end of file diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index d5129d410..343438add 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -83,7 +83,7 @@ defp create(group, settings) do defp migrate_from_db(opts) do if Pleroma.Config.get([:configurable_from_database]) do - env = opts[:env] || "prod" + env = Mix.env() config_path = if Pleroma.Config.get(:release) do @@ -105,6 +105,7 @@ defp migrate_from_db(opts) do :ok = File.close(file) System.cmd("mix", ["format", config_path]) + shell_info("Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs") else migration_error() end @@ -112,7 +113,7 @@ defp migrate_from_db(opts) do defp migration_error do shell_error( - "Migration is not allowed in config. You can change this behavior by setting `configurable_from_database` to true." + "Migration is not allowed in config. You can change this behavior by setting `config :pleroma, configurable_from_database: true`" ) end -- cgit v1.2.3 From 0871e8b8feb9f88a67ce12f8780691f41dae79a2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 12 Jul 2020 10:43:24 -0500 Subject: Make the query more precise --- docs/configuration/howto_database_config.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md index b39b75bd4..e4ddc190c 100644 --- a/docs/configuration/howto_database_config.md +++ b/docs/configuration/howto_database_config.md @@ -120,8 +120,8 @@ pleroma_dev=# select * from config; ----+-----------+------------------------------------------------------------+---------------------+---------------------+---------- 1 | :instance | \x836c0000000168026400046e616d656d00000007426c65726f6d616a | 2020-07-12 15:33:29 | 2020-07-12 15:33:29 | :pleroma (1 row) -pleroma_dev=# delete from config where key = ':instance'; +pleroma_dev=# delete from config where key = ':instance' and group = ':pleroma'; DELETE 1 ``` -Now the `config :pleroma, :instance` settings have been removed from the database. \ No newline at end of file +Now the `config :pleroma, :instance` settings have been removed from the database. -- cgit v1.2.3 From 46b123cded5f572851652cecedcce22aa87b97e7 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 12 Jul 2020 10:59:12 -0500 Subject: Still allow passing the arg, but fallback to MIX_ENV --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 343438add..38c6a6f1d 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -83,7 +83,7 @@ defp create(group, settings) do defp migrate_from_db(opts) do if Pleroma.Config.get([:configurable_from_database]) do - env = Mix.env() + env = opts[:env] || Mix.env() config_path = if Pleroma.Config.get(:release) do -- cgit v1.2.3 From a62f17da17fbebf817796b0278060abe2829c903 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 12 Jul 2020 19:11:30 -0500 Subject: Add `approval_pending` field to User --- lib/pleroma/user.ex | 2 ++ .../migrations/20200712234852_add_approval_pending_to_users.exs | 9 +++++++++ test/user_test.exs | 5 +++++ 3 files changed, 16 insertions(+) create mode 100644 priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index b9989f901..25c63fc44 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -106,6 +106,7 @@ defmodule Pleroma.User do field(:locked, :boolean, default: false) field(:confirmation_pending, :boolean, default: false) field(:password_reset_pending, :boolean, default: false) + field(:approval_pending, :boolean, default: false) field(:confirmation_token, :string, default: nil) field(:default_scope, :string, default: "public") field(:domain_blocks, {:array, :string}, default: []) @@ -262,6 +263,7 @@ def binary_id(%User{} = user), do: binary_id(user.id) @spec account_status(User.t()) :: account_status() def account_status(%User{deactivated: true}), do: :deactivated def account_status(%User{password_reset_pending: true}), do: :password_reset_pending + def account_status(%User{approval_pending: true}), do: :approval_pending def account_status(%User{confirmation_pending: true}) do if Config.get([:instance, :account_activation_required]) do diff --git a/priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs b/priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs new file mode 100644 index 000000000..f7eb8179b --- /dev/null +++ b/priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddApprovalPendingToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add(:approval_pending, :boolean) + end + end +end diff --git a/test/user_test.exs b/test/user_test.exs index 9788e09d9..040f532fe 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1342,6 +1342,11 @@ test "returns :deactivated for deactivated user" do user = insert(:user, local: true, confirmation_pending: false, deactivated: true) assert User.account_status(user) == :deactivated end + + test "returns :approval_pending for unapproved user" do + user = insert(:user, local: true, confirmation_pending: false, approval_pending: true) + assert User.account_status(user) == :approval_pending + end end describe "superuser?/1" do -- cgit v1.2.3 From 51ab8d0128970dd7458e93578acb36c20b1c185c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 12 Jul 2020 20:14:57 -0500 Subject: Add `account_approval_required` instance setting --- config/config.exs | 1 + config/description.exs | 5 +++++ docs/configuration/cheatsheet.md | 1 + lib/pleroma/web/mastodon_api/views/instance_view.ex | 1 + test/web/mastodon_api/controllers/instance_controller_test.exs | 1 + 5 files changed, 9 insertions(+) diff --git a/config/config.exs b/config/config.exs index 6fc84efc2..791740663 100644 --- a/config/config.exs +++ b/config/config.exs @@ -205,6 +205,7 @@ registrations_open: true, invites_enabled: false, account_activation_required: false, + account_approval_required: false, federating: true, federation_incoming_replies_max_depth: 100, federation_reachability_timeout_days: 7, diff --git a/config/description.exs b/config/description.exs index b0cc8d527..e57379dee 100644 --- a/config/description.exs +++ b/config/description.exs @@ -665,6 +665,11 @@ type: :boolean, description: "Require users to confirm their emails before signing in" }, + %{ + key: :account_approval_required, + type: :boolean, + description: "Require users to be manually approved by an admin before signing in" + }, %{ key: :federating, type: :boolean, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f796330f1..94389152e 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -33,6 +33,7 @@ To add configuration to your config file, you can copy it from the base config. * `registrations_open`: Enable registrations for anyone, invitations can be enabled when false. * `invites_enabled`: Enable user invitations for admins (depends on `registrations_open: false`). * `account_activation_required`: Require users to confirm their emails before signing in. +* `account_approval_required`: Require users to be manually approved by an admin before signing in. * `federating`: Enable federation with other instances. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 5deb0d7ed..243067a73 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -39,6 +39,7 @@ def render("show.json", _) do pleroma: %{ metadata: %{ account_activation_required: Keyword.get(instance, :account_activation_required), + account_approval_required: Keyword.get(instance, :account_approval_required), features: features(), federation: federation(), fields_limits: fields_limits() diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index cc880d82c..8a4183283 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -38,6 +38,7 @@ test "get instance information", %{conn: conn} do } = result assert result["pleroma"]["metadata"]["account_activation_required"] != nil + assert result["pleroma"]["metadata"]["account_approval_required"] != nil assert result["pleroma"]["metadata"]["features"] assert result["pleroma"]["metadata"]["federation"] assert result["pleroma"]["metadata"]["fields_limits"] -- cgit v1.2.3 From e4e557781877c7c3e4f6197cc52963025485dbb3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 12 Jul 2020 20:15:27 -0500 Subject: Prevent unapproved users from logging in --- lib/pleroma/web/oauth/oauth_controller.ex | 10 ++++++++++ test/web/oauth/oauth_controller_test.exs | 30 +++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 7683589cf..61fe81d33 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -337,6 +337,16 @@ defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :confirm ) end + defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :approval_pending}) do + render_error( + conn, + :forbidden, + "Your account is awaiting approval.", + %{}, + "awaiting_approval" + ) + end + defp handle_token_exchange_error(%Plug.Conn{} = conn, _error) do render_invalid_credentials_error(conn) end diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index d389e4ce0..ec5b78750 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -19,7 +19,10 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do key: "_test", signing_salt: "cooldude" ] - setup do: clear_config([:instance, :account_activation_required]) + setup do + clear_config([:instance, :account_activation_required]) + clear_config([:instance, :account_approval_required]) + end describe "in OAuth consumer mode, " do setup do @@ -995,6 +998,31 @@ test "rejects token exchange for user with confirmation_pending set to true" do } end + test "rejects token exchange for valid credentials belonging to an unapproved user and approval is required" do + Pleroma.Config.put([:instance, :account_approval_required], true) + password = "testpassword" + + user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password), approval_pending: true) + + refute Pleroma.User.account_status(user) == :active + + app = insert(:oauth_app) + + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert resp = json_response(conn, 403) + assert %{"error" => _} = resp + refute Map.has_key?(resp, "access_token") + end + test "rejects an invalid authorization code" do app = insert(:oauth_app) -- cgit v1.2.3 From bcfd38c8f3ecd2620bae7fc756ffc3f4bbe2b89e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 12 Jul 2020 21:31:13 -0500 Subject: Make a user unapproved when registering with `account_approval_required` on --- lib/pleroma/user.ex | 14 ++++++++++++++ test/user_test.exs | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 25c63fc44..e84900c4f 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -634,8 +634,16 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do opts[:need_confirmation] end + need_approval? = + if is_nil(opts[:need_approval]) do + Config.get([:instance, :account_approval_required]) + else + opts[:need_approval] + end + struct |> confirmation_changeset(need_confirmation: need_confirmation?) + |> approval_changeset(need_approval: need_approval?) |> cast(params, [ :bio, :raw_bio, @@ -2145,6 +2153,12 @@ def confirmation_changeset(user, need_confirmation: need_confirmation?) do cast(user, params, [:confirmation_pending, :confirmation_token]) end + @spec approval_changeset(User.t(), keyword()) :: Changeset.t() + def approval_changeset(user, need_approval: need_approval?) do + params = if need_approval?, do: %{approval_pending: true}, else: %{approval_pending: false} + cast(user, params, [:approval_pending]) + end + def add_pinnned_activity(user, %Pleroma.Activity{id: id}) do if id not in user.pinned_activities do max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) diff --git a/test/user_test.exs b/test/user_test.exs index 040f532fe..e57453982 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -516,6 +516,27 @@ test "it creates confirmed user if :confirmed option is given" do end end + describe "user registration, with :account_approval_required" do + @full_user_data %{ + bio: "A guy", + name: "my name", + nickname: "nick", + password: "test", + password_confirmation: "test", + email: "email@example.com" + } + setup do: clear_config([:instance, :account_approval_required], true) + + test "it creates unapproved user" do + changeset = User.register_changeset(%User{}, @full_user_data) + assert changeset.valid? + + {:ok, user} = Repo.insert(changeset) + + assert user.approval_pending + end + end + describe "get_or_fetch/1" do test "gets an existing user by nickname" do user = insert(:user) -- cgit v1.2.3 From 2aac92e9e05ba76903795cdddea652d7e444e701 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 13 Jul 2020 14:27:25 +0200 Subject: Transmogrifier.fix_in_reply_to/2: Use warn for non-fatal fail to get replied-to post --- lib/pleroma/web/activity_pub/transmogrifier.ex | 2 +- test/web/activity_pub/transmogrifier_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 884646ceb..168422c93 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -176,7 +176,7 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) |> Map.drop(["conversation"]) else e -> - Logger.error("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") + Logger.warn("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") object end else diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index f7b7d1a9f..fd8e7f24f 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -160,7 +160,7 @@ test "it does not crash if the object in inReplyTo can't be fetched" do assert capture_log(fn -> {:ok, _returned_activity} = Transmogrifier.handle_incoming(data) - end) =~ "[error] Couldn't fetch \"https://404.site/whatever\", error: nil" + end) =~ "[warn] Couldn't fetch \"https://404.site/whatever\", error: nil" end test "it works for incoming notices" do -- cgit v1.2.3 From ce243b107ffaf79fee0377998320d90c30dd77e0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 13 Jul 2020 14:23:03 +0200 Subject: Use Logger.info for {:reject, reason} --- lib/pleroma/object/fetcher.ex | 4 ++++ lib/pleroma/web/activity_pub/activity_pub.ex | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 3e2949ee2..e74c87269 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -124,6 +124,10 @@ def fetch_object_from_id!(id, options \\ []) do {:error, "Object has been deleted"} -> nil + {:reject, reason} -> + Logger.info("Rejected #{id} while fetching: #{inspect(reason)}") + nil + e -> Logger.error("Error while fetching #{id}: #{inspect(e)}") nil diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index bc7b5d95a..a4db1d87c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1370,6 +1370,10 @@ def fetch_and_prepare_user_from_ap_id(ap_id) do Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} + {:error, {:reject, reason} = e} -> + Logger.info("Rejected user #{ap_id}: #{inspect(reason)}") + {:error, e} + {:error, e} -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} -- cgit v1.2.3 From e1908a5270d7b060238c9bc8bcd2808c705c27d9 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 13 Jul 2020 08:39:56 -0500 Subject: Pick up env for both source and OTP installs --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 38c6a6f1d..7e2164181 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -83,7 +83,7 @@ defp create(group, settings) do defp migrate_from_db(opts) do if Pleroma.Config.get([:configurable_from_database]) do - env = opts[:env] || Mix.env() + env = opts[:env] || Pleroma.Config.get(:env) config_path = if Pleroma.Config.get(:release) do -- cgit v1.2.3 From 442fe3cd45edceda746e8c62056670c4d698aa0f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 13 Jul 2020 09:56:05 -0500 Subject: Show examples for both OTP and source --- docs/configuration/howto_database_config.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md index e4ddc190c..ded9a2eb3 100644 --- a/docs/configuration/howto_database_config.md +++ b/docs/configuration/howto_database_config.md @@ -13,9 +13,21 @@ The configuration of Pleroma has traditionally been managed with a config file, 2. Run the mix task to migrate to the database. You'll receive some debugging output and a few messages informing you of what happened. + **Source:** + ``` $ mix pleroma.config migrate_to_db + ``` + + or + + **OTP:** + ``` + $ ./bin/pleroma_ctl config migrate_to_db + ``` + + ``` 10:04:34.155 [debug] QUERY OK source="config" db=1.6ms decode=2.0ms queue=33.5ms idle=0.0ms SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] Migrating settings from file: /home/pleroma/config/dev.secret.exs @@ -82,9 +94,21 @@ config :pleroma, configurable_from_database: true 2. Run the mix task to migrate back from the database. You'll receive some debugging output and a few messages informing you of what happened. + **Source:** + ``` $ mix pleroma.config migrate_from_db + ``` + + or + + **OTP:** + + ``` + $ ./bin/pleroma_ctl config migrate_from_db + ``` + ``` 10:26:30.593 [debug] QUERY OK source="config" db=9.8ms decode=1.2ms queue=26.0ms idle=0.0ms SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] -- cgit v1.2.3 From d1cd3f4ec06214dc85e11dca30f193ee7d093488 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 13 Jul 2020 10:32:17 -0500 Subject: Lint --- lib/mix/tasks/pleroma/config.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 7e2164181..904c5a74b 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -105,7 +105,10 @@ defp migrate_from_db(opts) do :ok = File.close(file) System.cmd("mix", ["format", config_path]) - shell_info("Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs") + + shell_info( + "Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs" + ) else migration_error() end -- cgit v1.2.3 From b221b640a2dd443e3c2274b16ed5b62566329d09 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 13 Jul 2020 22:19:13 +0300 Subject: Transmogrifier: filtering weirdness in address fields --- lib/pleroma/web/activity_pub/transmogrifier.ex | 12 +++++++----- test/web/activity_pub/transmogrifier_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 884646ceb..f37bcab3e 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -62,15 +62,17 @@ def fix_summary(%{"summary" => _} = object) do def fix_summary(object), do: Map.put(object, "summary", "") def fix_addressing_list(map, field) do + addrs = map[field] + cond do - is_binary(map[field]) -> - Map.put(map, field, [map[field]]) + is_list(addrs) -> + Map.put(map, field, Enum.filter(addrs, &is_binary/1)) - is_nil(map[field]) -> - Map.put(map, field, []) + is_binary(addrs) -> + Map.put(map, field, [addrs]) true -> - map + Map.put(map, field, []) end end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index f7b7d1a9f..248b410c6 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -774,6 +774,29 @@ test "it correctly processes messages with non-array cc field" do assert [user.follower_address] == activity.data["to"] end + test "it correctly processes messages with weirdness in address fields" do + user = insert(:user) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [nil, user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public", ["¿"]], + "type" => "Create", + "object" => %{ + "content" => "…", + "type" => "Note", + "attributedTo" => user.ap_id, + "inReplyTo" => nil + }, + "actor" => user.ap_id + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + + assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] + assert [user.follower_address] == activity.data["to"] + end + test "it accepts Move activities" do old_user = insert(:user) new_user = insert(:user) -- cgit v1.2.3 From 5ddf0415c4fd6021422eb38b4625c01ad27582c5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 00:22:12 -0500 Subject: Accept `reason` in POST /api/v1/accounts and store in DB --- lib/pleroma/user.ex | 4 +- lib/pleroma/web/twitter_api/twitter_api.ex | 1 + ...0714043918_add_registration_reason_to_users.exs | 9 +++ .../controllers/account_controller_test.exs | 70 ++++++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index e84900c4f..51ccf6ffa 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -107,6 +107,7 @@ defmodule Pleroma.User do field(:confirmation_pending, :boolean, default: false) field(:password_reset_pending, :boolean, default: false) field(:approval_pending, :boolean, default: false) + field(:registration_reason, :string, default: nil) field(:confirmation_token, :string, default: nil) field(:default_scope, :string, default: "public") field(:domain_blocks, {:array, :string}, default: []) @@ -653,7 +654,8 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do :password, :password_confirmation, :emoji, - :accepts_chat_messages + :accepts_chat_messages, + :registration_reason ]) |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 5cfb385ac..4ff021b82 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -19,6 +19,7 @@ def register_user(params, opts \\ []) do |> Map.put(:nickname, params[:username]) |> Map.put(:name, Map.get(params, :fullname, params[:username])) |> Map.put(:password_confirmation, params[:password]) + |> Map.put(:registration_reason, params[:reason]) if Pleroma.Config.get([:instance, :registrations_open]) do create_user(params, opts) diff --git a/priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs b/priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs new file mode 100644 index 000000000..fa02fded4 --- /dev/null +++ b/priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddRegistrationReasonToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add(:registration_reason, :string) + end + 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 9c7b5e9b2..28d21371a 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -885,6 +885,7 @@ test "blocking / unblocking a user" do end setup do: clear_config([:instance, :account_activation_required]) + setup do: clear_config([:instance, :account_approval_required]) test "Account registration via Application", %{conn: conn} do conn = @@ -949,6 +950,75 @@ test "Account registration via Application", %{conn: conn} do assert token_from_db.user.confirmation_pending end + test "Account registration via app with account_approval_required", %{conn: conn} do + Pleroma.Config.put([:instance, :account_approval_required], true) + + conn = + 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" + }) + + assert %{ + "client_id" => client_id, + "client_secret" => client_secret, + "id" => _, + "name" => "client_name", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "vapid_key" => _, + "website" => nil + } = json_response_and_validate_schema(conn, 200) + + conn = + post(conn, "/oauth/token", %{ + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret + }) + + assert %{"access_token" => token, "refresh_token" => refresh, "scope" => scope} = + json_response(conn, 200) + + assert token + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + assert refresh + assert scope == "read write follow" + + conn = + build_conn() + |> put_req_header("content-type", "multipart/form-data") + |> put_req_header("authorization", "Bearer " <> token) + |> post("/api/v1/accounts", %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + bio: "Test Bio", + agreement: true, + reason: "I'm a cool dude, bro" + }) + + %{ + "access_token" => token, + "created_at" => _created_at, + "scope" => ^scope, + "token_type" => "Bearer" + } = json_response_and_validate_schema(conn, 200) + + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + token_from_db = Repo.preload(token_from_db, :user) + assert token_from_db.user + + assert token_from_db.user.confirmation_pending + assert token_from_db.user.approval_pending + + assert token_from_db.user.registration_reason == "I'm a cool dude, bro" + end + test "returns error when user already registred", %{conn: conn, valid_params: valid_params} do _user = insert(:user, email: "lain@example.org") app_token = insert(:oauth_token, user: nil) -- cgit v1.2.3 From 3062f86613696419f4716a53c3272ceef1b2b119 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 14 Jul 2020 07:31:21 +0300 Subject: added generated `pleroma.env` --- .gitignore | 2 + .../CLI_tasks/release_environments.md | 9 +++ docs/installation/otp_en.md | 7 ++- installation/pleroma.service | 2 + lib/mix/tasks/pleroma/release_env.ex | 64 ++++++++++++++++++++++ test/tasks/release_env_test.exs | 30 ++++++++++ 6 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 docs/administration/CLI_tasks/release_environments.md create mode 100644 lib/mix/tasks/pleroma/release_env.ex create mode 100644 test/tasks/release_env_test.exs diff --git a/.gitignore b/.gitignore index 599b52b9e..6ae21e914 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ erl_crash.dump # variables. /config/*.secret.exs /config/generated_config.exs +/config/*.env + # Database setup file, some may forget to delete it /config/setup_db.psql diff --git a/docs/administration/CLI_tasks/release_environments.md b/docs/administration/CLI_tasks/release_environments.md new file mode 100644 index 000000000..36ab43864 --- /dev/null +++ b/docs/administration/CLI_tasks/release_environments.md @@ -0,0 +1,9 @@ +# Generate release environment file + +```sh tab="OTP" + ./bin/pleroma_ctl release_env gen +``` + +```sh tab="From Source" +mix pleroma.release_env gen +``` diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index e4f822d1c..e115c2297 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -121,6 +121,9 @@ chown -R pleroma /etc/pleroma # Run the config generator su pleroma -s $SHELL -lc "./bin/pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" +# Run the environment file generator. +su pleroma -s $SHELL -lc "./bin/pleroma_ctl release_env gen" + # Create the postgres database su postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" @@ -131,7 +134,7 @@ su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate" # su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" # Start the instance to verify that everything is working as expected -su pleroma -s $SHELL -lc "./bin/pleroma daemon" +su pleroma -s $SHELL -lc "export $( cat /opt/pleroma/config/pleroma.env | xargs); ./bin/pleroma daemon" # Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly sleep 20 && curl http://localhost:4000/api/v1/instance @@ -200,6 +203,7 @@ rc-update add pleroma # Copy the service into a proper directory cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service + # Start pleroma and enable it on boot systemctl start pleroma systemctl enable pleroma @@ -275,4 +279,3 @@ This will create an account withe the username of 'joeuser' with the email addre ## Questions Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. - diff --git a/installation/pleroma.service b/installation/pleroma.service index 5dcbc1387..ee00a3b7a 100644 --- a/installation/pleroma.service +++ b/installation/pleroma.service @@ -17,6 +17,8 @@ Environment="MIX_ENV=prod" Environment="HOME=/var/lib/pleroma" ; Path to the folder containing the Pleroma installation. WorkingDirectory=/opt/pleroma +; Path to the environment file. the file contains RELEASE_COOKIE and etc +EnvironmentFile=/opt/pleroma/config/pleroma.env ; Path to the Mix binary. ExecStart=/usr/bin/mix phx.server diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex new file mode 100644 index 000000000..cbbbdeff6 --- /dev/null +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -0,0 +1,64 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.ReleaseEnv do + use Mix.Task + import Mix.Pleroma + + @shortdoc "Generate Pleroma environment file." + @moduledoc File.read!("docs/administration/CLI_tasks/release_environments.md") + + def run(["gen" | rest]) do + {options, [], []} = + OptionParser.parse( + rest, + strict: [ + force: :boolean, + path: :string + ], + aliases: [ + p: :path, + f: :force + ] + ) + + env_path = + get_option( + options, + :path, + "Environment file path", + "config/pleroma.env" + ) + |> Path.expand() + + proceed? = + if File.exists?(env_path) do + get_option( + options, + :force, + "Environment file is exist. Do you want overwritten the #{env_path} file? (y/n)", + "n" + ) === "y" + else + true + end + + if proceed? do + do_generate(env_path) + + shell_info( + "The file generated: #{env_path}.\nTo use the enviroment file need to add the line ';EnvironmentFile=#{ + env_path + }' in service file (/installation/pleroma.service)." + ) + end + end + + def do_generate(path) do + content = "RELEASE_COOKIE=#{Base.encode32(:crypto.strong_rand_bytes(32))}" + + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + end +end diff --git a/test/tasks/release_env_test.exs b/test/tasks/release_env_test.exs new file mode 100644 index 000000000..519f1eba9 --- /dev/null +++ b/test/tasks/release_env_test.exs @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.ReleaseEnvTest do + use ExUnit.Case + import ExUnit.CaptureIO, only: [capture_io: 1] + + @path "config/pleroma.test.env" + + def do_clean do + if File.exists?(@path) do + File.rm_rf(@path) + end + end + + setup do + do_clean() + on_exit(fn -> do_clean() end) + :ok + end + + test "generate pleroma.env" do + assert capture_io(fn -> + Mix.Tasks.Pleroma.ReleaseEnv.run(["gen", "--path", @path, "--force"]) + end) =~ "The file generated" + + assert File.read!(@path) =~ "RELEASE_COOKIE=" + end +end -- cgit v1.2.3 From cf3f8cb72a46f0c8c798d4022cff442fae4ab401 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sun, 19 Jul 2020 21:35:57 +0300 Subject: [#1940] Reinstated OAuth-less `admin_token` authentication. Refactored UserIsAdminPlug (freed from checking admin scopes presence). --- .../plugs/admin_secret_authentication_plug.ex | 12 +- lib/pleroma/plugs/user_is_admin_plug.ex | 25 +-- priv/gettext/errors.pot | 230 +++++++++++---------- priv/gettext/it/LC_MESSAGES/errors.po | 6 +- priv/gettext/nl/LC_MESSAGES/errors.po | 4 +- priv/gettext/pl/LC_MESSAGES/errors.po | 4 +- .../admin_secret_authentication_plug_test.exs | 4 + test/plugs/user_is_admin_plug_test.exs | 114 ++-------- .../controllers/admin_api_controller_test.exs | 10 + .../controllers/report_controller_test.exs | 2 +- 10 files changed, 169 insertions(+), 242 deletions(-) diff --git a/lib/pleroma/plugs/admin_secret_authentication_plug.ex b/lib/pleroma/plugs/admin_secret_authentication_plug.ex index b4b47a31f..ff0328d4a 100644 --- a/lib/pleroma/plugs/admin_secret_authentication_plug.ex +++ b/lib/pleroma/plugs/admin_secret_authentication_plug.ex @@ -4,7 +4,9 @@ defmodule Pleroma.Plugs.AdminSecretAuthenticationPlug do import Plug.Conn + alias Pleroma.User + alias Pleroma.Plugs.OAuthScopesPlug def init(options) do options @@ -26,7 +28,7 @@ def call(conn, _) do def authenticate(%{params: %{"admin_token" => admin_token}} = conn) do if admin_token == secret_token() do - assign(conn, :user, %User{is_admin: true}) + assign_admin_user(conn) else conn end @@ -36,8 +38,14 @@ def authenticate(conn) do token = secret_token() case get_req_header(conn, "x-admin-token") do - [^token] -> assign(conn, :user, %User{is_admin: true}) + [^token] -> assign_admin_user(conn) _ -> conn end end + + defp assign_admin_user(conn) do + conn + |> assign(:user, %User{is_admin: true}) + |> OAuthScopesPlug.skip_plug() + end end diff --git a/lib/pleroma/plugs/user_is_admin_plug.ex b/lib/pleroma/plugs/user_is_admin_plug.ex index 2748102df..488a61d1d 100644 --- a/lib/pleroma/plugs/user_is_admin_plug.ex +++ b/lib/pleroma/plugs/user_is_admin_plug.ex @@ -7,37 +7,18 @@ defmodule Pleroma.Plugs.UserIsAdminPlug do import Plug.Conn alias Pleroma.User - alias Pleroma.Web.OAuth def init(options) do options end - def call(%{assigns: %{user: %User{is_admin: true}} = assigns} = conn, _) do - token = assigns[:token] - - cond do - not Pleroma.Config.enforce_oauth_admin_scope_usage?() -> - conn - - token && OAuth.Scopes.contains_admin_scopes?(token.scopes) -> - # Note: checking for _any_ admin scope presence, not necessarily fitting requested action. - # Thus, controller must explicitly invoke OAuthScopesPlug to verify scope requirements. - # Admin might opt out of admin scope for some apps to block any admin actions from them. - conn - - true -> - fail(conn) - end + def call(%{assigns: %{user: %User{is_admin: true}}} = conn, _) do + conn end def call(conn, _) do - fail(conn) - end - - defp fail(conn) do conn - |> render_error(:forbidden, "User is not an admin or OAuth admin scope is not granted.") + |> render_error(:forbidden, "User is not an admin.") |> halt() end end diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index 0e1cf37eb..e337226a7 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -90,110 +90,100 @@ msgid "must be equal to %{number}" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:421 +#: lib/pleroma/web/common_api/common_api.ex:505 msgid "Account not found" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:249 +#: lib/pleroma/web/common_api/common_api.ex:339 msgid "Already voted" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:360 +#: lib/pleroma/web/oauth/oauth_controller.ex:359 msgid "Bad request" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:426 msgid "Can't delete object" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 -msgid "Can't delete this post" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/controller_helper.ex:95 -#: lib/pleroma/web/controller_helper.ex:101 +#: lib/pleroma/web/controller_helper.ex:105 +#: lib/pleroma/web/controller_helper.ex:111 msgid "Can't display this activity" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:227 -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:254 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:285 msgid "Can't find user" msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:114 +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:61 msgid "Can't get favorites" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:437 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:438 msgid "Can't like object" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:556 +#: lib/pleroma/web/common_api/utils.ex:563 msgid "Cannot post an empty status without attachments" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:504 +#: lib/pleroma/web/common_api/utils.ex:511 msgid "Comment must be up to %{max_size} characters" msgstr "" #, elixir-format -#: lib/pleroma/config/config_db.ex:222 +#: lib/pleroma/config/config_db.ex:191 msgid "Config with params %{params} not found" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:95 +#: lib/pleroma/web/common_api/common_api.ex:181 +#: lib/pleroma/web/common_api/common_api.ex:185 msgid "Could not delete" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:141 +#: lib/pleroma/web/common_api/common_api.ex:231 msgid "Could not favorite" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:370 +#: lib/pleroma/web/common_api/common_api.ex:453 msgid "Could not pin" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:112 -msgid "Could not repeat" -msgstr "" - -#, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:188 +#: lib/pleroma/web/common_api/common_api.ex:278 msgid "Could not unfavorite" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:380 +#: lib/pleroma/web/common_api/common_api.ex:463 msgid "Could not unpin" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:126 +#: lib/pleroma/web/common_api/common_api.ex:216 msgid "Could not unrepeat" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:428 -#: lib/pleroma/web/common_api/common_api.ex:437 +#: lib/pleroma/web/common_api/common_api.ex:512 +#: lib/pleroma/web/common_api/common_api.ex:521 msgid "Could not update state" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:202 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:207 msgid "Error." msgstr "" @@ -203,8 +193,8 @@ msgid "Invalid CAPTCHA" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:117 -#: lib/pleroma/web/oauth/oauth_controller.ex:569 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:568 msgid "Invalid credentials" msgstr "" @@ -214,22 +204,22 @@ msgid "Invalid credentials." msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:265 +#: lib/pleroma/web/common_api/common_api.ex:355 msgid "Invalid indices" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:1147 +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:29 msgid "Invalid parameters" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:411 +#: lib/pleroma/web/common_api/utils.ex:414 msgid "Invalid password." msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:187 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:220 msgid "Invalid request" msgstr "" @@ -239,44 +229,44 @@ msgid "Kocaptcha service unavailable" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:113 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:112 msgid "Missing parameters" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:540 +#: lib/pleroma/web/common_api/utils.ex:547 msgid "No such conversation" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:439 -#: lib/pleroma/web/admin_api/admin_api_controller.ex:465 lib/pleroma/web/admin_api/admin_api_controller.ex:507 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:388 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:414 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:456 msgid "No such permission_group" msgstr "" #, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:74 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:485 lib/pleroma/web/admin_api/admin_api_controller.ex:1135 -#: lib/pleroma/web/feed/user_controller.ex:73 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#: lib/pleroma/plugs/uploaded_media.ex:84 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:486 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:11 +#: lib/pleroma/web/feed/user_controller.ex:71 lib/pleroma/web/ostatus/ostatus_controller.ex:143 msgid "Not found" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:241 +#: lib/pleroma/web/common_api/common_api.ex:331 msgid "Poll's author can't vote" msgstr "" #, elixir-format #: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 #: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 -#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:290 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:306 #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 msgid "Record not found" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:1153 -#: lib/pleroma/web/feed/user_controller.ex:79 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:32 +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:35 +#: lib/pleroma/web/feed/user_controller.ex:77 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:36 #: lib/pleroma/web/ostatus/ostatus_controller.ex:149 msgid "Something went wrong" msgstr "" @@ -287,7 +277,7 @@ msgid "The message visibility must be direct" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/utils.ex:566 +#: lib/pleroma/web/common_api/utils.ex:573 msgid "The status is over the character limit" msgstr "" @@ -302,65 +292,65 @@ msgid "Throttled" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:266 +#: lib/pleroma/web/common_api/common_api.ex:356 msgid "Too many choices" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:442 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:443 msgid "Unhandled activity type" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:536 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:485 msgid "You can't revoke your own admin status." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:218 -#: lib/pleroma/web/oauth/oauth_controller.ex:309 +#: lib/pleroma/web/oauth/oauth_controller.ex:221 +#: lib/pleroma/web/oauth/oauth_controller.ex:308 msgid "Your account is currently disabled" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:180 -#: lib/pleroma/web/oauth/oauth_controller.ex:332 +#: lib/pleroma/web/oauth/oauth_controller.ex:183 +#: lib/pleroma/web/oauth/oauth_controller.ex:331 msgid "Your login is missing a confirmed e-mail address" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:390 msgid "can't read inbox of %{nickname} as %{as_nickname}" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:473 msgid "can't update outbox of %{nickname} as %{as_nickname}" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:388 +#: lib/pleroma/web/common_api/common_api.ex:471 msgid "conversation is already muted" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:314 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:492 msgid "error" msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:29 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:32 msgid "mascots can only be images" msgstr "" #, elixir-format -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:60 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:62 msgid "not found" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:395 +#: lib/pleroma/web/oauth/oauth_controller.ex:394 msgid "Bad OAuth request." msgstr "" @@ -375,17 +365,17 @@ msgid "CAPTCHA expired" msgstr "" #, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:55 +#: lib/pleroma/plugs/uploaded_media.ex:57 msgid "Failed" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:411 +#: lib/pleroma/web/oauth/oauth_controller.ex:410 msgid "Failed to authenticate: %{message}." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:442 +#: lib/pleroma/web/oauth/oauth_controller.ex:441 msgid "Failed to set up user account." msgstr "" @@ -395,7 +385,7 @@ msgid "Insufficient permissions: %{permissions}." msgstr "" #, elixir-format -#: lib/pleroma/plugs/uploaded_media.ex:94 +#: lib/pleroma/plugs/uploaded_media.ex:104 msgid "Internal Error" msgstr "" @@ -411,12 +401,12 @@ msgid "Invalid answer data" msgstr "" #, elixir-format -#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:128 +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:33 msgid "Nodeinfo schema version not handled" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:169 +#: lib/pleroma/web/oauth/oauth_controller.ex:172 msgid "This action is outside the authorized scopes" msgstr "" @@ -426,13 +416,13 @@ msgid "Unknown error, please check the details and try again." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:116 -#: lib/pleroma/web/oauth/oauth_controller.ex:155 +#: lib/pleroma/web/oauth/oauth_controller.ex:119 +#: lib/pleroma/web/oauth/oauth_controller.ex:158 msgid "Unlisted redirect_uri." msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:391 +#: lib/pleroma/web/oauth/oauth_controller.ex:390 msgid "Unsupported OAuth provider: %{provider}." msgstr "" @@ -452,12 +442,12 @@ msgid "CAPTCHA Error" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:200 +#: lib/pleroma/web/common_api/common_api.ex:290 msgid "Could not add reaction emoji" msgstr "" #, elixir-format -#: lib/pleroma/web/common_api/common_api.ex:211 +#: lib/pleroma/web/common_api/common_api.ex:301 msgid "Could not remove reaction emoji" msgstr "" @@ -472,39 +462,45 @@ msgid "List not found" msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:124 +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:123 msgid "Missing parameter: %{name}" msgstr "" #, elixir-format -#: lib/pleroma/web/oauth/oauth_controller.ex:207 -#: lib/pleroma/web/oauth/oauth_controller.ex:322 +#: lib/pleroma/web/oauth/oauth_controller.ex:210 +#: lib/pleroma/web/oauth/oauth_controller.ex:321 msgid "Password reset is required" msgstr "" #, elixir-format #: lib/pleroma/tests/auth_test_controller.ex:9 -#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 -#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/fallback_redirect_controller.ex:6 -#: lib/pleroma/web/feed/tag_controller.ex:6 lib/pleroma/web/feed/user_controller.ex:6 -#: lib/pleroma/web/mailer/subscription_controller.ex:2 lib/pleroma/web/masto_fe_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 -#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 -#: lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 -#: lib/pleroma/web/oauth/fallback_controller.ex:6 lib/pleroma/web/oauth/mfa_controller.ex:10 -#: lib/pleroma/web/oauth/oauth_controller.ex:6 lib/pleroma/web/ostatus/ostatus_controller.ex:6 -#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:2 -#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/pleroma_api_controller.ex:6 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/config_controller.ex:6 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/invite_controller.ex:6 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex:6 lib/pleroma/web/admin_api/controllers/relay_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/report_controller.ex:6 lib/pleroma/web/admin_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/embed_controller.ex:6 +#: lib/pleroma/web/fallback_redirect_controller.ex:6 lib/pleroma/web/feed/tag_controller.ex:6 +#: lib/pleroma/web/feed/user_controller.ex:6 lib/pleroma/web/mailer/subscription_controller.ex:2 +#: lib/pleroma/web/masto_fe_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 +#: lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 +#: lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 +#: lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 +#: lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 lib/pleroma/web/oauth/fallback_controller.ex:6 +#: lib/pleroma/web/oauth/mfa_controller.ex:10 lib/pleroma/web/oauth/oauth_controller.ex:6 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/chat_controller.ex:5 lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:2 lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/notification_controller.ex:6 #: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 #: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 #: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 @@ -519,46 +515,56 @@ msgid "Two-factor authentication enabled, you must use a access token." msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:210 msgid "Unexpected error occurred while adding file to pack." msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:138 msgid "Unexpected error occurred while creating pack." msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:278 msgid "Unexpected error occurred while removing file from pack." msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:250 msgid "Unexpected error occurred while updating file in pack." msgstr "" #, elixir-format -#: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:179 msgid "Unexpected error occurred while updating pack metadata." msgstr "" -#, elixir-format -#: lib/pleroma/plugs/user_is_admin_plug.ex:40 -msgid "User is not an admin or OAuth admin scope is not granted." -msgstr "" - #, elixir-format #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 msgid "Web push subscription is disabled on this Pleroma instance" msgstr "" #, elixir-format -#: lib/pleroma/web/admin_api/admin_api_controller.ex:502 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:451 msgid "You can't revoke your own admin/moderator status." msgstr "" #, elixir-format -#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:105 +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:126 msgid "authorization required for timeline view" msgstr "" + +#, elixir-format +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:24 +msgid "Access denied" +msgstr "" + +#, elixir-format +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:282 +msgid "This API requires an authenticated user" +msgstr "" + +#, elixir-format +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 +msgid "User is not an admin." +msgstr "" diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po index 406a297d1..cd0cd6c65 100644 --- a/priv/gettext/it/LC_MESSAGES/errors.po +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -562,11 +562,11 @@ msgstr "Errore inaspettato durante l'aggiornamento del file nel pacchetto." msgid "Unexpected error occurred while updating pack metadata." msgstr "Errore inaspettato durante l'aggiornamento dei metadati del pacchetto." -#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 #, elixir-format -msgid "User is not an admin or OAuth admin scope is not granted." +msgid "User is not an admin." msgstr "" -"L'utente non è un amministratore o non ha ricevuto questa autorizzazione " +"L'utente non è un amministratore." "OAuth." #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 diff --git a/priv/gettext/nl/LC_MESSAGES/errors.po b/priv/gettext/nl/LC_MESSAGES/errors.po index 3118f6b5d..cfcb05fe6 100644 --- a/priv/gettext/nl/LC_MESSAGES/errors.po +++ b/priv/gettext/nl/LC_MESSAGES/errors.po @@ -559,9 +559,9 @@ msgstr "" msgid "Unexpected error occurred while updating pack metadata." msgstr "" -#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 #, elixir-format -msgid "User is not an admin or OAuth admin scope is not granted." +msgid "User is not an admin." msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 diff --git a/priv/gettext/pl/LC_MESSAGES/errors.po b/priv/gettext/pl/LC_MESSAGES/errors.po index 7241d8a0a..653ea00a1 100644 --- a/priv/gettext/pl/LC_MESSAGES/errors.po +++ b/priv/gettext/pl/LC_MESSAGES/errors.po @@ -566,9 +566,9 @@ msgstr "Nieoczekiwany błąd podczas zmieniania pliku w paczce." msgid "Unexpected error occurred while updating pack metadata." msgstr "Nieoczekiwany błąd podczas zmieniania metadanych paczki." -#: lib/pleroma/plugs/user_is_admin_plug.ex:40 +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 #, elixir-format -msgid "User is not an admin or OAuth admin scope is not granted." +msgid "User is not an admin." msgstr "" #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 diff --git a/test/plugs/admin_secret_authentication_plug_test.exs b/test/plugs/admin_secret_authentication_plug_test.exs index 100016c62..b541a7208 100644 --- a/test/plugs/admin_secret_authentication_plug_test.exs +++ b/test/plugs/admin_secret_authentication_plug_test.exs @@ -7,6 +7,8 @@ defmodule Pleroma.Plugs.AdminSecretAuthenticationPlugTest do import Pleroma.Factory alias Pleroma.Plugs.AdminSecretAuthenticationPlug + alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.PlugHelper test "does nothing if a user is assigned", %{conn: conn} do user = insert(:user) @@ -39,6 +41,7 @@ test "with `admin_token` query parameter", %{conn: conn} do |> AdminSecretAuthenticationPlug.call(%{}) assert conn.assigns[:user].is_admin + assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end test "with `x-admin-token` HTTP header", %{conn: conn} do @@ -57,6 +60,7 @@ test "with `x-admin-token` HTTP header", %{conn: conn} do |> AdminSecretAuthenticationPlug.call(%{}) assert conn.assigns[:user].is_admin + assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) end end end diff --git a/test/plugs/user_is_admin_plug_test.exs b/test/plugs/user_is_admin_plug_test.exs index fd6a50e53..8bc00e444 100644 --- a/test/plugs/user_is_admin_plug_test.exs +++ b/test/plugs/user_is_admin_plug_test.exs @@ -8,112 +8,30 @@ defmodule Pleroma.Plugs.UserIsAdminPlugTest do alias Pleroma.Plugs.UserIsAdminPlug import Pleroma.Factory - describe "unless [:auth, :enforce_oauth_admin_scope_usage]," do - setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + test "accepts a user that is an admin" do + user = insert(:user, is_admin: true) - test "accepts a user that is an admin" do - user = insert(:user, is_admin: true) + conn = assign(build_conn(), :user, user) - conn = assign(build_conn(), :user, user) + ret_conn = UserIsAdminPlug.call(conn, %{}) - ret_conn = UserIsAdminPlug.call(conn, %{}) - - assert conn == ret_conn - end - - test "denies a user that isn't an admin" do - user = insert(:user) - - conn = - build_conn() - |> assign(:user, user) - |> UserIsAdminPlug.call(%{}) - - assert conn.status == 403 - end - - test "denies when a user isn't set" do - conn = UserIsAdminPlug.call(build_conn(), %{}) - - assert conn.status == 403 - end + assert conn == ret_conn end - describe "with [:auth, :enforce_oauth_admin_scope_usage]," do - setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) - - setup do - admin_user = insert(:user, is_admin: true) - non_admin_user = insert(:user, is_admin: false) - blank_user = nil - - {:ok, %{users: [admin_user, non_admin_user, blank_user]}} - end - - test "if token has any of admin scopes, accepts a user that is an admin", %{conn: conn} do - user = insert(:user, is_admin: true) - token = insert(:oauth_token, user: user, scopes: ["admin:something"]) - - conn = - conn - |> assign(:user, user) - |> assign(:token, token) + test "denies a user that isn't an admin" do + user = insert(:user) - ret_conn = UserIsAdminPlug.call(conn, %{}) + conn = + build_conn() + |> assign(:user, user) + |> UserIsAdminPlug.call(%{}) - assert conn == ret_conn - end - - test "if token has any of admin scopes, denies a user that isn't an admin", %{conn: conn} do - user = insert(:user, is_admin: false) - token = insert(:oauth_token, user: user, scopes: ["admin:something"]) - - conn = - conn - |> assign(:user, user) - |> assign(:token, token) - |> UserIsAdminPlug.call(%{}) - - assert conn.status == 403 - end - - test "if token has any of admin scopes, denies when a user isn't set", %{conn: conn} do - token = insert(:oauth_token, scopes: ["admin:something"]) - - conn = - conn - |> assign(:user, nil) - |> assign(:token, token) - |> UserIsAdminPlug.call(%{}) - - assert conn.status == 403 - end - - test "if token lacks admin scopes, denies users regardless of is_admin flag", - %{users: users} do - for user <- users do - token = insert(:oauth_token, user: user) - - conn = - build_conn() - |> assign(:user, user) - |> assign(:token, token) - |> UserIsAdminPlug.call(%{}) - - assert conn.status == 403 - end - end + assert conn.status == 403 + end - test "if token is missing, denies users regardless of is_admin flag", %{users: users} do - for user <- users do - conn = - build_conn() - |> assign(:user, user) - |> assign(:token, nil) - |> UserIsAdminPlug.call(%{}) + test "denies when a user isn't set" do + conn = UserIsAdminPlug.call(build_conn(), %{}) - assert conn.status == 403 - end - end + assert conn.status == 403 end end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index c2433f23c..da91cd552 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -41,6 +41,16 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do {:ok, %{admin: admin, token: token, conn: conn}} end + test "with valid `admin_token` query parameter, skips OAuth scopes check" do + clear_config([:admin_token], "password123") + + user = insert(:user) + + conn = get(build_conn(), "/api/pleroma/admin/users/#{user.nickname}?admin_token=password123") + + assert json_response(conn, 200) + end + describe "with [:auth, :enforce_oauth_admin_scope_usage]," do setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], true) diff --git a/test/web/admin_api/controllers/report_controller_test.exs b/test/web/admin_api/controllers/report_controller_test.exs index 940bce340..f30dc8956 100644 --- a/test/web/admin_api/controllers/report_controller_test.exs +++ b/test/web/admin_api/controllers/report_controller_test.exs @@ -297,7 +297,7 @@ test "returns 403 when requested by a non-admin" do |> get("/api/pleroma/admin/reports") assert json_response(conn, :forbidden) == - %{"error" => "User is not an admin or OAuth admin scope is not granted."} + %{"error" => "User is not an admin."} end test "returns 403 when requested by anonymous" do -- cgit v1.2.3 From 9b225db7d86289fb9d9c51f62e6ec29f6c07f60d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 14 Jul 2020 11:58:41 +0300 Subject: [#1940] Applied rate limit for requests with bad `admin_token`. Added doc warnings on `admin_token` setting. --- config/description.exs | 6 ++++-- docs/configuration/cheatsheet.md | 2 ++ lib/pleroma/plugs/admin_secret_authentication_plug.ex | 17 +++++++++++++---- test/plugs/admin_secret_authentication_plug_test.exs | 9 +++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/config/description.exs b/config/description.exs index 84dcdb87e..8ec4b712f 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2008,13 +2008,15 @@ label: "Pleroma Admin Token", type: :group, description: - "Allows to set a token that can be used to authenticate with the admin api without using an actual user by giving it as the `admin_token` parameter", + "Allows to set a token that can be used to authenticate with the admin api without using an actual user by giving it as the `admin_token` parameter (risky; use HTTP Basic Auth or OAuth-based authentication if possible)", children: [ %{ key: :admin_token, type: :string, description: "Admin token", - suggestions: ["We recommend a secure random string or UUID"] + suggestions: [ + "We recommend NOT setting the value do to increased security risk; if set, use a secure random long string or UUID (and change it as often as possible)" + ] } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f796330f1..24b162ce7 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -815,6 +815,8 @@ or curl -H "X-Admin-Token: somerandomtoken" "http://localhost:4000/api/pleroma/admin/users/invites" ``` +Warning: it's discouraged to use this feature because of the associated security risk: static / rarely changed instance-wide token is much weaker compared to email-password pair of a real admin user; consider using HTTP Basic Auth or OAuth-based authentication instead. + ### :auth * `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator. diff --git a/lib/pleroma/plugs/admin_secret_authentication_plug.ex b/lib/pleroma/plugs/admin_secret_authentication_plug.ex index ff0328d4a..2e54df47a 100644 --- a/lib/pleroma/plugs/admin_secret_authentication_plug.ex +++ b/lib/pleroma/plugs/admin_secret_authentication_plug.ex @@ -5,15 +5,19 @@ defmodule Pleroma.Plugs.AdminSecretAuthenticationPlug do import Plug.Conn - alias Pleroma.User alias Pleroma.Plugs.OAuthScopesPlug + alias Pleroma.Plugs.RateLimiter + alias Pleroma.User def init(options) do options end def secret_token do - Pleroma.Config.get(:admin_token) + case Pleroma.Config.get(:admin_token) do + blank when blank in [nil, ""] -> nil + token -> token + end end def call(%{assigns: %{user: %User{}}} = conn, _), do: conn @@ -30,7 +34,7 @@ def authenticate(%{params: %{"admin_token" => admin_token}} = conn) do if admin_token == secret_token() do assign_admin_user(conn) else - conn + handle_bad_token(conn) end end @@ -38,8 +42,9 @@ def authenticate(conn) do token = secret_token() case get_req_header(conn, "x-admin-token") do + blank when blank in [[], [""]] -> conn [^token] -> assign_admin_user(conn) - _ -> conn + _ -> handle_bad_token(conn) end end @@ -48,4 +53,8 @@ defp assign_admin_user(conn) do |> assign(:user, %User{is_admin: true}) |> OAuthScopesPlug.skip_plug() end + + defp handle_bad_token(conn) do + RateLimiter.call(conn, name: :authentication) + end end diff --git a/test/plugs/admin_secret_authentication_plug_test.exs b/test/plugs/admin_secret_authentication_plug_test.exs index b541a7208..89df03c4b 100644 --- a/test/plugs/admin_secret_authentication_plug_test.exs +++ b/test/plugs/admin_secret_authentication_plug_test.exs @@ -4,11 +4,14 @@ defmodule Pleroma.Plugs.AdminSecretAuthenticationPlugTest do use Pleroma.Web.ConnCase, async: true + + import Mock import Pleroma.Factory alias Pleroma.Plugs.AdminSecretAuthenticationPlug alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.PlugHelper + alias Pleroma.Plugs.RateLimiter test "does nothing if a user is assigned", %{conn: conn} do user = insert(:user) @@ -27,6 +30,10 @@ test "does nothing if a user is assigned", %{conn: conn} do describe "when secret set it assigns an admin user" do setup do: clear_config([:admin_token]) + setup_with_mocks([{RateLimiter, [:passthrough], []}]) do + :ok + end + test "with `admin_token` query parameter", %{conn: conn} do Pleroma.Config.put(:admin_token, "password123") @@ -35,6 +42,7 @@ test "with `admin_token` query parameter", %{conn: conn} do |> AdminSecretAuthenticationPlug.call(%{}) refute conn.assigns[:user] + assert called(RateLimiter.call(conn, name: :authentication)) conn = %{conn | params: %{"admin_token" => "password123"}} @@ -53,6 +61,7 @@ test "with `x-admin-token` HTTP header", %{conn: conn} do |> AdminSecretAuthenticationPlug.call(%{}) refute conn.assigns[:user] + assert called(RateLimiter.call(conn, name: :authentication)) conn = conn -- cgit v1.2.3 From 858d9fc7e8e722604676c90cf2707f0209f935ec Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 13 Jul 2020 15:47:13 +0200 Subject: MRF Policies: Return a {:reject, reason} instead of {:reject, nil} --- .../web/activity_pub/mrf/anti_followbot_policy.ex | 2 +- .../web/activity_pub/mrf/anti_link_spam_policy.ex | 7 +++---- lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex | 4 ++-- lib/pleroma/web/activity_pub/mrf/keyword_policy.ex | 7 ++++--- lib/pleroma/web/activity_pub/mrf/mention_policy.ex | 5 +++-- lib/pleroma/web/activity_pub/mrf/object_age_policy.ex | 8 +++----- lib/pleroma/web/activity_pub/mrf/reject_non_public.ex | 2 +- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 16 ++++++++++------ lib/pleroma/web/activity_pub/mrf/tag_policy.ex | 7 ++++--- .../web/activity_pub/mrf/user_allow_list_policy.ex | 2 +- lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex | 18 +++++++++++------- .../activity_pub/mrf/anti_followbot_policy_test.exs | 4 ++-- test/web/activity_pub/mrf/hellthread_policy_test.exs | 3 ++- test/web/activity_pub/mrf/keyword_policy_test.exs | 12 ++++++++---- test/web/activity_pub/mrf/mention_policy_test.exs | 6 ++++-- test/web/activity_pub/mrf/reject_non_public_test.exs | 4 ++-- test/web/activity_pub/mrf/simple_policy_test.exs | 16 ++++++++-------- test/web/activity_pub/mrf/tag_policy_test.exs | 6 +++--- .../activity_pub/mrf/user_allowlist_policy_test.exs | 2 +- test/web/activity_pub/mrf/vocabulary_policy_test.exs | 8 ++++---- 20 files changed, 77 insertions(+), 62 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 0270b96ae..b96388489 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -60,7 +60,7 @@ def filter(%{"type" => "Follow", "actor" => actor_id} = message) do if score < 0.8 do {:ok, message} else - {:reject, nil} + {:reject, "[AntiFollowbotPolicy] Scored #{actor_id} as #{score}"} end end diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index a7e187b5e..b22464111 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -39,14 +39,13 @@ def filter(%{"type" => "Create", "actor" => actor, "object" => object} = message {:ok, message} {:old_user, false} -> - {:reject, nil} + {:reject, "[AntiLinkSpamPolicy] User has no posts nor followers"} {:error, _} -> - {:reject, nil} + {:reject, "[AntiLinkSpamPolicy] Failed to get or fetch user by ap_id"} e -> - Logger.warn("[MRF anti-link-spam] WTF: unhandled error #{inspect(e)}") - {:reject, nil} + {:reject, "[AntiLinkSpamPolicy] Unhandled error #{inspect(e)}"} end end diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index f6b2c4415..9ba07b4e3 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -43,7 +43,7 @@ defp delist_message(message, _threshold), do: {:ok, message} defp reject_message(message, threshold) when threshold > 0 do with {_, recipients} <- get_recipient_count(message) do if recipients > threshold do - {:reject, nil} + {:reject, "[HellthreadPolicy] #{recipients} recipients is over the limit of #{threshold}"} else {:ok, message} end @@ -87,7 +87,7 @@ def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = message {:ok, message} <- delist_message(message, delist_threshold) do {:ok, message} else - _e -> {:reject, nil} + e -> e end end diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index 88b0d2b39..15e09dcf0 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -24,7 +24,7 @@ defp check_reject(%{"object" => %{"content" => content, "summary" => summary}} = if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern -> string_matches?(content, pattern) or string_matches?(summary, pattern) end) do - {:reject, nil} + {:reject, "[KeywordPolicy] Matches with rejected keyword"} else {:ok, message} end @@ -89,8 +89,9 @@ def filter(%{"type" => "Create", "object" => %{"content" => _content}} = message {:ok, message} <- check_replace(message) do {:ok, message} else - _e -> - {:reject, nil} + {:reject, nil} -> {:reject, "[KeywordPolicy] "} + {:reject, _} = e -> e + _e -> {:reject, "[KeywordPolicy] "} end end diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex index 06f003921..7910ca131 100644 --- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -12,8 +12,9 @@ def filter(%{"type" => "Create"} = message) do reject_actors = Pleroma.Config.get([:mrf_mention, :actors], []) recipients = (message["to"] || []) ++ (message["cc"] || []) - if Enum.any?(recipients, fn recipient -> Enum.member?(reject_actors, recipient) end) do - {:reject, nil} + if rejected_mention = + Enum.find(recipients, fn recipient -> Enum.member?(reject_actors, recipient) end) do + {:reject, "[MentionPolicy] Rejected for mention of #{rejected_mention}"} else {:ok, message} end diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index a62914135..5f111c72f 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -28,7 +28,7 @@ defp check_date(%{"object" => %{"published" => published}} = message) do defp check_reject(message, actions) do if :reject in actions do - {:reject, nil} + {:reject, "[ObjectAgePolicy]"} else {:ok, message} end @@ -47,9 +47,8 @@ defp check_delist(message, actions) do {:ok, message} else - # Unhandleable error: somebody is messing around, just drop the message. _e -> - {:reject, nil} + {:reject, "[ObjectAgePolicy] Unhandled error"} end else {:ok, message} @@ -69,9 +68,8 @@ defp check_strip_followers(message, actions) do {:ok, message} else - # Unhandleable error: somebody is messing around, just drop the message. _e -> - {:reject, nil} + {:reject, "[ObjectAgePolicy] Unhandled error"} end else {:ok, message} diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex index 4fd63106d..0b9ed2224 100644 --- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -38,7 +38,7 @@ def filter(%{"type" => "Create"} = object) do {:ok, object} true -> - {:reject, nil} + {:reject, "[RejectNonPublic] visibility: #{visibility}"} end end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 70a2ca053..b77b8c7b4 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -21,7 +21,7 @@ defp check_accept(%{host: actor_host} = _actor_info, object) do accepts == [] -> {:ok, object} actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} MRF.subdomain_match?(accepts, actor_host) -> {:ok, object} - true -> {:reject, nil} + true -> {:reject, "[SimplePolicy] host not in accept list"} end end @@ -31,7 +31,7 @@ defp check_reject(%{host: actor_host} = _actor_info, object) do |> MRF.subdomains_regex() if MRF.subdomain_match?(rejects, actor_host) do - {:reject, nil} + {:reject, "[SimplePolicy] host in reject list"} else {:ok, object} end @@ -114,7 +114,7 @@ defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} |> MRF.subdomains_regex() if MRF.subdomain_match?(report_removal, actor_host) do - {:reject, nil} + {:reject, "[SimplePolicy] host in report_removal list"} else {:ok, object} end @@ -159,7 +159,7 @@ def filter(%{"type" => "Delete", "actor" => actor} = object) do |> MRF.subdomains_regex() if MRF.subdomain_match?(reject_deletes, actor_host) do - {:reject, nil} + {:reject, "[SimplePolicy] host in reject_deletes list"} else {:ok, object} end @@ -177,7 +177,9 @@ def filter(%{"actor" => actor} = object) do {:ok, object} <- check_report_removal(actor_info, object) do {:ok, object} else - _e -> {:reject, nil} + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} end end @@ -191,7 +193,9 @@ def filter(%{"id" => actor, "type" => obj_type} = object) {:ok, object} <- check_banner_removal(actor_info, object) do {:ok, object} else - _e -> {:reject, nil} + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} end end diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex index c310462cb..febabda08 100644 --- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -134,12 +134,13 @@ defp process_tag( if user.local == true do {:ok, message} else - {:reject, nil} + {:reject, + "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-remote-subscription"} end end - defp process_tag("mrf_tag:disable-any-subscription", %{"type" => "Follow"}), - do: {:reject, nil} + defp process_tag("mrf_tag:disable-any-subscription", %{"type" => "Follow", "actor" => actor}), + do: {:reject, "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-any-subscription"} defp process_tag(_, message), do: {:ok, message} diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index 651aed70f..1a28f2ba2 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -14,7 +14,7 @@ defp filter_by_list(%{"actor" => actor} = object, allow_list) do if actor in allow_list do {:ok, object} else - {:reject, nil} + {:reject, "[UserAllowListPolicy] #{actor} not in the list"} end end diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex index 6167a74e2..a6c545570 100644 --- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -11,22 +11,26 @@ def filter(%{"type" => "Undo", "object" => child_message} = message) do with {:ok, _} <- filter(child_message) do {:ok, message} else - {:reject, nil} -> - {:reject, nil} + {:reject, _} = e -> e end end def filter(%{"type" => message_type} = message) do with accepted_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :accept]), rejected_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :reject]), - true <- - Enum.empty?(accepted_vocabulary) || Enum.member?(accepted_vocabulary, message_type), - false <- - length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, message_type), + {_, true} <- + {:accepted, + Enum.empty?(accepted_vocabulary) || Enum.member?(accepted_vocabulary, message_type)}, + {_, false} <- + {:rejected, + length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, message_type)}, {:ok, _} <- filter(message["object"]) do {:ok, message} else - _ -> {:reject, nil} + {:reject, _} = e -> e + {:accepted, _} -> {:reject, "[VocabularyPolicy] #{message_type} not in accept list"} + {:rejected, _} -> {:reject, "[VocabularyPolicy] #{message_type} in reject list"} + _ -> {:reject, "[VocabularyPolicy]"} end end 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 fca0de7c6..3c795f5ac 100644 --- a/test/web/activity_pub/mrf/anti_followbot_policy_test.exs +++ b/test/web/activity_pub/mrf/anti_followbot_policy_test.exs @@ -21,7 +21,7 @@ test "matches followbots by nickname" do "id" => "https://example.com/activities/1234" } - {:reject, nil} = AntiFollowbotPolicy.filter(message) + assert {:reject, "[AntiFollowbotPolicy]" <> _} = AntiFollowbotPolicy.filter(message) end test "matches followbots by display name" do @@ -36,7 +36,7 @@ test "matches followbots by display name" do "id" => "https://example.com/activities/1234" } - {:reject, nil} = AntiFollowbotPolicy.filter(message) + assert {:reject, "[AntiFollowbotPolicy]" <> _} = AntiFollowbotPolicy.filter(message) end end diff --git a/test/web/activity_pub/mrf/hellthread_policy_test.exs b/test/web/activity_pub/mrf/hellthread_policy_test.exs index 6e9daa7f9..26f5bcdaa 100644 --- a/test/web/activity_pub/mrf/hellthread_policy_test.exs +++ b/test/web/activity_pub/mrf/hellthread_policy_test.exs @@ -50,7 +50,8 @@ test "rejects the message if the recipient count is above reject_threshold", %{ } do Pleroma.Config.put([:mrf_hellthread], %{delist_threshold: 0, reject_threshold: 2}) - {:reject, nil} = filter(message) + assert {:reject, "[HellthreadPolicy] 3 recipients is over the limit of 2"} == + filter(message) end test "does not reject the message if the recipient count is below reject_threshold", %{ diff --git a/test/web/activity_pub/mrf/keyword_policy_test.exs b/test/web/activity_pub/mrf/keyword_policy_test.exs index fd1f7aec8..b3d0f3d90 100644 --- a/test/web/activity_pub/mrf/keyword_policy_test.exs +++ b/test/web/activity_pub/mrf/keyword_policy_test.exs @@ -25,7 +25,8 @@ test "rejects if string matches in content" do } } - assert {:reject, nil} == KeywordPolicy.filter(message) + assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} = + KeywordPolicy.filter(message) end test "rejects if string matches in summary" do @@ -39,7 +40,8 @@ test "rejects if string matches in summary" do } } - assert {:reject, nil} == KeywordPolicy.filter(message) + assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} = + KeywordPolicy.filter(message) end test "rejects if regex matches in content" do @@ -55,7 +57,8 @@ test "rejects if regex matches in content" do } } - {:reject, nil} == KeywordPolicy.filter(message) + {:reject, "[KeywordPolicy] Matches with rejected keyword"} == + KeywordPolicy.filter(message) end) end @@ -72,7 +75,8 @@ test "rejects if regex matches in summary" do } } - {:reject, nil} == KeywordPolicy.filter(message) + {:reject, "[KeywordPolicy] Matches with rejected keyword"} == + KeywordPolicy.filter(message) end) end end diff --git a/test/web/activity_pub/mrf/mention_policy_test.exs b/test/web/activity_pub/mrf/mention_policy_test.exs index aa003bef5..220309cc9 100644 --- a/test/web/activity_pub/mrf/mention_policy_test.exs +++ b/test/web/activity_pub/mrf/mention_policy_test.exs @@ -76,7 +76,8 @@ test "to" do "to" => ["https://example.com/blocked"] } - assert MentionPolicy.filter(message) == {:reject, nil} + assert MentionPolicy.filter(message) == + {:reject, "[MentionPolicy] Rejected for mention of https://example.com/blocked"} end test "cc" do @@ -88,7 +89,8 @@ test "cc" do "cc" => ["https://example.com/blocked"] } - assert MentionPolicy.filter(message) == {:reject, nil} + assert MentionPolicy.filter(message) == + {:reject, "[MentionPolicy] Rejected for mention of https://example.com/blocked"} end end end 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 f36299b86..58b46b9a2 100644 --- a/test/web/activity_pub/mrf/reject_non_public_test.exs +++ b/test/web/activity_pub/mrf/reject_non_public_test.exs @@ -64,7 +64,7 @@ test "it's rejected when addrer of message in the follower addresses of user and } Pleroma.Config.put([:mrf_rejectnonpublic, :allow_followersonly], false) - assert {:reject, nil} = RejectNonPublic.filter(message) + assert {:reject, _} = RejectNonPublic.filter(message) end end @@ -94,7 +94,7 @@ test "it's reject when direct messages aren't allow" do } Pleroma.Config.put([:mrf_rejectnonpublic, :allow_direct], false) - assert {:reject, nil} = RejectNonPublic.filter(message) + assert {:reject, _} = RejectNonPublic.filter(message) end end end diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index b7b9bc6a2..e842d8d8d 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -124,7 +124,7 @@ test "has a matching host" do report_message = build_report_message() local_message = build_local_message() - assert SimplePolicy.filter(report_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(report_message) assert SimplePolicy.filter(local_message) == {:ok, local_message} end @@ -133,7 +133,7 @@ test "match with wildcard domain" do report_message = build_report_message() local_message = build_local_message() - assert SimplePolicy.filter(report_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(report_message) assert SimplePolicy.filter(local_message) == {:ok, local_message} end end @@ -241,7 +241,7 @@ test "activity has a matching host" do remote_message = build_remote_message() - assert SimplePolicy.filter(remote_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(remote_message) end test "activity matches with wildcard domain" do @@ -249,7 +249,7 @@ test "activity matches with wildcard domain" do remote_message = build_remote_message() - assert SimplePolicy.filter(remote_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(remote_message) end test "actor has a matching host" do @@ -257,7 +257,7 @@ test "actor has a matching host" do remote_user = build_remote_user() - assert SimplePolicy.filter(remote_user) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(remote_user) end end @@ -279,7 +279,7 @@ test "is not empty but activity doesn't have a matching host" do remote_message = build_remote_message() assert SimplePolicy.filter(local_message) == {:ok, local_message} - assert SimplePolicy.filter(remote_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(remote_message) end test "activity has a matching host" do @@ -429,7 +429,7 @@ test "it accepts deletions even from non-whitelisted servers" do test "it rejects the deletion" do deletion_message = build_remote_deletion_message() - assert SimplePolicy.filter(deletion_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(deletion_message) end end @@ -439,7 +439,7 @@ test "it rejects the deletion" do test "it rejects the deletion" do deletion_message = build_remote_deletion_message() - assert SimplePolicy.filter(deletion_message) == {:reject, nil} + assert {:reject, _} = SimplePolicy.filter(deletion_message) end end diff --git a/test/web/activity_pub/mrf/tag_policy_test.exs b/test/web/activity_pub/mrf/tag_policy_test.exs index e7793641a..6ff71d640 100644 --- a/test/web/activity_pub/mrf/tag_policy_test.exs +++ b/test/web/activity_pub/mrf/tag_policy_test.exs @@ -12,8 +12,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.TagPolicyTest do describe "mrf_tag:disable-any-subscription" do test "rejects message" do actor = insert(:user, tags: ["mrf_tag:disable-any-subscription"]) - message = %{"object" => actor.ap_id, "type" => "Follow"} - assert {:reject, nil} = TagPolicy.filter(message) + message = %{"object" => actor.ap_id, "type" => "Follow", "actor" => actor.ap_id} + assert {:reject, _} = TagPolicy.filter(message) end end @@ -22,7 +22,7 @@ test "rejects non-local follow requests" do actor = insert(:user, tags: ["mrf_tag:disable-remote-subscription"]) follower = insert(:user, tags: ["mrf_tag:disable-remote-subscription"], local: false) message = %{"object" => actor.ap_id, "type" => "Follow", "actor" => follower.ap_id} - assert {:reject, nil} = TagPolicy.filter(message) + assert {:reject, _} = TagPolicy.filter(message) end test "allows non-local follow requests" do diff --git a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs index ba1b69658..8e1ad5bc8 100644 --- a/test/web/activity_pub/mrf/user_allowlist_policy_test.exs +++ b/test/web/activity_pub/mrf/user_allowlist_policy_test.exs @@ -26,6 +26,6 @@ test "rejected if allow list isn't empty and user not in allow list" do actor = insert(:user) Pleroma.Config.put([:mrf_user_allowlist], %{"localhost" => ["test-ap-id"]}) message = %{"actor" => actor.ap_id} - assert UserAllowListPolicy.filter(message) == {:reject, nil} + assert {:reject, _} = UserAllowListPolicy.filter(message) end end diff --git a/test/web/activity_pub/mrf/vocabulary_policy_test.exs b/test/web/activity_pub/mrf/vocabulary_policy_test.exs index 69f22bb77..2bceb67ee 100644 --- a/test/web/activity_pub/mrf/vocabulary_policy_test.exs +++ b/test/web/activity_pub/mrf/vocabulary_policy_test.exs @@ -46,7 +46,7 @@ test "it does not accept disallowed child objects" do } } - {:reject, nil} = VocabularyPolicy.filter(message) + {:reject, _} = VocabularyPolicy.filter(message) end test "it does not accept disallowed parent types" do @@ -60,7 +60,7 @@ test "it does not accept disallowed parent types" do } } - {:reject, nil} = VocabularyPolicy.filter(message) + {:reject, _} = VocabularyPolicy.filter(message) end end @@ -75,7 +75,7 @@ test "it rejects based on parent activity type" do "object" => "whatever" } - {:reject, nil} = VocabularyPolicy.filter(message) + {:reject, _} = VocabularyPolicy.filter(message) end test "it rejects based on child object type" do @@ -89,7 +89,7 @@ test "it rejects based on child object type" do } } - {:reject, nil} = VocabularyPolicy.filter(message) + {:reject, _} = VocabularyPolicy.filter(message) end test "it passes through objects that aren't disallowed" do -- cgit v1.2.3 From 8d56fb6d223995de3f753eeef9475583e2b1e6ad Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 14 Jul 2020 12:00:53 +0300 Subject: Migrate in-db config after updating to Oban 2.0 --- docs/configuration/cheatsheet.md | 3 +-- .../20200714081657_oban_2_0_config_changes.exs | 27 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20200714081657_oban_2_0_config_changes.exs diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f796330f1..7b1fd92f3 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -629,8 +629,7 @@ Email notifications settings. Configuration options described in [Oban readme](https://github.com/sorentwo/oban#usage): * `repo` - app's Ecto repo (`Pleroma.Repo`) -* `verbose` - logs verbosity -* `prune` - non-retryable jobs [pruning settings](https://github.com/sorentwo/oban#pruning) (`:disabled` / `{:maxlen, value}` / `{:maxage, value}`) +* `log` - logs verbosity * `queues` - job queues (see below) * `crontab` - periodic jobs, see [`Oban.Cron`](#obancron) diff --git a/priv/repo/migrations/20200714081657_oban_2_0_config_changes.exs b/priv/repo/migrations/20200714081657_oban_2_0_config_changes.exs new file mode 100644 index 000000000..c54bb2511 --- /dev/null +++ b/priv/repo/migrations/20200714081657_oban_2_0_config_changes.exs @@ -0,0 +1,27 @@ +defmodule Elixir.Pleroma.Repo.Migrations.Oban20ConfigChanges do + use Ecto.Migration + import Ecto.Query + alias Pleroma.ConfigDB + alias Pleroma.Repo + + def change do + config_entry = + from(c in ConfigDB, where: c.group == ^":pleroma" and c.key == ^"Oban") + |> select([c], struct(c, [:value, :id])) + |> Repo.one() + + if config_entry do + %{value: value} = config_entry + + value = + case Keyword.fetch(value, :verbose) do + {:ok, log} -> Keyword.put_new(value, :log, log) + _ -> value + end + |> Keyword.drop([:verbose, :prune]) + + Ecto.Changeset.change(config_entry, %{value: value}) + |> Repo.update() + end + end +end -- cgit v1.2.3 From e6ccc2556568f2180c3ce1945bdc7a0cba97e924 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 14 Jul 2020 11:41:30 +0300 Subject: Fix in-db configuration in dev environment Previously, in-db configuration only worked when `warnings_as_errors` was disabled because re-compiling scrubbers on application restart created a warning about module conflicts. This patch fixes that by enabling `ignore_module_conflict` option of the compiler at runtime, and enables `warnings_as_errors` in prod since there is no reason to keep it disabled anymore. --- lib/pleroma/application.ex | 4 ++++ mix.exs | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index b68a373a4..3282c6882 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -35,6 +35,10 @@ def user_agent do # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do + # Scrubbers are compiled at runtime and therefore will cause a conflict + # every time the application is restarted, so we disable module + # conflicts at runtime + Code.compiler_options(ignore_module_conflict: true) Config.Holder.save_default() Pleroma.HTML.compile_scrubbers() Config.DeprecationWarnings.warn() diff --git a/mix.exs b/mix.exs index d7992ee37..741f917e6 100644 --- a/mix.exs +++ b/mix.exs @@ -90,8 +90,6 @@ defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp warnings_as_errors(:prod), do: false - # Uncomment this if you need testing configurable_from_database logic - # defp warnings_as_errors(:dev), do: false defp warnings_as_errors(_), do: true # Specifies OAuth dependencies. -- cgit v1.2.3 From ce314e6fe236c7a41535dd8a9a0f097c74c6f1ce Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 14 Jul 2020 11:24:58 -0500 Subject: Clarify description and suggestion --- config/description.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 8ec4b712f..2b41e7dac 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2008,14 +2008,14 @@ label: "Pleroma Admin Token", type: :group, description: - "Allows to set a token that can be used to authenticate with the admin api without using an actual user by giving it as the `admin_token` parameter (risky; use HTTP Basic Auth or OAuth-based authentication if possible)", + "Allows setting a token that can be used to authenticate requests with admin privileges without a normal user account token. Append the `admin_token` parameter to requests to utilize it. (Please reconsider using HTTP Basic Auth or OAuth-based authentication if possible)", children: [ %{ key: :admin_token, type: :string, description: "Admin token", suggestions: [ - "We recommend NOT setting the value do to increased security risk; if set, use a secure random long string or UUID (and change it as often as possible)" + "Please use a high entropy string or UUID" ] } ] -- cgit v1.2.3 From 124b4709dcf12a417f5164e53ef3ba67e538d4c7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Tue, 14 Jul 2020 19:31:05 +0300 Subject: [#1940] Added `admin_token` param (as `admin_api_params/0`) to existing Admin API OpenAPI operations. --- lib/pleroma/web/api_spec/helpers.ex | 4 ++++ lib/pleroma/web/api_spec/operations/admin/config_operation.ex | 3 +++ lib/pleroma/web/api_spec/operations/admin/invite_operation.ex | 4 ++++ .../web/api_spec/operations/admin/media_proxy_cache_operation.ex | 3 +++ lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex | 6 ++++-- lib/pleroma/web/api_spec/operations/admin/relay_operation.ex | 3 +++ lib/pleroma/web/api_spec/operations/admin/report_operation.ex | 7 +++++-- lib/pleroma/web/api_spec/operations/admin/status_operation.ex | 7 ++++--- test/web/admin_api/controllers/config_controller_test.exs | 8 ++++++++ 9 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/api_spec/helpers.ex b/lib/pleroma/web/api_spec/helpers.ex index a258e8421..2a7f1a706 100644 --- a/lib/pleroma/web/api_spec/helpers.ex +++ b/lib/pleroma/web/api_spec/helpers.ex @@ -29,6 +29,10 @@ def request_body(description, schema_ref, opts \\ []) do } end + def admin_api_params do + [Operation.parameter(:admin_token, :query, :string, "Allows authorization via admin token.")] + end + def pagination_params do [ Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), diff --git a/lib/pleroma/web/api_spec/operations/admin/config_operation.ex b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex index 7b38a2ef4..3a8380797 100644 --- a/lib/pleroma/web/api_spec/operations/admin/config_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/config_operation.ex @@ -26,6 +26,7 @@ def show_operation do %Schema{type: :boolean, default: false}, "Get only saved in database settings" ) + | admin_api_params() ], security: [%{"oAuth" => ["read"]}], responses: %{ @@ -41,6 +42,7 @@ def update_operation do summary: "Update config settings", operationId: "AdminAPI.ConfigController.update", security: [%{"oAuth" => ["write"]}], + parameters: admin_api_params(), requestBody: request_body("Parameters", %Schema{ type: :object, @@ -73,6 +75,7 @@ def descriptions_operation do summary: "Get JSON with config descriptions.", operationId: "AdminAPI.ConfigController.descriptions", security: [%{"oAuth" => ["read"]}], + parameters: admin_api_params(), responses: %{ 200 => Operation.response("Config Descriptions", "application/json", %Schema{ diff --git a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex index d3af9db49..801024d75 100644 --- a/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/invite_operation.ex @@ -20,6 +20,7 @@ def index_operation do summary: "Get a list of generated invites", operationId: "AdminAPI.InviteController.index", security: [%{"oAuth" => ["read:invites"]}], + parameters: admin_api_params(), responses: %{ 200 => Operation.response("Invites", "application/json", %Schema{ @@ -51,6 +52,7 @@ def create_operation do summary: "Create an account registration invite token", operationId: "AdminAPI.InviteController.create", security: [%{"oAuth" => ["write:invites"]}], + parameters: admin_api_params(), requestBody: request_body("Parameters", %Schema{ type: :object, @@ -71,6 +73,7 @@ def revoke_operation do summary: "Revoke invite by token", operationId: "AdminAPI.InviteController.revoke", security: [%{"oAuth" => ["write:invites"]}], + parameters: admin_api_params(), requestBody: request_body( "Parameters", @@ -97,6 +100,7 @@ def email_operation do summary: "Sends registration invite via email", operationId: "AdminAPI.InviteController.email", security: [%{"oAuth" => ["write:invites"]}], + parameters: admin_api_params(), requestBody: request_body( "Parameters", diff --git a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex index 0358cfbad..20d033f66 100644 --- a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex @@ -33,6 +33,7 @@ def index_operation do %Schema{type: :integer, default: 50}, "Number of statuses to return" ) + | admin_api_params() ], responses: %{ 200 => success_response() @@ -46,6 +47,7 @@ def delete_operation do summary: "Remove a banned MediaProxy URL from Cachex", operationId: "AdminAPI.MediaProxyCacheController.delete", security: [%{"oAuth" => ["write:media_proxy_caches"]}], + parameters: admin_api_params(), requestBody: request_body( "Parameters", @@ -71,6 +73,7 @@ def purge_operation do summary: "Purge and optionally ban a MediaProxy URL", operationId: "AdminAPI.MediaProxyCacheController.purge", security: [%{"oAuth" => ["write:media_proxy_caches"]}], + parameters: admin_api_params(), requestBody: request_body( "Parameters", diff --git a/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex b/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex index fbc9f80d7..a75f3e622 100644 --- a/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/oauth_app_operation.ex @@ -36,6 +36,7 @@ def index_operation do %Schema{type: :integer, default: 50}, "Number of apps to return" ) + | admin_api_params() ], responses: %{ 200 => @@ -72,6 +73,7 @@ def create_operation do summary: "Create OAuth App", operationId: "AdminAPI.OAuthAppController.create", requestBody: request_body("Parameters", create_request()), + parameters: admin_api_params(), security: [%{"oAuth" => ["write"]}], responses: %{ 200 => Operation.response("App", "application/json", oauth_app()), @@ -85,7 +87,7 @@ def update_operation do tags: ["Admin", "oAuth Apps"], summary: "Update OAuth App", operationId: "AdminAPI.OAuthAppController.update", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], security: [%{"oAuth" => ["write"]}], requestBody: request_body("Parameters", update_request()), responses: %{ @@ -103,7 +105,7 @@ def delete_operation do tags: ["Admin", "oAuth Apps"], summary: "Delete OAuth App", operationId: "AdminAPI.OAuthAppController.delete", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], security: [%{"oAuth" => ["write"]}], responses: %{ 204 => no_content_response(), diff --git a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex index 7672cb467..67ee5eee0 100644 --- a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex @@ -19,6 +19,7 @@ def index_operation do summary: "List Relays", operationId: "AdminAPI.RelayController.index", security: [%{"oAuth" => ["read"]}], + parameters: admin_api_params(), responses: %{ 200 => Operation.response("Response", "application/json", %Schema{ @@ -41,6 +42,7 @@ def follow_operation do summary: "Follow a Relay", operationId: "AdminAPI.RelayController.follow", security: [%{"oAuth" => ["write:follows"]}], + parameters: admin_api_params(), requestBody: request_body("Parameters", %Schema{ type: :object, @@ -64,6 +66,7 @@ def unfollow_operation do summary: "Unfollow a Relay", operationId: "AdminAPI.RelayController.unfollow", security: [%{"oAuth" => ["write:follows"]}], + parameters: admin_api_params(), requestBody: request_body("Parameters", %Schema{ type: :object, diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex index 15e78bfaf..3bb7ec49e 100644 --- a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -48,6 +48,7 @@ def index_operation do %Schema{type: :integer, default: 50}, "Number number of log entries per page" ) + | admin_api_params() ], responses: %{ 200 => @@ -71,7 +72,7 @@ def show_operation do tags: ["Admin", "Reports"], summary: "Get an individual report", operationId: "AdminAPI.ReportController.show", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], security: [%{"oAuth" => ["read:reports"]}], responses: %{ 200 => Operation.response("Report", "application/json", report()), @@ -86,6 +87,7 @@ def update_operation do summary: "Change the state of one or multiple reports", operationId: "AdminAPI.ReportController.update", security: [%{"oAuth" => ["write:reports"]}], + parameters: admin_api_params(), requestBody: request_body("Parameters", update_request(), required: true), responses: %{ 204 => no_content_response(), @@ -100,7 +102,7 @@ def notes_create_operation do tags: ["Admin", "Reports"], summary: "Create report note", operationId: "AdminAPI.ReportController.notes_create", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], requestBody: request_body("Parameters", %Schema{ type: :object, @@ -124,6 +126,7 @@ def notes_delete_operation do parameters: [ Operation.parameter(:report_id, :path, :string, "Report ID"), Operation.parameter(:id, :path, :string, "Note ID") + | admin_api_params() ], security: [%{"oAuth" => ["write:reports"]}], responses: %{ diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex index 745399b4b..c105838a4 100644 --- a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -55,6 +55,7 @@ def index_operation do %Schema{type: :integer, default: 50}, "Number of statuses to return" ) + | admin_api_params() ], responses: %{ 200 => @@ -71,7 +72,7 @@ def show_operation do tags: ["Admin", "Statuses"], summary: "Show Status", operationId: "AdminAPI.StatusController.show", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], security: [%{"oAuth" => ["read:statuses"]}], responses: %{ 200 => Operation.response("Status", "application/json", status()), @@ -85,7 +86,7 @@ def update_operation do tags: ["Admin", "Statuses"], summary: "Change the scope of an individual reported status", operationId: "AdminAPI.StatusController.update", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], security: [%{"oAuth" => ["write:statuses"]}], requestBody: request_body("Parameters", update_request(), required: true), responses: %{ @@ -100,7 +101,7 @@ def delete_operation do tags: ["Admin", "Statuses"], summary: "Delete an individual reported status", operationId: "AdminAPI.StatusController.delete", - parameters: [id_param()], + parameters: [id_param() | admin_api_params()], security: [%{"oAuth" => ["write:statuses"]}], responses: %{ 200 => empty_object_response(), diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs index 064ef9bc7..61bc9fd39 100644 --- a/test/web/admin_api/controllers/config_controller_test.exs +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -152,6 +152,14 @@ test "subkeys with full update right merge", %{conn: conn} do assert emoji_val[:groups] == [a: 1, b: 2] assert assets_val[:mascots] == [a: 1, b: 2] end + + test "with valid `admin_token` query parameter, skips OAuth scopes check" do + clear_config([:admin_token], "password123") + + build_conn() + |> get("/api/pleroma/admin/config?admin_token=password123") + |> json_response_and_validate_schema(200) + end end test "POST /api/pleroma/admin/config error", %{conn: conn} do -- cgit v1.2.3 From a1570ba6ad4c871df3783d06772b2eb8d2d6c4f1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 13:04:57 -0500 Subject: AdminAPI: Return `registration_reason` with users --- docs/API/admin_api.md | 3 +- lib/pleroma/web/admin_api/views/account_view.ex | 3 +- .../controllers/admin_api_controller_test.exs | 70 +++++++++++++++------- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index baf895d90..de4e36efa 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -46,7 +46,8 @@ Configuration options: "local": bool, "tags": array, "avatar": string, - "display_name": string + "display_name": string, + "registration_reason": string, }, ... ] diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index e1e929632..78062e520 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -77,7 +77,8 @@ def render("show.json", %{user: user}) do "roles" => User.roles(user), "tags" => user.tags || [], "confirmation_pending" => user.confirmation_pending, - "url" => user.uri || user.ap_id + "url" => user.uri || user.ap_id, + "registration_reason" => user.registration_reason } end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index c2433f23c..556e8d97a 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -338,7 +338,8 @@ test "Show", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } assert expected == json_response(conn, 200) @@ -601,7 +602,9 @@ test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do describe "GET /api/pleroma/admin/users" do test "renders users array for the first page", %{conn: conn, admin: admin} do - user = insert(:user, local: false, tags: ["foo", "bar"]) + user = + insert(:user, local: false, tags: ["foo", "bar"], registration_reason: "I'm a chill dude") + conn = get(conn, "/api/pleroma/admin/users?page=1") users = @@ -616,7 +619,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, - "url" => admin.ap_id + "url" => admin.ap_id, + "registration_reason" => nil }, %{ "deactivated" => user.deactivated, @@ -628,7 +632,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => "I'm a chill dude" } ] |> Enum.sort_by(& &1["nickname"]) @@ -701,7 +706,8 @@ test "regular search", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -727,7 +733,8 @@ test "search by domain", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -753,7 +760,8 @@ test "search by full nickname", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -779,7 +787,8 @@ test "search by display name", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -805,7 +814,8 @@ test "search by email", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -831,7 +841,8 @@ test "regular search with page size", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -852,7 +863,8 @@ test "regular search with page size", %{conn: conn} do "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), "confirmation_pending" => false, - "url" => user2.ap_id + "url" => user2.ap_id, + "registration_reason" => nil } ] } @@ -885,7 +897,8 @@ test "only local users" do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -911,7 +924,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil }, %{ "deactivated" => admin.deactivated, @@ -923,7 +937,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, - "url" => admin.ap_id + "url" => admin.ap_id, + "registration_reason" => nil }, %{ "deactivated" => false, @@ -935,7 +950,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), "confirmation_pending" => false, - "url" => old_admin.ap_id + "url" => old_admin.ap_id, + "registration_reason" => nil } ] |> Enum.sort_by(& &1["nickname"]) @@ -966,7 +982,8 @@ test "load only admins", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, - "url" => admin.ap_id + "url" => admin.ap_id, + "registration_reason" => nil }, %{ "deactivated" => false, @@ -978,7 +995,8 @@ test "load only admins", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), "confirmation_pending" => false, - "url" => second_admin.ap_id + "url" => second_admin.ap_id, + "registration_reason" => nil } ] |> Enum.sort_by(& &1["nickname"]) @@ -1011,7 +1029,8 @@ test "load only moderators", %{conn: conn} do "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), "confirmation_pending" => false, - "url" => moderator.ap_id + "url" => moderator.ap_id, + "registration_reason" => nil } ] } @@ -1037,7 +1056,8 @@ test "load users with tags list", %{conn: conn} do "avatar" => User.avatar_url(user1) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user1.name || user1.nickname), "confirmation_pending" => false, - "url" => user1.ap_id + "url" => user1.ap_id, + "registration_reason" => nil }, %{ "deactivated" => false, @@ -1049,7 +1069,8 @@ test "load users with tags list", %{conn: conn} do "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), "confirmation_pending" => false, - "url" => user2.ap_id + "url" => user2.ap_id, + "registration_reason" => nil } ] |> Enum.sort_by(& &1["nickname"]) @@ -1089,7 +1110,8 @@ test "it works with multiple filters" do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } ] } @@ -1114,7 +1136,8 @@ test "it omits relay user", %{admin: admin, conn: conn} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, - "url" => admin.ap_id + "url" => admin.ap_id, + "registration_reason" => nil } ] } @@ -1177,7 +1200,8 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, - "url" => user.ap_id + "url" => user.ap_id, + "registration_reason" => nil } log_entry = Repo.one(ModerationLog) -- cgit v1.2.3 From 37297a8482eedbb0a3adab2748b3e76401d87e4a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 14 Jul 2020 13:12:16 -0500 Subject: Improve error messages --- lib/pleroma/application_requirements.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index f0f34734e..d51160b82 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -31,10 +31,10 @@ def check_confirmation_accounts!(:ok) do if Pleroma.Config.get([:instance, :account_activation_required]) && not Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do Logger.error( - "To use confirmation an user account need to enable and setting mailer.\nIf you want to start Pleroma anyway, set\nconfig :pleroma, :instance, account_activation_required: false\nOtherwise setup and enable mailer." + "Account activation enabled, but no Mailer settings enabled.\nPlease set config :pleroma, :instance, account_activation_required: false\nOtherwise setup and enable Mailer." ) - {:error, "Confirmation account: Mailer is disabled"} + {:error, "Account activation enabled, but Mailer is disabled. Cannot send confirmation emails."} else :ok end -- cgit v1.2.3 From 777a7edc6b4bf8b9e0ff3b86bdb780f8f2ae2610 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 14 Jul 2020 13:15:37 -0500 Subject: Lint and fix test to match new log message --- lib/pleroma/application_requirements.ex | 3 ++- test/application_requirements_test.exs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index d51160b82..ee88c3346 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -34,7 +34,8 @@ def check_confirmation_accounts!(:ok) do "Account activation enabled, but no Mailer settings enabled.\nPlease set config :pleroma, :instance, account_activation_required: false\nOtherwise setup and enable Mailer." ) - {:error, "Account activation enabled, but Mailer is disabled. Cannot send confirmation emails."} + {:error, + "Account activation enabled, but Mailer is disabled. Cannot send confirmation emails."} else :ok end diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index 8c92be290..fc609d174 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -26,7 +26,7 @@ test "raises if account confirmation is required but mailer isn't enable" do Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) assert_raise Pleroma.ApplicationRequirements.VerifyError, - "Confirmation account: Mailer is disabled", + "Account activation enabled, but Mailer is disabled. Cannot send confirmation emails.", fn -> capture_log(&Pleroma.ApplicationRequirements.verify!/0) end -- cgit v1.2.3 From 1dd767b8c7b7565ad94ccb85324e97fa9885923e Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 14 Jul 2020 21:44:08 +0300 Subject: Include port in host for signatures --- lib/pleroma/web/activity_pub/publisher.ex | 20 +++++++++++++----- test/web/activity_pub/publisher_test.exs | 34 ++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index b70cbd043..d88f7f3ee 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -49,7 +49,8 @@ def is_representable?(%Activity{} = activity) do """ def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = params) do Logger.debug("Federating #{id} to #{inbox}") - %{host: host, path: path} = URI.parse(inbox) + + uri = URI.parse(inbox) digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) @@ -57,8 +58,8 @@ def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = pa signature = Pleroma.Signature.sign(actor, %{ - "(request-target)": "post #{path}", - host: host, + "(request-target)": "post #{uri.path}", + host: signature_host(uri), "content-length": byte_size(json), digest: digest, date: date @@ -76,8 +77,9 @@ def publish_one(%{inbox: inbox, json: json, actor: %User{} = actor, id: id} = pa {"digest", digest} ] ) do - if !Map.has_key?(params, :unreachable_since) || params[:unreachable_since], - do: Instances.set_reachable(inbox) + if not Map.has_key?(params, :unreachable_since) || params[:unreachable_since] do + Instances.set_reachable(inbox) + end result else @@ -96,6 +98,14 @@ def publish_one(%{actor_id: actor_id} = params) do |> publish_one() end + defp signature_host(%URI{port: port, scheme: scheme, host: host}) do + if port == URI.default_port(scheme) do + host + else + "#{host}:#{port}" + end + end + defp should_federate?(inbox, public) do if public do true diff --git a/test/web/activity_pub/publisher_test.exs b/test/web/activity_pub/publisher_test.exs index c2bc38d52..b9388b966 100644 --- a/test/web/activity_pub/publisher_test.exs +++ b/test/web/activity_pub/publisher_test.exs @@ -123,6 +123,39 @@ test "it returns inbox for messages involving single recipients in total" do end describe "publish_one/1" do + test "publish to url with with different ports" do + inbox80 = "http://42.site/users/nick1/inbox" + inbox42 = "http://42.site:42/users/nick1/inbox" + + mock(fn + %{method: :post, url: "http://42.site:42/users/nick1/inbox"} -> + {:ok, %Tesla.Env{status: 200, body: "port 42"}} + + %{method: :post, url: "http://42.site/users/nick1/inbox"} -> + {:ok, %Tesla.Env{status: 200, body: "port 80"}} + end) + + actor = insert(:user) + + assert {:ok, %{body: "port 42"}} = + Publisher.publish_one(%{ + inbox: inbox42, + json: "{}", + actor: actor, + id: 1, + unreachable_since: true + }) + + assert {:ok, %{body: "port 80"}} = + Publisher.publish_one(%{ + inbox: inbox80, + json: "{}", + actor: actor, + id: 1, + unreachable_since: true + }) + end + test_with_mock "calls `Instances.set_reachable` on successful federation if `unreachable_since` is not specified", Instances, [:passthrough], @@ -131,7 +164,6 @@ test "it returns inbox for messages involving single recipients in total" do inbox = "http://200.site/users/nick1/inbox" assert {:ok, _} = Publisher.publish_one(%{inbox: inbox, json: "{}", actor: actor, id: 1}) - assert called(Instances.set_reachable(inbox)) end -- cgit v1.2.3 From b750129da1434823746e3dbc237d0e04552fa753 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 13:47:05 -0500 Subject: AdminAPI: Return `approval_pending` with users --- docs/API/admin_api.md | 2 ++ lib/pleroma/web/admin_api/views/account_view.ex | 1 + .../controllers/admin_api_controller_test.exs | 42 ++++++++++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index de4e36efa..fdd9df6c7 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -47,6 +47,8 @@ Configuration options: "tags": array, "avatar": string, "display_name": string, + "confirmation_pending": bool, + "approval_pending": bool, "registration_reason": string, }, ... diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 78062e520..bdab04ad2 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -77,6 +77,7 @@ def render("show.json", %{user: user}) do "roles" => User.roles(user), "tags" => user.tags || [], "confirmation_pending" => user.confirmation_pending, + "approval_pending" => user.approval_pending, "url" => user.uri || user.ap_id, "registration_reason" => user.registration_reason } diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 556e8d97a..ccda5df3f 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -338,6 +338,7 @@ test "Show", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -602,8 +603,8 @@ test "/api/pleroma/admin/users/:nickname/password_reset", %{conn: conn} do describe "GET /api/pleroma/admin/users" do test "renders users array for the first page", %{conn: conn, admin: admin} do - user = - insert(:user, local: false, tags: ["foo", "bar"], registration_reason: "I'm a chill dude") + user = insert(:user, local: false, tags: ["foo", "bar"]) + user2 = insert(:user, approval_pending: true, registration_reason: "I'm a chill dude") conn = get(conn, "/api/pleroma/admin/users?page=1") @@ -619,6 +620,7 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => admin.ap_id, "registration_reason" => nil }, @@ -632,14 +634,29 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, + "registration_reason" => nil + }, + %{ + "deactivated" => user2.deactivated, + "id" => user2.id, + "nickname" => user2.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user2) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user2.name || user2.nickname), + "confirmation_pending" => false, + "approval_pending" => true, + "url" => user2.ap_id, "registration_reason" => "I'm a chill dude" } ] |> Enum.sort_by(& &1["nickname"]) assert json_response(conn, 200) == %{ - "count" => 2, + "count" => 3, "page_size" => 50, "users" => users } @@ -706,6 +723,7 @@ test "regular search", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -733,6 +751,7 @@ test "search by domain", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -760,6 +779,7 @@ test "search by full nickname", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -787,6 +807,7 @@ test "search by display name", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -814,6 +835,7 @@ test "search by email", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -841,6 +863,7 @@ test "regular search with page size", %{conn: conn} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -863,6 +886,7 @@ test "regular search with page size", %{conn: conn} do "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user2.ap_id, "registration_reason" => nil } @@ -897,6 +921,7 @@ test "only local users" do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -924,6 +949,7 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil }, @@ -937,6 +963,7 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => admin.ap_id, "registration_reason" => nil }, @@ -950,6 +977,7 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "avatar" => User.avatar_url(old_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(old_admin.name || old_admin.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => old_admin.ap_id, "registration_reason" => nil } @@ -982,6 +1010,7 @@ test "load only admins", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => admin.ap_id, "registration_reason" => nil }, @@ -995,6 +1024,7 @@ test "load only admins", %{conn: conn, admin: admin} do "avatar" => User.avatar_url(second_admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(second_admin.name || second_admin.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => second_admin.ap_id, "registration_reason" => nil } @@ -1029,6 +1059,7 @@ test "load only moderators", %{conn: conn} do "avatar" => User.avatar_url(moderator) |> MediaProxy.url(), "display_name" => HTML.strip_tags(moderator.name || moderator.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => moderator.ap_id, "registration_reason" => nil } @@ -1056,6 +1087,7 @@ test "load users with tags list", %{conn: conn} do "avatar" => User.avatar_url(user1) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user1.name || user1.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user1.ap_id, "registration_reason" => nil }, @@ -1069,6 +1101,7 @@ test "load users with tags list", %{conn: conn} do "avatar" => User.avatar_url(user2) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user2.name || user2.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user2.ap_id, "registration_reason" => nil } @@ -1110,6 +1143,7 @@ test "it works with multiple filters" do "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } @@ -1136,6 +1170,7 @@ test "it omits relay user", %{admin: admin, conn: conn} do "avatar" => User.avatar_url(admin) |> MediaProxy.url(), "display_name" => HTML.strip_tags(admin.name || admin.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => admin.ap_id, "registration_reason" => nil } @@ -1200,6 +1235,7 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "avatar" => User.avatar_url(user) |> MediaProxy.url(), "display_name" => HTML.strip_tags(user.name || user.nickname), "confirmation_pending" => false, + "approval_pending" => false, "url" => user.ap_id, "registration_reason" => nil } -- cgit v1.2.3 From 33f1b29b2c9cfe6f09c6b088b8b6f7bf14379b9b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 14:14:43 -0500 Subject: AdminAPI: Filter users by `need_approval` --- docs/API/admin_api.md | 1 + lib/pleroma/user/query.ex | 5 +++ .../admin_api/controllers/admin_api_controller.ex | 2 +- .../controllers/admin_api_controller_test.exs | 38 ++++++++++++++++++++++ test/web/admin_api/search_test.exs | 11 +++++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index fdd9df6c7..42071376e 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -19,6 +19,7 @@ Configuration options: - `local`: only local users - `external`: only external users - `active`: only active users + - `need_approval`: only unapproved users - `deactivated`: only deactivated users - `is_admin`: users with admin role - `is_moderator`: users with moderator role diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 66ffe9090..45553cb6c 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -42,6 +42,7 @@ defmodule Pleroma.User.Query do external: boolean(), active: boolean(), deactivated: boolean(), + need_approval: boolean(), is_admin: boolean(), is_moderator: boolean(), super_users: boolean(), @@ -146,6 +147,10 @@ defp compose_query({:deactivated, true}, query) do |> where([u], not is_nil(u.nickname)) end + defp compose_query({:need_approval, _}, query) do + where(query, [u], u.approval_pending) + end + defp compose_query({:followers, %User{id: id}}, query) do query |> where([u], u.id != ^id) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index e5f14269a..037a6f269 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -350,7 +350,7 @@ def list_users(conn, params) do end end - @filters ~w(local external active deactivated is_admin is_moderator) + @filters ~w(local external active deactivated need_approval is_admin is_moderator) @spec maybe_parse_filters(String.t()) :: %{required(String.t()) => true} | %{} defp maybe_parse_filters(filters) when is_nil(filters) or filters == "", do: %{} diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index ccda5df3f..9cc8b1879 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -991,6 +991,44 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do } end + test "only unapproved users", %{conn: conn} do + user = + insert(:user, + nickname: "sadboy", + approval_pending: true, + registration_reason: "Plz let me in!" + ) + + insert(:user, nickname: "happyboy", approval_pending: false) + + conn = get(conn, "/api/pleroma/admin/users?filters=need_approval") + + users = + [ + %{ + "deactivated" => user.deactivated, + "id" => user.id, + "nickname" => user.nickname, + "roles" => %{"admin" => false, "moderator" => false}, + "local" => true, + "tags" => [], + "avatar" => User.avatar_url(user) |> MediaProxy.url(), + "display_name" => HTML.strip_tags(user.name || user.nickname), + "confirmation_pending" => false, + "approval_pending" => true, + "url" => user.ap_id, + "registration_reason" => "Plz let me in!" + } + ] + |> Enum.sort_by(& &1["nickname"]) + + assert json_response(conn, 200) == %{ + "count" => 1, + "page_size" => 50, + "users" => users + } + end + test "load only admins", %{conn: conn, admin: admin} do second_admin = insert(:user, is_admin: true) insert(:user) diff --git a/test/web/admin_api/search_test.exs b/test/web/admin_api/search_test.exs index e0e3d4153..b974cedd5 100644 --- a/test/web/admin_api/search_test.exs +++ b/test/web/admin_api/search_test.exs @@ -166,5 +166,16 @@ test "it returns user by email" do assert total == 3 assert count == 1 end + + test "it returns unapproved user" do + unapproved = insert(:user, approval_pending: true) + insert(:user) + insert(:user) + + {:ok, _results, total} = Search.user() + {:ok, [^unapproved], count} = Search.user(%{need_approval: true}) + assert total == 3 + assert count == 1 + end end end -- cgit v1.2.3 From 20d24741af8ae755ce7f753680a55ca24ef7c1d4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 18:02:44 -0500 Subject: AdminAPI: Add `PATCH /api/pleroma/admin/users/approve` endpoint --- docs/API/admin_api.md | 18 ++++++++++++++++ lib/pleroma/moderation_log.ex | 11 ++++++++++ lib/pleroma/user.ex | 13 +++++++++++ .../admin_api/controllers/admin_api_controller.ex | 16 ++++++++++++++ lib/pleroma/web/router.ex | 1 + test/user_test.exs | 25 ++++++++++++++++++++++ .../controllers/admin_api_controller_test.exs | 20 +++++++++++++++++ 7 files changed, 104 insertions(+) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 42071376e..4b143e4ee 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -246,6 +246,24 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret } ``` +## `PATCH /api/pleroma/admin/users/approve` + +### Approve user + +- Params: + - `nicknames`: nicknames array +- Response: + +```json +{ + users: [ + { + // user object + } + ] +} +``` + ## `GET /api/pleroma/admin/users/:nickname_or_id` ### Retrive the details of a user diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 7aacd9d80..31c9afe2a 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -409,6 +409,17 @@ def get_log_entry_message(%ModerationLog{ "@#{actor_nickname} deactivated users: #{users_to_nicknames_string(users)}" end + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "approve", + "subject" => users + } + }) do + "@#{actor_nickname} approved users: #{users_to_nicknames_string(users)}" + end + @spec get_log_entry_message(ModerationLog) :: String.t() def get_log_entry_message(%ModerationLog{ data: %{ diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 51ccf6ffa..439c2c9b6 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1471,6 +1471,19 @@ def deactivate(%User{} = user, status) do end end + def approve(users) when is_list(users) do + Repo.transaction(fn -> + Enum.map(users, fn user -> + with {:ok, user} <- approve(user), do: user + end) + end) + end + + def approve(%User{} = user) do + change(user, approval_pending: false) + |> update_and_set_cache() + end + def update_notification_settings(%User{} = user, settings) do user |> cast(%{notification_settings: settings}, []) diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 037a6f269..53f71fcbf 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -44,6 +44,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do :user_toggle_activation, :user_activate, :user_deactivate, + :user_approve, :tag_users, :untag_users, :right_add, @@ -303,6 +304,21 @@ def user_deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nickname |> render("index.json", %{users: Keyword.values(updated_users)}) end + def user_approve(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + users = Enum.map(nicknames, &User.get_cached_by_nickname/1) + {:ok, updated_users} = User.approve(users) + + ModerationLog.insert_log(%{ + actor: admin, + subject: users, + action: "approve" + }) + + conn + |> put_view(AccountView) + |> render("index.json", %{users: updated_users}) + end + def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do with {:ok, _} <- User.tag(nicknames, tags) do ModerationLog.insert_log(%{ diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 386308362..c6433cc53 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -138,6 +138,7 @@ defmodule Pleroma.Web.Router do patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) patch("/users/activate", AdminAPIController, :user_activate) patch("/users/deactivate", AdminAPIController, :user_deactivate) + patch("/users/approve", AdminAPIController, :user_approve) put("/users/tag", AdminAPIController, :tag_users) delete("/users/tag", AdminAPIController, :untag_users) diff --git a/test/user_test.exs b/test/user_test.exs index e57453982..9da2aa411 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1202,6 +1202,31 @@ test "hide a user's statuses from timelines and notifications" do end end + describe "approve" do + test "approves a user" do + user = insert(:user, approval_pending: true) + assert true == user.approval_pending + {:ok, user} = User.approve(user) + assert false == user.approval_pending + end + + test "approves a list of users" do + unapproved_users = [ + insert(:user, approval_pending: true), + insert(:user, approval_pending: true), + insert(:user, approval_pending: true) + ] + + {:ok, users} = User.approve(unapproved_users) + + assert Enum.count(users) == 3 + + Enum.each(users, fn user -> + assert false == user.approval_pending + end) + end + end + describe "delete" do setup do {:ok, user} = insert(:user) |> User.set_cache() diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 9cc8b1879..351df8883 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1257,6 +1257,26 @@ test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do "@#{admin.nickname} deactivated users: @#{user_one.nickname}, @#{user_two.nickname}" end + test "PATCH /api/pleroma/admin/users/approve", %{admin: admin, conn: conn} do + user_one = insert(:user, approval_pending: true) + user_two = insert(:user, approval_pending: true) + + conn = + patch( + conn, + "/api/pleroma/admin/users/approve", + %{nicknames: [user_one.nickname, user_two.nickname]} + ) + + response = json_response(conn, 200) + assert Enum.map(response["users"], & &1["approval_pending"]) == [false, false] + + log_entry = Repo.one(ModerationLog) + + assert ModerationLog.get_log_entry_message(log_entry) == + "@#{admin.nickname} approved users: @#{user_one.nickname}, @#{user_two.nickname}" + end + test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do user = insert(:user) -- cgit v1.2.3 From fab44f69703975c0f6182ed1c26c1dcdad221dc5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 18:46:57 -0500 Subject: Test User with confirmation_pending: true, approval_pending: true --- test/user_test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/user_test.exs b/test/user_test.exs index 9da2aa411..cd39e1623 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1390,7 +1390,10 @@ test "returns :deactivated for deactivated user" do end test "returns :approval_pending for unapproved user" do - user = insert(:user, local: true, confirmation_pending: false, approval_pending: true) + user = insert(:user, local: true, approval_pending: true) + assert User.account_status(user) == :approval_pending + + user = insert(:user, local: true, confirmation_pending: true, approval_pending: true) assert User.account_status(user) == :approval_pending end end -- cgit v1.2.3 From e82060c47253946b79685ebff9a38e2fe0ac360e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 18:47:23 -0500 Subject: Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fed80a99..a0f529108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support pagination in emoji packs API (for packs and for files in pack) - Support for viewing instances favicons next to posts and accounts - Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata. +- "By approval" registrations mode.
    API Changes -- cgit v1.2.3 From df3d1bf5e57389e41a70676ccab1df81d83e3d74 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 18:48:17 -0500 Subject: Add :approval_pending to User @type account_status --- lib/pleroma/user.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 439c2c9b6..9c3b46ae8 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -42,7 +42,12 @@ defmodule Pleroma.User do require Logger @type t :: %__MODULE__{} - @type account_status :: :active | :deactivated | :password_reset_pending | :confirmation_pending + @type account_status :: + :active + | :deactivated + | :password_reset_pending + | :confirmation_pending + | :approval_pending @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true} # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength -- cgit v1.2.3 From 0d004a9d046f279be8462e8c751b5f1bcec3d35b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 14 Jul 2020 20:31:20 -0500 Subject: Email admins when a new unapproved account is up for review --- lib/pleroma/emails/admin_email.ex | 14 +++++++++++ lib/pleroma/web/twitter_api/twitter_api.ex | 13 +++++++++++ test/emails/admin_email_test.exs | 20 ++++++++++++++++ test/web/twitter_api/twitter_api_test.exs | 37 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index aa0b2a66b..fae7faf00 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -82,4 +82,18 @@ def report(to, reporter, account, statuses, comment) do |> subject("#{instance_name()} Report") |> html_body(html_body) end + + def new_unapproved_registration(to, account) do + html_body = """ +

    New account for review: @#{account.nickname}

    +
    #{account.registration_reason}
    + Visit AdminFE + """ + + new() + |> to({to.name, to.email}) + |> from({instance_name(), instance_notify_email()}) + |> subject("New account up for review on #{instance_name()} (@#{account.nickname})") + |> html_body(html_body) + end end diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 4ff021b82..2294d9d0d 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -45,6 +45,7 @@ defp create_user(params, opts) do case User.register(changeset) do {:ok, user} -> + maybe_notify_admins(user) {:ok, user} {:error, changeset} -> @@ -57,6 +58,18 @@ defp create_user(params, opts) do end end + defp maybe_notify_admins(%User{} = account) do + if Pleroma.Config.get([:instance, :account_approval_required]) do + User.all_superusers() + |> Enum.filter(fn user -> not is_nil(user.email) end) + |> Enum.each(fn superuser -> + superuser + |> Pleroma.Emails.AdminEmail.new_unapproved_registration(account) + |> Pleroma.Emails.Mailer.deliver_async() + end) + end + end + def password_reset(nickname_or_email) do with true <- is_binary(nickname_or_email), %User{local: true, email: email} = user when is_binary(email) <- diff --git a/test/emails/admin_email_test.exs b/test/emails/admin_email_test.exs index 9082ae5a7..e24231e27 100644 --- a/test/emails/admin_email_test.exs +++ b/test/emails/admin_email_test.exs @@ -46,4 +46,24 @@ test "it works when the reporter is a remote user without email" do assert res.to == [{to_user.name, to_user.email}] assert res.from == {config[:name], config[:notify_email]} end + + test "new unapproved registration email" do + config = Pleroma.Config.get(:instance) + to_user = insert(:user) + account = insert(:user, registration_reason: "Plz let me in") + + res = AdminEmail.new_unapproved_registration(to_user, account) + + account_url = Helpers.user_feed_url(Pleroma.Web.Endpoint, :feed_redirect, account.id) + + assert res.to == [{to_user.name, to_user.email}] + assert res.from == {config[:name], config[:notify_email]} + assert res.subject == "New account up for review on #{config[:name]} (@#{account.nickname})" + + assert res.html_body == """ +

    New account for review: @#{account.nickname}

    +
    Plz let me in
    + Visit AdminFE + """ + end end diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 368533292..df9d59f6a 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do use Pleroma.DataCase + import Pleroma.Factory alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User @@ -85,6 +86,42 @@ test "it sends confirmation email if :account_activation_required is specified i ) end + test "it sends an admin email if :account_approval_required is specified in instance config" do + admin = insert(:user, is_admin: true) + setting = Pleroma.Config.get([:instance, :account_approval_required]) + + unless setting do + Pleroma.Config.put([:instance, :account_approval_required], true) + on_exit(fn -> Pleroma.Config.put([:instance, :account_approval_required], setting) end) + end + + data = %{ + :username => "lain", + :email => "lain@wired.jp", + :fullname => "lain iwakura", + :bio => "", + :password => "bear", + :confirm => "bear", + :reason => "I love anime" + } + + {:ok, user} = TwitterAPI.register_user(data) + ObanHelpers.perform_all() + + assert user.approval_pending + + email = Pleroma.Emails.AdminEmail.new_unapproved_registration(admin, user) + + notify_email = Pleroma.Config.get([:instance, :notify_email]) + instance_name = Pleroma.Config.get([:instance, :name]) + + Swoosh.TestAssertions.assert_email_sent( + from: {instance_name, notify_email}, + to: {admin.name, admin.email}, + html_body: email.html_body + ) + end + test "it registers a new user and parses mentions in the bio" do data1 = %{ :username => "john", -- cgit v1.2.3 From 6d8427cca21db0a9250f6ce32fe513c0bef7cddb Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 15 Jul 2020 09:58:35 +0200 Subject: AP C2S tests: Make sure you can't use another user's AP id --- .../activity_pub/activity_pub_controller_test.exs | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index e722f7c04..ed900d8f8 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -1082,6 +1082,45 @@ test "it increases like count when receiving a like action", %{conn: conn} do assert object = Object.get_by_ap_id(note_object.data["id"]) assert object.data["like_count"] == 1 end + + test "it doesn't spreads faulty attributedTo or actor fields", %{ + conn: conn, + activity: activity + } do + reimu = insert(:user, nickname: "reimu") + cirno = insert(:user, nickname: "cirno") + + assert reimu.ap_id + assert cirno.ap_id + + activity = + activity + |> put_in(["object", "actor"], reimu.ap_id) + |> put_in(["object", "attributedTo"], reimu.ap_id) + |> put_in(["actor"], reimu.ap_id) + |> put_in(["attributedTo"], reimu.ap_id) + + _reimu_outbox = + conn + |> assign(:user, cirno) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{reimu.nickname}/outbox", activity) + |> json_response(403) + + cirno_outbox = + conn + |> assign(:user, cirno) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{cirno.nickname}/outbox", activity) + |> json_response(201) + + assert cirno_outbox["attributedTo"] == nil + assert cirno_outbox["actor"] == cirno.ap_id + + assert cirno_object = Object.normalize(cirno_outbox["object"]) + assert cirno_object.data["actor"] == cirno.ap_id + assert cirno_object.data["attributedTo"] == cirno.ap_id + end end describe "/relay/followers" do -- cgit v1.2.3 From 7bcd7a959519ea023b075142aa36005f80503ba4 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 11 Jun 2020 20:23:10 +0200 Subject: QuestionValidator: Create --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/activity_pub/object_validator.ex | 41 +++++++++- .../object_validators/common_validations.ex | 4 +- .../object_validators/create_question_validator.ex | 94 ++++++++++++++++++++++ .../object_validators/note_validator.ex | 2 +- .../question_options_validator.ex | 47 +++++++++++ .../object_validators/question_validator.ex | 89 ++++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 10 ++- lib/pleroma/web/activity_pub/transmogrifier.ex | 17 +++- test/web/activity_pub/transmogrifier_test.exs | 2 +- 10 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex create mode 100644 lib/pleroma/web/activity_pub/object_validators/question_validator.ex diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index bc7b5d95a..462aa57a6 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -95,7 +95,7 @@ defp increase_poll_votes_if_vote(%{ defp increase_poll_votes_if_vote(_create_data), do: :noop - @object_types ["ChatMessage"] + @object_types ["ChatMessage", "Question"] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index df926829c..5cc66d7bd 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -16,10 +16,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateQuestionValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator @@ -105,17 +107,30 @@ def validate(%{"type" => "ChatMessage"} = object, meta) do end end + def validate(%{"type" => "Question"} = object, meta) do + with {:ok, object} <- + object + |> QuestionValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "EmojiReact"} = object, meta) do with {:ok, object} <- object |> EmojiReactValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object |> Map.from_struct()) + object = stringify_keys(object) {:ok, object, meta} end end - def validate(%{"type" => "Create", "object" => object} = create_activity, meta) do + def validate( + %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object} = create_activity, + meta + ) do with {:ok, object_data} <- cast_and_apply(object), meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- @@ -127,12 +142,27 @@ def validate(%{"type" => "Create", "object" => object} = create_activity, meta) end end + def validate( + %{"type" => "Create", "object" => %{"type" => "Question"} = object} = create_activity, + meta + ) do + with {:ok, object_data} <- cast_and_apply(object), + meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), + {:ok, create_activity} <- + create_activity + |> CreateQuestionValidator.cast_and_validate(meta) + |> Ecto.Changeset.apply_action(:insert) do + create_activity = stringify_keys(create_activity) + {:ok, create_activity, meta} + end + end + def validate(%{"type" => "Announce"} = object, meta) do with {:ok, object} <- object |> AnnounceValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object |> Map.from_struct()) + object = stringify_keys(object) {:ok, object, meta} end end @@ -141,8 +171,13 @@ def cast_and_apply(%{"type" => "ChatMessage"} = object) do ChatMessageValidator.cast_and_apply(object) end + def cast_and_apply(%{"type" => "Question"} = object) do + QuestionValidator.cast_and_apply(object) + end + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} + # is_struct/1 isn't present in Elixir 1.8.x def stringify_keys(%{__struct__: _} = object) do object |> Map.from_struct() 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 aeef31945..e746b9360 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do alias Pleroma.Object alias Pleroma.User - def validate_recipients_presence(cng, fields \\ [:to, :cc]) do + def validate_any_presence(cng, fields) do non_empty = fields |> Enum.map(fn field -> get_field(cng, field) end) @@ -24,7 +24,7 @@ def validate_recipients_presence(cng, fields \\ [:to, :cc]) do fields |> Enum.reduce(cng, fn field, cng -> cng - |> add_error(field, "no recipients in any field") + |> add_error(field, "none of #{inspect(fields)} present") end) end end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex new file mode 100644 index 000000000..f09207418 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex @@ -0,0 +1,94 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# Code based on CreateChatMessageValidator +# NOTES +# - Can probably be a generic create validator +# - doesn't embed, will only get the object id +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateQuestionValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:actor, Types.ObjectID) + field(:type, :string) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + field(:object, Types.ObjectID) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end + + def cast_and_validate(data, meta \\ []) do + cast_data(data) + |> validate_data(meta) + end + + def validate_data(cng, meta \\ []) do + cng + |> validate_required([:actor, :type, :object]) + |> validate_inclusion(:type, ["Create"]) + |> validate_actor_presence() + |> validate_any_presence([:to, :cc]) + |> validate_recipients_match(meta) + |> validate_actors_match(meta) + |> validate_object_nonexistence() + end + + def validate_object_nonexistence(cng) do + cng + |> validate_change(:object, fn :object, object_id -> + if Object.get_cached_by_ap_id(object_id) do + [{:object, "The object to create already exists"}] + else + [] + end + end) + end + + def validate_actors_match(cng, meta) do + object_actor = meta[:object_data]["actor"] + + cng + |> validate_change(:actor, fn :actor, actor -> + if actor == object_actor do + [] + else + [{:actor, "Actor doesn't match with object actor"}] + end + end) + end + + def validate_recipients_match(cng, meta) do + object_recipients = meta[:object_data]["to"] || [] + + cng + |> validate_change(:to, fn :to, recipients -> + activity_set = MapSet.new(recipients) + object_set = MapSet.new(object_recipients) + + if MapSet.equal?(activity_set, object_set) do + [] + else + [{:to, "Recipients don't match with object recipients"}] + end + end) + end +end 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 56b93dde8..a65fe2354 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:replies_count, :integer, default: 0) field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) - field(:inRepyTo, :string) + field(:inReplyTo, :string) field(:uri, ObjectValidators.Uri) field(:likes, {:array, :string}, default: []) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex new file mode 100644 index 000000000..9bc7e0cc0 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex @@ -0,0 +1,47 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsRepliesValidator + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:name, :string) + embeds_one(:replies, QuestionOptionsRepliesValidator) + field(:type, :string) + end + + def changeset(struct, data) do + struct + |> cast(data, [:name, :type]) + |> cast_embed(:replies) + |> validate_inclusion(:type, ["Note"]) + |> validate_required([:name, :type]) + end +end + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsRepliesValidator do + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + + embedded_schema do + field(:totalItems, :integer) + field(:type, :string) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + |> validate_inclusion(:type, ["Collection"]) + |> validate_required([:type]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex new file mode 100644 index 000000000..f94d79352 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -0,0 +1,89 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + # Extends from NoteValidator + embedded_schema do + field(:id, Types.ObjectID, 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) + field(:published, Types.DateTime) + # 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(:inReplyTo, :string) + field(:uri, Types.Uri) + + field(:likes, {:array, :string}, default: []) + field(:announcements, {:array, :string}, default: []) + + # see if needed + field(:conversation, :string) + field(:context_id, :string) + + field(:closed, Types.DateTime) + field(:voters, {:array, Types.ObjectID}, default: []) + embeds_many(:anyOf, QuestionOptionsValidator) + embeds_many(:oneOf, QuestionOptionsValidator) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf]) + |> cast_embed(:anyOf) + |> cast_embed(:oneOf) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Question"]) + |> validate_required([:id, :actor, :type, :content, :context]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_any_presence([:oneOf, :anyOf]) + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1d2c296a5..a78ec411f 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -268,9 +268,15 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do end end + def handle_object_creation(%{"type" => "Question"} = object, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do + {:ok, object, meta} + end + end + # Nothing to do - def handle_object_creation(object) do - {:ok, object} + def handle_object_creation(object, meta) do + {:ok, object, meta} end defp undo_like(nil, object), do: delete_object(object) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f37bcab3e..da5dc23bc 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -457,7 +457,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ["Article", "Event", "Note", "Video", "Page", "Question", "Answer", "Audio"] do + when objtype in ["Article", "Event", "Note", "Video", "Page", "Answer", "Audio"] do actor = Containment.get_actor(data) with nil <- Activity.get_create_by_object_ap_id(object["id"]), @@ -613,6 +613,21 @@ def handle_incoming( |> handle_incoming(options) end + def handle_incoming( + %{"type" => "Create", "object" => %{"type" => "Question"} = object} = data, + _options + ) do + data = + data + |> Map.put("object", fix_object(object)) + |> fix_addressing() + + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), + {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + end + end + def handle_incoming( %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, _options diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 248b410c6..73949b558 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -222,7 +222,7 @@ test "it works for incoming questions" do {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) - object = Object.normalize(activity) + object = Object.normalize(activity, false) assert Enum.all?(object.data["oneOf"], fn choice -> choice["name"] in [ -- cgit v1.2.3 From c5efaf6b00bf582a6eef7b5728fe5042f5fcc702 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 11 Jun 2020 20:43:01 +0200 Subject: AnswerValidator: Create --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/activity_pub/object_validator.ex | 20 +++++++- .../object_validators/answer_validator.ex | 58 ++++++++++++++++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 7 +-- 4 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/answer_validator.ex diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 462aa57a6..d8cc8d24f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -95,7 +95,7 @@ defp increase_poll_votes_if_vote(%{ defp increase_poll_votes_if_vote(_create_data), do: :noop - @object_types ["ChatMessage", "Question"] + @object_types ["ChatMessage", "Question", "Answer"] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 5cc66d7bd..c89311187 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator @@ -117,6 +118,16 @@ def validate(%{"type" => "Question"} = object, meta) do end end + def validate(%{"type" => "Answer"} = object, meta) do + with {:ok, object} <- + object + |> AnswerValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "EmojiReact"} = object, meta) do with {:ok, object} <- object @@ -143,9 +154,10 @@ def validate( end def validate( - %{"type" => "Create", "object" => %{"type" => "Question"} = object} = create_activity, + %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, meta - ) do + ) + when objtype in ["Question", "Answer"] do with {:ok, object_data} <- cast_and_apply(object), meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- @@ -175,6 +187,10 @@ def cast_and_apply(%{"type" => "Question"} = object) do QuestionValidator.cast_and_apply(object) end + def cast_and_apply(%{"type" => "Answer"} = object) do + QuestionValidator.cast_and_apply(object) + end + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} # is_struct/1 isn't present in Elixir 1.8.x diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex new file mode 100644 index 000000000..0b51eccfa --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do + use Ecto.Schema + + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + # Extends from NoteValidator + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:to, {:array, :string}, default: []) + field(:cc, {:array, :string}, default: []) + field(:bto, {:array, :string}, default: []) + field(:bcc, {:array, :string}, default: []) + field(:type, :string) + field(:name, :string) + field(:inReplyTo, :string) + field(:attributedTo, Types.ObjectID) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Answer"]) + |> validate_required([:id, :inReplyTo, :name]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_actor_presence() + end +end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index da5dc23bc..9900602e4 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -457,7 +457,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ["Article", "Event", "Note", "Video", "Page", "Answer", "Audio"] do + when objtype in ["Article", "Event", "Note", "Video", "Page", "Audio"] do actor = Containment.get_actor(data) with nil <- Activity.get_create_by_object_ap_id(object["id"]), @@ -614,9 +614,10 @@ def handle_incoming( end def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "Question"} = object} = data, + %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, _options - ) do + ) + when objtype in ["Question", "Answer"] do data = data |> Map.put("object", fix_object(object)) -- cgit v1.2.3 From 89a2433154b05c378f9c5b3224375049f3dbc8af Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 12 Jun 2020 21:22:05 +0200 Subject: QuestionOptionsValidator: inline schema for replies --- .../question_options_validator.ex | 28 +++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex index 9bc7e0cc0..8291d7b9f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex @@ -5,42 +5,32 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsRepliesValidator - import Ecto.Changeset @primary_key false embedded_schema do field(:name, :string) - embeds_one(:replies, QuestionOptionsRepliesValidator) + + embeds_one :replies, Replies do + field(:totalItems, :integer) + field(:type, :string) + end + field(:type, :string) end def changeset(struct, data) do struct |> cast(data, [:name, :type]) - |> cast_embed(:replies) + |> cast_embed(:replies, with: &replies_changeset/2) |> validate_inclusion(:type, ["Note"]) |> validate_required([:name, :type]) end -end - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsRepliesValidator do - use Ecto.Schema - - import Ecto.Changeset - @primary_key false - - embedded_schema do - field(:totalItems, :integer) - field(:type, :string) - end - - def changeset(struct, data) do + def replies_changeset(struct, data) do struct - |> cast(data, __schema__(:fields)) + |> cast(data, [:totalItems, :type]) |> validate_inclusion(:type, ["Collection"]) |> validate_required([:type]) end -- cgit v1.2.3 From 10bd08ef07bd91995589ad37cb25e6889dac59b3 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jun 2020 22:25:04 +0200 Subject: transmogrifier_test: test date, anyOf and oneOf completely --- .../question_options_validator.ex | 2 +- test/web/activity_pub/transmogrifier_test.exs | 35 +++++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex index 8291d7b9f..478b3b5cf 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do embedded_schema do field(:name, :string) - embeds_one :replies, Replies do + embeds_one :replies, Replies, primary_key: false do field(:totalItems, :integer) field(:type, :string) end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 73949b558..4184b93ce 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -224,14 +224,33 @@ test "it works for incoming questions" do object = Object.normalize(activity, false) - assert Enum.all?(object.data["oneOf"], fn choice -> - choice["name"] in [ - "Dunno", - "Everyone knows that!", - "25 char limit is dumb", - "I can't even fit a funny" - ] - end) + assert object.data["closed"] == "2019-05-11T09:03:36Z" + + assert object.data["anyOf"] == [] + + assert Enum.sort(object.data["oneOf"]) == + Enum.sort([ + %{ + "name" => "25 char limit is dumb", + "replies" => %{"totalItems" => 0, "type" => "Collection"}, + "type" => "Note" + }, + %{ + "name" => "Dunno", + "replies" => %{"totalItems" => 0, "type" => "Collection"}, + "type" => "Note" + }, + %{ + "name" => "Everyone knows that!", + "replies" => %{"totalItems" => 1, "type" => "Collection"}, + "type" => "Note" + }, + %{ + "name" => "I can't even fit a funny", + "replies" => %{"totalItems" => 1, "type" => "Collection"}, + "type" => "Note" + } + ]) end test "it works for incoming listens" do -- cgit v1.2.3 From 4644a8bd109faf6c684767fc60c37e298b8c5c07 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 15 Jun 2020 00:30:45 +0200 Subject: Fix multiple-choice poll detection --- lib/pleroma/object.ex | 17 ++++++++++------- lib/pleroma/web/common_api/common_api.ex | 9 +++++++-- lib/pleroma/web/mastodon_api/views/poll_view.ex | 4 ++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 546c4ea01..4dd929cfd 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -255,6 +255,12 @@ def increase_replies_count(ap_id) do end end + defp poll_is_multiple?(%Object{data: %{"anyOf" => anyOf}}) do + !Enum.empty?(anyOf) + end + + defp poll_is_multiple?(_), do: false + def decrease_replies_count(ap_id) do Object |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id))) @@ -281,10 +287,10 @@ def decrease_replies_count(ap_id) do def increase_vote_count(ap_id, name, actor) do with %Object{} = object <- Object.normalize(ap_id), "Question" <- object.data["type"] do - multiple = Map.has_key?(object.data, "anyOf") + key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf" options = - (object.data["anyOf"] || object.data["oneOf"] || []) + object.data[key] |> Enum.map(fn %{"name" => ^name} = option -> Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1)) @@ -296,11 +302,8 @@ def increase_vote_count(ap_id, name, actor) do voters = [actor | object.data["voters"] || []] |> Enum.uniq() data = - if multiple do - Map.put(object.data, "anyOf", options) - else - Map.put(object.data, "oneOf", options) - end + object.data + |> Map.put(key, options) |> Map.put("voters", voters) object diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 4d5b0decf..692ceab1e 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -340,8 +340,13 @@ defp validate_existing_votes(%{ap_id: ap_id}, object) do end end - defp get_options_and_max_count(%{data: %{"anyOf" => any_of}}), do: {any_of, Enum.count(any_of)} - defp get_options_and_max_count(%{data: %{"oneOf" => one_of}}), do: {one_of, 1} + defp get_options_and_max_count(%{data: %{"anyOf" => any_of}}) + when is_list(any_of) and any_of != [], + do: {any_of, Enum.count(any_of)} + + defp get_options_and_max_count(%{data: %{"oneOf" => one_of}}) + when is_list(one_of) and one_of != [], + do: {one_of, 1} defp normalize_and_validate_choices(choices, object) do choices = Enum.map(choices, fn i -> if is_binary(i), do: String.to_integer(i), else: i end) diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 59a5deb28..73c990e2e 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -28,10 +28,10 @@ def render("show.json", %{object: object, multiple: multiple, options: options} def render("show.json", %{object: object} = params) do case object.data do - %{"anyOf" => options} when is_list(options) -> + %{"anyOf" => options} when is_list(options) and options != [] -> render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options})) - %{"oneOf" => options} when is_list(options) -> + %{"oneOf" => options} when is_list(options) and options != [] -> render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options})) _ -> -- cgit v1.2.3 From 6b9c4bc1f1e5013cdfc084603cdf6cfa83ac2778 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jun 2020 22:24:00 +0200 Subject: fetcher: more descriptive variable names --- lib/pleroma/object/fetcher.ex | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 3e2949ee2..e1ab4ef8b 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -23,21 +23,21 @@ defp touch_changeset(changeset) do Ecto.Changeset.put_change(changeset, :updated_at, updated_at) end - defp maybe_reinject_internal_fields(data, %{data: %{} = old_data}) do + defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields()) - Map.merge(data, internal_fields) + Map.merge(new_data, internal_fields) end - defp maybe_reinject_internal_fields(data, _), do: data + defp maybe_reinject_internal_fields(_, new_data), do: new_data @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} - defp reinject_object(struct, data) do - Logger.debug("Reinjecting object #{data["id"]}") + defp reinject_object(%Object{} = object, new_data) do + Logger.debug("Reinjecting object #{new_data["id"]}") - with data <- Transmogrifier.fix_object(data), - data <- maybe_reinject_internal_fields(data, struct), - changeset <- Object.change(struct, %{data: data}), + with new_data <- Transmogrifier.fix_object(new_data), + data <- maybe_reinject_internal_fields(object, new_data), + changeset <- Object.change(object, %{data: data}), changeset <- touch_changeset(changeset), {:ok, object} <- Repo.insert_or_update(changeset), {:ok, object} <- Object.set_cache(object) do @@ -51,8 +51,8 @@ defp reinject_object(struct, data) do def refetch_object(%Object{data: %{"id" => id}} = object) do with {:local, false} <- {:local, Object.local?(object)}, - {:ok, data} <- fetch_and_contain_remote_object_from_id(id), - {:ok, object} <- reinject_object(object, data) do + {:ok, new_data} <- fetch_and_contain_remote_object_from_id(id), + {:ok, object} <- reinject_object(object, new_data) do {:ok, object} else {:local, true} -> {:ok, object} -- cgit v1.2.3 From ad867ccfa18afe2a6104cad4d3035834c5a264c8 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jun 2020 22:01:14 +0200 Subject: fetcher: Reinject Question through validator --- lib/pleroma/object/fetcher.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index e1ab4ef8b..3956bb727 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.Repo alias Pleroma.Signature alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Federator @@ -32,6 +33,24 @@ defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do defp maybe_reinject_internal_fields(_, new_data), do: new_data @spec reinject_object(struct(), map()) :: {:ok, Object.t()} | {:error, any()} + defp reinject_object(%Object{data: %{"type" => "Question"}} = object, new_data) do + Logger.debug("Reinjecting object #{new_data["id"]}") + + with new_data <- Transmogrifier.fix_object(new_data), + data <- maybe_reinject_internal_fields(object, new_data), + {:ok, data, _} <- ObjectValidator.validate(data, %{}), + changeset <- Object.change(object, %{data: data}), + changeset <- touch_changeset(changeset), + {:ok, object} <- Repo.insert_or_update(changeset), + {:ok, object} <- Object.set_cache(object) do + {:ok, object} + else + e -> + Logger.error("Error while processing object: #{inspect(e)}") + {:error, e} + end + end + defp reinject_object(%Object{} = object, new_data) do Logger.debug("Reinjecting object #{new_data["id"]}") -- cgit v1.2.3 From 47ba796f415740c443cd8477c121280656b13032 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 15 Jun 2020 02:20:18 +0200 Subject: create_question_validator: remove validate_recipients_match --- .../object_validators/create_question_validator.ex | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex index f09207418..6d3f71566 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex @@ -47,7 +47,6 @@ def validate_data(cng, meta \\ []) do |> validate_inclusion(:type, ["Create"]) |> validate_actor_presence() |> validate_any_presence([:to, :cc]) - |> validate_recipients_match(meta) |> validate_actors_match(meta) |> validate_object_nonexistence() end @@ -75,20 +74,4 @@ def validate_actors_match(cng, meta) do end end) end - - def validate_recipients_match(cng, meta) do - object_recipients = meta[:object_data]["to"] || [] - - cng - |> validate_change(:to, fn :to, recipients -> - activity_set = MapSet.new(recipients) - object_set = MapSet.new(object_recipients) - - if MapSet.equal?(activity_set, object_set) do - [] - else - [{:to, "Recipients don't match with object recipients"}] - end - end) - end end -- cgit v1.2.3 From 173f69c854adf966d52b3767c4de43a0b1ce5b00 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 15 Jun 2020 05:18:30 +0200 Subject: question_validator: fix for mastodon poll expiration Mastodon activities do not have a "closed" field, this could be seen on https://pouet.it/users/lanodan_tmp/statuses/104345126997708380 which runs Mastodon 3.1.4 (SDF runs 3.1.2) --- .../activity_pub/object_validators/question_validator.ex | 10 ++++++++++ lib/pleroma/web/mastodon_api/views/poll_view.ex | 14 ++++++-------- test/fixtures/mastodon-question-activity.json | 1 - 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index f94d79352..605cb56f8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -71,7 +71,17 @@ def cast_data(data) do |> changeset(data) end + def fix(data) do + cond do + is_binary(data["closed"]) -> data + is_binary(data["endTime"]) -> Map.put(data, "closed", data["endTime"]) + true -> Map.drop(data, ["closed"]) + end + end + def changeset(struct, data) do + data = fix(data) + struct |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf]) |> cast_embed(:anyOf) diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 73c990e2e..ce595ae8a 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -40,15 +40,13 @@ def render("show.json", %{object: object} = params) do end defp end_time_and_expired(object) do - case object.data["closed"] || object.data["endTime"] do - end_time when is_binary(end_time) -> - end_time = NaiveDateTime.from_iso8601!(end_time) - expired = NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) == :lt + if object.data["closed"] do + end_time = NaiveDateTime.from_iso8601!(object.data["closed"]) + expired = NaiveDateTime.compare(end_time, NaiveDateTime.utc_now()) == :lt - {Utils.to_masto_date(end_time), expired} - - _ -> - {nil, false} + {Utils.to_masto_date(end_time), expired} + else + {nil, false} end end diff --git a/test/fixtures/mastodon-question-activity.json b/test/fixtures/mastodon-question-activity.json index ac329c7d5..3648b9f90 100644 --- a/test/fixtures/mastodon-question-activity.json +++ b/test/fixtures/mastodon-question-activity.json @@ -49,7 +49,6 @@ "en": "

    Why is Tenshi eating a corndog so cute?

    " }, "endTime": "2019-05-11T09:03:36Z", - "closed": "2019-05-11T09:03:36Z", "attachment": [], "tag": [], "replies": { -- cgit v1.2.3 From 4f70fd4105e90c8fc06a1eb6bd70084874bae3a5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 18 Jun 2020 19:54:56 +0200 Subject: question_validator: remove conversation field --- lib/pleroma/web/activity_pub/object_validators/question_validator.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 605cb56f8..211f520c4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -45,7 +45,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:announcements, {:array, :string}, default: []) # see if needed - field(:conversation, :string) field(:context_id, :string) field(:closed, Types.DateTime) -- cgit v1.2.3 From 82895a40123ca24258a95b9ac7e117dd8b1e698e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 18 Jun 2020 04:05:42 +0200 Subject: SideEffects: port ones from ActivityPub.do_create and ActivityPub.insert --- lib/pleroma/object/containment.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 13 +-- lib/pleroma/web/activity_pub/builder.ex | 15 ++++ lib/pleroma/web/activity_pub/object_validator.ex | 6 +- .../object_validators/answer_validator.ex | 7 +- .../object_validators/common_validations.ex | 38 ++++++++ .../object_validators/create_generic_validator.ex | 100 +++++++++++++++++++++ .../object_validators/create_question_validator.ex | 77 ---------------- .../object_validators/question_validator.ex | 28 ++++-- lib/pleroma/web/activity_pub/side_effects.ex | 30 ++++++- lib/pleroma/web/activity_pub/transmogrifier.ex | 49 ++++++---- lib/pleroma/web/common_api/common_api.ex | 25 +++--- lib/pleroma/web/common_api/utils.ex | 11 --- test/web/activity_pub/transmogrifier_test.exs | 2 +- 14 files changed, 259 insertions(+), 144 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index 99608b8a5..bc88e8a0c 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -55,7 +55,7 @@ defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do defp compare_uris(_id_uri, _other_uri), do: :error @doc """ - Checks that an imported AP object's actor matches the domain it came from. + Checks that an imported AP object's actor matches the host it came from. """ def contain_origin(_id, %{"actor" => nil}), do: :error diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index d8cc8d24f..9d13a06c4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -66,7 +66,7 @@ defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil( defp check_remote_limit(_), do: true - defp increase_note_count_if_public(actor, object) do + def increase_note_count_if_public(actor, object) do if is_public?(object), do: User.increase_note_count(actor), else: {:ok, actor} end @@ -85,16 +85,6 @@ defp increase_replies_count_if_reply(%{ defp increase_replies_count_if_reply(_create_data), do: :noop - defp increase_poll_votes_if_vote(%{ - "object" => %{"inReplyTo" => reply_ap_id, "name" => name}, - "type" => "Create", - "actor" => actor - }) do - Object.increase_vote_count(reply_ap_id, name, actor) - end - - defp increase_poll_votes_if_vote(_create_data), do: :noop - @object_types ["ChatMessage", "Question", "Answer"] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(%{"type" => type} = object, meta) when type in @object_types do @@ -258,7 +248,6 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param with {:ok, activity} <- insert(create_data, local, fake), {:fake, false, activity} <- {:fake, fake, activity}, _ <- increase_replies_count_if_reply(create_data), - _ <- increase_poll_votes_if_vote(create_data), {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:ok, _actor} <- increase_note_count_if_public(actor, activity), _ <- notify_and_stream(activity), diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index d5f3610ed..e97381954 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -115,6 +115,21 @@ def chat_message(actor, recipient, content, opts \\ []) do end end + def answer(user, object, name) do + {:ok, + %{ + "type" => "Answer", + "actor" => user.ap_id, + "cc" => [object.data["actor"]], + "to" => [], + "name" => name, + "inReplyTo" => object.data["id"], + "context" => object.data["context"], + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "id" => Utils.generate_object_id() + }, []} + end + @spec tombstone(String.t(), String.t()) :: {:ok, map(), keyword()} def tombstone(actor, id) do {:ok, diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index c89311187..a24aaf00c 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.CreateQuestionValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator @@ -162,7 +162,7 @@ def validate( meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- create_activity - |> CreateQuestionValidator.cast_and_validate(meta) + |> CreateGenericValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do create_activity = stringify_keys(create_activity) {:ok, create_activity, meta} @@ -188,7 +188,7 @@ def cast_and_apply(%{"type" => "Question"} = object) do end def cast_and_apply(%{"type" => "Answer"} = object) do - QuestionValidator.cast_and_apply(object) + AnswerValidator.cast_and_apply(object) end def cast_and_apply(o), do: {:error, {:validator_not_set, o}} diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index 0b51eccfa..8d4c92520 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -13,22 +13,25 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do @primary_key false @derive Jason.Encoder - # Extends from NoteValidator embedded_schema do field(:id, Types.ObjectID, primary_key: true) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) + + # is this actually needed? field(:bto, {:array, :string}, default: []) field(:bcc, {:array, :string}, default: []) + field(:type, :string) field(:name, :string) field(:inReplyTo, :string) field(:attributedTo, Types.ObjectID) + field(:actor, Types.ObjectID) end def cast_and_apply(data) do data - |> cast_data + |> cast_data() |> apply_action(:insert) end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index e746b9360..140555a45 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -42,6 +42,19 @@ def validate_actor_presence(cng, options \\ []) do end) end + def validate_actor_is_active(cng, options \\ []) do + field_name = Keyword.get(options, :field_name, :actor) + + cng + |> validate_change(field_name, fn field_name, actor -> + if %User{deactivated: false} = User.get_cached_by_ap_id(actor) do + [] + else + [{field_name, "can't find user (or deactivated)"}] + end + end) + end + def validate_object_presence(cng, options \\ []) do field_name = Keyword.get(options, :field_name, :object) allowed_types = Keyword.get(options, :allowed_types, false) @@ -77,4 +90,29 @@ def validate_object_or_user_presence(cng, options \\ []) do if actor_cng.valid?, do: actor_cng, else: object_cng end + + def validate_host_match(cng, fields \\ [:id, :actor]) do + unique_hosts = + fields + |> Enum.map(fn field -> + %URI{host: host} = + cng + |> get_field(field) + |> URI.parse() + + host + end) + |> Enum.uniq() + |> Enum.count() + + if unique_hosts == 1 do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "hosts of #{inspect(fields)} aren't matching") + end) + end + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex new file mode 100644 index 000000000..4ad4ca0de --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -0,0 +1,100 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# Code based on CreateChatMessageValidator +# NOTES +# - doesn't embed, will only get the object id +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do + use Ecto.Schema + + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.Types + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, Types.ObjectID, primary_key: true) + field(:actor, Types.ObjectID) + field(:type, :string) + field(:to, Types.Recipients, default: []) + field(:cc, Types.Recipients, default: []) + field(:object, Types.ObjectID) + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data, meta \\ []) do + data + |> cast_data + |> validate_data(meta) + end + + def changeset(struct, data) do + struct + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng, meta \\ []) do + cng + |> validate_required([:actor, :type, :object]) + |> validate_inclusion(:type, ["Create"]) + |> validate_actor_is_active() + |> validate_any_presence([:to, :cc]) + |> validate_actors_match(meta) + |> validate_object_nonexistence() + |> validate_object_containment() + end + + def validate_object_containment(cng) do + actor = get_field(cng, :actor) + + cng + |> validate_change(:object, fn :object, object_id -> + %URI{host: object_id_host} = URI.parse(object_id) + %URI{host: actor_host} = URI.parse(actor) + + if object_id_host == actor_host do + [] + else + [{:object, "The host of the object id doesn't match with the host of the actor"}] + end + end) + end + + def validate_object_nonexistence(cng) do + cng + |> validate_change(:object, fn :object, object_id -> + if Object.get_cached_by_ap_id(object_id) do + [{:object, "The object to create already exists"}] + else + [] + end + end) + end + + def validate_actors_match(cng, meta) do + object_actor = meta[:object_data]["actor"] + + cng + |> validate_change(:actor, fn :actor, actor -> + if actor == object_actor do + [] + else + [{:actor, "Actor doesn't match with object actor"}] + end + end) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex deleted file mode 100644 index 6d3f71566..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/create_question_validator.ex +++ /dev/null @@ -1,77 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -# Code based on CreateChatMessageValidator -# NOTES -# - Can probably be a generic create validator -# - doesn't embed, will only get the object id -defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateQuestionValidator do - use Ecto.Schema - - alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types - - import Ecto.Changeset - import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations - - @primary_key false - - embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:actor, Types.ObjectID) - field(:type, :string) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) - field(:object, Types.ObjectID) - end - - def cast_and_apply(data) do - data - |> cast_data - |> apply_action(:insert) - end - - def cast_data(data) do - cast(%__MODULE__{}, data, __schema__(:fields)) - end - - def cast_and_validate(data, meta \\ []) do - cast_data(data) - |> validate_data(meta) - end - - def validate_data(cng, meta \\ []) do - cng - |> validate_required([:actor, :type, :object]) - |> validate_inclusion(:type, ["Create"]) - |> validate_actor_presence() - |> validate_any_presence([:to, :cc]) - |> validate_actors_match(meta) - |> validate_object_nonexistence() - end - - def validate_object_nonexistence(cng) do - cng - |> validate_change(:object, fn :object, object_id -> - if Object.get_cached_by_ap_id(object_id) do - [{:object, "The object to create already exists"}] - else - [] - end - end) - end - - def validate_actors_match(cng, meta) do - object_actor = meta[:object_data]["actor"] - - cng - |> validate_change(:actor, fn :actor, actor -> - if actor == object_actor do - [] - else - [{:actor, "Actor doesn't match with object actor"}] - end - end) - end -end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 211f520c4..f7f3b1354 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do use Ecto.Schema + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator alias Pleroma.Web.ActivityPub.ObjectValidators.Types @@ -40,13 +41,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:announcement_count, :integer, default: 0) field(:inReplyTo, :string) field(:uri, Types.Uri) + # short identifier for PleromaFE to group statuses by context + field(:context_id, :integer) field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) - # see if needed - field(:context_id, :string) - field(:closed, Types.DateTime) field(:voters, {:array, Types.ObjectID}, default: []) embeds_many(:anyOf, QuestionOptionsValidator) @@ -70,7 +70,7 @@ def cast_data(data) do |> changeset(data) end - def fix(data) do + defp fix_closed(data) do cond do is_binary(data["closed"]) -> data is_binary(data["endTime"]) -> Map.put(data, "closed", data["endTime"]) @@ -78,6 +78,23 @@ def fix(data) do end end + # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults + defp fix_defaults(data) do + %{data: %{"id" => context}, id: context_id} = Utils.create_context(data["context"]) + + data + |> Map.put_new_lazy("id", &Utils.generate_object_id/0) + |> Map.put_new_lazy("published", &Utils.make_date/0) + |> Map.put_new("context", context) + |> Map.put_new("context_id", context_id) + end + + defp fix(data) do + data + |> fix_closed() + |> fix_defaults() + end + def changeset(struct, data) do data = fix(data) @@ -92,7 +109,8 @@ def validate_data(data_cng) do |> validate_inclusion(:type, ["Question"]) |> validate_required([:id, :actor, :type, :content, :context]) |> CommonValidations.validate_any_presence([:cc, :to]) - |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_actor_is_active() |> CommonValidations.validate_any_presence([:oneOf, :anyOf]) + |> CommonValidations.validate_host_match() end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a78ec411f..c17197bec 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do """ alias Pleroma.Activity alias Pleroma.Activity.Ir.Topics + alias Pleroma.ActivityExpiration alias Pleroma.Chat alias Pleroma.Chat.MessageReference alias Pleroma.FollowingRelationship @@ -19,6 +20,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Push alias Pleroma.Web.Streamer + alias Pleroma.Workers.BackgroundWorker def handle(object, meta \\ []) @@ -135,10 +137,24 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # Tasks this handles # - Actually create object # - Rollback if we couldn't create it + # - Increase the user note count + # - Increase the reply count # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do - with {:ok, _object, meta} <- handle_object_creation(meta[:object_data], meta) do + with {:ok, object, meta} <- handle_object_creation(meta[:object_data], meta), + %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do {:ok, notifications} = Notification.create_notifications(activity, do_send: false) + {:ok, _user} = ActivityPub.increase_note_count_if_public(user, object) + + if in_reply_to = object.data["inReplyTo"] do + Object.increase_replies_count(in_reply_to) + end + + if expires_at = activity.data["expires_at"] do + ActivityExpiration.create(activity, expires_at) + end + + BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) meta = meta @@ -268,6 +284,18 @@ def handle_object_creation(%{"type" => "ChatMessage"} = object, meta) do end end + def handle_object_creation(%{"type" => "Answer"} = object_map, meta) do + with {:ok, object, meta} <- Pipeline.common_pipeline(object_map, meta) do + Object.increase_vote_count( + object.data["inReplyTo"], + object.data["name"], + object.data["actor"] + ) + + {:ok, object, meta} + end + end + def handle_object_creation(%{"type" => "Question"} = object, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 9900602e4..26325d5de 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -419,6 +419,29 @@ defp get_reported(objects) do end) end + # Compatibility wrapper for Mastodon votes + defp handle_create(%{"object" => %{"type" => "Answer"}} = data, _user) do + handle_incoming(data) + end + + defp handle_create(%{"object" => object} = data, user) do + %{ + to: data["to"], + object: object, + actor: user, + context: object["context"], + local: false, + published: data["published"], + additional: + Map.take(data, [ + "cc", + "directMessage", + "id" + ]) + } + |> ActivityPub.create() + end + def handle_incoming(data, options \\ []) # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them @@ -461,26 +484,14 @@ def handle_incoming( actor = Containment.get_actor(data) with nil <- Activity.get_create_by_object_ap_id(object["id"]), - {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor), - data <- Map.put(data, "actor", actor) |> fix_addressing() do - object = fix_object(object, options) - - params = %{ - to: data["to"], - object: object, - actor: user, - context: object["context"], - local: false, - published: data["published"], - additional: - Map.take(data, [ - "cc", - "directMessage", - "id" - ]) - } + {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor) do + data = + data + |> Map.put("object", fix_object(object, options)) + |> Map.put("actor", actor) + |> fix_addressing() - with {:ok, created_activity} <- ActivityPub.create(params) do + with {:ok, created_activity} <- handle_create(data, user) do reply_depth = (options[:depth] || 0) + 1 if Federator.allowed_thread_distance?(reply_depth) do diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 692ceab1e..c08e0ffeb 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -308,18 +308,19 @@ def vote(user, %{data: %{"type" => "Question"}} = object, choices) do {:ok, options, choices} <- normalize_and_validate_choices(choices, object) do answer_activities = Enum.map(choices, fn index -> - answer_data = make_answer_data(user, object, Enum.at(options, index)["name"]) - - {:ok, activity} = - ActivityPub.create(%{ - to: answer_data["to"], - actor: user, - context: object.data["context"], - object: answer_data, - additional: %{"cc" => answer_data["cc"]} - }) - - activity + {:ok, answer_object, _meta} = + Builder.answer(user, object, Enum.at(options, index)["name"]) + + {:ok, activity_data, _meta} = Builder.create(user, answer_object, []) + + {:ok, activity, _meta} = + activity_data + |> Map.put("cc", answer_object["cc"]) + |> Map.put("context", answer_object["context"]) + |> Pipeline.common_pipeline(local: true) + + # TODO: Do preload of Pleroma.Object in Pipeline + Activity.normalize(activity.data) end) object = Object.get_cached_by_ap_id(object.data["id"]) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 9c38b73eb..9d7b24eb2 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -548,17 +548,6 @@ def conversation_id_to_context(id) do end end - def make_answer_data(%User{ap_id: ap_id}, object, name) do - %{ - "type" => "Answer", - "actor" => ap_id, - "cc" => [object.data["actor"]], - "to" => [], - "name" => name, - "inReplyTo" => object.data["id"] - } - end - def validate_character_limit("" = _full_payload, [] = _attachments) do {:error, dgettext("errors", "Cannot post an empty status without attachments")} end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 4184b93ce..62b5b06aa 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -282,7 +282,7 @@ test "it works for incoming listens" do assert object.data["length"] == 180_000 end - test "it rewrites Note votes to Answers and increments vote counters on question activities" do + test "it rewrites Note votes to Answer and increments vote counters on Question activities" do user = insert(:user) {:ok, activity} = -- cgit v1.2.3 From 39870d99b810940ccce430411c9fc6939f760663 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 19 Jun 2020 23:31:19 +0200 Subject: transmogrifier tests: Move & enhance in specialised modules --- .../transmogrifier/answer_handling_test.exs | 78 ++++++++++++++++++ .../transmogrifier/question_handling_test.exs | 65 +++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 92 ---------------------- 3 files changed, 143 insertions(+), 92 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/answer_handling_test.exs create mode 100644 test/web/activity_pub/transmogrifier/question_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/answer_handling_test.exs b/test/web/activity_pub/transmogrifier/answer_handling_test.exs new file mode 100644 index 000000000..0f6605c3f --- /dev/null +++ b/test/web/activity_pub/transmogrifier/answer_handling_test.exs @@ -0,0 +1,78 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.AnswerHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + test "incoming, rewrites Note to Answer and increments vote counters" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "suya...", + poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} + }) + + object = Object.normalize(activity) + + data = + File.read!("test/fixtures/mastodon-vote.json") + |> Poison.decode!() + |> Kernel.put_in(["to"], user.ap_id) + |> Kernel.put_in(["object", "inReplyTo"], object.data["id"]) + |> Kernel.put_in(["object", "to"], user.ap_id) + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + answer_object = Object.normalize(activity) + assert answer_object.data["type"] == "Answer" + assert answer_object.data["inReplyTo"] == object.data["id"] + + new_object = Object.get_by_ap_id(object.data["id"]) + assert new_object.data["replies_count"] == object.data["replies_count"] + + assert Enum.any?( + new_object.data["oneOf"], + fn + %{"name" => "suya..", "replies" => %{"totalItems" => 1}} -> true + _ -> false + end + ) + end + + test "outgoing, rewrites Answer to Note" do + user = insert(:user) + + {:ok, poll_activity} = + CommonAPI.post(user, %{ + status: "suya...", + poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} + }) + + poll_object = Object.normalize(poll_activity) + # TODO: Replace with CommonAPI vote creation when implemented + data = + File.read!("test/fixtures/mastodon-vote.json") + |> Poison.decode!() + |> Kernel.put_in(["to"], user.ap_id) + |> Kernel.put_in(["object", "inReplyTo"], poll_object.data["id"]) + |> Kernel.put_in(["object", "to"], user.ap_id) + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + + assert data["object"]["type"] == "Note" + end +end diff --git a/test/web/activity_pub/transmogrifier/question_handling_test.exs b/test/web/activity_pub/transmogrifier/question_handling_test.exs new file mode 100644 index 000000000..b7b9a1a7b --- /dev/null +++ b/test/web/activity_pub/transmogrifier/question_handling_test.exs @@ -0,0 +1,65 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Transmogrifier + + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + + test "Mastodon Question activity" do + data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + + object = Object.normalize(activity, false) + + assert object.data["closed"] == "2019-05-11T09:03:36Z" + + assert object.data["context"] == + "tag:mastodon.sdf.org,2019-05-10:objectId=15095122:objectType=Conversation" + + assert object.data["context_id"] + + assert object.data["anyOf"] == [] + + assert Enum.sort(object.data["oneOf"]) == + Enum.sort([ + %{ + "name" => "25 char limit is dumb", + "replies" => %{"totalItems" => 0, "type" => "Collection"}, + "type" => "Note" + }, + %{ + "name" => "Dunno", + "replies" => %{"totalItems" => 0, "type" => "Collection"}, + "type" => "Note" + }, + %{ + "name" => "Everyone knows that!", + "replies" => %{"totalItems" => 1, "type" => "Collection"}, + "type" => "Note" + }, + %{ + "name" => "I can't even fit a funny", + "replies" => %{"totalItems" => 1, "type" => "Collection"}, + "type" => "Note" + } + ]) + end + + test "returns an error if received a second time" do + data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() + + assert {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + + assert {:error, {:validate_object, {:error, _}}} = Transmogrifier.handle_incoming(data) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 62b5b06aa..272431135 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -217,42 +217,6 @@ test "it works for incoming notices with hashtags" do assert Enum.at(object.data["tag"], 2) == "moo" end - test "it works for incoming questions" do - data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() - - {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) - - object = Object.normalize(activity, false) - - assert object.data["closed"] == "2019-05-11T09:03:36Z" - - assert object.data["anyOf"] == [] - - assert Enum.sort(object.data["oneOf"]) == - Enum.sort([ - %{ - "name" => "25 char limit is dumb", - "replies" => %{"totalItems" => 0, "type" => "Collection"}, - "type" => "Note" - }, - %{ - "name" => "Dunno", - "replies" => %{"totalItems" => 0, "type" => "Collection"}, - "type" => "Note" - }, - %{ - "name" => "Everyone knows that!", - "replies" => %{"totalItems" => 1, "type" => "Collection"}, - "type" => "Note" - }, - %{ - "name" => "I can't even fit a funny", - "replies" => %{"totalItems" => 1, "type" => "Collection"}, - "type" => "Note" - } - ]) - end - test "it works for incoming listens" do data = %{ "@context" => "https://www.w3.org/ns/activitystreams", @@ -282,38 +246,6 @@ test "it works for incoming listens" do assert object.data["length"] == 180_000 end - test "it rewrites Note votes to Answer and increments vote counters on Question activities" do - user = insert(:user) - - {:ok, activity} = - CommonAPI.post(user, %{ - status: "suya...", - poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} - }) - - object = Object.normalize(activity) - - data = - File.read!("test/fixtures/mastodon-vote.json") - |> Poison.decode!() - |> Kernel.put_in(["to"], user.ap_id) - |> Kernel.put_in(["object", "inReplyTo"], object.data["id"]) - |> Kernel.put_in(["object", "to"], user.ap_id) - - {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) - answer_object = Object.normalize(activity) - assert answer_object.data["type"] == "Answer" - object = Object.get_by_ap_id(object.data["id"]) - - assert Enum.any?( - object.data["oneOf"], - fn - %{"name" => "suya..", "replies" => %{"totalItems" => 1}} -> true - _ -> false - end - ) - end - test "it works for incoming notices with contentMap" do data = File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() @@ -1280,30 +1212,6 @@ test "successfully reserializes a message with AS2 objects in IR" do end end - test "Rewrites Answers to Notes" do - user = insert(:user) - - {:ok, poll_activity} = - CommonAPI.post(user, %{ - status: "suya...", - poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} - }) - - poll_object = Object.normalize(poll_activity) - # TODO: Replace with CommonAPI vote creation when implemented - data = - File.read!("test/fixtures/mastodon-vote.json") - |> Poison.decode!() - |> Kernel.put_in(["to"], user.ap_id) - |> Kernel.put_in(["object", "inReplyTo"], poll_object.data["id"]) - |> Kernel.put_in(["object", "to"], user.ap_id) - - {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) - {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) - - assert data["object"]["type"] == "Note" - end - describe "fix_explicit_addressing" do setup do user = insert(:user) -- cgit v1.2.3 From fe6924d00d710e7e568d0ed40c02d3c516d91460 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 19 Jun 2020 23:43:36 +0200 Subject: CreateGenericValidator: add expires_at --- .../web/activity_pub/object_validators/create_generic_validator.ex | 1 + lib/pleroma/web/activity_pub/side_effects.ex | 2 ++ 2 files changed, 3 insertions(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 4ad4ca0de..f467ccc7c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -23,6 +23,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do field(:to, Types.Recipients, default: []) field(:cc, Types.Recipients, default: []) field(:object, Types.ObjectID) + field(:expires_at, Types.DateTime) end def cast_data(data) do diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index c17197bec..5104d38ee 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -139,6 +139,8 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Rollback if we couldn't create it # - Increase the user note count # - Increase the reply count + # - Increase replies count + # - Set up ActivityExpiration # - Set up notifications def handle(%{data: %{"type" => "Create"}} = activity, meta) do with {:ok, object, meta} <- handle_object_creation(meta[:object_data], meta), -- cgit v1.2.3 From 435a65b9768b9f2c614ba5e00d83753b5834a695 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sat, 20 Jun 2020 00:07:39 +0200 Subject: QuestionValidator: Use AttachmentValidator --- .../web/activity_pub/object_validators/question_validator.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index f7f3b1354..56f02777d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do use Ecto.Schema alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator alias Pleroma.Web.ActivityPub.ObjectValidators.Types @@ -34,8 +35,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do # TODO: Write type field(:emoji, :map, default: %{}) field(:sensitive, :boolean, default: false) - # TODO: Write type - field(:attachment, {:array, :map}, default: []) + embeds_many(:attachment, AttachmentValidator) field(:replies_count, :integer, default: 0) field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) @@ -99,7 +99,8 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf]) + |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf, :attachment]) + |> cast_embed(:attachment) |> cast_embed(:anyOf) |> cast_embed(:oneOf) end -- cgit v1.2.3 From d713930ea7fe78f705f534febe4ceaf0a0216d24 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sat, 20 Jun 2020 00:23:04 +0200 Subject: Fixup for EctoType module move --- .../activity_pub/object_validators/answer_validator.ex | 8 ++++---- .../object_validators/create_generic_validator.ex | 14 +++++++------- .../object_validators/question_validator.ex | 18 +++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index 8d4c92520..9861eec7f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do @derive Jason.Encoder embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) @@ -25,8 +25,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do field(:type, :string) field(:name, :string) field(:inReplyTo, :string) - field(:attributedTo, Types.ObjectID) - field(:actor, Types.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) end def cast_and_apply(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index f467ccc7c..97e2def10 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -8,8 +8,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do use Ecto.Schema + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object - alias Pleroma.Web.ActivityPub.ObjectValidators.Types import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -17,13 +17,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do @primary_key false embedded_schema do - field(:id, Types.ObjectID, primary_key: true) - field(:actor, Types.ObjectID) + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:actor, ObjectValidators.ObjectID) field(:type, :string) - field(:to, Types.Recipients, default: []) - field(:cc, Types.Recipients, default: []) - field(:object, Types.ObjectID) - field(:expires_at, Types.DateTime) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:object, ObjectValidators.ObjectID) + field(:expires_at, ObjectValidators.DateTime) end def cast_data(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 56f02777d..53cf35d40 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -5,11 +5,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do use Ecto.Schema - alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.Types + alias Pleroma.Web.ActivityPub.Utils import Ecto.Changeset @@ -18,7 +18,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do # Extends from NoteValidator embedded_schema do - field(:id, Types.ObjectID, primary_key: true) + field(:id, ObjectValidators.ObjectID, primary_key: true) field(:to, {:array, :string}, default: []) field(:cc, {:array, :string}, default: []) field(:bto, {:array, :string}, default: []) @@ -28,10 +28,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:type, :string) field(:content, :string) field(:context, :string) - field(:actor, Types.ObjectID) - field(:attributedTo, Types.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) field(:summary, :string) - field(:published, Types.DateTime) + field(:published, ObjectValidators.DateTime) # TODO: Write type field(:emoji, :map, default: %{}) field(:sensitive, :boolean, default: false) @@ -40,15 +40,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inReplyTo, :string) - field(:uri, Types.Uri) + field(:uri, ObjectValidators.Uri) # short identifier for PleromaFE to group statuses by context field(:context_id, :integer) field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) - field(:closed, Types.DateTime) - field(:voters, {:array, Types.ObjectID}, default: []) + field(:closed, ObjectValidators.DateTime) + field(:voters, {:array, ObjectValidators.ObjectID}, default: []) embeds_many(:anyOf, QuestionOptionsValidator) embeds_many(:oneOf, QuestionOptionsValidator) end -- cgit v1.2.3 From c19bdc811e526f83a2120c58f858044f4ff96e5f Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 23 Jun 2020 05:30:34 +0200 Subject: Fix attachments in polls --- .../object_validators/url_object_validator.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 12 ++- test/fixtures/tesla_mock/poll_attachment.json | 99 ++++++++++++++++++++++ test/object/fetcher_test.exs | 7 ++ test/support/http_request_mock.ex | 8 ++ test/web/activity_pub/transmogrifier_test.exs | 27 ++++-- 6 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/tesla_mock/poll_attachment.json diff --git a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex index f64fac46d..881030f38 100644 --- a/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/url_object_validator.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator do embedded_schema do field(:type, :string) field(:href, ObjectValidators.Uri) - field(:mediaType, :string) + field(:mediaType, :string, default: "application/octet-stream") end def changeset(struct, data) do diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 26325d5de..0ad982720 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -240,13 +240,17 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm if href do attachment_url = - %{"href" => href} + %{ + "href" => href, + "type" => Map.get(url || %{}, "type", "Link") + } |> Maps.put_if_present("mediaType", media_type) - |> Maps.put_if_present("type", Map.get(url || %{}, "type")) - %{"url" => [attachment_url]} + %{ + "url" => [attachment_url], + "type" => data["type"] || "Document" + } |> Maps.put_if_present("mediaType", media_type) - |> Maps.put_if_present("type", data["type"]) |> Maps.put_if_present("name", data["name"]) else nil diff --git a/test/fixtures/tesla_mock/poll_attachment.json b/test/fixtures/tesla_mock/poll_attachment.json new file mode 100644 index 000000000..92e822dc8 --- /dev/null +++ b/test/fixtures/tesla_mock/poll_attachment.json @@ -0,0 +1,99 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://patch.cx/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "actor": "https://patch.cx/users/rin", + "anyOf": [], + "attachment": [ + { + "mediaType": "image/jpeg", + "name": "screenshot_mpv:Totoro@01:18:44.345.jpg", + "type": "Document", + "url": "https://shitposter.club/media/3bb4c4d402f8fdcc7f80963c3d7cf6f10f936897fd374922ade33199d2f86d87.jpg?name=screenshot_mpv%3ATotoro%4001%3A18%3A44.345.jpg" + } + ], + "attributedTo": "https://patch.cx/users/rin", + "cc": [ + "https://patch.cx/users/rin/followers" + ], + "closed": "2020-06-19T23:22:02.754678Z", + "content": "@rinpatch", + "closed": "2019-09-19T00:32:36.785333", + "content": "can you vote on this poll?", + "id": "https://patch.cx/objects/tesla_mock/poll_attachment", + "oneOf": [ + { + "name": "a", + "replies": { + "totalItems": 0, + "type": "Collection" + }, + "type": "Note" + }, + { + "name": "A", + "replies": { + "totalItems": 0, + "type": "Collection" + }, + "type": "Note" + }, + { + "name": "Aa", + "replies": { + "totalItems": 0, + "type": "Collection" + }, + "type": "Note" + }, + { + "name": "AA", + "replies": { + "totalItems": 0, + "type": "Collection" + }, + "type": "Note" + }, + { + "name": "AAa", + "replies": { + "totalItems": 1, + "type": "Collection" + }, + "type": "Note" + }, + { + "name": "AAA", + "replies": { + "totalItems": 3, + "type": "Collection" + }, + "type": "Note" + } + ], + "published": "2020-06-19T23:12:02.786113Z", + "sensitive": false, + "summary": "", + "tag": [ + { + "href": "https://mastodon.sdf.org/users/rinpatch", + "name": "@rinpatch@mastodon.sdf.org", + "type": "Mention" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://mastodon.sdf.org/users/rinpatch" + ], + "type": "Question", + "voters": [ + "https://shitposter.club/users/moonman", + "https://skippers-bin.com/users/7v1w1r8ce6", + "https://mastodon.sdf.org/users/rinpatch", + "https://mastodon.social/users/emelie" + ] +} diff --git a/test/object/fetcher_test.exs b/test/object/fetcher_test.exs index d9098ea1b..16cfa7f5c 100644 --- a/test/object/fetcher_test.exs +++ b/test/object/fetcher_test.exs @@ -177,6 +177,13 @@ test "handle HTTP 404 response" do "https://mastodon.example.org/users/userisgone404" ) end + + test "it can fetch pleroma polls with attachments" do + {:ok, object} = + Fetcher.fetch_object_from_id("https://patch.cx/objects/tesla_mock/poll_attachment") + + assert object + end end describe "pruning" do diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 19a202654..eeeba7880 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -82,6 +82,14 @@ def get("https://mastodon.sdf.org/users/rinpatch", _, _, _) do }} end + def get("https://patch.cx/objects/tesla_mock/poll_attachment", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/poll_attachment.json") + }} + end + def get( "https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/emelie", _, diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 272431135..7269e81bb 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -620,7 +620,8 @@ test "it remaps video URLs as attachments if necessary" do %{ "href" => "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", - "mediaType" => "video/mp4" + "mediaType" => "video/mp4", + "type" => "Link" } ] } @@ -639,7 +640,8 @@ test "it remaps video URLs as attachments if necessary" do %{ "href" => "https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4", - "mediaType" => "video/mp4" + "mediaType" => "video/mp4", + "type" => "Link" } ] } @@ -1459,8 +1461,13 @@ test "returns modified object when attachment is map" do "attachment" => [ %{ "mediaType" => "video/mp4", + "type" => "Document", "url" => [ - %{"href" => "https://peertube.moe/stat-480.mp4", "mediaType" => "video/mp4"} + %{ + "href" => "https://peertube.moe/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } ] } ] @@ -1477,14 +1484,24 @@ test "returns modified object when attachment is list" do "attachment" => [ %{ "mediaType" => "video/mp4", + "type" => "Document", "url" => [ - %{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"} + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } ] }, %{ "mediaType" => "video/mp4", + "type" => "Document", "url" => [ - %{"href" => "https://pe.er/stat-480.mp4", "mediaType" => "video/mp4"} + %{ + "href" => "https://pe.er/stat-480.mp4", + "mediaType" => "video/mp4", + "type" => "Link" + } ] } ] -- cgit v1.2.3 From bfe2dafd398114240fcc2d3472799d6770904f6a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 26 Jun 2020 00:07:43 +0200 Subject: {Answer,Question}Validator: Keep both actor and attributedTo for now but sync them --- lib/pleroma/web/activity_pub/builder.ex | 1 + .../activity_pub/object_validators/answer_validator.ex | 8 ++++++-- .../object_validators/common_validations.ex | 18 ++++++++++++++++++ .../object_validators/create_generic_validator.ex | 6 +++--- .../object_validators/question_validator.ex | 6 +++++- lib/pleroma/web/activity_pub/transmogrifier.ex | 7 ++++++- 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index e97381954..49ce5a938 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -120,6 +120,7 @@ def answer(user, object, name) do %{ "type" => "Answer", "actor" => user.ap_id, + "attributedTo" => user.ap_id, "cc" => [object.data["actor"]], "to" => [], "name" => name, diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index 9861eec7f..ebddd5038 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -26,6 +26,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do field(:name, :string) field(:inReplyTo, :string) field(:attributedTo, ObjectValidators.ObjectID) + + # TODO: Remove actor on objects field(:actor, ObjectValidators.ObjectID) end @@ -54,8 +56,10 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Answer"]) - |> validate_required([:id, :inReplyTo, :name]) + |> validate_required([:id, :inReplyTo, :name, :attributedTo, :actor]) |> CommonValidations.validate_any_presence([:cc, :to]) - |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_is_active() + |> CommonValidations.validate_host_match() end end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 140555a45..e981dacaa 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -115,4 +115,22 @@ def validate_host_match(cng, fields \\ [:id, :actor]) do end) end end + + def validate_fields_match(cng, fields) do + unique_fields = + fields + |> Enum.map(fn field -> get_field(cng, field) end) + |> Enum.uniq() + |> Enum.count() + + if unique_fields == 1 do + cng + else + fields + |> Enum.reduce(cng, fn field, cng -> + cng + |> add_error(field, "Fields #{inspect(fields)} aren't matching") + end) + end + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 97e2def10..54ea14f89 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -87,14 +87,14 @@ def validate_object_nonexistence(cng) do end def validate_actors_match(cng, meta) do - object_actor = meta[:object_data]["actor"] + attributed_to = meta[:object_data]["attributedTo"] || meta[:object_data]["actor"] cng |> validate_change(:actor, fn :actor, actor -> - if actor == object_actor do + if actor == attributed_to do [] else - [{:actor, "Actor doesn't match with object actor"}] + [{:actor, "Actor doesn't match with object attributedTo"}] end end) end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 53cf35d40..466b3e6c2 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -28,7 +28,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:type, :string) field(:content, :string) field(:context, :string) + + # TODO: Remove actor on objects field(:actor, ObjectValidators.ObjectID) + field(:attributedTo, ObjectValidators.ObjectID) field(:summary, :string) field(:published, ObjectValidators.DateTime) @@ -108,8 +111,9 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Question"]) - |> validate_required([:id, :actor, :type, :content, :context]) + |> validate_required([:id, :actor, :attributedTo, :type, :content, :context]) |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) |> CommonValidations.validate_actor_is_active() |> CommonValidations.validate_any_presence([:oneOf, :anyOf]) |> CommonValidations.validate_host_match() diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0ad982720..6ab8a52c1 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -157,7 +157,12 @@ def fix_addressing(object) do end def fix_actor(%{"attributedTo" => actor} = object) do - Map.put(object, "actor", Containment.get_actor(%{"actor" => actor})) + actor = Containment.get_actor(%{"actor" => actor}) + + # TODO: Remove actor field for Objects + object + |> Map.put("actor", actor) + |> Map.put("attributedTo", actor) end def fix_in_reply_to(object, options \\ []) -- cgit v1.2.3 From 922ca232988b90b7a4fb5918bb76c383c90fd770 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 2 Jul 2020 05:47:18 +0200 Subject: Question: Add tests on HTML tags in options Closes: https://git.pleroma.social/pleroma/pleroma/-/issues/1362 --- .../transmogrifier/question_handling_test.exs | 35 ++++++++++++++++++++++ test/web/mastodon_api/views/poll_view_test.exs | 29 ++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/test/web/activity_pub/transmogrifier/question_handling_test.exs b/test/web/activity_pub/transmogrifier/question_handling_test.exs index b7b9a1a7b..fba8106b5 100644 --- a/test/web/activity_pub/transmogrifier/question_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/question_handling_test.exs @@ -55,6 +55,41 @@ test "Mastodon Question activity" do ]) end + test "Mastodon Question activity with HTML tags in plaintext" do + options = [ + %{ + "type" => "Note", + "name" => "", + "replies" => %{"totalItems" => 0, "type" => "Collection"} + }, + %{ + "type" => "Note", + "name" => "", + "replies" => %{"totalItems" => 0, "type" => "Collection"} + }, + %{ + "type" => "Note", + "name" => "", + "replies" => %{"totalItems" => 1, "type" => "Collection"} + }, + %{ + "type" => "Note", + "name" => "", + "replies" => %{"totalItems" => 1, "type" => "Collection"} + } + ] + + data = + File.read!("test/fixtures/mastodon-question-activity.json") + |> Poison.decode!() + |> Kernel.put_in(["object", "oneOf"], options) + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + object = Object.normalize(activity, false) + + assert Enum.sort(object.data["oneOf"]) == Enum.sort(options) + end + test "returns an error if received a second time" do data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() diff --git a/test/web/mastodon_api/views/poll_view_test.exs b/test/web/mastodon_api/views/poll_view_test.exs index 76672f36c..b7e2f17ef 100644 --- a/test/web/mastodon_api/views/poll_view_test.exs +++ b/test/web/mastodon_api/views/poll_view_test.exs @@ -135,4 +135,33 @@ test "does not crash on polls with no end date" do assert result[:expires_at] == nil assert result[:expired] == false end + + test "doesn't strips HTML tags" do + user = insert(:user) + + {:ok, activity} = + CommonAPI.post(user, %{ + status: "What's with the smug face?", + poll: %{ + options: [ + "", + "", + "", + "" + ], + expires_in: 20 + } + }) + + object = Object.normalize(activity) + + assert %{ + options: [ + %{title: "", votes_count: 0}, + %{title: "", votes_count: 0}, + %{title: "", votes_count: 0}, + %{title: "", votes_count: 0} + ] + } = PollView.render("show.json", %{object: object}) + end end -- cgit v1.2.3 From e4beff90f5670876184b2593c1b4a49f2339d048 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 2 Jul 2020 05:45:19 +0200 Subject: Create Question: Add context field to create --- lib/pleroma/web/activity_pub/builder.ex | 10 +++++++++- .../object_validators/create_generic_validator.ex | 17 +++++++++++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 2 ++ .../transmogrifier/question_handling_test.exs | 14 ++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 49ce5a938..1b4c421b8 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -80,6 +80,13 @@ def delete(actor, object_id) do end def create(actor, object, recipients) do + context = + if is_map(object) do + object["context"] + else + nil + end + {:ok, %{ "id" => Utils.generate_activity_id(), @@ -88,7 +95,8 @@ def create(actor, object, recipients) do "object" => object, "type" => "Create", "published" => DateTime.utc_now() |> DateTime.to_iso8601() - }, []} + } + |> Pleroma.Maps.put_if_present("context", context), []} end def chat_message(actor, recipient, content, opts \\ []) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 54ea14f89..ff889330e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -24,6 +24,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do field(:cc, ObjectValidators.Recipients, default: []) field(:object, ObjectValidators.ObjectID) field(:expires_at, ObjectValidators.DateTime) + + # Should be moved to object, done for CommonAPI.Utils.make_context + field(:context, :string) end def cast_data(data) do @@ -55,6 +58,7 @@ def validate_data(cng, meta \\ []) do |> validate_actor_is_active() |> validate_any_presence([:to, :cc]) |> validate_actors_match(meta) + |> validate_context_match(meta) |> validate_object_nonexistence() |> validate_object_containment() end @@ -98,4 +102,17 @@ def validate_actors_match(cng, meta) do end end) end + + def validate_context_match(cng, %{object_data: %{"context" => object_context}}) do + cng + |> validate_change(:context, fn :context, context -> + if context == object_context do + [] + else + [{:context, "context field not matching between Create and object (#{object_context})"}] + end + end) + end + + def validate_context_match(cng, _), do: cng end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 6ab8a52c1..edabe1130 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -643,6 +643,8 @@ def handle_incoming( |> Map.put("object", fix_object(object)) |> fix_addressing() + data = Map.put_new(data, "context", data["object"]["context"]) + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} diff --git a/test/web/activity_pub/transmogrifier/question_handling_test.exs b/test/web/activity_pub/transmogrifier/question_handling_test.exs index fba8106b5..12516c4ab 100644 --- a/test/web/activity_pub/transmogrifier/question_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/question_handling_test.exs @@ -8,6 +8,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -23,6 +26,8 @@ test "Mastodon Question activity" do assert object.data["closed"] == "2019-05-11T09:03:36Z" + assert object.data["context"] == activity.data["context"] + assert object.data["context"] == "tag:mastodon.sdf.org,2019-05-10:objectId=15095122:objectType=Conversation" @@ -53,6 +58,15 @@ test "Mastodon Question activity" do "type" => "Note" } ]) + + user = insert(:user) + + {:ok, reply_activity} = CommonAPI.post(user, %{status: "hewwo", in_reply_to_id: activity.id}) + + reply_object = Object.normalize(reply_activity, false) + + assert reply_object.data["context"] == object.data["context"] + assert reply_object.data["context_id"] == object.data["context_id"] end test "Mastodon Question activity with HTML tags in plaintext" do -- cgit v1.2.3 From 58a4f350a8bc361d793cb96442f856362c18f195 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 6 May 2020 01:51:10 +0300 Subject: Refactor gun pooling and simplify adapter option insertion This patch refactors gun pooling to use Elixir process registry and simplifies adapter option insertion. Having the pool use process registry instead of a GenServer has a number of advantages: - Simpler code: the initial implementation adds about half the lines of code it deletes - Concurrency: unlike a GenServer, ETS-based registry can handle multiple checkout/checkin requests at the same time - Precise and easy idle connection clousure: current proposal for closing idle connections in the GenServer-based pool needs to filter through all connections once a minute and compare their last active time with closing time. With Elixir process registry this can be done by just using `Process.send_after`/`Process.cancel_timer` in the worker process. - Lower memory footprint: In my tests `gun-memory-leak` branch uses about 290mb on peak load (250 connections) and 235mb on idle (5-10 connections). Registry-based pool uses 210mb on idle and 240mb on peak load --- config/config.exs | 2 + lib/pleroma/application.ex | 8 +- lib/pleroma/gun/conn.ex | 78 +------- lib/pleroma/gun/connection_pool.ex | 129 +++++++++++++ lib/pleroma/gun/connection_pool/worker.ex | 95 ++++++++++ lib/pleroma/http/adapter_helper.ex | 133 ++++++++++++-- lib/pleroma/http/adapter_helper/default.ex | 17 ++ lib/pleroma/http/adapter_helper/gun.ex | 32 +--- lib/pleroma/http/adapter_helper/hackney.ex | 3 + lib/pleroma/http/connection.ex | 124 ------------- lib/pleroma/http/http.ex | 53 ++---- lib/pleroma/pool/connections.ex | 283 ----------------------------- lib/pleroma/pool/pool.ex | 22 --- lib/pleroma/pool/request.ex | 65 ------- lib/pleroma/pool/supervisor.ex | 42 ----- lib/pleroma/reverse_proxy/client/tesla.ex | 2 +- 16 files changed, 402 insertions(+), 686 deletions(-) create mode 100644 lib/pleroma/gun/connection_pool.ex create mode 100644 lib/pleroma/gun/connection_pool/worker.ex create mode 100644 lib/pleroma/http/adapter_helper/default.ex delete mode 100644 lib/pleroma/http/connection.ex delete mode 100644 lib/pleroma/pool/connections.ex delete mode 100644 lib/pleroma/pool/pool.ex delete mode 100644 lib/pleroma/pool/request.ex delete mode 100644 lib/pleroma/pool/supervisor.ex diff --git a/config/config.exs b/config/config.exs index 6fc84efc2..577ccc198 100644 --- a/config/config.exs +++ b/config/config.exs @@ -647,8 +647,10 @@ prepare: :unnamed config :pleroma, :connections_pool, + reclaim_multiplier: 0.1, checkin_timeout: 250, max_connections: 250, + max_idle_time: 30_000, retry: 1, retry_timeout: 1000, await_up_timeout: 5_000 diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 3282c6882..be14c1f9f 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -223,9 +223,7 @@ defp task_children(_) do # start hackney and gun pools in tests 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] + http_children(Tesla.Adapter.Hackney, nil) ++ http_children(Tesla.Adapter.Gun, nil) end defp http_children(Tesla.Adapter.Hackney, _) do @@ -244,7 +242,9 @@ defp http_children(Tesla.Adapter.Hackney, _) do end end - defp http_children(Tesla.Adapter.Gun, _), do: [Pleroma.Pool.Supervisor] + defp http_children(Tesla.Adapter.Gun, _) do + [{Registry, keys: :unique, name: Pleroma.Gun.ConnectionPool}] + end defp http_children(_, _), do: [] end diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index cd25a2e74..77f78c7ff 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -3,40 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Gun.Conn do - @moduledoc """ - Struct for gun connection data - """ alias Pleroma.Gun - alias Pleroma.Pool.Connections require Logger - @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 - - @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 + def open(%URI{} = uri, opts) do pool_opts = Pleroma.Config.get([:connections_pool], []) opts = @@ -45,30 +16,10 @@ 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) + |> Map.put_new(:supervise, false) |> maybe_add_tls_opts(uri) - key = "#{uri.scheme}:#{uri.host}:#{uri.port}" - - max_connections = pool_opts[:max_connections] || 250 - - conn_pid = - if Connections.count(name) < max_connections do - do_open(uri, opts) - else - close_least_used_and_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 = Gun.set_owner(conn_pid, Process.whereis(name)) - Connections.add_conn(name, key, conn) - end + do_open(uri, opts) end defp maybe_add_tls_opts(opts, %URI{scheme: "http"}), do: opts @@ -81,7 +32,7 @@ defp maybe_add_tls_opts(opts, %URI{scheme: "https", host: host}) do reuse_sessions: false, verify_fun: {&:ssl_verify_hostname.verify_fun/3, - [check_hostname: Pleroma.HTTP.Connection.format_host(host)]} + [check_hostname: Pleroma.HTTP.AdapterHelper.format_host(host)]} ] tls_opts = @@ -105,7 +56,7 @@ defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]), stream <- Gun.connect(conn, connect_opts), {:response, :fin, 200, _} <- Gun.await(conn, stream) do - conn + {:ok, conn} else error -> Logger.warn( @@ -141,7 +92,7 @@ defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do with {:ok, conn} <- Gun.open(proxy_host, proxy_port, opts), {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]) do - conn + {:ok, conn} else error -> Logger.warn( @@ -155,11 +106,11 @@ 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 - host = Pleroma.HTTP.Connection.parse_host(host) + host = Pleroma.HTTP.AdapterHelper.parse_host(host) with {:ok, conn} <- Gun.open(host, port, opts), {:ok, _} <- Gun.await_up(conn, opts[:await_up_timeout]) do - conn + {:ok, conn} else error -> Logger.warn( @@ -171,7 +122,7 @@ defp do_open(%URI{host: host, port: port} = uri, opts) do end defp destination_opts(%URI{host: host, port: port}) do - host = Pleroma.HTTP.Connection.parse_host(host) + host = Pleroma.HTTP.AdapterHelper.parse_host(host) %{host: host, port: port} end @@ -181,17 +132,6 @@ 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 - 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 diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex new file mode 100644 index 000000000..e6abee69c --- /dev/null +++ b/lib/pleroma/gun/connection_pool.ex @@ -0,0 +1,129 @@ +defmodule Pleroma.Gun.ConnectionPool do + @registry __MODULE__ + + def get_conn(uri, opts) do + case enforce_pool_limits() do + :ok -> + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + + case Registry.lookup(@registry, key) do + # The key has already been registered, but connection is not up yet + [{worker_pid, {nil, _used_by, _crf, _last_reference}}] -> + get_gun_pid_from_worker(worker_pid) + + [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> + GenServer.cast(worker_pid, {:add_client, self(), false}) + {:ok, gun_pid} + + [] -> + # :gun.set_owner fails in :connected state for whatevever reason, + # so we open the connection in the process directly and send it's pid back + # We trust gun to handle timeouts by itself + case GenServer.start(Pleroma.Gun.ConnectionPool.Worker, [uri, key, opts, self()], + timeout: :infinity + ) do + {:ok, _worker_pid} -> + receive do + {:conn_pid, pid} -> {:ok, pid} + end + + {:error, {:error, {:already_registered, worker_pid}}} -> + get_gun_pid_from_worker(worker_pid) + + err -> + err + end + end + + :error -> + {:error, :pool_full} + end + end + + @enforcer_key "enforcer" + defp enforce_pool_limits() do + max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) + + if Registry.count(@registry) >= max_connections do + case Registry.lookup(@registry, @enforcer_key) do + [] -> + pid = + spawn(fn -> + {:ok, _pid} = Registry.register(@registry, @enforcer_key, nil) + + reclaim_max = + [:connections_pool, :reclaim_multiplier] + |> Pleroma.Config.get() + |> Kernel.*(max_connections) + |> round + |> max(1) + + unused_conns = + Registry.select( + @registry, + [ + {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], + [{{:"$1", :"$3", :"$4"}}]} + ] + ) + + case unused_conns do + [] -> + exit(:pool_full) + + unused_conns -> + unused_conns + |> Enum.sort(fn {_pid1, crf1, last_reference1}, + {_pid2, crf2, last_reference2} -> + crf1 <= crf2 and last_reference1 <= last_reference2 + end) + |> Enum.take(reclaim_max) + |> Enum.each(fn {pid, _, _} -> GenServer.call(pid, :idle_close) end) + end + end) + + wait_for_enforcer_finish(pid) + + [{pid, _}] -> + wait_for_enforcer_finish(pid) + end + else + :ok + end + end + + defp wait_for_enforcer_finish(pid) do + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, :pool_full} -> + :error + + {:DOWN, ^ref, :process, ^pid, :normal} -> + :ok + end + end + + defp get_gun_pid_from_worker(worker_pid) do + # GenServer.call will block the process for timeout length if + # the server crashes on startup (which will happen if gun fails to connect) + # so instead we use cast + monitor + + ref = Process.monitor(worker_pid) + GenServer.cast(worker_pid, {:add_client, self(), true}) + + receive do + {:conn_pid, pid} -> {:ok, pid} + {:DOWN, ^ref, :process, ^worker_pid, reason} -> reason + end + end + + def release_conn(conn_pid) do + [worker_pid] = + Registry.select(@registry, [ + {{:_, :"$1", {:"$2", :_, :_, :_}}, [{:==, :"$2", conn_pid}], [:"$1"]} + ]) + + GenServer.cast(worker_pid, {:remove_client, self()}) + end +end diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex new file mode 100644 index 000000000..ebde4bbf6 --- /dev/null +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -0,0 +1,95 @@ +defmodule Pleroma.Gun.ConnectionPool.Worker do + alias Pleroma.Gun + use GenServer + + @registry Pleroma.Gun.ConnectionPool + + @impl true + def init([uri, key, opts, client_pid]) do + time = :os.system_time(:second) + # Register before opening connection to prevent race conditions + with {:ok, _owner} <- Registry.register(@registry, key, {nil, [client_pid], 1, time}), + {:ok, conn_pid} <- Gun.Conn.open(uri, opts), + Process.link(conn_pid) do + {_, _} = + Registry.update_value(@registry, key, fn {_, used_by, crf, last_reference} -> + {conn_pid, used_by, crf, last_reference} + end) + + send(client_pid, {:conn_pid, conn_pid}) + {:ok, %{key: key, timer: nil}, :hibernate} + else + err -> {:stop, err} + end + end + + @impl true + def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) do + time = :os.system_time(:second) + + {{conn_pid, _, _, _}, _} = + Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> + {conn_pid, [client_pid | used_by], crf(time - last_reference, crf), time} + end) + + if send_pid_back, do: send(client_pid, {:conn_pid, conn_pid}) + + state = + if state.timer != nil do + Process.cancel_timer(state[:timer]) + %{state | timer: nil} + else + state + end + + {:noreply, state, :hibernate} + end + + @impl true + def handle_cast({:remove_client, client_pid}, %{key: key} = state) do + {{_conn_pid, used_by, _crf, _last_reference}, _} = + Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> + {conn_pid, List.delete(used_by, client_pid), crf, last_reference} + end) + + timer = + if used_by == [] do + max_idle = Pleroma.Config.get([:connections_pool, :max_idle_time], 30_000) + Process.send_after(self(), :idle_close, max_idle) + else + nil + end + + {:noreply, %{state | timer: timer}, :hibernate} + end + + @impl true + def handle_info(:idle_close, state) do + # Gun monitors the owner process, and will close the connection automatically + # when it's terminated + {:stop, :normal, state} + end + + # Gracefully shutdown if the connection got closed without any streams left + @impl true + def handle_info({:gun_down, _pid, _protocol, _reason, []}, state) do + {:stop, :normal, state} + end + + # Otherwise, shutdown with an error + @impl true + def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams} = down_message, state) do + {:stop, {:error, down_message}, state} + end + + @impl true + def handle_call(:idle_close, _, %{key: key} = state) do + Registry.unregister(@registry, key) + {:stop, :normal, state} + end + + # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 + defp crf(time_delta, prev_crf) do + 1 + :math.pow(0.5, time_delta / 100) * prev_crf + end +end diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 510722ff9..0532ea31d 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -3,7 +3,21 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.HTTP.AdapterHelper do - alias Pleroma.HTTP.Connection + @moduledoc """ + Configure Tesla.Client with default and customized adapter options. + """ + @defaults [pool: :federation] + + @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() + + alias Pleroma.Config + alias Pleroma.HTTP.AdapterHelper + require Logger @type proxy :: {Connection.host(), pos_integer()} @@ -11,24 +25,13 @@ defmodule Pleroma.HTTP.AdapterHelper do @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 + @callback get_conn(URI.t(), keyword()) :: {:ok, term()} | {:error, term()} @spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil def format_proxy(nil), do: nil def format_proxy(proxy_url) do - case Connection.parse_proxy(proxy_url) do + case parse_proxy(proxy_url) do {:ok, host, port} -> {host, port} {:ok, type, host, port} -> {type, host, port} _ -> nil @@ -38,4 +41,106 @@ 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) + + @doc """ + Merge default connection & adapter options with received ones. + """ + + @spec options(URI.t(), keyword()) :: keyword() + def options(%URI{} = uri, opts \\ []) do + @defaults + |> pool_timeout() + |> Keyword.merge(opts) + |> adapter_helper().options(uri) + end + + defp pool_timeout(opts) do + {config_key, default} = + if 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 + + @spec after_request(keyword()) :: :ok + def after_request(opts), do: adapter_helper().after_request(opts) + + def get_conn(uri, opts), do: adapter_helper().get_conn(uri, opts) + 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.Default + 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 failed #{inspect(proxy)}") + {:error, :invalid_proxy_port} + + :error -> + Logger.warn("Parsing port failed #{inspect(proxy)}") + {:error, :invalid_proxy_port} + + _ -> + Logger.warn("Parsing proxy failed #{inspect(proxy)}") + {:error, :invalid_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 failed #{inspect(proxy)}") + {:error, :invalid_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 + + @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/adapter_helper/default.ex b/lib/pleroma/http/adapter_helper/default.ex new file mode 100644 index 000000000..218cfacc0 --- /dev/null +++ b/lib/pleroma/http/adapter_helper/default.ex @@ -0,0 +1,17 @@ +defmodule Pleroma.HTTP.AdapterHelper.Default do + alias Pleroma.HTTP.AdapterHelper + + @behaviour Pleroma.HTTP.AdapterHelper + + @spec options(keyword(), URI.t()) :: keyword() + def options(opts, _uri) do + proxy = Pleroma.Config.get([:http, :proxy_url], nil) + AdapterHelper.maybe_add_proxy(opts, AdapterHelper.format_proxy(proxy)) + end + + @spec after_request(keyword()) :: :ok + def after_request(_opts), do: :ok + + @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} + def get_conn(_uri, opts), do: {:ok, opts} +end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index ead7cdc6b..6f7cc9784 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -5,8 +5,8 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do @behaviour Pleroma.HTTP.AdapterHelper + alias Pleroma.Gun.ConnectionPool alias Pleroma.HTTP.AdapterHelper - alias Pleroma.Pool.Connections require Logger @@ -31,13 +31,13 @@ def options(incoming_opts \\ [], %URI{} = uri) do |> Keyword.merge(config_opts) |> add_scheme_opts(uri) |> AdapterHelper.maybe_add_proxy(proxy) - |> maybe_get_conn(uri, incoming_opts) + |> Keyword.merge(incoming_opts) end @spec after_request(keyword()) :: :ok def after_request(opts) do if opts[:conn] && opts[:body_as] != :chunks do - Connections.checkout(opts[:conn], self(), :gun_connections) + ConnectionPool.release_conn(opts[:conn]) end :ok @@ -51,27 +51,11 @@ defp add_scheme_opts(opts, %{scheme: "https"}) do |> Keyword.put(:tls_opts, log_level: :warning) end - defp maybe_get_conn(adapter_opts, uri, incoming_opts) do - {receive_conn?, opts} = - adapter_opts - |> Keyword.merge(incoming_opts) - |> Keyword.pop(:receive_conn, true) - - if Connections.alive?(:gun_connections) and receive_conn? do - checkin_conn(uri, opts) - else - opts - end - end - - defp checkin_conn(uri, opts) do - case Connections.checkin(uri, :gun_connections) do - nil -> - Task.start(Pleroma.Gun.Conn, :open, [uri, :gun_connections, opts]) - opts - - conn when is_pid(conn) -> - Keyword.merge(opts, conn: conn, close_conn: false) + @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} | {:error, atom()} + def get_conn(uri, opts) do + case ConnectionPool.get_conn(uri, opts) do + {:ok, conn_pid} -> {:ok, Keyword.merge(opts, conn: conn_pid, close_conn: false)} + err -> err end end end diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index 3972a03a9..42d552740 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -25,4 +25,7 @@ def options(connection_opts \\ [], %URI{} = uri) do defp add_scheme_opts(opts, _), do: opts def after_request(_), do: :ok + + @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} + def get_conn(_uri, opts), do: {:ok, opts} end diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex deleted file mode 100644 index ebacf7902..000000000 --- a/lib/pleroma/http/connection.ex +++ /dev/null @@ -1,124 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -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 :: - {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() - - @doc """ - Merge default connection & adapter options with received ones. - """ - - @spec options(URI.t(), keyword()) :: keyword() - def options(%URI{} = uri, opts \\ []) do - @defaults - |> pool_timeout() - |> Keyword.merge(opts) - |> adapter_helper().options(uri) - end - - defp pool_timeout(opts) do - {config_key, default} = - if 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 - - @spec after_request(keyword()) :: :ok - def after_request(opts), do: adapter_helper().after_request(opts) - - 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 - 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 failed #{inspect(proxy)}") - {:error, :invalid_proxy_port} - - :error -> - Logger.warn("Parsing port failed #{inspect(proxy)}") - {:error, :invalid_proxy_port} - - _ -> - Logger.warn("Parsing proxy failed #{inspect(proxy)}") - {:error, :invalid_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 failed #{inspect(proxy)}") - {:error, :invalid_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 - - @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/http.ex b/lib/pleroma/http/http.ex index 66ca75367..8ded76601 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -7,7 +7,7 @@ defmodule Pleroma.HTTP do Wrapper for `Tesla.request/2`. """ - alias Pleroma.HTTP.Connection + alias Pleroma.HTTP.AdapterHelper alias Pleroma.HTTP.Request alias Pleroma.HTTP.RequestBuilder, as: Builder alias Tesla.Client @@ -60,49 +60,26 @@ 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) - adapter_opts = Connection.options(uri, options[:adapter] || []) - options = put_in(options[:adapter], adapter_opts) - params = options[:params] || [] - request = build_request(method, headers, options, url, body, params) + adapter_opts = AdapterHelper.options(uri, options[:adapter] || []) - adapter = Application.get_env(:tesla, :adapter) - client = Tesla.client([Tesla.Middleware.FollowRedirects], adapter) + case AdapterHelper.get_conn(uri, adapter_opts) do + {:ok, adapter_opts} -> + options = put_in(options[:adapter], adapter_opts) + params = options[:params] || [] + request = build_request(method, headers, options, url, body, params) - pid = Process.whereis(adapter_opts[:pool]) + adapter = Application.get_env(:tesla, :adapter) + client = Tesla.client([Tesla.Middleware.FollowRedirects], adapter) - pool_alive? = - if adapter == Tesla.Adapter.Gun && pid do - Process.alive?(pid) - else - false - end + response = request(client, request) - request_opts = - adapter_opts - |> Enum.into(%{}) - |> Map.put(:env, Pleroma.Config.get([:env])) - |> Map.put(:pool_alive?, pool_alive?) + AdapterHelper.after_request(adapter_opts) - response = request(client, request, request_opts) + response - Connection.after_request(adapter_opts) - - response - end - - @spec request(Client.t(), keyword(), map()) :: {:ok, Env.t()} | {:error, any()} - def request(%Client{} = client, request, %{env: :test}), do: request(client, request) - - def request(%Client{} = client, request, %{body_as: :chunks}), do: request(client, request) - - def request(%Client{} = client, request, %{pool_alive?: false}), do: request(client, request) - - def request(%Client{} = client, request, %{pool: pool, timeout: timeout}) do - :poolboy.transaction( - pool, - &Pleroma.Pool.Request.execute(&1, client, request, timeout), - timeout - ) + err -> + err + end end @spec request(Client.t(), keyword()) :: {:ok, Env.t()} | {:error, any()} diff --git a/lib/pleroma/pool/connections.ex b/lib/pleroma/pool/connections.ex deleted file mode 100644 index acafe1bea..000000000 --- a/lib/pleroma/pool/connections.ex +++ /dev/null @@ -1,283 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Pool.Connections do - use GenServer - - alias Pleroma.Config - alias Pleroma.Gun - - require Logger - - @type domain :: String.t() - @type conn :: Pleroma.Gun.Conn.t() - - @type t :: %__MODULE__{ - conns: %{domain() => conn()}, - opts: keyword() - } - - defstruct conns: %{}, opts: [] - - @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 = Config.get([:connections_pool, :checkin_timeout], 250) - - GenServer.call(name, {:checkin, uri}, timeout) - end - - @spec alive?(atom()) :: boolean() - def alive?(name) do - if pid = Process.whereis(name) do - Process.alive?(pid) - else - false - end - end - - @spec get_state(atom()) :: t() - 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({:add_conn, key, conn}, state) do - state = put_in(state.conns[key], conn) - - Process.monitor(conn.conn) - {:noreply, state} - end - - @impl true - def handle_cast({:checkout, conn_pid, pid}, state) do - 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 - - put_in(state.conns[key], %{conn | conn_state: conn_state, used_by: used_by}) - else - false -> - Logger.debug("checkout for closed conn #{inspect(conn_pid)}") - state - - nil -> - Logger.debug("checkout for alive conn #{inspect(conn_pid)}, but is not in state") - state - end - - {: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 - key = "#{uri.scheme}:#{uri.host}:#{uri.port}" - - case state.conns[key] do - %{conn: pid, gun_state: :up} = conn -> - time = :os.system_time(:second) - last_reference = time - conn.last_reference - crf = crf(last_reference, 100, conn.crf) - - state = - put_in(state.conns[key], %{ - conn - | last_reference: time, - crf: crf, - conn_state: :active, - used_by: [from | conn.used_by] - }) - - {:reply, pid, state} - - %{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_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(&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 - %{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, - conn_state: :active, - retries: 0 - }) - else - {false, key} -> - put_in( - state.conns, - Map.delete(state.conns, key) - ) - - nil -> - :ok = Gun.close(conn_pid) - - state - end - - {:noreply, state} - end - - @impl true - def handle_info({:gun_down, conn_pid, _protocol, _reason, _killed}, state) do - 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), - {true, key} <- {Process.alive?(conn_pid), key} do - if conn.retries == retries do - :ok = Gun.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, key} -> - put_in( - state.conns, - Map.delete(state.conns, key) - ) - - nil -> - Logger.debug(":gun_down for conn which isn't found in state") - - state - end - - {:noreply, state} - end - - @impl true - def handle_info({:DOWN, _ref, :process, conn_pid, reason}, state) do - Logger.debug("received DOWN 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 for conn which isn't found in state") - - state - end - - {:noreply, state} - 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 - - def crf(current, steps, crf) do - 1 + :math.pow(0.5, current / steps) * crf - end -end diff --git a/lib/pleroma/pool/pool.ex b/lib/pleroma/pool/pool.ex deleted file mode 100644 index 21a6fbbc5..000000000 --- a/lib/pleroma/pool/pool.ex +++ /dev/null @@ -1,22 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 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 deleted file mode 100644 index 3fb930db7..000000000 --- a/lib/pleroma/pool/request.ex +++ /dev/null @@ -1,65 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 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(client, request) - - {:reply, response, state} - end - - @impl true - def handle_info({:gun_data, _conn, _stream, _, _}, state) do - {: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 - {:noreply, state} - end - - @impl true - def handle_info({:gun_error, _conn, _stream, _error}, state) do - {: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 deleted file mode 100644 index faf646cb2..000000000 --- a/lib/pleroma/pool/supervisor.ex +++ /dev/null @@ -1,42 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Pool.Supervisor do - use Supervisor - - alias Pleroma.Config - alias Pleroma.Pool - - def start_link(args) do - Supervisor.start_link(__MODULE__, args, name: __MODULE__) - end - - def init(_) do - conns_child = %{ - id: Pool.Connections, - start: - {Pool.Connections, :start_link, [{:gun_connections, Config.get([:connections_pool])}]} - } - - Supervisor.init([conns_child | pools()], strategy: :one_for_one) - end - - defp 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) - |> Pool.child_spec() - end - end -end diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index e81ea8bde..65785445d 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -48,7 +48,7 @@ def stream_body(%{pid: pid, opts: opts, fin: true}) do # 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) + if conn, do: :ok = Pleroma.Gun.ConnectionPool.release_conn(conn) :done end -- cgit v1.2.3 From fffbcffb8c9ce1e96de5d1a5e15005e271deacd4 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 6 May 2020 21:41:34 +0300 Subject: Connection Pool: don't enforce pool limits if no new connection needs to be opened --- lib/pleroma/gun/connection_pool.ex | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index e6abee69c..ed7ddff81 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -2,20 +2,20 @@ defmodule Pleroma.Gun.ConnectionPool do @registry __MODULE__ def get_conn(uri, opts) do - case enforce_pool_limits() do - :ok -> - key = "#{uri.scheme}:#{uri.host}:#{uri.port}" + key = "#{uri.scheme}:#{uri.host}:#{uri.port}" - case Registry.lookup(@registry, key) do - # The key has already been registered, but connection is not up yet - [{worker_pid, {nil, _used_by, _crf, _last_reference}}] -> - get_gun_pid_from_worker(worker_pid) + case Registry.lookup(@registry, key) do + # The key has already been registered, but connection is not up yet + [{worker_pid, {nil, _used_by, _crf, _last_reference}}] -> + get_gun_pid_from_worker(worker_pid) - [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> - GenServer.cast(worker_pid, {:add_client, self(), false}) - {:ok, gun_pid} + [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> + GenServer.cast(worker_pid, {:add_client, self(), false}) + {:ok, gun_pid} - [] -> + [] -> + case enforce_pool_limits() do + :ok -> # :gun.set_owner fails in :connected state for whatevever reason, # so we open the connection in the process directly and send it's pid back # We trust gun to handle timeouts by itself @@ -33,10 +33,10 @@ def get_conn(uri, opts) do err -> err end - end - :error -> - {:error, :pool_full} + :error -> + {:error, :pool_full} + end end end -- cgit v1.2.3 From d08b1576990ca33ac4178fb757ec03a777c55b5b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 6 May 2020 21:51:10 +0300 Subject: Connection pool: check that there actually is a result Sometimes connections died before being released to the pool, resulting in MatchErrors --- lib/pleroma/gun/connection_pool.ex | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index ed7ddff81..0daf1da44 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -119,11 +119,17 @@ defp get_gun_pid_from_worker(worker_pid) do end def release_conn(conn_pid) do - [worker_pid] = + query_result = Registry.select(@registry, [ {{:_, :"$1", {:"$2", :_, :_, :_}}, [{:==, :"$2", conn_pid}], [:"$1"]} ]) - GenServer.cast(worker_pid, {:remove_client, self()}) + case query_result do + [worker_pid] -> + GenServer.cast(worker_pid, {:remove_client, self()}) + + [] -> + :ok + end end end -- cgit v1.2.3 From ec9d0d146b4ec6752f8f2896ace9bb5585469773 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 6 May 2020 23:14:24 +0300 Subject: Connection pool: Fix race conditions in limit enforcement Fixes race conditions in limit enforcement by putting worker processes in a DynamicSupervisor --- lib/pleroma/application.ex | 2 +- lib/pleroma/gun/connection_pool.ex | 105 +++++---------------- lib/pleroma/gun/connection_pool/worker.ex | 12 +-- .../gun/connection_pool/worker_supervisor.ex | 91 ++++++++++++++++++ 4 files changed, 118 insertions(+), 92 deletions(-) create mode 100644 lib/pleroma/gun/connection_pool/worker_supervisor.ex diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index be14c1f9f..cfdaf1770 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -243,7 +243,7 @@ defp http_children(Tesla.Adapter.Hackney, _) do end defp http_children(Tesla.Adapter.Gun, _) do - [{Registry, keys: :unique, name: Pleroma.Gun.ConnectionPool}] + Pleroma.Gun.ConnectionPool.children() end defp http_children(_, _), do: [] diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index 0daf1da44..545bfaf7f 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -1,6 +1,15 @@ defmodule Pleroma.Gun.ConnectionPool do @registry __MODULE__ + alias Pleroma.Gun.ConnectionPool.WorkerSupervisor + + def children do + [ + {Registry, keys: :unique, name: @registry}, + Pleroma.Gun.ConnectionPool.WorkerSupervisor + ] + end + def get_conn(uri, opts) do key = "#{uri.scheme}:#{uri.host}:#{uri.port}" @@ -14,93 +23,21 @@ def get_conn(uri, opts) do {:ok, gun_pid} [] -> - case enforce_pool_limits() do - :ok -> - # :gun.set_owner fails in :connected state for whatevever reason, - # so we open the connection in the process directly and send it's pid back - # We trust gun to handle timeouts by itself - case GenServer.start(Pleroma.Gun.ConnectionPool.Worker, [uri, key, opts, self()], - timeout: :infinity - ) do - {:ok, _worker_pid} -> - receive do - {:conn_pid, pid} -> {:ok, pid} - end - - {:error, {:error, {:already_registered, worker_pid}}} -> - get_gun_pid_from_worker(worker_pid) - - err -> - err + # :gun.set_owner fails in :connected state for whatevever reason, + # so we open the connection in the process directly and send it's pid back + # We trust gun to handle timeouts by itself + case WorkerSupervisor.start_worker([uri, key, opts, self()]) do + {:ok, _worker_pid} -> + receive do + {:conn_pid, pid} -> {:ok, pid} end - :error -> - {:error, :pool_full} - end - end - end - - @enforcer_key "enforcer" - defp enforce_pool_limits() do - max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) - - if Registry.count(@registry) >= max_connections do - case Registry.lookup(@registry, @enforcer_key) do - [] -> - pid = - spawn(fn -> - {:ok, _pid} = Registry.register(@registry, @enforcer_key, nil) - - reclaim_max = - [:connections_pool, :reclaim_multiplier] - |> Pleroma.Config.get() - |> Kernel.*(max_connections) - |> round - |> max(1) - - unused_conns = - Registry.select( - @registry, - [ - {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], - [{{:"$1", :"$3", :"$4"}}]} - ] - ) + {:error, {:error, {:already_registered, worker_pid}}} -> + get_gun_pid_from_worker(worker_pid) - case unused_conns do - [] -> - exit(:pool_full) - - unused_conns -> - unused_conns - |> Enum.sort(fn {_pid1, crf1, last_reference1}, - {_pid2, crf2, last_reference2} -> - crf1 <= crf2 and last_reference1 <= last_reference2 - end) - |> Enum.take(reclaim_max) - |> Enum.each(fn {pid, _, _} -> GenServer.call(pid, :idle_close) end) - end - end) - - wait_for_enforcer_finish(pid) - - [{pid, _}] -> - wait_for_enforcer_finish(pid) - end - else - :ok - end - end - - defp wait_for_enforcer_finish(pid) do - ref = Process.monitor(pid) - - receive do - {:DOWN, ^ref, :process, ^pid, :pool_full} -> - :error - - {:DOWN, ^ref, :process, ^pid, :normal} -> - :ok + err -> + err + end end end diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index ebde4bbf6..25fafc64c 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -1,9 +1,13 @@ defmodule Pleroma.Gun.ConnectionPool.Worker do alias Pleroma.Gun - use GenServer + use GenServer, restart: :temporary @registry Pleroma.Gun.ConnectionPool + def start_link(opts) do + GenServer.start_link(__MODULE__, opts) + end + @impl true def init([uri, key, opts, client_pid]) do time = :os.system_time(:second) @@ -82,12 +86,6 @@ def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams} = down_me {:stop, {:error, down_message}, state} end - @impl true - def handle_call(:idle_close, _, %{key: key} = state) do - Registry.unregister(@registry, key) - {:stop, :normal, state} - end - # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 defp crf(time_delta, prev_crf) do 1 + :math.pow(0.5, time_delta / 100) * prev_crf diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex new file mode 100644 index 000000000..5b546bd87 --- /dev/null +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -0,0 +1,91 @@ +defmodule Pleroma.Gun.ConnectionPool.WorkerSupervisor do + @doc "Supervisor for pool workers. Does not do anything except enforce max connection limit" + + use DynamicSupervisor + + def start_link(opts) do + DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(_opts) do + DynamicSupervisor.init( + strategy: :one_for_one, + max_children: Pleroma.Config.get([:connections_pool, :max_connections]) + ) + end + + def start_worker(opts) do + case DynamicSupervisor.start_child(__MODULE__, {Pleroma.Gun.ConnectionPool.Worker, opts}) do + {:error, :max_children} -> + case free_pool() do + :ok -> start_worker(opts) + :error -> {:error, :pool_full} + end + + res -> + res + end + end + + @registry Pleroma.Gun.ConnectionPool + @enforcer_key "enforcer" + defp free_pool do + case Registry.lookup(@registry, @enforcer_key) do + [] -> + pid = + spawn(fn -> + {:ok, _pid} = Registry.register(@registry, @enforcer_key, nil) + + max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) + + reclaim_max = + [:connections_pool, :reclaim_multiplier] + |> Pleroma.Config.get() + |> Kernel.*(max_connections) + |> round + |> max(1) + + unused_conns = + Registry.select( + @registry, + [ + {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], + [{{:"$1", :"$3", :"$4"}}]} + ] + ) + + case unused_conns do + [] -> + exit(:no_unused_conns) + + unused_conns -> + unused_conns + |> Enum.sort(fn {_pid1, crf1, last_reference1}, {_pid2, crf2, last_reference2} -> + crf1 <= crf2 and last_reference1 <= last_reference2 + end) + |> Enum.take(reclaim_max) + |> Enum.each(fn {pid, _, _} -> + DynamicSupervisor.terminate_child(__MODULE__, pid) + end) + end + end) + + wait_for_enforcer_finish(pid) + + [{pid, _}] -> + wait_for_enforcer_finish(pid) + end + end + + defp wait_for_enforcer_finish(pid) do + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, :no_unused_conns} -> + :error + + {:DOWN, ^ref, :process, ^pid, :normal} -> + :ok + end + end +end -- cgit v1.2.3 From 0ffde499b8a8f31c82183253bdd692c75733ca2f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 15 Jul 2020 15:24:47 +0300 Subject: Connection Pool: register workers using :via --- lib/pleroma/gun/connection_pool.ex | 8 +++++--- lib/pleroma/gun/connection_pool/worker.ex | 17 ++++++++--------- lib/pleroma/gun/connection_pool/worker_supervisor.ex | 3 +-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index 545bfaf7f..e951872fe 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -15,7 +15,7 @@ def get_conn(uri, opts) do case Registry.lookup(@registry, key) do # The key has already been registered, but connection is not up yet - [{worker_pid, {nil, _used_by, _crf, _last_reference}}] -> + [{worker_pid, nil}] -> get_gun_pid_from_worker(worker_pid) [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> @@ -26,13 +26,13 @@ def get_conn(uri, opts) do # :gun.set_owner fails in :connected state for whatevever reason, # so we open the connection in the process directly and send it's pid back # We trust gun to handle timeouts by itself - case WorkerSupervisor.start_worker([uri, key, opts, self()]) do + case WorkerSupervisor.start_worker([key, uri, opts, self()]) do {:ok, _worker_pid} -> receive do {:conn_pid, pid} -> {:ok, pid} end - {:error, {:error, {:already_registered, worker_pid}}} -> + {:error, {:already_started, worker_pid}} -> get_gun_pid_from_worker(worker_pid) err -> @@ -56,6 +56,8 @@ defp get_gun_pid_from_worker(worker_pid) do end def release_conn(conn_pid) do + # :ets.fun2ms(fn {_, {worker_pid, {gun_pid, _, _, _}}} when gun_pid == conn_pid -> + # worker_pid end) query_result = Registry.select(@registry, [ {{:_, :"$1", {:"$2", :_, :_, :_}}, [{:==, :"$2", conn_pid}], [:"$1"]} diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 25fafc64c..0a94f16a2 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -4,20 +4,19 @@ defmodule Pleroma.Gun.ConnectionPool.Worker do @registry Pleroma.Gun.ConnectionPool - def start_link(opts) do - GenServer.start_link(__MODULE__, opts) + def start_link([key | _] = opts) do + GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {@registry, key}}) end @impl true - def init([uri, key, opts, client_pid]) do - time = :os.system_time(:second) - # Register before opening connection to prevent race conditions - with {:ok, _owner} <- Registry.register(@registry, key, {nil, [client_pid], 1, time}), - {:ok, conn_pid} <- Gun.Conn.open(uri, opts), + def init([key, uri, opts, client_pid]) do + with {:ok, conn_pid} <- Gun.Conn.open(uri, opts), Process.link(conn_pid) do + time = :os.system_time(:second) + {_, _} = - Registry.update_value(@registry, key, fn {_, used_by, crf, last_reference} -> - {conn_pid, used_by, crf, last_reference} + Registry.update_value(@registry, key, fn _ -> + {conn_pid, [client_pid], 1, time} end) send(client_pid, {:conn_pid, conn_pid}) diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex index 5b546bd87..d090c034e 100644 --- a/lib/pleroma/gun/connection_pool/worker_supervisor.ex +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -1,5 +1,5 @@ defmodule Pleroma.Gun.ConnectionPool.WorkerSupervisor do - @doc "Supervisor for pool workers. Does not do anything except enforce max connection limit" + @moduledoc "Supervisor for pool workers. Does not do anything except enforce max connection limit" use DynamicSupervisor @@ -35,7 +35,6 @@ defp free_pool do pid = spawn(fn -> {:ok, _pid} = Registry.register(@registry, @enforcer_key, nil) - max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) reclaim_max = -- cgit v1.2.3 From 7738fbbaf5a6fcd6a10b4ef0a2dcea731a3d4192 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 15 Jul 2020 15:26:25 +0300 Subject: Connection pool: implement logging and telemetry events --- lib/pleroma/application.ex | 1 + .../gun/connection_pool/worker_supervisor.ex | 44 ++++++++++++--- lib/pleroma/telemetry/logger.ex | 62 ++++++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 lib/pleroma/telemetry/logger.ex diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index cfdaf1770..37fcdf293 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -39,6 +39,7 @@ def start(_type, _args) do # every time the application is restarted, so we disable module # conflicts at runtime Code.compiler_options(ignore_module_conflict: true) + Pleroma.Telemetry.Logger.attach() Config.Holder.save_default() Pleroma.HTML.compile_scrubbers() Config.DeprecationWarnings.warn() diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex index d090c034e..4b5d10d2a 100644 --- a/lib/pleroma/gun/connection_pool/worker_supervisor.ex +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -18,8 +18,12 @@ def start_worker(opts) do case DynamicSupervisor.start_child(__MODULE__, {Pleroma.Gun.ConnectionPool.Worker, opts}) do {:error, :max_children} -> case free_pool() do - :ok -> start_worker(opts) - :error -> {:error, :pool_full} + :ok -> + start_worker(opts) + + :error -> + :telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts}) + {:error, :pool_full} end res -> @@ -44,6 +48,14 @@ defp free_pool do |> round |> max(1) + :telemetry.execute([:pleroma, :connection_pool, :reclaim, :start], %{}, %{ + max_connections: max_connections, + reclaim_max: reclaim_max + }) + + # :ets.fun2ms( + # fn {_, {worker_pid, {_, used_by, crf, last_reference}}} when used_by == [] -> + # {worker_pid, crf, last_reference} end) unused_conns = Registry.select( @registry, @@ -55,17 +67,35 @@ defp free_pool do case unused_conns do [] -> + :telemetry.execute( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: 0}, + %{ + max_connections: max_connections + } + ) + exit(:no_unused_conns) unused_conns -> - unused_conns - |> Enum.sort(fn {_pid1, crf1, last_reference1}, {_pid2, crf2, last_reference2} -> - crf1 <= crf2 and last_reference1 <= last_reference2 - end) - |> Enum.take(reclaim_max) + reclaimed = + unused_conns + |> Enum.sort(fn {_pid1, crf1, last_reference1}, + {_pid2, crf2, last_reference2} -> + crf1 <= crf2 and last_reference1 <= last_reference2 + end) + |> Enum.take(reclaim_max) + + reclaimed |> Enum.each(fn {pid, _, _} -> DynamicSupervisor.terminate_child(__MODULE__, pid) end) + + :telemetry.execute( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: Enum.count(reclaimed)}, + %{max_connections: max_connections} + ) end end) diff --git a/lib/pleroma/telemetry/logger.ex b/lib/pleroma/telemetry/logger.ex new file mode 100644 index 000000000..d76dd37b5 --- /dev/null +++ b/lib/pleroma/telemetry/logger.ex @@ -0,0 +1,62 @@ +defmodule Pleroma.Telemetry.Logger do + @moduledoc "Transforms Pleroma telemetry events to logs" + + require Logger + + @events [ + [:pleroma, :connection_pool, :reclaim, :start], + [:pleroma, :connection_pool, :reclaim, :stop], + [:pleroma, :connection_pool, :provision_failure] + ] + def attach do + :telemetry.attach_many("pleroma-logger", @events, &handle_event/4, []) + end + + # Passing anonymous functions instead of strings to logger is intentional, + # that way strings won't be concatenated if the message is going to be thrown + # out anyway due to higher log level configured + + def handle_event( + [:pleroma, :connection_pool, :reclaim, :start], + _, + %{max_connections: max_connections, reclaim_max: reclaim_max}, + _ + ) do + Logger.debug(fn -> + "Connection pool is exhausted (reached #{max_connections} connections). Starting idle connection cleanup to reclaim as much as #{ + reclaim_max + } connections" + end) + end + + def handle_event( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: 0}, + _, + _ + ) do + Logger.error(fn -> + "Connection pool failed to reclaim any connections due to all of them being in use. It will have to drop requests for opening connections to new hosts" + end) + end + + def handle_event( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: reclaimed_count}, + _, + _ + ) do + Logger.debug(fn -> "Connection pool cleaned up #{reclaimed_count} idle connections" end) + end + + def handle_event( + [:pleroma, :connection_pool, :provision_failure], + %{opts: [key | _]}, + _, + _ + ) do + Logger.error(fn -> + "Connection pool had to refuse opening a connection to #{key} due to connection limit exhaustion" + end) + end +end -- cgit v1.2.3 From e94ba05e523d735cd7a357a3aa30e433f60ef9a3 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 7 May 2020 16:11:48 +0300 Subject: Connection pool: Fix a possible infinite recursion if the pool is exhausted --- lib/pleroma/gun/connection_pool/worker_supervisor.ex | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex index 4b5d10d2a..5cb8d488a 100644 --- a/lib/pleroma/gun/connection_pool/worker_supervisor.ex +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -14,16 +14,14 @@ def init(_opts) do ) end - def start_worker(opts) do + def start_worker(opts, retry \\ false) do case DynamicSupervisor.start_child(__MODULE__, {Pleroma.Gun.ConnectionPool.Worker, opts}) do {:error, :max_children} -> - case free_pool() do - :ok -> - start_worker(opts) - - :error -> - :telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts}) - {:error, :pool_full} + if retry or free_pool() == :error do + :telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts}) + {:error, :pool_full} + else + start_worker(opts, true) end res -> -- cgit v1.2.3 From 1b15cb066c612c72d106e7e7026819ea14e0ceab Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 8 May 2020 18:18:59 +0300 Subject: Connection pool: Add client death tracking While running this in production I noticed a number of ghost processes with all their clients dead before they released the connection, so let's track them to log it and remove them from clients --- lib/pleroma/gun/connection_pool/worker.ex | 31 ++++++++++++++++++++++++++++++- lib/pleroma/telemetry/logger.ex | 16 +++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 0a94f16a2..8467325f3 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -20,7 +20,10 @@ def init([key, uri, opts, client_pid]) do end) send(client_pid, {:conn_pid, conn_pid}) - {:ok, %{key: key, timer: nil}, :hibernate} + + {:ok, + %{key: key, timer: nil, client_monitors: %{client_pid => Process.monitor(client_pid)}}, + :hibernate} else err -> {:stop, err} end @@ -45,6 +48,9 @@ def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) d state end + ref = Process.monitor(client_pid) + + state = put_in(state.client_monitors[client_pid], ref) {:noreply, state, :hibernate} end @@ -55,6 +61,9 @@ def handle_cast({:remove_client, client_pid}, %{key: key} = state) do {conn_pid, List.delete(used_by, client_pid), crf, last_reference} end) + {ref, state} = pop_in(state.client_monitors[client_pid]) + Process.demonitor(ref) + timer = if used_by == [] do max_idle = Pleroma.Config.get([:connections_pool, :max_idle_time], 30_000) @@ -85,6 +94,26 @@ def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams} = down_me {:stop, {:error, down_message}, state} end + @impl true + def handle_info({:DOWN, _ref, :process, pid, reason}, state) do + # Sometimes the client is dead before we demonitor it in :remove_client, so the message + # arrives anyway + + case state.client_monitors[pid] do + nil -> + {:noreply, state, :hibernate} + + _ref -> + :telemetry.execute( + [:pleroma, :connection_pool, :client_death], + %{client_pid: pid, reason: reason}, + %{key: state.key} + ) + + handle_cast({:remove_client, pid}, state) + end + end + # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 defp crf(time_delta, prev_crf) do 1 + :math.pow(0.5, time_delta / 100) * prev_crf diff --git a/lib/pleroma/telemetry/logger.ex b/lib/pleroma/telemetry/logger.ex index d76dd37b5..4cacae02f 100644 --- a/lib/pleroma/telemetry/logger.ex +++ b/lib/pleroma/telemetry/logger.ex @@ -6,7 +6,8 @@ defmodule Pleroma.Telemetry.Logger do @events [ [:pleroma, :connection_pool, :reclaim, :start], [:pleroma, :connection_pool, :reclaim, :stop], - [:pleroma, :connection_pool, :provision_failure] + [:pleroma, :connection_pool, :provision_failure], + [:pleroma, :connection_pool, :client_death] ] def attach do :telemetry.attach_many("pleroma-logger", @events, &handle_event/4, []) @@ -59,4 +60,17 @@ def handle_event( "Connection pool had to refuse opening a connection to #{key} due to connection limit exhaustion" end) end + + def handle_event( + [:pleroma, :connection_pool, :client_death], + %{client_pid: client_pid, reason: reason}, + %{key: key}, + _ + ) do + Logger.warn(fn -> + "Pool worker for #{key}: Client #{inspect(client_pid)} died before releasing the connection with #{ + inspect(reason) + }" + end) + end end -- cgit v1.2.3 From 281ddd5e371c5698489774e703106bd7c3ccb56b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 8 May 2020 19:57:11 +0300 Subject: Connection pool: fix connections being supervised by gun_sup --- lib/pleroma/gun/api.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex index f51cd7db8..09be74392 100644 --- a/lib/pleroma/gun/api.ex +++ b/lib/pleroma/gun/api.ex @@ -19,7 +19,8 @@ defmodule Pleroma.Gun.API do :tls_opts, :tcp_opts, :socks_opts, - :ws_opts + :ws_opts, + :supervise ] @impl Gun -- cgit v1.2.3 From 94c8f3cfafb92c6d092549b24bb69f3870e1c0d8 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 16 May 2020 11:49:19 +0300 Subject: Use a custom pool-aware FollowRedirects middleware --- lib/pleroma/http/adapter_helper.ex | 4 - lib/pleroma/http/adapter_helper/default.ex | 3 - lib/pleroma/http/adapter_helper/gun.ex | 9 -- lib/pleroma/http/adapter_helper/hackney.ex | 2 - lib/pleroma/http/http.ex | 9 +- lib/pleroma/tesla/middleware/follow_redirects.ex | 106 +++++++++++++++++++++++ 6 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 lib/pleroma/tesla/middleware/follow_redirects.ex diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 0532ea31d..bcb9b2b1e 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -24,7 +24,6 @@ defmodule Pleroma.HTTP.AdapterHelper do | {Connection.proxy_type(), Connection.host(), pos_integer()} @callback options(keyword(), URI.t()) :: keyword() - @callback after_request(keyword()) :: :ok @callback get_conn(URI.t(), keyword()) :: {:ok, term()} | {:error, term()} @spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil @@ -67,9 +66,6 @@ defp pool_timeout(opts) do Keyword.merge(opts, timeout: timeout) end - @spec after_request(keyword()) :: :ok - def after_request(opts), do: adapter_helper().after_request(opts) - def get_conn(uri, opts), do: adapter_helper().get_conn(uri, opts) defp adapter, do: Application.get_env(:tesla, :adapter) diff --git a/lib/pleroma/http/adapter_helper/default.ex b/lib/pleroma/http/adapter_helper/default.ex index 218cfacc0..e13441316 100644 --- a/lib/pleroma/http/adapter_helper/default.ex +++ b/lib/pleroma/http/adapter_helper/default.ex @@ -9,9 +9,6 @@ def options(opts, _uri) do AdapterHelper.maybe_add_proxy(opts, AdapterHelper.format_proxy(proxy)) end - @spec after_request(keyword()) :: :ok - def after_request(_opts), do: :ok - @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} def get_conn(_uri, opts), do: {:ok, opts} end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 6f7cc9784..5b4629978 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -34,15 +34,6 @@ def options(incoming_opts \\ [], %URI{} = uri) do |> Keyword.merge(incoming_opts) end - @spec after_request(keyword()) :: :ok - def after_request(opts) do - if opts[:conn] && opts[:body_as] != :chunks do - ConnectionPool.release_conn(opts[:conn]) - end - - :ok - end - defp add_scheme_opts(opts, %{scheme: "http"}), do: opts defp add_scheme_opts(opts, %{scheme: "https"}) do diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index 42d552740..cd569422b 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -24,8 +24,6 @@ def options(connection_opts \\ [], %URI{} = uri) do defp add_scheme_opts(opts, _), do: opts - def after_request(_), do: :ok - @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} def get_conn(_uri, opts), do: {:ok, opts} end diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 8ded76601..afcb4d738 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -69,14 +69,11 @@ def request(method, url, body, headers, options) when is_binary(url) do request = build_request(method, headers, options, url, body, params) adapter = Application.get_env(:tesla, :adapter) - client = Tesla.client([Tesla.Middleware.FollowRedirects], adapter) + client = Tesla.client([Pleroma.HTTP.Middleware.FollowRedirects], adapter) - response = request(client, request) - - AdapterHelper.after_request(adapter_opts) - - response + request(client, request) + # Connection release is handled in a custom FollowRedirects middleware err -> err end diff --git a/lib/pleroma/tesla/middleware/follow_redirects.ex b/lib/pleroma/tesla/middleware/follow_redirects.ex new file mode 100644 index 000000000..f2c502c69 --- /dev/null +++ b/lib/pleroma/tesla/middleware/follow_redirects.ex @@ -0,0 +1,106 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2015-2020 Tymon Tobolski +# Copyright © 2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Middleware.FollowRedirects do + @moduledoc """ + Pool-aware version of https://github.com/teamon/tesla/blob/master/lib/tesla/middleware/follow_redirects.ex + + Follow 3xx redirects + ## Options + - `:max_redirects` - limit number of redirects (default: `5`) + """ + + alias Pleroma.Gun.ConnectionPool + + @behaviour Tesla.Middleware + + @max_redirects 5 + @redirect_statuses [301, 302, 303, 307, 308] + + @impl Tesla.Middleware + def call(env, next, opts \\ []) do + max = Keyword.get(opts, :max_redirects, @max_redirects) + + redirect(env, next, max) + end + + defp redirect(env, next, left) do + opts = env.opts[:adapter] + + case Tesla.run(env, next) do + {:ok, %{status: status} = res} when status in @redirect_statuses and left > 0 -> + release_conn(opts) + + case Tesla.get_header(res, "location") do + nil -> + {:ok, res} + + location -> + location = parse_location(location, res) + + case get_conn(location, opts) do + {:ok, opts} -> + %{env | opts: Keyword.put(env.opts, :adapter, opts)} + |> new_request(res.status, location) + |> redirect(next, left - 1) + + e -> + e + end + end + + {:ok, %{status: status}} when status in @redirect_statuses -> + release_conn(opts) + {:error, {__MODULE__, :too_many_redirects}} + + other -> + unless opts[:body_as] == :chunks do + release_conn(opts) + end + + other + end + end + + defp get_conn(location, opts) do + uri = URI.parse(location) + + case ConnectionPool.get_conn(uri, opts) do + {:ok, conn} -> + {:ok, Keyword.merge(opts, conn: conn)} + + e -> + e + end + end + + defp release_conn(opts) do + ConnectionPool.release_conn(opts[:conn]) + end + + # The 303 (See Other) redirect was added in HTTP/1.1 to indicate that the originally + # requested resource is not available, however a related resource (or another redirect) + # available via GET is available at the specified location. + # https://tools.ietf.org/html/rfc7231#section-6.4.4 + defp new_request(env, 303, location), do: %{env | url: location, method: :get, query: []} + + # The 307 (Temporary Redirect) status code indicates that the target + # resource resides temporarily under a different URI and the user agent + # MUST NOT change the request method (...) + # https://tools.ietf.org/html/rfc7231#section-6.4.7 + defp new_request(env, 307, location), do: %{env | url: location} + + defp new_request(env, _, location), do: %{env | url: location, query: []} + + defp parse_location("https://" <> _rest = location, _env), do: location + defp parse_location("http://" <> _rest = location, _env), do: location + + defp parse_location(location, env) do + env.url + |> URI.parse() + |> URI.merge(location) + |> URI.to_string() + end +end -- cgit v1.2.3 From 4128e3a84a2b6d75a8f92759e65ee673b47cec01 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 17 May 2020 22:16:02 +0300 Subject: HTTP: Implement max request limits --- config/config.exs | 15 ++++++--------- lib/pleroma/application.ex | 3 ++- lib/pleroma/http/adapter_helper/gun.ex | 21 +++++++++++++++++++++ lib/pleroma/http/http.ex | 17 ++++++++++++++++- mix.exs | 3 +++ mix.lock | 1 + 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/config/config.exs b/config/config.exs index 577ccc198..dfc7a99d1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -648,7 +648,8 @@ config :pleroma, :connections_pool, reclaim_multiplier: 0.1, - checkin_timeout: 250, + connection_acquisition_wait: 250, + connection_acquisition_retries: 5, max_connections: 250, max_idle_time: 30_000, retry: 1, @@ -658,23 +659,19 @@ config :pleroma, :pools, federation: [ size: 50, - max_overflow: 10, - timeout: 150_000 + max_waiting: 10 ], media: [ size: 50, - max_overflow: 10, - timeout: 150_000 + max_waiting: 10 ], upload: [ size: 25, - max_overflow: 5, - timeout: 300_000 + max_waiting: 5 ], default: [ size: 10, - max_overflow: 2, - timeout: 10_000 + max_waiting: 2 ] config :pleroma, :hackney_pools, diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 37fcdf293..0ffb55358 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -244,7 +244,8 @@ defp http_children(Tesla.Adapter.Hackney, _) do end defp http_children(Tesla.Adapter.Gun, _) do - Pleroma.Gun.ConnectionPool.children() + Pleroma.Gun.ConnectionPool.children() ++ + [{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}] end defp http_children(_, _), do: [] diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 5b4629978..883f7f6f7 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -49,4 +49,25 @@ def get_conn(uri, opts) do err -> err end end + + @prefix Pleroma.Gun.ConnectionPool + def limiter_setup do + wait = Pleroma.Config.get([:connections_pool, :connection_acquisition_wait]) + retries = Pleroma.Config.get([:connections_pool, :connection_acquisition_retries]) + + :pools + |> Pleroma.Config.get([]) + |> Enum.each(fn {name, opts} -> + max_running = Keyword.get(opts, :size, 50) + max_waiting = Keyword.get(opts, :max_waiting, 10) + + :ok = + ConcurrentLimiter.new(:"#{@prefix}.#{name}", max_running, max_waiting, + wait: wait, + max_retries: retries + ) + end) + + :ok + end end diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index afcb4d738..6128bc4cf 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -71,7 +71,13 @@ def request(method, url, body, headers, options) when is_binary(url) do adapter = Application.get_env(:tesla, :adapter) client = Tesla.client([Pleroma.HTTP.Middleware.FollowRedirects], adapter) - request(client, request) + maybe_limit( + fn -> + request(client, request) + end, + adapter, + adapter_opts + ) # Connection release is handled in a custom FollowRedirects middleware err -> @@ -92,4 +98,13 @@ defp build_request(method, headers, options, url, body, params) do |> Builder.add_param(:query, :query, params) |> Builder.convert_to_keyword() end + + @prefix Pleroma.Gun.ConnectionPool + defp maybe_limit(fun, Tesla.Adapter.Gun, opts) do + ConcurrentLimiter.limit(:"#{@prefix}.#{opts[:pool] || :default}", fun) + end + + defp maybe_limit(fun, _, _) do + fun.() + end end diff --git a/mix.exs b/mix.exs index 741f917e6..4dfce58e7 100644 --- a/mix.exs +++ b/mix.exs @@ -191,6 +191,9 @@ defp deps do {:plug_static_index_html, "~> 1.0.0"}, {:excoveralls, "~> 0.12.1", only: :test}, {:flake_id, "~> 0.1.0"}, + {:concurrent_limiter, + git: "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter", + ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"}, {:remote_ip, git: "https://git.pleroma.social/pleroma/remote_ip.git", ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"}, diff --git a/mix.lock b/mix.lock index f801f9e0c..89c97decf 100644 --- a/mix.lock +++ b/mix.lock @@ -15,6 +15,7 @@ "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, + "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter", "8eee96c6ba39b9286ec44c51c52d9f2758951365", [ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"]}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, -- cgit v1.2.3 From 721e89e88bafbf0db15b590604e886e37f3291c7 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 28 May 2020 14:06:18 +0300 Subject: Remove tests for old pool --- test/http/adapter_helper/gun_test.exs | 171 -------- test/http/connection_test.exs | 135 ------ test/pool/connections_test.exs | 760 ---------------------------------- 3 files changed, 1066 deletions(-) delete mode 100644 test/http/connection_test.exs delete mode 100644 test/pool/connections_test.exs diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index 2e961826e..49eebf355 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -9,24 +9,10 @@ defmodule Pleroma.HTTP.AdapterHelper.GunTest do import Mox alias Pleroma.Config - alias Pleroma.Gun.Conn alias Pleroma.HTTP.AdapterHelper.Gun - alias Pleroma.Pool.Connections setup :verify_on_exit! - defp gun_mock(_) do - gun_mock() - :ok - end - - defp gun_mock do - Pleroma.GunMock - |> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(1000) end) end) - |> stub(:await_up, fn _, _ -> {:ok, :http} end) - |> stub(:set_owner, fn _, _ -> :ok end) - end - describe "options/1" do setup do: clear_config([:http, :adapter], a: 1, b: 2) @@ -62,46 +48,12 @@ test "https url with non standart port" do assert opts[:certificates_verification] 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) - uri = URI.parse("http://some-domain2.com") - - opts = Gun.options(uri) - - assert opts[:conn] == nil - assert opts[:close_conn] == nil - - Process.sleep(50) - 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([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) - - opts = Gun.options(uri) - - assert opts[:certificates_verification] - refute opts[:tls_opts] == [] - - 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") @@ -132,127 +84,4 @@ test "passed opts have more weight than defaults" do assert opts[:proxy] == {'example.com', 4321} 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) - 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 = Conn.open(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 = Conn.open(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 = Conn.open(uri, :gun_connections) - opts = Gun.options(uri) - :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 = Conn.open(uri, :gun_connections) - opts = Gun.options(uri) - :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/connection_test.exs b/test/http/connection_test.exs deleted file mode 100644 index 7c94a50b2..000000000 --- a/test/http/connection_test.exs +++ /dev/null @@ -1,135 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 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 - - 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, :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, :invalid_proxy} - end) =~ "parsing proxy fail \"localhost\"" - end - - test "host with bad port" do - capture_log(fn -> - 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, :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, :invalid_proxy} - end) =~ "parsing proxy fail {:socks5, :localhost}" - end - - test "with nil" do - assert Connection.parse_proxy(nil) == nil - end - end - - describe "options/3" do - setup 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 - 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 diff --git a/test/pool/connections_test.exs b/test/pool/connections_test.exs deleted file mode 100644 index aeda54875..000000000 --- a/test/pool/connections_test.exs +++ /dev/null @@ -1,760 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Pool.ConnectionsTest do - 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 - name = :test_connections - {:ok, pid} = Connections.start_link({name, [checkin_timeout: 150]}) - {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.GunMock) - - on_exit(fn -> - 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) - 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 - open_mock() - url = "http://some-domain.com" - key = "http:some-domain.com:80" - refute Connections.checkin(url, name) - :ok = Conn.open(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 - open_mock() - url = "http://ですsome-domain.com" - refute Connections.checkin(url, name) - - :ok = Conn.open(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 - open_mock() - url = "http://127.0.0.1" - - refute Connections.checkin(url, name) - - :ok = Conn.open(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 - open_mock() - url = "http://[2a03:2880:f10c:83:face:b00c:0:25de]" - - refute Connections.checkin(url, name) - - :ok = Conn.open(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 - open_mock() - |> info_mock() - |> allow(self(), name) - - self = self() - url = "http://127.0.0.1" - :ok = Conn.open(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() - - 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) - 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 - open_mock(2) - 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 = 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 = Conn.open(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 - expect(GunMock, :open, &start_and_register(&1, &2, &3)) - url = "http://gun-not-up.com" - - assert capture_log(fn -> - refute Conn.open(url, name) - refute Connections.checkin(url, name) - end) =~ - "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 - 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) - 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 - open_mock() - url = "http://some-domain.com" - :ok = Conn.open(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 - open_mock(3) - self = self() - http_url = "http://some-domain.com" - https_url = "https://some-domain.com" - :ok = Conn.open(https_url, name) - :ok = Conn.open(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 = Conn.open(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 "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}) - - 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 - open_mock() - |> connect_mock() - - url = "http://proxy-tuple-atom.com" - :ok = Conn.open(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 - open_mock() - |> connect_mock() - - url = "https://proxy-string.com" - - :ok = Conn.open(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 - open_mock() - |> connect_mock() - - url = "https://proxy-tuple-atom.com" - :ok = Conn.open(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 - open_mock() - - url = "http://proxy-socks.com" - - :ok = Conn.open(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 - open_mock() - url = "https://proxy-socks.com" - - :ok = Conn.open(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", %{name: name} do - Connections.add_conn(name, "1", %Conn{ - conn_state: :idle, - last_reference: now() - 1 - }) - - 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", %{name: name} do - Connections.add_conn(name, "1", %Conn{ - conn_state: :idle, - crf: 1.999 - }) - - 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", %{name: name} do - Connections.add_conn(name, "1", %Conn{ - conn_state: :idle, - crf: 3, - last_reference: now() - 1 - }) - - 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", %{name: name} do - Connections.add_conn(name, "1", %Conn{ - conn_state: :idle, - crf: 1.99, - last_reference: now() - 1 - }) - - 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" 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 -end -- cgit v1.2.3 From bf3492ceb31f6332a4c58feba271e3755fabe25a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 6 Jun 2020 17:53:39 +0300 Subject: Connection Pool: add tests --- test/gun/conneciton_pool_test.exs | 101 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 test/gun/conneciton_pool_test.exs diff --git a/test/gun/conneciton_pool_test.exs b/test/gun/conneciton_pool_test.exs new file mode 100644 index 000000000..aea908fac --- /dev/null +++ b/test/gun/conneciton_pool_test.exs @@ -0,0 +1,101 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Gun.ConnectionPoolTest do + use Pleroma.DataCase + + import Mox + import ExUnit.CaptureLog + alias Pleroma.Config + alias Pleroma.Gun.ConnectionPool + + defp gun_mock(_) do + Pleroma.GunMock + |> stub(:open, fn _, _, _ -> Task.start_link(fn -> Process.sleep(100) end) end) + |> stub(:await_up, fn _, _ -> {:ok, :http} end) + |> stub(:set_owner, fn _, _ -> :ok end) + + :ok + end + + setup :set_mox_from_context + setup :gun_mock + + test "gives the same connection to 2 concurrent requests" do + Enum.map( + [ + "http://www.korean-books.com.kp/KBMbooks/en/periodic/pictorial/20200530163914.pdf", + "http://www.korean-books.com.kp/KBMbooks/en/periodic/pictorial/20200528183427.pdf" + ], + fn uri -> + uri = URI.parse(uri) + task_parent = self() + + Task.start_link(fn -> + {:ok, conn} = ConnectionPool.get_conn(uri, []) + ConnectionPool.release_conn(conn) + send(task_parent, conn) + end) + end + ) + + [pid, pid] = + for _ <- 1..2 do + receive do + pid -> pid + end + end + end + + test "connection limit is respected with concurrent requests" do + clear_config([:connections_pool, :max_connections]) do + Config.put([:connections_pool, :max_connections], 1) + # The supervisor needs a reboot to apply the new config setting + Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill) + + on_exit(fn -> + Process.exit(Process.whereis(Pleroma.Gun.ConnectionPool.WorkerSupervisor), :kill) + end) + end + + capture_log(fn -> + Enum.map( + [ + "https://ninenines.eu/", + "https://youtu.be/PFGwMiDJKNY" + ], + fn uri -> + uri = URI.parse(uri) + task_parent = self() + + Task.start_link(fn -> + result = ConnectionPool.get_conn(uri, []) + # Sleep so that we don't end up with a situation, + # where request from the second process gets processed + # only after the first process already released the connection + Process.sleep(50) + + case result do + {:ok, pid} -> + ConnectionPool.release_conn(pid) + + _ -> + nil + end + + send(task_parent, result) + end) + end + ) + + [{:error, :pool_full}, {:ok, _pid}] = + for _ <- 1..2 do + receive do + result -> result + end + end + |> Enum.sort() + end) + end +end -- cgit v1.2.3 From 00926a63fb174a8bcb2f496921c5d17e04e44b1d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 16 Jun 2020 16:20:28 +0300 Subject: Adapter Helper: Use built-in ip address type --- lib/pleroma/http/adapter_helper.ex | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index bcb9b2b1e..8ca433732 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -8,12 +8,8 @@ defmodule Pleroma.HTTP.AdapterHelper do """ @defaults [pool: :federation] - @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() + @type host() :: charlist() | :inet.ip_address() alias Pleroma.Config alias Pleroma.HTTP.AdapterHelper @@ -114,7 +110,7 @@ def parse_proxy(proxy) when is_tuple(proxy) do end end - @spec parse_host(String.t() | atom() | charlist()) :: charlist() | ip_address() + @spec parse_host(String.t() | atom() | charlist()) :: charlist() | :inet.ip_address() def parse_host(host) when is_list(host), do: host def parse_host(host) when is_atom(host), do: to_charlist(host) -- cgit v1.2.3 From 7882f28569bfaee2996d059990eec279415f0785 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 17 Jun 2020 12:54:13 +0300 Subject: Use erlang monotonic time for CRF calculation --- lib/pleroma/gun/connection_pool/worker.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 8467325f3..418cb18c1 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -12,7 +12,7 @@ def start_link([key | _] = opts) do def init([key, uri, opts, client_pid]) do with {:ok, conn_pid} <- Gun.Conn.open(uri, opts), Process.link(conn_pid) do - time = :os.system_time(:second) + time = :erlang.monotonic_time() {_, _} = Registry.update_value(@registry, key, fn _ -> @@ -31,7 +31,7 @@ def init([key, uri, opts, client_pid]) do @impl true def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) do - time = :os.system_time(:second) + time = :erlang.monotonic_time() {{conn_pid, _, _, _}, _} = Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> -- cgit v1.2.3 From 007843b75e0c7087dad1ef932224b21327d81793 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 23 Jun 2020 15:38:45 +0300 Subject: Add documentation for new connection pool settings and remove some `:retry_timeout` and `:retry` got removed because reconnecting on failure is something the new pool intentionally doesn't do. `:max_overflow` had to go in favor of `:max_waiting`, I didn't reuse the key because the settings are very different in their behaviour. `:checkin_timeout` got removed in favor of `:connection_acquisition_wait`, I didn't reuse the key because the settings are somewhat different. I didn't do any migrations/deprecation warnings/changelog entries because these settings were never in stable. --- config/description.exs | 156 ++++++++++----------------------------- docs/configuration/cheatsheet.md | 30 ++++---- lib/pleroma/gun/conn.ex | 2 - 3 files changed, 52 insertions(+), 136 deletions(-) diff --git a/config/description.exs b/config/description.exs index afc4dcd79..f1c6773f1 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3161,36 +3161,37 @@ description: "Advanced settings for `gun` connections pool", children: [ %{ - key: :checkin_timeout, + key: :connection_acquisition_wait, 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.", + description: + "Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries. Default: 250ms.", suggestions: [250] }, %{ - key: :retry, + key: :connection_acquisition_retries, type: :integer, description: - "Number of retries, while `gun` will try to reconnect if connection goes down. Default: 1.", - suggestions: [1] + "Number of attempts to acquire the connection from the pool if it is overloaded. Default: 5", + suggestions: [5] }, %{ - key: :retry_timeout, + key: :max_connections, type: :integer, - description: - "Time between retries when `gun` will try to reconnect in milliseconds. Default: 1000ms.", - suggestions: [1000] + description: "Maximum number of connections in the pool. Default: 250 connections.", + suggestions: [250] }, %{ key: :await_up_timeout, type: :integer, description: "Timeout while `gun` will wait until connection is up. Default: 5000ms.", suggestions: [5000] + }, + %{ + key: :reclaim_multiplier, + type: :integer, + description: + "Multiplier for the number of idle connection to be reclaimed if the pool is full. For example if the pool maxes out at 250 connections and this setting is set to 0.3, the pool will reclaim at most 75 idle connections if it's overloaded. Default: 0.1", + suggestions: [0.1] } ] }, @@ -3199,108 +3200,29 @@ 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] - } - ] - } - ] + children: + Enum.map([:federation, :media, :upload, :default], fn pool_name -> + %{ + key: pool_name, + type: :keyword, + description: "Settings for #{pool_name} pool.", + children: [ + %{ + key: :size, + type: :integer, + description: "Maximum number of concurrent requests in the pool.", + suggestions: [50] + }, + %{ + key: :max_waiting, + type: :integer, + description: + "Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errrors when a new request is made", + suggestions: [10] + } + ] + } + end) }, %{ group: :pleroma, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index ba62a721e..6c1babba3 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -448,36 +448,32 @@ For each pool, the options are: *For `gun` adapter* -Advanced settings for connections pool. Pool with opened connections. These connections can be reused in worker pools. +Settings for HTTP connection pool. -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. - -* `: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 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. +* `:connection_acquisition_wait` - Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries. +* `connection_acquisition_retries` - Number of attempts to acquire the connection from the pool if it is overloaded. Each attempt is timed `:connection_acquisition_wait` apart. +* `:max_connections` - Maximum number of connections in the pool. +* `:await_up_timeout` - Timeout to connect to the host. +* `:reclaim_multiplier` - Multiplied by `:max_connections` this will be the maximum number of idle connections that will be reclaimed in case the pool is overloaded. ### :pools *For `gun` adapter* -Advanced settings for workers pools. +Settings for request pools. These pools are limited on top of `:connections_pool`. 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. -* `:media` for rich media, media proxy -* `:upload` for uploaded media (if using a remote uploader and `proxy_remote: true`) -* `:default` for other requests +* `:federation` for the federation jobs. You may want this pool's max_connections to be at least equal to the number of federator jobs + retry queue jobs. +* `:media` - for rich media, media proxy. +* `:upload` - for proxying media when a remote uploader is used and `proxy_remote: true`. +* `:default` - for other requests. For each pool, the options are: -* `:size` - how much workers the pool can hold +* `:size` - limit to how much requests can be concurrently executed. * `:timeout` - timeout while `gun` will wait for response -* `:max_overflow` - additional workers if pool is under load +* `:max_waiting` - limit to how much requests can be waiting for others to finish, after this is reached, subsequent requests will be dropped. ## Captcha diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 77f78c7ff..9dc8880db 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -13,8 +13,6 @@ def open(%URI{} = uri, opts) do opts = opts |> Enum.into(%{}) - |> 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) |> Map.put_new(:supervise, false) |> maybe_add_tls_opts(uri) -- cgit v1.2.3 From 37f1e781cb19594a6534efbc4d28e793d5960915 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 23 Jun 2020 20:36:21 +0300 Subject: Gun adapter helper: fix wildcard cert issues on OTP 23 See https://bugs.erlang.org/browse/ERL-1260 for more info. The ssl match function is basically copied from mint, except that `:string.lowercase/1` was replaced by `:string.casefold`. It was a TODO in mint's code, so might as well do it since we don't need to support OTP <20. Closes #1834 --- lib/pleroma/http/adapter_helper/gun.ex | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 883f7f6f7..07aaed7f6 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -39,9 +39,36 @@ defp add_scheme_opts(opts, %{scheme: "http"}), do: opts defp add_scheme_opts(opts, %{scheme: "https"}) do opts |> Keyword.put(:certificates_verification, true) - |> Keyword.put(:tls_opts, log_level: :warning) + |> Keyword.put(:tls_opts, + log_level: :warning, + customize_hostname_check: [match_fun: &ssl_match_fun/2] + ) end + # ssl_match_fun is adapted from [Mint](https://github.com/elixir-mint/mint) + # Copyright 2018 Eric Meadows-Jönsson and Andrea Leopardi + + # Wildcard domain handling for DNS ID entries in the subjectAltName X.509 + # extension. Note that this is a subset of the wildcard patterns implemented + # by OTP when matching against the subject CN attribute, but this is the only + # wildcard usage defined by the CA/Browser Forum's Baseline Requirements, and + # therefore the only pattern used in commercially issued certificates. + defp ssl_match_fun({:dns_id, reference}, {:dNSName, [?*, ?. | presented]}) do + case domain_without_host(reference) do + '' -> + :default + + domain -> + :string.casefold(domain) == :string.casefold(presented) + end + end + + defp ssl_match_fun(_reference, _presented), do: :default + + defp domain_without_host([]), do: [] + defp domain_without_host([?. | domain]), do: domain + defp domain_without_host([_ | more]), do: domain_without_host(more) + @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} | {:error, atom()} def get_conn(uri, opts) do case ConnectionPool.get_conn(uri, opts) do -- cgit v1.2.3 From 9df59189747620c60173e6a67f8721971f123efd Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 23 Jun 2020 15:52:57 +0300 Subject: config.exs: make gun the default again --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index dfc7a99d1..30b5e83bd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -172,7 +172,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, -- cgit v1.2.3 From 12fa5541f01ca5cfe082a62dac3317da78043e8f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 30 Jun 2020 15:58:53 +0300 Subject: FollowRedirects: Unconditionally release the connection if there is an error There is no need for streaming the body if there is no body --- lib/pleroma/tesla/middleware/follow_redirects.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/tesla/middleware/follow_redirects.ex b/lib/pleroma/tesla/middleware/follow_redirects.ex index f2c502c69..5a7032215 100644 --- a/lib/pleroma/tesla/middleware/follow_redirects.ex +++ b/lib/pleroma/tesla/middleware/follow_redirects.ex @@ -55,6 +55,10 @@ defp redirect(env, next, left) do release_conn(opts) {:error, {__MODULE__, :too_many_redirects}} + {:error, _} = e -> + release_conn(opts) + e + other -> unless opts[:body_as] == :chunks do release_conn(opts) -- cgit v1.2.3 From 9b73c35ca8b051316815461247b802bc8567854f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 30 Jun 2020 18:35:15 +0300 Subject: Request limiter setup: consider {:error, :existing} a success When the application restarts (which happens after certain config changes), the limiters are not destroyed, so `ConcurrentLimiter.new` will produce {:error, :existing} --- lib/pleroma/http/adapter_helper/gun.ex | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 07aaed7f6..b8c4cc59c 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -88,11 +88,17 @@ def limiter_setup do max_running = Keyword.get(opts, :size, 50) max_waiting = Keyword.get(opts, :max_waiting, 10) - :ok = + result = ConcurrentLimiter.new(:"#{@prefix}.#{name}", max_running, max_waiting, wait: wait, max_retries: retries ) + + case result do + :ok -> :ok + {:error, :existing} -> :ok + e -> raise e + end end) :ok -- cgit v1.2.3 From a705637dcf7ffe063c9c0f3f190f57e44aaa63f2 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 2 Jul 2020 01:53:27 +0300 Subject: Connection Pool: fix LRFU implementation to not actually be LRU The numbers of the native time unit were so small the CRF was always 1, making it an LRU. This commit switches the time to miliseconds and changes the time delta multiplier to the one yielding mostly highest hit rates according to the paper --- lib/pleroma/gun/connection_pool/worker.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 418cb18c1..ec0502621 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -12,7 +12,7 @@ def start_link([key | _] = opts) do def init([key, uri, opts, client_pid]) do with {:ok, conn_pid} <- Gun.Conn.open(uri, opts), Process.link(conn_pid) do - time = :erlang.monotonic_time() + time = :erlang.monotonic_time(:millisecond) {_, _} = Registry.update_value(@registry, key, fn _ -> @@ -31,7 +31,7 @@ def init([key, uri, opts, client_pid]) do @impl true def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) do - time = :erlang.monotonic_time() + time = :erlang.monotonic_time(:millisecond) {{conn_pid, _, _, _}, _} = Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> @@ -116,6 +116,6 @@ def handle_info({:DOWN, _ref, :process, pid, reason}, state) do # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 defp crf(time_delta, prev_crf) do - 1 + :math.pow(0.5, time_delta / 100) * prev_crf + 1 + :math.pow(0.5, 0.0001 * time_delta) * prev_crf end end -- cgit v1.2.3 From 33747e9366ef7422c9b39ac360ad1d96405bc4fd Mon Sep 17 00:00:00 2001 From: rinpatch Date: Mon, 6 Jul 2020 12:13:02 +0300 Subject: config.exs: set gun retries to 0 The new pooling code just removes the connection when it's down, there is no need to reconnect a connection that is just sitting idle, better just open a new one next time it's needed --- config/config.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 30b5e83bd..61406687a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -652,8 +652,7 @@ connection_acquisition_retries: 5, max_connections: 250, max_idle_time: 30_000, - retry: 1, - retry_timeout: 1000, + retry: 0, await_up_timeout: 5_000 config :pleroma, :pools, -- cgit v1.2.3 From 46dd276d686e49676101e2af743aad61393f4b70 Mon Sep 17 00:00:00 2001 From: href Date: Tue, 7 Jul 2020 18:56:17 +0200 Subject: ConnectionPool.Worker: Open gun conn in continue instead of init --- lib/pleroma/gun/connection_pool/worker.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index ec0502621..6ee622fb0 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -9,7 +9,12 @@ def start_link([key | _] = opts) do end @impl true - def init([key, uri, opts, client_pid]) do + def init([_key, _uri, _opts, _client_pid] = opts) do + {:ok, nil, {:continue, {:connect, opts}}} + end + + @impl true + def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do with {:ok, conn_pid} <- Gun.Conn.open(uri, opts), Process.link(conn_pid) do time = :erlang.monotonic_time(:millisecond) @@ -21,7 +26,7 @@ def init([key, uri, opts, client_pid]) do send(client_pid, {:conn_pid, conn_pid}) - {:ok, + {:noreply, %{key: key, timer: nil, client_monitors: %{client_pid => Process.monitor(client_pid)}}, :hibernate} else -- cgit v1.2.3 From 6a0f2bdf8ceb4127678cc55406a02d41c7fb0ed7 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 13:01:02 +0200 Subject: Ensure connections error get known by the caller --- lib/pleroma/gun/connection_pool.ex | 22 ++++++++++++---------- lib/pleroma/gun/connection_pool/worker.ex | 3 ++- lib/pleroma/http/adapter_helper/gun.ex | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index e951872fe..d3eead7d8 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -16,7 +16,7 @@ def get_conn(uri, opts) do case Registry.lookup(@registry, key) do # The key has already been registered, but connection is not up yet [{worker_pid, nil}] -> - get_gun_pid_from_worker(worker_pid) + get_gun_pid_from_worker(worker_pid, true) [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> GenServer.cast(worker_pid, {:add_client, self(), false}) @@ -27,13 +27,11 @@ def get_conn(uri, opts) do # so we open the connection in the process directly and send it's pid back # We trust gun to handle timeouts by itself case WorkerSupervisor.start_worker([key, uri, opts, self()]) do - {:ok, _worker_pid} -> - receive do - {:conn_pid, pid} -> {:ok, pid} - end + {:ok, worker_pid} -> + get_gun_pid_from_worker(worker_pid, false) {:error, {:already_started, worker_pid}} -> - get_gun_pid_from_worker(worker_pid) + get_gun_pid_from_worker(worker_pid, true) err -> err @@ -41,17 +39,21 @@ def get_conn(uri, opts) do end end - defp get_gun_pid_from_worker(worker_pid) do + defp get_gun_pid_from_worker(worker_pid, register) do # GenServer.call will block the process for timeout length if # the server crashes on startup (which will happen if gun fails to connect) # so instead we use cast + monitor ref = Process.monitor(worker_pid) - GenServer.cast(worker_pid, {:add_client, self(), true}) + if register, do: GenServer.cast(worker_pid, {:add_client, self(), true}) receive do - {:conn_pid, pid} -> {:ok, pid} - {:DOWN, ^ref, :process, ^worker_pid, reason} -> reason + {:conn_pid, pid} -> + Process.demonitor(ref) + {:ok, pid} + + {:DOWN, ^ref, :process, ^worker_pid, reason} -> + {:error, reason} end end diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 6ee622fb0..16a508ad9 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -30,7 +30,8 @@ def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do %{key: key, timer: nil, client_monitors: %{client_pid => Process.monitor(client_pid)}}, :hibernate} else - err -> {:stop, err} + err -> + {:stop, err, nil} end end diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index b8c4cc59c..74677ddb5 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -14,7 +14,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do connect_timeout: 5_000, domain_lookup_timeout: 5_000, tls_handshake_timeout: 5_000, - retry: 1, + retry: 0, retry_timeout: 1000, await_up_timeout: 5_000 ] -- cgit v1.2.3 From 23d714ed3038a24eb78314d52807c46d8b8de2f3 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 13:22:42 +0200 Subject: Fix race in enforcer/reclaimer start --- lib/pleroma/gun/connection_pool/reclaimer.ex | 85 ++++++++++++++++++++++ .../gun/connection_pool/worker_supervisor.ex | 81 +-------------------- 2 files changed, 89 insertions(+), 77 deletions(-) create mode 100644 lib/pleroma/gun/connection_pool/reclaimer.ex diff --git a/lib/pleroma/gun/connection_pool/reclaimer.ex b/lib/pleroma/gun/connection_pool/reclaimer.ex new file mode 100644 index 000000000..1793ac3ee --- /dev/null +++ b/lib/pleroma/gun/connection_pool/reclaimer.ex @@ -0,0 +1,85 @@ +defmodule Pleroma.Gun.ConnectionPool.Reclaimer do + use GenServer, restart: :temporary + + @registry Pleroma.Gun.ConnectionPool + + def start_monitor() do + pid = + case :gen_server.start(__MODULE__, [], name: {:via, Registry, {@registry, "reclaimer"}}) do + {:ok, pid} -> + pid + + {:error, {:already_registered, pid}} -> + pid + end + + {pid, Process.monitor(pid)} + end + + @impl true + def init(_) do + {:ok, nil, {:continue, :reclaim}} + end + + @impl true + def handle_continue(:reclaim, _) do + max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) + + reclaim_max = + [:connections_pool, :reclaim_multiplier] + |> Pleroma.Config.get() + |> Kernel.*(max_connections) + |> round + |> max(1) + + :telemetry.execute([:pleroma, :connection_pool, :reclaim, :start], %{}, %{ + max_connections: max_connections, + reclaim_max: reclaim_max + }) + + # :ets.fun2ms( + # fn {_, {worker_pid, {_, used_by, crf, last_reference}}} when used_by == [] -> + # {worker_pid, crf, last_reference} end) + unused_conns = + Registry.select( + @registry, + [ + {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], [{{:"$1", :"$3", :"$4"}}]} + ] + ) + + case unused_conns do + [] -> + :telemetry.execute( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: 0}, + %{ + max_connections: max_connections + } + ) + + {:stop, :no_unused_conns, nil} + + unused_conns -> + reclaimed = + unused_conns + |> Enum.sort(fn {_pid1, crf1, last_reference1}, {_pid2, crf2, last_reference2} -> + crf1 <= crf2 and last_reference1 <= last_reference2 + end) + |> Enum.take(reclaim_max) + + reclaimed + |> Enum.each(fn {pid, _, _} -> + DynamicSupervisor.terminate_child(Pleroma.Gun.ConnectionPool.WorkerSupervisor, pid) + end) + + :telemetry.execute( + [:pleroma, :connection_pool, :reclaim, :stop], + %{reclaimed_count: Enum.count(reclaimed)}, + %{max_connections: max_connections} + ) + + {:stop, :normal, nil} + end + end +end diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex index 5cb8d488a..39615c956 100644 --- a/lib/pleroma/gun/connection_pool/worker_supervisor.ex +++ b/lib/pleroma/gun/connection_pool/worker_supervisor.ex @@ -29,89 +29,16 @@ def start_worker(opts, retry \\ false) do end end - @registry Pleroma.Gun.ConnectionPool - @enforcer_key "enforcer" defp free_pool do - case Registry.lookup(@registry, @enforcer_key) do - [] -> - pid = - spawn(fn -> - {:ok, _pid} = Registry.register(@registry, @enforcer_key, nil) - max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) - - reclaim_max = - [:connections_pool, :reclaim_multiplier] - |> Pleroma.Config.get() - |> Kernel.*(max_connections) - |> round - |> max(1) - - :telemetry.execute([:pleroma, :connection_pool, :reclaim, :start], %{}, %{ - max_connections: max_connections, - reclaim_max: reclaim_max - }) - - # :ets.fun2ms( - # fn {_, {worker_pid, {_, used_by, crf, last_reference}}} when used_by == [] -> - # {worker_pid, crf, last_reference} end) - unused_conns = - Registry.select( - @registry, - [ - {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], - [{{:"$1", :"$3", :"$4"}}]} - ] - ) - - case unused_conns do - [] -> - :telemetry.execute( - [:pleroma, :connection_pool, :reclaim, :stop], - %{reclaimed_count: 0}, - %{ - max_connections: max_connections - } - ) - - exit(:no_unused_conns) - - unused_conns -> - reclaimed = - unused_conns - |> Enum.sort(fn {_pid1, crf1, last_reference1}, - {_pid2, crf2, last_reference2} -> - crf1 <= crf2 and last_reference1 <= last_reference2 - end) - |> Enum.take(reclaim_max) - - reclaimed - |> Enum.each(fn {pid, _, _} -> - DynamicSupervisor.terminate_child(__MODULE__, pid) - end) - - :telemetry.execute( - [:pleroma, :connection_pool, :reclaim, :stop], - %{reclaimed_count: Enum.count(reclaimed)}, - %{max_connections: max_connections} - ) - end - end) - - wait_for_enforcer_finish(pid) - - [{pid, _}] -> - wait_for_enforcer_finish(pid) - end + wait_for_reclaimer_finish(Pleroma.Gun.ConnectionPool.Reclaimer.start_monitor()) end - defp wait_for_enforcer_finish(pid) do - ref = Process.monitor(pid) - + defp wait_for_reclaimer_finish({pid, mon}) do receive do - {:DOWN, ^ref, :process, ^pid, :no_unused_conns} -> + {:DOWN, ^mon, :process, ^pid, :no_unused_conns} -> :error - {:DOWN, ^ref, :process, ^pid, :normal} -> + {:DOWN, ^mon, :process, ^pid, :normal} -> :ok end end -- cgit v1.2.3 From 53ba6815b170d7d5c11282933b99730209f526ea Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 13:58:38 +0200 Subject: parentheses... --- lib/pleroma/gun/connection_pool/reclaimer.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/gun/connection_pool/reclaimer.ex b/lib/pleroma/gun/connection_pool/reclaimer.ex index 1793ac3ee..cea800882 100644 --- a/lib/pleroma/gun/connection_pool/reclaimer.ex +++ b/lib/pleroma/gun/connection_pool/reclaimer.ex @@ -3,7 +3,7 @@ defmodule Pleroma.Gun.ConnectionPool.Reclaimer do @registry Pleroma.Gun.ConnectionPool - def start_monitor() do + def start_monitor do pid = case :gen_server.start(__MODULE__, [], name: {:via, Registry, {@registry, "reclaimer"}}) do {:ok, pid} -> -- cgit v1.2.3 From 6b1f6a1cf7efc8bbaf099c7363a5aeadd256c781 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 14:59:10 +0200 Subject: Bump gun --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 4dfce58e7..db2e9324e 100644 --- a/mix.exs +++ b/mix.exs @@ -141,7 +141,7 @@ defp deps do {:castore, "~> 0.1"}, {:cowlib, "~> 2.8", override: true}, {:gun, - github: "ninenines/gun", ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc", override: true}, + github: "ninenines/gun", ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", override: true}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, {:ex_aws, "~> 2.1"}, diff --git a/mix.lock b/mix.lock index 89c97decf..06add0510 100644 --- a/mix.lock +++ b/mix.lock @@ -50,7 +50,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", "e1a69b36b180a574c0ac314ced9613fdd52312cc", [ref: "e1a69b36b180a574c0ac314ced9613fdd52312cc"]}, + "gun": {:git, "https://github.com/ninenines/gun.git", "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", [ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327"]}, "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [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]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "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"}, -- cgit v1.2.3 From ebfa59168942df9f8df73972a407cd2beada41e1 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 15:02:56 +0200 Subject: Go back to upstream Tesla --- mix.exs | 4 +--- mix.lock | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index db2e9324e..52b4cf268 100644 --- a/mix.exs +++ b/mix.exs @@ -135,9 +135,7 @@ defp deps do {:poison, "~> 3.0", override: true}, # {:tesla, "~> 1.3", override: true}, {:tesla, - git: "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", - ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b", - override: true}, + github: "teamon/tesla", ref: "af3707078b10793f6a534938e56b963aff82fe3c", override: true}, {:castore, "~> 0.1"}, {:cowlib, "~> 2.8", override: true}, {:gun, diff --git a/mix.lock b/mix.lock index 06add0510..8dd37a40f 100644 --- a/mix.lock +++ b/mix.lock @@ -108,7 +108,7 @@ "swoosh": {:git, "https://github.com/swoosh/swoosh", "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", [ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5"]}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, - "tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]}, + "tesla": {:git, "https://github.com/teamon/tesla.git", "af3707078b10793f6a534938e56b963aff82fe3c", [ref: "af3707078b10793f6a534938e56b963aff82fe3c"]}, "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, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, -- cgit v1.2.3 From ce1a42bd04bcf352ea1565b411444a98261b0a96 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 15:12:09 +0200 Subject: Simplify TLS opts - `verify_fun` is not useful now - use `customize_check_hostname` (OTP 20+ so OK) - `partial_chain` is useless as of OTP 21.1 (wasn't there, but hackney/.. uses it) --- lib/pleroma/gun/conn.ex | 5 ++--- lib/pleroma/http/adapter_helper/gun.ex | 28 ---------------------------- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 9dc8880db..5c12e8153 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -28,9 +28,8 @@ defp maybe_add_tls_opts(opts, %URI{scheme: "https", host: host}) do cacertfile: CAStore.file_path(), depth: 20, reuse_sessions: false, - verify_fun: - {&:ssl_verify_hostname.verify_fun/3, - [check_hostname: Pleroma.HTTP.AdapterHelper.format_host(host)]} + log_level: :warning, + customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] ] tls_opts = diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 74677ddb5..b4ff8306c 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -39,36 +39,8 @@ defp add_scheme_opts(opts, %{scheme: "http"}), do: opts defp add_scheme_opts(opts, %{scheme: "https"}) do opts |> Keyword.put(:certificates_verification, true) - |> Keyword.put(:tls_opts, - log_level: :warning, - customize_hostname_check: [match_fun: &ssl_match_fun/2] - ) end - # ssl_match_fun is adapted from [Mint](https://github.com/elixir-mint/mint) - # Copyright 2018 Eric Meadows-Jönsson and Andrea Leopardi - - # Wildcard domain handling for DNS ID entries in the subjectAltName X.509 - # extension. Note that this is a subset of the wildcard patterns implemented - # by OTP when matching against the subject CN attribute, but this is the only - # wildcard usage defined by the CA/Browser Forum's Baseline Requirements, and - # therefore the only pattern used in commercially issued certificates. - defp ssl_match_fun({:dns_id, reference}, {:dNSName, [?*, ?. | presented]}) do - case domain_without_host(reference) do - '' -> - :default - - domain -> - :string.casefold(domain) == :string.casefold(presented) - end - end - - defp ssl_match_fun(_reference, _presented), do: :default - - defp domain_without_host([]), do: [] - defp domain_without_host([?. | domain]), do: domain - defp domain_without_host([_ | more]), do: domain_without_host(more) - @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} | {:error, atom()} def get_conn(uri, opts) do case ConnectionPool.get_conn(uri, opts) do -- cgit v1.2.3 From afd378f84c4c1b784eba11b35c21e0b6ae3d7915 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 16:02:57 +0200 Subject: host is now useless --- lib/pleroma/gun/conn.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 5c12e8153..a3f75a4bb 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -22,7 +22,7 @@ def open(%URI{} = uri, opts) do defp maybe_add_tls_opts(opts, %URI{scheme: "http"}), do: opts - defp maybe_add_tls_opts(opts, %URI{scheme: "https", host: host}) do + defp maybe_add_tls_opts(opts, %URI{scheme: "https"}) do tls_opts = [ verify: :verify_peer, cacertfile: CAStore.file_path(), -- cgit v1.2.3 From e499275076422631b31f1455ab720aae9d7786d2 Mon Sep 17 00:00:00 2001 From: href Date: Wed, 8 Jul 2020 19:23:32 +0200 Subject: Don't test tls_options in adapter helper test. --- test/http/adapter_helper/gun_test.exs | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/http/adapter_helper/gun_test.exs b/test/http/adapter_helper/gun_test.exs index 49eebf355..80589c73d 100644 --- a/test/http/adapter_helper/gun_test.exs +++ b/test/http/adapter_helper/gun_test.exs @@ -21,7 +21,6 @@ test "https url with default port" do opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] - assert opts[:tls_opts][:log_level] == :warning end test "https ipv4 with default port" do @@ -29,7 +28,6 @@ test "https ipv4 with default port" do opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] - assert opts[:tls_opts][:log_level] == :warning end test "https ipv6 with default port" do @@ -37,7 +35,6 @@ test "https ipv6 with default port" do opts = Gun.options([receive_conn: false], uri) assert opts[:certificates_verification] - assert opts[:tls_opts][:log_level] == :warning end test "https url with non standart port" do -- cgit v1.2.3 From 6d583bcc3b23c0c16aefa3f34155e7e15b745b01 Mon Sep 17 00:00:00 2001 From: href Date: Mon, 13 Jul 2020 10:44:36 +0200 Subject: Set a default timeout for Gun adapter timeout --- lib/pleroma/http/adapter_helper.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 8ca433732..9ec3836b0 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -44,15 +44,17 @@ def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy) @spec options(URI.t(), keyword()) :: keyword() def options(%URI{} = uri, opts \\ []) do @defaults - |> pool_timeout() + |> put_timeout() |> Keyword.merge(opts) |> adapter_helper().options(uri) end - defp pool_timeout(opts) do + # For Hackney, this is the time a connection can stay idle in the pool. + # For Gun, this is the timeout to receive a message from Gun. + defp put_timeout(opts) do {config_key, default} = if adapter() == Tesla.Adapter.Gun do - {:pools, Config.get([:pools, :default, :timeout])} + {:pools, Config.get([:pools, :default, :timeout], 5_000)} else {:hackney_pools, 10_000} end -- cgit v1.2.3 From 7115c5f82efe1ca1817da3152ba3cbc66e0da1a4 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 15 Jul 2020 15:58:08 +0300 Subject: ConnectionPool.Worker: do not stop with an error when there is a timeout This produced error log messages about GenServer termination every time the connection was not open due to a timeout. Instead we stop with `{:shutdown, }` since shutting down when the connection can't be established is normal behavior. --- lib/pleroma/gun/connection_pool.ex | 5 ++++- lib/pleroma/gun/connection_pool/worker.ex | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index d3eead7d8..8b41a668c 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -53,7 +53,10 @@ defp get_gun_pid_from_worker(worker_pid, register) do {:ok, pid} {:DOWN, ^ref, :process, ^worker_pid, reason} -> - {:error, reason} + case reason do + {:shutdown, error} -> error + _ -> {:error, reason} + end end end diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 16a508ad9..f33447cb6 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -31,7 +31,7 @@ def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do :hibernate} else err -> - {:stop, err, nil} + {:stop, {:shutdown, err}, nil} end end -- cgit v1.2.3 From c413649a8db26db742ff53c6c09a9a3b96e8cb6a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 15 Jul 2020 16:20:17 +0300 Subject: Bring back oban job pruning Closes #1945 --- config/config.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.exs b/config/config.exs index 6fc84efc2..daeefdca3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -512,6 +512,7 @@ attachments_cleanup: 5, new_users_digest: 1 ], + plugins: [Oban.Plugins.Pruner], crontab: [ {"0 0 * * *", Pleroma.Workers.Cron.ClearOauthTokenWorker}, {"0 * * * *", Pleroma.Workers.Cron.StatsWorker}, -- cgit v1.2.3 From d29b8997f4a3601eac7f2e1e57de27a67df6699c Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 15 Jul 2020 15:25:33 +0200 Subject: MastoAPI: fix & test giving MRF reject reasons --- .../web/mastodon_api/controllers/status_controller.ex | 5 +++++ .../web/mastodon_api/controllers/status_controller_test.exs | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 12be530c9..9bb2ef117 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -172,6 +172,11 @@ def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, with_direct_conversation_id: true ) else + {:error, {:reject, message}} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{error: message}) + {:error, message} -> conn |> put_status(:unprocessable_entity) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index fd2de8d80..d34f300da 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -22,6 +22,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:instance, :federating]) setup do: clear_config([:instance, :allow_relay]) setup do: clear_config([:rich_media, :enabled]) + setup do: clear_config([:mrf, :policies]) + setup do: clear_config([:mrf_keyword, :reject]) describe "posting statuses" do setup do: oauth_access(["write:statuses"]) @@ -157,6 +159,17 @@ test "it fails to create a status if `expires_in` is less or equal than an hour" |> json_response_and_validate_schema(422) end + test "Get MRF reason when posting a status is rejected by one", %{conn: conn} do + Pleroma.Config.put([:mrf_keyword, :reject], ["GNO"]) + Pleroma.Config.put([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy]) + + assert %{"error" => "[KeywordPolicy] Matches with rejected keyword"} = + conn + |> put_req_header("content-type", "application/json") + |> post("api/v1/statuses", %{"status" => "GNO/Linux"}) + |> json_response_and_validate_schema(422) + end + test "posting an undefined status with an attachment", %{user: user, conn: conn} do file = %Plug.Upload{ content_type: "image/jpg", -- cgit v1.2.3 From 34d1d3e93e8642f2b784b5b957551af068c0f7ba Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 15 Jul 2020 09:38:56 -0500 Subject: Update FE bundle --- priv/static/index.html | 2 +- .../static/static/css/app.6dbc7dea4fc148c85860.css | Bin 0 -> 5616 bytes .../static/css/app.6dbc7dea4fc148c85860.css.map | 1 + .../static/static/css/app.77b1644622e3bae24b6b.css | Bin 5616 -> 0 bytes .../static/css/app.77b1644622e3bae24b6b.css.map | 1 - priv/static/static/font/fontello.1594374054351.eot | Bin 24524 -> 0 bytes priv/static/static/font/fontello.1594374054351.svg | 138 --------------------- priv/static/static/font/fontello.1594374054351.ttf | Bin 24356 -> 0 bytes .../static/static/font/fontello.1594374054351.woff | Bin 14912 -> 0 bytes .../static/font/fontello.1594374054351.woff2 | Bin 12540 -> 0 bytes priv/static/static/font/fontello.1594823398494.eot | Bin 0 -> 24524 bytes priv/static/static/font/fontello.1594823398494.svg | 138 +++++++++++++++++++++ priv/static/static/font/fontello.1594823398494.ttf | Bin 0 -> 24356 bytes .../static/static/font/fontello.1594823398494.woff | Bin 0 -> 14912 bytes .../static/font/fontello.1594823398494.woff2 | Bin 0 -> 12584 bytes priv/static/static/fontello.1589385935077.css | Bin 3421 -> 0 bytes priv/static/static/fontello.1594030805019.css | Bin 3609 -> 0 bytes priv/static/static/fontello.1594134783339.css | Bin 3693 -> 0 bytes priv/static/static/fontello.1594374054351.css | Bin 3736 -> 0 bytes priv/static/static/fontello.1594823398494.css | Bin 0 -> 3736 bytes priv/static/static/fontello.json | 0 priv/static/static/js/10.2823375ec309b971aaea.js | Bin 23120 -> 0 bytes .../static/js/10.2823375ec309b971aaea.js.map | Bin 113 -> 0 bytes priv/static/static/js/10.5ef4671883649cf93524.js | Bin 0 -> 22666 bytes .../static/js/10.5ef4671883649cf93524.js.map | Bin 0 -> 113 bytes priv/static/static/js/11.2cb4b0f72a4654070a58.js | Bin 16564 -> 0 bytes .../static/js/11.2cb4b0f72a4654070a58.js.map | Bin 113 -> 0 bytes priv/static/static/js/11.c5b938b4349f87567338.js | Bin 0 -> 16124 bytes .../static/js/11.c5b938b4349f87567338.js.map | Bin 0 -> 113 bytes priv/static/static/js/12.500b3e4676dd47599a58.js | Bin 22582 -> 0 bytes .../static/js/12.500b3e4676dd47599a58.js.map | Bin 113 -> 0 bytes priv/static/static/js/12.ab82f9512fa85e78c114.js | Bin 0 -> 22115 bytes .../static/js/12.ab82f9512fa85e78c114.js.map | Bin 0 -> 113 bytes priv/static/static/js/13.3ef79a2643680080d28f.js | Bin 26143 -> 0 bytes .../static/js/13.3ef79a2643680080d28f.js.map | Bin 113 -> 0 bytes priv/static/static/js/13.40e59c5015d3307b94ad.js | Bin 0 -> 27100 bytes .../static/js/13.40e59c5015d3307b94ad.js.map | Bin 0 -> 113 bytes priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js | Bin 28652 -> 0 bytes .../static/js/14.b7f6eb3ea71d2ac2bb41.js.map | Bin 113 -> 0 bytes priv/static/static/js/14.de791a47ee5249a526b1.js | Bin 0 -> 28164 bytes .../static/js/14.de791a47ee5249a526b1.js.map | Bin 0 -> 113 bytes priv/static/static/js/15.d814a29a970070494722.js | Bin 7939 -> 0 bytes .../static/js/15.d814a29a970070494722.js.map | Bin 113 -> 0 bytes priv/static/static/js/15.e24854297ad682aec45a.js | Bin 0 -> 7787 bytes .../static/js/15.e24854297ad682aec45a.js.map | Bin 0 -> 113 bytes priv/static/static/js/16.017fa510b293035ac370.js | Bin 15892 -> 0 bytes .../static/js/16.017fa510b293035ac370.js.map | Bin 113 -> 0 bytes priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js | Bin 0 -> 15702 bytes .../static/js/16.b7b0e4b8227a50fcb9bb.js.map | Bin 0 -> 113 bytes priv/static/static/js/17.c63932b65417ee7346a3.js | Bin 2234 -> 0 bytes .../static/js/17.c63932b65417ee7346a3.js.map | Bin 113 -> 0 bytes priv/static/static/js/17.c98118b6bb84ee3b5b08.js | Bin 0 -> 2086 bytes .../static/js/17.c98118b6bb84ee3b5b08.js.map | Bin 0 -> 113 bytes priv/static/static/js/18.89c20aa67a4dd067ea37.js | Bin 0 -> 28169 bytes .../static/js/18.89c20aa67a4dd067ea37.js.map | Bin 0 -> 113 bytes priv/static/static/js/18.fd12f9746a55aa24a8b7.js | Bin 23585 -> 0 bytes .../static/js/18.fd12f9746a55aa24a8b7.js.map | Bin 113 -> 0 bytes priv/static/static/js/19.3adebd64964c92700074.js | Bin 32200 -> 0 bytes .../static/js/19.3adebd64964c92700074.js.map | Bin 113 -> 0 bytes priv/static/static/js/19.6e13bad8131c4501c1c5.js | Bin 0 -> 31632 bytes .../static/js/19.6e13bad8131c4501c1c5.js.map | Bin 0 -> 113 bytes priv/static/static/js/2.78a48aa26599b00c3b8d.js | Bin 0 -> 178659 bytes .../static/static/js/2.78a48aa26599b00c3b8d.js.map | Bin 0 -> 459053 bytes priv/static/static/js/2.d81ca020d6885c6c3b03.js | Bin 179851 -> 0 bytes .../static/static/js/2.d81ca020d6885c6c3b03.js.map | Bin 461434 -> 0 bytes priv/static/static/js/20.3615c3cea2e1c2707a4f.js | Bin 0 -> 26374 bytes .../static/js/20.3615c3cea2e1c2707a4f.js.map | Bin 0 -> 113 bytes priv/static/static/js/20.e0c3ad29d59470506c04.js | Bin 26951 -> 0 bytes .../static/js/20.e0c3ad29d59470506c04.js.map | Bin 113 -> 0 bytes priv/static/static/js/21.64dedfc646e13e6f7915.js | Bin 0 -> 13162 bytes .../static/js/21.64dedfc646e13e6f7915.js.map | Bin 0 -> 113 bytes priv/static/static/js/21.849ecc09a1d58bdc64c6.js | Bin 13310 -> 0 bytes .../static/js/21.849ecc09a1d58bdc64c6.js.map | Bin 113 -> 0 bytes priv/static/static/js/22.6fa63bc6a054b7638e9e.js | Bin 0 -> 19706 bytes .../static/js/22.6fa63bc6a054b7638e9e.js.map | Bin 0 -> 113 bytes priv/static/static/js/22.8782f133c9f66d3f2bbe.js | Bin 20130 -> 0 bytes .../static/js/22.8782f133c9f66d3f2bbe.js.map | Bin 113 -> 0 bytes priv/static/static/js/23.2653bf91bc77c2ed0160.js | Bin 28187 -> 0 bytes .../static/js/23.2653bf91bc77c2ed0160.js.map | Bin 113 -> 0 bytes priv/static/static/js/23.e0ddea2b6e049d221ee7.js | Bin 0 -> 27732 bytes .../static/js/23.e0ddea2b6e049d221ee7.js.map | Bin 0 -> 113 bytes priv/static/static/js/24.38e3b9d44e9ee703ebf6.js | Bin 0 -> 18493 bytes .../static/js/24.38e3b9d44e9ee703ebf6.js.map | Bin 0 -> 113 bytes priv/static/static/js/24.f931d864a2297d880a9a.js | Bin 18949 -> 0 bytes .../static/js/24.f931d864a2297d880a9a.js.map | Bin 113 -> 0 bytes priv/static/static/js/25.696b41c0a8660e1f85af.js | Bin 0 -> 26932 bytes .../static/js/25.696b41c0a8660e1f85af.js.map | Bin 0 -> 113 bytes priv/static/static/js/25.886acc9ba83c64659279.js | Bin 27408 -> 0 bytes .../static/js/25.886acc9ba83c64659279.js.map | Bin 113 -> 0 bytes priv/static/static/js/26.1168f22384be75dc5492.js | Bin 0 -> 14249 bytes .../static/js/26.1168f22384be75dc5492.js.map | Bin 0 -> 113 bytes priv/static/static/js/26.e15b1645079c72c60586.js | Bin 14415 -> 0 bytes .../static/js/26.e15b1645079c72c60586.js.map | Bin 113 -> 0 bytes priv/static/static/js/27.3c0cfbb2a898b35486dd.js | Bin 0 -> 2022 bytes .../static/js/27.3c0cfbb2a898b35486dd.js.map | Bin 0 -> 113 bytes priv/static/static/js/27.7b41e5953f74af7fddd1.js | Bin 2175 -> 0 bytes .../static/js/27.7b41e5953f74af7fddd1.js.map | Bin 113 -> 0 bytes priv/static/static/js/28.4f39e562aaceaa01e883.js | Bin 25778 -> 0 bytes .../static/js/28.4f39e562aaceaa01e883.js.map | Bin 113 -> 0 bytes priv/static/static/js/28.926c71d6f1813e177271.js | Bin 0 -> 25289 bytes .../static/js/28.926c71d6f1813e177271.js.map | Bin 0 -> 113 bytes priv/static/static/js/29.137e2a68b558eed58152.js | Bin 24135 -> 0 bytes .../static/js/29.137e2a68b558eed58152.js.map | Bin 113 -> 0 bytes priv/static/static/js/29.187064ebed099ae45749.js | Bin 0 -> 23857 bytes .../static/js/29.187064ebed099ae45749.js.map | Bin 0 -> 113 bytes priv/static/static/js/30.73e09f3b43617410dec7.js | Bin 21485 -> 0 bytes .../static/js/30.73e09f3b43617410dec7.js.map | Bin 113 -> 0 bytes priv/static/static/js/30.d78855ca19bf749be905.js | Bin 0 -> 21107 bytes .../static/js/30.d78855ca19bf749be905.js.map | Bin 0 -> 113 bytes priv/static/static/js/5.2b4a2787bacdd3d910db.js | Bin 7028 -> 0 bytes .../static/static/js/5.2b4a2787bacdd3d910db.js.map | Bin 112 -> 0 bytes priv/static/static/js/5.84f3dce298bc720719c7.js | Bin 0 -> 6994 bytes .../static/static/js/5.84f3dce298bc720719c7.js.map | Bin 0 -> 112 bytes priv/static/static/js/6.9c94bc0cc78979694cf4.js | Bin 7955 -> 0 bytes .../static/static/js/6.9c94bc0cc78979694cf4.js.map | Bin 112 -> 0 bytes priv/static/static/js/6.b9497e1d403b901a664e.js | Bin 0 -> 7790 bytes .../static/static/js/6.b9497e1d403b901a664e.js.map | Bin 0 -> 112 bytes priv/static/static/js/7.455b574116ce3f004ffb.js | Bin 0 -> 15618 bytes .../static/static/js/7.455b574116ce3f004ffb.js.map | Bin 0 -> 112 bytes priv/static/static/js/7.b4ac57fd946a3a189047.js | Bin 15765 -> 0 bytes .../static/static/js/7.b4ac57fd946a3a189047.js.map | Bin 112 -> 0 bytes priv/static/static/js/8.8db9f2dcc5ed429777f7.js | Bin 0 -> 21682 bytes .../static/static/js/8.8db9f2dcc5ed429777f7.js.map | Bin 0 -> 112 bytes priv/static/static/js/8.e03e32ca713d01db0433.js | Bin 21966 -> 0 bytes .../static/static/js/8.e03e32ca713d01db0433.js.map | Bin 112 -> 0 bytes priv/static/static/js/9.72d903ca8e0c5a532b87.js | Bin 13880 -> 0 bytes .../static/static/js/9.72d903ca8e0c5a532b87.js.map | Bin 112 -> 0 bytes priv/static/static/js/9.da3973d058660aa9612f.js | Bin 0 -> 13753 bytes .../static/static/js/9.da3973d058660aa9612f.js.map | Bin 0 -> 112 bytes priv/static/static/js/app.1e68e208590653dab5aa.js | Bin 571655 -> 0 bytes .../static/js/app.1e68e208590653dab5aa.js.map | Bin 1463721 -> 0 bytes priv/static/static/js/app.31bba9f1e242ff273dcb.js | Bin 0 -> 572414 bytes .../static/js/app.31bba9f1e242ff273dcb.js.map | Bin 0 -> 1465392 bytes .../static/js/vendors~app.247dc52c7abe6a0dab87.js | Bin 304076 -> 0 bytes .../js/vendors~app.247dc52c7abe6a0dab87.js.map | Bin 1274957 -> 0 bytes .../static/js/vendors~app.9e24ed238da5a8538f50.js | Bin 0 -> 304076 bytes .../js/vendors~app.9e24ed238da5a8538f50.js.map | Bin 0 -> 1274957 bytes priv/static/sw-pleroma.js | Bin 181435 -> 181435 bytes 138 files changed, 140 insertions(+), 140 deletions(-) create mode 100644 priv/static/static/css/app.6dbc7dea4fc148c85860.css create mode 100644 priv/static/static/css/app.6dbc7dea4fc148c85860.css.map delete mode 100644 priv/static/static/css/app.77b1644622e3bae24b6b.css delete mode 100644 priv/static/static/css/app.77b1644622e3bae24b6b.css.map delete mode 100644 priv/static/static/font/fontello.1594374054351.eot delete mode 100644 priv/static/static/font/fontello.1594374054351.svg delete mode 100644 priv/static/static/font/fontello.1594374054351.ttf delete mode 100644 priv/static/static/font/fontello.1594374054351.woff delete mode 100644 priv/static/static/font/fontello.1594374054351.woff2 create mode 100644 priv/static/static/font/fontello.1594823398494.eot create mode 100644 priv/static/static/font/fontello.1594823398494.svg create mode 100644 priv/static/static/font/fontello.1594823398494.ttf create mode 100644 priv/static/static/font/fontello.1594823398494.woff create mode 100644 priv/static/static/font/fontello.1594823398494.woff2 delete mode 100644 priv/static/static/fontello.1589385935077.css delete mode 100644 priv/static/static/fontello.1594030805019.css delete mode 100644 priv/static/static/fontello.1594134783339.css delete mode 100644 priv/static/static/fontello.1594374054351.css create mode 100644 priv/static/static/fontello.1594823398494.css mode change 100755 => 100644 priv/static/static/fontello.json delete mode 100644 priv/static/static/js/10.2823375ec309b971aaea.js delete mode 100644 priv/static/static/js/10.2823375ec309b971aaea.js.map create mode 100644 priv/static/static/js/10.5ef4671883649cf93524.js create mode 100644 priv/static/static/js/10.5ef4671883649cf93524.js.map delete mode 100644 priv/static/static/js/11.2cb4b0f72a4654070a58.js delete mode 100644 priv/static/static/js/11.2cb4b0f72a4654070a58.js.map create mode 100644 priv/static/static/js/11.c5b938b4349f87567338.js create mode 100644 priv/static/static/js/11.c5b938b4349f87567338.js.map delete mode 100644 priv/static/static/js/12.500b3e4676dd47599a58.js delete mode 100644 priv/static/static/js/12.500b3e4676dd47599a58.js.map create mode 100644 priv/static/static/js/12.ab82f9512fa85e78c114.js create mode 100644 priv/static/static/js/12.ab82f9512fa85e78c114.js.map delete mode 100644 priv/static/static/js/13.3ef79a2643680080d28f.js delete mode 100644 priv/static/static/js/13.3ef79a2643680080d28f.js.map create mode 100644 priv/static/static/js/13.40e59c5015d3307b94ad.js create mode 100644 priv/static/static/js/13.40e59c5015d3307b94ad.js.map delete mode 100644 priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js delete mode 100644 priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map create mode 100644 priv/static/static/js/14.de791a47ee5249a526b1.js create mode 100644 priv/static/static/js/14.de791a47ee5249a526b1.js.map delete mode 100644 priv/static/static/js/15.d814a29a970070494722.js delete mode 100644 priv/static/static/js/15.d814a29a970070494722.js.map create mode 100644 priv/static/static/js/15.e24854297ad682aec45a.js create mode 100644 priv/static/static/js/15.e24854297ad682aec45a.js.map delete mode 100644 priv/static/static/js/16.017fa510b293035ac370.js delete mode 100644 priv/static/static/js/16.017fa510b293035ac370.js.map create mode 100644 priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js create mode 100644 priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map delete mode 100644 priv/static/static/js/17.c63932b65417ee7346a3.js delete mode 100644 priv/static/static/js/17.c63932b65417ee7346a3.js.map create mode 100644 priv/static/static/js/17.c98118b6bb84ee3b5b08.js create mode 100644 priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map create mode 100644 priv/static/static/js/18.89c20aa67a4dd067ea37.js create mode 100644 priv/static/static/js/18.89c20aa67a4dd067ea37.js.map delete mode 100644 priv/static/static/js/18.fd12f9746a55aa24a8b7.js delete mode 100644 priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map delete mode 100644 priv/static/static/js/19.3adebd64964c92700074.js delete mode 100644 priv/static/static/js/19.3adebd64964c92700074.js.map create mode 100644 priv/static/static/js/19.6e13bad8131c4501c1c5.js create mode 100644 priv/static/static/js/19.6e13bad8131c4501c1c5.js.map create mode 100644 priv/static/static/js/2.78a48aa26599b00c3b8d.js create mode 100644 priv/static/static/js/2.78a48aa26599b00c3b8d.js.map delete mode 100644 priv/static/static/js/2.d81ca020d6885c6c3b03.js delete mode 100644 priv/static/static/js/2.d81ca020d6885c6c3b03.js.map create mode 100644 priv/static/static/js/20.3615c3cea2e1c2707a4f.js create mode 100644 priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map delete mode 100644 priv/static/static/js/20.e0c3ad29d59470506c04.js delete mode 100644 priv/static/static/js/20.e0c3ad29d59470506c04.js.map create mode 100644 priv/static/static/js/21.64dedfc646e13e6f7915.js create mode 100644 priv/static/static/js/21.64dedfc646e13e6f7915.js.map delete mode 100644 priv/static/static/js/21.849ecc09a1d58bdc64c6.js delete mode 100644 priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map create mode 100644 priv/static/static/js/22.6fa63bc6a054b7638e9e.js create mode 100644 priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map delete mode 100644 priv/static/static/js/22.8782f133c9f66d3f2bbe.js delete mode 100644 priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map delete mode 100644 priv/static/static/js/23.2653bf91bc77c2ed0160.js delete mode 100644 priv/static/static/js/23.2653bf91bc77c2ed0160.js.map create mode 100644 priv/static/static/js/23.e0ddea2b6e049d221ee7.js create mode 100644 priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map create mode 100644 priv/static/static/js/24.38e3b9d44e9ee703ebf6.js create mode 100644 priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map delete mode 100644 priv/static/static/js/24.f931d864a2297d880a9a.js delete mode 100644 priv/static/static/js/24.f931d864a2297d880a9a.js.map create mode 100644 priv/static/static/js/25.696b41c0a8660e1f85af.js create mode 100644 priv/static/static/js/25.696b41c0a8660e1f85af.js.map delete mode 100644 priv/static/static/js/25.886acc9ba83c64659279.js delete mode 100644 priv/static/static/js/25.886acc9ba83c64659279.js.map create mode 100644 priv/static/static/js/26.1168f22384be75dc5492.js create mode 100644 priv/static/static/js/26.1168f22384be75dc5492.js.map delete mode 100644 priv/static/static/js/26.e15b1645079c72c60586.js delete mode 100644 priv/static/static/js/26.e15b1645079c72c60586.js.map create mode 100644 priv/static/static/js/27.3c0cfbb2a898b35486dd.js create mode 100644 priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map delete mode 100644 priv/static/static/js/27.7b41e5953f74af7fddd1.js delete mode 100644 priv/static/static/js/27.7b41e5953f74af7fddd1.js.map delete mode 100644 priv/static/static/js/28.4f39e562aaceaa01e883.js delete mode 100644 priv/static/static/js/28.4f39e562aaceaa01e883.js.map create mode 100644 priv/static/static/js/28.926c71d6f1813e177271.js create mode 100644 priv/static/static/js/28.926c71d6f1813e177271.js.map delete mode 100644 priv/static/static/js/29.137e2a68b558eed58152.js delete mode 100644 priv/static/static/js/29.137e2a68b558eed58152.js.map create mode 100644 priv/static/static/js/29.187064ebed099ae45749.js create mode 100644 priv/static/static/js/29.187064ebed099ae45749.js.map delete mode 100644 priv/static/static/js/30.73e09f3b43617410dec7.js delete mode 100644 priv/static/static/js/30.73e09f3b43617410dec7.js.map create mode 100644 priv/static/static/js/30.d78855ca19bf749be905.js create mode 100644 priv/static/static/js/30.d78855ca19bf749be905.js.map delete mode 100644 priv/static/static/js/5.2b4a2787bacdd3d910db.js delete mode 100644 priv/static/static/js/5.2b4a2787bacdd3d910db.js.map create mode 100644 priv/static/static/js/5.84f3dce298bc720719c7.js create mode 100644 priv/static/static/js/5.84f3dce298bc720719c7.js.map delete mode 100644 priv/static/static/js/6.9c94bc0cc78979694cf4.js delete mode 100644 priv/static/static/js/6.9c94bc0cc78979694cf4.js.map create mode 100644 priv/static/static/js/6.b9497e1d403b901a664e.js create mode 100644 priv/static/static/js/6.b9497e1d403b901a664e.js.map create mode 100644 priv/static/static/js/7.455b574116ce3f004ffb.js create mode 100644 priv/static/static/js/7.455b574116ce3f004ffb.js.map delete mode 100644 priv/static/static/js/7.b4ac57fd946a3a189047.js delete mode 100644 priv/static/static/js/7.b4ac57fd946a3a189047.js.map create mode 100644 priv/static/static/js/8.8db9f2dcc5ed429777f7.js create mode 100644 priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map delete mode 100644 priv/static/static/js/8.e03e32ca713d01db0433.js delete mode 100644 priv/static/static/js/8.e03e32ca713d01db0433.js.map delete mode 100644 priv/static/static/js/9.72d903ca8e0c5a532b87.js delete mode 100644 priv/static/static/js/9.72d903ca8e0c5a532b87.js.map create mode 100644 priv/static/static/js/9.da3973d058660aa9612f.js create mode 100644 priv/static/static/js/9.da3973d058660aa9612f.js.map delete mode 100644 priv/static/static/js/app.1e68e208590653dab5aa.js delete mode 100644 priv/static/static/js/app.1e68e208590653dab5aa.js.map create mode 100644 priv/static/static/js/app.31bba9f1e242ff273dcb.js create mode 100644 priv/static/static/js/app.31bba9f1e242ff273dcb.js.map delete mode 100644 priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js delete mode 100644 priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map create mode 100644 priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js create mode 100644 priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map diff --git a/priv/static/index.html b/priv/static/index.html index 80820166a..2257dec35 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/css/app.6dbc7dea4fc148c85860.css b/priv/static/static/css/app.6dbc7dea4fc148c85860.css new file mode 100644 index 000000000..3927e3b77 Binary files /dev/null and b/priv/static/static/css/app.6dbc7dea4fc148c85860.css differ diff --git a/priv/static/static/css/app.6dbc7dea4fc148c85860.css.map b/priv/static/static/css/app.6dbc7dea4fc148c85860.css.map new file mode 100644 index 000000000..963d5b3b8 --- /dev/null +++ b/priv/static/static/css/app.6dbc7dea4fc148c85860.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.6dbc7dea4fc148c85860.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css b/priv/static/static/css/app.77b1644622e3bae24b6b.css deleted file mode 100644 index 8038882c0..000000000 Binary files a/priv/static/static/css/app.77b1644622e3bae24b6b.css and /dev/null differ diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css.map b/priv/static/static/css/app.77b1644622e3bae24b6b.css.map deleted file mode 100644 index 4b042ef35..000000000 --- a/priv/static/static/css/app.77b1644622e3bae24b6b.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.77b1644622e3bae24b6b.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594374054351.eot b/priv/static/static/font/fontello.1594374054351.eot deleted file mode 100644 index 62b619386..000000000 Binary files a/priv/static/static/font/fontello.1594374054351.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1594374054351.svg b/priv/static/static/font/fontello.1594374054351.svg deleted file mode 100644 index 71b5d70af..000000000 --- a/priv/static/static/font/fontello.1594374054351.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594374054351.ttf b/priv/static/static/font/fontello.1594374054351.ttf deleted file mode 100644 index be55bef81..000000000 Binary files a/priv/static/static/font/fontello.1594374054351.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1594374054351.woff b/priv/static/static/font/fontello.1594374054351.woff deleted file mode 100644 index 115945f70..000000000 Binary files a/priv/static/static/font/fontello.1594374054351.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1594374054351.woff2 b/priv/static/static/font/fontello.1594374054351.woff2 deleted file mode 100644 index cb214aab3..000000000 Binary files a/priv/static/static/font/fontello.1594374054351.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1594823398494.eot b/priv/static/static/font/fontello.1594823398494.eot new file mode 100644 index 000000000..12e6beabf Binary files /dev/null and b/priv/static/static/font/fontello.1594823398494.eot differ diff --git a/priv/static/static/font/fontello.1594823398494.svg b/priv/static/static/font/fontello.1594823398494.svg new file mode 100644 index 000000000..71b5d70af --- /dev/null +++ b/priv/static/static/font/fontello.1594823398494.svg @@ -0,0 +1,138 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594823398494.ttf b/priv/static/static/font/fontello.1594823398494.ttf new file mode 100644 index 000000000..6f21845a8 Binary files /dev/null and b/priv/static/static/font/fontello.1594823398494.ttf differ diff --git a/priv/static/static/font/fontello.1594823398494.woff b/priv/static/static/font/fontello.1594823398494.woff new file mode 100644 index 000000000..a7cd098f4 Binary files /dev/null and b/priv/static/static/font/fontello.1594823398494.woff differ diff --git a/priv/static/static/font/fontello.1594823398494.woff2 b/priv/static/static/font/fontello.1594823398494.woff2 new file mode 100644 index 000000000..c61bf111a Binary files /dev/null and b/priv/static/static/font/fontello.1594823398494.woff2 differ diff --git a/priv/static/static/fontello.1589385935077.css b/priv/static/static/fontello.1589385935077.css deleted file mode 100644 index 746492163..000000000 Binary files a/priv/static/static/fontello.1589385935077.css and /dev/null differ diff --git a/priv/static/static/fontello.1594030805019.css b/priv/static/static/fontello.1594030805019.css deleted file mode 100644 index 9251070fe..000000000 Binary files a/priv/static/static/fontello.1594030805019.css and /dev/null differ diff --git a/priv/static/static/fontello.1594134783339.css b/priv/static/static/fontello.1594134783339.css deleted file mode 100644 index ff35edaba..000000000 Binary files a/priv/static/static/fontello.1594134783339.css and /dev/null differ diff --git a/priv/static/static/fontello.1594374054351.css b/priv/static/static/fontello.1594374054351.css deleted file mode 100644 index 6dea8ee3e..000000000 Binary files a/priv/static/static/fontello.1594374054351.css and /dev/null differ diff --git a/priv/static/static/fontello.1594823398494.css b/priv/static/static/fontello.1594823398494.css new file mode 100644 index 000000000..fe61b94c6 Binary files /dev/null and b/priv/static/static/fontello.1594823398494.css differ diff --git a/priv/static/static/fontello.json b/priv/static/static/fontello.json old mode 100755 new mode 100644 diff --git a/priv/static/static/js/10.2823375ec309b971aaea.js b/priv/static/static/js/10.2823375ec309b971aaea.js deleted file mode 100644 index 8f34c42ea..000000000 Binary files a/priv/static/static/js/10.2823375ec309b971aaea.js and /dev/null differ diff --git a/priv/static/static/js/10.2823375ec309b971aaea.js.map b/priv/static/static/js/10.2823375ec309b971aaea.js.map deleted file mode 100644 index 8933e2336..000000000 Binary files a/priv/static/static/js/10.2823375ec309b971aaea.js.map and /dev/null differ diff --git a/priv/static/static/js/10.5ef4671883649cf93524.js b/priv/static/static/js/10.5ef4671883649cf93524.js new file mode 100644 index 000000000..6819c854b Binary files /dev/null and b/priv/static/static/js/10.5ef4671883649cf93524.js differ diff --git a/priv/static/static/js/10.5ef4671883649cf93524.js.map b/priv/static/static/js/10.5ef4671883649cf93524.js.map new file mode 100644 index 000000000..95fa2207e Binary files /dev/null and b/priv/static/static/js/10.5ef4671883649cf93524.js.map differ diff --git a/priv/static/static/js/11.2cb4b0f72a4654070a58.js b/priv/static/static/js/11.2cb4b0f72a4654070a58.js deleted file mode 100644 index 03b0234f2..000000000 Binary files a/priv/static/static/js/11.2cb4b0f72a4654070a58.js and /dev/null differ diff --git a/priv/static/static/js/11.2cb4b0f72a4654070a58.js.map b/priv/static/static/js/11.2cb4b0f72a4654070a58.js.map deleted file mode 100644 index b53e5e23a..000000000 Binary files a/priv/static/static/js/11.2cb4b0f72a4654070a58.js.map and /dev/null differ diff --git a/priv/static/static/js/11.c5b938b4349f87567338.js b/priv/static/static/js/11.c5b938b4349f87567338.js new file mode 100644 index 000000000..b97f69bf3 Binary files /dev/null and b/priv/static/static/js/11.c5b938b4349f87567338.js differ diff --git a/priv/static/static/js/11.c5b938b4349f87567338.js.map b/priv/static/static/js/11.c5b938b4349f87567338.js.map new file mode 100644 index 000000000..5ccf83b1d Binary files /dev/null and b/priv/static/static/js/11.c5b938b4349f87567338.js.map differ diff --git a/priv/static/static/js/12.500b3e4676dd47599a58.js b/priv/static/static/js/12.500b3e4676dd47599a58.js deleted file mode 100644 index 52dfbde92..000000000 Binary files a/priv/static/static/js/12.500b3e4676dd47599a58.js and /dev/null differ diff --git a/priv/static/static/js/12.500b3e4676dd47599a58.js.map b/priv/static/static/js/12.500b3e4676dd47599a58.js.map deleted file mode 100644 index 700da90b0..000000000 Binary files a/priv/static/static/js/12.500b3e4676dd47599a58.js.map and /dev/null differ diff --git a/priv/static/static/js/12.ab82f9512fa85e78c114.js b/priv/static/static/js/12.ab82f9512fa85e78c114.js new file mode 100644 index 000000000..100d72b33 Binary files /dev/null and b/priv/static/static/js/12.ab82f9512fa85e78c114.js differ diff --git a/priv/static/static/js/12.ab82f9512fa85e78c114.js.map b/priv/static/static/js/12.ab82f9512fa85e78c114.js.map new file mode 100644 index 000000000..23335ae23 Binary files /dev/null and b/priv/static/static/js/12.ab82f9512fa85e78c114.js.map differ diff --git a/priv/static/static/js/13.3ef79a2643680080d28f.js b/priv/static/static/js/13.3ef79a2643680080d28f.js deleted file mode 100644 index 4070d1f3f..000000000 Binary files a/priv/static/static/js/13.3ef79a2643680080d28f.js and /dev/null differ diff --git a/priv/static/static/js/13.3ef79a2643680080d28f.js.map b/priv/static/static/js/13.3ef79a2643680080d28f.js.map deleted file mode 100644 index bb2f24e3a..000000000 Binary files a/priv/static/static/js/13.3ef79a2643680080d28f.js.map and /dev/null differ diff --git a/priv/static/static/js/13.40e59c5015d3307b94ad.js b/priv/static/static/js/13.40e59c5015d3307b94ad.js new file mode 100644 index 000000000..2088bb6b7 Binary files /dev/null and b/priv/static/static/js/13.40e59c5015d3307b94ad.js differ diff --git a/priv/static/static/js/13.40e59c5015d3307b94ad.js.map b/priv/static/static/js/13.40e59c5015d3307b94ad.js.map new file mode 100644 index 000000000..3931b5ef9 Binary files /dev/null and b/priv/static/static/js/13.40e59c5015d3307b94ad.js.map differ diff --git a/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js b/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js deleted file mode 100644 index 316ba1291..000000000 Binary files a/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js and /dev/null differ diff --git a/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map b/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map deleted file mode 100644 index 07a26f298..000000000 Binary files a/priv/static/static/js/14.b7f6eb3ea71d2ac2bb41.js.map and /dev/null differ diff --git a/priv/static/static/js/14.de791a47ee5249a526b1.js b/priv/static/static/js/14.de791a47ee5249a526b1.js new file mode 100644 index 000000000..0e341275e Binary files /dev/null and b/priv/static/static/js/14.de791a47ee5249a526b1.js differ diff --git a/priv/static/static/js/14.de791a47ee5249a526b1.js.map b/priv/static/static/js/14.de791a47ee5249a526b1.js.map new file mode 100644 index 000000000..4bef54546 Binary files /dev/null and b/priv/static/static/js/14.de791a47ee5249a526b1.js.map differ diff --git a/priv/static/static/js/15.d814a29a970070494722.js b/priv/static/static/js/15.d814a29a970070494722.js deleted file mode 100644 index 17eaf5218..000000000 Binary files a/priv/static/static/js/15.d814a29a970070494722.js and /dev/null differ diff --git a/priv/static/static/js/15.d814a29a970070494722.js.map b/priv/static/static/js/15.d814a29a970070494722.js.map deleted file mode 100644 index 9792088bf..000000000 Binary files a/priv/static/static/js/15.d814a29a970070494722.js.map and /dev/null differ diff --git a/priv/static/static/js/15.e24854297ad682aec45a.js b/priv/static/static/js/15.e24854297ad682aec45a.js new file mode 100644 index 000000000..671370192 Binary files /dev/null and b/priv/static/static/js/15.e24854297ad682aec45a.js differ diff --git a/priv/static/static/js/15.e24854297ad682aec45a.js.map b/priv/static/static/js/15.e24854297ad682aec45a.js.map new file mode 100644 index 000000000..89789a542 Binary files /dev/null and b/priv/static/static/js/15.e24854297ad682aec45a.js.map differ diff --git a/priv/static/static/js/16.017fa510b293035ac370.js b/priv/static/static/js/16.017fa510b293035ac370.js deleted file mode 100644 index 387cfc9c7..000000000 Binary files a/priv/static/static/js/16.017fa510b293035ac370.js and /dev/null differ diff --git a/priv/static/static/js/16.017fa510b293035ac370.js.map b/priv/static/static/js/16.017fa510b293035ac370.js.map deleted file mode 100644 index 2886028bd..000000000 Binary files a/priv/static/static/js/16.017fa510b293035ac370.js.map and /dev/null differ diff --git a/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js b/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js new file mode 100644 index 000000000..6a3ea9513 Binary files /dev/null and b/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js differ diff --git a/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map b/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map new file mode 100644 index 000000000..fec45b087 Binary files /dev/null and b/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map differ diff --git a/priv/static/static/js/17.c63932b65417ee7346a3.js b/priv/static/static/js/17.c63932b65417ee7346a3.js deleted file mode 100644 index e3172472a..000000000 Binary files a/priv/static/static/js/17.c63932b65417ee7346a3.js and /dev/null differ diff --git a/priv/static/static/js/17.c63932b65417ee7346a3.js.map b/priv/static/static/js/17.c63932b65417ee7346a3.js.map deleted file mode 100644 index f4c55d0cc..000000000 Binary files a/priv/static/static/js/17.c63932b65417ee7346a3.js.map and /dev/null differ diff --git a/priv/static/static/js/17.c98118b6bb84ee3b5b08.js b/priv/static/static/js/17.c98118b6bb84ee3b5b08.js new file mode 100644 index 000000000..c41f0b6b8 Binary files /dev/null and b/priv/static/static/js/17.c98118b6bb84ee3b5b08.js differ diff --git a/priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map b/priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map new file mode 100644 index 000000000..0c20fc89b Binary files /dev/null and b/priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map differ diff --git a/priv/static/static/js/18.89c20aa67a4dd067ea37.js b/priv/static/static/js/18.89c20aa67a4dd067ea37.js new file mode 100644 index 000000000..db1c78a49 Binary files /dev/null and b/priv/static/static/js/18.89c20aa67a4dd067ea37.js differ diff --git a/priv/static/static/js/18.89c20aa67a4dd067ea37.js.map b/priv/static/static/js/18.89c20aa67a4dd067ea37.js.map new file mode 100644 index 000000000..72cdf0e0e Binary files /dev/null and b/priv/static/static/js/18.89c20aa67a4dd067ea37.js.map differ diff --git a/priv/static/static/js/18.fd12f9746a55aa24a8b7.js b/priv/static/static/js/18.fd12f9746a55aa24a8b7.js deleted file mode 100644 index be1ecbba5..000000000 Binary files a/priv/static/static/js/18.fd12f9746a55aa24a8b7.js and /dev/null differ diff --git a/priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map b/priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map deleted file mode 100644 index c98c107b3..000000000 Binary files a/priv/static/static/js/18.fd12f9746a55aa24a8b7.js.map and /dev/null differ diff --git a/priv/static/static/js/19.3adebd64964c92700074.js b/priv/static/static/js/19.3adebd64964c92700074.js deleted file mode 100644 index 9d5adbe4e..000000000 Binary files a/priv/static/static/js/19.3adebd64964c92700074.js and /dev/null differ diff --git a/priv/static/static/js/19.3adebd64964c92700074.js.map b/priv/static/static/js/19.3adebd64964c92700074.js.map deleted file mode 100644 index d113a66dc..000000000 Binary files a/priv/static/static/js/19.3adebd64964c92700074.js.map and /dev/null differ diff --git a/priv/static/static/js/19.6e13bad8131c4501c1c5.js b/priv/static/static/js/19.6e13bad8131c4501c1c5.js new file mode 100644 index 000000000..8b32827cc Binary files /dev/null and b/priv/static/static/js/19.6e13bad8131c4501c1c5.js differ diff --git a/priv/static/static/js/19.6e13bad8131c4501c1c5.js.map b/priv/static/static/js/19.6e13bad8131c4501c1c5.js.map new file mode 100644 index 000000000..762d85e27 Binary files /dev/null and b/priv/static/static/js/19.6e13bad8131c4501c1c5.js.map differ diff --git a/priv/static/static/js/2.78a48aa26599b00c3b8d.js b/priv/static/static/js/2.78a48aa26599b00c3b8d.js new file mode 100644 index 000000000..ecb27aa9c Binary files /dev/null and b/priv/static/static/js/2.78a48aa26599b00c3b8d.js differ diff --git a/priv/static/static/js/2.78a48aa26599b00c3b8d.js.map b/priv/static/static/js/2.78a48aa26599b00c3b8d.js.map new file mode 100644 index 000000000..167cfa1c6 Binary files /dev/null and b/priv/static/static/js/2.78a48aa26599b00c3b8d.js.map differ diff --git a/priv/static/static/js/2.d81ca020d6885c6c3b03.js b/priv/static/static/js/2.d81ca020d6885c6c3b03.js deleted file mode 100644 index f751a05da..000000000 Binary files a/priv/static/static/js/2.d81ca020d6885c6c3b03.js and /dev/null differ diff --git a/priv/static/static/js/2.d81ca020d6885c6c3b03.js.map b/priv/static/static/js/2.d81ca020d6885c6c3b03.js.map deleted file mode 100644 index 9a675dbc5..000000000 Binary files a/priv/static/static/js/2.d81ca020d6885c6c3b03.js.map and /dev/null differ diff --git a/priv/static/static/js/20.3615c3cea2e1c2707a4f.js b/priv/static/static/js/20.3615c3cea2e1c2707a4f.js new file mode 100644 index 000000000..74f89016c Binary files /dev/null and b/priv/static/static/js/20.3615c3cea2e1c2707a4f.js differ diff --git a/priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map b/priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map new file mode 100644 index 000000000..acddecea7 Binary files /dev/null and b/priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map differ diff --git a/priv/static/static/js/20.e0c3ad29d59470506c04.js b/priv/static/static/js/20.e0c3ad29d59470506c04.js deleted file mode 100644 index ddedbd1ff..000000000 Binary files a/priv/static/static/js/20.e0c3ad29d59470506c04.js and /dev/null differ diff --git a/priv/static/static/js/20.e0c3ad29d59470506c04.js.map b/priv/static/static/js/20.e0c3ad29d59470506c04.js.map deleted file mode 100644 index 83a9fbc98..000000000 Binary files a/priv/static/static/js/20.e0c3ad29d59470506c04.js.map and /dev/null differ diff --git a/priv/static/static/js/21.64dedfc646e13e6f7915.js b/priv/static/static/js/21.64dedfc646e13e6f7915.js new file mode 100644 index 000000000..407e6665e Binary files /dev/null and b/priv/static/static/js/21.64dedfc646e13e6f7915.js differ diff --git a/priv/static/static/js/21.64dedfc646e13e6f7915.js.map b/priv/static/static/js/21.64dedfc646e13e6f7915.js.map new file mode 100644 index 000000000..8e3432668 Binary files /dev/null and b/priv/static/static/js/21.64dedfc646e13e6f7915.js.map differ diff --git a/priv/static/static/js/21.849ecc09a1d58bdc64c6.js b/priv/static/static/js/21.849ecc09a1d58bdc64c6.js deleted file mode 100644 index ef58a3da1..000000000 Binary files a/priv/static/static/js/21.849ecc09a1d58bdc64c6.js and /dev/null differ diff --git a/priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map b/priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map deleted file mode 100644 index 9447b7ce3..000000000 Binary files a/priv/static/static/js/21.849ecc09a1d58bdc64c6.js.map and /dev/null differ diff --git a/priv/static/static/js/22.6fa63bc6a054b7638e9e.js b/priv/static/static/js/22.6fa63bc6a054b7638e9e.js new file mode 100644 index 000000000..4a8740c99 Binary files /dev/null and b/priv/static/static/js/22.6fa63bc6a054b7638e9e.js differ diff --git a/priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map b/priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map new file mode 100644 index 000000000..1c556f040 Binary files /dev/null and b/priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map differ diff --git a/priv/static/static/js/22.8782f133c9f66d3f2bbe.js b/priv/static/static/js/22.8782f133c9f66d3f2bbe.js deleted file mode 100644 index 82692acdb..000000000 Binary files a/priv/static/static/js/22.8782f133c9f66d3f2bbe.js and /dev/null differ diff --git a/priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map b/priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map deleted file mode 100644 index 41e527ff6..000000000 Binary files a/priv/static/static/js/22.8782f133c9f66d3f2bbe.js.map and /dev/null differ diff --git a/priv/static/static/js/23.2653bf91bc77c2ed0160.js b/priv/static/static/js/23.2653bf91bc77c2ed0160.js deleted file mode 100644 index 2aad331b4..000000000 Binary files a/priv/static/static/js/23.2653bf91bc77c2ed0160.js and /dev/null differ diff --git a/priv/static/static/js/23.2653bf91bc77c2ed0160.js.map b/priv/static/static/js/23.2653bf91bc77c2ed0160.js.map deleted file mode 100644 index 4f031922e..000000000 Binary files a/priv/static/static/js/23.2653bf91bc77c2ed0160.js.map and /dev/null differ diff --git a/priv/static/static/js/23.e0ddea2b6e049d221ee7.js b/priv/static/static/js/23.e0ddea2b6e049d221ee7.js new file mode 100644 index 000000000..51fe36368 Binary files /dev/null and b/priv/static/static/js/23.e0ddea2b6e049d221ee7.js differ diff --git a/priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map b/priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map new file mode 100644 index 000000000..36bae2bf4 Binary files /dev/null and b/priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map differ diff --git a/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js b/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js new file mode 100644 index 000000000..e5abf0af6 Binary files /dev/null and b/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js differ diff --git a/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map b/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map new file mode 100644 index 000000000..09f3c19d0 Binary files /dev/null and b/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map differ diff --git a/priv/static/static/js/24.f931d864a2297d880a9a.js b/priv/static/static/js/24.f931d864a2297d880a9a.js deleted file mode 100644 index 0362730e0..000000000 Binary files a/priv/static/static/js/24.f931d864a2297d880a9a.js and /dev/null differ diff --git a/priv/static/static/js/24.f931d864a2297d880a9a.js.map b/priv/static/static/js/24.f931d864a2297d880a9a.js.map deleted file mode 100644 index 2fb375e79..000000000 Binary files a/priv/static/static/js/24.f931d864a2297d880a9a.js.map and /dev/null differ diff --git a/priv/static/static/js/25.696b41c0a8660e1f85af.js b/priv/static/static/js/25.696b41c0a8660e1f85af.js new file mode 100644 index 000000000..b114890fc Binary files /dev/null and b/priv/static/static/js/25.696b41c0a8660e1f85af.js differ diff --git a/priv/static/static/js/25.696b41c0a8660e1f85af.js.map b/priv/static/static/js/25.696b41c0a8660e1f85af.js.map new file mode 100644 index 000000000..f6d208812 Binary files /dev/null and b/priv/static/static/js/25.696b41c0a8660e1f85af.js.map differ diff --git a/priv/static/static/js/25.886acc9ba83c64659279.js b/priv/static/static/js/25.886acc9ba83c64659279.js deleted file mode 100644 index 4ff4c331b..000000000 Binary files a/priv/static/static/js/25.886acc9ba83c64659279.js and /dev/null differ diff --git a/priv/static/static/js/25.886acc9ba83c64659279.js.map b/priv/static/static/js/25.886acc9ba83c64659279.js.map deleted file mode 100644 index c39f71238..000000000 Binary files a/priv/static/static/js/25.886acc9ba83c64659279.js.map and /dev/null differ diff --git a/priv/static/static/js/26.1168f22384be75dc5492.js b/priv/static/static/js/26.1168f22384be75dc5492.js new file mode 100644 index 000000000..b77a4d30f Binary files /dev/null and b/priv/static/static/js/26.1168f22384be75dc5492.js differ diff --git a/priv/static/static/js/26.1168f22384be75dc5492.js.map b/priv/static/static/js/26.1168f22384be75dc5492.js.map new file mode 100644 index 000000000..c9b0d8495 Binary files /dev/null and b/priv/static/static/js/26.1168f22384be75dc5492.js.map differ diff --git a/priv/static/static/js/26.e15b1645079c72c60586.js b/priv/static/static/js/26.e15b1645079c72c60586.js deleted file mode 100644 index 303170088..000000000 Binary files a/priv/static/static/js/26.e15b1645079c72c60586.js and /dev/null differ diff --git a/priv/static/static/js/26.e15b1645079c72c60586.js.map b/priv/static/static/js/26.e15b1645079c72c60586.js.map deleted file mode 100644 index e62345884..000000000 Binary files a/priv/static/static/js/26.e15b1645079c72c60586.js.map and /dev/null differ diff --git a/priv/static/static/js/27.3c0cfbb2a898b35486dd.js b/priv/static/static/js/27.3c0cfbb2a898b35486dd.js new file mode 100644 index 000000000..a0765356f Binary files /dev/null and b/priv/static/static/js/27.3c0cfbb2a898b35486dd.js differ diff --git a/priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map b/priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map new file mode 100644 index 000000000..0cc5f46b2 Binary files /dev/null and b/priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map differ diff --git a/priv/static/static/js/27.7b41e5953f74af7fddd1.js b/priv/static/static/js/27.7b41e5953f74af7fddd1.js deleted file mode 100644 index 769fba11b..000000000 Binary files a/priv/static/static/js/27.7b41e5953f74af7fddd1.js and /dev/null differ diff --git a/priv/static/static/js/27.7b41e5953f74af7fddd1.js.map b/priv/static/static/js/27.7b41e5953f74af7fddd1.js.map deleted file mode 100644 index 078f5ff9a..000000000 Binary files a/priv/static/static/js/27.7b41e5953f74af7fddd1.js.map and /dev/null differ diff --git a/priv/static/static/js/28.4f39e562aaceaa01e883.js b/priv/static/static/js/28.4f39e562aaceaa01e883.js deleted file mode 100644 index 629359bda..000000000 Binary files a/priv/static/static/js/28.4f39e562aaceaa01e883.js and /dev/null differ diff --git a/priv/static/static/js/28.4f39e562aaceaa01e883.js.map b/priv/static/static/js/28.4f39e562aaceaa01e883.js.map deleted file mode 100644 index 24c675a4c..000000000 Binary files a/priv/static/static/js/28.4f39e562aaceaa01e883.js.map and /dev/null differ diff --git a/priv/static/static/js/28.926c71d6f1813e177271.js b/priv/static/static/js/28.926c71d6f1813e177271.js new file mode 100644 index 000000000..55cf840f2 Binary files /dev/null and b/priv/static/static/js/28.926c71d6f1813e177271.js differ diff --git a/priv/static/static/js/28.926c71d6f1813e177271.js.map b/priv/static/static/js/28.926c71d6f1813e177271.js.map new file mode 100644 index 000000000..1ae8f08cb Binary files /dev/null and b/priv/static/static/js/28.926c71d6f1813e177271.js.map differ diff --git a/priv/static/static/js/29.137e2a68b558eed58152.js b/priv/static/static/js/29.137e2a68b558eed58152.js deleted file mode 100644 index 50cb11ffd..000000000 Binary files a/priv/static/static/js/29.137e2a68b558eed58152.js and /dev/null differ diff --git a/priv/static/static/js/29.137e2a68b558eed58152.js.map b/priv/static/static/js/29.137e2a68b558eed58152.js.map deleted file mode 100644 index 0ac2f7fd3..000000000 Binary files a/priv/static/static/js/29.137e2a68b558eed58152.js.map and /dev/null differ diff --git a/priv/static/static/js/29.187064ebed099ae45749.js b/priv/static/static/js/29.187064ebed099ae45749.js new file mode 100644 index 000000000..6eaae0226 Binary files /dev/null and b/priv/static/static/js/29.187064ebed099ae45749.js differ diff --git a/priv/static/static/js/29.187064ebed099ae45749.js.map b/priv/static/static/js/29.187064ebed099ae45749.js.map new file mode 100644 index 000000000..b5dd63f96 Binary files /dev/null and b/priv/static/static/js/29.187064ebed099ae45749.js.map differ diff --git a/priv/static/static/js/30.73e09f3b43617410dec7.js b/priv/static/static/js/30.73e09f3b43617410dec7.js deleted file mode 100644 index 0c3d03cfa..000000000 Binary files a/priv/static/static/js/30.73e09f3b43617410dec7.js and /dev/null differ diff --git a/priv/static/static/js/30.73e09f3b43617410dec7.js.map b/priv/static/static/js/30.73e09f3b43617410dec7.js.map deleted file mode 100644 index cb546de17..000000000 Binary files a/priv/static/static/js/30.73e09f3b43617410dec7.js.map and /dev/null differ diff --git a/priv/static/static/js/30.d78855ca19bf749be905.js b/priv/static/static/js/30.d78855ca19bf749be905.js new file mode 100644 index 000000000..9202d19d3 Binary files /dev/null and b/priv/static/static/js/30.d78855ca19bf749be905.js differ diff --git a/priv/static/static/js/30.d78855ca19bf749be905.js.map b/priv/static/static/js/30.d78855ca19bf749be905.js.map new file mode 100644 index 000000000..b9f39664d Binary files /dev/null and b/priv/static/static/js/30.d78855ca19bf749be905.js.map differ diff --git a/priv/static/static/js/5.2b4a2787bacdd3d910db.js b/priv/static/static/js/5.2b4a2787bacdd3d910db.js deleted file mode 100644 index 18c059380..000000000 Binary files a/priv/static/static/js/5.2b4a2787bacdd3d910db.js and /dev/null differ diff --git a/priv/static/static/js/5.2b4a2787bacdd3d910db.js.map b/priv/static/static/js/5.2b4a2787bacdd3d910db.js.map deleted file mode 100644 index e9e78632d..000000000 Binary files a/priv/static/static/js/5.2b4a2787bacdd3d910db.js.map and /dev/null differ diff --git a/priv/static/static/js/5.84f3dce298bc720719c7.js b/priv/static/static/js/5.84f3dce298bc720719c7.js new file mode 100644 index 000000000..242b2a525 Binary files /dev/null and b/priv/static/static/js/5.84f3dce298bc720719c7.js differ diff --git a/priv/static/static/js/5.84f3dce298bc720719c7.js.map b/priv/static/static/js/5.84f3dce298bc720719c7.js.map new file mode 100644 index 000000000..4fcc32982 Binary files /dev/null and b/priv/static/static/js/5.84f3dce298bc720719c7.js.map differ diff --git a/priv/static/static/js/6.9c94bc0cc78979694cf4.js b/priv/static/static/js/6.9c94bc0cc78979694cf4.js deleted file mode 100644 index 415938f67..000000000 Binary files a/priv/static/static/js/6.9c94bc0cc78979694cf4.js and /dev/null differ diff --git a/priv/static/static/js/6.9c94bc0cc78979694cf4.js.map b/priv/static/static/js/6.9c94bc0cc78979694cf4.js.map deleted file mode 100644 index 948368f60..000000000 Binary files a/priv/static/static/js/6.9c94bc0cc78979694cf4.js.map and /dev/null differ diff --git a/priv/static/static/js/6.b9497e1d403b901a664e.js b/priv/static/static/js/6.b9497e1d403b901a664e.js new file mode 100644 index 000000000..8c66e9062 Binary files /dev/null and b/priv/static/static/js/6.b9497e1d403b901a664e.js differ diff --git a/priv/static/static/js/6.b9497e1d403b901a664e.js.map b/priv/static/static/js/6.b9497e1d403b901a664e.js.map new file mode 100644 index 000000000..5af0576c7 Binary files /dev/null and b/priv/static/static/js/6.b9497e1d403b901a664e.js.map differ diff --git a/priv/static/static/js/7.455b574116ce3f004ffb.js b/priv/static/static/js/7.455b574116ce3f004ffb.js new file mode 100644 index 000000000..0e35f6904 Binary files /dev/null and b/priv/static/static/js/7.455b574116ce3f004ffb.js differ diff --git a/priv/static/static/js/7.455b574116ce3f004ffb.js.map b/priv/static/static/js/7.455b574116ce3f004ffb.js.map new file mode 100644 index 000000000..971b570e1 Binary files /dev/null and b/priv/static/static/js/7.455b574116ce3f004ffb.js.map differ diff --git a/priv/static/static/js/7.b4ac57fd946a3a189047.js b/priv/static/static/js/7.b4ac57fd946a3a189047.js deleted file mode 100644 index 18b6ab76c..000000000 Binary files a/priv/static/static/js/7.b4ac57fd946a3a189047.js and /dev/null differ diff --git a/priv/static/static/js/7.b4ac57fd946a3a189047.js.map b/priv/static/static/js/7.b4ac57fd946a3a189047.js.map deleted file mode 100644 index 054d52650..000000000 Binary files a/priv/static/static/js/7.b4ac57fd946a3a189047.js.map and /dev/null differ diff --git a/priv/static/static/js/8.8db9f2dcc5ed429777f7.js b/priv/static/static/js/8.8db9f2dcc5ed429777f7.js new file mode 100644 index 000000000..79082fc7c Binary files /dev/null and b/priv/static/static/js/8.8db9f2dcc5ed429777f7.js differ diff --git a/priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map b/priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map new file mode 100644 index 000000000..e193375de Binary files /dev/null and b/priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map differ diff --git a/priv/static/static/js/8.e03e32ca713d01db0433.js b/priv/static/static/js/8.e03e32ca713d01db0433.js deleted file mode 100644 index 4d5894322..000000000 Binary files a/priv/static/static/js/8.e03e32ca713d01db0433.js and /dev/null differ diff --git a/priv/static/static/js/8.e03e32ca713d01db0433.js.map b/priv/static/static/js/8.e03e32ca713d01db0433.js.map deleted file mode 100644 index d1385c203..000000000 Binary files a/priv/static/static/js/8.e03e32ca713d01db0433.js.map and /dev/null differ diff --git a/priv/static/static/js/9.72d903ca8e0c5a532b87.js b/priv/static/static/js/9.72d903ca8e0c5a532b87.js deleted file mode 100644 index ce0f066c5..000000000 Binary files a/priv/static/static/js/9.72d903ca8e0c5a532b87.js and /dev/null differ diff --git a/priv/static/static/js/9.72d903ca8e0c5a532b87.js.map b/priv/static/static/js/9.72d903ca8e0c5a532b87.js.map deleted file mode 100644 index 4cf79de5b..000000000 Binary files a/priv/static/static/js/9.72d903ca8e0c5a532b87.js.map and /dev/null differ diff --git a/priv/static/static/js/9.da3973d058660aa9612f.js b/priv/static/static/js/9.da3973d058660aa9612f.js new file mode 100644 index 000000000..50d977f67 Binary files /dev/null and b/priv/static/static/js/9.da3973d058660aa9612f.js differ diff --git a/priv/static/static/js/9.da3973d058660aa9612f.js.map b/priv/static/static/js/9.da3973d058660aa9612f.js.map new file mode 100644 index 000000000..4c8f70599 Binary files /dev/null and b/priv/static/static/js/9.da3973d058660aa9612f.js.map differ diff --git a/priv/static/static/js/app.1e68e208590653dab5aa.js b/priv/static/static/js/app.1e68e208590653dab5aa.js deleted file mode 100644 index 27cc3d910..000000000 Binary files a/priv/static/static/js/app.1e68e208590653dab5aa.js and /dev/null differ diff --git a/priv/static/static/js/app.1e68e208590653dab5aa.js.map b/priv/static/static/js/app.1e68e208590653dab5aa.js.map deleted file mode 100644 index 71636d936..000000000 Binary files a/priv/static/static/js/app.1e68e208590653dab5aa.js.map and /dev/null differ diff --git a/priv/static/static/js/app.31bba9f1e242ff273dcb.js b/priv/static/static/js/app.31bba9f1e242ff273dcb.js new file mode 100644 index 000000000..22413689c Binary files /dev/null and b/priv/static/static/js/app.31bba9f1e242ff273dcb.js differ diff --git a/priv/static/static/js/app.31bba9f1e242ff273dcb.js.map b/priv/static/static/js/app.31bba9f1e242ff273dcb.js.map new file mode 100644 index 000000000..8ff7a00c6 Binary files /dev/null and b/priv/static/static/js/app.31bba9f1e242ff273dcb.js.map differ diff --git a/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js b/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js deleted file mode 100644 index bf6671e4b..000000000 Binary files a/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map b/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map deleted file mode 100644 index 2a3bf1b99..000000000 Binary files a/priv/static/static/js/vendors~app.247dc52c7abe6a0dab87.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js b/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js new file mode 100644 index 000000000..76c8a8dc1 Binary files /dev/null and b/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js differ diff --git a/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map b/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map new file mode 100644 index 000000000..f3c067c15 Binary files /dev/null and b/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 098f58d49..9b7d127fd 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ -- cgit v1.2.3 From 9ce95fa68f933c911c10e640b9a32ddae8a631c9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 15 Jul 2020 17:04:30 -0500 Subject: Use `approval_required` in /api/v1/instance --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 2 +- test/web/mastodon_api/controllers/instance_controller_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 243067a73..5389d63cd 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -26,6 +26,7 @@ def render("show.json", _) do thumbnail: Keyword.get(instance, :instance_thumbnail), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), + approval_required: Keyword.get(instance, :account_approval_required), # Extra (not present in Mastodon): max_toot_chars: Keyword.get(instance, :limit), poll_limits: Keyword.get(instance, :poll_limits), @@ -39,7 +40,6 @@ def render("show.json", _) do pleroma: %{ metadata: %{ account_activation_required: Keyword.get(instance, :account_activation_required), - account_approval_required: Keyword.get(instance, :account_approval_required), features: features(), federation: federation(), fields_limits: fields_limits() diff --git a/test/web/mastodon_api/controllers/instance_controller_test.exs b/test/web/mastodon_api/controllers/instance_controller_test.exs index 8a4183283..6a9ccd979 100644 --- a/test/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/web/mastodon_api/controllers/instance_controller_test.exs @@ -27,6 +27,7 @@ test "get instance information", %{conn: conn} do "thumbnail" => _, "languages" => _, "registrations" => _, + "approval_required" => _, "poll_limits" => _, "upload_limit" => _, "avatar_upload_limit" => _, @@ -38,7 +39,6 @@ test "get instance information", %{conn: conn} do } = result assert result["pleroma"]["metadata"]["account_activation_required"] != nil - assert result["pleroma"]["metadata"]["account_approval_required"] != nil assert result["pleroma"]["metadata"]["features"] assert result["pleroma"]["metadata"]["federation"] assert result["pleroma"]["metadata"]["fields_limits"] -- cgit v1.2.3 From 02cc42e72c5f7dde78c705c3cbc83d2c13fb7a71 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 15 Jul 2020 17:08:46 -0500 Subject: Squash User approval migrations --- .../migrations/20200712234852_add_approval_fields_to_users.exs | 10 ++++++++++ .../20200712234852_add_approval_pending_to_users.exs | 9 --------- .../20200714043918_add_registration_reason_to_users.exs | 9 --------- 3 files changed, 10 insertions(+), 18 deletions(-) create mode 100644 priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs delete mode 100644 priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs delete mode 100644 priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs diff --git a/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs b/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs new file mode 100644 index 000000000..559640f01 --- /dev/null +++ b/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs @@ -0,0 +1,10 @@ +defmodule Pleroma.Repo.Migrations.AddApprovalFieldsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add(:approval_pending, :boolean) + add(:registration_reason, :string) + end + end +end diff --git a/priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs b/priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs deleted file mode 100644 index f7eb8179b..000000000 --- a/priv/repo/migrations/20200712234852_add_approval_pending_to_users.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Pleroma.Repo.Migrations.AddApprovalPendingToUsers do - use Ecto.Migration - - def change do - alter table(:users) do - add(:approval_pending, :boolean) - end - end -end diff --git a/priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs b/priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs deleted file mode 100644 index fa02fded4..000000000 --- a/priv/repo/migrations/20200714043918_add_registration_reason_to_users.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Pleroma.Repo.Migrations.AddRegistrationReasonToUsers do - use Ecto.Migration - - def change do - alter table(:users) do - add(:registration_reason, :string) - end - end -end -- cgit v1.2.3 From 4e0e19a7060da9f3eb06ffb0bdb816c7dedb720b Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 16 Jul 2020 08:52:14 +0300 Subject: update task messages --- installation/init.d/pleroma | 1 + lib/mix/tasks/pleroma/release_env.ex | 32 ++++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/installation/init.d/pleroma b/installation/init.d/pleroma index 384536f7e..e908cda1b 100755 --- a/installation/init.d/pleroma +++ b/installation/init.d/pleroma @@ -8,6 +8,7 @@ pidfile="/var/run/pleroma.pid" directory=/opt/pleroma healthcheck_delay=60 healthcheck_timer=30 +export $(cat /opt/pleroma/config/pleroma.env) : ${pleroma_port:-4000} diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex index cbbbdeff6..63030c5cc 100644 --- a/lib/mix/tasks/pleroma/release_env.ex +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -23,14 +23,15 @@ def run(["gen" | rest]) do ] ) - env_path = + file_path = get_option( options, :path, "Environment file path", - "config/pleroma.env" + "./config/pleroma.env" ) - |> Path.expand() + + env_path = Path.expand(file_path) proceed? = if File.exists?(env_path) do @@ -45,13 +46,24 @@ def run(["gen" | rest]) do end if proceed? do - do_generate(env_path) + case do_generate(env_path) do + {:error, reason} -> + shell_error( + File.Error.message(%{action: "write to file", reason: reason, path: env_path}) + ) - shell_info( - "The file generated: #{env_path}.\nTo use the enviroment file need to add the line ';EnvironmentFile=#{ - env_path - }' in service file (/installation/pleroma.service)." - ) + _ -> + shell_info("\nThe file generated: #{env_path}.\n") + + shell_info(""" + WARNING: before start pleroma app please to made the file read-only and non-modifiable. + Example: + chmod 0444 #{file_path} + chattr +i #{file_path} + """) + end + else + shell_info("\nThe file is exist. #{env_path}.\n") end end @@ -59,6 +71,6 @@ def do_generate(path) do content = "RELEASE_COOKIE=#{Base.encode32(:crypto.strong_rand_bytes(32))}" File.mkdir_p!(Path.dirname(path)) - File.write!(path, content) + File.write(path, content) end end -- cgit v1.2.3 From c72676d22f9c2e9ed83ba793fe9a85efd7e9a544 Mon Sep 17 00:00:00 2001 From: Maksim Date: Thu, 16 Jul 2020 13:30:17 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/mix/tasks/pleroma/release_env.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex index 63030c5cc..4d8b6ff27 100644 --- a/lib/mix/tasks/pleroma/release_env.ex +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -56,7 +56,7 @@ def run(["gen" | rest]) do shell_info("\nThe file generated: #{env_path}.\n") shell_info(""" - WARNING: before start pleroma app please to made the file read-only and non-modifiable. + WARNING: before start pleroma app please make sure to make the file read-only and non-modifiable. Example: chmod 0444 #{file_path} chattr +i #{file_path} -- cgit v1.2.3 From 16da9f5cfd63237549da7156e5297d356628a70f Mon Sep 17 00:00:00 2001 From: Maksim Date: Thu, 16 Jul 2020 13:30:28 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/mix/tasks/pleroma/release_env.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex index 4d8b6ff27..9da74ffcf 100644 --- a/lib/mix/tasks/pleroma/release_env.ex +++ b/lib/mix/tasks/pleroma/release_env.ex @@ -38,7 +38,7 @@ def run(["gen" | rest]) do get_option( options, :force, - "Environment file is exist. Do you want overwritten the #{env_path} file? (y/n)", + "Environment file already exists. Do you want to overwrite the #{env_path} file? (y/n)", "n" ) === "y" else -- cgit v1.2.3 From 5fcb3e873822c602a5f50cbeb159427e02ea1818 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 16 Jul 2020 16:35:09 +0300 Subject: fix docs --- docs/installation/otp_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index e115c2297..338dfa7d0 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -134,7 +134,7 @@ su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate" # su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" # Start the instance to verify that everything is working as expected -su pleroma -s $SHELL -lc "export $( cat /opt/pleroma/config/pleroma.env | xargs); ./bin/pleroma daemon" +su pleroma -s $SHELL -lc "export $(cat /opt/pleroma/config/pleroma.env); ./bin/pleroma daemon" # Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly sleep 20 && curl http://localhost:4000/api/v1/instance -- cgit v1.2.3 From 3be64556dbe5618de3429a481f41eff917053ce8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 16 Jul 2020 13:11:03 -0500 Subject: Improve TOTP token and recovery input fields in OAuth login --- lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex | 2 +- lib/pleroma/web/templates/o_auth/mfa/totp.html.eex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex index 750f65386..5ab59b57b 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -10,7 +10,7 @@ <%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
    <%= label f, :code, "Recovery code" %> - <%= text_input f, :code %> + <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %> <%= hidden_input f, :mfa_token, value: @mfa_token %> <%= hidden_input f, :state, value: @state %> <%= hidden_input f, :redirect_uri, value: @redirect_uri %> diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex index af6e546b0..8323ff8a1 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -10,7 +10,7 @@ <%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
    <%= label f, :code, "Authentication code" %> - <%= text_input f, :code %> + <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, maxlength: 6, pattern: "[0-9]{6}", spellcheck: false] %> <%= hidden_input f, :mfa_token, value: @mfa_token %> <%= hidden_input f, :state, value: @state %> <%= hidden_input f, :redirect_uri, value: @redirect_uri %> -- cgit v1.2.3 From 6fdaee7caed16a083d751b63c3dcfd119da57b21 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Jul 2020 14:52:48 -0500 Subject: description.exs typofixes --- config/description.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index a936b7abf..b97b0a7ec 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2238,13 +2238,13 @@ %{ key: :new_window, type: :boolean, - description: "Link URLs will open in new window/tab." + description: "Link URLs will open in a new window/tab." }, %{ key: :truncate, type: [:integer, false], description: - "Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...`", + "Set to a number to truncate URLs longer than the number. Truncated URLs will end in `...`", suggestions: [15, false] }, %{ -- cgit v1.2.3 From 5701840d30f8f70721598115039519e3fe747186 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Jul 2020 14:54:20 -0500 Subject: Use updated Linkify from git --- mix.exs | 4 +++- mix.lock | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 9886de666..727cdb71f 100644 --- a/mix.exs +++ b/mix.exs @@ -166,7 +166,9 @@ defp deps do {:floki, "~> 0.25"}, {:timex, "~> 3.5"}, {:ueberauth, "~> 0.4"}, - {:linkify, "~> 0.1.0"}, + {:linkify, + git: "https://git.pleroma.social/pleroma/elixir-libraries/linkify.git", + ref: "a08513aa7e879f056c44c5b8aea8c0fd073be5c8"}, {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, diff --git a/mix.lock b/mix.lock index 150d14875..2025a965d 100644 --- a/mix.lock +++ b/mix.lock @@ -62,7 +62,7 @@ "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, - "linkify": {:hex, :linkify, "0.1.0", "a2d35de64271c7fbbc7d8773adb9f595592b7fbaa581271c7733f39d3058bfa4", [:mix], [], "hexpm", "d3140ef8dbdcc53ef93a6a5374c11fffe0189f00d132161e9d020a417780bee7"}, + "linkify": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/linkify.git", "a08513aa7e879f056c44c5b8aea8c0fd073be5c8", [ref: "a08513aa7e879f056c44c5b8aea8c0fd073be5c8"]}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, -- cgit v1.2.3 From 880301985b49dd0e38a748a9c834eec4d550ea1b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 1 Jun 2020 19:51:41 -0500 Subject: Formatter: Test link with local mention --- test/formatter_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/formatter_test.exs b/test/formatter_test.exs index bef5a2c28..ae0d7b377 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -255,6 +255,16 @@ test "it can parse mentions and return the relevant users" do assert {_text, ^expected_mentions, []} = Formatter.linkify(text) end + + test "it parses URL containing local mention" do + _user = insert(:user, %{nickname: "lain"}) + + text = "https://example.com/@lain" + + expected = ~S(https://example.com/@lain) + + assert {^expected, [], []} = Formatter.linkify(text) + end end describe ".parse_tags" do -- cgit v1.2.3 From 613e096389d6945016e78499505c3ec5786d0ab0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Jul 2020 16:35:03 -0500 Subject: Migrate :auto_linker --> Pleroma.Formatter in ConfigDB --- .../20200716195806_autolinker_to_linkify.exs | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 priv/repo/migrations/20200716195806_autolinker_to_linkify.exs diff --git a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs new file mode 100644 index 000000000..9ec4203eb --- /dev/null +++ b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs @@ -0,0 +1,37 @@ +defmodule Pleroma.Repo.Migrations.AutolinkerToLinkify do + use Ecto.Migration + + alias Pleroma.Repo + alias Pleroma.ConfigDB + + @autolinker_path %{group: :auto_linker, key: :opts} + @linkify_path %{group: :pleroma, key: Pleroma.Formatter} + + @compat_opts [:class, :rel, :new_window, :truncate, :strip_prefix, :extra] + + def change do + with {:ok, {old, new}} <- maybe_get_params() do + move_config(old, new) + end + end + + defp move_config(%{} = old, %{} = new) do + {:ok, _} = ConfigDB.update_or_create(new) + {:ok, _} = ConfigDB.delete(old) + :ok + end + + defp maybe_get_params() do + with %ConfigDB{value: opts} <- ConfigDB.get_by_params(@autolinker_path), + %{} = opts <- transform_opts(opts), + %{} = linkify_params <- Map.put(@linkify_path, :value, opts) do + {:ok, {@autolinker_path, linkify_params}} + end + end + + defp transform_opts(opts) when is_list(opts) do + opts + |> Enum.into(%{}) + |> Map.take(@compat_opts) + end +end -- cgit v1.2.3 From 5e745567031e87ee0854dca8d10065449af27d9c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Jul 2020 20:25:53 -0500 Subject: Sanitize `reason` param in POST /api/v1/accounts --- lib/pleroma/web/twitter_api/twitter_api.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 2294d9d0d..424a705dd 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do alias Pleroma.Emails.Mailer alias Pleroma.Emails.UserEmail + alias Pleroma.HTML alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserInviteToken @@ -19,7 +20,7 @@ def register_user(params, opts \\ []) do |> Map.put(:nickname, params[:username]) |> Map.put(:name, Map.get(params, :fullname, params[:username])) |> Map.put(:password_confirmation, params[:password]) - |> Map.put(:registration_reason, params[:reason]) + |> Map.put(:registration_reason, HTML.strip_tags(params[:reason])) if Pleroma.Config.get([:instance, :registrations_open]) do create_user(params, opts) -- cgit v1.2.3 From 2a3bb23091fcc857ff8e678f81107186e0aeb3fe Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 17 Jul 2020 11:03:47 +0300 Subject: Bug issue template: remove choice in "Installation type" It made gitlab display "1 of 2 tasks completed" when one is chosen, which is totally not what this was used for. --- .gitlab/issue_templates/Bug.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 9ce9b6918..dd0d6eb24 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -8,9 +8,7 @@ ### Environment -* Installation type: - - [ ] OTP - - [ ] From source +* Installation type (OTP or From Source): * Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE): * Elixir version (`elixir -v` for from source installations, N/A for OTP): * Operating system: -- cgit v1.2.3 From 62438530e24d9553b8c1240ad7a39ea0906832b9 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 17 Jul 2020 08:19:49 -0500 Subject: TOTP length is configurable, so we can't hardcode this here. --- lib/pleroma/web/templates/o_auth/mfa/totp.html.eex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex index 8323ff8a1..af85777eb 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -10,7 +10,7 @@ <%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
    <%= label f, :code, "Authentication code" %> - <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, maxlength: 6, pattern: "[0-9]{6}", spellcheck: false] %> + <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %> <%= hidden_input f, :mfa_token, value: @mfa_token %> <%= hidden_input f, :state, value: @state %> <%= hidden_input f, :redirect_uri, value: @redirect_uri %> -- cgit v1.2.3 From af376cbffbae3ae594e594813873719dfd69664e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 17 Jul 2020 18:06:05 +0300 Subject: using atom keys in search params --- lib/pleroma/gopher/server.ex | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/gopher/server.ex b/lib/pleroma/gopher/server.ex index 3d56d50a9..e9f54c4c0 100644 --- a/lib/pleroma/gopher/server.ex +++ b/lib/pleroma/gopher/server.ex @@ -96,16 +96,18 @@ def response("") do def response("/main/public") do posts = - ActivityPub.fetch_public_activities(%{"type" => ["Create"], "local_only" => true}) - |> render_activities + %{type: ["Create"], local_only: true} + |> ActivityPub.fetch_public_activities() + |> render_activities() info("Welcome to the Public Timeline!") <> posts <> ".\r\n" end def response("/main/all") do posts = - ActivityPub.fetch_public_activities(%{"type" => ["Create"]}) - |> render_activities + %{type: ["Create"]} + |> ActivityPub.fetch_public_activities() + |> render_activities() info("Welcome to the Federated Timeline!") <> posts <> ".\r\n" end @@ -130,13 +132,14 @@ def response("/notices/" <> id) do def response("/users/" <> nickname) do with %User{} = user <- User.get_cached_by_nickname(nickname) do params = %{ - "type" => ["Create"], - "actor_id" => user.ap_id + type: ["Create"], + actor_id: user.ap_id } activities = - ActivityPub.fetch_public_activities(params) - |> render_activities + params + |> ActivityPub.fetch_public_activities() + |> render_activities() info("Posts by #{user.nickname}") <> activities <> ".\r\n" else -- cgit v1.2.3 From 20a496d2cbea18c563694c7026c0e951e99cfc3b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 17 Jul 2020 10:45:41 -0500 Subject: Expose the post formats in /api/v1/instance --- CHANGELOG.md | 1 + docs/API/differences_in_mastoapi_responses.md | 1 + lib/pleroma/web/mastodon_api/views/instance_view.ex | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02f28241..75488f026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). has been simplified down to `block_from_strangers`. - **Breaking:** Notification Settings API option for hiding push notification contents has been renamed to `hide_notification_contents` +- Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance
    diff --git a/docs/API/differences_in_mastoapi_responses.md b/docs/API/differences_in_mastoapi_responses.md index c4a9c6dad..38865dc68 100644 --- a/docs/API/differences_in_mastoapi_responses.md +++ b/docs/API/differences_in_mastoapi_responses.md @@ -236,6 +236,7 @@ Has theses additional parameters (which are the same as in Pleroma-API): - `pleroma.metadata.features`: A list of supported features - `pleroma.metadata.federation`: The federation restrictions of this instance - `pleroma.metadata.fields_limits`: A list of values detailing the length and count limitation for various instance-configurable fields. +- `pleroma.metadata.post_formats`: A list of the allowed post format types - `vapid_public_key`: The public key needed for push messages ## Markers diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 5deb0d7ed..cd3bc7f00 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -41,7 +41,8 @@ def render("show.json", _) do account_activation_required: Keyword.get(instance, :account_activation_required), features: features(), federation: federation(), - fields_limits: fields_limits() + fields_limits: fields_limits(), + post_formats: Config.get([:instance, :allowed_post_formats]) }, vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) } -- cgit v1.2.3 From 57568437361dd14151e3aa0590c7d1da05141cf4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Jul 2020 12:19:41 -0500 Subject: Fully delete users with status :approval_pending --- lib/pleroma/user.ex | 13 +++++++------ test/user_test.exs | 11 +++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 8e2c9fbe2..23288d434 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1525,12 +1525,13 @@ defp delete_or_deactivate(%User{local: false} = user), do: delete_and_invalidate defp delete_or_deactivate(%User{local: true} = user) do status = account_status(user) - if status == :confirmation_pending do - delete_and_invalidate_cache(user) - else - user - |> change(%{deactivated: true, email: nil}) - |> update_and_set_cache() + case status do + :confirmation_pending -> delete_and_invalidate_cache(user) + :approval_pending -> delete_and_invalidate_cache(user) + _ -> + user + |> change(%{deactivated: true, email: nil}) + |> update_and_set_cache() end end diff --git a/test/user_test.exs b/test/user_test.exs index cd39e1623..57cc054af 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1314,6 +1314,17 @@ test "deactivates user when activation is not required", %{user: user} do end end + test "delete/1 when approval is pending deletes the user" do + user = insert(:user, approval_pending: true) + {:ok, user: user} + + {:ok, job} = User.delete(user) + {:ok, _} = ObanHelpers.perform(job) + + refute User.get_cached_by_id(user.id) + refute User.get_by_id(user.id) + end + test "get_public_key_for_ap_id fetches a user that's not in the db" do assert {:ok, _key} = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end -- cgit v1.2.3 From 15f8921b111bc38d0d9eb9ccd1fd09e41cdbc85e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Jul 2020 12:26:52 -0500 Subject: Test that unapproved users can never log in regardless of admin settings --- test/web/oauth/oauth_controller_test.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/web/oauth/oauth_controller_test.exs b/test/web/oauth/oauth_controller_test.exs index ec5b78750..1200126b8 100644 --- a/test/web/oauth/oauth_controller_test.exs +++ b/test/web/oauth/oauth_controller_test.exs @@ -998,8 +998,7 @@ test "rejects token exchange for user with confirmation_pending set to true" do } end - test "rejects token exchange for valid credentials belonging to an unapproved user and approval is required" do - Pleroma.Config.put([:instance, :account_approval_required], true) + test "rejects token exchange for valid credentials belonging to an unapproved user" do password = "testpassword" user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password), approval_pending: true) -- cgit v1.2.3 From 48f8b26c92880c0898daac3d691c61be0b891d0b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 16 Jul 2020 21:39:10 -0500 Subject: OpenAPI: Add :id to follower/following endpoints, fixes #1958 --- .../web/api_spec/operations/account_operation.ex | 2 ++ test/pagination_test.exs | 14 ++++++++++++++ .../controllers/account_controller_test.exs | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 952d9347b..50c8e0242 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -159,6 +159,7 @@ def followers_operation do "Accounts which follow the given account, if network is not hidden by the account owner.", parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:id, :query, :string, "ID of the resource owner"), with_relationships_param() | pagination_params() ], responses: %{ @@ -177,6 +178,7 @@ def following_operation do "Accounts which the given account is following, if network is not hidden by the account owner.", parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"}, + Operation.parameter(:id, :query, :string, "ID of the resource owner"), with_relationships_param() | pagination_params() ], responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} diff --git a/test/pagination_test.exs b/test/pagination_test.exs index 9165427ae..e526f23e8 100644 --- a/test/pagination_test.exs +++ b/test/pagination_test.exs @@ -54,6 +54,20 @@ test "paginates by min_id & limit", %{notes: notes} do assert length(paginated) == 1 end + + test "handles id gracefully", %{notes: notes} do + id = Enum.at(notes, 1).id |> Integer.to_string() + + paginated = + Pagination.fetch_paginated(Object, %{ + id: "9s99Hq44Cnv8PKBwWG", + max_id: id, + limit: 20, + offset: 0 + }) + + assert length(paginated) == 1 + end end describe "offset" do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 9c7b5e9b2..c304487ea 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -583,6 +583,15 @@ test "getting followers, pagination", %{user: user, conn: conn} do |> get("/api/v1/accounts/#{user.id}/followers?max_id=#{follower3_id}") |> json_response_and_validate_schema(200) + assert [%{"id" => ^follower2_id}, %{"id" => ^follower1_id}] = + conn + |> get( + "/api/v1/accounts/#{user.id}/followers?id=#{user.id}&limit=20&max_id=#{ + follower3_id + }" + ) + |> json_response_and_validate_schema(200) + res_conn = get(conn, "/api/v1/accounts/#{user.id}/followers?limit=1&max_id=#{follower3_id}") assert [%{"id" => ^follower2_id}] = json_response_and_validate_schema(res_conn, 200) @@ -654,6 +663,16 @@ test "getting following, pagination", %{user: user, conn: conn} do assert id2 == following2.id assert id1 == following1.id + res_conn = + get( + conn, + "/api/v1/accounts/#{user.id}/following?id=#{user.id}&limit=20&max_id=#{following3.id}" + ) + + assert [%{"id" => id2}, %{"id" => id1}] = json_response_and_validate_schema(res_conn, 200) + assert id2 == following2.id + assert id1 == following1.id + res_conn = get(conn, "/api/v1/accounts/#{user.id}/following?limit=1&max_id=#{following3.id}") -- cgit v1.2.3 From 7ce722ce3e3dbc633324ff0ccaeddc467397ac5e Mon Sep 17 00:00:00 2001 From: KokaKiwi Date: Sat, 18 Jul 2020 12:55:04 +0200 Subject: Fix /api/pleroma/emoji/packs index endpoint. --- lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 33ecd1f70..866901344 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do ) @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] - plug(:skip_plug, @skip_plugs when action in [:archive, :show, :list]) + plug(:skip_plug, @skip_plugs when action in [:index, :show, :archive]) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiPackOperation -- cgit v1.2.3 From 4bac25e6f5d332b06e481d25b80efb62026c6a1e Mon Sep 17 00:00:00 2001 From: href Date: Sat, 18 Jul 2020 13:17:38 +0200 Subject: Don't enable Pleroma.HTTP.Middleware.FollowRedirects unless Gun is used --- lib/pleroma/http/http.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index 6128bc4cf..b37b3fa89 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -69,7 +69,8 @@ def request(method, url, body, headers, options) when is_binary(url) do request = build_request(method, headers, options, url, body, params) adapter = Application.get_env(:tesla, :adapter) - client = Tesla.client([Pleroma.HTTP.Middleware.FollowRedirects], adapter) + + client = Tesla.client(adapter_middlewares(adapter), adapter) maybe_limit( fn -> @@ -107,4 +108,10 @@ defp maybe_limit(fun, Tesla.Adapter.Gun, opts) do defp maybe_limit(fun, _, _) do fun.() end + + defp adapter_middlewares(Tesla.Adapter.Gun) do + [Pleroma.HTTP.Middleware.FollowRedirects] + end + + defp adapter_middlewares(_), do: [] end -- cgit v1.2.3 From ae74c52e222c6e230ac04f484306c0a16aa270a5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 Jul 2020 15:10:48 -0500 Subject: Test angry face in formatter D:< #1968 --- test/formatter_test.exs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/formatter_test.exs b/test/formatter_test.exs index ae0d7b377..8713ab9c2 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -265,6 +265,26 @@ test "it parses URL containing local mention" do assert {^expected, [], []} = Formatter.linkify(text) end + + test "it correctly parses angry face D:< with mention" do + lain = + insert(:user, %{ + nickname: "lain@lain.com", + ap_id: "https://lain.com/users/lain", + id: "9qrWmR0cKniB0YU0TA" + }) + + text = "@lain@lain.com D:<" + + expected_text = + ~S(@lain D:<) + + expected_mentions = [ + {"@lain@lain.com", lain} + ] + + assert {^expected_text, ^expected_mentions, []} = Formatter.linkify(text) + end end describe ".parse_tags" do -- cgit v1.2.3 From 531c3ab9f34a41812f82e9e7dd3a604fbc11405d Mon Sep 17 00:00:00 2001 From: Dym Sohin Date: Mon, 20 Jul 2020 11:41:43 +0000 Subject: fix markdown rendering withing
    ; typo parent**s**_visible --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75488f026..080270073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    API Changes -- Mastodon API: Add pleroma.parents_visible field to statuses. + +- Mastodon API: Add pleroma.parent_visible field to statuses. - Mastodon API: Extended `/api/v1/instance`. - Mastodon API: Support for `include_types` in `/api/v1/notifications`. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. @@ -121,6 +122,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Follow request notifications
    API Changes + - Admin API: `GET /api/pleroma/admin/need_reboot`.
    @@ -188,6 +190,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking**: Using third party engines for user recommendation
    API Changes + - **Breaking**: AdminAPI: migrate_from_db endpoint
    -- cgit v1.2.3 From 5d263dfdb314f1ed6eca9e5c183149efcc58c367 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Jul 2020 09:29:03 -0500 Subject: Update linkify to latest release --- mix.exs | 4 +--- mix.lock | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 727cdb71f..f44d7a887 100644 --- a/mix.exs +++ b/mix.exs @@ -166,9 +166,7 @@ defp deps do {:floki, "~> 0.25"}, {:timex, "~> 3.5"}, {:ueberauth, "~> 0.4"}, - {:linkify, - git: "https://git.pleroma.social/pleroma/elixir-libraries/linkify.git", - ref: "a08513aa7e879f056c44c5b8aea8c0fd073be5c8"}, + {:linkify, "~> 0.2.0"}, {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, diff --git a/mix.lock b/mix.lock index 2025a965d..6430ddd19 100644 --- a/mix.lock +++ b/mix.lock @@ -62,7 +62,7 @@ "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, - "linkify": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/linkify.git", "a08513aa7e879f056c44c5b8aea8c0fd073be5c8", [ref: "a08513aa7e879f056c44c5b8aea8c0fd073be5c8"]}, + "linkify": {:hex, :linkify, "0.2.0", "2518bbbea21d2caa9d372424e1ad845b640c6630e2d016f1bd1f518f9ebcca28", [:mix], [], "hexpm", "b8ca8a68b79e30b7938d6c996085f3db14939f29538a59ca5101988bb7f917f6"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, -- cgit v1.2.3 From 204dddcfaaa5ff1113ef2f772ce5d6fcbbaaec6e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 20 Jul 2020 13:45:05 -0500 Subject: Pleroma.Formatter can have partial updates --- lib/pleroma/config/config_db.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index f8141ced8..e5b7811aa 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -156,7 +156,6 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do {:quack, :meta}, {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, - {:linkify, :opts}, {:swarm, :node_blacklist}, {:logger, :backends} ] -- cgit v1.2.3 From 3edaecae96975c229c3b8bd7be2dc1208b9bcb82 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 21 Jul 2020 09:25:53 +0300 Subject: added welcome email --- CHANGELOG.md | 1 + config/config.exs | 16 +++++++-- docs/configuration/cheatsheet.md | 12 +++++++ lib/pleroma/config/helpers.ex | 17 ++++++++++ lib/pleroma/config/utils.ex | 17 ++++++++++ lib/pleroma/emails/user_email.ex | 17 ++++++---- lib/pleroma/user.ex | 21 +++++++++++- lib/pleroma/user/welcome_email.ex | 68 +++++++++++++++++++++++++++++++++++++ lib/pleroma/user/welcome_message.ex | 41 ++++++++++++++-------- test/emails/user_email_test.exs | 1 + test/tasks/config_test.exs | 4 +-- test/user_test.exs | 27 ++++++++++++--- 12 files changed, 212 insertions(+), 30 deletions(-) create mode 100644 lib/pleroma/config/helpers.ex create mode 100644 lib/pleroma/config/utils.ex create mode 100644 lib/pleroma/user/welcome_email.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 080270073..b5720aa33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support pagination in emoji packs API (for packs and for files in pack) - Support for viewing instances favicons next to posts and accounts - Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata. +- Configuration: Add `:welcome` setting for welcoming message to a newly registered users.
    API Changes diff --git a/config/config.exs b/config/config.exs index 2d3f35e70..16b7f6dc7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -225,8 +225,6 @@ autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, - welcome_user_nickname: nil, - welcome_message: nil, max_report_comment_size: 1000, safe_dm_mentions: false, healthcheck: false, @@ -254,6 +252,20 @@ ] ] +config :pleroma, :welcome, + direct_message: [ + enabled: false, + sender_nickname: nil, + message: nil + ], + email: [ + enabled: false, + sender_nickname: nil, + subject: "Welcome to <%= instance_name %>", + html: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + ] + config :pleroma, :feed, post_title: %{ max_length: 100, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6c1babba3..7e8f86aba 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -63,6 +63,18 @@ To add configuration to your config file, you can copy it from the base config. * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. +## Welcome +* `direct_message`: - welcome message sent as a direct message. + * `enabled`: Enables the send a direct message to a newly registered user. Defaults to `false`. + * `sender_nickname`: The nickname of the local user that sends the welcome message. + * `message`: A message that will be send to a newly registered users as a direct message. +* `email`: - welcome message sent as a email. + * `enabled`: Enables the send a welcome email to a newly registered user. Defaults to `false`. + * `sender_nickname`: The nickname of the local user that sends the welcome email. + * `subject`: A subject of welcome email. + * `html`: A html that will be send to a newly registered users as a email. + * `text`: A text that will be send to a newly registered users as a email. + ## Message rewrite facility ### :mrf diff --git a/lib/pleroma/config/helpers.ex b/lib/pleroma/config/helpers.ex new file mode 100644 index 000000000..3dce40ea0 --- /dev/null +++ b/lib/pleroma/config/helpers.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Helpers do + alias Pleroma.Config + + def instance_name, do: Config.get([:instance, :name]) + + defp instance_notify_email do + Config.get([:instance, :notify_email]) || Config.get([:instance, :email]) + end + + def sender do + {instance_name(), instance_notify_email()} + end +end diff --git a/lib/pleroma/config/utils.ex b/lib/pleroma/config/utils.ex new file mode 100644 index 000000000..f1afbb42f --- /dev/null +++ b/lib/pleroma/config/utils.ex @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Config.Utils do + alias Pleroma.Config + + def instance_name, do: Config.get([:instance, :name]) + + defp instance_notify_email do + Config.get([:instance, :notify_email]) || Config.get([:instance, :email]) + end + + def sender do + {instance_name(), instance_notify_email()} + end +end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index dfadc10b3..313533859 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -12,17 +12,22 @@ defmodule Pleroma.Emails.UserEmail do alias Pleroma.Web.Endpoint alias Pleroma.Web.Router - defp instance_name, do: Config.get([:instance, :name]) - - defp sender do - email = Config.get([:instance, :notify_email]) || Config.get([:instance, :email]) - {instance_name(), email} - end + import Pleroma.Config.Helpers, only: [instance_name: 0, sender: 0] defp recipient(email, nil), do: email defp recipient(email, name), do: {name, email} defp recipient(%User{} = user), do: recipient(user.email, user.name) + @spec welcome(User.t(), map()) :: Swoosh.Email.t() + def welcome(user, opts \\ %{}) do + new() + |> to(recipient(user)) + |> from(Map.get(opts, :sender, sender())) + |> subject(Map.get(opts, :subject, "Welcome to #{instance_name()}!")) + |> html_body(Map.get(opts, :html, "Welcome to #{instance_name()}!")) + |> text_body(Map.get(opts, :text, "Welcome to #{instance_name()}!")) + end + def password_reset_email(user, token) when is_binary(token) do password_reset_url = Router.Helpers.reset_password_url(Endpoint, :reset, token) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9240e912d..29526b8fd 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -713,12 +713,31 @@ def register(%Ecto.Changeset{} = changeset) do def post_register_action(%User{} = user) do with {:ok, user} <- autofollow_users(user), {:ok, user} <- set_cache(user), - {:ok, _} <- User.WelcomeMessage.post_welcome_message_to_user(user), + {:ok, _} <- send_welcome_email(user), + {:ok, _} <- send_welcome_message(user), {:ok, _} <- try_send_confirmation_email(user) do {:ok, user} end end + def send_welcome_message(user) do + if User.WelcomeMessage.enabled?() do + User.WelcomeMessage.post_message(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + + def send_welcome_email(user) do + if User.WelcomeEmail.enabled?() do + User.WelcomeEmail.send_email(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + def try_send_confirmation_email(%User{} = user) do if user.confirmation_pending && Config.get([:instance, :account_activation_required]) do diff --git a/lib/pleroma/user/welcome_email.ex b/lib/pleroma/user/welcome_email.ex new file mode 100644 index 000000000..53062b961 --- /dev/null +++ b/lib/pleroma/user/welcome_email.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeEmail do + @moduledoc """ + The module represents the functions to send welcome email. + """ + + alias Pleroma.Config + alias Pleroma.Emails + alias Pleroma.User + + import Pleroma.Config.Utils, only: [instance_name: 0] + + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:welcome, :email, :enabled], false) + + @spec send_email(User.t()) :: {:ok, Oban.Job.t()} + def send_email(%User{} = user) do + user + |> Emails.UserEmail.welcome(email_options(user)) + |> Emails.Mailer.deliver_async() + end + + defp email_options(user) do + bindings = [user: user, instance_name: instance_name()] + + %{} + |> add_sender(Config.get([:welcome, :email, :sender_nickname], nil)) + |> add_option(:subject, bindings) + |> add_option(:html, bindings) + |> add_option(:text, bindings) + end + + defp add_option(opts, option, bindings) do + [:welcome, :email, option] + |> Config.get(nil) + |> eval_string(bindings) + |> merge_options(opts, option) + end + + def add_sender(opts, nickname) do + nickname + |> fetch_sender() + |> merge_options(opts, :sender) + end + + defp merge_options(nil, options, _option), do: options + + defp merge_options(value, options, option) do + Map.merge(options, %{option => value}) + end + + defp fetch_sender(nickname) when is_binary(nickname) do + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + {instance_name(), user.email} + else + _ -> nil + end + end + + defp fetch_sender(_), do: nil + + defp eval_string(nil, _), do: nil + defp eval_string("", _), do: nil + defp eval_string(str, bindings), do: EEx.eval_string(str, bindings) +end diff --git a/lib/pleroma/user/welcome_message.ex b/lib/pleroma/user/welcome_message.ex index f8f520285..86e1c0678 100644 --- a/lib/pleroma/user/welcome_message.ex +++ b/lib/pleroma/user/welcome_message.ex @@ -3,32 +3,45 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.WelcomeMessage do + alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI - def post_welcome_message_to_user(user) do - with %User{} = sender_user <- welcome_user(), - message when is_binary(message) <- welcome_message() do - CommonAPI.post(sender_user, %{ + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:welcome, :direct_message, :enabled], false) + + @spec post_message(User.t()) :: {:ok, Pleroma.Activity.t() | nil} + def post_message(user) do + [:welcome, :direct_message, :sender_nickname] + |> Config.get(nil) + |> fetch_sender() + |> do_post(user, welcome_message()) + end + + defp do_post(%User{} = sender, %User{nickname: nickname}, message) + when is_binary(message) do + CommonAPI.post( + sender, + %{ visibility: "direct", - status: "@#{user.nickname}\n#{message}" - }) - else - _ -> {:ok, nil} - end + status: "@#{nickname}\n#{message}" + } + ) end - defp welcome_user do - with nickname when is_binary(nickname) <- - Pleroma.Config.get([:instance, :welcome_user_nickname]), - %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + defp do_post(_sender, _recipient, _message), do: {:ok, nil} + + defp fetch_sender(nickname) when is_binary(nickname) do + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do user else _ -> nil end end + defp fetch_sender(_), do: nil + defp welcome_message do - Pleroma.Config.get([:instance, :welcome_message]) + Config.get([:welcome, :direct_message, :message], nil) end end diff --git a/test/emails/user_email_test.exs b/test/emails/user_email_test.exs index a75623bb4..502702e49 100644 --- a/test/emails/user_email_test.exs +++ b/test/emails/user_email_test.exs @@ -10,6 +10,7 @@ defmodule Pleroma.Emails.UserEmailTest do alias Pleroma.Web.Router import Pleroma.Factory + import Swoosh.TestAssertions test "build password reset email" do config = Pleroma.Config.get(:instance) diff --git a/test/tasks/config_test.exs b/test/tasks/config_test.exs index 71f36c0e3..fb12e7fb3 100644 --- a/test/tasks/config_test.exs +++ b/test/tasks/config_test.exs @@ -129,8 +129,6 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil autofollowed_nicknames: [], max_pinned_statuses: 1, attachment_links: false, - welcome_user_nickname: nil, - welcome_message: nil, max_report_comment_size: 1000, safe_dm_mentions: false, healthcheck: false, @@ -172,7 +170,7 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil end assert file == - "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n welcome_user_nickname: nil,\n welcome_message: nil,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" + "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" end end end diff --git a/test/user_test.exs b/test/user_test.exs index 9788e09d9..e887a3fb2 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -17,6 +17,7 @@ defmodule Pleroma.UserTest do import Pleroma.Factory import ExUnit.CaptureLog + import Swoosh.TestAssertions setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -386,8 +387,8 @@ test "fetches correct profile for nickname beginning with number" do email: "email@example.com" } setup do: clear_config([:instance, :autofollowed_nicknames]) - setup do: clear_config([:instance, :welcome_message]) - setup do: clear_config([:instance, :welcome_user_nickname]) + + setup do: clear_config([:welcome]) test "it autofollows accounts that are set for it" do user = insert(:user) @@ -408,17 +409,35 @@ test "it autofollows accounts that are set for it" do test "it sends a welcome message if it is set" do welcome_user = insert(:user) + Pleroma.Config.put([:welcome, :direct_message, :enabled], true) + Pleroma.Config.put([:welcome, :direct_message, :sender_nickname], welcome_user.nickname) + Pleroma.Config.put([:welcome, :direct_message, :message], "Hello, this is a cool site") + + Pleroma.Config.put([:welcome, :email, :enabled], true) + Pleroma.Config.put([:welcome, :email, :sender_nickname], welcome_user.nickname) + + Pleroma.Config.put( + [:welcome, :email, :subject], + "Hello, welcome to cool site: <%= instance_name %>" + ) - Pleroma.Config.put([:instance, :welcome_user_nickname], welcome_user.nickname) - Pleroma.Config.put([:instance, :welcome_message], "Hello, this is a cool site") + instance_name = Pleroma.Config.get([:instance, :name]) cng = User.register_changeset(%User{}, @full_user_data) {:ok, registered_user} = User.register(cng) + ObanHelpers.perform_all() activity = Repo.one(Pleroma.Activity) assert registered_user.ap_id in activity.recipients assert Object.normalize(activity).data["content"] =~ "cool site" assert activity.actor == welcome_user.ap_id + + assert_email_sent( + from: {instance_name, welcome_user.email}, + to: {registered_user.name, registered_user.email}, + subject: "Hello, welcome to cool site: #{instance_name}", + html_body: "Welcome to #{instance_name}" + ) end setup do: clear_config([:instance, :account_activation_required]) -- cgit v1.2.3 From 6afc6717d642060b086d01c2bfff5ead0aad1273 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 21 Jul 2020 10:31:58 +0300 Subject: copy tmp file if test depends on it --- test/upload/filter/anonymize_filename_test.exs | 2 ++ test/uploaders/local_test.exs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/test/upload/filter/anonymize_filename_test.exs b/test/upload/filter/anonymize_filename_test.exs index 2d5c580f1..adff70f57 100644 --- a/test/upload/filter/anonymize_filename_test.exs +++ b/test/upload/filter/anonymize_filename_test.exs @@ -9,6 +9,8 @@ defmodule Pleroma.Upload.Filter.AnonymizeFilenameTest do alias Pleroma.Upload setup do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + upload_file = %Upload{ name: "an… image.jpg", content_type: "image/jpg", diff --git a/test/uploaders/local_test.exs b/test/uploaders/local_test.exs index ae2cfef94..18122ff6c 100644 --- a/test/uploaders/local_test.exs +++ b/test/uploaders/local_test.exs @@ -14,6 +14,7 @@ test "it returns path to local folder for files" do describe "put_file/1" do test "put file to local folder" do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") file_path = "local_upload/files/image.jpg" file = %Pleroma.Upload{ @@ -32,6 +33,7 @@ test "put file to local folder" do describe "delete_file/1" do test "deletes local file" do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") file_path = "local_upload/files/image.jpg" file = %Pleroma.Upload{ -- cgit v1.2.3 From bdb3375933b17ffd596d9d870d797fcc47a4828b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 21 Jul 2020 16:06:46 +0400 Subject: Allow unblocking a domain via query params --- .../web/api_spec/operations/domain_block_operation.ex | 6 +++--- .../controllers/domain_block_controller.ex | 5 +++++ .../controllers/domain_block_controller_test.exs | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex index 049bcf931..8234394f9 100644 --- a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -57,6 +57,7 @@ def delete_operation do description: "Remove a domain block, if it exists in the user's array of blocked domains.", operationId: "DomainBlockController.delete", requestBody: domain_block_request(), + parameters: [Operation.parameter(:domain, :query, %Schema{type: :string}, "Domain name")], security: [%{"oAuth" => ["follow", "write:blocks"]}], responses: %{ 200 => Operation.response("Empty object", "application/json", %Schema{type: :object}) @@ -71,10 +72,9 @@ defp domain_block_request do type: :object, properties: %{ domain: %Schema{type: :string} - }, - required: [:domain] + } }, - required: true, + required: false, example: %{ "domain" => "facebook.com" } diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index 825b231ab..117e89426 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -37,4 +37,9 @@ def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, User.unblock_domain(blocker, domain) json(conn, %{}) end + + def delete(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do + User.unblock_domain(blocker, domain) + json(conn, %{}) + end end diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs index 01a24afcf..978290d62 100644 --- a/test/web/mastodon_api/controllers/domain_block_controller_test.exs +++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -32,6 +32,24 @@ test "blocking / unblocking a domain" do refute User.blocks?(user, other_user) end + test "unblocking a domain via query params" do + %{user: user, conn: conn} = oauth_access(["write:blocks"]) + other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"}) + + User.block_domain(user, "dogwhistle.zone") + user = refresh_record(user) + assert User.blocks?(user, other_user) + + ret_conn = + conn + |> put_req_header("content-type", "application/json") + |> delete("/api/v1/domain_blocks?domain=dogwhistle.zone") + + assert %{} == json_response_and_validate_schema(ret_conn, 200) + user = User.get_cached_by_ap_id(user.ap_id) + refute User.blocks?(user, other_user) + end + test "getting a list of domain blocks" do %{user: user, conn: conn} = oauth_access(["read:blocks"]) -- cgit v1.2.3 From 696c13ce54aff25737f8f753a94747d79b9c54b0 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Jul 2020 22:17:34 +0000 Subject: Revert "Merge branch 'linkify' into 'develop'" This reverts merge request !2677 --- CHANGELOG.md | 1 - config/config.exs | 18 ++++++----- config/description.exs | 20 ++++-------- docs/configuration/cheatsheet.md | 35 ++++++++++---------- lib/pleroma/config/config_db.ex | 1 + lib/pleroma/formatter.ex | 26 +++++++-------- lib/pleroma/web/rich_media/helpers.ex | 4 +-- mix.exs | 4 ++- mix.lock | 2 +- .../20200716195806_autolinker_to_linkify.exs | 37 ---------------------- test/formatter_test.exs | 30 ------------------ 11 files changed, 52 insertions(+), 126 deletions(-) delete mode 100644 priv/repo/migrations/20200716195806_autolinker_to_linkify.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index f4397ec3c..080270073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - **Breaking:** Elixir >=1.9 is now required (was >= 1.8) -- **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated. - In Conversations, return only direct messages as `last_status` - Using the `only_media` filter on timelines will now exclude reblog media - MFR policy to set global expiration for all local Create activities diff --git a/config/config.exs b/config/config.exs index 406bf2a9b..2d3f35e70 100644 --- a/config/config.exs +++ b/config/config.exs @@ -527,14 +527,16 @@ federator_outgoing: 5 ] -config :pleroma, Pleroma.Formatter, - class: false, - rel: "ugc", - new_window: false, - truncate: false, - strip_prefix: false, - extra: true, - validate_tld: :no_scheme +config :auto_linker, + opts: [ + extra: true, + # TODO: Set to :no_scheme when it works properly + validate_tld: true, + class: false, + strip_prefix: false, + new_window: false, + rel: "ugc" + ] config :pleroma, :ldap, enabled: System.get_env("LDAP_ENABLED") == "true", diff --git a/config/description.exs b/config/description.exs index b97b0a7ec..f1c6773f1 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2216,12 +2216,11 @@ ] }, %{ - group: :pleroma, - key: Pleroma.Formatter, + group: :auto_linker, + key: :opts, label: "Auto Linker", type: :group, - description: - "Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs.", + description: "Configuration for the auto_linker library", children: [ %{ key: :class, @@ -2238,31 +2237,24 @@ %{ key: :new_window, type: :boolean, - description: "Link URLs will open in a new window/tab." + description: "Link URLs will open in new window/tab" }, %{ key: :truncate, type: [:integer, false], description: - "Set to a number to truncate URLs longer than the number. Truncated URLs will end in `...`", + "Set to a number to truncate URLs longer then the number. Truncated URLs will end in `..`", suggestions: [15, false] }, %{ key: :strip_prefix, type: :boolean, - description: "Strip the scheme prefix." + description: "Strip the scheme prefix" }, %{ key: :extra, type: :boolean, description: "Link URLs with rarely used schemes (magnet, ipfs, irc, etc.)" - }, - %{ - key: :validate_tld, - type: [:atom, :boolean], - description: - "Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for URLs without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't)", - suggestions: [:no_scheme, true] } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 042ad30c9..6c1babba3 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -934,29 +934,30 @@ Configure OAuth 2 provider capabilities: ### :uri_schemes * `valid_schemes`: List of the scheme part that is considered valid to be an URL. -### Pleroma.Formatter +### :auto_linker -Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs. +Configuration for the `auto_linker` library: -* `class` - specify the class to be added to the generated link (default: `false`) -* `rel` - specify the rel attribute (default: `ugc`) -* `new_window` - adds `target="_blank"` attribute (default: `false`) -* `truncate` - Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...` (default: `false`) -* `strip_prefix` - Strip the scheme prefix (default: `false`) -* `extra` - link URLs with rarely used schemes (magnet, ipfs, irc, etc.) (default: `true`) -* `validate_tld` - Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for urls without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't) (default: `:no_scheme`) +* `class: "auto-linker"` - specify the class to be added to the generated link. false to clear. +* `rel: "noopener noreferrer"` - override the rel attribute. false to clear. +* `new_window: true` - set to false to remove `target='_blank'` attribute. +* `scheme: false` - Set to true to link urls with schema `http://google.com`. +* `truncate: false` - Set to a number to truncate urls longer then the number. Truncated urls will end in `..`. +* `strip_prefix: true` - Strip the scheme prefix. +* `extra: false` - link urls with rarely used schemes (magnet, ipfs, irc, etc.). Example: ```elixir -config :pleroma, Pleroma.Formatter, - class: false, - rel: "ugc", - new_window: false, - truncate: false, - strip_prefix: false, - extra: true, - validate_tld: :no_scheme +config :auto_linker, + opts: [ + scheme: true, + extra: true, + class: false, + strip_prefix: false, + new_window: false, + rel: "ugc" + ] ``` ## Custom Runtime Modules (`:modules`) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index e5b7811aa..1a89d8895 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -156,6 +156,7 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do {:quack, :meta}, {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, + {:auto_linker, :opts}, {:swarm, :node_blacklist}, {:logger, :backends} ] diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 0c450eae4..02a93a8dc 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -10,15 +10,11 @@ defmodule Pleroma.Formatter do @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ - defp linkify_opts do - Pleroma.Config.get(Pleroma.Formatter) ++ - [ - hashtag: true, - hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, - mention: true, - mention_handler: &Pleroma.Formatter.mention_handler/4 - ] - end + @auto_linker_config hashtag: true, + hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, + mention: true, + mention_handler: &Pleroma.Formatter.mention_handler/4, + scheme: true def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do case User.get_cached_by_nickname(nickname) do @@ -84,19 +80,19 @@ def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do @spec linkify(String.t(), keyword()) :: {String.t(), [{String.t(), User.t()}], [{String.t(), String.t()}]} def linkify(text, options \\ []) do - options = linkify_opts() ++ options + options = options ++ @auto_linker_config if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) acc = %{mentions: MapSet.new(), tags: MapSet.new()} - {text_mentions, %{mentions: mentions}} = Linkify.link_map(mentions, acc, options) - {text_rest, %{tags: tags}} = Linkify.link_map(rest, acc, options) + {text_mentions, %{mentions: mentions}} = AutoLinker.link_map(mentions, acc, options) + {text_rest, %{tags: tags}} = AutoLinker.link_map(rest, acc, options) {text_mentions <> text_rest, MapSet.to_list(mentions), MapSet.to_list(tags)} else acc = %{mentions: MapSet.new(), tags: MapSet.new()} - {text, %{mentions: mentions, tags: tags}} = Linkify.link_map(text, acc, options) + {text, %{mentions: mentions, tags: tags}} = AutoLinker.link_map(text, acc, options) {text, MapSet.to_list(mentions), MapSet.to_list(tags)} end @@ -115,9 +111,9 @@ def mentions_escape(text, options \\ []) do if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) - Linkify.link(mentions, options) <> Linkify.link(rest, options) + AutoLinker.link(mentions, options) <> AutoLinker.link(rest, options) else - Linkify.link(text, options) + AutoLinker.link(text, options) end end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 747f2dc6b..1729141e9 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Web.RichMedia.Helpers do @spec validate_page_url(URI.t() | binary()) :: :ok | :error defp validate_page_url(page_url) when is_binary(page_url) do - validate_tld = Pleroma.Config.get([Pleroma.Formatter, :validate_tld]) + validate_tld = Application.get_env(:auto_linker, :opts)[:validate_tld] page_url - |> Linkify.Parser.url?(validate_tld: validate_tld) + |> AutoLinker.Parser.url?(scheme: true, validate_tld: validate_tld) |> parse_uri(page_url) end diff --git a/mix.exs b/mix.exs index f44d7a887..52b4cf268 100644 --- a/mix.exs +++ b/mix.exs @@ -166,7 +166,9 @@ defp deps do {:floki, "~> 0.25"}, {:timex, "~> 3.5"}, {:ueberauth, "~> 0.4"}, - {:linkify, "~> 0.2.0"}, + {:auto_linker, + git: "https://git.pleroma.social/pleroma/auto_linker.git", + ref: "95e8188490e97505c56636c1379ffdf036c1fdde"}, {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, diff --git a/mix.lock b/mix.lock index 6430ddd19..8dd37a40f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, + "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, @@ -62,7 +63,6 @@ "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, - "linkify": {:hex, :linkify, "0.2.0", "2518bbbea21d2caa9d372424e1ad845b640c6630e2d016f1bd1f518f9ebcca28", [:mix], [], "hexpm", "b8ca8a68b79e30b7938d6c996085f3db14939f29538a59ca5101988bb7f917f6"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, diff --git a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs deleted file mode 100644 index 9ec4203eb..000000000 --- a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Pleroma.Repo.Migrations.AutolinkerToLinkify do - use Ecto.Migration - - alias Pleroma.Repo - alias Pleroma.ConfigDB - - @autolinker_path %{group: :auto_linker, key: :opts} - @linkify_path %{group: :pleroma, key: Pleroma.Formatter} - - @compat_opts [:class, :rel, :new_window, :truncate, :strip_prefix, :extra] - - def change do - with {:ok, {old, new}} <- maybe_get_params() do - move_config(old, new) - end - end - - defp move_config(%{} = old, %{} = new) do - {:ok, _} = ConfigDB.update_or_create(new) - {:ok, _} = ConfigDB.delete(old) - :ok - end - - defp maybe_get_params() do - with %ConfigDB{value: opts} <- ConfigDB.get_by_params(@autolinker_path), - %{} = opts <- transform_opts(opts), - %{} = linkify_params <- Map.put(@linkify_path, :value, opts) do - {:ok, {@autolinker_path, linkify_params}} - end - end - - defp transform_opts(opts) when is_list(opts) do - opts - |> Enum.into(%{}) - |> Map.take(@compat_opts) - end -end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index 8713ab9c2..bef5a2c28 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -255,36 +255,6 @@ test "it can parse mentions and return the relevant users" do assert {_text, ^expected_mentions, []} = Formatter.linkify(text) end - - test "it parses URL containing local mention" do - _user = insert(:user, %{nickname: "lain"}) - - text = "https://example.com/@lain" - - expected = ~S(https://example.com/@lain) - - assert {^expected, [], []} = Formatter.linkify(text) - end - - test "it correctly parses angry face D:< with mention" do - lain = - insert(:user, %{ - nickname: "lain@lain.com", - ap_id: "https://lain.com/users/lain", - id: "9qrWmR0cKniB0YU0TA" - }) - - text = "@lain@lain.com D:<" - - expected_text = - ~S(@lain D:<) - - expected_mentions = [ - {"@lain@lain.com", lain} - ] - - assert {^expected_text, ^expected_mentions, []} = Formatter.linkify(text) - end end describe ".parse_tags" do -- cgit v1.2.3 From 5b1eeb06d81872696fac89dba457fe62b62d6182 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 21 Jul 2020 22:18:17 +0000 Subject: Revert "Merge branch 'revert-2b5d9eb1' into 'develop'" This reverts merge request !2784 --- CHANGELOG.md | 1 + config/config.exs | 18 +++++------ config/description.exs | 20 ++++++++---- docs/configuration/cheatsheet.md | 35 ++++++++++---------- lib/pleroma/config/config_db.ex | 1 - lib/pleroma/formatter.ex | 26 ++++++++------- lib/pleroma/web/rich_media/helpers.ex | 4 +-- mix.exs | 4 +-- mix.lock | 2 +- .../20200716195806_autolinker_to_linkify.exs | 37 ++++++++++++++++++++++ test/formatter_test.exs | 30 ++++++++++++++++++ 11 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 priv/repo/migrations/20200716195806_autolinker_to_linkify.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 080270073..f4397ec3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - **Breaking:** Elixir >=1.9 is now required (was >= 1.8) +- **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated. - In Conversations, return only direct messages as `last_status` - Using the `only_media` filter on timelines will now exclude reblog media - MFR policy to set global expiration for all local Create activities diff --git a/config/config.exs b/config/config.exs index 2d3f35e70..406bf2a9b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -527,16 +527,14 @@ federator_outgoing: 5 ] -config :auto_linker, - opts: [ - extra: true, - # TODO: Set to :no_scheme when it works properly - validate_tld: true, - class: false, - strip_prefix: false, - new_window: false, - rel: "ugc" - ] +config :pleroma, Pleroma.Formatter, + class: false, + rel: "ugc", + new_window: false, + truncate: false, + strip_prefix: false, + extra: true, + validate_tld: :no_scheme config :pleroma, :ldap, enabled: System.get_env("LDAP_ENABLED") == "true", diff --git a/config/description.exs b/config/description.exs index f1c6773f1..b97b0a7ec 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2216,11 +2216,12 @@ ] }, %{ - group: :auto_linker, - key: :opts, + group: :pleroma, + key: Pleroma.Formatter, label: "Auto Linker", type: :group, - description: "Configuration for the auto_linker library", + description: + "Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs.", children: [ %{ key: :class, @@ -2237,24 +2238,31 @@ %{ key: :new_window, type: :boolean, - description: "Link URLs will open in new window/tab" + description: "Link URLs will open in a new window/tab." }, %{ key: :truncate, type: [:integer, false], description: - "Set to a number to truncate URLs longer then the number. Truncated URLs will end in `..`", + "Set to a number to truncate URLs longer than the number. Truncated URLs will end in `...`", suggestions: [15, false] }, %{ key: :strip_prefix, type: :boolean, - description: "Strip the scheme prefix" + description: "Strip the scheme prefix." }, %{ key: :extra, type: :boolean, description: "Link URLs with rarely used schemes (magnet, ipfs, irc, etc.)" + }, + %{ + key: :validate_tld, + type: [:atom, :boolean], + description: + "Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for URLs without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't)", + suggestions: [:no_scheme, true] } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6c1babba3..042ad30c9 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -934,30 +934,29 @@ Configure OAuth 2 provider capabilities: ### :uri_schemes * `valid_schemes`: List of the scheme part that is considered valid to be an URL. -### :auto_linker +### Pleroma.Formatter -Configuration for the `auto_linker` library: +Configuration for Pleroma's link formatter which parses mentions, hashtags, and URLs. -* `class: "auto-linker"` - specify the class to be added to the generated link. false to clear. -* `rel: "noopener noreferrer"` - override the rel attribute. false to clear. -* `new_window: true` - set to false to remove `target='_blank'` attribute. -* `scheme: false` - Set to true to link urls with schema `http://google.com`. -* `truncate: false` - Set to a number to truncate urls longer then the number. Truncated urls will end in `..`. -* `strip_prefix: true` - Strip the scheme prefix. -* `extra: false` - link urls with rarely used schemes (magnet, ipfs, irc, etc.). +* `class` - specify the class to be added to the generated link (default: `false`) +* `rel` - specify the rel attribute (default: `ugc`) +* `new_window` - adds `target="_blank"` attribute (default: `false`) +* `truncate` - Set to a number to truncate URLs longer then the number. Truncated URLs will end in `...` (default: `false`) +* `strip_prefix` - Strip the scheme prefix (default: `false`) +* `extra` - link URLs with rarely used schemes (magnet, ipfs, irc, etc.) (default: `true`) +* `validate_tld` - Set to false to disable TLD validation for URLs/emails. Can be set to :no_scheme to validate TLDs only for urls without a scheme (e.g `example.com` will be validated, but `http://example.loki` won't) (default: `:no_scheme`) Example: ```elixir -config :auto_linker, - opts: [ - scheme: true, - extra: true, - class: false, - strip_prefix: false, - new_window: false, - rel: "ugc" - ] +config :pleroma, Pleroma.Formatter, + class: false, + rel: "ugc", + new_window: false, + truncate: false, + strip_prefix: false, + extra: true, + validate_tld: :no_scheme ``` ## Custom Runtime Modules (`:modules`) diff --git a/lib/pleroma/config/config_db.ex b/lib/pleroma/config/config_db.ex index 1a89d8895..e5b7811aa 100644 --- a/lib/pleroma/config/config_db.ex +++ b/lib/pleroma/config/config_db.ex @@ -156,7 +156,6 @@ defp only_full_update?(%ConfigDB{group: group, key: key}) do {:quack, :meta}, {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, - {:auto_linker, :opts}, {:swarm, :node_blacklist}, {:logger, :backends} ] diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 02a93a8dc..0c450eae4 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -10,11 +10,15 @@ defmodule Pleroma.Formatter do @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ - @auto_linker_config hashtag: true, - hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, - mention: true, - mention_handler: &Pleroma.Formatter.mention_handler/4, - scheme: true + defp linkify_opts do + Pleroma.Config.get(Pleroma.Formatter) ++ + [ + hashtag: true, + hashtag_handler: &Pleroma.Formatter.hashtag_handler/4, + mention: true, + mention_handler: &Pleroma.Formatter.mention_handler/4 + ] + end def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do case User.get_cached_by_nickname(nickname) do @@ -80,19 +84,19 @@ def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do @spec linkify(String.t(), keyword()) :: {String.t(), [{String.t(), User.t()}], [{String.t(), String.t()}]} def linkify(text, options \\ []) do - options = options ++ @auto_linker_config + options = linkify_opts() ++ options if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) acc = %{mentions: MapSet.new(), tags: MapSet.new()} - {text_mentions, %{mentions: mentions}} = AutoLinker.link_map(mentions, acc, options) - {text_rest, %{tags: tags}} = AutoLinker.link_map(rest, acc, options) + {text_mentions, %{mentions: mentions}} = Linkify.link_map(mentions, acc, options) + {text_rest, %{tags: tags}} = Linkify.link_map(rest, acc, options) {text_mentions <> text_rest, MapSet.to_list(mentions), MapSet.to_list(tags)} else acc = %{mentions: MapSet.new(), tags: MapSet.new()} - {text, %{mentions: mentions, tags: tags}} = AutoLinker.link_map(text, acc, options) + {text, %{mentions: mentions, tags: tags}} = Linkify.link_map(text, acc, options) {text, MapSet.to_list(mentions), MapSet.to_list(tags)} end @@ -111,9 +115,9 @@ def mentions_escape(text, options \\ []) do if options[:safe_mention] && Regex.named_captures(@safe_mention_regex, text) do %{"mentions" => mentions, "rest" => rest} = Regex.named_captures(@safe_mention_regex, text) - AutoLinker.link(mentions, options) <> AutoLinker.link(rest, options) + Linkify.link(mentions, options) <> Linkify.link(rest, options) else - AutoLinker.link(text, options) + Linkify.link(text, options) end end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 1729141e9..747f2dc6b 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -11,10 +11,10 @@ defmodule Pleroma.Web.RichMedia.Helpers do @spec validate_page_url(URI.t() | binary()) :: :ok | :error defp validate_page_url(page_url) when is_binary(page_url) do - validate_tld = Application.get_env(:auto_linker, :opts)[:validate_tld] + validate_tld = Pleroma.Config.get([Pleroma.Formatter, :validate_tld]) page_url - |> AutoLinker.Parser.url?(scheme: true, validate_tld: validate_tld) + |> Linkify.Parser.url?(validate_tld: validate_tld) |> parse_uri(page_url) end diff --git a/mix.exs b/mix.exs index 52b4cf268..f44d7a887 100644 --- a/mix.exs +++ b/mix.exs @@ -166,9 +166,7 @@ defp deps do {:floki, "~> 0.25"}, {:timex, "~> 3.5"}, {:ueberauth, "~> 0.4"}, - {:auto_linker, - git: "https://git.pleroma.social/pleroma/auto_linker.git", - ref: "95e8188490e97505c56636c1379ffdf036c1fdde"}, + {:linkify, "~> 0.2.0"}, {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, diff --git a/mix.lock b/mix.lock index 8dd37a40f..6430ddd19 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,5 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, - "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, @@ -63,6 +62,7 @@ "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, + "linkify": {:hex, :linkify, "0.2.0", "2518bbbea21d2caa9d372424e1ad845b640c6630e2d016f1bd1f518f9ebcca28", [:mix], [], "hexpm", "b8ca8a68b79e30b7938d6c996085f3db14939f29538a59ca5101988bb7f917f6"}, "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, diff --git a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs new file mode 100644 index 000000000..9ec4203eb --- /dev/null +++ b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs @@ -0,0 +1,37 @@ +defmodule Pleroma.Repo.Migrations.AutolinkerToLinkify do + use Ecto.Migration + + alias Pleroma.Repo + alias Pleroma.ConfigDB + + @autolinker_path %{group: :auto_linker, key: :opts} + @linkify_path %{group: :pleroma, key: Pleroma.Formatter} + + @compat_opts [:class, :rel, :new_window, :truncate, :strip_prefix, :extra] + + def change do + with {:ok, {old, new}} <- maybe_get_params() do + move_config(old, new) + end + end + + defp move_config(%{} = old, %{} = new) do + {:ok, _} = ConfigDB.update_or_create(new) + {:ok, _} = ConfigDB.delete(old) + :ok + end + + defp maybe_get_params() do + with %ConfigDB{value: opts} <- ConfigDB.get_by_params(@autolinker_path), + %{} = opts <- transform_opts(opts), + %{} = linkify_params <- Map.put(@linkify_path, :value, opts) do + {:ok, {@autolinker_path, linkify_params}} + end + end + + defp transform_opts(opts) when is_list(opts) do + opts + |> Enum.into(%{}) + |> Map.take(@compat_opts) + end +end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index bef5a2c28..8713ab9c2 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -255,6 +255,36 @@ test "it can parse mentions and return the relevant users" do assert {_text, ^expected_mentions, []} = Formatter.linkify(text) end + + test "it parses URL containing local mention" do + _user = insert(:user, %{nickname: "lain"}) + + text = "https://example.com/@lain" + + expected = ~S(https://example.com/@lain) + + assert {^expected, [], []} = Formatter.linkify(text) + end + + test "it correctly parses angry face D:< with mention" do + lain = + insert(:user, %{ + nickname: "lain@lain.com", + ap_id: "https://lain.com/users/lain", + id: "9qrWmR0cKniB0YU0TA" + }) + + text = "@lain@lain.com D:<" + + expected_text = + ~S(@lain D:<) + + expected_mentions = [ + {"@lain@lain.com", lain} + ] + + assert {^expected_text, ^expected_mentions, []} = Formatter.linkify(text) + end end describe ".parse_tags" do -- cgit v1.2.3 From 341a8f35002e2ec8b6a91453b40acf0f04ba7631 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 21 Jul 2020 17:26:59 -0500 Subject: Skip the correct plug --- lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index 866901344..657f46324 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do ] ) - @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.ExpectPublicOrAuthenticatedCheckPlug] + @skip_plugs [Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug] plug(:skip_plug, @skip_plugs when action in [:index, :show, :archive]) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiPackOperation -- cgit v1.2.3 From 109836306cc4bd4dfeb67aea0e9b78f77cd0b839 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 21 Jul 2020 17:27:13 -0500 Subject: Test that Emoji Packs can be listed when instance is not public --- test/web/pleroma_api/controllers/emoji_pack_controller_test.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs index df58a5eb6..e113bb15f 100644 --- a/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_pack_controller_test.exs @@ -14,6 +14,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerTest do ) setup do: clear_config([:auth, :enforce_oauth_admin_scope_usage], false) + setup do: clear_config([:instance, :public], true) + setup do admin = insert(:user, is_admin: true) token = insert(:oauth_admin_token, user: admin) @@ -27,6 +29,11 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackControllerTest do {:ok, %{admin_conn: admin_conn}} end + test "GET /api/pleroma/emoji/packs when :public: false", %{conn: conn} do + Config.put([:instance, :public], false) + conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) + end + test "GET /api/pleroma/emoji/packs", %{conn: conn} do resp = conn |> get("/api/pleroma/emoji/packs") |> json_response_and_validate_schema(200) -- cgit v1.2.3 From b157b7dab36f77b0f30ae18022445d586c242300 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 21 Jul 2020 17:29:11 -0500 Subject: Document the emoji packs API fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4397ec3c..16bcb5bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Admin API: fix `GET /api/pleroma/admin/users/:nickname/credentials` returning 404 when getting the credentials of a remote user while `:instance, :limit_to_local_content` is set to `:unauthenticated` - Fix CSP policy generation to include remote Captcha services - Fix edge case where MediaProxy truncates media, usually caused when Caddy is serving content for the other Federated instance. +- Emoji Packs could not be listed when instance was set to `public: false` ## [Unreleased (patch)] -- cgit v1.2.3 From 7cafb96c02c569209b4684a237311a0e14648185 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 22 Jul 2020 08:58:06 +0300 Subject: added tests --- test/emails/user_email_test.exs | 1 - test/user/welcome_email_test.exs | 49 ++++++++++++++++++++++++++++++++++++++ test/user/welcome_message_test.exs | 34 ++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 test/user/welcome_email_test.exs create mode 100644 test/user/welcome_message_test.exs diff --git a/test/emails/user_email_test.exs b/test/emails/user_email_test.exs index 502702e49..a75623bb4 100644 --- a/test/emails/user_email_test.exs +++ b/test/emails/user_email_test.exs @@ -10,7 +10,6 @@ defmodule Pleroma.Emails.UserEmailTest do alias Pleroma.Web.Router import Pleroma.Factory - import Swoosh.TestAssertions test "build password reset email" do config = Pleroma.Config.get(:instance) diff --git a/test/user/welcome_email_test.exs b/test/user/welcome_email_test.exs new file mode 100644 index 000000000..1a80109d4 --- /dev/null +++ b/test/user/welcome_email_test.exs @@ -0,0 +1,49 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeEmailTest do + use Pleroma.DataCase + + alias Pleroma.Config + alias Pleroma.Tests.ObanHelpers + alias Pleroma.User.WelcomeEmail + + import Pleroma.Factory + import Swoosh.TestAssertions + + setup do: clear_config([:welcome]) + + describe "send_email/1" do + test "send a welcome email" do + welcome_user = insert(:user) + user = insert(:user, name: "Jimm") + + Config.put([:welcome, :email, :enabled], true) + Config.put([:welcome, :email, :sender_nickname], welcome_user.nickname) + + Config.put( + [:welcome, :email, :subject], + "Hello, welcome to pleroma: <%= instance_name %>" + ) + + Config.put( + [:welcome, :email, :html], + "

    Hello <%= user.name %>.

    Welcome to <%= instance_name %>

    " + ) + + instance_name = Config.get([:instance, :name]) + + {:ok, _job} = WelcomeEmail.send_email(user) + + ObanHelpers.perform_all() + + assert_email_sent( + from: {instance_name, welcome_user.email}, + to: {user.name, user.email}, + subject: "Hello, welcome to pleroma: #{instance_name}", + html_body: "

    Hello #{user.name}.

    Welcome to #{instance_name}

    " + ) + end + end +end diff --git a/test/user/welcome_message_test.exs b/test/user/welcome_message_test.exs new file mode 100644 index 000000000..3cd6f5cb7 --- /dev/null +++ b/test/user/welcome_message_test.exs @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeMessageTest do + use Pleroma.DataCase + + alias Pleroma.Config + alias Pleroma.User.WelcomeMessage + + import Pleroma.Factory + + setup do: clear_config([:welcome]) + + describe "post_message/1" do + test "send a direct welcome message" do + welcome_user = insert(:user) + user = insert(:user, name: "Jimm") + + Config.put([:welcome, :direct_message, :enabled], true) + Config.put([:welcome, :direct_message, :sender_nickname], welcome_user.nickname) + + Config.put( + [:welcome, :direct_message, :message], + "Hello. Welcome to Pleroma" + ) + + {:ok, %Pleroma.Activity{} = activity} = WelcomeMessage.post_message(user) + assert user.ap_id in activity.recipients + assert activity.data["directMessage"] == true + assert Pleroma.Object.normalize(activity).data["content"] =~ "Hello. Welcome to Pleroma" + end + end +end -- cgit v1.2.3 From b620290dd98d29a20afa86b116d1299d97ce222b Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 22 Jul 2020 09:17:00 +0300 Subject: update description --- CHANGELOG.md | 2 +- config/description.exs | 94 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5720aa33..0d8b3efee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support pagination in emoji packs API (for packs and for files in pack) - Support for viewing instances favicons next to posts and accounts - Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata. -- Configuration: Add `:welcome` setting for welcoming message to a newly registered users. +- Configuration: Added `:welcome` settings for the welcome message to newly registered users.
    API Changes diff --git a/config/description.exs b/config/description.exs index f1c6773f1..3786a608d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -778,23 +778,6 @@ type: :boolean, description: "Enable to automatically add attachment link text to statuses" }, - %{ - key: :welcome_message, - type: :string, - description: - "A message that will be sent to a newly registered users as a direct message", - suggestions: [ - "Hi, @username! Welcome on board!" - ] - }, - %{ - key: :welcome_user_nickname, - type: :string, - description: "The nickname of the local user that sends the welcome message", - suggestions: [ - "lain" - ] - }, %{ key: :max_report_comment_size, type: :integer, @@ -962,6 +945,83 @@ } ] }, + %{ + group: :welcome, + type: :group, + description: "Welcome messages settings", + children: [ + %{ + group: :direct_message, + type: :group, + descpiption: "Direct message settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables sends direct message for new user after registration" + }, + %{ + key: :message, + type: :string, + description: + "A message that will be sent to a newly registered users as a direct message", + suggestions: [ + "Hi, @username! Welcome on board!" + ] + }, + %{ + key: :sender_nickname, + type: :string, + description: "The nickname of the local user that sends the welcome message", + suggestions: [ + "lain" + ] + } + ] + }, + %{ + group: :email, + type: :group, + descpiption: "Email message settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables sends direct message for new user after registration" + }, + %{ + key: :sender_nickname, + type: :string, + description: "The nickname of the local user that sends the welcome email", + suggestions: [ + "lain" + ] + }, + %{ + key: :subject, + type: :string, + description: + "The subject of welcome email. Can be use EEX template with `user` and `instance_name` variables.", + suggestions: ["Welcome to <%= instance_name%>"] + }, + %{ + key: :html, + type: :string, + description: + "The html content of welcome email. Can be use EEX template with `user` and `instance_name` variables.", + suggestions: ["

    Hello <%= user.name%>. Welcome to <%= instance_name%>

    "] + }, + %{ + key: :text, + type: :string, + description: + "The text content of welcome email. Can be use EEX template with `user` and `instance_name` variables.", + suggestions: ["Hello <%= user.name%>. \n Welcome to <%= instance_name%>\n"] + } + ] + } + ] + }, %{ group: :logger, type: :group, -- cgit v1.2.3 From c8fe0321b094b691563c2444b3f5e5f325e38431 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 22 Jul 2020 12:00:07 +0200 Subject: mix.exs: Append .git to git repo URLs Closes: https://git.pleroma.social/pleroma/pleroma/-/issues/1980 --- mix.exs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 52b4cf268..930d09b1b 100644 --- a/mix.exs +++ b/mix.exs @@ -151,12 +151,13 @@ defp deps do {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.3", only: :test}, {:crypt, - git: "https://github.com/msantos/crypt", ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, + git: "https://github.com/msantos/crypt.git", + ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, {:cors_plug, "~> 1.5"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, {:web_push_encryption, "~> 0.2.1"}, {:swoosh, - git: "https://github.com/swoosh/swoosh", + git: "https://github.com/swoosh/swoosh.git", ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", override: true}, {:phoenix_swoosh, "~> 0.2"}, @@ -190,7 +191,7 @@ defp deps do {:excoveralls, "~> 0.12.1", only: :test}, {:flake_id, "~> 0.1.0"}, {:concurrent_limiter, - git: "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter", + git: "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"}, {:remote_ip, git: "https://git.pleroma.social/pleroma/remote_ip.git", -- cgit v1.2.3 From 5879d3685425bebaece3ecfe1e090654c91f44b1 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 22 Jul 2020 15:34:47 +0300 Subject: fix sender for welcome email --- config/config.exs | 2 +- config/description.exs | 9 +++++---- docs/configuration/cheatsheet.md | 22 +++++++++++++++++++--- lib/pleroma/user/welcome_email.ex | 24 +++++++++--------------- test/user/welcome_email_test.exs | 18 +++++++++++++++--- test/user_test.exs | 2 +- 6 files changed, 50 insertions(+), 27 deletions(-) diff --git a/config/config.exs b/config/config.exs index 16b7f6dc7..baee67d93 100644 --- a/config/config.exs +++ b/config/config.exs @@ -260,7 +260,7 @@ ], email: [ enabled: false, - sender_nickname: nil, + sender: nil, subject: "Welcome to <%= instance_name %>", html: "Welcome to <%= instance_name %>", text: "Welcome to <%= instance_name %>" diff --git a/config/description.exs b/config/description.exs index 3786a608d..e012040f5 100644 --- a/config/description.exs +++ b/config/description.exs @@ -990,11 +990,12 @@ description: "Enables sends direct message for new user after registration" }, %{ - key: :sender_nickname, - type: :string, - description: "The nickname of the local user that sends the welcome email", + key: :sender, + type: [:string, :tuple], + description: + "The email address or tuple with `{nickname, email}` that will use as sender to the welcome email.", suggestions: [ - "lain" + {"Pleroma App", "welcome@pleroma.app"} ] }, %{ diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 7e8f86aba..e1eccea1f 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -46,8 +46,6 @@ To add configuration to your config file, you can copy it from the base config. * `max_pinned_statuses`: The maximum number of pinned statuses. `0` will disable the feature. * `autofollowed_nicknames`: Set to nicknames of (local) users that every new user should automatically follow. * `attachment_links`: Set to true to enable automatically adding attachment link text to statuses. -* `welcome_message`: A message that will be send to a newly registered users as a direct message. -* `welcome_user_nickname`: The nickname of the local user that sends the welcome message. * `max_report_comment_size`: The maximum size of the report comment (Default: `1000`). * `safe_dm_mentions`: If set to true, only mentions at the beginning of a post will be used to address people in direct messages. This is to prevent accidental mentioning of people when talking about them (e.g. "@friend hey i really don't like @enemy"). Default: `false`. * `healthcheck`: If set to true, system data will be shown on ``/api/pleroma/healthcheck``. @@ -70,11 +68,29 @@ To add configuration to your config file, you can copy it from the base config. * `message`: A message that will be send to a newly registered users as a direct message. * `email`: - welcome message sent as a email. * `enabled`: Enables the send a welcome email to a newly registered user. Defaults to `false`. - * `sender_nickname`: The nickname of the local user that sends the welcome email. + * `sender`: The email address or tuple with `{nickname, email}` that will use as sender to the welcome email. * `subject`: A subject of welcome email. * `html`: A html that will be send to a newly registered users as a email. * `text`: A text that will be send to a newly registered users as a email. + Example: + + ```elixir + config :pleroma, :welcome, + direct_message: [ + enabled: true, + sender_nickname: "lain", + message: "Hi, @username! Welcome on board!" + ], + email: [ + enabled: true, + sender: {"Pleroma App", "welcome@pleroma.app"}, + subject: "Welcome to <%= instance_name %>", + html: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + ] + ``` + ## Message rewrite facility ### :mrf diff --git a/lib/pleroma/user/welcome_email.ex b/lib/pleroma/user/welcome_email.ex index 53062b961..91a9591dd 100644 --- a/lib/pleroma/user/welcome_email.ex +++ b/lib/pleroma/user/welcome_email.ex @@ -27,7 +27,7 @@ defp email_options(user) do bindings = [user: user, instance_name: instance_name()] %{} - |> add_sender(Config.get([:welcome, :email, :sender_nickname], nil)) + |> add_sender(Config.get([:welcome, :email, :sender], nil)) |> add_option(:subject, bindings) |> add_option(:html, bindings) |> add_option(:text, bindings) @@ -40,28 +40,22 @@ defp add_option(opts, option, bindings) do |> merge_options(opts, option) end - def add_sender(opts, nickname) do - nickname - |> fetch_sender() - |> merge_options(opts, :sender) + defp add_sender(opts, {_name, _email} = sender) do + merge_options(sender, opts, :sender) end + defp add_sender(opts, sender) when is_binary(sender) do + add_sender(opts, {instance_name(), sender}) + end + + defp add_sender(opts, _), do: opts + defp merge_options(nil, options, _option), do: options defp merge_options(value, options, option) do Map.merge(options, %{option => value}) end - defp fetch_sender(nickname) when is_binary(nickname) do - with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do - {instance_name(), user.email} - else - _ -> nil - end - end - - defp fetch_sender(_), do: nil - defp eval_string(nil, _), do: nil defp eval_string("", _), do: nil defp eval_string(str, bindings), do: EEx.eval_string(str, bindings) diff --git a/test/user/welcome_email_test.exs b/test/user/welcome_email_test.exs index 1a80109d4..d005d11b2 100644 --- a/test/user/welcome_email_test.exs +++ b/test/user/welcome_email_test.exs @@ -16,11 +16,10 @@ defmodule Pleroma.User.WelcomeEmailTest do describe "send_email/1" do test "send a welcome email" do - welcome_user = insert(:user) user = insert(:user, name: "Jimm") Config.put([:welcome, :email, :enabled], true) - Config.put([:welcome, :email, :sender_nickname], welcome_user.nickname) + Config.put([:welcome, :email, :sender], "welcome@pleroma.app") Config.put( [:welcome, :email, :subject], @@ -39,7 +38,20 @@ test "send a welcome email" do ObanHelpers.perform_all() assert_email_sent( - from: {instance_name, welcome_user.email}, + from: {instance_name, "welcome@pleroma.app"}, + to: {user.name, user.email}, + subject: "Hello, welcome to pleroma: #{instance_name}", + html_body: "

    Hello #{user.name}.

    Welcome to #{instance_name}

    " + ) + + Config.put([:welcome, :email, :sender], {"Pleroma App", "welcome@pleroma.app"}) + + {:ok, _job} = WelcomeEmail.send_email(user) + + ObanHelpers.perform_all() + + assert_email_sent( + from: {"Pleroma App", "welcome@pleroma.app"}, to: {user.name, user.email}, subject: "Hello, welcome to pleroma: #{instance_name}", html_body: "

    Hello #{user.name}.

    Welcome to #{instance_name}

    " diff --git a/test/user_test.exs b/test/user_test.exs index e887a3fb2..132697139 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -414,7 +414,7 @@ test "it sends a welcome message if it is set" do Pleroma.Config.put([:welcome, :direct_message, :message], "Hello, this is a cool site") Pleroma.Config.put([:welcome, :email, :enabled], true) - Pleroma.Config.put([:welcome, :email, :sender_nickname], welcome_user.nickname) + Pleroma.Config.put([:welcome, :email, :sender], welcome_user.email) Pleroma.Config.put( [:welcome, :email, :subject], -- cgit v1.2.3 From 0cb9e1da746ee5bfb8147cead3944f0e13fb447f Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 22 Jul 2020 14:44:06 +0200 Subject: StatusView: Handle badly formatted emoji reactions. --- lib/pleroma/web/mastodon_api/views/status_view.ex | 24 +++++++++++++++++------ test/web/mastodon_api/views/status_view_test.exs | 17 ++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index fa9d695f3..91b41ef59 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -297,13 +297,17 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} emoji_reactions = with %{data: %{"reactions" => emoji_reactions}} <- object do - Enum.map(emoji_reactions, fn [emoji, users] -> - %{ - name: emoji, - count: length(users), - me: !!(opts[:for] && opts[:for].ap_id in users) - } + Enum.map(emoji_reactions, fn + [emoji, users] when is_list(users) -> + build_emoji_map(emoji, users, opts[:for]) + + {emoji, users} when is_list(users) -> + build_emoji_map(emoji, users, opts[:for]) + + _ -> + nil end) + |> Enum.reject(&is_nil/1) else _ -> [] end @@ -545,4 +549,12 @@ defp present?(_), do: true defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}), do: id in pinned_activities + + defp build_emoji_map(emoji, users, current_user) do + %{ + name: emoji, + count: length(users), + me: !!(current_user && current_user.ap_id in users) + } + 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 fa26b3129..8791d3573 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -56,6 +56,23 @@ test "has an emoji reaction list" do ] end + test "works correctly with badly formatted emojis" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{status: "yo"}) + + activity + |> Object.normalize(false) + |> Object.update_data(%{"reactions" => %{"☕" => [user.ap_id], "x" => 1}}) + + activity = Activity.get_by_id(activity.id) + + status = StatusView.render("show.json", activity: activity, for: user) + + assert status[:pleroma][:emoji_reactions] == [ + %{name: "☕", count: 1, me: true} + ] + end + test "loads and returns the direct conversation id when given the `with_direct_conversation_id` option" do user = insert(:user) -- cgit v1.2.3 From db0224d1745e753b73bd0e993bc0e75eec295651 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 22 Jul 2020 16:00:49 +0300 Subject: added check user email for welcome email --- lib/pleroma/user.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 29526b8fd..5bc256b50 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -729,7 +729,7 @@ def send_welcome_message(user) do end end - def send_welcome_email(user) do + def send_welcome_email(%User{email: email} = user) when is_binary(email) do if User.WelcomeEmail.enabled?() do User.WelcomeEmail.send_email(user) {:ok, :enqueued} @@ -737,6 +737,7 @@ def send_welcome_email(user) do {:ok, :noop} end end + def send_welcome_email(_), do: {:ok, :noop} def try_send_confirmation_email(%User{} = user) do if user.confirmation_pending && -- cgit v1.2.3 From f9e8a94106a715afae351b08399e2e35da9de07b Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Wed, 22 Jul 2020 17:26:36 +0300 Subject: Add multiarch support to docker container, fixes https://git.pleroma.social/pleroma/pleroma-docker-compose/-/issues/2 --- .gitlab-ci.yml | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c9ab84892..816c05b1e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -285,10 +285,13 @@ 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 push $IMAGE_TAG - - docker push $IMAGE_TAG_SLUG - - docker push $IMAGE_TAG_LATEST + - mkdir -p /root/.docker/cli-plugins + - wget https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx + - chmod +x ~/.docker/cli-plugins/docker-buildx + - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - docker buildx create --name mbuilder --driver docker-container --use + - docker buildx inspect --bootstrap + - docker buildx build --platform linux/amd64,linux/arm/v7 --push --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 . tags: - dind only: @@ -303,10 +306,13 @@ 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 push $IMAGE_TAG - - docker push $IMAGE_TAG_SLUG - - docker push $IMAGE_TAG_LATEST_STABLE + - mkdir -p /root/.docker/cli-plugins + - wget https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx + - chmod +x ~/.docker/cli-plugins/docker-buildx + - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - docker buildx create --name mbuilder --driver docker-container --use + - docker buildx inspect --bootstrap + - docker buildx build --platform linux/amd64,linux/arm/v7 --push --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 . tags: - dind only: @@ -321,9 +327,14 @@ 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 push $IMAGE_TAG - - docker push $IMAGE_TAG_SLUG + script: + - mkdir -p /root/.docker/cli-plugins + - wget https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx + - chmod +x ~/.docker/cli-plugins/docker-buildx + - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - docker buildx create --name mbuilder --driver docker-container --use + - docker buildx inspect --bootstrap + - docker buildx build --platform linux/amd64,linux/arm/v7 --push --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 . tags: - dind only: -- cgit v1.2.3 From 188b0dc72d3e5bf0c4d4aa5b2a505e3e0af69df7 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Wed, 22 Jul 2020 18:15:30 +0300 Subject: Add related_policy field --- config/description.exs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index b97b0a7ec..e4850218e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1426,6 +1426,7 @@ group: :pleroma, key: :mrf_simple, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy", label: "MRF Simple", type: :group, description: "Simple ingress policies", @@ -1492,6 +1493,7 @@ group: :pleroma, key: :mrf_activity_expiration, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy", label: "MRF Activity Expiration Policy", type: :group, description: "Adds automatic expiration to all local activities", @@ -1508,6 +1510,7 @@ group: :pleroma, key: :mrf_subchain, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.SubchainPolicy", label: "MRF Subchain", type: :group, description: @@ -1530,6 +1533,7 @@ group: :pleroma, key: :mrf_rejectnonpublic, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNonPublic", description: "RejectNonPublic drops posts with non-public visibility settings.", label: "MRF Reject Non Public", type: :group, @@ -1551,6 +1555,7 @@ group: :pleroma, key: :mrf_hellthread, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy", label: "MRF Hellthread", type: :group, description: "Block messages with excessive user mentions", @@ -1576,6 +1581,7 @@ group: :pleroma, key: :mrf_keyword, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.KeywordPolicy", label: "MRF Keyword", type: :group, description: "Reject or Word-Replace messages with a keyword or regex", @@ -1607,6 +1613,7 @@ group: :pleroma, key: :mrf_mention, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.MentionPolicy", label: "MRF Mention", type: :group, description: "Block messages which mention a specific user", @@ -1623,6 +1630,7 @@ group: :pleroma, key: :mrf_vocabulary, tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.VocabularyPolicy", label: "MRF Vocabulary", type: :group, description: "Filter messages which belong to certain activity vocabularies", @@ -1646,6 +1654,8 @@ # %{ # group: :pleroma, # key: :mrf_user_allowlist, + # tab: :mrf, + # related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy", # type: :map, # description: # "The keys in this section are the domain names that the policy should apply to." <> @@ -2910,8 +2920,9 @@ }, %{ group: :pleroma, - tab: :mrf, key: :mrf_normalize_markup, + tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.NormalizeMarkup", label: "MRF Normalize Markup", description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.", type: :group, @@ -3106,8 +3117,9 @@ %{ group: :pleroma, key: :mrf_object_age, - label: "MRF Object Age", tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy", + label: "MRF Object Age", type: :group, description: "Rejects or delists posts based on their timestamp deviance from your server's clock.", -- cgit v1.2.3 From 6f5f7af607518b6f67df68bab9bf76142e9a622c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Wed, 22 Jul 2020 19:06:00 +0300 Subject: [#1973] Fixed accounts rendering in GET /api/v1/pleroma/chats with truish :restrict_unauthenticated. Made `Pleroma.Web.MastodonAPI.AccountView.render("show.json", _)` demand :for or :force option in order to prevent incorrect rendering of empty map instead of expected user representation with truish :restrict_unauthenticated setting. --- lib/pleroma/web/activity_pub/utils.ex | 9 +++-- .../admin_api/controllers/admin_api_controller.ex | 6 ++- lib/pleroma/web/admin_api/views/account_view.ex | 2 +- lib/pleroma/web/chat_channel.ex | 6 ++- .../mastodon_api/controllers/search_controller.ex | 1 - lib/pleroma/web/mastodon_api/views/account_view.ex | 23 +++++++++-- .../web/mastodon_api/views/conversation_view.ex | 2 +- .../web/pleroma_api/controllers/chat_controller.ex | 15 +++++--- lib/pleroma/web/pleroma_api/views/chat_view.ex | 17 ++++++-- .../web/pleroma_api/views/emoji_reaction_view.ex | 2 +- mix.lock | 6 +-- test/web/activity_pub/activity_pub_test.exs | 2 +- test/web/activity_pub/transmogrifier_test.exs | 2 +- test/web/activity_pub/utils_test.exs | 2 +- test/web/admin_api/views/report_view_test.exs | 21 +++++----- test/web/mastodon_api/views/account_view_test.exs | 38 ++++++++++++------ test/web/mastodon_api/views/status_view_test.exs | 2 +- .../controllers/chat_controller_test.exs | 22 +++++++++++ test/web/pleroma_api/views/chat_view_test.exs | 2 +- test/web/twitter_api/twitter_api_test.exs | 45 +++++++--------------- 20 files changed, 143 insertions(+), 82 deletions(-) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index dfae602df..11c64cffd 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -719,15 +719,18 @@ defp build_flag_object(act) when is_map(act) or is_binary(act) do case Activity.get_by_ap_id_with_object(id) do %Activity{} = activity -> + activity_actor = User.get_by_ap_id(activity.object.data["actor"]) + %{ "type" => "Note", "id" => activity.data["id"], "content" => activity.object.data["content"], "published" => activity.object.data["published"], "actor" => - AccountView.render("show.json", %{ - user: User.get_by_ap_id(activity.object.data["actor"]) - }) + AccountView.render( + "show.json", + %{user: activity_actor, force: true} + ) } _ -> diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index e5f14269a..225ceb1fd 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -345,7 +345,11 @@ def list_users(conn, params) do with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do json( conn, - AccountView.render("index.json", users: users, count: count, page_size: page_size) + AccountView.render("index.json", + users: users, + count: count, + page_size: page_size + ) ) end end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index e1e929632..4ae030b84 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -105,7 +105,7 @@ def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, e end def merge_account_views(%User{} = user) do - MastodonAPI.AccountView.render("show.json", %{user: user}) + MastodonAPI.AccountView.render("show.json", %{user: user, force: true}) |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) end diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index bce27897f..08d0e80f9 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -4,8 +4,10 @@ defmodule Pleroma.Web.ChatChannel do use Phoenix.Channel + alias Pleroma.User alias Pleroma.Web.ChatChannel.ChatChannelState + alias Pleroma.Web.MastodonAPI.AccountView def join("chat:public", _message, socket) do send(self(), :after_join) @@ -22,9 +24,9 @@ def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} if String.length(text) in 1..Pleroma.Config.get([:instance, :chat_limit]) do author = User.get_cached_by_nickname(user_name) - author = Pleroma.Web.MastodonAPI.AccountView.render("show.json", user: author) + author_json = AccountView.render("show.json", user: author, force: true) - message = ChatChannelState.add_message(%{text: text, author: author}) + message = ChatChannelState.add_message(%{text: text, author: author_json}) broadcast!(socket, "new_msg", message) end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 29affa7d5..5a983db39 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -93,7 +93,6 @@ defp resource_search(_, "accounts", query, options) do AccountView.render("index.json", users: accounts, for: options[:for_user], - as: :user, embed_relationships: options[:embed_relationships] ) end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index bc9745044..b929d5a03 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -27,21 +27,38 @@ def render("index.json", %{users: users} = opts) do UserRelationship.view_relationships_option(reading_user, users) end - opts = Map.put(opts, :relationships, relationships_opt) + opts = + opts + |> Map.merge(%{relationships: relationships_opt, as: :user}) + |> Map.delete(:users) users |> render_many(AccountView, "show.json", opts) |> Enum.filter(&Enum.any?/1) end - def render("show.json", %{user: user} = opts) do - if User.visible_for(user, opts[:for]) == :visible do + @doc """ + Renders specified user account. + :force option skips visibility check and renders any user (local or remote) + regardless of [:pleroma, :restrict_unauthenticated] setting. + :for option specifies the requester and can be a User record or nil. + """ + def render("show.json", %{user: _user, force: true} = opts) do + do_render("show.json", opts) + end + + def render("show.json", %{user: user, for: for_user_or_nil} = opts) do + if User.visible_for(user, for_user_or_nil) == :visible do do_render("show.json", opts) else %{} end end + def render("show.json", _) do + raise "In order to prevent account accessibility issues, :force or :for option is required." + end + def render("mention.json", %{user: user}) do %{ id: to_string(user.id), diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 06f0c1728..a91994915 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -38,7 +38,7 @@ def render("participation.json", %{participation: participation, for: user}) do %{ id: participation.id |> to_string(), - accounts: render(AccountView, "index.json", users: users, as: :user), + accounts: render(AccountView, "index.json", users: users, for: user), unread: !participation.read, last_status: render(StatusView, "show.json", diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index c8ef3d915..e8a1746d4 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -89,11 +89,11 @@ def post_chat_message( cm_ref <- MessageReference.for_chat_and_object(chat, message) do conn |> put_view(MessageReferenceView) - |> render("show.json", for: user, chat_message_reference: cm_ref) + |> render("show.json", chat_message_reference: cm_ref) end end - def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ + def mark_message_as_read(%{assigns: %{user: %{id: user_id}}} = conn, %{ id: chat_id, message_id: message_id }) do @@ -104,12 +104,15 @@ def mark_message_as_read(%{assigns: %{user: %{id: user_id} = user}} = conn, %{ {:ok, cm_ref} <- MessageReference.mark_as_read(cm_ref) do conn |> put_view(MessageReferenceView) - |> render("show.json", for: user, chat_message_reference: cm_ref) + |> render("show.json", chat_message_reference: cm_ref) end end def mark_as_read( - %{body_params: %{last_read_id: last_read_id}, assigns: %{user: %{id: user_id}}} = conn, + %{ + body_params: %{last_read_id: last_read_id}, + assigns: %{user: %{id: user_id}} + } = conn, %{id: id} ) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id), @@ -121,7 +124,7 @@ def mark_as_read( end end - def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = params) do + def messages(%{assigns: %{user: %{id: user_id}}} = conn, %{id: id} = params) do with %Chat{} = chat <- Repo.get_by(Chat, id: id, user_id: user_id) do cm_refs = chat @@ -130,7 +133,7 @@ def messages(%{assigns: %{user: %{id: user_id} = user}} = conn, %{id: id} = para conn |> put_view(MessageReferenceView) - |> render("index.json", for: user, chat_message_references: cm_refs) + |> render("index.json", chat_message_references: cm_refs) else _ -> conn diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 1c996da11..2ae7c8122 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -15,10 +15,11 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do def render("show.json", %{chat: %Chat{} = chat} = opts) do recipient = User.get_cached_by_ap_id(chat.recipient) last_message = opts[:last_message] || MessageReference.last_message_for_chat(chat) + account_view_opts = account_view_opts(opts, recipient) %{ id: chat.id |> to_string(), - account: AccountView.render("show.json", Map.put(opts, :user, recipient)), + account: AccountView.render("show.json", account_view_opts), unread: MessageReference.unread_count_for_chat(chat), last_message: last_message && @@ -27,7 +28,17 @@ def render("show.json", %{chat: %Chat{} = chat} = opts) do } end - def render("index.json", %{chats: chats}) do - render_many(chats, __MODULE__, "show.json") + def render("index.json", %{chats: chats} = opts) do + render_many(chats, __MODULE__, "show.json", Map.delete(opts, :chats)) + end + + defp account_view_opts(opts, recipient) do + account_view_opts = Map.put(opts, :user, recipient) + + if Map.has_key?(account_view_opts, :for) do + account_view_opts + else + Map.put(account_view_opts, :force, true) + end end end diff --git a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex index 84d2d303d..e0f98b50a 100644 --- a/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex +++ b/lib/pleroma/web/pleroma_api/views/emoji_reaction_view.ex @@ -17,7 +17,7 @@ def render("show.json", %{emoji_reaction: [emoji, user_ap_ids], user: user}) do %{ name: emoji, count: length(users), - accounts: render(AccountView, "index.json", users: users, for: user, as: :user), + accounts: render(AccountView, "index.json", users: users, for: user), me: !!(user && user.ap_id in user_ap_ids) } end diff --git a/mix.lock b/mix.lock index 8dd37a40f..9e4b2f09c 100644 --- a/mix.lock +++ b/mix.lock @@ -15,14 +15,14 @@ "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, - "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter", "8eee96c6ba39b9286ec44c51c52d9f2758951365", [ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"]}, + "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "8eee96c6ba39b9286ec44c51c52d9f2758951365", [ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"]}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, - "crypt": {:git, "https://github.com/msantos/crypt", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, + "crypt": {:git, "https://github.com/msantos/crypt.git", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, @@ -105,7 +105,7 @@ "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, - "swoosh": {:git, "https://github.com/swoosh/swoosh", "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", [ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5"]}, + "swoosh": {:git, "https://github.com/swoosh/swoosh.git", "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", [ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5"]}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "tesla": {:git, "https://github.com/teamon/tesla.git", "af3707078b10793f6a534938e56b963aff82fe3c", [ref: "af3707078b10793f6a534938e56b963aff82fe3c"]}, diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index f3951462f..34905a928 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1179,7 +1179,7 @@ test "it can create a Flag activity", "id" => activity_ap_id, "content" => content, "published" => activity_with_object.object.data["published"], - "actor" => AccountView.render("show.json", %{user: target_account}) + "actor" => AccountView.render("show.json", %{user: target_account, force: true}) } assert %Activity{ diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 248b410c6..01e18eace 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -710,7 +710,7 @@ test "it accepts Flag activities" do "id" => activity.data["id"], "content" => "test post", "published" => object.data["published"], - "actor" => AccountView.render("show.json", %{user: user}) + "actor" => AccountView.render("show.json", %{user: user, force: true}) } message = %{ diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index 361dc5a41..ab984d486 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -482,7 +482,7 @@ test "returns map with Flag object" do "id" => activity_ap_id, "content" => content, "published" => activity.object.data["published"], - "actor" => AccountView.render("show.json", %{user: target_account}) + "actor" => AccountView.render("show.json", %{user: target_account, force: true}) } assert %{ diff --git a/test/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index f00b0afb2..e171509e5 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -4,11 +4,14 @@ defmodule Pleroma.Web.AdminAPI.ReportViewTest do use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.CommonAPI - alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.MastodonAPI alias Pleroma.Web.MastodonAPI.StatusView test "renders a report" do @@ -21,13 +24,13 @@ test "renders a report" do content: nil, actor: Map.merge( - AccountView.render("show.json", %{user: user}), - Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}) + MastodonAPI.AccountView.render("show.json", %{user: user, force: true}), + AdminAPI.AccountView.render("show.json", %{user: user}) ), account: Map.merge( - AccountView.render("show.json", %{user: other_user}), - Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user}) + MastodonAPI.AccountView.render("show.json", %{user: other_user, force: true}), + AdminAPI.AccountView.render("show.json", %{user: other_user}) ), statuses: [], notes: [], @@ -56,13 +59,13 @@ test "includes reported statuses" do content: nil, actor: Map.merge( - AccountView.render("show.json", %{user: user}), - Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}) + MastodonAPI.AccountView.render("show.json", %{user: user, force: true}), + AdminAPI.AccountView.render("show.json", %{user: user}) ), account: Map.merge( - AccountView.render("show.json", %{user: other_user}), - Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: other_user}) + MastodonAPI.AccountView.render("show.json", %{user: other_user, force: true}), + AdminAPI.AccountView.render("show.json", %{user: other_user}) ), statuses: [StatusView.render("show.json", %{activity: activity})], state: "open", diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index a83bf90a3..2b18c2e43 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -95,7 +95,7 @@ test "Represent a user account" do } } - assert expected == AccountView.render("show.json", %{user: user}) + assert expected == AccountView.render("show.json", %{user: user, force: true}) end test "Favicon is nil when :instances_favicons is disabled" do @@ -108,11 +108,12 @@ test "Favicon is nil when :instances_favicons is disabled" do favicon: "https://shitposter.club/plugins/Qvitter/img/gnusocial-favicons/favicon-16x16.png" } - } = AccountView.render("show.json", %{user: user}) + } = AccountView.render("show.json", %{user: user, force: true}) Config.put([:instances_favicons, :enabled], false) - assert %{pleroma: %{favicon: nil}} = AccountView.render("show.json", %{user: user}) + assert %{pleroma: %{favicon: nil}} = + AccountView.render("show.json", %{user: user, force: true}) end test "Represent the user account for the account owner" do @@ -189,7 +190,7 @@ test "Represent a Service(bot) account" do } } - assert expected == AccountView.render("show.json", %{user: user}) + assert expected == AccountView.render("show.json", %{user: user, force: true}) end test "Represent a Funkwhale channel" do @@ -198,7 +199,7 @@ test "Represent a Funkwhale channel" do "https://channels.tests.funkwhale.audio/federation/actors/compositions" ) - assert represented = AccountView.render("show.json", %{user: user}) + assert represented = AccountView.render("show.json", %{user: user, force: true}) assert represented.acct == "compositions@channels.tests.funkwhale.audio" assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" end @@ -223,6 +224,21 @@ test "Represent a smaller mention" do assert expected == AccountView.render("mention.json", %{user: user}) end + test "demands :for or :force option for account rendering" do + clear_config([:restrict_unauthenticated, :profiles, :local], false) + + user = insert(:user) + user_id = user.id + + assert %{id: ^user_id} = AccountView.render("show.json", %{user: user, for: nil}) + assert %{id: ^user_id} = AccountView.render("show.json", %{user: user, for: user}) + assert %{id: ^user_id} = AccountView.render("show.json", %{user: user, force: true}) + + assert_raise RuntimeError, ~r/:force or :for option is required/, fn -> + AccountView.render("show.json", %{user: user}) + end + end + describe "relationship" do defp test_relationship_rendering(user, other_user, expected_result) do opts = %{user: user, target: other_user, relationships: nil} @@ -336,7 +352,7 @@ test "returns the settings store if the requesting user is the represented user assert result.pleroma.settings_store == %{:fe => "test"} - result = AccountView.render("show.json", %{user: user, with_pleroma_settings: true}) + result = AccountView.render("show.json", %{user: user, for: nil, with_pleroma_settings: true}) assert result.pleroma[:settings_store] == nil result = AccountView.render("show.json", %{user: user, for: user}) @@ -345,13 +361,13 @@ test "returns the settings store if the requesting user is the represented user test "doesn't sanitize display names" do user = insert(:user, name: " username ") - result = AccountView.render("show.json", %{user: user}) + result = AccountView.render("show.json", %{user: user, force: true}) assert result.display_name == " username " end test "never display nil user follow counts" do user = insert(:user, following_count: 0, follower_count: 0) - result = AccountView.render("show.json", %{user: user}) + result = AccountView.render("show.json", %{user: user, force: true}) assert result.following_count == 0 assert result.followers_count == 0 @@ -375,7 +391,7 @@ test "shows when follows/followers stats are hidden and sets follow/follower cou followers_count: 0, following_count: 0, pleroma: %{hide_follows_count: true, hide_followers_count: true} - } = AccountView.render("show.json", %{user: user}) + } = AccountView.render("show.json", %{user: user, force: true}) end test "shows when follows/followers are hidden" do @@ -388,7 +404,7 @@ test "shows when follows/followers are hidden" do followers_count: 1, following_count: 1, pleroma: %{hide_follows: true, hide_followers: true} - } = AccountView.render("show.json", %{user: user}) + } = AccountView.render("show.json", %{user: user, force: true}) end test "shows actual follower/following count to the account owner" do @@ -531,7 +547,7 @@ test "uses mediaproxy urls when it's enabled" do emoji: %{"joker_smile" => "https://evil.website/society.png"} ) - AccountView.render("show.json", %{user: user}) + AccountView.render("show.json", %{user: user, force: true}) |> Enum.all?(fn {key, url} when key in [:avatar, :avatar_static, :header, :header_static] -> String.starts_with?(url, Pleroma.Web.base_url()) diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index fa26b3129..d44e3f6e6 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -177,7 +177,7 @@ test "a note activity" do id: to_string(note.id), uri: object_data["id"], url: Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, note), - account: AccountView.render("show.json", %{user: user}), + account: AccountView.render("show.json", %{user: user, force: true}), in_reply_to_id: nil, in_reply_to_account_id: nil, card: nil, diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index 82e16741d..d71e80d03 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -332,5 +332,27 @@ test "it return a list of chats the current user is participating in, in descend chat_1.id |> to_string() ] end + + test "it is not affected by :restrict_unauthenticated setting (issue #1973)", %{ + conn: conn, + user: user + } do + clear_config([:restrict_unauthenticated, :profiles, :local], true) + clear_config([:restrict_unauthenticated, :profiles, :remote], true) + + user2 = insert(:user) + user3 = insert(:user, local: false) + + {:ok, _chat_12} = Chat.get_or_create(user.id, user2.ap_id) + {:ok, _chat_13} = Chat.get_or_create(user.id, user3.ap_id) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + account_ids = Enum.map(result, &get_in(&1, ["account", "id"])) + assert Enum.sort(account_ids) == Enum.sort([user2.id, user3.id]) + end end end diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 14eecb1bd..46d47cd4f 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -26,7 +26,7 @@ test "it represents a chat" do assert represented_chat == %{ id: "#{chat.id}", - account: AccountView.render("show.json", user: recipient), + account: AccountView.render("show.json", user: recipient, force: true), unread: 0, last_message: nil, updated_at: Utils.to_masto_date(chat.updated_at) diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 368533292..5bb2d8d89 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -4,11 +4,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do use Pleroma.DataCase + alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.UserInviteToken - alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.TwitterAPI.TwitterAPI setup_all do @@ -27,13 +27,10 @@ test "it registers a new user and returns the user." do {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("lain") - - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) + assert user == User.get_cached_by_nickname("lain") end - test "it registers a new user with empty string in bio and returns the user." do + test "it registers a new user with empty string in bio and returns the user" do data = %{ :username => "lain", :email => "lain@wired.jp", @@ -45,10 +42,7 @@ test "it registers a new user with empty string in bio and returns the user." do {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("lain") - - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) + assert user == User.get_cached_by_nickname("lain") end test "it sends confirmation email if :account_activation_required is specified in instance config" do @@ -134,13 +128,10 @@ test "returns user on success" do {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("vinny") - invite = Repo.get_by(UserInviteToken, token: invite.token) + assert user == User.get_cached_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) assert invite.used == true - - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) end test "returns error on invalid token" do @@ -197,10 +188,8 @@ test "returns error on expired token" do check_fn = fn invite -> data = Map.put(data, :token, invite.token) {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("vinny") - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) + assert user == User.get_cached_by_nickname("vinny") end {:ok, data: data, check_fn: check_fn} @@ -260,14 +249,11 @@ test "returns user on success, after him registration fails" do } {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("vinny") - invite = Repo.get_by(UserInviteToken, token: invite.token) + assert user == User.get_cached_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) assert invite.used == true - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) - data = %{ :username => "GrimReaper", :email => "death@reapers.afterlife", @@ -302,13 +288,10 @@ test "returns user on success" do } {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("vinny") - invite = Repo.get_by(UserInviteToken, token: invite.token) + assert user == User.get_cached_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) refute invite.used - - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) end test "error after max uses" do @@ -327,13 +310,11 @@ test "error after max uses" do } {:ok, user} = TwitterAPI.register_user(data) - fetched_user = User.get_cached_by_nickname("vinny") + assert user == User.get_cached_by_nickname("vinny") + invite = Repo.get_by(UserInviteToken, token: invite.token) assert invite.used == true - assert AccountView.render("show.json", %{user: user}) == - AccountView.render("show.json", %{user: fetched_user}) - data = %{ :username => "GrimReaper", :email => "death@reapers.afterlife", -- cgit v1.2.3 From 7045db5a506aa672d141dc33cfadd53208b4d067 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Jul 2020 11:27:52 -0500 Subject: Fix linkify ConfigDB migration --- priv/repo/migrations/20200716195806_autolinker_to_linkify.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs index 9ec4203eb..782a3cc55 100644 --- a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs +++ b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs @@ -23,7 +23,7 @@ defp move_config(%{} = old, %{} = new) do defp maybe_get_params() do with %ConfigDB{value: opts} <- ConfigDB.get_by_params(@autolinker_path), - %{} = opts <- transform_opts(opts), + opts <- transform_opts(opts), %{} = linkify_params <- Map.put(@linkify_path, :value, opts) do {:ok, {@autolinker_path, linkify_params}} end @@ -33,5 +33,6 @@ defp transform_opts(opts) when is_list(opts) do opts |> Enum.into(%{}) |> Map.take(@compat_opts) + |> Map.to_list() end end -- cgit v1.2.3 From 67389b77af7c6f9ccd18ec385b6ef4fd102e3eb6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Jul 2020 13:10:10 -0500 Subject: Add AutolinkerToLinkify migration test --- .../20200716195806_autolinker_to_linkify.exs | 4 +- .../20200716195806_autolinker_to_linkify_test.exs | 68 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 test/migrations/20200716195806_autolinker_to_linkify_test.exs diff --git a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs index 782a3cc55..570acba84 100644 --- a/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs +++ b/priv/repo/migrations/20200716195806_autolinker_to_linkify.exs @@ -1,7 +1,5 @@ defmodule Pleroma.Repo.Migrations.AutolinkerToLinkify do use Ecto.Migration - - alias Pleroma.Repo alias Pleroma.ConfigDB @autolinker_path %{group: :auto_linker, key: :opts} @@ -29,7 +27,7 @@ defp maybe_get_params() do end end - defp transform_opts(opts) when is_list(opts) do + def transform_opts(opts) when is_list(opts) do opts |> Enum.into(%{}) |> Map.take(@compat_opts) diff --git a/test/migrations/20200716195806_autolinker_to_linkify_test.exs b/test/migrations/20200716195806_autolinker_to_linkify_test.exs new file mode 100644 index 000000000..362cf5535 --- /dev/null +++ b/test/migrations/20200716195806_autolinker_to_linkify_test.exs @@ -0,0 +1,68 @@ +defmodule Pleroma.Repo.Migrations.AutolinkerToLinkifyTest do + use Pleroma.DataCase + import Pleroma.Factory + alias Pleroma.ConfigDB + + setup_all do + [{module, _}] = + Code.require_file("20200716195806_autolinker_to_linkify.exs", "priv/repo/migrations") + + {:ok, %{migration: module}} + end + + test "change/0 converts auto_linker opts for Pleroma.Formatter", %{migration: migration} do + autolinker_opts = [ + extra: true, + validate_tld: true, + class: false, + strip_prefix: false, + new_window: false, + rel: "ugc" + ] + + insert(:config, group: :auto_linker, key: :opts, value: autolinker_opts) + + migration.change() + + assert nil == ConfigDB.get_by_params(%{group: :auto_linker, key: :opts}) + + %{value: new_opts} = ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Formatter}) + + assert new_opts == [ + class: false, + extra: true, + new_window: false, + rel: "ugc", + strip_prefix: false + ] + + {text, _mentions, []} = + Pleroma.Formatter.linkify( + "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" + ) + + assert text == + "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" + end + + test "transform_opts/1 returns a list of compatible opts", %{migration: migration} do + old_opts = [ + extra: true, + validate_tld: true, + class: false, + strip_prefix: false, + new_window: false, + rel: "ugc" + ] + + expected_opts = [ + class: false, + extra: true, + new_window: false, + rel: "ugc", + strip_prefix: false + ] + + assert migration.transform_opts(old_opts) == expected_opts + end +end -- cgit v1.2.3 From b87a1f8eaff7e5663fd4b84b43be350754eb37d2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Jul 2020 13:45:15 -0500 Subject: Refactor require_migration/1 into a test helper function --- test/migrations/20200716195806_autolinker_to_linkify_test.exs | 8 ++------ test/support/helpers.ex | 5 +++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/migrations/20200716195806_autolinker_to_linkify_test.exs b/test/migrations/20200716195806_autolinker_to_linkify_test.exs index 362cf5535..063dab0f7 100644 --- a/test/migrations/20200716195806_autolinker_to_linkify_test.exs +++ b/test/migrations/20200716195806_autolinker_to_linkify_test.exs @@ -1,14 +1,10 @@ defmodule Pleroma.Repo.Migrations.AutolinkerToLinkifyTest do use Pleroma.DataCase import Pleroma.Factory + import Pleroma.Tests.Helpers, only: [require_migration: 1] alias Pleroma.ConfigDB - setup_all do - [{module, _}] = - Code.require_file("20200716195806_autolinker_to_linkify.exs", "priv/repo/migrations") - - {:ok, %{migration: module}} - end + setup_all do: require_migration("20200716195806_autolinker_to_linkify") test "change/0 converts auto_linker opts for Pleroma.Formatter", %{migration: migration} do autolinker_opts = [ diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 26281b45e..5cbf2e291 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -32,6 +32,11 @@ defmacro clear_config(config_path, temp_setting) do end end + def require_migration(migration_name) do + [{module, _}] = Code.require_file("#{migration_name}.exs", "priv/repo/migrations") + {:ok, %{migration: module}} + end + defmacro __using__(_opts) do quote do import Pleroma.Tests.Helpers, -- cgit v1.2.3 From c7a0016f9f4731c58a7989c7ee10e19d3f90d2eb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Jul 2020 14:18:09 -0500 Subject: Migration to fix malformed Pleroma.Formatter config --- ...200722185515_fix_malformed_formatter_config.exs | 26 +++++++++ ...2185515_fix_malformed_formatter_config_test.exs | 62 ++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 priv/repo/migrations/20200722185515_fix_malformed_formatter_config.exs create mode 100644 test/migrations/20200722185515_fix_malformed_formatter_config_test.exs diff --git a/priv/repo/migrations/20200722185515_fix_malformed_formatter_config.exs b/priv/repo/migrations/20200722185515_fix_malformed_formatter_config.exs new file mode 100644 index 000000000..77b760825 --- /dev/null +++ b/priv/repo/migrations/20200722185515_fix_malformed_formatter_config.exs @@ -0,0 +1,26 @@ +defmodule Pleroma.Repo.Migrations.FixMalformedFormatterConfig do + use Ecto.Migration + alias Pleroma.ConfigDB + + @config_path %{group: :pleroma, key: Pleroma.Formatter} + + def change do + with %ConfigDB{value: %{} = opts} <- ConfigDB.get_by_params(@config_path), + fixed_opts <- Map.to_list(opts) do + fix_config(fixed_opts) + else + _ -> :skipped + end + end + + defp fix_config(fixed_opts) when is_list(fixed_opts) do + {:ok, _} = + ConfigDB.update_or_create(%{ + group: :pleroma, + key: Pleroma.Formatter, + value: fixed_opts + }) + + :ok + end +end diff --git a/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs b/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs new file mode 100644 index 000000000..9e8f997a0 --- /dev/null +++ b/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs @@ -0,0 +1,62 @@ +defmodule Pleroma.Repo.Migrations.FixMalformedFormatterConfigTest do + use Pleroma.DataCase + import Pleroma.Factory + import Pleroma.Tests.Helpers, only: [require_migration: 1] + alias Pleroma.ConfigDB + + setup_all do: require_migration("20200722185515_fix_malformed_formatter_config") + + test "change/0 converts a map into a list", %{migration: migration} do + incorrect_opts = %{ + class: false, + extra: true, + new_window: false, + rel: "ugc", + strip_prefix: false + } + + insert(:config, group: :pleroma, key: Pleroma.Formatter, value: incorrect_opts) + + assert :ok == migration.change() + + %{value: new_opts} = ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Formatter}) + + assert new_opts == [ + class: false, + extra: true, + new_window: false, + rel: "ugc", + strip_prefix: false + ] + + {text, _mentions, []} = + Pleroma.Formatter.linkify( + "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" + ) + + assert text == + "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" + end + + test "change/0 skips if Pleroma.Formatter config is already a list", %{migration: migration} do + opts = [ + class: false, + extra: true, + new_window: false, + rel: "ugc", + strip_prefix: false + ] + + insert(:config, group: :pleroma, key: Pleroma.Formatter, value: opts) + + assert :skipped == migration.change() + + %{value: new_opts} = ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Formatter}) + + assert new_opts == opts + end + + test "change/0 skips if Pleroma.Formatter is empty", %{migration: migration} do + assert :skipped == migration.change() + end +end -- cgit v1.2.3 From b6488a4db4accc6cda716c5fdfb03f5a30ddf3d4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Jul 2020 16:01:55 -0500 Subject: Update linkify migration tests to use config from ConfigDB --- test/formatter_test.exs | 1 + .../20200716195806_autolinker_to_linkify_test.exs | 16 ++++++++++------ ...0200722185515_fix_malformed_formatter_config_test.exs | 12 ++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/test/formatter_test.exs b/test/formatter_test.exs index 8713ab9c2..f066bd50a 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -10,6 +10,7 @@ defmodule Pleroma.FormatterTest do import Pleroma.Factory setup_all do + clear_config(Pleroma.Formatter) Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) :ok end diff --git a/test/migrations/20200716195806_autolinker_to_linkify_test.exs b/test/migrations/20200716195806_autolinker_to_linkify_test.exs index 063dab0f7..250d11c61 100644 --- a/test/migrations/20200716195806_autolinker_to_linkify_test.exs +++ b/test/migrations/20200716195806_autolinker_to_linkify_test.exs @@ -1,9 +1,10 @@ defmodule Pleroma.Repo.Migrations.AutolinkerToLinkifyTest do use Pleroma.DataCase import Pleroma.Factory - import Pleroma.Tests.Helpers, only: [require_migration: 1] + import Pleroma.Tests.Helpers alias Pleroma.ConfigDB + setup do: clear_config(Pleroma.Formatter) setup_all do: require_migration("20200716195806_autolinker_to_linkify") test "change/0 converts auto_linker opts for Pleroma.Formatter", %{migration: migration} do @@ -13,7 +14,7 @@ test "change/0 converts auto_linker opts for Pleroma.Formatter", %{migration: mi class: false, strip_prefix: false, new_window: false, - rel: "ugc" + rel: "testing" ] insert(:config, group: :auto_linker, key: :opts, value: autolinker_opts) @@ -28,17 +29,20 @@ test "change/0 converts auto_linker opts for Pleroma.Formatter", %{migration: mi class: false, extra: true, new_window: false, - rel: "ugc", + rel: "testing", strip_prefix: false ] + Pleroma.Config.put(Pleroma.Formatter, new_opts) + assert new_opts == Pleroma.Config.get(Pleroma.Formatter) + {text, _mentions, []} = Pleroma.Formatter.linkify( "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" ) assert text == - "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" + "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" end test "transform_opts/1 returns a list of compatible opts", %{migration: migration} do @@ -48,14 +52,14 @@ test "transform_opts/1 returns a list of compatible opts", %{migration: migratio class: false, strip_prefix: false, new_window: false, - rel: "ugc" + rel: "qqq" ] expected_opts = [ class: false, extra: true, new_window: false, - rel: "ugc", + rel: "qqq", strip_prefix: false ] diff --git a/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs b/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs index 9e8f997a0..d3490478e 100644 --- a/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs +++ b/test/migrations/20200722185515_fix_malformed_formatter_config_test.exs @@ -1,9 +1,10 @@ defmodule Pleroma.Repo.Migrations.FixMalformedFormatterConfigTest do use Pleroma.DataCase import Pleroma.Factory - import Pleroma.Tests.Helpers, only: [require_migration: 1] + import Pleroma.Tests.Helpers alias Pleroma.ConfigDB + setup do: clear_config(Pleroma.Formatter) setup_all do: require_migration("20200722185515_fix_malformed_formatter_config") test "change/0 converts a map into a list", %{migration: migration} do @@ -11,7 +12,7 @@ test "change/0 converts a map into a list", %{migration: migration} do class: false, extra: true, new_window: false, - rel: "ugc", + rel: "F", strip_prefix: false } @@ -25,17 +26,20 @@ test "change/0 converts a map into a list", %{migration: migration} do class: false, extra: true, new_window: false, - rel: "ugc", + rel: "F", strip_prefix: false ] + Pleroma.Config.put(Pleroma.Formatter, new_opts) + assert new_opts == Pleroma.Config.get(Pleroma.Formatter) + {text, _mentions, []} = Pleroma.Formatter.linkify( "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" ) assert text == - "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" + "https://www.businessinsider.com/walmart-will-close-stores-on-thanksgiving-ending-black-friday-tradition-2020-7\n\nOmg will COVID finally end Black Friday???" end test "change/0 skips if Pleroma.Formatter config is already a list", %{migration: migration} do -- cgit v1.2.3 From 7991ddad582537f34b4964125195961e596b8687 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 23 Jul 2020 06:51:19 +0300 Subject: added warning to use old keys --- CHANGELOG.md | 1 + lib/pleroma/application_requirements.ex | 17 +++++++++++++++++ lib/pleroma/config/deprecation_warnings.ex | 18 ++++++++++++++++++ lib/pleroma/user.ex | 1 + test/application_requirements_test.exs | 14 ++++++++++++++ 5 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8b3efee..c0fd49341 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/). - OGP rich media parser merged with TwitterCard - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. - Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated. +- **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated.
    API Changes diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 88575a498..b4d8ff23b 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -17,6 +17,7 @@ defmodule VerifyError, do: defexception([:message]) def verify! do :ok |> check_migrations_applied!() + |> check_welcome_message_config!() |> check_rum!() |> handle_result() end @@ -24,6 +25,22 @@ def verify! do defp handle_result(:ok), do: :ok defp handle_result({:error, message}), do: raise(VerifyError, message: message) + defp check_welcome_message_config!(:ok) do + if Pleroma.Config.get([:welcome, :email, :enabled], false) and + not Pleroma.Emails.Mailer.enabled?() do + Logger.error(""" + To send welcome email do you need to enable mail. + \nconfig :pleroma, Pleroma.Emails.Mailer, enabled: true + """) + + {:error, "The mail disabled."} + else + :ok + end + end + + defp check_welcome_message_config!(result), do: result + # Checks for pending migrations. # def check_migrations_applied!(:ok) do diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 026871c4f..1401cbdf6 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -55,6 +55,24 @@ def warn do mrf_user_allowlist() check_old_mrf_config() check_media_proxy_whitelist_config() + check_welcome_message_config() + end + + def check_welcome_message_config do + instance_config = Pleroma.Config.get([:instance]) + + use_old_config = + Keyword.has_key?(instance_config, :welcome_user_nickname) or + Keyword.has_key?(instance_config, :welcome_message) + + if use_old_config do + Logger.error(""" + !!!DEPRECATION WARNING!!! + Your config is using old namespaces for Welcome messages configuration. You are need to change to new namespaces: + \n* `config :pleroma, :instance, welcome_user_nickname` is now `config :pleroma, :welcome, :direct_message, :sender_nickname` + \n* `config :pleroma, :instance, welcome_message` is now `config :pleroma, :welcome, :direct_message, :message` + """) + end end def check_old_mrf_config do diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 5bc256b50..95047b592 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -737,6 +737,7 @@ def send_welcome_email(%User{email: email} = user) when is_binary(email) do {:ok, :noop} end end + def send_welcome_email(_), do: {:ok, :noop} def try_send_confirmation_email(%User{} = user) do diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index 481cdfd73..b59a9988e 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -9,6 +9,20 @@ defmodule Pleroma.ApplicationRequirementsTest do alias Pleroma.Repo + describe "check_welcome_message_config!/1" do + setup do: clear_config([:welcome]) + setup do: clear_config([Pleroma.Emails.Mailer]) + + test "raises if welcome email enabled but mail disabled" do + Pleroma.Config.put([:welcome, :email, :enabled], true) + Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) + + assert_raise Pleroma.ApplicationRequirements.VerifyError, "The mail disabled.", fn -> + capture_log(&Pleroma.ApplicationRequirements.verify!/0) + end + end + end + describe "check_rum!" do setup_with_mocks([ {Pleroma.ApplicationRequirements, [:passthrough], -- cgit v1.2.3 From 9ea51a6de516b37341a9566d11d0110c2d87c1b6 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 23 Jul 2020 15:08:30 +0300 Subject: [#2791] AccountView: renamed `:force` option to `:skip_visibility_check`. --- lib/pleroma/web/activity_pub/utils.ex | 2 +- lib/pleroma/web/admin_api/views/account_view.ex | 2 +- lib/pleroma/web/chat_channel.ex | 2 +- lib/pleroma/web/mastodon_api/views/account_view.ex | 8 +++--- lib/pleroma/web/pleroma_api/views/chat_view.ex | 2 +- test/web/activity_pub/activity_pub_test.exs | 3 ++- test/web/activity_pub/transmogrifier_test.exs | 2 +- test/web/activity_pub/utils_test.exs | 3 ++- test/web/admin_api/views/report_view_test.exs | 14 +++++++--- test/web/mastodon_api/views/account_view_test.exs | 30 ++++++++++++---------- test/web/mastodon_api/views/status_view_test.exs | 2 +- test/web/pleroma_api/views/chat_view_test.exs | 3 ++- 12 files changed, 44 insertions(+), 29 deletions(-) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 11c64cffd..713b0ca1f 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -729,7 +729,7 @@ defp build_flag_object(act) when is_map(act) or is_binary(act) do "actor" => AccountView.render( "show.json", - %{user: activity_actor, force: true} + %{user: activity_actor, skip_visibility_check: true} ) } diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 4ae030b84..88fbb5315 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -105,7 +105,7 @@ def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, e end def merge_account_views(%User{} = user) do - MastodonAPI.AccountView.render("show.json", %{user: user, force: true}) + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}) |> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user})) end diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/chat_channel.ex index 08d0e80f9..3b1469c19 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/chat_channel.ex @@ -24,7 +24,7 @@ def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} if String.length(text) in 1..Pleroma.Config.get([:instance, :chat_limit]) do author = User.get_cached_by_nickname(user_name) - author_json = AccountView.render("show.json", user: author, force: true) + author_json = AccountView.render("show.json", user: author, skip_visibility_check: true) message = ChatChannelState.add_message(%{text: text, author: author_json}) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b929d5a03..864c0417f 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -39,11 +39,12 @@ def render("index.json", %{users: users} = opts) do @doc """ Renders specified user account. - :force option skips visibility check and renders any user (local or remote) + :skip_visibility_check option skips visibility check and renders any user (local or remote) regardless of [:pleroma, :restrict_unauthenticated] setting. :for option specifies the requester and can be a User record or nil. + Only use `user: user, for: user` when `user` is the actual requester of own profile. """ - def render("show.json", %{user: _user, force: true} = opts) do + def render("show.json", %{user: _user, skip_visibility_check: true} = opts) do do_render("show.json", opts) end @@ -56,7 +57,8 @@ def render("show.json", %{user: user, for: for_user_or_nil} = opts) do end def render("show.json", _) do - raise "In order to prevent account accessibility issues, :force or :for option is required." + raise "In order to prevent account accessibility issues, " <> + ":skip_visibility_check or :for option is required." end def render("mention.json", %{user: user}) do diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index 2ae7c8122..04dc20d51 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -38,7 +38,7 @@ defp account_view_opts(opts, recipient) do if Map.has_key?(account_view_opts, :for) do account_view_opts else - Map.put(account_view_opts, :force, true) + Map.put(account_view_opts, :skip_visibility_check, true) end end end diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index 34905a928..d6eab7337 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -1179,7 +1179,8 @@ test "it can create a Flag activity", "id" => activity_ap_id, "content" => content, "published" => activity_with_object.object.data["published"], - "actor" => AccountView.render("show.json", %{user: target_account, force: true}) + "actor" => + AccountView.render("show.json", %{user: target_account, skip_visibility_check: true}) } assert %Activity{ diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 01e18eace..2d089b19b 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -710,7 +710,7 @@ test "it accepts Flag activities" do "id" => activity.data["id"], "content" => "test post", "published" => object.data["published"], - "actor" => AccountView.render("show.json", %{user: user, force: true}) + "actor" => AccountView.render("show.json", %{user: user, skip_visibility_check: true}) } message = %{ diff --git a/test/web/activity_pub/utils_test.exs b/test/web/activity_pub/utils_test.exs index ab984d486..d50213545 100644 --- a/test/web/activity_pub/utils_test.exs +++ b/test/web/activity_pub/utils_test.exs @@ -482,7 +482,8 @@ test "returns map with Flag object" do "id" => activity_ap_id, "content" => content, "published" => activity.object.data["published"], - "actor" => AccountView.render("show.json", %{user: target_account, force: true}) + "actor" => + AccountView.render("show.json", %{user: target_account, skip_visibility_check: true}) } assert %{ diff --git a/test/web/admin_api/views/report_view_test.exs b/test/web/admin_api/views/report_view_test.exs index e171509e5..5a02292be 100644 --- a/test/web/admin_api/views/report_view_test.exs +++ b/test/web/admin_api/views/report_view_test.exs @@ -24,12 +24,15 @@ test "renders a report" do content: nil, actor: Map.merge( - MastodonAPI.AccountView.render("show.json", %{user: user, force: true}), + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}), AdminAPI.AccountView.render("show.json", %{user: user}) ), account: Map.merge( - MastodonAPI.AccountView.render("show.json", %{user: other_user, force: true}), + MastodonAPI.AccountView.render("show.json", %{ + user: other_user, + skip_visibility_check: true + }), AdminAPI.AccountView.render("show.json", %{user: other_user}) ), statuses: [], @@ -59,12 +62,15 @@ test "includes reported statuses" do content: nil, actor: Map.merge( - MastodonAPI.AccountView.render("show.json", %{user: user, force: true}), + MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true}), AdminAPI.AccountView.render("show.json", %{user: user}) ), account: Map.merge( - MastodonAPI.AccountView.render("show.json", %{user: other_user, force: true}), + MastodonAPI.AccountView.render("show.json", %{ + user: other_user, + skip_visibility_check: true + }), AdminAPI.AccountView.render("show.json", %{user: other_user}) ), statuses: [StatusView.render("show.json", %{activity: activity})], diff --git a/test/web/mastodon_api/views/account_view_test.exs b/test/web/mastodon_api/views/account_view_test.exs index 2b18c2e43..8f37efa3c 100644 --- a/test/web/mastodon_api/views/account_view_test.exs +++ b/test/web/mastodon_api/views/account_view_test.exs @@ -95,7 +95,7 @@ test "Represent a user account" do } } - assert expected == AccountView.render("show.json", %{user: user, force: true}) + assert expected == AccountView.render("show.json", %{user: user, skip_visibility_check: true}) end test "Favicon is nil when :instances_favicons is disabled" do @@ -108,12 +108,12 @@ test "Favicon is nil when :instances_favicons is disabled" do favicon: "https://shitposter.club/plugins/Qvitter/img/gnusocial-favicons/favicon-16x16.png" } - } = AccountView.render("show.json", %{user: user, force: true}) + } = AccountView.render("show.json", %{user: user, skip_visibility_check: true}) Config.put([:instances_favicons, :enabled], false) assert %{pleroma: %{favicon: nil}} = - AccountView.render("show.json", %{user: user, force: true}) + AccountView.render("show.json", %{user: user, skip_visibility_check: true}) end test "Represent the user account for the account owner" do @@ -190,7 +190,7 @@ test "Represent a Service(bot) account" do } } - assert expected == AccountView.render("show.json", %{user: user, force: true}) + assert expected == AccountView.render("show.json", %{user: user, skip_visibility_check: true}) end test "Represent a Funkwhale channel" do @@ -199,7 +199,9 @@ test "Represent a Funkwhale channel" do "https://channels.tests.funkwhale.audio/federation/actors/compositions" ) - assert represented = AccountView.render("show.json", %{user: user, force: true}) + assert represented = + AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + assert represented.acct == "compositions@channels.tests.funkwhale.audio" assert represented.url == "https://channels.tests.funkwhale.audio/channels/compositions" end @@ -224,7 +226,7 @@ test "Represent a smaller mention" do assert expected == AccountView.render("mention.json", %{user: user}) end - test "demands :for or :force option for account rendering" do + test "demands :for or :skip_visibility_check option for account rendering" do clear_config([:restrict_unauthenticated, :profiles, :local], false) user = insert(:user) @@ -232,9 +234,11 @@ test "demands :for or :force option for account rendering" do assert %{id: ^user_id} = AccountView.render("show.json", %{user: user, for: nil}) assert %{id: ^user_id} = AccountView.render("show.json", %{user: user, for: user}) - assert %{id: ^user_id} = AccountView.render("show.json", %{user: user, force: true}) - assert_raise RuntimeError, ~r/:force or :for option is required/, fn -> + assert %{id: ^user_id} = + AccountView.render("show.json", %{user: user, skip_visibility_check: true}) + + assert_raise RuntimeError, ~r/:skip_visibility_check or :for option is required/, fn -> AccountView.render("show.json", %{user: user}) end end @@ -361,13 +365,13 @@ test "returns the settings store if the requesting user is the represented user test "doesn't sanitize display names" do user = insert(:user, name: " username ") - result = AccountView.render("show.json", %{user: user, force: true}) + result = AccountView.render("show.json", %{user: user, skip_visibility_check: true}) assert result.display_name == " username " end test "never display nil user follow counts" do user = insert(:user, following_count: 0, follower_count: 0) - result = AccountView.render("show.json", %{user: user, force: true}) + result = AccountView.render("show.json", %{user: user, skip_visibility_check: true}) assert result.following_count == 0 assert result.followers_count == 0 @@ -391,7 +395,7 @@ test "shows when follows/followers stats are hidden and sets follow/follower cou followers_count: 0, following_count: 0, pleroma: %{hide_follows_count: true, hide_followers_count: true} - } = AccountView.render("show.json", %{user: user, force: true}) + } = AccountView.render("show.json", %{user: user, skip_visibility_check: true}) end test "shows when follows/followers are hidden" do @@ -404,7 +408,7 @@ test "shows when follows/followers are hidden" do followers_count: 1, following_count: 1, pleroma: %{hide_follows: true, hide_followers: true} - } = AccountView.render("show.json", %{user: user, force: true}) + } = AccountView.render("show.json", %{user: user, skip_visibility_check: true}) end test "shows actual follower/following count to the account owner" do @@ -547,7 +551,7 @@ test "uses mediaproxy urls when it's enabled" do emoji: %{"joker_smile" => "https://evil.website/society.png"} ) - AccountView.render("show.json", %{user: user, force: true}) + AccountView.render("show.json", %{user: user, skip_visibility_check: true}) |> Enum.all?(fn {key, url} when key in [:avatar, :avatar_static, :header, :header_static] -> String.starts_with?(url, Pleroma.Web.base_url()) diff --git a/test/web/mastodon_api/views/status_view_test.exs b/test/web/mastodon_api/views/status_view_test.exs index d44e3f6e6..d97d818bb 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -177,7 +177,7 @@ test "a note activity" do id: to_string(note.id), uri: object_data["id"], url: Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, note), - account: AccountView.render("show.json", %{user: user, force: true}), + account: AccountView.render("show.json", %{user: user, skip_visibility_check: true}), in_reply_to_id: nil, in_reply_to_account_id: nil, card: nil, diff --git a/test/web/pleroma_api/views/chat_view_test.exs b/test/web/pleroma_api/views/chat_view_test.exs index 46d47cd4f..02484b705 100644 --- a/test/web/pleroma_api/views/chat_view_test.exs +++ b/test/web/pleroma_api/views/chat_view_test.exs @@ -26,7 +26,8 @@ test "it represents a chat" do assert represented_chat == %{ id: "#{chat.id}", - account: AccountView.render("show.json", user: recipient, force: true), + account: + AccountView.render("show.json", user: recipient, skip_visibility_check: true), unread: 0, last_message: nil, updated_at: Utils.to_masto_date(chat.updated_at) -- cgit v1.2.3 From 4bfad0b483957acf755a043f33799742997da859 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 23 Jul 2020 12:59:40 -0500 Subject: Support blocking via query parameters as well and document the change. --- CHANGELOG.md | 1 + .../web/api_spec/operations/domain_block_operation.ex | 3 +++ .../mastodon_api/controllers/domain_block_controller.ex | 5 +++++ .../controllers/domain_block_controller_test.exs | 14 ++++++++++++++ 4 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75488f026..4481e8b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** Notification Settings API option for hiding push notification contents has been renamed to `hide_notification_contents` - Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance +- Mastodon API (legacy): Allow query parameters for `/api/v1/domain_blocks`, e.g. `/api/v1/domain_blocks?domain=badposters.zone`
    diff --git a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex index 8234394f9..1e0da8209 100644 --- a/lib/pleroma/web/api_spec/operations/domain_block_operation.ex +++ b/lib/pleroma/web/api_spec/operations/domain_block_operation.ex @@ -31,6 +31,7 @@ def index_operation do } end + # Supporting domain query parameter is deprecated in Mastodon API def create_operation do %Operation{ tags: ["domain_blocks"], @@ -45,11 +46,13 @@ def create_operation do """, operationId: "DomainBlockController.create", requestBody: domain_block_request(), + parameters: [Operation.parameter(:domain, :query, %Schema{type: :string}, "Domain name")], security: [%{"oAuth" => ["follow", "write:blocks"]}], responses: %{200 => empty_object_response()} } end + # Supporting domain query parameter is deprecated in Mastodon API def delete_operation do %Operation{ tags: ["domain_blocks"], diff --git a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex index 117e89426..9c2d093cd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex @@ -32,6 +32,11 @@ def create(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, json(conn, %{}) end + def create(%{assigns: %{user: blocker}} = conn, %{domain: domain}) do + User.block_domain(blocker, domain) + json(conn, %{}) + end + @doc "DELETE /api/v1/domain_blocks" def delete(%{assigns: %{user: blocker}, body_params: %{domain: domain}} = conn, _params) do User.unblock_domain(blocker, domain) diff --git a/test/web/mastodon_api/controllers/domain_block_controller_test.exs b/test/web/mastodon_api/controllers/domain_block_controller_test.exs index 978290d62..664654500 100644 --- a/test/web/mastodon_api/controllers/domain_block_controller_test.exs +++ b/test/web/mastodon_api/controllers/domain_block_controller_test.exs @@ -32,6 +32,20 @@ test "blocking / unblocking a domain" do refute User.blocks?(user, other_user) end + test "blocking a domain via query params" do + %{user: user, conn: conn} = oauth_access(["write:blocks"]) + other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"}) + + ret_conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/domain_blocks?domain=dogwhistle.zone") + + assert %{} == json_response_and_validate_schema(ret_conn, 200) + user = User.get_cached_by_ap_id(user.ap_id) + assert User.blocks?(user, other_user) + end + test "unblocking a domain via query params" do %{user: user, conn: conn} = oauth_access(["write:blocks"]) other_user = insert(:user, %{ap_id: "https://dogwhistle.zone/@pundit"}) -- cgit v1.2.3 From 61ef1fca4bdefc1281d2ffaac8af43d0fcdb6ee4 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 24 Jul 2020 08:35:06 +0300 Subject: remove duplicate module --- config/test.exs | 2 ++ lib/pleroma/config/utils.ex | 17 ----------------- lib/pleroma/user/welcome_email.ex | 2 +- 3 files changed, 3 insertions(+), 18 deletions(-) delete mode 100644 lib/pleroma/config/utils.ex diff --git a/config/test.exs b/config/test.exs index abcf793e5..db0655e73 100644 --- a/config/test.exs +++ b/config/test.exs @@ -118,6 +118,8 @@ streaming_enabled: true, public_endpoint: nil +config :tzdata, :autoupdate, :disabled + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else diff --git a/lib/pleroma/config/utils.ex b/lib/pleroma/config/utils.ex deleted file mode 100644 index f1afbb42f..000000000 --- a/lib/pleroma/config/utils.ex +++ /dev/null @@ -1,17 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Config.Utils do - alias Pleroma.Config - - def instance_name, do: Config.get([:instance, :name]) - - defp instance_notify_email do - Config.get([:instance, :notify_email]) || Config.get([:instance, :email]) - end - - def sender do - {instance_name(), instance_notify_email()} - end -end diff --git a/lib/pleroma/user/welcome_email.ex b/lib/pleroma/user/welcome_email.ex index 91a9591dd..5322000d4 100644 --- a/lib/pleroma/user/welcome_email.ex +++ b/lib/pleroma/user/welcome_email.ex @@ -11,7 +11,7 @@ defmodule Pleroma.User.WelcomeEmail do alias Pleroma.Emails alias Pleroma.User - import Pleroma.Config.Utils, only: [instance_name: 0] + import Pleroma.Config.Helpers, only: [instance_name: 0] @spec enabled?() :: boolean() def enabled?, do: Config.get([:welcome, :email, :enabled], false) -- cgit v1.2.3 From 91f3cf9bc6e8e8567d20bb859ee0bb9854a20a07 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2020 14:06:41 +0200 Subject: Pipeline: Add embedded object federation. --- lib/pleroma/web/activity_pub/pipeline.ex | 7 +++++ test/web/activity_pub/pipeline_test.exs | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 6875c47f6..50d9016e6 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -52,6 +52,13 @@ defp maybe_federate(%Activity{} = activity, meta) do do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating]) if !do_not_federate && local do + activity = + if object = Keyword.get(meta, :embedded_object) do + %{activity | data: Map.put(activity.data, "object", object)} + else + activity + end + Federator.publish(activity) {:ok, :federated} else diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index 8deb64501..202b5fe61 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -14,6 +14,51 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do :ok end + test "when given an `embedded_object` in meta, Federation will receive a the original activity with the `object` field set to this embedded object" do + activity = insert(:note_activity) + object = %{"id" => "1", "type" => "Love"} + meta = [local: true, embedded_object: object] + + activity_with_object = %{activity | data: Map.put(activity.data, "object", object)} + + 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, + handle_after_transaction: fn m -> 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)) + refute called(Pleroma.Web.Federator.publish(activity)) + assert_called(Pleroma.Web.Federator.publish(activity_with_object)) + end + end + test "it goes through validation, filtering, persisting, side effects and federation for local activities" do activity = insert(:note_activity) meta = [local: true] -- cgit v1.2.3 From 3d13fb05f851d127d852ee9c26afa4dab61410ad Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2020 14:40:22 +0200 Subject: Side Effects: On undoing, put information about the undone object. --- lib/pleroma/web/activity_pub/side_effects.ex | 4 ++++ test/web/activity_pub/side_effects_test.exs | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 1d2c296a5..33bee1576 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -174,6 +174,10 @@ def handle(%{data: %{"type" => "Announce"}} = object, meta) do def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do with undone_object <- Activity.get_by_ap_id(undone_object), :ok <- handle_undoing(undone_object) do + meta = + meta + |> Keyword.put(:embedded_object, undone_object.data) + {:ok, object, meta} end end diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 2649b060a..d48c235c0 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -312,8 +312,13 @@ test "when activation is required", %{delete: delete, user: user} do } end - test "deletes the original block", %{block_undo: block_undo, block: block} do - {:ok, _block_undo, _} = SideEffects.handle(block_undo) + test "deletes the original block, but sets `embedded_object`", %{ + block_undo: block_undo, + block: block + } do + {:ok, _block_undo, meta} = SideEffects.handle(block_undo) + + assert meta[:embedded_object] == block.data refute Activity.get_by_id(block.id) end -- cgit v1.2.3 From 1dd6de03ee655f5247ac62fee488307c934d7378 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 24 Jul 2020 14:54:13 +0200 Subject: CommonAPI Tests: Check that undoing objects federates them as embeds. --- test/web/common_api/common_api_test.exs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 7e11fede3..313dda21b 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -624,14 +624,27 @@ test "unreacting to a status with an emoji" do user = insert(:user) other_user = insert(:user) - {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) - {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍") + clear_config([:instance, :federating], true) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + {:ok, activity} = CommonAPI.post(other_user, %{status: "cofe"}) + {:ok, reaction} = CommonAPI.react_with_emoji(activity.id, user, "👍") + + {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") - {:ok, unreaction} = CommonAPI.unreact_with_emoji(activity.id, user, "👍") + assert unreaction.data["type"] == "Undo" + assert unreaction.data["object"] == reaction.data["id"] + assert unreaction.local - assert unreaction.data["type"] == "Undo" - assert unreaction.data["object"] == reaction.data["id"] - assert unreaction.local + # On federation, it contains the undone (and deleted) object + unreaction_with_object = %{ + unreaction + | data: Map.put(unreaction.data, "object", reaction.data) + } + + assert called(Pleroma.Web.Federator.publish(unreaction_with_object)) + end end test "repeating a status" do -- cgit v1.2.3 From 9be66682369f1aa3c221d411073c20e10b5a3ac1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 24 Jul 2020 12:05:42 -0500 Subject: Fix mix tasks that make HTTP calls by starting the Gun connection pool --- lib/mix/pleroma.ex | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 9f0bf6ecb..c2b607fb3 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -24,8 +24,10 @@ def start_pleroma do Application.put_env(:logger, :console, level: :debug) end + adapter = Application.get_env(:tesla, :adapter) + apps = - if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Gun do + if adapter == Tesla.Adapter.Gun do [:gun | @apps] else [:hackney | @apps] @@ -33,11 +35,13 @@ def start_pleroma do Enum.each(apps, &Application.ensure_all_started/1) - children = [ - Pleroma.Repo, - {Pleroma.Config.TransferTask, false}, - Pleroma.Web.Endpoint - ] + children = + [ + Pleroma.Repo, + {Pleroma.Config.TransferTask, false}, + Pleroma.Web.Endpoint + ] ++ + http_children(adapter) cachex_children = Enum.map(@cachex_children, &Pleroma.Application.build_cachex(&1, [])) @@ -115,4 +119,11 @@ def mix_shell?, do: :erlang.function_exported(Mix, :shell, 0) def escape_sh_path(path) do ~S(') <> String.replace(path, ~S('), ~S(\')) <> ~S(') end + + defp http_children(Tesla.Adapter.Gun) do + Pleroma.Gun.ConnectionPool.children() ++ + [{Task, &Pleroma.HTTP.AdapterHelper.Gun.limiter_setup/0}] + end + + defp http_children(_), do: [] end -- cgit v1.2.3 From 65a1b048a8effa23eb99b1aeae3b97a7e7df3ef5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 24 Jul 2020 12:06:56 -0500 Subject: Ensure Oban is available during mix tasks. Fixes: mix pleroma.user rm username --- lib/mix/pleroma.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index c2b607fb3..074492a46 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -39,7 +39,8 @@ def start_pleroma do [ Pleroma.Repo, {Pleroma.Config.TransferTask, false}, - Pleroma.Web.Endpoint + Pleroma.Web.Endpoint, + {Oban, Pleroma.Config.get(Oban)} ] ++ http_children(adapter) -- cgit v1.2.3 From 643664d58365c88beb8a6da9c02a15ec6c8ef48d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Sat, 25 Jul 2020 07:09:09 +0300 Subject: added migrate old settings to new --- .../20200724133313_move_welcome_settings.exs | 94 ++++++++++++++ .../20200724133313_move_welcome_settings_test.exs | 140 +++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 priv/repo/migrations/20200724133313_move_welcome_settings.exs create mode 100644 test/migrations/20200724133313_move_welcome_settings_test.exs diff --git a/priv/repo/migrations/20200724133313_move_welcome_settings.exs b/priv/repo/migrations/20200724133313_move_welcome_settings.exs new file mode 100644 index 000000000..323a8fcee --- /dev/null +++ b/priv/repo/migrations/20200724133313_move_welcome_settings.exs @@ -0,0 +1,94 @@ +defmodule Pleroma.Repo.Migrations.MoveWelcomeSettings do + use Ecto.Migration + + alias Pleroma.ConfigDB + + @old_keys [:welcome_user_nickname, :welcome_message] + + def up do + with {:ok, config, {keep_values, move_values}} <- get_old_values() do + insert_welcome_settings(move_values) + update_instance_config(config, keep_values) + end + end + + def down do + with {:ok, welcome_config, revert_values} <- get_revert_values() do + revert_instance_config(revert_values) + Pleroma.Repo.delete(welcome_config) + end + end + + defp insert_welcome_settings([_ | _] = values) do + unless String.trim(values[:welcome_message]) == "" do + config_values = [ + direct_message: %{ + enabled: true, + sender_nickname: values[:welcome_user_nickname], + message: values[:welcome_message] + }, + email: %{ + enabled: false, + sender: nil, + subject: "Welcome to <%= instance_name %>", + html: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + } + ] + + {:ok, _} = + %ConfigDB{} + |> ConfigDB.changeset(%{group: :pleroma, key: :welcome, value: config_values}) + |> Pleroma.Repo.insert() + end + + :ok + end + + defp insert_welcome_settings(_), do: :noop + + defp revert_instance_config(%{} = revert_values) do + values = [ + welcome_user_nickname: revert_values[:sender_nickname], + welcome_message: revert_values[:message] + ] + + ConfigDB.update_or_create(%{group: :pleroma, key: :instance, value: values}) + end + + defp revert_instance_config(_), do: :noop + + defp update_instance_config(config, values) do + {:ok, _} = + config + |> ConfigDB.changeset(%{value: values}) + |> Pleroma.Repo.update() + + :ok + end + + defp get_revert_values do + config = ConfigDB.get_by_params(%{group: :pleroma, key: :welcome}) + + cond do + is_nil(config) -> {:noop, nil, nil} + true -> {:ok, config, config.value[:direct_message]} + end + end + + defp get_old_values do + config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) + + cond do + is_nil(config) -> + {:noop, config, {}} + + is_binary(config.value[:welcome_message]) -> + {:ok, config, + {Keyword.drop(config.value, @old_keys), Keyword.take(config.value, @old_keys)}} + + true -> + {:ok, config, {Keyword.drop(config.value, @old_keys), []}} + end + end +end diff --git a/test/migrations/20200724133313_move_welcome_settings_test.exs b/test/migrations/20200724133313_move_welcome_settings_test.exs new file mode 100644 index 000000000..739f24547 --- /dev/null +++ b/test/migrations/20200724133313_move_welcome_settings_test.exs @@ -0,0 +1,140 @@ +defmodule Pleroma.Repo.Migrations.MoveWelcomeSettingsTest do + use Pleroma.DataCase + import Pleroma.Factory + import Pleroma.Tests.Helpers + alias Pleroma.ConfigDB + + setup_all do: require_migration("20200724133313_move_welcome_settings") + + describe "up/0" do + test "converts welcome settings", %{migration: migration} do + insert(:config, + group: :pleroma, + key: :instance, + value: [ + welcome_message: "Test message", + welcome_user_nickname: "jimm", + name: "Pleroma" + ] + ) + + migration.up() + instance_config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) + welcome_config = ConfigDB.get_by_params(%{group: :pleroma, key: :welcome}) + + assert instance_config.value == [name: "Pleroma"] + + assert welcome_config.value == [ + direct_message: %{ + enabled: true, + message: "Test message", + sender_nickname: "jimm" + }, + email: %{ + enabled: false, + html: "Welcome to <%= instance_name %>", + sender: nil, + subject: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + } + ] + end + + test "does nothing when message empty", %{migration: migration} do + insert(:config, + group: :pleroma, + key: :instance, + value: [ + welcome_message: "", + welcome_user_nickname: "jimm", + name: "Pleroma" + ] + ) + + migration.up() + instance_config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) + refute ConfigDB.get_by_params(%{group: :pleroma, key: :welcome}) + assert instance_config.value == [name: "Pleroma"] + end + + test "does nothing when welcome_message not set", %{migration: migration} do + insert(:config, + group: :pleroma, + key: :instance, + value: [welcome_user_nickname: "jimm", name: "Pleroma"] + ) + + migration.up() + instance_config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) + refute ConfigDB.get_by_params(%{group: :pleroma, key: :welcome}) + assert instance_config.value == [name: "Pleroma"] + end + end + + describe "down/0" do + test "revert new settings to old when instance setting not exists", %{migration: migration} do + insert(:config, + group: :pleroma, + key: :welcome, + value: [ + direct_message: %{ + enabled: true, + message: "Test message", + sender_nickname: "jimm" + }, + email: %{ + enabled: false, + html: "Welcome to <%= instance_name %>", + sender: nil, + subject: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + } + ] + ) + + migration.down() + + refute ConfigDB.get_by_params(%{group: :pleroma, key: :welcome}) + instance_config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) + + assert instance_config.value == [ + welcome_user_nickname: "jimm", + welcome_message: "Test message" + ] + end + + test "revert new settings to old when instance setting exists", %{migration: migration} do + insert(:config, group: :pleroma, key: :instance, value: [name: "Pleroma App"]) + + insert(:config, + group: :pleroma, + key: :welcome, + value: [ + direct_message: %{ + enabled: true, + message: "Test message", + sender_nickname: "jimm" + }, + email: %{ + enabled: false, + html: "Welcome to <%= instance_name %>", + sender: nil, + subject: "Welcome to <%= instance_name %>", + text: "Welcome to <%= instance_name %>" + } + ] + ) + + migration.down() + + refute ConfigDB.get_by_params(%{group: :pleroma, key: :welcome}) + instance_config = ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) + + assert instance_config.value == [ + name: "Pleroma App", + welcome_user_nickname: "jimm", + welcome_message: "Test message" + ] + end + end +end -- cgit v1.2.3 From 4d80cf540913cddbf86a89f94ea75c6c12d8376b Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Sun, 26 Jul 2020 01:48:50 +0300 Subject: Update types in Pleroma.Formatter group --- config/description.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/description.exs b/config/description.exs index e4850218e..a5e66f3fb 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2235,13 +2235,13 @@ children: [ %{ key: :class, - type: [:string, false], + type: [:string, :boolean], description: "Specify the class to be added to the generated link. Disable to clear.", suggestions: ["auto-linker", false] }, %{ key: :rel, - type: [:string, false], + type: [:string, :boolean], description: "Override the rel attribute. Disable to clear.", suggestions: ["ugc", "noopener noreferrer", false] }, @@ -2252,7 +2252,7 @@ }, %{ key: :truncate, - type: [:integer, false], + type: [:integer, :boolean], description: "Set to a number to truncate URLs longer than the number. Truncated URLs will end in `...`", suggestions: [15, false] -- cgit v1.2.3 From b31844d6e01fc8bea4ecbe93b072846ca4309e88 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Sun, 26 Jul 2020 13:54:56 +0000 Subject: OpenAPI: Replace actor_id by account_id to follow ChatMessage schema --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index cf299bfc2..1a5b05899 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -300,11 +300,11 @@ def chat_messages_response do "content" => "Check this out :firefox:", "id" => "13", "chat_id" => "1", - "actor_id" => "someflakeid", + "account_id" => "someflakeid", "unread" => false }, %{ - "actor_id" => "someflakeid", + "account_id" => "someflakeid", "content" => "Whats' up?", "id" => "12", "chat_id" => "1", @@ -337,7 +337,7 @@ def chat_message_create do def mark_as_read do %Schema{ - title: "MarkAsReadRequest", + title: "MarkAsReadRequest",Update chat_operation.ex description: "POST body for marking a number of chat messages as read", type: :object, required: [:last_read_id], -- cgit v1.2.3 From 6107440ea0da3a9e59576a86a9dab50acd83936e Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Sun, 26 Jul 2020 13:59:46 +0000 Subject: OpenAPI: remove accidentally pasted buffer data --- lib/pleroma/web/api_spec/operations/chat_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index 1a5b05899..b1a0d26ab 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -337,7 +337,7 @@ def chat_message_create do def mark_as_read do %Schema{ - title: "MarkAsReadRequest",Update chat_operation.ex + title: "MarkAsReadRequest", description: "POST body for marking a number of chat messages as read", type: :object, required: [:last_read_id], -- cgit v1.2.3 From d4fbec62a37f229108a4ae5ef069042a8aa4aa22 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 26 Jul 2020 19:18:21 +0300 Subject: ReverseProxy: Fix a gun connection leak when there is an error with no body - Modify `close/1` function to do the same thing it does for hackney, which is - close the client rather than the whole connection - Release the connection when there is no body to chunk --- lib/pleroma/reverse_proxy/client/tesla.ex | 9 ++++++--- lib/pleroma/reverse_proxy/reverse_proxy.ex | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index 65785445d..84addc404 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -5,6 +5,8 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do @behaviour Pleroma.ReverseProxy.Client + alias Pleroma.Gun.ConnectionPool + @type headers() :: [{String.t(), String.t()}] @type status() :: pos_integer() @@ -31,6 +33,8 @@ def request(method, url, headers, body, opts \\ []) do if is_map(response.body) and method != :head do {:ok, response.status, response.headers, response.body} else + conn_pid = response.opts[:adapter][:conn] + ConnectionPool.release_conn(conn_pid) {:ok, response.status, response.headers} end else @@ -48,7 +52,7 @@ def stream_body(%{pid: pid, opts: opts, fin: true}) do # if there were redirects we need to checkout old conn conn = opts[:old_conn] || opts[:conn] - if conn, do: :ok = Pleroma.Gun.ConnectionPool.release_conn(conn) + if conn, do: :ok = ConnectionPool.release_conn(conn) :done end @@ -74,8 +78,7 @@ defp read_chunk!(%{pid: pid, stream: stream, opts: opts}) do @impl true @spec close(map) :: :ok | no_return() def close(%{pid: pid}) do - adapter = check_adapter() - adapter.close(pid) + ConnectionPool.release_conn(pid) end defp check_adapter do diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index 28ad4c846..0de4e2309 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -165,6 +165,9 @@ defp request(method, url, headers, opts) do {:ok, code, _, _} -> {:error, {:invalid_http_response, code}} + {:ok, code, _} -> + {:error, {:invalid_http_response, code}} + {:error, error} -> {:error, error} end -- cgit v1.2.3 From 6bf8eee5f90d27d81e645cacfff60b001316f5cd Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 26 Jul 2020 20:44:26 +0300 Subject: ReverseProxy tesla client: remove handling of old_conn This is no longer relevant because we use a custom FollowRedirects middleware now --- lib/pleroma/reverse_proxy/client/tesla.ex | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index 84addc404..d5a339681 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -45,15 +45,8 @@ def request(method, url, headers, body, opts \\ []) do @impl true @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 - 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 = ConnectionPool.release_conn(conn) - + def stream_body(%{pid: pid, fin: true}) do + ConnectionPool.release_conn(pid) :done end -- cgit v1.2.3 From 0d5d1c62efa94ea8fd204dbe4a77073b0374cad4 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Jul 2020 12:24:41 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/config/deprecation_warnings.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 1401cbdf6..0f52eb210 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -68,7 +68,7 @@ def check_welcome_message_config do if use_old_config do Logger.error(""" !!!DEPRECATION WARNING!!! - Your config is using old namespaces for Welcome messages configuration. You are need to change to new namespaces: + Your config is using the old namespace for Welcome messages configuration. You need to change to the new namespace: \n* `config :pleroma, :instance, welcome_user_nickname` is now `config :pleroma, :welcome, :direct_message, :sender_nickname` \n* `config :pleroma, :instance, welcome_message` is now `config :pleroma, :welcome, :direct_message, :message` """) -- cgit v1.2.3 From 9e6f4694dd21f92bb2292e819d0f7f1cad149887 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 27 Jul 2020 16:39:50 +0200 Subject: Pipeline: Unify embedded_object / object_data, move to validator. --- lib/pleroma/web/activity_pub/object_validator.ex | 7 +++++++ lib/pleroma/web/activity_pub/pipeline.ex | 2 +- lib/pleroma/web/activity_pub/side_effects.ex | 4 ---- test/web/activity_pub/pipeline_test.exs | 4 ++-- test/web/activity_pub/side_effects_test.exs | 5 ++--- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index df926829c..0dcc7be4d 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do the system. """ + alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object alias Pleroma.User @@ -71,6 +72,12 @@ def validate(%{"type" => "Undo"} = object, meta) do |> UndoValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) + undone_object = Activity.get_by_ap_id(object["object"]) + + meta = + meta + |> Keyword.put(:object_data, undone_object.data) + {:ok, object, meta} end end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 50d9016e6..36e325c37 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -53,7 +53,7 @@ defp maybe_federate(%Activity{} = activity, meta) do if !do_not_federate && local do activity = - if object = Keyword.get(meta, :embedded_object) do + if object = Keyword.get(meta, :object_data) do %{activity | data: Map.put(activity.data, "object", object)} else activity diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 33bee1576..1d2c296a5 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -174,10 +174,6 @@ def handle(%{data: %{"type" => "Announce"}} = object, meta) do def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do with undone_object <- Activity.get_by_ap_id(undone_object), :ok <- handle_undoing(undone_object) do - meta = - meta - |> Keyword.put(:embedded_object, undone_object.data) - {:ok, object, meta} end end diff --git a/test/web/activity_pub/pipeline_test.exs b/test/web/activity_pub/pipeline_test.exs index 202b5fe61..f2a231eaf 100644 --- a/test/web/activity_pub/pipeline_test.exs +++ b/test/web/activity_pub/pipeline_test.exs @@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do :ok end - test "when given an `embedded_object` in meta, Federation will receive a the original activity with the `object` field set to this embedded object" do + test "when given an `object_data` in meta, Federation will receive a the original activity with the `object` field set to this embedded object" do activity = insert(:note_activity) object = %{"id" => "1", "type" => "Love"} - meta = [local: true, embedded_object: object] + meta = [local: true, object_data: object] activity_with_object = %{activity | data: Map.put(activity.data, "object", object)} diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index d48c235c0..4a08eb7ee 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -312,13 +312,12 @@ test "when activation is required", %{delete: delete, user: user} do } end - test "deletes the original block, but sets `embedded_object`", %{ + test "deletes the original block", %{ block_undo: block_undo, block: block } do - {:ok, _block_undo, meta} = SideEffects.handle(block_undo) + {:ok, _block_undo, _meta} = SideEffects.handle(block_undo) - assert meta[:embedded_object] == block.data refute Activity.get_by_id(block.id) end -- cgit v1.2.3 From 4a6389316dac53c1ca2ec36d160690476d881185 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 27 Jul 2020 17:59:13 +0200 Subject: masto_fe_view: Remove @default_settings --- lib/pleroma/web/views/masto_fe_view.ex | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex index c3096006e..3b78629dc 100644 --- a/lib/pleroma/web/views/masto_fe_view.ex +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -9,36 +9,6 @@ defmodule Pleroma.Web.MastoFEView do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.CustomEmojiView - @default_settings %{ - onboarded: true, - home: %{ - shows: %{ - reblog: true, - reply: true - } - }, - notifications: %{ - alerts: %{ - follow: true, - favourite: true, - reblog: true, - mention: true - }, - shows: %{ - follow: true, - favourite: true, - reblog: true, - mention: true - }, - sounds: %{ - follow: true, - favourite: true, - reblog: true, - mention: true - } - } - } - def initial_state(token, user, custom_emojis) do limit = Config.get([:instance, :limit]) @@ -86,7 +56,7 @@ def initial_state(token, user, custom_emojis) do "video\/mp4" ] }, - settings: user.settings || @default_settings, + settings: user.settings || %{}, push_subscription: nil, accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)}, custom_emojis: render(CustomEmojiView, "index.json", custom_emojis: custom_emojis), -- cgit v1.2.3 From 6f44a0ee84a8dca7a94a38b45493a444390f13ec Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jul 2020 15:13:34 -0500 Subject: Add configurable registration_reason limit --- config/config.exs | 1 + lib/pleroma/user.ex | 2 ++ .../20200712234852_add_approval_fields_to_users.exs | 2 +- test/user_test.exs | 18 +++++++++++++++++- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index d8bf921bb..1dc196a6b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -238,6 +238,7 @@ max_remote_account_fields: 20, account_field_name_length: 512, account_field_value_length: 2048, + registration_reason_length: 500, external_user_synchronization: true, extended_nickname_format: true, cleanup_attachments: false, diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a78123fe4..913b6afd1 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -641,6 +641,7 @@ def force_password_reset(user), do: update_password_reset_pending(user, true) def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Config.get([:instance, :user_bio_length], 5000) name_limit = Config.get([:instance, :user_name_length], 100) + reason_limit = Config.get([:instance, :registration_reason_length], 500) params = Map.put_new(params, :accepts_chat_messages, true) need_confirmation? = @@ -681,6 +682,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do |> validate_format(:email, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) + |> validate_length(:registration_reason, max: reason_limit) |> maybe_validate_required_email(opts[:external]) |> put_password_hash |> put_ap_id() diff --git a/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs b/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs index 559640f01..43f741a5b 100644 --- a/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs +++ b/priv/repo/migrations/20200712234852_add_approval_fields_to_users.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Repo.Migrations.AddApprovalFieldsToUsers do def change do alter table(:users) do add(:approval_pending, :boolean) - add(:registration_reason, :string) + add(:registration_reason, :text) end end end diff --git a/test/user_test.exs b/test/user_test.exs index 5da86bcec..5bf677666 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -550,7 +550,8 @@ test "it creates confirmed user if :confirmed option is given" do nickname: "nick", password: "test", password_confirmation: "test", - email: "email@example.com" + email: "email@example.com", + registration_reason: "I'm a cool guy :)" } setup do: clear_config([:instance, :account_approval_required], true) @@ -561,6 +562,21 @@ test "it creates unapproved user" do {:ok, user} = Repo.insert(changeset) assert user.approval_pending + assert user.registration_reason == "I'm a cool guy :)" + end + + test "it restricts length of registration reason" do + reason_limit = Pleroma.Config.get([:instance, :registration_reason_length]) + + assert is_integer(reason_limit) + + params = + @full_user_data + |> Map.put(:registration_reason, "Quia et nesciunt dolores numquam ipsam nisi sapiente soluta. Ullam repudiandae nisi quam porro officiis officiis ad. Consequatur animi velit ex quia. Odit voluptatem perferendis quia ut nisi. Dignissimos sit soluta atque aliquid dolorem ut dolorum ut. Labore voluptates iste iusto amet voluptatum earum. Ad fugit illum nam eos ut nemo. Pariatur ea fuga non aspernatur. Dignissimos debitis officia corporis est nisi ab et. Atque itaque alias eius voluptas minus. Accusamus numquam tempore occaecati in.") + + changeset = User.register_changeset(%User{}, params) + + refute changeset.valid? end end -- cgit v1.2.3 From 520dce857e4a6d3cdce275c46b3ad7b46a582c76 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jul 2020 15:24:20 -0500 Subject: Add :registration_reason_length to description.exs --- config/description.exs | 8 ++++++++ docs/configuration/cheatsheet.md | 1 + 2 files changed, 9 insertions(+) diff --git a/config/description.exs b/config/description.exs index 509effbc3..df9f256ef 100644 --- a/config/description.exs +++ b/config/description.exs @@ -879,6 +879,14 @@ 2048 ] }, + %{ + key: :registration_reason_length, + type: :integer, + description: "Maximum registration reason length. Default: 500.", + suggestions: [ + 500 + ] + }, %{ key: :external_user_synchronization, type: :boolean, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 5cf073293..c89df24cc 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -59,6 +59,7 @@ To add configuration to your config file, you can copy it from the base config. * `max_remote_account_fields`: The maximum number of custom fields in the remote user profile (default: `20`). * `account_field_name_length`: An account field name maximum length (default: `512`). * `account_field_value_length`: An account field value maximum length (default: `2048`). +* `registration_reason_length`: Maximum registration reason length (default: `500`). * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. -- cgit v1.2.3 From f43518eb7433a6c50d635d6536c3fbe3a37ea82b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jul 2020 19:19:14 -0500 Subject: Lint, fix test --- lib/pleroma/user.ex | 8 ++++++-- test/user_test.exs | 5 ++++- test/web/mastodon_api/controllers/account_controller_test.exs | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 913b6afd1..dcf6ebee2 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1553,8 +1553,12 @@ defp delete_or_deactivate(%User{local: true} = user) do status = account_status(user) case status do - :confirmation_pending -> delete_and_invalidate_cache(user) - :approval_pending -> delete_and_invalidate_cache(user) + :confirmation_pending -> + delete_and_invalidate_cache(user) + + :approval_pending -> + delete_and_invalidate_cache(user) + _ -> user |> change(%{deactivated: true, email: nil}) diff --git a/test/user_test.exs b/test/user_test.exs index 5bf677666..624baf8ad 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -572,7 +572,10 @@ test "it restricts length of registration reason" do params = @full_user_data - |> Map.put(:registration_reason, "Quia et nesciunt dolores numquam ipsam nisi sapiente soluta. Ullam repudiandae nisi quam porro officiis officiis ad. Consequatur animi velit ex quia. Odit voluptatem perferendis quia ut nisi. Dignissimos sit soluta atque aliquid dolorem ut dolorum ut. Labore voluptates iste iusto amet voluptatum earum. Ad fugit illum nam eos ut nemo. Pariatur ea fuga non aspernatur. Dignissimos debitis officia corporis est nisi ab et. Atque itaque alias eius voluptas minus. Accusamus numquam tempore occaecati in.") + |> Map.put( + :registration_reason, + "Quia et nesciunt dolores numquam ipsam nisi sapiente soluta. Ullam repudiandae nisi quam porro officiis officiis ad. Consequatur animi velit ex quia. Odit voluptatem perferendis quia ut nisi. Dignissimos sit soluta atque aliquid dolorem ut dolorum ut. Labore voluptates iste iusto amet voluptatum earum. Ad fugit illum nam eos ut nemo. Pariatur ea fuga non aspernatur. Dignissimos debitis officia corporis est nisi ab et. Atque itaque alias eius voluptas minus. Accusamus numquam tempore occaecati in." + ) changeset = User.register_changeset(%User{}, params) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index e6b283aab..1ba5bc964 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -1017,7 +1017,7 @@ test "Account registration via app with account_approval_required", %{conn: conn password: "PlzDontHackLain", bio: "Test Bio", agreement: true, - reason: "I'm a cool dude, bro" + reason: "I am a cool dude, bro" }) %{ @@ -1035,7 +1035,7 @@ test "Account registration via app with account_approval_required", %{conn: conn assert token_from_db.user.confirmation_pending assert token_from_db.user.approval_pending - assert token_from_db.user.registration_reason == "I'm a cool dude, bro" + assert token_from_db.user.registration_reason == "I am a cool dude, bro" end test "returns error when user already registred", %{conn: conn, valid_params: valid_params} do -- cgit v1.2.3 From f688c8df82b955b50552b3198ddc153a716451c2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 27 Jul 2020 20:36:31 -0500 Subject: Fix User.registration_reason HTML sanitizing issues --- lib/pleroma/emails/admin_email.ex | 3 ++- lib/pleroma/web/twitter_api/twitter_api.ex | 3 +-- test/web/mastodon_api/controllers/account_controller_test.exs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index fae7faf00..c27ad1065 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Emails.AdminEmail do import Swoosh.Email alias Pleroma.Config + alias Pleroma.HTML alias Pleroma.Web.Router.Helpers defp instance_config, do: Config.get(:instance) @@ -86,7 +87,7 @@ def report(to, reporter, account, statuses, comment) do def new_unapproved_registration(to, account) do html_body = """

    New account for review: @#{account.nickname}

    -
    #{account.registration_reason}
    +
    #{HTML.strip_tags(account.registration_reason)}
    Visit AdminFE """ diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 424a705dd..2294d9d0d 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do alias Pleroma.Emails.Mailer alias Pleroma.Emails.UserEmail - alias Pleroma.HTML alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserInviteToken @@ -20,7 +19,7 @@ def register_user(params, opts \\ []) do |> Map.put(:nickname, params[:username]) |> Map.put(:name, Map.get(params, :fullname, params[:username])) |> Map.put(:password_confirmation, params[:password]) - |> Map.put(:registration_reason, HTML.strip_tags(params[:reason])) + |> Map.put(:registration_reason, params[:reason]) if Pleroma.Config.get([:instance, :registrations_open]) do create_user(params, opts) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 1ba5bc964..e6b283aab 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -1017,7 +1017,7 @@ test "Account registration via app with account_approval_required", %{conn: conn password: "PlzDontHackLain", bio: "Test Bio", agreement: true, - reason: "I am a cool dude, bro" + reason: "I'm a cool dude, bro" }) %{ @@ -1035,7 +1035,7 @@ test "Account registration via app with account_approval_required", %{conn: conn assert token_from_db.user.confirmation_pending assert token_from_db.user.approval_pending - assert token_from_db.user.registration_reason == "I am a cool dude, bro" + assert token_from_db.user.registration_reason == "I'm a cool dude, bro" end test "returns error when user already registred", %{conn: conn, valid_params: valid_params} do -- cgit v1.2.3 From 3e5fb90eaaf2546c591625ef8577b05f547e4506 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 28 Jul 2020 07:00:02 +0300 Subject: locked earmark version --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index da0e88287..a14b0c51a 100644 --- a/mix.exs +++ b/mix.exs @@ -145,7 +145,7 @@ defp deps do {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, - {:earmark, "~> 1.3"}, + {:earmark, "1.4.3"}, {:bbcode_pleroma, "~> 0.2.0"}, {:ex_machina, "~> 2.3", only: :test}, {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, -- cgit v1.2.3 From 14c28dcbd1b534c2749401c148dd973181a00fec Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Jul 2020 15:44:47 +0200 Subject: InstanceStatic: Refactor. --- lib/pleroma/plugs/instance_static.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/pleroma/plugs/instance_static.ex b/lib/pleroma/plugs/instance_static.ex index 7516f75c3..18255eac3 100644 --- a/lib/pleroma/plugs/instance_static.ex +++ b/lib/pleroma/plugs/instance_static.ex @@ -26,18 +26,14 @@ def file_path(path) do def init(opts) do opts |> Keyword.put(:from, "__unconfigured_instance_static_plug") - |> Keyword.put(:at, "/__unconfigured_instance_static_plug") |> Plug.Static.init() end for only <- Pleroma.Constants.static_only_files() do - at = Plug.Router.Utils.split("/") - def call(%{request_path: "/" <> unquote(only) <> _} = conn, opts) do call_static( conn, opts, - unquote(at), Pleroma.Config.get([:instance, :static_dir], "instance/static") ) end @@ -47,11 +43,10 @@ def call(conn, _) do conn end - defp call_static(conn, opts, at, from) do + defp call_static(conn, opts, from) do opts = opts |> Map.put(:from, from) - |> Map.put(:at, at) Plug.Static.call(conn, opts) end -- cgit v1.2.3 From ad5c42628ab36eb506ee20d0458c5cfd5bbe79ab Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Jul 2020 17:35:16 +0200 Subject: FrontendStatic: Add plug to serve frontends based on configuration. --- lib/pleroma/plugs/frontend_static.ex | 54 ++++++++++++++++++++++++++++++++++++ lib/pleroma/plugs/instance_static.ex | 8 +++--- lib/pleroma/web/endpoint.ex | 11 ++++++++ test/plugs/frontend_static_test.exs | 30 ++++++++++++++++++++ test/plugs/instance_static_test.exs | 24 +++++++++++++++- 5 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 lib/pleroma/plugs/frontend_static.ex create mode 100644 test/plugs/frontend_static_test.exs diff --git a/lib/pleroma/plugs/frontend_static.ex b/lib/pleroma/plugs/frontend_static.ex new file mode 100644 index 000000000..f549ca75f --- /dev/null +++ b/lib/pleroma/plugs/frontend_static.ex @@ -0,0 +1,54 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Plugs.FrontendStatic do + require Pleroma.Constants + + @moduledoc """ + This is a shim to call `Plug.Static` but with runtime `from` configuration`. It dispatches to the different frontends. + """ + @behaviour Plug + + def file_path(path, frontend_type \\ :primary) do + if configuration = Pleroma.Config.get([:frontends, frontend_type]) do + instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static") + + Path.join([ + instance_static_path, + "frontends", + configuration["name"], + configuration["ref"], + path + ]) + else + nil + end + end + + def init(opts) do + opts + |> Keyword.put(:from, "__unconfigured_frontend_static_plug") + |> Plug.Static.init() + end + + def call(conn, opts) do + frontend_type = Map.get(opts, :frontend_type, :primary) + path = file_path("", frontend_type) + + if path do + conn + |> call_static(opts, path) + else + conn + end + end + + defp call_static(conn, opts, from) do + opts = + opts + |> Map.put(:from, from) + + Plug.Static.call(conn, opts) + end +end diff --git a/lib/pleroma/plugs/instance_static.ex b/lib/pleroma/plugs/instance_static.ex index 18255eac3..0fb57e422 100644 --- a/lib/pleroma/plugs/instance_static.ex +++ b/lib/pleroma/plugs/instance_static.ex @@ -16,11 +16,11 @@ def file_path(path) do instance_path = Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path) - if File.exists?(instance_path) do - instance_path - else + frontend_path = Pleroma.Plugs.FrontendStatic.file_path(path, :primary) + + (File.exists?(instance_path) && instance_path) || + (frontend_path && File.exists?(frontend_path) && frontend_path) || Path.join(Application.app_dir(:pleroma, "priv/static/"), path) - end end def init(opts) do diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 226d42c2c..527fb288d 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -28,6 +28,17 @@ defmodule Pleroma.Web.Endpoint do } ) + # Careful! No `only` restriction here, as we don't know what frontends contain. + plug(Pleroma.Plugs.FrontendStatic, + at: "/", + frontend_type: :primary, + gzip: true, + cache_control_for_etags: @static_cache_control, + headers: %{ + "cache-control" => @static_cache_control + } + ) + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phoenix.digest diff --git a/test/plugs/frontend_static_test.exs b/test/plugs/frontend_static_test.exs new file mode 100644 index 000000000..d11d91d78 --- /dev/null +++ b/test/plugs/frontend_static_test.exs @@ -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.FrontendStaticPlugTest do + use Pleroma.Web.ConnCase + + @dir "test/tmp/instance_static" + + setup do + File.mkdir_p!(@dir) + on_exit(fn -> File.rm_rf(@dir) end) + end + + setup do: clear_config([:instance, :static_dir], @dir) + + test "overrides existing static files", %{conn: conn} do + name = "pelmora" + ref = "uguu" + + clear_config([:frontends, :primary], %{"name" => name, "ref" => ref}) + path = "#{@dir}/frontends/#{name}/#{ref}" + + File.mkdir_p!(path) + File.write!("#{path}/index.html", "from frontend plug") + + index = get(conn, "/") + assert html_response(index, 200) == "from frontend plug" + end +end diff --git a/test/plugs/instance_static_test.exs b/test/plugs/instance_static_test.exs index be2613ad0..d42ba817e 100644 --- a/test/plugs/instance_static_test.exs +++ b/test/plugs/instance_static_test.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.RuntimeStaticPlugTest do +defmodule Pleroma.Web.InstanceStaticPlugTest do use Pleroma.Web.ConnCase @dir "test/tmp/instance_static" @@ -24,6 +24,28 @@ test "overrides index" do assert html_response(index, 200) == "hello world" end + test "also overrides frontend files", %{conn: conn} do + name = "pelmora" + ref = "uguu" + + clear_config([:frontends, :primary], %{"name" => name, "ref" => ref}) + + bundled_index = get(conn, "/") + refute html_response(bundled_index, 200) == "from frontend plug" + + path = "#{@dir}/frontends/#{name}/#{ref}" + File.mkdir_p!(path) + File.write!("#{path}/index.html", "from frontend plug") + + index = get(conn, "/") + assert html_response(index, 200) == "from frontend plug" + + File.write!(@dir <> "/index.html", "from instance static") + + index = get(conn, "/") + assert html_response(index, 200) == "from instance static" + end + test "overrides any file in static/static" do bundled_index = get(build_conn(), "/static/terms-of-service.html") -- cgit v1.2.3 From d64c9763906f84c9cb8bcc778c790cfb5b78708b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 28 Jul 2020 17:40:21 +0200 Subject: Add description for configuration. --- config/description.exs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/config/description.exs b/config/description.exs index c303fc878..91261c1e1 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3481,5 +3481,30 @@ suggestions: ["s3.eu-central-1.amazonaws.com"] } ] + }, + %{ + group: :pleroma, + key: :frontends, + type: :group, + description: "Installed frontends management", + children: [ + %{ + key: :primary, + type: :map, + description: "Primary frontend, the one that is served for all pages by default", + children: [ + %{ + key: "name", + type: :string, + description: "Name of the installed primary frontend" + }, + %{ + key: "ref", + type: :string, + description: "reference of the installed primary frontend to be used" + } + ] + } + ] } ] -- cgit v1.2.3 From 08732e8a0335ae44c866c2dd63927c65158b27c9 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Jul 2020 17:46:37 +0200 Subject: Docs: Add frontend info to cheat sheet. --- config/config.exs | 9 +++++++++ docs/configuration/cheatsheet.md | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/config/config.exs b/config/config.exs index acf3b5c96..09dd9e786 100644 --- a/config/config.exs +++ b/config/config.exs @@ -645,6 +645,15 @@ config :pleroma, :static_fe, enabled: false +# Example of frontend configuration +# This example will make us serve the primary frontend from the +# `/frontends/pleroma/develop` folder in your instance static directory. +# +# With no frontend configuration, the bundled files from the `static` directory will +# be used. +# +# config :pleroma, :frontends, primary: %{"name" => "pleroma", "ref" => "develop"} + config :pleroma, :web_cache_ttl, activity_pub: nil, activity_pub_question: 30_000 diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 5e50f1ba9..5dc895c0a 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -1046,3 +1046,23 @@ Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practi Control favicons for instances. * `enabled`: Allow/disallow displaying and getting instances favicons + +## Frontend management + +Frontends in Pleroma are swappable - you can specify which one to use here. + +For now, you can set a frontend with the key `primary` and the options of `name` and `ref` set. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref. + +If you don't set anything here, the bundled frontend will be used. + +Example: + +``` +config :pleroma, :frontends, + primary: %{ + "name" => "pleroma", + "ref" => "stable" + } +``` + +This would serve frontend from the the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. -- cgit v1.2.3 From 393128fb025d4e6f9172315491062f7e91e62bdb Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Jul 2020 17:48:35 +0200 Subject: Cheatsheet: Add more info. --- 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 5dc895c0a..aebbcd50e 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -1065,4 +1065,4 @@ config :pleroma, :frontends, } ``` -This would serve frontend from the the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. +This would serve the frontend from the the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. You can choose the name and ref any way you like, but they will be used by mix tasks to automate installation in the future, the name referring to the project and the ref referring to a commit. -- cgit v1.2.3 From 81350faa8eea925a323144dcc0cfe38970132acf Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 28 Jul 2020 17:50:32 +0200 Subject: Cheatsheet: Add even more info. --- docs/configuration/cheatsheet.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index aebbcd50e..2a25a024a 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -1051,7 +1051,9 @@ Control favicons for instances. Frontends in Pleroma are swappable - you can specify which one to use here. -For now, you can set a frontend with the key `primary` and the options of `name` and `ref` set. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref. +For now, you can set a frontend with the key `primary` and the options of `name` and `ref`. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref. + +The key `primary` refers to the frontend that will be served by default for general requests. In the future, other frontends like the admin frontend will also be configurable here. If you don't set anything here, the bundled frontend will be used. -- cgit v1.2.3 From bee29f6610695b9f059bb2e0f3424b2345388aae Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 28 Jul 2020 12:10:04 -0500 Subject: Clarify location of frontends directory --- config/config.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 09dd9e786..48fe7c669 100644 --- a/config/config.exs +++ b/config/config.exs @@ -647,7 +647,8 @@ # Example of frontend configuration # This example will make us serve the primary frontend from the -# `/frontends/pleroma/develop` folder in your instance static directory. +# frontends directory within your `:pleroma, :instance, static_dir`. +# e.g., instance/static/frontends/pleroma/develop/ # # With no frontend configuration, the bundled files from the `static` directory will # be used. -- cgit v1.2.3 From 4ce6179dc7843d99823cf41be86574973b66200f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 28 Jul 2020 20:49:48 +0300 Subject: gun ConnectionPool: replace casts with calls The slowdown from this is most likely immesurable, however it eliminates possible false positives when tracking dead clients. --- lib/pleroma/gun/connection_pool.ex | 4 ++-- lib/pleroma/gun/connection_pool/worker.ex | 40 +++++++++++++++---------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index 8b41a668c..c6894be53 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -19,7 +19,7 @@ def get_conn(uri, opts) do get_gun_pid_from_worker(worker_pid, true) [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> - GenServer.cast(worker_pid, {:add_client, self(), false}) + GenServer.call(worker_pid, :add_client) {:ok, gun_pid} [] -> @@ -70,7 +70,7 @@ def release_conn(conn_pid) do case query_result do [worker_pid] -> - GenServer.cast(worker_pid, {:remove_client, self()}) + GenServer.call(worker_pid, :remove_client) [] -> :ok diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index f33447cb6..a61892c60 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -36,7 +36,16 @@ def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do end @impl true - def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) do + def handle_cast({:add_client, client_pid, send}, state) do + case handle_call(:add_client, {client_pid, nil}, state) do + {:reply, conn_pid, state, :hibernate} -> + if send, do: send(client_pid, {:conn_pid, conn_pid}) + {:noreply, state, :hibernate} + end + end + + @impl true + def handle_call(:add_client, {client_pid, _}, %{key: key} = state) do time = :erlang.monotonic_time(:millisecond) {{conn_pid, _, _, _}, _} = @@ -44,8 +53,6 @@ def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) d {conn_pid, [client_pid | used_by], crf(time - last_reference, crf), time} end) - if send_pid_back, do: send(client_pid, {:conn_pid, conn_pid}) - state = if state.timer != nil do Process.cancel_timer(state[:timer]) @@ -57,11 +64,11 @@ def handle_cast({:add_client, client_pid, send_pid_back}, %{key: key} = state) d ref = Process.monitor(client_pid) state = put_in(state.client_monitors[client_pid], ref) - {:noreply, state, :hibernate} + {:reply, conn_pid, state, :hibernate} end @impl true - def handle_cast({:remove_client, client_pid}, %{key: key} = state) do + def handle_call(:remove_client, {client_pid, _}, %{key: key} = state) do {{_conn_pid, used_by, _crf, _last_reference}, _} = Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> {conn_pid, List.delete(used_by, client_pid), crf, last_reference} @@ -78,7 +85,7 @@ def handle_cast({:remove_client, client_pid}, %{key: key} = state) do nil end - {:noreply, %{state | timer: timer}, :hibernate} + {:reply, :ok, %{state | timer: timer}, :hibernate} end @impl true @@ -102,22 +109,13 @@ def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams} = down_me @impl true def handle_info({:DOWN, _ref, :process, pid, reason}, state) do - # Sometimes the client is dead before we demonitor it in :remove_client, so the message - # arrives anyway - - case state.client_monitors[pid] do - nil -> - {:noreply, state, :hibernate} + :telemetry.execute( + [:pleroma, :connection_pool, :client_death], + %{client_pid: pid, reason: reason}, + %{key: state.key} + ) - _ref -> - :telemetry.execute( - [:pleroma, :connection_pool, :client_death], - %{client_pid: pid, reason: reason}, - %{key: state.key} - ) - - handle_cast({:remove_client, pid}, state) - end + handle_cast({:remove_client, pid, false}, state) end # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 -- cgit v1.2.3 From 3b7c454418700ca36c0a71272f913ea8c6e464e9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jul 2020 14:49:49 -0500 Subject: Let favourites and emoji reactions optionally be hidden --- config/config.exs | 3 ++- config/description.exs | 5 +++++ docs/configuration/cheatsheet.md | 1 + .../web/mastodon_api/controllers/status_controller.ex | 3 ++- .../controllers/emoji_reaction_controller.ex | 3 ++- .../controllers/status_controller_test.exs | 15 +++++++++++++++ .../controllers/emoji_reaction_controller_test.exs | 19 +++++++++++++++++++ 7 files changed, 46 insertions(+), 3 deletions(-) diff --git a/config/config.exs b/config/config.exs index 48fe7c669..903a92cca 100644 --- a/config/config.exs +++ b/config/config.exs @@ -250,7 +250,8 @@ number: 5, length: 16 ] - ] + ], + show_reactions: true config :pleroma, :welcome, direct_message: [ diff --git a/config/description.exs b/config/description.exs index 91261c1e1..9dc87824b 100644 --- a/config/description.exs +++ b/config/description.exs @@ -942,6 +942,11 @@ description: "The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.", suggestions: ["/instance/thumbnail.jpeg"] + }, + %{ + key: :show_reactions, + type: :boolean, + description: "Let favourites and emoji reactions be viewed through the API." } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2a25a024a..2971ea324 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -60,6 +60,7 @@ To add configuration to your config file, you can copy it from the base config. * `account_field_value_length`: An account field value maximum length (default: `2048`). * `external_user_synchronization`: Enabling following/followers counters synchronization for external users. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. +* `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`). ## Welcome * `direct_message`: - welcome message sent as a direct message. diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 9bb2ef117..ecfa38489 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -314,7 +314,8 @@ def card(%{assigns: %{user: user}} = conn, %{id: status_id}) do @doc "GET /api/v1/statuses/:id/favourited_by" def favourited_by(%{assigns: %{user: user}} = conn, %{id: id}) do - with %Activity{} = activity <- Activity.get_by_id_with_object(id), + with true <- Pleroma.Config.get([:instance, :show_reactions]), + %Activity{} = activity <- Activity.get_by_id_with_object(id), {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)}, %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do users = diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex index 19dcffdf3..7f9254c13 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -25,7 +25,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do - with %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), + with true <- Pleroma.Config.get([:instance, :show_reactions]), + %Activity{} = activity <- Activity.get_by_id_with_object(activity_id), %Object{data: %{"reactions" => reactions}} when is_list(reactions) <- Object.normalize(activity) do reactions = filter(reactions, params) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index d34f300da..e3f127163 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -21,6 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:instance, :federating]) setup do: clear_config([:instance, :allow_relay]) + setup do: clear_config([:instance, :show_reactions]) setup do: clear_config([:rich_media, :enabled]) setup do: clear_config([:mrf, :policies]) setup do: clear_config([:mrf_keyword, :reject]) @@ -1432,6 +1433,20 @@ test "requires authentication for private posts", %{user: user} do [%{"id" => id}] = response assert id == other_user.id end + + test "returns empty array when :show_reactions is disabled", %{conn: conn, activity: activity} do + Pleroma.Config.put([:instance, :show_reactions], false) + + other_user = insert(:user) + {:ok, _} = CommonAPI.favorite(other_user, activity.id) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/favourited_by") + |> json_response_and_validate_schema(:ok) + + assert Enum.empty?(response) + end end describe "GET /api/v1/statuses/:id/reblogged_by" do diff --git a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index e1bb5ebfe..8af2ee03f 100644 --- a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -13,6 +13,8 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do import Pleroma.Factory + setup do: clear_config([:instance, :show_reactions]) + test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -106,6 +108,23 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do result end + test "GET /api/v1/pleroma/statuses/:id/reactions with :show_reactions disabled", %{conn: conn} do + Pleroma.Config.put([:instance, :show_reactions], false) + + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"}) + {:ok, _} = CommonAPI.react_with_emoji(activity.id, other_user, "🎅") + + result = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/reactions") + |> json_response_and_validate_schema(200) + + assert result == [] + end + test "GET /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) -- cgit v1.2.3 From dab1d8c98efd462ecb9aac47f7c54a5e3e015e27 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 28 Jul 2020 23:48:41 +0300 Subject: gun ConnectionPool: Re-add a missing cast for remove_client --- lib/pleroma/gun/connection_pool.ex | 2 +- lib/pleroma/gun/connection_pool/worker.ex | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index c6894be53..49e9885bb 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -45,7 +45,7 @@ defp get_gun_pid_from_worker(worker_pid, register) do # so instead we use cast + monitor ref = Process.monitor(worker_pid) - if register, do: GenServer.cast(worker_pid, {:add_client, self(), true}) + if register, do: GenServer.cast(worker_pid, {:add_client, self()}) receive do {:conn_pid, pid} -> diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index a61892c60..fec9d0efa 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -36,10 +36,18 @@ def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do end @impl true - def handle_cast({:add_client, client_pid, send}, state) do + def handle_cast({:add_client, client_pid}, state) do case handle_call(:add_client, {client_pid, nil}, state) do {:reply, conn_pid, state, :hibernate} -> - if send, do: send(client_pid, {:conn_pid, conn_pid}) + send(client_pid, {:conn_pid, conn_pid}) + {:noreply, state, :hibernate} + end + end + + @impl true + def handle_cast({:remove_client, client_pid}, state) do + case handle_call(:remove_client, {client_pid, nil}, state) do + {:reply, _, state, :hibernate} -> {:noreply, state, :hibernate} end end @@ -115,7 +123,7 @@ def handle_info({:DOWN, _ref, :process, pid, reason}, state) do %{key: state.key} ) - handle_cast({:remove_client, pid, false}, state) + handle_cast({:remove_client, pid}, state) end # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 -- cgit v1.2.3 From 3c90f7f7156889a1f74950ab976819faa281df43 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jul 2020 18:55:29 -0500 Subject: SimpleMRF: Let instances be silenced --- config/description.exs | 6 +++ docs/configuration/cheatsheet.md | 1 + lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 28 ++++++++++++++ test/web/activity_pub/mrf/simple_policy_test.exs | 47 +++++++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/config/description.exs b/config/description.exs index 91261c1e1..9ffe6f93d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1524,6 +1524,12 @@ description: "List of instances to only accept activities from (except deletes)", suggestions: ["example.com", "*.example.com"] }, + %{ + key: :silence, + type: {:list, :string}, + description: "Force posts from the given instances to be visible by followers only", + suggestions: ["example.com", "*.example.com"] + }, %{ key: :report_removal, type: {:list, :string}, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2a25a024a..9a7f4f369 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -122,6 +122,7 @@ To add configuration to your config file, you can copy it from the base config. * `federated_timeline_removal`: List of instances to remove from Federated (aka The Whole Known Network) Timeline. * `reject`: List of instances to reject any activities from. * `accept`: List of instances to accept any activities from. +* `silence`: List of instances to force posts as followers-only. * `report_removal`: List of instances to reject reports from. * `avatar_removal`: List of instances to strip avatars from. * `banner_removal`: List of instances to strip banners from. diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index b77b8c7b4..e168a943e 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do @behaviour Pleroma.Web.ActivityPub.MRF alias Pleroma.Config + alias Pleroma.FollowingRelationship alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -108,6 +109,32 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do {:ok, object} end + defp check_silence(%{host: actor_host} = _actor_info, object) do + silence = + Config.get([:mrf_simple, :silence]) + |> MRF.subdomains_regex() + + object = + with true <- MRF.subdomain_match?(silence, actor_host), + user <- User.get_cached_by_ap_id(object["actor"]) do + to = + FollowingRelationship.followers_ap_ids(user, Map.get(object, "to", [])) ++ + [user.follower_address] + + cc = FollowingRelationship.followers_ap_ids(user, Map.get(object, "cc", [])) + + object + |> Map.put("to", to) + |> Map.put("cc", cc) + else + _ -> object + end + + {:ok, object} + end + + defp check_silence(_actor_info, object), do: {:ok, object} + defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do report_removal = Config.get([:mrf_simple, :report_removal]) @@ -174,6 +201,7 @@ def filter(%{"actor" => actor} = object) do {:ok, object} <- check_media_removal(actor_info, object), {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), + {:ok, object} <- check_silence(actor_info, object), {:ok, object} <- check_report_removal(actor_info, object) do {:ok, object} else diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index e842d8d8d..510a31d80 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do import Pleroma.Factory alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF.SimplePolicy + alias Pleroma.Web.CommonAPI setup do: clear_config(:mrf_simple, @@ -15,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do federated_timeline_removal: [], report_removal: [], reject: [], + silence: [], accept: [], avatar_removal: [], banner_removal: [], @@ -261,6 +263,51 @@ test "actor has a matching host" do end end + describe "when :silence" do + test "is empty" do + Config.put([:mrf_simple, :silence], []) + {_, ftl_message} = build_ftl_actor_and_message() + local_message = build_local_message() + + assert SimplePolicy.filter(ftl_message) == {:ok, ftl_message} + assert SimplePolicy.filter(local_message) == {:ok, local_message} + end + + test "has a matching host" do + actor = insert(:user) + following_user = insert(:user) + non_following_user = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(following_user, actor) + + activity = %{ + "actor" => actor.ap_id, + "to" => [ + "https://www.w3.org/ns/activitystreams#Public", + following_user.ap_id, + non_following_user.ap_id + ], + "cc" => [actor.follower_address, "http://foo.bar/qux"] + } + + actor_domain = + activity + |> Map.fetch!("actor") + |> URI.parse() + |> Map.fetch!(:host) + + Config.put([:mrf_simple, :silence], [actor_domain]) + + assert {:ok, new_activity} = SimplePolicy.filter(activity) + assert actor.follower_address in new_activity["to"] + assert following_user.ap_id in new_activity["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["cc"] + refute non_following_user.ap_id in new_activity["to"] + refute non_following_user.ap_id in new_activity["cc"] + end + end + describe "when :accept" do test "is empty" do Config.put([:mrf_simple, :accept], []) -- cgit v1.2.3 From 2a99e7df8e3c5c5c6cdf15bff56d0258c9a5287e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jul 2020 20:17:18 -0500 Subject: SimpleMRF silence: optimize, work okay with nil values in addressing --- lib/pleroma/following_relationship.ex | 6 +++++- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 13 ++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index c2020d30a..83b366dd4 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -95,7 +95,11 @@ def followers_query(%User{} = user) do |> where([r], r.state == ^:follow_accept) end - def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do + def followers_ap_ids(user, from_ap_ids \\ nil) + + def followers_ap_ids(_, []), do: [] + + def followers_ap_ids(%User{} = user, from_ap_ids) do query = user |> followers_query() diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index e168a943e..4dce22cfa 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -117,14 +117,15 @@ defp check_silence(%{host: actor_host} = _actor_info, object) do object = with true <- MRF.subdomain_match?(silence, actor_host), user <- User.get_cached_by_ap_id(object["actor"]) do - to = - FollowingRelationship.followers_ap_ids(user, Map.get(object, "to", [])) ++ - [user.follower_address] + # Don't use Map.get/3 intentionally, these must not be nil + fixed_to = object["to"] || [] + fixed_cc = object["cc"] || [] - cc = FollowingRelationship.followers_ap_ids(user, Map.get(object, "cc", [])) + to = FollowingRelationship.followers_ap_ids(user, fixed_to) + cc = FollowingRelationship.followers_ap_ids(user, fixed_cc) object - |> Map.put("to", to) + |> Map.put("to", [user.follower_address] ++ to) |> Map.put("cc", cc) else _ -> object @@ -133,8 +134,6 @@ defp check_silence(%{host: actor_host} = _actor_info, object) do {:ok, object} end - defp check_silence(_actor_info, object), do: {:ok, object} - defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do report_removal = Config.get([:mrf_simple, :report_removal]) -- cgit v1.2.3 From 15b8446cdb1b5130d5feddc3369dd41417df7eda Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 06:45:08 +0300 Subject: updated dev & test packages --- mix.exs | 18 ++++++++++-------- mix.lock | 17 +++++++++-------- .../admin_api/controllers/report_controller_test.exs | 12 +++++------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/mix.exs b/mix.exs index a14b0c51a..8552035bb 100644 --- a/mix.exs +++ b/mix.exs @@ -147,14 +147,10 @@ defp deps do {:sweet_xml, "~> 0.6.6"}, {:earmark, "1.4.3"}, {:bbcode_pleroma, "~> 0.2.0"}, - {:ex_machina, "~> 2.3", only: :test}, - {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, - {:mock, "~> 0.3.3", only: :test}, {:crypt, git: "https://github.com/msantos/crypt.git", ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, {:cors_plug, "~> 1.5"}, - {:ex_doc, "~> 0.21", only: :dev, runtime: false}, {:web_push_encryption, "~> 0.2.1"}, {:swoosh, git: "https://github.com/swoosh/swoosh.git", @@ -162,7 +158,6 @@ defp deps do override: true}, {:phoenix_swoosh, "~> 0.2"}, {:gen_smtp, "~> 0.13"}, - {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}, {:ex_syslogger, "~> 1.4"}, {:floki, "~> 0.25"}, {:timex, "~> 3.5"}, @@ -186,7 +181,6 @@ defp deps do {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, {:ex_const, "~> 0.2"}, {:plug_static_index_html, "~> 1.0.0"}, - {:excoveralls, "~> 0.12.1", only: :test}, {:flake_id, "~> 0.1.0"}, {:concurrent_limiter, git: "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", @@ -197,11 +191,19 @@ defp deps do {:captcha, git: "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"}, - {:mox, "~> 0.5", only: :test}, {:restarter, path: "./restarter"}, {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", - ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"} + ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"}, + + ## dev & test + {:ex_doc, "~> 0.22", only: :dev, runtime: false}, + {:ex_machina, "~> 2.4", only: :test}, + {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, + {:mock, "~> 0.3.5", only: :test}, + {:excoveralls, "~> 0.13.1", only: :test}, + {:mox, "~> 0.5", only: :test}, + {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock index 80679cded..727ba3ca7 100644 --- a/mix.lock +++ b/mix.lock @@ -19,7 +19,7 @@ "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, - "credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0bbd3222607ccaaac5c0340f7f525c627ae4d7aee6c8c8c108922620c5b6446"}, + "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, "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.git", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, @@ -27,6 +27,7 @@ "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "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.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, @@ -37,10 +38,10 @@ "ex_aws": {:hex, :ex_aws, "2.1.1", "1e4de2106cfbf4e837de41be41cd15813eabc722315e388f0d6bb3732cec47cd", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "06b6fde12b33bb6d65d5d3493e903ba5a56d57a72350c15285a4298338089e10"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, - "ex_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_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, + "ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [: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", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, - "excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"}, + "excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"}, "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"}, @@ -63,18 +64,18 @@ "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "linkify": {:hex, :linkify, "0.2.0", "2518bbbea21d2caa9d372424e1ad845b640c6630e2d016f1bd1f518f9ebcca28", [:mix], [], "hexpm", "b8ca8a68b79e30b7938d6c996085f3db14939f29538a59ca5101988bb7f917f6"}, - "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"}, + "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, - "mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"}, + "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "2.0.0", "e6ce70d94dd46815ec0882a1ffb7356df9a9d5b8a40a64ce5c2536617a447379", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "cf574813bd048b98a698aa587c21367d2e06842d4e1b1993dcd6a696e9e633bd"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, diff --git a/test/web/admin_api/controllers/report_controller_test.exs b/test/web/admin_api/controllers/report_controller_test.exs index f30dc8956..57946e6bb 100644 --- a/test/web/admin_api/controllers/report_controller_test.exs +++ b/test/web/admin_api/controllers/report_controller_test.exs @@ -204,7 +204,7 @@ test "updates state of multiple reports", %{ test "returns empty response when no reports created", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports") + |> get(report_path(conn, :index)) |> json_response_and_validate_schema(:ok) assert Enum.empty?(response["reports"]) @@ -224,7 +224,7 @@ test "returns reports", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports") + |> get(report_path(conn, :index)) |> json_response_and_validate_schema(:ok) [report] = response["reports"] @@ -256,7 +256,7 @@ test "returns reports with specified state", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports?state=open") + |> get(report_path(conn, :index, %{state: "open"})) |> json_response_and_validate_schema(:ok) assert [open_report] = response["reports"] @@ -268,7 +268,7 @@ test "returns reports with specified state", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/reports?state=closed") + |> get(report_path(conn, :index, %{state: "closed"})) |> json_response_and_validate_schema(:ok) assert [closed_report] = response["reports"] @@ -280,9 +280,7 @@ test "returns reports with specified state", %{conn: conn} do assert %{"total" => 0, "reports" => []} == conn - |> get("/api/pleroma/admin/reports?state=resolved", %{ - "" => "" - }) + |> get(report_path(conn, :index, %{state: "resolved"})) |> json_response_and_validate_schema(:ok) end -- cgit v1.2.3 From ed881247b70457835131fd7d94780eb9b65005b3 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 06:50:00 +0300 Subject: set swoosh version --- mix.exs | 7 ++----- mix.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/mix.exs b/mix.exs index 8552035bb..dc12df873 100644 --- a/mix.exs +++ b/mix.exs @@ -152,11 +152,8 @@ defp deps do ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, {:cors_plug, "~> 1.5"}, {:web_push_encryption, "~> 0.2.1"}, - {:swoosh, - git: "https://github.com/swoosh/swoosh.git", - ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", - override: true}, - {:phoenix_swoosh, "~> 0.2"}, + {:swoosh, "~> 1.0"}, + {:phoenix_swoosh, "~> 0.3"}, {:gen_smtp, "~> 0.13"}, {:ex_syslogger, "~> 1.4"}, {:floki, "~> 0.25"}, diff --git a/mix.lock b/mix.lock index 727ba3ca7..3125ac3ce 100644 --- a/mix.lock +++ b/mix.lock @@ -17,8 +17,8 @@ "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "8eee96c6ba39b9286ec44c51c52d9f2758951365", [ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"]}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, - "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"}, - "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"}, + "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, + "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, "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.git", "f63a705f92c26955977ee62a313012e309a4d77a", [ref: "f63a705f92c26955977ee62a313012e309a4d77a"]}, @@ -81,13 +81,13 @@ "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, - "phoenix": {:hex, :phoenix, "1.4.13", "67271ad69b51f3719354604f4a3f968f83aa61c19199343656c9caee057ff3b8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab765a0feddb81fc62e2116c827b5f068df85159c162bee760745276ad7ddc1b"}, + "phoenix": {:hex, :phoenix, "1.4.17", "1b1bd4cff7cfc87c94deaa7d60dd8c22e04368ab95499483c50640ef3bd838d8", [: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", "3a8e5d7a3d76d452bb5fb86e8b7bd115f737e4f8efe202a463d4aeb4a5809611"}, "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_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, - "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "ebf1bfa7b3c1c850c04929afe02e2e0d7ab135e0706332c865de03e761676b1f"}, - "plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"}, + "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.0", "2acfa0db038a7649e0a4614eee970e6ed9a39d191ccd79a03583b51d0da98165", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "b8bbae4b59a676de6b8bd8675eda37bc8b4424812ae429d6fdcb2b039e00003b"}, + "plug": {:hex, :plug, "1.10.3", "c9cebe917637d8db0e759039cc106adca069874e1a9034fd6e3fdd427fd3c283", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "01f9037a2a1de1d633b5a881101e6a444bcabb1d386ca1e00bb273a1f1d9d939"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, "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"}, @@ -106,7 +106,7 @@ "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, - "swoosh": {:git, "https://github.com/swoosh/swoosh.git", "c96e0ca8a00d8f211ec1f042a4626b09f249caa5", [ref: "c96e0ca8a00d8f211ec1f042a4626b09f249caa5"]}, + "swoosh": {:hex, :swoosh, "1.0.0", "c547cfc83f30e12d5d1fdcb623d7de2c2e29a5becfc68bf8f42ba4d23d2c2756", [: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: true]}, {: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", "b3b08e463f876cb6167f7168e9ad99a069a724e124bcee61847e0e1ed13f4a0d"}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "tesla": {:git, "https://github.com/teamon/tesla.git", "af3707078b10793f6a534938e56b963aff82fe3c", [ref: "af3707078b10793f6a534938e56b963aff82fe3c"]}, -- cgit v1.2.3 From 992a271196a90713859fc5c523724d81102c7f27 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 06:55:44 +0300 Subject: updated the minor version packages --- mix.exs | 12 ++++++------ mix.lock | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mix.exs b/mix.exs index dc12df873..d36741eee 100644 --- a/mix.exs +++ b/mix.exs @@ -114,9 +114,9 @@ defp oauth_deps do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.4.8"}, + {:phoenix, "~> 1.4.17"}, {:tzdata, "~> 1.0.3"}, - {:plug_cowboy, "~> 2.0"}, + {:plug_cowboy, "~> 2.3"}, {:phoenix_pubsub, "~> 1.1"}, {:phoenix_ecto, "~> 4.0"}, {:ecto_enum, "~> 1.4"}, @@ -129,7 +129,7 @@ defp deps do {:trailing_format_plug, "~> 0.0.7"}, {:fast_sanitize, "~> 0.1"}, {:html_entities, "~> 0.5", override: true}, - {:phoenix_html, "~> 2.10"}, + {:phoenix_html, "~> 2.14"}, {:calendar, "~> 0.17.4"}, {:cachex, "~> 3.2"}, {:poison, "~> 3.0", override: true}, @@ -137,7 +137,7 @@ defp deps do {:tesla, github: "teamon/tesla", ref: "af3707078b10793f6a534938e56b963aff82fe3c", override: true}, {:castore, "~> 0.1"}, - {:cowlib, "~> 2.8", override: true}, + {:cowlib, "~> 2.9", override: true}, {:gun, github: "ninenines/gun", ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", override: true}, {:jason, "~> 1.0"}, @@ -156,8 +156,8 @@ defp deps do {:phoenix_swoosh, "~> 0.3"}, {:gen_smtp, "~> 0.13"}, {:ex_syslogger, "~> 1.4"}, - {:floki, "~> 0.25"}, - {:timex, "~> 3.5"}, + {:floki, "~> 0.27"}, + {:timex, "~> 3.6"}, {:ueberauth, "~> 0.4"}, {:linkify, "~> 0.2.0"}, {:http_signatures, diff --git a/mix.lock b/mix.lock index 3125ac3ce..4c04e7ef3 100644 --- a/mix.lock +++ b/mix.lock @@ -10,7 +10,7 @@ "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, "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"}, + "castore": {:hex, :castore, "0.1.6", "2da0dccb3eacb67841d11790598ff03cd5caee861e01fad61dce1376b5da28e6", [:mix], [], "hexpm", "f874c510b720d31dd6334e9ae5c859a06a3c9e67dfe1a195c512e57588556d3f"}, "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, @@ -35,7 +35,7 @@ "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": {:hex, :ex_aws, "2.1.3", "26b6f036f0127548706aade4a509978fc7c26bd5334b004fba9bfe2687a525df", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0bdbe2aed9f326922fc5a6a80417e32f0c895f4b3b2b0b9676ebf23dd16c5da4"}, "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.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, @@ -45,17 +45,17 @@ "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", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, + "floki": {:hex, :floki, "0.27.0", "6b29a14283f1e2e8fad824bc930eaa9477c462022075df6bea8f0ad811c13599", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "583b8c13697c37179f1f82443bcc7ad2f76fbc0bf4c186606eebd658f7f2631b"}, "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"}, + "gettext": {:hex, :gettext, "0.18.0", "406d6b9e0e3278162c2ae1de0a60270452c553536772167e2d701f028116f870", [:mix], [], "hexpm", "c3f850be6367ebe1a08616c2158affe4a23231c70391050bf359d5f92f66a571"}, "gun": {:git, "https://github.com/ninenines/gun.git", "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", [ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327"]}, "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [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]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]}, - "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, + "httpoison": {:hex, :httpoison, "1.7.0", "abba7d086233c2d8574726227b6c2c4f6e53c4deae7fe5f6de531162ce9929a0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "975cc87c845a103d3d1ea1ccfd68a2700c211a434d8428b10c323dc95dc5b980"}, "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, @@ -73,7 +73,7 @@ "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, - "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm", "052346cf322311c49a0f22789f3698eea030eec09b8c47367f0686ef2634ae14"}, + "mox": {:hex, :mox, "0.5.2", "55a0a5ba9ccc671518d068c8dddd20eeb436909ea79d1799e2209df7eaa98b6c", [:mix], [], "hexpm", "df4310628cd628ee181df93f50ddfd07be3e5ecc30232d3b6aadf30bdfe6092b"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, @@ -101,7 +101,7 @@ "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", "72f3840fedd94f06315c523f6cecf5b4827233bed7ae3fe135b2a0ebeab5e196"}, + "recon": {:hex, :recon, "2.5.1", "430ffa60685ac1efdfb1fe4c97b8767c92d0d92e6e7c3e8621559ba77598678a", [:mix, :rebar3], [], "hexpm", "5721c6b6d50122d8f68cccac712caa1231f97894bab779eff5ff0f886cb44648"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, @@ -110,10 +110,10 @@ "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "tesla": {:git, "https://github.com/teamon/tesla.git", "af3707078b10793f6a534938e56b963aff82fe3c", [ref: "af3707078b10793f6a534938e56b963aff82fe3c"]}, - "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"}, + "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [: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", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, "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, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, - "ueberauth": {:hex, :ueberauth, "0.6.2", "25a31111249d60bad8b65438b2306a4dc91f3208faa62f5a8c33e8713989b2e8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "db9fbfb5ac707bc4f85a297758406340bf0358b4af737a88113c1a9eee120ac7"}, + "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "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"}, -- cgit v1.2.3 From 88f57418c8da90956c12f58ada282976084a55d2 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 07:02:36 +0300 Subject: updated `pot` package --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index d36741eee..a31d86b28 100644 --- a/mix.exs +++ b/mix.exs @@ -174,7 +174,7 @@ defp deps do {:quack, "~> 0.1.1"}, {:joken, "~> 2.0"}, {:benchee, "~> 1.0"}, - {:pot, "~> 0.10.2"}, + {:pot, "~> 0.11"}, {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, {:ex_const, "~> 0.2"}, {:plug_static_index_html, "~> 1.0.0"}, diff --git a/mix.lock b/mix.lock index 4c04e7ef3..d23e77cfe 100644 --- a/mix.lock +++ b/mix.lock @@ -93,7 +93,7 @@ "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.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [: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", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"}, - "pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"}, + "pot": {:hex, :pot, "0.11.0", "61bad869a94534739dd4614a25a619bc5c47b9970e9a0ea5bef4628036fc7a16", [:rebar3], [], "hexpm", "57ee6ee6bdeb639661ffafb9acefe3c8f966e45394de6a766813bb9e1be4e54b"}, "prometheus": {:hex, :prometheus, "4.6.0", "20510f381db1ccab818b4cf2fac5fa6ab5cc91bc364a154399901c001465f46f", [:mix, :rebar3], [], "hexpm", "4905fd2992f8038eccd7aa0cd22f40637ed618c0bed1f75c05aacec15b7545de"}, "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"}, -- cgit v1.2.3 From d6e36aaf06e0e80eeb062ce6228a794a585309ba Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 07:13:59 +0300 Subject: set `jason` version --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index a31d86b28..557e0d700 100644 --- a/mix.exs +++ b/mix.exs @@ -140,7 +140,7 @@ defp deps do {:cowlib, "~> 2.9", override: true}, {:gun, github: "ninenines/gun", ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", override: true}, - {:jason, "~> 1.0"}, + {:jason, "~> 1.2"}, {:mogrify, "~> 0.6.1"}, {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, -- cgit v1.2.3 From b4603a9c9cf9d072a3220aed1c843132d642cc1f Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 07:23:06 +0300 Subject: set http_signatures version --- mix.exs | 4 +--- mix.lock | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 557e0d700..ba539e9ed 100644 --- a/mix.exs +++ b/mix.exs @@ -160,9 +160,7 @@ defp deps do {:timex, "~> 3.6"}, {:ueberauth, "~> 0.4"}, {:linkify, "~> 0.2.0"}, - {:http_signatures, - git: "https://git.pleroma.social/pleroma/http_signatures.git", - ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, + {:http_signatures, "~> 0.1.0"}, {:telemetry, "~> 0.3"}, {:poolboy, "~> 1.5"}, {:prometheus, "~> 4.6"}, diff --git a/mix.lock b/mix.lock index d23e77cfe..ebeee05a1 100644 --- a/mix.lock +++ b/mix.lock @@ -54,7 +54,7 @@ "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [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]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "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"]}, + "http_signatures": {:hex, :http_signatures, "0.1.0", "4e4b501a936dbf4cb5222597038a89ea10781776770d2e185849fa829686b34c", [:mix], [], "hexpm", "f8a7b3731e3fd17d38fa6e343fcad7b03d6874a3b0a108c8568a71ed9c2cf824"}, "httpoison": {:hex, :httpoison, "1.7.0", "abba7d086233c2d8574726227b6c2c4f6e53c4deae7fe5f6de531162ce9929a0", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "975cc87c845a103d3d1ea1ccfd68a2700c211a434d8428b10c323dc95dc5b980"}, "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, -- cgit v1.2.3 From cd2423d7f5c082d49fb429708bb8476342b35136 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 29 Jul 2020 09:22:49 +0300 Subject: update mogrify package --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index ba539e9ed..c0a8d0e9f 100644 --- a/mix.exs +++ b/mix.exs @@ -141,7 +141,7 @@ defp deps do {:gun, github: "ninenines/gun", ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", override: true}, {:jason, "~> 1.2"}, - {:mogrify, "~> 0.6.1"}, + {:mogrify, "~> 0.7.4"}, {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, diff --git a/mix.lock b/mix.lock index ebeee05a1..a7cd6060c 100644 --- a/mix.lock +++ b/mix.lock @@ -72,7 +72,7 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, - "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm", "3bc928d817974fa10cc11e6c89b9a9361e37e96dbbf3d868c41094ec05745dcd"}, + "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"}, "mox": {:hex, :mox, "0.5.2", "55a0a5ba9ccc671518d068c8dddd20eeb436909ea79d1799e2209df7eaa98b6c", [:mix], [], "hexpm", "df4310628cd628ee181df93f50ddfd07be3e5ecc30232d3b6aadf30bdfe6092b"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, -- cgit v1.2.3 From edf8b6abfeba487406db756254a00524e3a9dce2 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 10:53:08 +0200 Subject: EnsureRePrepended: Don't break on chat messages. --- lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex | 3 ++- test/web/activity_pub/mrf/ensure_re_prepended_test.exs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex index 2627a0007..3bf70b894 100644 --- a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex +++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex @@ -27,7 +27,8 @@ def filter_by_summary( def filter_by_summary(_in_reply_to, child), do: child - def filter(%{"type" => "Create", "object" => child_object} = object) do + def filter(%{"type" => "Create", "object" => child_object} = object) + when is_map(child_object) do child = child_object["inReplyTo"] |> Object.normalize(child_object["inReplyTo"]) 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 38ddec5bb..9a283f27d 100644 --- a/test/web/activity_pub/mrf/ensure_re_prepended_test.exs +++ b/test/web/activity_pub/mrf/ensure_re_prepended_test.exs @@ -78,5 +78,15 @@ test "it skip if parent and child summary isn't equal" do assert {:ok, res} = EnsureRePrepended.filter(message) assert res == message end + + test "it skips if the object is only a reference" do + message = %{ + "type" => "Create", + "object" => "somereference" + } + + assert {:ok, res} = EnsureRePrepended.filter(message) + assert res == message + end end end -- cgit v1.2.3 From c25c21dd22202fe0fc827ef57e5a69631fa35bf7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 11:47:03 +0200 Subject: AccountController: Don't explicitly ask to keep users unconfirmed. Confirmation is set in User.register_changeset based on the config settings. --- .../mastodon_api/controllers/account_controller.ex | 2 +- test/user_test.exs | 27 ++++++--- .../controllers/account_controller_test.exs | 68 +++++++++++++++++++++- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index fe5d022f5..4c97904b6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -100,7 +100,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do with :ok <- validate_email_param(params), :ok <- TwitterAPI.validate_captcha(app, params), - {:ok, user} <- TwitterAPI.register_user(params, need_confirmation: true), + {:ok, user} <- TwitterAPI.register_user(params), {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do json(conn, OAuthView.render("token.json", %{user: user, token: token})) else diff --git a/test/user_test.exs b/test/user_test.exs index d087e9101..80c0bd79c 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -500,6 +500,24 @@ test "it sets the password_hash and ap_id" do assert changeset.changes.follower_address == "#{changeset.changes.ap_id}/followers" end + + test "it sets the 'accepts_chat_messages' set to true" do + changeset = User.register_changeset(%User{}, @full_user_data) + assert changeset.valid? + + {:ok, user} = Repo.insert(changeset) + + assert user.accepts_chat_messages + end + + test "it creates a confirmed user" do + changeset = User.register_changeset(%User{}, @full_user_data) + assert changeset.valid? + + {:ok, user} = Repo.insert(changeset) + + refute user.confirmation_pending + end end describe "user registration, with :account_activation_required" do @@ -513,15 +531,6 @@ test "it sets the password_hash and ap_id" do } setup do: clear_config([:instance, :account_activation_required], true) - test "it sets the 'accepts_chat_messages' set to true" do - changeset = User.register_changeset(%User{}, @full_user_data) - assert changeset.valid? - - {:ok, user} = Repo.insert(changeset) - - assert user.accepts_chat_messages - end - test "it creates unconfirmed user" do changeset = User.register_changeset(%User{}, @full_user_data) assert changeset.valid? diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index c304487ea..a2332d2af 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -903,9 +903,73 @@ test "blocking / unblocking a user" do [valid_params: valid_params] end - setup do: clear_config([:instance, :account_activation_required]) + test "Account registration via Application, no confirmation required", %{conn: conn} do + clear_config([:instance, :account_activation_required], false) + + conn = + 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" + }) + + assert %{ + "client_id" => client_id, + "client_secret" => client_secret, + "id" => _, + "name" => "client_name", + "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", + "vapid_key" => _, + "website" => nil + } = json_response_and_validate_schema(conn, 200) + + conn = + post(conn, "/oauth/token", %{ + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret + }) + + assert %{"access_token" => token, "refresh_token" => refresh, "scope" => scope} = + json_response(conn, 200) + + assert token + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + assert refresh + assert scope == "read write follow" + + conn = + build_conn() + |> put_req_header("content-type", "multipart/form-data") + |> put_req_header("authorization", "Bearer " <> token) + |> post("/api/v1/accounts", %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + bio: "Test Bio", + agreement: true + }) + + %{ + "access_token" => token, + "created_at" => _created_at, + "scope" => ^scope, + "token_type" => "Bearer" + } = json_response_and_validate_schema(conn, 200) + + token_from_db = Repo.get_by(Token, token: token) + assert token_from_db + token_from_db = Repo.preload(token_from_db, :user) + assert token_from_db.user + refute token_from_db.user.confirmation_pending + end test "Account registration via Application", %{conn: conn} do + clear_config([:instance, :account_activation_required], true) + conn = conn |> put_req_header("content-type", "application/json") @@ -1188,8 +1252,6 @@ test "respects rate limit setting", %{conn: conn} do assert token_from_db token_from_db = Repo.preload(token_from_db, :user) assert token_from_db.user - - assert token_from_db.user.confirmation_pending end conn = -- cgit v1.2.3 From 6a25f72a75f90b29f0a82dd8fcb1bdca25996de7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 13:02:48 +0200 Subject: FrontendStatic: Work correctly for other frontend types. --- lib/pleroma/plugs/frontend_static.ex | 1 + test/plugs/frontend_static_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/pleroma/plugs/frontend_static.ex b/lib/pleroma/plugs/frontend_static.ex index f549ca75f..11a0d5382 100644 --- a/lib/pleroma/plugs/frontend_static.ex +++ b/lib/pleroma/plugs/frontend_static.ex @@ -30,6 +30,7 @@ def init(opts) do opts |> Keyword.put(:from, "__unconfigured_frontend_static_plug") |> Plug.Static.init() + |> Map.put(:frontend_type, opts[:frontend_type]) end def call(conn, opts) do diff --git a/test/plugs/frontend_static_test.exs b/test/plugs/frontend_static_test.exs index d11d91d78..6f4923048 100644 --- a/test/plugs/frontend_static_test.exs +++ b/test/plugs/frontend_static_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.FrontendStaticPlugTest do + alias Pleroma.Plugs.FrontendStatic use Pleroma.Web.ConnCase @dir "test/tmp/instance_static" @@ -14,6 +15,18 @@ defmodule Pleroma.Web.FrontendStaticPlugTest do setup do: clear_config([:instance, :static_dir], @dir) + test "init will give a static plug config + the frontend type" do + opts = + [ + at: "/admin", + frontend_type: :admin + ] + |> FrontendStatic.init() + + assert opts[:at] == ["admin"] + assert opts[:frontend_type] == :admin + end + test "overrides existing static files", %{conn: conn} do name = "pelmora" ref = "uguu" @@ -27,4 +40,18 @@ test "overrides existing static files", %{conn: conn} do index = get(conn, "/") assert html_response(index, 200) == "from frontend plug" end + + test "overrides existing static files for the `pleroma/admin` path", %{conn: conn} do + name = "pelmora" + ref = "uguu" + + clear_config([:frontends, :admin], %{"name" => name, "ref" => ref}) + path = "#{@dir}/frontends/#{name}/#{ref}" + + File.mkdir_p!(path) + File.write!("#{path}/index.html", "from frontend plug") + + index = get(conn, "/pleroma/admin/") + assert html_response(index, 200) == "from frontend plug" + end end -- cgit v1.2.3 From 66974e17a06bc26d7ea0be26bdd77f82b80afdaa Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 13:03:04 +0200 Subject: Endpoint: Serve a dynamically configured admin interface --- lib/pleroma/web/endpoint.ex | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 527fb288d..8b153763d 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -39,6 +39,18 @@ defmodule Pleroma.Web.Endpoint do } ) + plug(Plug.Static.IndexHtml, at: "/pleroma/admin/") + + plug(Pleroma.Plugs.FrontendStatic, + at: "/pleroma/admin", + frontend_type: :admin, + gzip: true, + cache_control_for_etags: @static_cache_control, + headers: %{ + "cache-control" => @static_cache_control + } + ) + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phoenix.digest @@ -56,8 +68,6 @@ defmodule Pleroma.Web.Endpoint do } ) - plug(Plug.Static.IndexHtml, at: "/pleroma/admin/") - plug(Plug.Static, at: "/pleroma/admin/", from: {:pleroma, "priv/static/adminfe/"} -- cgit v1.2.3 From e2f82968e87de20502ed46ad0f0392ae04f89819 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 13:04:29 +0200 Subject: Config: Update frontend config example --- config/config.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 48fe7c669..d9c7969bc 100644 --- a/config/config.exs +++ b/config/config.exs @@ -653,7 +653,9 @@ # With no frontend configuration, the bundled files from the `static` directory will # be used. # -# config :pleroma, :frontends, primary: %{"name" => "pleroma", "ref" => "develop"} +# config :pleroma, :frontends, +# primary: %{"name" => "pleroma", "ref" => "develop"}, +# admin: %{"name" => "admin", "ref" => "stable"} config :pleroma, :web_cache_ttl, activity_pub: nil, -- cgit v1.2.3 From 54afb35685d5fca6056cc3c7f40c946aa02dc9a7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 13:06:51 +0200 Subject: Cheatsheet: Update frontends information. --- docs/configuration/cheatsheet.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 2a25a024a..58bf787c8 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -1051,11 +1051,11 @@ Control favicons for instances. Frontends in Pleroma are swappable - you can specify which one to use here. -For now, you can set a frontend with the key `primary` and the options of `name` and `ref`. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref. +You can set a frontends for the key `primary` and `admin` and the options of `name` and `ref`. This will then make Pleroma serve the frontend from a folder constructed by concatenating the instance static path, `frontends` and the name and ref. -The key `primary` refers to the frontend that will be served by default for general requests. In the future, other frontends like the admin frontend will also be configurable here. +The key `primary` refers to the frontend that will be served by default for general requests. The key `admin` refers to the frontend that will be served at the `/pleroma/admin` path. -If you don't set anything here, the bundled frontend will be used. +If you don't set anything here, the bundled frontends will be used. Example: @@ -1064,6 +1064,10 @@ config :pleroma, :frontends, primary: %{ "name" => "pleroma", "ref" => "stable" + }, + admin: %{ + "name" => "admin", + "ref" => "develop" } ``` -- cgit v1.2.3 From f715bf1915b75cb3c5b24d4661a94885aaa1a0ac Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 13:09:42 +0200 Subject: Changelog: Include frontend information. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f318584..a4881cc82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Frontends: Add configurable frontends for primary and admin fe. - Chats: Added `accepts_chat_messages` field to user, exposed in APIs and federation. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. -- cgit v1.2.3 From 2e27847573e91946e6777f9aa18b9cf9ccaf820d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 29 Jul 2020 14:02:02 +0200 Subject: feed/user_controller: Return 404 when the user is remote --- lib/pleroma/web/feed/user_controller.ex | 3 ++- test/web/feed/user_controller_test.exs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index d56f43818..9cd334a33 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -47,7 +47,7 @@ def feed(conn, %{"nickname" => nickname} = params) do "atom" end - with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do + with {_, %User{local: true} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do activities = %{ type: ["Create"], @@ -71,6 +71,7 @@ def errors(conn, {:error, :not_found}) do render_error(conn, :not_found, "Not found") end + def errors(conn, {:fetch_user, %User{local: false}}), do: errors(conn, {:error, :not_found}) def errors(conn, {:fetch_user, nil}), do: errors(conn, {:error, :not_found}) def errors(conn, _) do diff --git a/test/web/feed/user_controller_test.exs b/test/web/feed/user_controller_test.exs index fa2ed1ea5..0d2a61967 100644 --- a/test/web/feed/user_controller_test.exs +++ b/test/web/feed/user_controller_test.exs @@ -181,6 +181,17 @@ test "returns feed with public and unlisted activities", %{conn: conn} do assert activity_titles == ['public', 'unlisted'] end + + test "returns 404 when the user is remote", %{conn: conn} do + user = insert(:user, local: false) + + {:ok, _} = CommonAPI.post(user, %{status: "test"}) + + assert conn + |> put_req_header("accept", "application/atom+xml") + |> get(user_feed_path(conn, :feed, user.nickname)) + |> response(404) + end end # Note: see ActivityPubControllerTest for JSON format tests -- cgit v1.2.3 From d249f91b3f36e320d135d2f57964f49f55ea61a3 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 29 Jul 2020 16:27:11 +0200 Subject: Descriptions: Update with admin frontend info --- config/description.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/config/description.exs b/config/description.exs index 30a503696..b96fe9705 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3517,6 +3517,23 @@ description: "reference of the installed primary frontend to be used" } ] + }, + %{ + key: :admin, + type: :map, + description: "Admin frontend", + children: [ + %{ + key: "name", + type: :string, + description: "Name of the installed Admin frontend" + }, + %{ + key: "ref", + type: :string, + description: "reference of the installed Admin frontend to be used" + } + ] } ] } -- cgit v1.2.3 From 026a51cb27e250a55a03c509390390e8141dc290 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Jul 2020 12:45:32 -0500 Subject: :show_reactions, add CHANGELOG.md, refactor test --- CHANGELOG.md | 1 + test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5256600..7ce208b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata. - "By approval" registrations mode. - Configuration: Added `:welcome` settings for the welcome message to newly registered users. +- Ability to hide favourites and emoji reactions in the API with `[:instance, :show_reactions]` config.
    API Changes diff --git a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index 8af2ee03f..3deab30d1 100644 --- a/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -13,8 +13,6 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do import Pleroma.Factory - setup do: clear_config([:instance, :show_reactions]) - test "PUT /api/v1/pleroma/statuses/:id/reactions/:emoji", %{conn: conn} do user = insert(:user) other_user = insert(:user) @@ -109,7 +107,7 @@ test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do end test "GET /api/v1/pleroma/statuses/:id/reactions with :show_reactions disabled", %{conn: conn} do - Pleroma.Config.put([:instance, :show_reactions], false) + clear_config([:instance, :show_reactions], false) user = insert(:user) other_user = insert(:user) -- cgit v1.2.3 From 00d090004eefdf6cf2cf644be1d4dcfdd8b0ba35 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Jul 2020 12:50:11 -0500 Subject: :show_reactions, refactor the other test --- test/web/mastodon_api/controllers/status_controller_test.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/web/mastodon_api/controllers/status_controller_test.exs b/test/web/mastodon_api/controllers/status_controller_test.exs index e3f127163..5955d8334 100644 --- a/test/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/web/mastodon_api/controllers/status_controller_test.exs @@ -21,7 +21,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do setup do: clear_config([:instance, :federating]) setup do: clear_config([:instance, :allow_relay]) - setup do: clear_config([:instance, :show_reactions]) setup do: clear_config([:rich_media, :enabled]) setup do: clear_config([:mrf, :policies]) setup do: clear_config([:mrf_keyword, :reject]) @@ -1435,7 +1434,7 @@ test "requires authentication for private posts", %{user: user} do end test "returns empty array when :show_reactions is disabled", %{conn: conn, activity: activity} do - Pleroma.Config.put([:instance, :show_reactions], false) + clear_config([:instance, :show_reactions], false) other_user = insert(:user) {:ok, _} = CommonAPI.favorite(other_user, activity.id) -- cgit v1.2.3 From 93638935d783c092dabac51982426ebd98a21e0e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Jul 2020 12:58:08 -0500 Subject: SimpleMRF: :silence --> :followers_only --- config/description.exs | 2 +- docs/configuration/cheatsheet.md | 2 +- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 10 +++++----- test/web/activity_pub/mrf/simple_policy_test.exs | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/config/description.exs b/config/description.exs index 9ffe6f93d..dc6e2a76e 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1525,7 +1525,7 @@ suggestions: ["example.com", "*.example.com"] }, %{ - key: :silence, + key: :followers_only, type: {:list, :string}, description: "Force posts from the given instances to be visible by followers only", suggestions: ["example.com", "*.example.com"] diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 9a7f4f369..b195b6f17 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -122,7 +122,7 @@ To add configuration to your config file, you can copy it from the base config. * `federated_timeline_removal`: List of instances to remove from Federated (aka The Whole Known Network) Timeline. * `reject`: List of instances to reject any activities from. * `accept`: List of instances to accept any activities from. -* `silence`: List of instances to force posts as followers-only. +* `followers_only`: List of instances to force posts as followers-only. * `report_removal`: List of instances to reject reports from. * `avatar_removal`: List of instances to strip avatars from. * `banner_removal`: List of instances to strip banners from. diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 4dce22cfa..ffaac767e 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -109,13 +109,13 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do {:ok, object} end - defp check_silence(%{host: actor_host} = _actor_info, object) do - silence = - Config.get([:mrf_simple, :silence]) + defp check_followers_only(%{host: actor_host} = _actor_info, object) do + followers_only = + Config.get([:mrf_simple, :followers_only]) |> MRF.subdomains_regex() object = - with true <- MRF.subdomain_match?(silence, actor_host), + with true <- MRF.subdomain_match?(followers_only, actor_host), user <- User.get_cached_by_ap_id(object["actor"]) do # Don't use Map.get/3 intentionally, these must not be nil fixed_to = object["to"] || [] @@ -200,7 +200,7 @@ def filter(%{"actor" => actor} = object) do {:ok, object} <- check_media_removal(actor_info, object), {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), - {:ok, object} <- check_silence(actor_info, object), + {:ok, object} <- check_followers_only(actor_info, object), {:ok, object} <- check_report_removal(actor_info, object) do {:ok, object} else diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index 510a31d80..c0e82731b 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -16,7 +16,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do federated_timeline_removal: [], report_removal: [], reject: [], - silence: [], + followers_only: [], accept: [], avatar_removal: [], banner_removal: [], @@ -263,9 +263,9 @@ test "actor has a matching host" do end end - describe "when :silence" do + describe "when :followers_only" do test "is empty" do - Config.put([:mrf_simple, :silence], []) + Config.put([:mrf_simple, :followers_only], []) {_, ftl_message} = build_ftl_actor_and_message() local_message = build_local_message() @@ -296,7 +296,7 @@ test "has a matching host" do |> URI.parse() |> Map.fetch!(:host) - Config.put([:mrf_simple, :silence], [actor_domain]) + Config.put([:mrf_simple, :followers_only], [actor_domain]) assert {:ok, new_activity} = SimplePolicy.filter(activity) assert actor.follower_address in new_activity["to"] -- cgit v1.2.3 From 33f042780909f3478597a8ecd10a2bf31e99dcc9 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 29 Jul 2020 16:07:22 -0500 Subject: Expose seconds_valid in Pleroma Captcha API endpoint --- CHANGELOG.md | 1 + docs/API/pleroma_api.md | 2 +- lib/pleroma/captcha/kocaptcha.ex | 3 ++- lib/pleroma/captcha/native.ex | 3 ++- test/captcha_test.exs | 6 ++++-- test/support/captcha_mock.ex | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5256600..52f917720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). contents has been renamed to `hide_notification_contents` - Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance - Mastodon API (legacy): Allow query parameters for `/api/v1/domain_blocks`, e.g. `/api/v1/domain_blocks?domain=badposters.zone` +- **Breaking:** Pleroma API: `/api/pleroma/captcha` responses now include `seconds_valid` with an integer value.
    diff --git a/docs/API/pleroma_api.md b/docs/API/pleroma_api.md index 5bd38ad36..b29f4d5a0 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -50,7 +50,7 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi * Authentication: not required * Params: none * Response: Provider specific JSON, the only guaranteed parameter is `type` -* Example response: `{"type": "kocaptcha", "token": "whatever", "url": "https://captcha.kotobank.ch/endpoint"}` +* Example response: `{"type": "kocaptcha", "token": "whatever", "url": "https://captcha.kotobank.ch/endpoint", seconds_valid: 300}` ## `/api/pleroma/delete_account` ### Delete an account diff --git a/lib/pleroma/captcha/kocaptcha.ex b/lib/pleroma/captcha/kocaptcha.ex index 6bc2fa158..337506647 100644 --- a/lib/pleroma/captcha/kocaptcha.ex +++ b/lib/pleroma/captcha/kocaptcha.ex @@ -21,7 +21,8 @@ def new do type: :kocaptcha, token: json_resp["token"], url: endpoint <> json_resp["url"], - answer_data: json_resp["md5"] + answer_data: json_resp["md5"], + seconds_valid: Pleroma.Config.get([Pleroma.Captcha, :seconds_valid]) } end end diff --git a/lib/pleroma/captcha/native.ex b/lib/pleroma/captcha/native.ex index a90631d61..8d604d2b2 100644 --- a/lib/pleroma/captcha/native.ex +++ b/lib/pleroma/captcha/native.ex @@ -17,7 +17,8 @@ def new do type: :native, token: token(), url: "data:image/png;base64," <> Base.encode64(img_binary), - answer_data: answer_data + answer_data: answer_data, + seconds_valid: Pleroma.Config.get([Pleroma.Captcha, :seconds_valid]) } end end diff --git a/test/captcha_test.exs b/test/captcha_test.exs index 1ab9019ab..1b9f4a12f 100644 --- a/test/captcha_test.exs +++ b/test/captcha_test.exs @@ -41,7 +41,8 @@ test "new and validate" do answer_data: answer, token: ^token, url: ^url, - type: :kocaptcha + type: :kocaptcha, + seconds_valid: 300 } = new assert Kocaptcha.validate(token, "7oEy8c", answer) == :ok @@ -56,7 +57,8 @@ test "new and validate" do answer_data: answer, token: token, type: :native, - url: "data:image/png;base64," <> _ + url: "data:image/png;base64," <> _, + seconds_valid: 300 } = new assert is_binary(answer) diff --git a/test/support/captcha_mock.ex b/test/support/captcha_mock.ex index 7b0c1d5af..2ed2ba3b4 100644 --- a/test/support/captcha_mock.ex +++ b/test/support/captcha_mock.ex @@ -16,7 +16,8 @@ def new, type: :mock, token: "afa1815e14e29355e6c8f6b143a39fa2", answer_data: @solution, - url: "https://example.org/captcha.png" + url: "https://example.org/captcha.png", + seconds_valid: 300 } @impl Service -- cgit v1.2.3 From df82839c30b331d2a447301f1d70f3d67583844f Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 30 Jul 2020 08:58:19 +0300 Subject: updated `calendar` package --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index c0a8d0e9f..2e9775e04 100644 --- a/mix.exs +++ b/mix.exs @@ -130,7 +130,7 @@ defp deps do {:fast_sanitize, "~> 0.1"}, {:html_entities, "~> 0.5", override: true}, {:phoenix_html, "~> 2.14"}, - {:calendar, "~> 0.17.4"}, + {:calendar, "~> 1.0"}, {:cachex, "~> 3.2"}, {:poison, "~> 3.0", override: true}, # {:tesla, "~> 1.3", override: true}, diff --git a/mix.lock b/mix.lock index a7cd6060c..8389a324d 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,7 @@ "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, - "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"}, + "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, "castore": {:hex, :castore, "0.1.6", "2da0dccb3eacb67841d11790598ff03cd5caee861e01fad61dce1376b5da28e6", [:mix], [], "hexpm", "f874c510b720d31dd6334e9ae5c859a06a3c9e67dfe1a195c512e57588556d3f"}, "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, -- cgit v1.2.3 From 4f10ef5e46f6ecd780994a10b139acb1351b7225 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 30 Jul 2020 09:08:03 +0300 Subject: set `web_push_encryption` version --- mix.exs | 3 +-- mix.lock | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 2e9775e04..87c5c92af 100644 --- a/mix.exs +++ b/mix.exs @@ -133,7 +133,6 @@ defp deps do {:calendar, "~> 1.0"}, {:cachex, "~> 3.2"}, {:poison, "~> 3.0", override: true}, - # {:tesla, "~> 1.3", override: true}, {:tesla, github: "teamon/tesla", ref: "af3707078b10793f6a534938e56b963aff82fe3c", override: true}, {:castore, "~> 0.1"}, @@ -151,7 +150,7 @@ defp deps do git: "https://github.com/msantos/crypt.git", ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, {:cors_plug, "~> 1.5"}, - {:web_push_encryption, "~> 0.2.1"}, + {:web_push_encryption, "~> 0.3"}, {:swoosh, "~> 1.0"}, {:phoenix_swoosh, "~> 0.3"}, {:gen_smtp, "~> 0.13"}, diff --git a/mix.lock b/mix.lock index 8389a324d..9038f5c90 100644 --- a/mix.lock +++ b/mix.lock @@ -10,7 +10,7 @@ "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, - "castore": {:hex, :castore, "0.1.6", "2da0dccb3eacb67841d11790598ff03cd5caee861e01fad61dce1376b5da28e6", [:mix], [], "hexpm", "f874c510b720d31dd6334e9ae5c859a06a3c9e67dfe1a195c512e57588556d3f"}, + "castore": {:hex, :castore, "0.1.7", "1ca19eee705cde48c9e809e37fdd0730510752cc397745e550f6065a56a701e9", [:mix], [], "hexpm", "a2ae2c13d40e9c308387f1aceb14786dca019ebc2a11484fb2a9f797ea0aa0d8"}, "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, @@ -116,6 +116,6 @@ "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, - "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"}, + "web_push_encryption": {:hex, :web_push_encryption, "0.3.0", "598b5135e696fd1404dc8d0d7c0fa2c027244a4e5d5e5a98ba267f14fdeaabc8", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "f10bdd1afe527ede694749fb77a2f22f146a51b054c7fa541c9fd920fba7c875"}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, } -- cgit v1.2.3 From aac7e0314eee9cb629e7bdc290b32aa0b12100cc Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 30 Jul 2020 09:08:50 +0300 Subject: set `postgrex` version --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 87c5c92af..00fb2eb5e 100644 --- a/mix.exs +++ b/mix.exs @@ -121,7 +121,7 @@ defp deps do {:phoenix_ecto, "~> 4.0"}, {:ecto_enum, "~> 1.4"}, {:ecto_sql, "~> 3.4.4"}, - {:postgrex, ">= 0.13.5"}, + {:postgrex, ">= 0.15.5"}, {:oban, "~> 2.0.0"}, {:gettext, "~> 0.15"}, {:pbkdf2_elixir, "~> 1.0"}, -- cgit v1.2.3 From b261135683483d7650e8f30fdbd26491dd94b7c4 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 30 Jul 2020 09:12:42 +0300 Subject: updated `cors_plug` --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 00fb2eb5e..c26e436c6 100644 --- a/mix.exs +++ b/mix.exs @@ -149,7 +149,7 @@ defp deps do {:crypt, git: "https://github.com/msantos/crypt.git", ref: "f63a705f92c26955977ee62a313012e309a4d77a"}, - {:cors_plug, "~> 1.5"}, + {:cors_plug, "~> 2.0"}, {:web_push_encryption, "~> 0.3"}, {:swoosh, "~> 1.0"}, {:phoenix_swoosh, "~> 0.3"}, diff --git a/mix.lock b/mix.lock index 9038f5c90..17b11cdb2 100644 --- a/mix.lock +++ b/mix.lock @@ -16,7 +16,7 @@ "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "8eee96c6ba39b9286ec44c51c52d9f2758951365", [ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"]}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, - "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9af027d20dc12dd0c4345a6b87247e0c62965871feea0bfecf9764648b02cc69"}, + "cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f0d0e13f71c51fd4ef8b2c7e051388e4dfb267522a83a22392c856de7e46465f"}, "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, -- cgit v1.2.3 From 56171cbde61c24b06623d8fd1a43fd8e02df37fa Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 30 Jul 2020 09:23:35 +0300 Subject: set versions --- mix.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index c26e436c6..860c6aee7 100644 --- a/mix.exs +++ b/mix.exs @@ -123,9 +123,9 @@ defp deps do {:ecto_sql, "~> 3.4.4"}, {:postgrex, ">= 0.15.5"}, {:oban, "~> 2.0.0"}, - {:gettext, "~> 0.15"}, - {:pbkdf2_elixir, "~> 1.0"}, - {:bcrypt_elixir, "~> 2.0"}, + {:gettext, "~> 0.18"}, + {:pbkdf2_elixir, "~> 1.2"}, + {:bcrypt_elixir, "~> 2.2"}, {:trailing_format_plug, "~> 0.0.7"}, {:fast_sanitize, "~> 0.1"}, {:html_entities, "~> 0.5", override: true}, -- cgit v1.2.3 From 20d89472e3b48453b5e2e71cce0f6d97cddbbf53 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 08:00:07 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f917720..d22959392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). contents has been renamed to `hide_notification_contents` - Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance - Mastodon API (legacy): Allow query parameters for `/api/v1/domain_blocks`, e.g. `/api/v1/domain_blocks?domain=badposters.zone` -- **Breaking:** Pleroma API: `/api/pleroma/captcha` responses now include `seconds_valid` with an integer value. +- Pleroma API: `/api/pleroma/captcha` responses now include `seconds_valid` with an integer value.
    -- cgit v1.2.3 From 2e20ceee523084a11c07c5a3a99fa2de3be15e7a Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 14:12:41 +0200 Subject: Mix tasks: Add frontend task to download and install frontends. Co-authored-by: Roman Chvanikov --- lib/mix/tasks/pleroma/frontend.ex | 121 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 lib/mix/tasks/pleroma/frontend.ex diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex new file mode 100644 index 000000000..bd65e9e36 --- /dev/null +++ b/lib/mix/tasks/pleroma/frontend.ex @@ -0,0 +1,121 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Frontend do + use Mix.Task + + import Mix.Pleroma + + @shortdoc "Manages bundled Pleroma frontends" + + # @moduledoc File.read!("docs/administration/CLI_tasks/frontend.md") + + def run(["install", "none" | _args]) do + shell_info("Skipping frontend installation because none was requested") + "none" + end + + def run(["install", frontend | args]) do + log_level = Logger.level() + Logger.configure(level: :warn) + start_pleroma() + + {options, [], []} = + OptionParser.parse( + args, + strict: [ + ref: :string, + static_dir: :string, + build_url: :string + ] + ) + + instance_static_dir = + with nil <- options[:static_dir] do + Pleroma.Config.get!([:instance, :static_dir]) + end + + cmd_frontend_info = %{ + "name" => frontend, + "ref" => options[:ref], + "build_url" => options[:build_url] + } + + config_frontend_info = Pleroma.Config.get([:frontends, :available, frontend], %{}) + + frontend_info = + Map.merge(config_frontend_info, cmd_frontend_info, fn _key, config, cmd -> + # This only overrides things that are actually set + cmd || config + end) + + ref = frontend_info["ref"] + + unless ref do + raise "No ref given or configured" + end + + dest = + Path.join([ + instance_static_dir, + "frontends", + frontend, + ref + ]) + + fe_label = "#{frontend} (#{ref})" + + shell_info("Downloading pre-built bundle for #{fe_label}") + tmp_dir = Path.join(dest, "tmp") + + with {_, :ok} <- {:download, download_build(frontend_info, tmp_dir)}, + shell_info("Installing #{fe_label} to #{dest}"), + :ok <- install_frontend(frontend_info, tmp_dir, dest) do + File.rm_rf!(tmp_dir) + shell_info("Frontend #{fe_label} installed to #{dest}") + + Logger.configure(level: log_level) + else + {:download, _} -> + shell_info("Could not download the frontend") + + _e -> + shell_info("Could not install the frontend") + end + end + + defp download_build(frontend_info, dest) do + url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) + + with {:ok, %{status: 200, body: zip_body}} <- + Pleroma.HTTP.get(url, [], timeout: 120_000, recv_timeout: 120_000), + {:ok, unzipped} <- :zip.unzip(zip_body, [:memory]) do + File.rm_rf!(dest) + File.mkdir_p!(dest) + + Enum.each(unzipped, fn {filename, data} -> + path = filename + + new_file_path = Path.join(dest, path) + + new_file_path + |> Path.dirname() + |> File.mkdir_p!() + + File.write!(new_file_path, data) + end) + + :ok + else + e -> {:error, e} + end + end + + defp install_frontend(frontend_info, source, dest) do + from = frontend_info["build_dir"] || "dist" + File.mkdir_p!(dest) + File.cp_r!(Path.join([source, from]), dest) + :ok + end +end -- cgit v1.2.3 From 4ce4d799fd9fa5ab4fde6c6aa55bf8b516d64c12 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 14:14:58 +0200 Subject: Config: Add frontend information. --- config/config.exs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 0c9685c4c..bf24d1bd9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -657,7 +657,41 @@ # # config :pleroma, :frontends, # primary: %{"name" => "pleroma", "ref" => "develop"}, -# admin: %{"name" => "admin", "ref" => "stable"} +# admin: %{"name" => "admin", "ref" => "stable"}, +# available: %{...} + +config :pleroma, :frontends, + available: %{ + "pleroma" => %{ + "name" => "pleroma", + "git" => "https://git.pleroma.social/pleroma/pleroma-fe", + "build_url" => + "https://git.pleroma.social/pleroma/pleroma-fe/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "develop" + }, + "fedi-fe" => %{ + "name" => "fedi-fe", + "git" => "https://git.pleroma.social/pleroma/fedi-fe", + "build_url" => + "https://git.pleroma.social/pleroma/fedi-fe/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "master" + }, + "admin-fe" => %{ + "name" => "admin-fe", + "git" => "https://git.pleroma.social/pleroma/admin-fe", + "build_url" => + "https://git.pleroma.social/pleroma/admin-fe/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "develop" + }, + "soapbox-fe" => %{ + "name" => "soapbox-fe", + "git" => "https://gitlab.com/soapbox-pub/soapbox-fe", + "build_url" => + "https://gitlab.com/soapbox-pub/soapbox-fe/-/jobs/artifacts/${ref}/download?job=build-production", + "ref" => "v1.0.0", + "build_dir" => "static" + } + } config :pleroma, :web_cache_ttl, activity_pub: nil, -- cgit v1.2.3 From 99bfdffb1dfa283d1d039c8d46b7da095876197d Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 14:17:58 +0200 Subject: Config: Add kenoma as available frontend. --- config/config.exs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/config/config.exs b/config/config.exs index bf24d1bd9..82be82747 100644 --- a/config/config.exs +++ b/config/config.exs @@ -656,14 +656,21 @@ # be used. # # config :pleroma, :frontends, -# primary: %{"name" => "pleroma", "ref" => "develop"}, -# admin: %{"name" => "admin", "ref" => "stable"}, +# primary: %{"name" => "pleroma-fe", "ref" => "develop"}, +# admin: %{"name" => "admin-fe", "ref" => "stable"}, # available: %{...} config :pleroma, :frontends, available: %{ - "pleroma" => %{ - "name" => "pleroma", + "kenoma" => %{ + "name" => "kenoma", + "git" => "https://git.pleroma.social/lambadalambda/kenoma", + "build_url" => + "https://git.pleroma.social/lambadalambda/kenoma/-/jobs/artifacts/${ref}/download?job=build", + "ref" => "master" + }, + "pleroma-fe" => %{ + "name" => "pleroma-fe", "git" => "https://git.pleroma.social/pleroma/pleroma-fe", "build_url" => "https://git.pleroma.social/pleroma/pleroma-fe/-/jobs/artifacts/${ref}/download?job=build", -- cgit v1.2.3 From e2e66e50d3066d48d8ef9200e7d221f5aeec4c44 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 14:29:00 +0200 Subject: SimplePolicyTest: Add test for leaking DMs. --- test/web/activity_pub/mrf/simple_policy_test.exs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index c0e82731b..9a1a7bdc8 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -290,6 +290,15 @@ test "has a matching host" do "cc" => [actor.follower_address, "http://foo.bar/qux"] } + dm_activity = %{ + "actor" => actor.ap_id, + "to" => [ + following_user.ap_id, + non_following_user.ap_id + ], + "cc" => [] + } + actor_domain = activity |> Map.fetch!("actor") @@ -305,6 +314,10 @@ test "has a matching host" do refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["cc"] refute non_following_user.ap_id in new_activity["to"] refute non_following_user.ap_id in new_activity["cc"] + + assert {:ok, new_dm_activity} = SimplePolicy.filter(dm_activity) + assert new_dm_activity["to"] == [following_user.ap_id] + assert new_dm_activity["cc"] == [] end end -- cgit v1.2.3 From 1f24186036010ea588e78f5d73bcddfff27c8f9d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 30 Jul 2020 12:01:46 -0500 Subject: Attempt to fix markdown formatting --- docs/configuration/howto_database_config.md | 45 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md index ded9a2eb3..174b08662 100644 --- a/docs/configuration/howto_database_config.md +++ b/docs/configuration/howto_database_config.md @@ -27,26 +27,27 @@ The configuration of Pleroma has traditionally been managed with a config file, $ ./bin/pleroma_ctl config migrate_to_db ``` - ``` - 10:04:34.155 [debug] QUERY OK source="config" db=1.6ms decode=2.0ms queue=33.5ms idle=0.0ms -SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] -Migrating settings from file: /home/pleroma/config/dev.secret.exs - - 10:04:34.240 [debug] QUERY OK db=4.5ms queue=0.3ms idle=92.2ms -TRUNCATE config; [] - - 10:04:34.244 [debug] QUERY OK db=2.8ms queue=0.3ms idle=97.2ms -ALTER SEQUENCE config_id_seq RESTART; [] - - 10:04:34.256 [debug] QUERY OK source="config" db=0.8ms queue=1.4ms idle=109.8ms -SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 WHERE ((c0."group" = $1) AND (c0."key" = $2)) [":pleroma", ":instance"] + ``` + 10:04:34.155 [debug] QUERY OK source="config" db=1.6ms decode=2.0ms queue=33.5ms idle=0.0ms + SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] + Migrating settings from file: /home/pleroma/config/dev.secret.exs + + 10:04:34.240 [debug] QUERY OK db=4.5ms queue=0.3ms idle=92.2ms + TRUNCATE config; [] - 10:04:34.292 [debug] QUERY OK db=2.6ms queue=1.7ms idle=137.7ms -INSERT INTO "config" ("group","key","value","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [":pleroma", ":instance", <<131, 108, 0, 0, 0, 1, 104, 2, 100, 0, 4, 110, 97, 109, 101, 109, 0, 0, 0, 7, 66, 108, 101, 114, 111, 109, 97, 106>>, ~N[2020-07-12 15:04:34], ~N[2020-07-12 15:04:34]] - Settings for key instance migrated. - Settings for group :pleroma migrated. + 10:04:34.244 [debug] QUERY OK db=2.8ms queue=0.3ms idle=97.2ms + ALTER SEQUENCE config_id_seq RESTART; [] + + 10:04:34.256 [debug] QUERY OK source="config" db=0.8ms queue=1.4ms idle=109.8ms + SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 WHERE ((c0."group" = $1) AND (c0."key" = $2)) [":pleroma", ":instance"] + + 10:04:34.292 [debug] QUERY OK db=2.6ms queue=1.7ms idle=137.7ms + INSERT INTO "config" ("group","key","value","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" [":pleroma", ":instance", <<131, 108, 0, 0, 0, 1, 104, 2, 100, 0, 4, 110, 97, 109, 101, 109, 0, 0, 0, 7, 66, 108, 101, 114, 111, 109, 97, 106>>, ~N[2020-07-12 15:04:34], ~N[2020-07-12 15:04:34]] + Settings for key instance migrated. + Settings for group :pleroma migrated. ``` + 3. It is recommended to backup your config file now. ``` cp config/dev.secret.exs config/dev.secret.exs.orig @@ -110,12 +111,12 @@ config :pleroma, configurable_from_database: true ``` 10:26:30.593 [debug] QUERY OK source="config" db=9.8ms decode=1.2ms queue=26.0ms idle=0.0ms -SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] - + SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] + 10:26:30.659 [debug] QUERY OK source="config" db=1.1ms idle=80.7ms -SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] -Database configuration settings have been saved to config/dev.exported_from_db.secret.exs -``` + SELECT c0."id", c0."key", c0."group", c0."value", c0."inserted_at", c0."updated_at" FROM "config" AS c0 [] + Database configuration settings have been saved to config/dev.exported_from_db.secret.exs + ``` 3. The in-database configuration still exists, but it will not be used if you remove `config :pleroma, configurable_from_database: true` from your config. -- cgit v1.2.3 From cfc6484c40bc407ef6202276999eb53e7609b254 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 30 Jul 2020 12:37:56 -0500 Subject: OTP users need Pleroma running to execute pleroma_ctl, so reorganize instructions. --- docs/configuration/howto_database_config.md | 67 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/docs/configuration/howto_database_config.md b/docs/configuration/howto_database_config.md index ded9a2eb3..0528eabdb 100644 --- a/docs/configuration/howto_database_config.md +++ b/docs/configuration/howto_database_config.md @@ -5,13 +5,7 @@ The configuration of Pleroma has traditionally been managed with a config file, ## Migration to database config -1. Stop your Pleroma instance and edit your Pleroma config to enable database configuration: - - ``` - config :pleroma, configurable_from_database: true - ``` - -2. Run the mix task to migrate to the database. You'll receive some debugging output and a few messages informing you of what happened. +1. Run the mix task to migrate to the database. You'll receive some debugging output and a few messages informing you of what happened. **Source:** @@ -23,6 +17,8 @@ The configuration of Pleroma has traditionally been managed with a config file, **OTP:** + *Note: OTP users need Pleroma to be running for `pleroma_ctl` commands to work* + ``` $ ./bin/pleroma_ctl config migrate_to_db ``` @@ -47,45 +43,50 @@ INSERT INTO "config" ("group","key","value","inserted_at","updated_at") VALUES ( Settings for group :pleroma migrated. ``` -3. It is recommended to backup your config file now. +2. It is recommended to backup your config file now. ``` cp config/dev.secret.exs config/dev.secret.exs.orig ``` -4. Now you can edit your config file and strip it down to the only settings which are not possible to control in the database. e.g., the Postgres and webserver (Endpoint) settings cannot be controlled in the database because the application needs the settings to start up and access the database. - - ⚠️ **THIS IS NOT REQUIRED** - - Any settings in the database will override those in the config file, but you may find it less confusing if the setting is only declared in one place. +3. Edit your Pleroma config to enable database configuration: - A non-exhaustive list of settings that are only possible in the config file include the following: + ``` + config :pleroma, configurable_from_database: true + ``` -* config :pleroma, Pleroma.Web.Endpoint -* config :pleroma, Pleroma.Repo -* config :pleroma, configurable_from_database -* config :pleroma, :database, rum_enabled -* config :pleroma, :connections_pool +4. ⚠️ **THIS IS NOT REQUIRED** ⚠️ -Here is an example of a server config stripped down after migration: + Now you can edit your config file and strip it down to the only settings which are not possible to control in the database. e.g., the Postgres (Repo) and webserver (Endpoint) settings cannot be controlled in the database because the application needs the settings to start up and access the database. -``` -use Mix.Config + Any settings in the database will override those in the config file, but you may find it less confusing if the setting is only declared in one place. -config :pleroma, Pleroma.Web.Endpoint, - url: [host: "cool.pleroma.site", scheme: "https", port: 443] + A non-exhaustive list of settings that are only possible in the config file include the following: + * config :pleroma, Pleroma.Web.Endpoint + * config :pleroma, Pleroma.Repo + * config :pleroma, configurable\_from\_database + * config :pleroma, :database, rum_enabled + * config :pleroma, :connections_pool -config :pleroma, Pleroma.Repo, - adapter: Ecto.Adapters.Postgres, - username: "pleroma", - password: "MySecretPassword", - database: "pleroma_prod", - hostname: "localhost" + Here is an example of a server config stripped down after migration: -config :pleroma, configurable_from_database: true -``` + ``` + use Mix.Config -5. Start your instance back up and you can now access the Settings tab in AdminFE. + config :pleroma, Pleroma.Web.Endpoint, + url: [host: "cool.pleroma.site", scheme: "https", port: 443] + + config :pleroma, Pleroma.Repo, + adapter: Ecto.Adapters.Postgres, + username: "pleroma", + password: "MySecretPassword", + database: "pleroma_prod", + hostname: "localhost" + + config :pleroma, configurable_from_database: true + ``` + +5. Restart instance you can now access the Settings tab in AdminFE. ## Reverting back from database config -- cgit v1.2.3 From 781b270863b7a3dcf49c5b52c73aa60511c91a6c Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 19:57:26 +0200 Subject: ChatMessageReferenceView: Display preview cards. --- .../web/pleroma_api/views/chat/message_reference_view.ex | 9 +++++++-- lib/pleroma/web/rich_media/helpers.ex | 15 ++++++++++++--- .../views/chat/message_reference_view_test.exs | 15 +++++++++++++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex index f2112a86e..d4e08b50d 100644 --- a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -14,7 +14,7 @@ def render( %{ chat_message_reference: %{ id: id, - object: %{data: chat_message}, + object: %{data: chat_message} = object, chat_id: chat_id, unread: unread } @@ -30,7 +30,12 @@ def render( attachment: chat_message["attachment"] && StatusView.render("attachment.json", attachment: chat_message["attachment"]), - unread: unread + unread: unread, + card: + StatusView.render( + "card.json", + Pleroma.Web.RichMedia.Helpers.fetch_data_for_object(object) + ) } end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 747f2dc6b..5c7daf1a5 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -49,11 +49,11 @@ defp get_tld(host) do |> hd end - def fetch_data_for_activity(%Activity{data: %{"type" => "Create"}} = activity) do + def fetch_data_for_object(object) do with true <- Config.get([:rich_media, :enabled]), - %Object{} = object <- Object.normalize(activity), false <- object.data["sensitive"] || false, - {:ok, page_url} <- HTML.extract_first_external_url(object, object.data["content"]), + {:ok, page_url} <- + HTML.extract_first_external_url(object, object.data["content"]), :ok <- validate_page_url(page_url), {:ok, rich_media} <- Parser.parse(page_url) do %{page_url: page_url, rich_media: rich_media} @@ -62,6 +62,15 @@ def fetch_data_for_activity(%Activity{data: %{"type" => "Create"}} = activity) d end end + def fetch_data_for_activity(%Activity{data: %{"type" => "Create"}} = activity) do + with true <- Config.get([:rich_media, :enabled]), + %Object{} = object <- Object.normalize(activity) do + fetch_data_for_object(object) + else + _ -> %{} + end + end + def fetch_data_for_activity(_), do: %{} def perform(:fetch, %Activity{} = activity) do diff --git a/test/web/pleroma_api/views/chat/message_reference_view_test.exs b/test/web/pleroma_api/views/chat/message_reference_view_test.exs index e5b165255..40dbae3cd 100644 --- a/test/web/pleroma_api/views/chat/message_reference_view_test.exs +++ b/test/web/pleroma_api/views/chat/message_reference_view_test.exs @@ -43,7 +43,17 @@ test "it displays a chat message" do assert chat_message[:unread] == false assert match?([%{shortcode: "firefox"}], chat_message[:emojis]) - {:ok, activity} = CommonAPI.post_chat_message(recipient, user, "gkgkgk", media_id: upload.id) + clear_config([:rich_media, :enabled], true) + + Tesla.Mock.mock(fn + %{url: "https://example.com/ogp"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/rich_media/ogp.html")} + end) + + {:ok, activity} = + CommonAPI.post_chat_message(recipient, user, "gkgkgk https://example.com/ogp", + media_id: upload.id + ) object = Object.normalize(activity) @@ -52,10 +62,11 @@ test "it displays a chat message" do chat_message_two = MessageReferenceView.render("show.json", chat_message_reference: cm_ref) assert chat_message_two[:id] == cm_ref.id - assert chat_message_two[:content] == "gkgkgk" + assert chat_message_two[:content] == object.data["content"] assert chat_message_two[:account_id] == recipient.id assert chat_message_two[:chat_id] == chat_message[:chat_id] assert chat_message_two[:attachment] assert chat_message_two[:unread] == true + assert chat_message_two[:card] end end -- cgit v1.2.3 From a3c37379e9d4d41de38c609447c840213e37db84 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 30 Jul 2020 19:57:45 +0200 Subject: ChatMessage schema: Add preview cards. --- lib/pleroma/web/api_spec/schemas/chat_message.ex | 35 +++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/schemas/chat_message.ex b/lib/pleroma/web/api_spec/schemas/chat_message.ex index 3ee85aa76..bbf2a4427 100644 --- a/lib/pleroma/web/api_spec/schemas/chat_message.ex +++ b/lib/pleroma/web/api_spec/schemas/chat_message.ex @@ -19,13 +19,46 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do content: %Schema{type: :string, nullable: true}, created_at: %Schema{type: :string, format: :"date-time"}, emojis: %Schema{type: :array}, - attachment: %Schema{type: :object, nullable: true} + attachment: %Schema{type: :object, nullable: true}, + card: %Schema{ + type: :object, + nullable: true, + description: "Preview card for links included within status content", + required: [:url, :title, :description, :type], + properties: %{ + type: %Schema{ + type: :string, + enum: ["link", "photo", "video", "rich"], + description: "The type of the preview card" + }, + provider_name: %Schema{ + type: :string, + nullable: true, + description: "The provider of the original resource" + }, + provider_url: %Schema{ + type: :string, + format: :uri, + description: "A link to the provider of the original resource" + }, + url: %Schema{type: :string, format: :uri, description: "Location of linked resource"}, + image: %Schema{ + type: :string, + nullable: true, + format: :uri, + description: "Preview thumbnail" + }, + title: %Schema{type: :string, description: "Title of linked resource"}, + description: %Schema{type: :string, description: "Description of preview"} + } + } }, example: %{ "account_id" => "someflakeid", "chat_id" => "1", "content" => "hey you again", "created_at" => "2020-04-21T15:06:45.000Z", + "card" => nil, "emojis" => [ %{ "static_url" => "https://dontbulling.me/emoji/Firefox.gif", -- cgit v1.2.3 From 052833f8eef87d1fcfe7a33f4366f67830882857 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 30 Jul 2020 15:57:41 -0500 Subject: Fix example json response --- 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 b29f4d5a0..4e97d26c0 100644 --- a/docs/API/pleroma_api.md +++ b/docs/API/pleroma_api.md @@ -50,7 +50,7 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi * Authentication: not required * Params: none * Response: Provider specific JSON, the only guaranteed parameter is `type` -* Example response: `{"type": "kocaptcha", "token": "whatever", "url": "https://captcha.kotobank.ch/endpoint", seconds_valid: 300}` +* Example response: `{"type": "kocaptcha", "token": "whatever", "url": "https://captcha.kotobank.ch/endpoint", "seconds_valid": 300}` ## `/api/pleroma/delete_account` ### Delete an account -- cgit v1.2.3 From 1dd162a5f75e6c2ef1813cd477e6d938127220d9 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 31 Jul 2020 09:57:30 +0200 Subject: SimplePolicy: Fix problem with DM leaks. --- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 8 ++++++-- test/web/activity_pub/mrf/simple_policy_test.exs | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index ffaac767e..bb193475a 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -109,6 +109,10 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do {:ok, object} end + defp intersection(list1, list2) do + list1 -- list1 -- list2 + end + defp check_followers_only(%{host: actor_host} = _actor_info, object) do followers_only = Config.get([:mrf_simple, :followers_only]) @@ -125,8 +129,8 @@ defp check_followers_only(%{host: actor_host} = _actor_info, object) do cc = FollowingRelationship.followers_ap_ids(user, fixed_cc) object - |> Map.put("to", [user.follower_address] ++ to) - |> Map.put("cc", cc) + |> Map.put("to", intersection([user.follower_address | to], fixed_to)) + |> Map.put("cc", intersection([user.follower_address | cc], fixed_cc)) else _ -> object end diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index 9a1a7bdc8..d7dde62c4 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -308,7 +308,7 @@ test "has a matching host" do Config.put([:mrf_simple, :followers_only], [actor_domain]) assert {:ok, new_activity} = SimplePolicy.filter(activity) - assert actor.follower_address in new_activity["to"] + assert actor.follower_address in new_activity["cc"] assert following_user.ap_id in new_activity["to"] refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["to"] refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["cc"] -- cgit v1.2.3 From 7bcd2e948ea5cc59ad0a40e2840be86650f0995e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 31 Jul 2020 10:50:45 +0200 Subject: Config: Default to Hackney again Gun is still acting up. --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 857e0afbb..d31208c25 100644 --- a/config/config.exs +++ b/config/config.exs @@ -172,7 +172,7 @@ "application/ld+json" => ["activity+json"] } -config :tesla, adapter: Tesla.Adapter.Gun +config :tesla, adapter: Tesla.Adapter.Hackney # Configures http settings, upstream proxy etc. config :pleroma, :http, -- cgit v1.2.3 From 0309514656b92771dc5078cc1ec6d343e42399b0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 30 Jul 2020 18:49:48 +0200 Subject: Default MRF to ObjectAgePolicy, 7 days threshold --- config/config.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index 4b91a58b7..134dccf49 100644 --- a/config/config.exs +++ b/config/config.exs @@ -391,8 +391,9 @@ accept: [], reject: [] +# threshold of 7 days config :pleroma, :mrf_object_age, - threshold: 172_800, + threshold: 604_800, actions: [:delist, :strip_followers] config :pleroma, :rich_media, @@ -718,7 +719,7 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false config :pleroma, :mrf, - policies: Pleroma.Web.ActivityPub.MRF.NoOpPolicy, + policies: Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, transparency: true, transparency_exclusions: [] -- cgit v1.2.3 From 37b9e5e1384a6dae103773e29b386ac9843ecf5e Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 31 Jul 2020 10:29:16 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- 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 d18d638b9..65cccda30 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -124,7 +124,7 @@ To add configuration to your config file, you can copy it from the base config. * `federated_timeline_removal`: List of instances to remove from Federated (aka The Whole Known Network) Timeline. * `reject`: List of instances to reject any activities from. * `accept`: List of instances to accept any activities from. -* `followers_only`: List of instances to force posts as followers-only. +* `followers_only`: List of instances to decrease post visibility to only the followers, including for DM mentions. * `report_removal`: List of instances to reject reports from. * `avatar_removal`: List of instances to strip avatars from. * `banner_removal`: List of instances to strip banners from. -- cgit v1.2.3 From 27b0a8b15542c645f70937a124ab8190cc399313 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 31 Jul 2020 14:13:38 +0300 Subject: [#1985] Prevented force login on registration if account approval and/or email confirmation needed. Refactored login code in OAuthController, reused in AccountController. Added tests. --- .../web/api_spec/operations/account_operation.ex | 15 ++++- .../mastodon_api/controllers/account_controller.ex | 29 ++++++++- lib/pleroma/web/oauth/oauth_controller.ex | 48 +++++++++----- .../controllers/account_controller_test.exs | 73 +++++++++------------- 4 files changed, 103 insertions(+), 62 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 50c8e0242..aaebc9b5c 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -449,21 +449,32 @@ defp create_request do } end - # TODO: This is actually a token respone, but there's no oauth operation file yet. + # Note: this is a token response (if login succeeds!), but there's no oauth operation file yet. defp create_response do %Schema{ title: "AccountCreateResponse", description: "Response schema for an account", type: :object, properties: %{ + # The response when auto-login on create succeeds (token is issued): token_type: %Schema{type: :string}, access_token: %Schema{type: :string}, refresh_token: %Schema{type: :string}, scope: %Schema{type: :string}, created_at: %Schema{type: :integer, format: :"date-time"}, me: %Schema{type: :string}, - expires_in: %Schema{type: :integer} + expires_in: %Schema{type: :integer}, + # + # The response when registration succeeds but auto-login fails (no token): + identifier: %Schema{type: :string}, + message: %Schema{type: :string} }, + required: [], + # Note: example of successful registration with failed login response: + # example: %{ + # "identifier" => "missing_confirmed_email", + # "message" => "You have been registered. Please check your email for further instructions." + # }, example: %{ "token_type" => "Bearer", "access_token" => "i9hAVVzGld86Pl5JtLtizKoXVvtTlSCJvwaugCxvZzk", diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 4c97904b6..f45678184 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -27,8 +27,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.OAuth.OAuthController alias Pleroma.Web.OAuth.OAuthView - alias Pleroma.Web.OAuth.Token alias Pleroma.Web.TwitterAPI.TwitterAPI plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -101,10 +101,33 @@ def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do with :ok <- validate_email_param(params), :ok <- TwitterAPI.validate_captcha(app, params), {:ok, user} <- TwitterAPI.register_user(params), - {:ok, token} <- Token.create_token(app, user, %{scopes: app.scopes}) do + {_, {:ok, token}} <- + {:login, OAuthController.login(user, app, app.scopes)} do json(conn, OAuthView.render("token.json", %{user: user, token: token})) else - {:error, error} -> json_response(conn, :bad_request, %{error: error}) + {:login, {:account_status, :confirmation_pending}} -> + json_response(conn, :ok, %{ + message: "You have been registered. Please check your email for further instructions.", + identifier: "missing_confirmed_email" + }) + + {:login, {:account_status, :approval_pending}} -> + json_response(conn, :ok, %{ + message: + "You have been registered. You'll be able to log in once your account is approved.", + identifier: "awaiting_approval" + }) + + {:login, _} -> + json_response(conn, :ok, %{ + message: + "You have been registered. Some post-registration steps may be pending. " <> + "Please log in manually.", + identifier: "manual_login_required" + }) + + {:error, error} -> + json_response(conn, :bad_request, %{error: error}) end end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 61fe81d33..f29b3cb57 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -260,11 +260,8 @@ def token_exchange( ) do with {:ok, %User{} = user} <- Authenticator.get_user(conn), {:ok, app} <- Token.Utils.fetch_app(conn), - {:account_status, :active} <- {:account_status, User.account_status(user)}, - {:ok, scopes} <- validate_scopes(app, params), - {:ok, auth} <- Authorization.create_authorization(app, user, scopes), - {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)}, - {:ok, token} <- Token.exchange_token(app, auth) do + requested_scopes <- Scopes.fetch_scopes(params, app.scopes), + {:ok, token} <- login(user, app, requested_scopes) do json(conn, OAuthView.render("token.json", %{user: user, token: token})) else error -> @@ -522,6 +519,8 @@ def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "register"} = end end + defp do_create_authorization(conn, auth_attrs, user \\ nil) + defp do_create_authorization( %Plug.Conn{} = conn, %{ @@ -531,19 +530,37 @@ defp do_create_authorization( "redirect_uri" => redirect_uri } = auth_attrs }, - user \\ nil + user ) do with {_, {:ok, %User{} = user}} <- {:get_user, (user && {:ok, user}) || Authenticator.get_user(conn)}, %App{} = app <- Repo.get_by(App, client_id: client_id), true <- redirect_uri in String.split(app.redirect_uris), - {:ok, scopes} <- validate_scopes(app, auth_attrs), - {:account_status, :active} <- {:account_status, User.account_status(user)}, - {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do + requested_scopes <- Scopes.fetch_scopes(auth_attrs, app.scopes), + {:ok, auth} <- do_create_authorization(user, app, requested_scopes) do {:ok, auth, user} end end + defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes) + when is_list(requested_scopes) do + with {:account_status, :active} <- {:account_status, User.account_status(user)}, + {:ok, scopes} <- validate_scopes(app, requested_scopes), + {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do + {:ok, auth} + end + end + + # Note: intended to be a private function but opened for AccountController that logs in on signup + @doc "If checks pass, creates authorization and token for given user, app and requested scopes." + def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested_scopes) do + with {:ok, auth} <- do_create_authorization(user, app, requested_scopes), + {:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)}, + {:ok, token} <- Token.exchange_token(app, auth) do + {:ok, token} + end + end + # Special case: Local MastodonFE defp redirect_uri(%Plug.Conn{} = conn, "."), do: auth_url(conn, :login) @@ -560,12 +577,15 @@ defp build_and_response_mfa_token(user, auth) do end end - @spec validate_scopes(App.t(), map()) :: + @spec validate_scopes(App.t(), map() | list()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - defp validate_scopes(%App{} = app, params) do - params - |> Scopes.fetch_scopes(app.scopes) - |> Scopes.validate(app.scopes) + defp validate_scopes(%App{} = app, params) when is_map(params) do + requested_scopes = Scopes.fetch_scopes(params, app.scopes) + validate_scopes(app, requested_scopes) + end + + defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do + Scopes.validate(requested_scopes, app.scopes) end def default_redirect_uri(%App{} = app) do diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 708f8b5b3..d390c3ce1 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -5,7 +5,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do use Pleroma.Web.ConnCase - alias Pleroma.Config alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -16,8 +15,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do import Pleroma.Factory describe "account fetching" do - setup do: clear_config([:instance, :limit_to_local_content]) - test "works by id" do %User{id: user_id} = insert(:user) @@ -42,7 +39,7 @@ test "works by nickname" do end test "works by nickname for remote users" do - Config.put([:instance, :limit_to_local_content], false) + clear_config([:instance, :limit_to_local_content], false) user = insert(:user, nickname: "user@example.com", local: false) @@ -53,7 +50,7 @@ test "works by nickname for remote users" do end test "respects limit_to_local_content == :all for remote user nicknames" do - Config.put([:instance, :limit_to_local_content], :all) + clear_config([:instance, :limit_to_local_content], :all) user = insert(:user, nickname: "user@example.com", local: false) @@ -63,7 +60,7 @@ test "respects limit_to_local_content == :all for remote user nicknames" do end test "respects limit_to_local_content == :unauthenticated for remote user nicknames" do - Config.put([:instance, :limit_to_local_content], :unauthenticated) + clear_config([:instance, :limit_to_local_content], :unauthenticated) user = insert(:user, nickname: "user@example.com", local: false) reading_user = insert(:user) @@ -903,8 +900,10 @@ test "blocking / unblocking a user" do [valid_params: valid_params] end - test "Account registration via Application, no confirmation required", %{conn: conn} do + test "registers and logs in without :account_activation_required / :account_approval_required", + %{conn: conn} do clear_config([:instance, :account_activation_required], false) + clear_config([:instance, :account_approval_required], false) conn = conn @@ -962,15 +961,16 @@ test "Account registration via Application, no confirmation required", %{conn: c token_from_db = Repo.get_by(Token, token: token) assert token_from_db - token_from_db = Repo.preload(token_from_db, :user) - assert token_from_db.user - refute token_from_db.user.confirmation_pending - end + user = Repo.preload(token_from_db, :user).user - setup do: clear_config([:instance, :account_approval_required]) + assert user + refute user.confirmation_pending + refute user.approval_pending + end - test "Account registration via Application", %{conn: conn} do + test "registers but does not log in with :account_activation_required", %{conn: conn} do clear_config([:instance, :account_activation_required], true) + clear_config([:instance, :account_approval_required], false) conn = conn @@ -1019,23 +1019,18 @@ test "Account registration via Application", %{conn: conn} do agreement: true }) - %{ - "access_token" => token, - "created_at" => _created_at, - "scope" => ^scope, - "token_type" => "Bearer" - } = json_response_and_validate_schema(conn, 200) - - token_from_db = Repo.get_by(Token, token: token) - assert token_from_db - token_from_db = Repo.preload(token_from_db, :user) - assert token_from_db.user + response = json_response_and_validate_schema(conn, 200) + assert %{"identifier" => "missing_confirmed_email"} = response + refute response["access_token"] + refute response["token_type"] - assert token_from_db.user.confirmation_pending + user = Repo.get_by(User, email: "lain@example.org") + assert user.confirmation_pending end - test "Account registration via app with account_approval_required", %{conn: conn} do - Pleroma.Config.put([:instance, :account_approval_required], true) + test "registers but does not log in with :account_approval_required", %{conn: conn} do + clear_config([:instance, :account_approval_required], true) + clear_config([:instance, :account_activation_required], false) conn = conn @@ -1085,21 +1080,15 @@ test "Account registration via app with account_approval_required", %{conn: conn reason: "I'm a cool dude, bro" }) - %{ - "access_token" => token, - "created_at" => _created_at, - "scope" => ^scope, - "token_type" => "Bearer" - } = json_response_and_validate_schema(conn, 200) - - token_from_db = Repo.get_by(Token, token: token) - assert token_from_db - token_from_db = Repo.preload(token_from_db, :user) - assert token_from_db.user + response = json_response_and_validate_schema(conn, 200) + assert %{"identifier" => "awaiting_approval"} = response + refute response["access_token"] + refute response["token_type"] - assert token_from_db.user.approval_pending + user = Repo.get_by(User, email: "lain@example.org") - assert token_from_db.user.registration_reason == "I'm a cool dude, bro" + assert user.approval_pending + assert user.registration_reason == "I'm a cool dude, bro" end test "returns error when user already registred", %{conn: conn, valid_params: valid_params} do @@ -1153,11 +1142,9 @@ test "returns bad_request if missing required params", %{ end) end - setup do: clear_config([:instance, :account_activation_required]) - test "returns bad_request if missing email params when :account_activation_required is enabled", %{conn: conn, valid_params: valid_params} do - Pleroma.Config.put([:instance, :account_activation_required], true) + clear_config([:instance, :account_activation_required], true) app_token = insert(:oauth_token, user: nil) -- cgit v1.2.3 From 010d77ec855245def7fa785357db6e43cf1f14c7 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 31 Jul 2020 15:17:09 +0000 Subject: Revert "Merge branch 'mrf-silence-2' into 'develop'" This reverts merge request !2820 --- config/description.exs | 6 --- docs/configuration/cheatsheet.md | 1 - lib/pleroma/following_relationship.ex | 6 +-- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 31 ------------ test/web/activity_pub/mrf/simple_policy_test.exs | 60 ----------------------- 5 files changed, 1 insertion(+), 103 deletions(-) diff --git a/config/description.exs b/config/description.exs index d623a9f75..11fbe0d78 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1542,12 +1542,6 @@ description: "List of instances to only accept activities from (except deletes)", suggestions: ["example.com", "*.example.com"] }, - %{ - key: :followers_only, - type: {:list, :string}, - description: "Force posts from the given instances to be visible by followers only", - suggestions: ["example.com", "*.example.com"] - }, %{ key: :report_removal, type: {:list, :string}, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 7de82a41d..9c768abef 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -125,7 +125,6 @@ To add configuration to your config file, you can copy it from the base config. * `federated_timeline_removal`: List of instances to remove from Federated (aka The Whole Known Network) Timeline. * `reject`: List of instances to reject any activities from. * `accept`: List of instances to accept any activities from. -* `followers_only`: List of instances to decrease post visibility to only the followers, including for DM mentions. * `report_removal`: List of instances to reject reports from. * `avatar_removal`: List of instances to strip avatars from. * `banner_removal`: List of instances to strip banners from. diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 83b366dd4..c2020d30a 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -95,11 +95,7 @@ def followers_query(%User{} = user) do |> where([r], r.state == ^:follow_accept) end - def followers_ap_ids(user, from_ap_ids \\ nil) - - def followers_ap_ids(_, []), do: [] - - def followers_ap_ids(%User{} = user, from_ap_ids) do + def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do query = user |> followers_query() diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index bb193475a..b77b8c7b4 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do @behaviour Pleroma.Web.ActivityPub.MRF alias Pleroma.Config - alias Pleroma.FollowingRelationship alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -109,35 +108,6 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do {:ok, object} end - defp intersection(list1, list2) do - list1 -- list1 -- list2 - end - - defp check_followers_only(%{host: actor_host} = _actor_info, object) do - followers_only = - Config.get([:mrf_simple, :followers_only]) - |> MRF.subdomains_regex() - - object = - with true <- MRF.subdomain_match?(followers_only, actor_host), - user <- User.get_cached_by_ap_id(object["actor"]) do - # Don't use Map.get/3 intentionally, these must not be nil - fixed_to = object["to"] || [] - fixed_cc = object["cc"] || [] - - to = FollowingRelationship.followers_ap_ids(user, fixed_to) - cc = FollowingRelationship.followers_ap_ids(user, fixed_cc) - - object - |> Map.put("to", intersection([user.follower_address | to], fixed_to)) - |> Map.put("cc", intersection([user.follower_address | cc], fixed_cc)) - else - _ -> object - end - - {:ok, object} - end - defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do report_removal = Config.get([:mrf_simple, :report_removal]) @@ -204,7 +174,6 @@ def filter(%{"actor" => actor} = object) do {:ok, object} <- check_media_removal(actor_info, object), {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), - {:ok, object} <- check_followers_only(actor_info, object), {:ok, object} <- check_report_removal(actor_info, object) do {:ok, object} else diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index d7dde62c4..e842d8d8d 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do import Pleroma.Factory alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF.SimplePolicy - alias Pleroma.Web.CommonAPI setup do: clear_config(:mrf_simple, @@ -16,7 +15,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do federated_timeline_removal: [], report_removal: [], reject: [], - followers_only: [], accept: [], avatar_removal: [], banner_removal: [], @@ -263,64 +261,6 @@ test "actor has a matching host" do end end - describe "when :followers_only" do - test "is empty" do - Config.put([:mrf_simple, :followers_only], []) - {_, ftl_message} = build_ftl_actor_and_message() - local_message = build_local_message() - - assert SimplePolicy.filter(ftl_message) == {:ok, ftl_message} - assert SimplePolicy.filter(local_message) == {:ok, local_message} - end - - test "has a matching host" do - actor = insert(:user) - following_user = insert(:user) - non_following_user = insert(:user) - - {:ok, _, _, _} = CommonAPI.follow(following_user, actor) - - activity = %{ - "actor" => actor.ap_id, - "to" => [ - "https://www.w3.org/ns/activitystreams#Public", - following_user.ap_id, - non_following_user.ap_id - ], - "cc" => [actor.follower_address, "http://foo.bar/qux"] - } - - dm_activity = %{ - "actor" => actor.ap_id, - "to" => [ - following_user.ap_id, - non_following_user.ap_id - ], - "cc" => [] - } - - actor_domain = - activity - |> Map.fetch!("actor") - |> URI.parse() - |> Map.fetch!(:host) - - Config.put([:mrf_simple, :followers_only], [actor_domain]) - - assert {:ok, new_activity} = SimplePolicy.filter(activity) - assert actor.follower_address in new_activity["cc"] - assert following_user.ap_id in new_activity["to"] - refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["to"] - refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["cc"] - refute non_following_user.ap_id in new_activity["to"] - refute non_following_user.ap_id in new_activity["cc"] - - assert {:ok, new_dm_activity} = SimplePolicy.filter(dm_activity) - assert new_dm_activity["to"] == [following_user.ap_id] - assert new_dm_activity["cc"] == [] - end - end - describe "when :accept" do test "is empty" do Config.put([:mrf_simple, :accept], []) -- cgit v1.2.3 From 4b18a07392558401c88a60db3751feefd9481e13 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 31 Jul 2020 15:18:04 +0000 Subject: Revert "Merge branch 'revert-1ac0969c' into 'develop'" This reverts merge request !2825 --- config/description.exs | 6 +++ docs/configuration/cheatsheet.md | 1 + lib/pleroma/following_relationship.ex | 6 ++- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 31 ++++++++++++ test/web/activity_pub/mrf/simple_policy_test.exs | 60 +++++++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 11fbe0d78..d623a9f75 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1542,6 +1542,12 @@ description: "List of instances to only accept activities from (except deletes)", suggestions: ["example.com", "*.example.com"] }, + %{ + key: :followers_only, + type: {:list, :string}, + description: "Force posts from the given instances to be visible by followers only", + suggestions: ["example.com", "*.example.com"] + }, %{ key: :report_removal, type: {:list, :string}, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 9c768abef..7de82a41d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -125,6 +125,7 @@ To add configuration to your config file, you can copy it from the base config. * `federated_timeline_removal`: List of instances to remove from Federated (aka The Whole Known Network) Timeline. * `reject`: List of instances to reject any activities from. * `accept`: List of instances to accept any activities from. +* `followers_only`: List of instances to decrease post visibility to only the followers, including for DM mentions. * `report_removal`: List of instances to reject reports from. * `avatar_removal`: List of instances to strip avatars from. * `banner_removal`: List of instances to strip banners from. diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index c2020d30a..83b366dd4 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -95,7 +95,11 @@ def followers_query(%User{} = user) do |> where([r], r.state == ^:follow_accept) end - def followers_ap_ids(%User{} = user, from_ap_ids \\ nil) do + def followers_ap_ids(user, from_ap_ids \\ nil) + + def followers_ap_ids(_, []), do: [] + + def followers_ap_ids(%User{} = user, from_ap_ids) do query = user |> followers_query() diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index b77b8c7b4..bb193475a 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do @behaviour Pleroma.Web.ActivityPub.MRF alias Pleroma.Config + alias Pleroma.FollowingRelationship alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF @@ -108,6 +109,35 @@ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do {:ok, object} end + defp intersection(list1, list2) do + list1 -- list1 -- list2 + end + + defp check_followers_only(%{host: actor_host} = _actor_info, object) do + followers_only = + Config.get([:mrf_simple, :followers_only]) + |> MRF.subdomains_regex() + + object = + with true <- MRF.subdomain_match?(followers_only, actor_host), + user <- User.get_cached_by_ap_id(object["actor"]) do + # Don't use Map.get/3 intentionally, these must not be nil + fixed_to = object["to"] || [] + fixed_cc = object["cc"] || [] + + to = FollowingRelationship.followers_ap_ids(user, fixed_to) + cc = FollowingRelationship.followers_ap_ids(user, fixed_cc) + + object + |> Map.put("to", intersection([user.follower_address | to], fixed_to)) + |> Map.put("cc", intersection([user.follower_address | cc], fixed_cc)) + else + _ -> object + end + + {:ok, object} + end + defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do report_removal = Config.get([:mrf_simple, :report_removal]) @@ -174,6 +204,7 @@ def filter(%{"actor" => actor} = object) do {:ok, object} <- check_media_removal(actor_info, object), {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), + {:ok, object} <- check_followers_only(actor_info, object), {:ok, object} <- check_report_removal(actor_info, object) do {:ok, object} else diff --git a/test/web/activity_pub/mrf/simple_policy_test.exs b/test/web/activity_pub/mrf/simple_policy_test.exs index e842d8d8d..d7dde62c4 100644 --- a/test/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/web/activity_pub/mrf/simple_policy_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do import Pleroma.Factory alias Pleroma.Config alias Pleroma.Web.ActivityPub.MRF.SimplePolicy + alias Pleroma.Web.CommonAPI setup do: clear_config(:mrf_simple, @@ -15,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do federated_timeline_removal: [], report_removal: [], reject: [], + followers_only: [], accept: [], avatar_removal: [], banner_removal: [], @@ -261,6 +263,64 @@ test "actor has a matching host" do end end + describe "when :followers_only" do + test "is empty" do + Config.put([:mrf_simple, :followers_only], []) + {_, ftl_message} = build_ftl_actor_and_message() + local_message = build_local_message() + + assert SimplePolicy.filter(ftl_message) == {:ok, ftl_message} + assert SimplePolicy.filter(local_message) == {:ok, local_message} + end + + test "has a matching host" do + actor = insert(:user) + following_user = insert(:user) + non_following_user = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(following_user, actor) + + activity = %{ + "actor" => actor.ap_id, + "to" => [ + "https://www.w3.org/ns/activitystreams#Public", + following_user.ap_id, + non_following_user.ap_id + ], + "cc" => [actor.follower_address, "http://foo.bar/qux"] + } + + dm_activity = %{ + "actor" => actor.ap_id, + "to" => [ + following_user.ap_id, + non_following_user.ap_id + ], + "cc" => [] + } + + actor_domain = + activity + |> Map.fetch!("actor") + |> URI.parse() + |> Map.fetch!(:host) + + Config.put([:mrf_simple, :followers_only], [actor_domain]) + + assert {:ok, new_activity} = SimplePolicy.filter(activity) + assert actor.follower_address in new_activity["cc"] + assert following_user.ap_id in new_activity["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["to"] + refute "https://www.w3.org/ns/activitystreams#Public" in new_activity["cc"] + refute non_following_user.ap_id in new_activity["to"] + refute non_following_user.ap_id in new_activity["cc"] + + assert {:ok, new_dm_activity} = SimplePolicy.filter(dm_activity) + assert new_dm_activity["to"] == [following_user.ap_id] + assert new_dm_activity["cc"] == [] + end + end + describe "when :accept" do test "is empty" do Config.put([:mrf_simple, :accept], []) -- cgit v1.2.3 From 7e01339dddf78d99f609fdac934e89724f8254c3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 31 Jul 2020 17:58:50 +0200 Subject: Frontend mix task: Support installation from local file. --- lib/mix/tasks/pleroma/frontend.ex | 41 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex index bd65e9e36..c385c355a 100644 --- a/lib/mix/tasks/pleroma/frontend.ex +++ b/lib/mix/tasks/pleroma/frontend.ex @@ -27,7 +27,9 @@ def run(["install", frontend | args]) do strict: [ ref: :string, static_dir: :string, - build_url: :string + build_url: :string, + build_dir: :string, + file: :string ] ) @@ -39,7 +41,8 @@ def run(["install", frontend | args]) do cmd_frontend_info = %{ "name" => frontend, "ref" => options[:ref], - "build_url" => options[:build_url] + "build_url" => options[:build_url], + "build_dir" => options[:build_dir] } config_frontend_info = Pleroma.Config.get([:frontends, :available, frontend], %{}) @@ -66,10 +69,10 @@ def run(["install", frontend | args]) do fe_label = "#{frontend} (#{ref})" - shell_info("Downloading pre-built bundle for #{fe_label}") tmp_dir = Path.join(dest, "tmp") - with {_, :ok} <- {:download, download_build(frontend_info, tmp_dir)}, + with {_, :ok} <- + {:download_or_unzip, download_or_unzip(frontend_info, tmp_dir, options[:file])}, shell_info("Installing #{fe_label} to #{dest}"), :ok <- install_frontend(frontend_info, tmp_dir, dest) do File.rm_rf!(tmp_dir) @@ -77,20 +80,26 @@ def run(["install", frontend | args]) do Logger.configure(level: log_level) else - {:download, _} -> - shell_info("Could not download the frontend") + {:download_or_unzip, _} -> + shell_info("Could not download or unzip the frontend") _e -> shell_info("Could not install the frontend") end end - defp download_build(frontend_info, dest) do - url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) + defp download_or_unzip(frontend_info, temp_dir, file) do + if file do + with {:ok, zip} <- File.read(Path.expand(file)) do + unzip(zip, temp_dir) + end + else + download_build(frontend_info, temp_dir) + end + end - with {:ok, %{status: 200, body: zip_body}} <- - Pleroma.HTTP.get(url, [], timeout: 120_000, recv_timeout: 120_000), - {:ok, unzipped} <- :zip.unzip(zip_body, [:memory]) do + def unzip(zip, dest) do + with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do File.rm_rf!(dest) File.mkdir_p!(dest) @@ -107,6 +116,16 @@ defp download_build(frontend_info, dest) do end) :ok + end + end + + defp download_build(frontend_info, dest) do + shell_info("Downloading pre-built bundle for #{frontend_info["name"]}") + url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) + + with {:ok, %{status: 200, body: zip_body}} <- + Pleroma.HTTP.get(url, [], timeout: 120_000, recv_timeout: 120_000) do + unzip(zip_body, dest) else e -> {:error, e} end -- cgit v1.2.3 From 4bf44b7d657da540b25db8ac3e8906641c4242bd Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 1 Aug 2020 10:04:25 +0300 Subject: Don't override user-agent header if it's been set --- lib/pleroma/http/request_builder.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/http/request_builder.ex b/lib/pleroma/http/request_builder.ex index 2fc876d92..8a44a001d 100644 --- a/lib/pleroma/http/request_builder.ex +++ b/lib/pleroma/http/request_builder.ex @@ -34,10 +34,12 @@ def url(request, u), do: %{request | url: u} @spec headers(Request.t(), Request.headers()) :: Request.t() def headers(request, headers) do headers_list = - if Pleroma.Config.get([:http, :send_user_agent]) do + with true <- Pleroma.Config.get([:http, :send_user_agent]), + nil <- Enum.find(headers, fn {key, _val} -> String.downcase(key) == "user-agent" end) do [{"user-agent", Pleroma.Application.user_agent()} | headers] else - headers + _ -> + headers end %{request | headers: headers_list} -- cgit v1.2.3 From 87180ff817e4b9e3a3b90e7f0054b60b0d0c2c41 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 1 Aug 2020 12:16:06 +0300 Subject: Fix ConnecitonPool deadlocking after reaching the connection limit The issue was with ConcurrentLimiter not decrementing counters on overload. It was fixed in the latest commit, but concurrentlimiter version wasn't updated in Pleroma for some reason. Closes #1977 --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 860c6aee7..0e723c15f 100644 --- a/mix.exs +++ b/mix.exs @@ -178,7 +178,7 @@ defp deps do {:flake_id, "~> 0.1.0"}, {:concurrent_limiter, git: "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", - ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"}, + ref: "55e92f84b4ed531bd487952a71040a9c69dc2807"}, {:remote_ip, git: "https://git.pleroma.social/pleroma/remote_ip.git", ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"}, diff --git a/mix.lock b/mix.lock index 17b11cdb2..55c3c59c6 100644 --- a/mix.lock +++ b/mix.lock @@ -14,7 +14,7 @@ "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, - "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "8eee96c6ba39b9286ec44c51c52d9f2758951365", [ref: "8eee96c6ba39b9286ec44c51c52d9f2758951365"]}, + "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "55e92f84b4ed531bd487952a71040a9c69dc2807", [ref: "55e92f84b4ed531bd487952a71040a9c69dc2807"]}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f0d0e13f71c51fd4ef8b2c7e051388e4dfb267522a83a22392c856de7e46465f"}, "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, -- cgit v1.2.3 From 45be1fe00e93fadab27a8e93e4537f11f6edd5eb Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 1 Aug 2020 17:59:50 +0300 Subject: ConnectionPool: fix gun open errors being returned without an error tuple When gun shuts down due to the host being unreachable, the worker process shuts down with the same shutdown reason since they are linked. Gun doesn't have error tuples in it's shutdown reason though, so we need to handle it in get_conn. Closes #2008 --- lib/pleroma/gun/connection_pool.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index 49e9885bb..f34602b73 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -10,6 +10,7 @@ def children do ] end + @spec get_conn(URI.t(), keyword()) :: {:ok, pid()} | {:error, term()} def get_conn(uri, opts) do key = "#{uri.scheme}:#{uri.host}:#{uri.port}" @@ -54,12 +55,14 @@ defp get_gun_pid_from_worker(worker_pid, register) do {:DOWN, ^ref, :process, ^worker_pid, reason} -> case reason do - {:shutdown, error} -> error + {:shutdown, {:error, _} = error} -> error + {:shutdown, error} -> {:error, error} _ -> {:error, reason} end end end + @spec release_conn(pid()) :: :ok def release_conn(conn_pid) do # :ets.fun2ms(fn {_, {worker_pid, {gun_pid, _, _, _}}} when gun_pid == conn_pid -> # worker_pid end) -- cgit v1.2.3 From cb1e3893aa8c03e3245978eb6d76bc2b3c534ba0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 1 Aug 2020 16:08:29 -0500 Subject: SimpleMRF: Add missing :followers_only to config.exs --- config/config.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.exs b/config/config.exs index d31208c25..e1d2e13d2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -374,6 +374,7 @@ federated_timeline_removal: [], report_removal: [], reject: [], + followers_only: [], accept: [], avatar_removal: [], banner_removal: [], -- cgit v1.2.3 From f671d7e68c77e5d41dd0716f48f387561efc3999 Mon Sep 17 00:00:00 2001 From: Ilja Date: Sun, 2 Aug 2020 15:54:59 +0200 Subject: Add welcome chatmessages * I added the option in config/config.exs * created a new module lib/pleroma/user/welcome_chat_message.ex * Added it to the registration flow * added to the cheatsheet * added to the config/description.ex * added to the Changelog.md --- CHANGELOG.md | 2 +- config/config.exs | 5 ++++ config/description.exs | 29 ++++++++++++++++++++ docs/configuration/cheatsheet.md | 4 +++ lib/pleroma/user.ex | 10 +++++++ lib/pleroma/user/welcome_chat_message.ex | 45 ++++++++++++++++++++++++++++++++ test/user/welcome_chat_massage_test.exs | 35 +++++++++++++++++++++++++ test/user_test.exs | 35 ++++++++++++++++++++----- 8 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 lib/pleroma/user/welcome_chat_message.ex create mode 100644 test/user/welcome_chat_massage_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 129c269aa..4b682d70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Support for viewing instances favicons next to posts and accounts - Added Pleroma.Upload.Filter.Exiftool as an alternate EXIF stripping mechanism targeting GPS/location metadata. - "By approval" registrations mode. -- Configuration: Added `:welcome` settings for the welcome message to newly registered users. +- Configuration: Added `:welcome` settings for the welcome message to newly registered users. You can send a welcome message as a direct message, chat or email. - Ability to hide favourites and emoji reactions in the API with `[:instance, :show_reactions]` config.
    diff --git a/config/config.exs b/config/config.exs index d31208c25..c0213612b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -261,6 +261,11 @@ sender_nickname: nil, message: nil ], + chat_message: [ + enabled: false, + sender_nickname: nil, + message: nil + ], email: [ enabled: false, sender: nil, diff --git a/config/description.exs b/config/description.exs index 11fbe0d78..9c8cbacb5 100644 --- a/config/description.exs +++ b/config/description.exs @@ -997,6 +997,35 @@ } ] }, + %{ + group: :chat_message, + type: :group, + descpiption: "Chat message settings", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables sends chat message for new user after registration" + }, + %{ + key: :message, + type: :string, + description: + "A message that will be sent to a newly registered users as a chat message", + suggestions: [ + "Hi, @username! Welcome on board!" + ] + }, + %{ + key: :sender_nickname, + type: :string, + description: "The nickname of the local user that sends the welcome message", + suggestions: [ + "lain" + ] + } + ] + }, %{ group: :email, type: :group, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 9c768abef..59c3fb06d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -69,6 +69,10 @@ To add configuration to your config file, you can copy it from the base config. * `enabled`: Enables the send a direct message to a newly registered user. Defaults to `false`. * `sender_nickname`: The nickname of the local user that sends the welcome message. * `message`: A message that will be send to a newly registered users as a direct message. +* `chat_message`: - welcome message sent as a chat message. + * `enabled`: Enables the send a chat message to a newly registered user. Defaults to `false`. + * `sender_nickname`: The nickname of the local user that sends the welcome message. + * `message`: A message that will be send to a newly registered users as a chat message. * `email`: - welcome message sent as a email. * `enabled`: Enables the send a welcome email to a newly registered user. Defaults to `false`. * `sender`: The email address or tuple with `{nickname, email}` that will use as sender to the welcome email. diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index dcf6ebee2..0c1fab223 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -734,6 +734,7 @@ def post_register_action(%User{} = user) do {:ok, user} <- set_cache(user), {:ok, _} <- send_welcome_email(user), {:ok, _} <- send_welcome_message(user), + {:ok, _} <- send_welcome_chat_message(user), {:ok, _} <- try_send_confirmation_email(user) do {:ok, user} end @@ -748,6 +749,15 @@ def send_welcome_message(user) do end end + def send_welcome_chat_message(user) do + if User.WelcomeChatMessage.enabled?() do + User.WelcomeChatMessage.post_message(user) + {:ok, :enqueued} + else + {:ok, :noop} + end + end + def send_welcome_email(%User{email: email} = user) when is_binary(email) do if User.WelcomeEmail.enabled?() do User.WelcomeEmail.send_email(user) diff --git a/lib/pleroma/user/welcome_chat_message.ex b/lib/pleroma/user/welcome_chat_message.ex new file mode 100644 index 000000000..3e7d1f424 --- /dev/null +++ b/lib/pleroma/user/welcome_chat_message.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeChatMessage do + alias Pleroma.Config + alias Pleroma.User + alias Pleroma.Web.CommonAPI + + @spec enabled?() :: boolean() + def enabled?, do: Config.get([:welcome, :chat_message, :enabled], false) + + @spec post_message(User.t()) :: {:ok, Pleroma.Activity.t() | nil} + def post_message(user) do + [:welcome, :chat_message, :sender_nickname] + |> Config.get(nil) + |> fetch_sender() + |> do_post(user, welcome_message()) + end + + defp do_post(%User{} = sender, recipient, message) + when is_binary(message) do + CommonAPI.post_chat_message( + sender, + recipient, + message + ) + end + + defp do_post(_sender, _recipient, _message), do: {:ok, nil} + + defp fetch_sender(nickname) when is_binary(nickname) do + with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + user + else + _ -> nil + end + end + + defp fetch_sender(_), do: nil + + defp welcome_message do + Config.get([:welcome, :chat_message, :message], nil) + end +end diff --git a/test/user/welcome_chat_massage_test.exs b/test/user/welcome_chat_massage_test.exs new file mode 100644 index 000000000..3fef6fa6d --- /dev/null +++ b/test/user/welcome_chat_massage_test.exs @@ -0,0 +1,35 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.User.WelcomeChatMessageTest do + use Pleroma.DataCase + + alias Pleroma.Config + alias Pleroma.User.WelcomeChatMessage + + import Pleroma.Factory + + setup do: clear_config([:welcome]) + + describe "post_message/1" do + test "send a chat welcome message" do + welcome_user = insert(:user) + user = insert(:user, name: "mewmew") + + Config.put([:welcome, :chat_message, :enabled], true) + Config.put([:welcome, :chat_message, :sender_nickname], welcome_user.nickname) + + Config.put( + [:welcome, :chat_message, :message], + "Hello. Welcome to blob.cat" + ) + + {:ok, %Pleroma.Activity{} = activity} = WelcomeChatMessage.post_message(user) + + assert user.ap_id in activity.recipients + assert Pleroma.Object.normalize(activity).data["type"] == "ChatMessage" + assert Pleroma.Object.normalize(activity).data["content"] =~ "Hello. Welcome to " + end + end +end diff --git a/test/user_test.exs b/test/user_test.exs index 904cea536..2c1f2b7c5 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -412,8 +412,36 @@ test "it sends a welcome message if it is set" do welcome_user = insert(:user) Pleroma.Config.put([:welcome, :direct_message, :enabled], true) Pleroma.Config.put([:welcome, :direct_message, :sender_nickname], welcome_user.nickname) - Pleroma.Config.put([:welcome, :direct_message, :message], "Hello, this is a cool site") + Pleroma.Config.put([:welcome, :direct_message, :message], "Hello, this is a direct message") + cng = User.register_changeset(%User{}, @full_user_data) + {:ok, registered_user} = User.register(cng) + ObanHelpers.perform_all() + + activity = Repo.one(Pleroma.Activity) + assert registered_user.ap_id in activity.recipients + assert Object.normalize(activity).data["content"] =~ "direct message" + assert activity.actor == welcome_user.ap_id + end + + test "it sends a welcome chat message if it is set" do + welcome_user = insert(:user) + Pleroma.Config.put([:welcome, :chat_message, :enabled], true) + Pleroma.Config.put([:welcome, :chat_message, :sender_nickname], welcome_user.nickname) + Pleroma.Config.put([:welcome, :chat_message, :message], "Hello, this is a chat message") + + cng = User.register_changeset(%User{}, @full_user_data) + {:ok, registered_user} = User.register(cng) + ObanHelpers.perform_all() + + activity = Repo.one(Pleroma.Activity) + assert registered_user.ap_id in activity.recipients + assert Object.normalize(activity).data["content"] =~ "chat message" + assert activity.actor == welcome_user.ap_id + end + + test "it sends a welcome email message if it is set" do + welcome_user = insert(:user) Pleroma.Config.put([:welcome, :email, :enabled], true) Pleroma.Config.put([:welcome, :email, :sender], welcome_user.email) @@ -428,11 +456,6 @@ test "it sends a welcome message if it is set" do {:ok, registered_user} = User.register(cng) ObanHelpers.perform_all() - activity = Repo.one(Pleroma.Activity) - assert registered_user.ap_id in activity.recipients - assert Object.normalize(activity).data["content"] =~ "cool site" - assert activity.actor == welcome_user.ap_id - assert_email_sent( from: {instance_name, welcome_user.email}, to: {registered_user.name, registered_user.email}, -- cgit v1.2.3 From 0012894d4eb7089bf96fd9a3551455edfddf095b Mon Sep 17 00:00:00 2001 From: swentel Date: Sun, 2 Aug 2020 19:33:22 +0200 Subject: Add indigenous to clients --- docs/clients.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/clients.md b/docs/clients.md index ea751637e..2a42c659f 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -75,6 +75,13 @@ Feel free to contact us to be added to this list! - Platform: Android, iOS - Features: No Streaming +### Indigenous +- Homepage: +- Source Code: +- Contact: [@realize.be@realize.be](@realize.be@realize.be) +- Platforms: Android +- Features: No Streaming + ## Alternative Web Interfaces ### Brutaldon - Homepage: -- cgit v1.2.3 From c2c3dd46133499db4102a946f07be87efdf82f1a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 2 Aug 2020 13:42:23 -0500 Subject: Migrate legacy tags set by AdminFE to match TagPolicy, #2010 --- .../migrations/20200802170532_fix_legacy_tags.exs | 37 ++++++++++++++++++++++ .../20200802170532_fix_legacy_tags_test.exs | 24 ++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 priv/repo/migrations/20200802170532_fix_legacy_tags.exs create mode 100644 test/migrations/20200802170532_fix_legacy_tags_test.exs diff --git a/priv/repo/migrations/20200802170532_fix_legacy_tags.exs b/priv/repo/migrations/20200802170532_fix_legacy_tags.exs new file mode 100644 index 000000000..f7274b44e --- /dev/null +++ b/priv/repo/migrations/20200802170532_fix_legacy_tags.exs @@ -0,0 +1,37 @@ +# Fix legacy tags set by AdminFE that don't align with TagPolicy MRF + +defmodule Pleroma.Repo.Migrations.FixLegacyTags do + use Ecto.Migration + alias Pleroma.Repo + alias Pleroma.User + import Ecto.Query + + @old_new_map %{ + "force_nsfw" => "mrf_tag:media-force-nsfw", + "strip_media" => "mrf_tag:media-strip", + "force_unlisted" => "mrf_tag:force-unlisted", + "sandbox" => "mrf_tag:sandbox", + "disable_remote_subscription" => "mrf_tag:disable-remote-subscription", + "disable_any_subscription" => "mrf_tag:disable-any-subscription" + } + + def change do + legacy_tags = Map.keys(@old_new_map) + + from(u in User, where: fragment("? && ?", u.tags, ^legacy_tags)) + |> Repo.all() + |> Enum.each(fn user -> + fix_tags_changeset(user) + |> Repo.update() + end) + end + + defp fix_tags_changeset(%User{tags: tags} = user) do + new_tags = + Enum.map(tags, fn tag -> + Map.get(@old_new_map, tag, tag) + end) + + Ecto.Changeset.change(user, tags: new_tags) + end +end diff --git a/test/migrations/20200802170532_fix_legacy_tags_test.exs b/test/migrations/20200802170532_fix_legacy_tags_test.exs new file mode 100644 index 000000000..3b4dee407 --- /dev/null +++ b/test/migrations/20200802170532_fix_legacy_tags_test.exs @@ -0,0 +1,24 @@ +defmodule Pleroma.Repo.Migrations.FixLegacyTagsTest do + alias Pleroma.User + use Pleroma.DataCase + import Pleroma.Factory + import Pleroma.Tests.Helpers + + setup_all do: require_migration("20200802170532_fix_legacy_tags") + + test "change/0 converts legacy user tags into correct values", %{migration: migration} do + user = insert(:user, tags: ["force_nsfw", "force_unlisted", "verified"]) + user2 = insert(:user) + + assert :ok == migration.change() + + fixed_user = User.get_by_id(user.id) + fixed_user2 = User.get_by_id(user2.id) + + assert fixed_user.tags == ["mrf_tag:media-force-nsfw", "mrf_tag:force-unlisted", "verified"] + assert fixed_user2.tags == [] + + # user2 should not have been updated + assert fixed_user2.updated_at == fixed_user2.inserted_at + end +end -- cgit v1.2.3 From dc88b6f0919cf5686af7d5b935e8ee462491704b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 2 Aug 2020 14:53:42 -0500 Subject: Add email blacklist, fixes #1404 --- config/config.exs | 3 ++- config/description.exs | 7 +++++++ docs/configuration/cheatsheet.md | 5 +++++ lib/pleroma/user.ex | 11 ++++++++++- test/user_test.exs | 23 +++++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/config/config.exs b/config/config.exs index d31208c25..ba263bf95 100644 --- a/config/config.exs +++ b/config/config.exs @@ -509,7 +509,8 @@ "user_exists", "users", "web" - ] + ], + email_blacklist: [] config :pleroma, Oban, repo: Pleroma.Repo, diff --git a/config/description.exs b/config/description.exs index 11fbe0d78..3fe22e969 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3021,6 +3021,7 @@ %{ key: :restricted_nicknames, type: {:list, :string}, + description: "List of nicknames users may not register with.", suggestions: [ ".well-known", "~", @@ -3053,6 +3054,12 @@ "users", "web" ] + }, + %{ + key: :email_blacklist, + type: {:list, :string}, + description: "List of email domains users may not register with.", + suggestions: ["mailinator.com", "maildrop.cc"] } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 9c768abef..1a86179f3 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -202,6 +202,11 @@ config :pleroma, :mrf_user_allowlist, %{ * `sign_object_fetches`: Sign object fetches with HTTP signatures * `authorized_fetch_mode`: Require HTTP signatures for AP fetches +## Pleroma.User + +* `restricted_nicknames`: List of nicknames users may not register with. +* `email_blacklist`: List of email domains users may not register with. + ## Pleroma.ScheduledActivity * `daily_user_limit`: the number of scheduled activities a user is allowed to create in a single day (Default: `25`) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index dcf6ebee2..d0cc098fe 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -676,10 +676,19 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do |> validate_required([:name, :nickname, :password, :password_confirmation]) |> validate_confirmation(:password) |> unique_constraint(:email) + |> validate_format(:email, @email_regex) + |> validate_change(:email, fn :email, email -> + valid? = + Config.get([User, :email_blacklist]) + |> Enum.all?(fn blacklisted_domain -> + !String.ends_with?(email, ["@" <> blacklisted_domain, "." <> blacklisted_domain]) + end) + + if valid?, do: [], else: [email: "Email domain is blacklisted"] + end) |> unique_constraint(:nickname) |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) |> validate_format(:nickname, local_nickname_regex()) - |> validate_format(:email, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) |> validate_length(:registration_reason, max: reason_limit) diff --git a/test/user_test.exs b/test/user_test.exs index 904cea536..7c45e69e7 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -490,6 +490,29 @@ test "it restricts certain nicknames" do refute changeset.valid? end + test "it blocks blacklisted email domains" do + clear_config([User, :email_blacklist], ["trolling.world"]) + + # Block with match + params = Map.put(@full_user_data, :email, "troll@trolling.world") + changeset = User.register_changeset(%User{}, params) + refute changeset.valid? + + # Block with subdomain match + params = Map.put(@full_user_data, :email, "troll@gnomes.trolling.world") + changeset = User.register_changeset(%User{}, params) + refute changeset.valid? + + # Pass with different domains that are similar + params = Map.put(@full_user_data, :email, "troll@gnomestrolling.world") + changeset = User.register_changeset(%User{}, params) + assert changeset.valid? + + params = Map.put(@full_user_data, :email, "troll@trolling.world.us") + changeset = User.register_changeset(%User{}, params) + assert changeset.valid? + end + test "it sets the password_hash and ap_id" do changeset = User.register_changeset(%User{}, @full_user_data) -- cgit v1.2.3 From 77b48cb4ce81165a3a4f28e91b8f22dd510d3d00 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 2 Aug 2020 16:36:55 -0500 Subject: Factory: Add report_activity_factory --- test/support/factory.ex | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/support/factory.ex b/test/support/factory.ex index 635d83650..4c09d65b6 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -297,6 +297,30 @@ def follow_activity_factory do } end + def report_activity_factory(attrs \\ %{}) do + user = attrs[:user] || insert(:user) + activity = attrs[:activity] || insert(:note_activity) + state = attrs[:state] || "open" + + data = %{ + "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), + "actor" => user.ap_id, + "type" => "Flag", + "object" => [activity.actor, activity.data["id"]], + "published" => DateTime.utc_now() |> DateTime.to_iso8601(), + "to" => [], + "cc" => [activity.actor], + "context" => activity.data["context"], + "state" => state, + } + + %Pleroma.Activity{ + data: data, + actor: data["actor"], + recipients: data["to"] ++ data["cc"] + } + end + def oauth_app_factory do %Pleroma.Web.OAuth.App{ client_name: sequence(:client_name, &"Some client #{&1}"), -- cgit v1.2.3 From f9301044ed9c80314d1c313035359956cf5dbc1a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 2 Aug 2020 16:37:33 -0500 Subject: Add ReportNote test --- test/report_note_test.exs | 16 ++++++++++++++++ test/support/factory.ex | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 test/report_note_test.exs diff --git a/test/report_note_test.exs b/test/report_note_test.exs new file mode 100644 index 000000000..25c1d6a61 --- /dev/null +++ b/test/report_note_test.exs @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReportNoteTest do + alias Pleroma.ReportNote + use Pleroma.DataCase + import Pleroma.Factory + + test "create/3" do + user = insert(:user) + report = insert(:report_activity) + assert {:ok, note} = ReportNote.create(user.id, report.id, "naughty boy") + assert note.content == "naughty boy" + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 4c09d65b6..486eda8da 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -311,7 +311,7 @@ def report_activity_factory(attrs \\ %{}) do "to" => [], "cc" => [activity.actor], "context" => activity.data["context"], - "state" => state, + "state" => state } %Pleroma.Activity{ -- cgit v1.2.3 From 10c792110e6ea8ed21f739ef8f4f0eff4659ebf9 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 3 Aug 2020 14:12:32 +0200 Subject: MRF Object Age Policy: Don't break on messages without cc/to --- .../web/activity_pub/mrf/object_age_policy.ex | 13 ++++--- .../activity_pub/mrf/object_age_policy_test.exs | 42 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index 5f111c72f..d45d2d7e3 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -37,8 +37,13 @@ defp check_reject(message, actions) do defp check_delist(message, actions) do if :delist in actions do with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do - to = List.delete(message["to"], Pleroma.Constants.as_public()) ++ [user.follower_address] - cc = List.delete(message["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()] + to = + List.delete(message["to"] || [], Pleroma.Constants.as_public()) ++ + [user.follower_address] + + cc = + List.delete(message["cc"] || [], user.follower_address) ++ + [Pleroma.Constants.as_public()] message = message @@ -58,8 +63,8 @@ defp check_delist(message, actions) do defp check_strip_followers(message, actions) do if :strip_followers in actions do with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do - to = List.delete(message["to"], user.follower_address) - cc = List.delete(message["cc"], user.follower_address) + to = List.delete(message["to"] || [], user.follower_address) + cc = List.delete(message["cc"] || [], user.follower_address) message = message 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 b0fb753bd..cf6acc9a2 100644 --- a/test/web/activity_pub/mrf/object_age_policy_test.exs +++ b/test/web/activity_pub/mrf/object_age_policy_test.exs @@ -38,6 +38,17 @@ defp get_new_message do end describe "with reject action" do + test "works with objects with empty to or cc fields" do + Config.put([:mrf_object_age, :actions], [:reject]) + + data = + get_old_message() + |> Map.put("cc", nil) + |> Map.put("to", nil) + + assert match?({:reject, _}, ObjectAgePolicy.filter(data)) + end + test "it rejects an old post" do Config.put([:mrf_object_age, :actions], [:reject]) @@ -56,6 +67,21 @@ test "it allows a new post" do end describe "with delist action" do + test "works with objects with empty to or cc fields" do + Config.put([:mrf_object_age, :actions], [:delist]) + + data = + get_old_message() + |> Map.put("cc", nil) + |> Map.put("to", nil) + + {:ok, _u} = User.get_or_fetch_by_ap_id(data["actor"]) + + {:ok, data} = ObjectAgePolicy.filter(data) + + assert Visibility.get_visibility(%{data: data}) == "unlisted" + end + test "it delists an old post" do Config.put([:mrf_object_age, :actions], [:delist]) @@ -80,6 +106,22 @@ test "it allows a new post" do end describe "with strip_followers action" do + test "works with objects with empty to or cc fields" do + Config.put([:mrf_object_age, :actions], [:strip_followers]) + + data = + get_old_message() + |> Map.put("cc", nil) + |> Map.put("to", nil) + + {:ok, user} = User.get_or_fetch_by_ap_id(data["actor"]) + + {:ok, data} = ObjectAgePolicy.filter(data) + + refute user.follower_address in data["to"] + refute user.follower_address in data["cc"] + end + test "it strips followers collections from an old post" do Config.put([:mrf_object_age, :actions], [:strip_followers]) -- cgit v1.2.3 From de3bdc63adac0141500bdc2692124cd104330bda Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 3 Aug 2020 15:00:14 +0200 Subject: AccountControllerTest: Add test for message returned. --- .../controllers/account_controller_test.exs | 29 ++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index d390c3ce1..2cb388655 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -940,17 +940,32 @@ test "registers and logs in without :account_activation_required / :account_appr assert refresh assert scope == "read write follow" + clear_config([User, :email_blacklist], ["example.org"]) + + params = %{ + username: "lain", + email: "lain@example.org", + password: "PlzDontHackLain", + bio: "Test Bio", + agreement: true + } + conn = build_conn() |> put_req_header("content-type", "multipart/form-data") |> put_req_header("authorization", "Bearer " <> token) - |> post("/api/v1/accounts", %{ - username: "lain", - email: "lain@example.org", - password: "PlzDontHackLain", - bio: "Test Bio", - agreement: true - }) + |> post("/api/v1/accounts", params) + + assert %{"error" => "{\"email\":[\"Email domain is blacklisted\"]}"} = + json_response_and_validate_schema(conn, 400) + + Pleroma.Config.put([User, :email_blacklist], []) + + conn = + build_conn() + |> put_req_header("content-type", "multipart/form-data") + |> put_req_header("authorization", "Bearer " <> token) + |> post("/api/v1/accounts", params) %{ "access_token" => token, -- cgit v1.2.3 From 187d9bda0f28d5cc9548abc0b81f4f34e2aaacb1 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 3 Aug 2020 16:39:01 +0200 Subject: Description: Add new fields for frontend configuration. --- config/description.exs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/config/description.exs b/config/description.exs index b96fe9705..969d50a1d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3515,6 +3515,23 @@ key: "ref", type: :string, description: "reference of the installed primary frontend to be used" + }, + %{ + key: "git", + type: :string, + description: "URL of the git repository of the frontend" + }, + %{ + key: "build_url", + type: :string, + description: + "Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.", + example: "https://some.url/builds/${ref}.zip" + }, + %{ + key: "build_dir", + type: :string, + description: "The directory inside the zip file " } ] }, @@ -3532,6 +3549,23 @@ key: "ref", type: :string, description: "reference of the installed Admin frontend to be used" + }, + %{ + key: "git", + type: :string, + description: "URL of the git repository of the frontend" + }, + %{ + key: "build_url", + type: :string, + description: + "Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.", + example: "https://some.url/builds/${ref}.zip" + }, + %{ + key: "build_dir", + type: :string, + description: "The directory inside the zip file " } ] } -- cgit v1.2.3 From 5c2745725e2480f18bc81176575210bbef7f286c Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 3 Aug 2020 17:44:59 +0200 Subject: Docs: Document installation of frontends Co-authored-by: Roman Chvanikov --- docs/administration/CLI_tasks/frontend.md | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/administration/CLI_tasks/frontend.md diff --git a/docs/administration/CLI_tasks/frontend.md b/docs/administration/CLI_tasks/frontend.md new file mode 100644 index 000000000..50bb926d0 --- /dev/null +++ b/docs/administration/CLI_tasks/frontend.md @@ -0,0 +1,50 @@ +# Managing frontends + +`mix pleroma.frontend install [--ref ] [--file ] [--build-url ] [--path ] [--build-dir ]` + +Frontend can be installed either from local zip file, or automatically downloaded from the web. + +You can give all the options directly on the command like, but missing information will be filled out by looking at the data configured under `frontends.available` in the config files. + +Currently known `` values are: +- [admin-fe](https://git.pleroma.social/pleroma/admin-fe) +- [kenoma](http://git.pleroma.social/lambadalambda/kenoma) +- [pleroma-fe](http://git.pleroma.social/pleroma/pleroma-fe) +- [fedi-fe](https://git.pleroma.social/pleroma/fedi-fe) +- [soapbox-fe](https://gitlab.com/soapbox-pub/soapbox-fe) + +You can still install frontends that are not configured, see below. + +## Example installations for a known frontend + +For a frontend configured under the `available` key, it's enough to install it by name. + +```bash +mix pleroma.frontend install pleroma +``` + +This will download the latest build for the the pre-configured `ref` and install it. It can then be configured as the one of the served frontends in the config file (see `primary` or `admin`). + +You can override any of the details. To install a pleroma build from a different url, you could do this: + +```bash +mix pleroma.frontend install pleroma --ref 2huedition --build-url https://example.org/raymoo.zip +``` + +Similarly, you can also install from a local zip file. + +```bash +mix pleroma.frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip +``` + +The resulting frontend will always be installed into a folder of this template: `${instance_static}/frontends/${name}/${ref}` + +Careful: This folder will be completely replaced on installation + +## Example installation for an unknown frontend + +The installation process is the same, but you will have to give all the needed options on the commond line. For example: + +```bash +mix pleroma.frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip +``` -- cgit v1.2.3 From 8b1da33a54dc3c6a489be7e2391e64af9e24d439 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 3 Aug 2020 17:50:53 +0200 Subject: Docs: Add info about installing from a local path. --- docs/administration/CLI_tasks/frontend.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/administration/CLI_tasks/frontend.md b/docs/administration/CLI_tasks/frontend.md index 50bb926d0..11c1c8614 100644 --- a/docs/administration/CLI_tasks/frontend.md +++ b/docs/administration/CLI_tasks/frontend.md @@ -48,3 +48,6 @@ The installation process is the same, but you will have to give all the needed o ```bash mix pleroma.frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip ``` + +If you don't have a zip file but just want to install a frontend from a local path, you can simply copy the files over a folder of this template: `${instance_static}/frontends/${name}/${ref}` + -- cgit v1.2.3 From e26f2c913529748423384d467472f3ad06248ea4 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 3 Aug 2020 18:23:26 +0200 Subject: Changelog: Update with frontend mix task. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb782e82c..1edaed2c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Frontends: Add mix task to install frontends. - Frontends: Add configurable frontends for primary and admin fe. - Chats: Added `accepts_chat_messages` field to user, exposed in APIs and federation. - Chats: Added support for federated chats. For details, see the docs. -- cgit v1.2.3 From 13e5540c2c0945e9c81f5289f74526f837715c6d Mon Sep 17 00:00:00 2001 From: Ilja Date: Mon, 3 Aug 2020 16:44:56 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 9c8cbacb5..439f17fd7 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1013,7 +1013,7 @@ description: "A message that will be sent to a newly registered users as a chat message", suggestions: [ - "Hi, @username! Welcome on board!" + "Hello, welcome on board!" ] }, %{ -- cgit v1.2.3 From 016d8d6c560cb81dfe67cc660e12d2e70d0bc6af Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 3 Aug 2020 12:37:31 -0500 Subject: Consolidate construction of Rich Media Parser HTTP requests --- lib/pleroma/web/rich_media/helpers.ex | 21 +++++++++++++++++++++ lib/pleroma/web/rich_media/parser.ex | 20 +------------------- lib/pleroma/web/rich_media/parsers/oembed_parser.ex | 2 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 5c7daf1a5..6210f2c5a 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -9,6 +9,11 @@ defmodule Pleroma.Web.RichMedia.Helpers do alias Pleroma.Object alias Pleroma.Web.RichMedia.Parser + @rich_media_options [ + pool: :media, + max_body: 2_000_000 + ] + @spec validate_page_url(URI.t() | binary()) :: :ok | :error defp validate_page_url(page_url) when is_binary(page_url) do validate_tld = Pleroma.Config.get([Pleroma.Formatter, :validate_tld]) @@ -77,4 +82,20 @@ def perform(:fetch, %Activity{} = activity) do fetch_data_for_activity(activity) :ok end + + def rich_media_get(url) do + headers = [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + + options = + if Application.get_env(:tesla, :adapter) == Tesla.Adapter.Hackney do + Keyword.merge(@rich_media_options, + recv_timeout: 2_000, + with_body: true + ) + else + @rich_media_options + end + + Pleroma.HTTP.get(url, headers, options) + end end diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index c8a767935..ca592833f 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -3,11 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parser do - @options [ - pool: :media, - max_body: 2_000_000 - ] - defp parsers do Pleroma.Config.get([:rich_media, :parsers]) end @@ -75,21 +70,8 @@ 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 - rich_media_agent = Pleroma.Application.user_agent() <> "; Bot" - - {:ok, %Tesla.Env{body: html}} = - Pleroma.HTTP.get(url, [{"user-agent", rich_media_agent}], adapter: opts) + {:ok, %Tesla.Env{body: html}} = Pleroma.Web.RichMedia.Helpers.rich_media_get(url) html |> parse_html() diff --git a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex index 6bdeac89c..1fe6729c3 100644 --- a/lib/pleroma/web/rich_media/parsers/oembed_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/oembed_parser.ex @@ -22,7 +22,7 @@ defp get_oembed_url([{"link", attributes, _children} | _]) do end defp get_oembed_data(url) do - with {:ok, %Tesla.Env{body: json}} <- Pleroma.HTTP.get(url, [], adapter: [pool: :media]) do + with {:ok, %Tesla.Env{body: json}} <- Pleroma.Web.RichMedia.Helpers.rich_media_get(url) do Jason.decode(json) end end -- cgit v1.2.3 From cbf8bfc6942cbfbb5266a20d9929faf2e192ac70 Mon Sep 17 00:00:00 2001 From: Ilja Date: Mon, 3 Aug 2020 20:13:43 +0200 Subject: Improved WelcomeChatMessageTest * Checks if message is the same using ==/2 instead of =~/2 --- test/user/welcome_chat_massage_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/user/welcome_chat_massage_test.exs b/test/user/welcome_chat_massage_test.exs index 3fef6fa6d..fe26d6e4d 100644 --- a/test/user/welcome_chat_massage_test.exs +++ b/test/user/welcome_chat_massage_test.exs @@ -14,22 +14,22 @@ defmodule Pleroma.User.WelcomeChatMessageTest do describe "post_message/1" do test "send a chat welcome message" do - welcome_user = insert(:user) - user = insert(:user, name: "mewmew") + welcome_user = insert(:user, name: "mewmew") + user = insert(:user) Config.put([:welcome, :chat_message, :enabled], true) Config.put([:welcome, :chat_message, :sender_nickname], welcome_user.nickname) Config.put( [:welcome, :chat_message, :message], - "Hello. Welcome to blob.cat" + "Hello, welcome to Blob/Cat!" ) {:ok, %Pleroma.Activity{} = activity} = WelcomeChatMessage.post_message(user) assert user.ap_id in activity.recipients assert Pleroma.Object.normalize(activity).data["type"] == "ChatMessage" - assert Pleroma.Object.normalize(activity).data["content"] =~ "Hello. Welcome to " + assert Pleroma.Object.normalize(activity).data["content"] == "Hello, welcome to Blob/Cat!" end end end -- cgit v1.2.3 From 1489c2ae5fdb01ee2f1a40c40582842868cac888 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Tue, 4 Aug 2020 01:45:18 +0300 Subject: Fix :args settings description in Upload.Filter.Mogrify group --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index ae2f6d23f..00bab20eb 100644 --- a/config/description.exs +++ b/config/description.exs @@ -194,7 +194,7 @@ type: [:string, {:list, :string}, {:list, :tuple}], description: "List of actions for the mogrify command. It's possible to add self-written settings as string. " <> - "For example `[\"auto-orient\", \"strip\", {\"resize\", \"3840x1080>\"}]` string will be parsed into list of the settings.", + "For example `auto-orient, strip, {\"resize\", \"3840x1080>\"}` value will be parsed into valid list of the settings.", suggestions: [ "strip", "auto-orient", -- cgit v1.2.3 From ae95472dccbf708259f49730149a1599e9ac0e9c Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Tue, 4 Aug 2020 02:04:29 +0300 Subject: Update :welcome settings description --- config/description.exs | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/config/description.exs b/config/description.exs index 00bab20eb..a947c8f3f 100644 --- a/config/description.exs +++ b/config/description.exs @@ -964,25 +964,26 @@ ] }, %{ - group: :welcome, + group: :pleroma, + key: :welcome, type: :group, description: "Welcome messages settings", children: [ %{ - group: :direct_message, - type: :group, + key: :direct_message, + type: :keyword, descpiption: "Direct message settings", children: [ %{ key: :enabled, type: :boolean, - description: "Enables sends direct message for new user after registration" + description: "Enables sending a direct message to newly registered users" }, %{ key: :message, type: :string, description: - "A message that will be sent to a newly registered users as a direct message", + "A message that will be sent to newly registered users", suggestions: [ "Hi, @username! Welcome on board!" ] @@ -990,7 +991,7 @@ %{ key: :sender_nickname, type: :string, - description: "The nickname of the local user that sends the welcome message", + description: "The nickname of the local user that sends a welcome message", suggestions: [ "lain" ] @@ -998,20 +999,20 @@ ] }, %{ - group: :chat_message, - type: :group, + key: :chat_message, + type: :keyword, descpiption: "Chat message settings", children: [ %{ key: :enabled, type: :boolean, - description: "Enables sends chat message for new user after registration" + description: "Enables sending a chat message to newly registered users" }, %{ key: :message, type: :string, description: - "A message that will be sent to a newly registered users as a chat message", + "A message that will be sent to newly registered users as a chat message", suggestions: [ "Hello, welcome on board!" ] @@ -1019,7 +1020,7 @@ %{ key: :sender_nickname, type: :string, - description: "The nickname of the local user that sends the welcome message", + description: "The nickname of the local user that sends a welcome chat message", suggestions: [ "lain" ] @@ -1027,20 +1028,20 @@ ] }, %{ - group: :email, - type: :group, + key: :email, + type: :keyword, descpiption: "Email message settings", children: [ %{ key: :enabled, type: :boolean, - description: "Enables sends direct message for new user after registration" + description: "Enables sending an email to newly registered users" }, %{ key: :sender, type: [:string, :tuple], description: - "The email address or tuple with `{nickname, email}` that will use as sender to the welcome email.", + "Email address and/or nickname that will be used to send the welcome email.", suggestions: [ {"Pleroma App", "welcome@pleroma.app"} ] @@ -1049,21 +1050,21 @@ key: :subject, type: :string, description: - "The subject of welcome email. Can be use EEX template with `user` and `instance_name` variables.", + "Subject of the welcome email. EEX template with user and instance_name variables can be used.", suggestions: ["Welcome to <%= instance_name%>"] }, %{ key: :html, type: :string, description: - "The html content of welcome email. Can be use EEX template with `user` and `instance_name` variables.", + "HTML content of the welcome email. EEX template with user and instance_name variables can be used.", suggestions: ["

    Hello <%= user.name%>. Welcome to <%= instance_name%>

    "] }, %{ key: :text, type: :string, description: - "The text content of welcome email. Can be use EEX template with `user` and `instance_name` variables.", + "Text content of the welcome email. EEX template with user and instance_name variables can be used.", suggestions: ["Hello <%= user.name%>. \n Welcome to <%= instance_name%>\n"] } ] -- cgit v1.2.3 From 63b1ca6a0766772edb2affc65c42e2dad96c0de4 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Tue, 4 Aug 2020 02:21:25 +0300 Subject: Add label to :restrict_unauthenticated setting, fix typos --- config/description.exs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/config/description.exs b/config/description.exs index a947c8f3f..9c8e330bf 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3293,13 +3293,13 @@ group: :pleroma, key: :connections_pool, type: :group, - description: "Advanced settings for `gun` connections pool", + description: "Advanced settings for `Gun` connections pool", children: [ %{ key: :connection_acquisition_wait, type: :integer, description: - "Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries. Default: 250ms.", + "Timeout to acquire a connection from pool. The total max time is this value multiplied by the number of retries. Default: 250ms.", suggestions: [250] }, %{ @@ -3334,7 +3334,7 @@ group: :pleroma, key: :pools, type: :group, - description: "Advanced settings for `gun` workers pools", + description: "Advanced settings for `Gun` workers pools", children: Enum.map([:federation, :media, :upload, :default], fn pool_name -> %{ @@ -3363,7 +3363,7 @@ group: :pleroma, key: :hackney_pools, type: :group, - description: "Advanced settings for `hackney` connections pools", + description: "Advanced settings for `Hackney` connections pools", children: [ %{ key: :federation, @@ -3427,6 +3427,7 @@ %{ group: :pleroma, key: :restrict_unauthenticated, + label: "Restrict Unauthenticated", type: :group, description: "Disallow viewing timelines, user profiles and statuses for unauthenticated users.", -- cgit v1.2.3 From 058daf498f10e58221bd29a42799f52e56a800a9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Aug 2020 19:57:53 -0500 Subject: Email blacklist: Update response phrasing --- lib/pleroma/user.ex | 2 +- test/web/mastodon_api/controllers/account_controller_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d0cc098fe..16679ac42 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -684,7 +684,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do !String.ends_with?(email, ["@" <> blacklisted_domain, "." <> blacklisted_domain]) end) - if valid?, do: [], else: [email: "Email domain is blacklisted"] + if valid?, do: [], else: [credentials: "Invalid credentials"] end) |> unique_constraint(:nickname) |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 2cb388655..86e3ac3e7 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -956,7 +956,7 @@ test "registers and logs in without :account_activation_required / :account_appr |> put_req_header("authorization", "Bearer " <> token) |> post("/api/v1/accounts", params) - assert %{"error" => "{\"email\":[\"Email domain is blacklisted\"]}"} = + assert %{"error" => "{\"credentials\":[\"Invalid credentials\"]}"} = json_response_and_validate_schema(conn, 400) Pleroma.Config.put([User, :email_blacklist], []) -- cgit v1.2.3 From 4f57e85ab9c80fb7cb51428cef978793ba22971c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Aug 2020 22:20:49 -0500 Subject: Email blacklist: Update phrasing again --- lib/pleroma/user.ex | 2 +- test/web/mastodon_api/controllers/account_controller_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 16679ac42..9e03373de 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -684,7 +684,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do !String.ends_with?(email, ["@" <> blacklisted_domain, "." <> blacklisted_domain]) end) - if valid?, do: [], else: [credentials: "Invalid credentials"] + if valid?, do: [], else: [email: "Invalid email"] end) |> unique_constraint(:nickname) |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) diff --git a/test/web/mastodon_api/controllers/account_controller_test.exs b/test/web/mastodon_api/controllers/account_controller_test.exs index 86e3ac3e7..17a1e7d66 100644 --- a/test/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/web/mastodon_api/controllers/account_controller_test.exs @@ -956,7 +956,7 @@ test "registers and logs in without :account_activation_required / :account_appr |> put_req_header("authorization", "Bearer " <> token) |> post("/api/v1/accounts", params) - assert %{"error" => "{\"credentials\":[\"Invalid credentials\"]}"} = + assert %{"error" => "{\"email\":[\"Invalid email\"]}"} = json_response_and_validate_schema(conn, 400) Pleroma.Config.put([User, :email_blacklist], []) -- cgit v1.2.3 From 2f4289d455fbd2d949ac1e10d5ea2b9c78f15e82 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 12:49:56 +0200 Subject: Changelog: Add info about email blacklist --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129c269aa..6ae5fb928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Configuration: Added a blacklist for email servers. - Chats: Added `accepts_chat_messages` field to user, exposed in APIs and federation. - Chats: Added support for federated chats. For details, see the docs. - ActivityPub: Added support for existing AP ids for instances migrated from Mastodon. -- cgit v1.2.3 From 56e9bf33932bacfdffd700b97e3117fc593cac11 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 4 Aug 2020 14:35:47 +0300 Subject: Unify Config.get behaviour for atom/list key param --- lib/pleroma/config.ex | 34 +++++++++++++++++++++++++++------- test/config_test.exs | 28 ++++++++++++++++++++++++++++ test/support/helpers.ex | 14 ++++++++++++-- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index cc80deff5..88d1972ba 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -11,13 +11,33 @@ def get(key), do: get(key, nil) def get([key], default), do: get(key, default) - def get([parent_key | keys], default) do - case :pleroma - |> Application.get_env(parent_key) - |> get_in(keys) do - nil -> default - any -> any - end + def get([root_key | keys], default) do + # This is to mimic Application.get_env/3 behaviour that returns `nil` if the + # actual value is `nil`. + Enum.reduce_while(keys, Application.get_env(:pleroma, root_key), fn key, config -> + case key do + [last_key] when is_map(config) -> + {:halt, Map.get(config, last_key, default)} + + [last_key] when is_list(config) -> + {:halt, Keyword.get(config, last_key, default)} + + _ -> + case config do + %{^key => value} -> + {:cont, value} + + [_ | _] -> + case :lists.keyfind(key, 1, config) do + {_, value} -> {:cont, value} + _ -> {:halt, default} + end + + _ -> + {:halt, default} + end + end + end) end def get(key, default) do diff --git a/test/config_test.exs b/test/config_test.exs index a46ab4302..3f3da06d0 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -28,6 +28,34 @@ test "get/1 with a list of keys" do assert Pleroma.Config.get([:azerty, :uiop], true) == true end + describe "nil values" do + setup do + Pleroma.Config.put(:lorem, nil) + Pleroma.Config.put(:ipsum, %{dolor: [sit: nil]}) + Pleroma.Config.put(:dolor, sit: %{amet: nil}) + + on_exit(fn -> Enum.each(~w(lorem ipsum dolor)a, &Pleroma.Config.delete/1) end) + end + + test "get/1 with an atom for nil value" do + assert Pleroma.Config.get(:lorem) == nil + end + + test "get/2 with an atom for nil value" do + assert Pleroma.Config.get(:lorem, true) == nil + end + + test "get/1 with a list of keys for nil value" do + assert Pleroma.Config.get([:ipsum, :dolor, :sit]) == nil + assert Pleroma.Config.get([:dolor, :sit, :amet]) == nil + end + + test "get/2 with a list of keys for nil value" do + assert Pleroma.Config.get([:ipsum, :dolor, :sit], true) == nil + assert Pleroma.Config.get([:dolor, :sit, :amet], true) == nil + end + end + test "get/1 when value is false" do Pleroma.Config.put([:instance, :false_test], false) Pleroma.Config.put([:instance, :nested], []) diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 5cbf2e291..7d729541d 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -17,9 +17,19 @@ defmacro clear_config(config_path) do defmacro clear_config(config_path, do: yield) do quote do - initial_setting = Config.get(unquote(config_path)) + initial_setting = Config.get(unquote(config_path), :__clear_config_absent__) unquote(yield) - on_exit(fn -> Config.put(unquote(config_path), initial_setting) end) + + on_exit(fn -> + case initial_setting do + :__clear_config_absent__ -> + Config.delete(unquote(config_path)) + + _ -> + Config.put(unquote(config_path), initial_setting) + end + end) + :ok end end -- cgit v1.2.3 From 953f71bcfa25569d8b92d4047f4bdbee97e0077c Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 13:38:30 +0200 Subject: App Test: Make more resilient --- test/tasks/app_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/tasks/app_test.exs b/test/tasks/app_test.exs index b8f03566d..71a84ac8e 100644 --- a/test/tasks/app_test.exs +++ b/test/tasks/app_test.exs @@ -50,13 +50,13 @@ test "with errors" do defp assert_app(name, redirect, scopes) do app = Repo.get_by(Pleroma.Web.OAuth.App, client_name: name) - assert_received {:mix_shell, :info, [message]} + assert_receive {:mix_shell, :info, [message]} assert message == "#{name} successfully created:" - assert_received {:mix_shell, :info, [message]} + assert_receive {:mix_shell, :info, [message]} assert message == "App client_id: #{app.client_id}" - assert_received {:mix_shell, :info, [message]} + assert_receive {:mix_shell, :info, [message]} assert message == "App client_secret: #{app.client_secret}" assert app.scopes == scopes -- cgit v1.2.3 From 988ca4ab6a0d299308d96e84aa45ef63341128bf Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 14:07:10 +0200 Subject: Test Config: Don't have any MRFs by default --- config/test.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/test.exs b/config/test.exs index db0655e73..413c7f0b9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -120,6 +120,8 @@ config :tzdata, :autoupdate, :disabled +config :pleroma, :mrf, policies: [] + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else -- cgit v1.2.3 From e92c040ad3d0cc568ea0dc4b79f207a392c7c90f Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 14:08:12 +0200 Subject: CommonAPITest: Add test that deactivated users can't post. --- test/web/common_api/common_api_test.exs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/web/common_api/common_api_test.exs b/test/web/common_api/common_api_test.exs index 313dda21b..4ba6232dc 100644 --- a/test/web/common_api/common_api_test.exs +++ b/test/web/common_api/common_api_test.exs @@ -458,6 +458,11 @@ test "it adds emoji in the object" do end describe "posting" do + test "deactivated users can't post" do + user = insert(:user, deactivated: true) + assert {:error, _} = CommonAPI.post(user, %{status: "ye"}) + end + test "it supports explicit addressing" do user = insert(:user) user_two = insert(:user) -- cgit v1.2.3 From 8bb54415470852f95967bc75fb8917db78eb0fbd Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Tue, 4 Aug 2020 15:10:44 +0300 Subject: Update descriptions in :frontends group --- config/description.exs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 9c8e330bf..7da01b175 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3550,13 +3550,15 @@ children: [ %{ key: "name", + label: "Name", type: :string, - description: "Name of the installed primary frontend" + description: "Name of the installed primary frontend. Valid config must include both `Name` and `Reference` values." }, %{ key: "ref", + label: "Reference", type: :string, - description: "reference of the installed primary frontend to be used" + description: "Reference of the installed primary frontend to be used. Valid config must include both `Name` and `Reference` values." } ] } -- cgit v1.2.3 From 0cfadcf2caf84e2db944036576bad888a9707ff1 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 14:15:32 +0200 Subject: TransmogrifierTest: Add test for deactivated users --- test/web/activity_pub/transmogrifier_test.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 7d33feaf2..828964a36 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -163,6 +163,14 @@ test "it does not crash if the object in inReplyTo can't be fetched" do end) =~ "[warn] Couldn't fetch \"https://404.site/whatever\", error: nil" end + test "it does not work for deactivated users" do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() + + insert(:user, ap_id: data["actor"], deactivated: true) + + assert {:error, _} = Transmogrifier.handle_incoming(data) + end + test "it works for incoming notices" do data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() -- cgit v1.2.3 From 1a00713744803824b16efd575c9c6880b1d1a57e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 14:17:03 +0200 Subject: CommonValidations: Treat deactivated users as not present. --- .../object_validators/common_validations.ex | 13 +++++++++---- .../activity_pub/transmogrifier/chat_message_test.exs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index aeef31945..bd46f8034 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -34,10 +34,15 @@ def validate_actor_presence(cng, options \\ []) 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"}] + case User.get_cached_by_ap_id(actor) do + %User{deactivated: true} -> + [{field_name, "user is deactivated"}] + + %User{} -> + [] + + _ -> + [{field_name, "can't find user"}] end end) end diff --git a/test/web/activity_pub/transmogrifier/chat_message_test.exs b/test/web/activity_pub/transmogrifier/chat_message_test.exs index d6736dc3e..31274c067 100644 --- a/test/web/activity_pub/transmogrifier/chat_message_test.exs +++ b/test/web/activity_pub/transmogrifier/chat_message_test.exs @@ -124,6 +124,24 @@ test "it fetches the actor if they aren't in our system" do {:ok, %Activity{} = _activity} = Transmogrifier.handle_incoming(data) end + test "it doesn't work for deactivated users" do + data = + File.read!("test/fixtures/create-chat-message.json") + |> Poison.decode!() + + _author = + insert(:user, + ap_id: data["actor"], + local: false, + last_refreshed_at: DateTime.utc_now(), + deactivated: true + ) + + _recipient = insert(:user, ap_id: List.first(data["to"]), local: true) + + assert {:error, _} = Transmogrifier.handle_incoming(data) + end + test "it inserts it and creates a chat" do data = File.read!("test/fixtures/create-chat-message.json") -- cgit v1.2.3 From 0f088d8ce35150d7baa0591a25c831fce0181239 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 4 Aug 2020 14:23:35 +0200 Subject: question_validator: Allow content to be an empty-string (blank) --- .../web/activity_pub/object_validators/question_validator.ex | 2 +- test/web/activity_pub/transmogrifier/question_handling_test.exs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 466b3e6c2..d248c6aec 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -111,7 +111,7 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Question"]) - |> validate_required([:id, :actor, :attributedTo, :type, :content, :context]) + |> validate_required([:id, :actor, :attributedTo, :type, :context]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) |> CommonValidations.validate_actor_is_active() diff --git a/test/web/activity_pub/transmogrifier/question_handling_test.exs b/test/web/activity_pub/transmogrifier/question_handling_test.exs index 12516c4ab..9fb965d7f 100644 --- a/test/web/activity_pub/transmogrifier/question_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/question_handling_test.exs @@ -111,4 +111,13 @@ test "returns an error if received a second time" do assert {:error, {:validate_object, {:error, _}}} = Transmogrifier.handle_incoming(data) end + + test "accepts a Question with no content" do + data = + File.read!("test/fixtures/mastodon-question-activity.json") + |> Poison.decode!() + |> Kernel.put_in(["object", "content"], "") + + assert {:ok, %Activity{local: false}} = Transmogrifier.handle_incoming(data) + end end -- cgit v1.2.3 From 36aa34a1a8c489f74a9821095d823f8060afac5f Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 4 Aug 2020 15:08:51 +0200 Subject: MastodonAPITest: Do the needful --- test/web/mastodon_api/mastodon_api_test.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/web/mastodon_api/mastodon_api_test.exs b/test/web/mastodon_api/mastodon_api_test.exs index c08be37d4..0c5a38bf6 100644 --- a/test/web/mastodon_api/mastodon_api_test.exs +++ b/test/web/mastodon_api/mastodon_api_test.exs @@ -17,8 +17,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPITest do test "returns error when followed user is deactivated" do follower = insert(:user) user = insert(:user, local: true, deactivated: true) - {:error, error} = MastodonAPI.follow(follower, user) - assert error == :rejected + assert {:error, _error} = MastodonAPI.follow(follower, user) end test "following for user" do -- cgit v1.2.3 From 697e3db01c0a1ee1e18fe25946a4ef56527828e7 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 4 Aug 2020 08:55:40 -0500 Subject: Add analyze mix alias to run the same credo checks we use in CI --- mix.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 0e723c15f..63142dee7 100644 --- a/mix.exs +++ b/mix.exs @@ -214,7 +214,8 @@ defp aliases do "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate", "test"], - docs: ["pleroma.docs", "docs"] + docs: ["pleroma.docs", "docs"], + analyze: ["credo --strict --only=warnings,todo,fixme,consistency,readability"] ] end -- cgit v1.2.3 From 91fbb5b21f9d8f098c9796eb4dd917bcd1e92404 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 4 Aug 2020 18:26:37 +0400 Subject: Fix ActivityExpirationPolicy --- lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex | 4 ++-- test/web/activity_pub/mrf/activity_expiration_policy_test.exs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index 8e47f1e02..7b4c78e0f 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -21,8 +21,8 @@ def filter(activity) do @impl true def describe, do: {:ok, %{}} - defp local?(%{"id" => id}) do - String.starts_with?(id, Pleroma.Web.Endpoint.url()) + defp local?(%{"actor" => actor}) do + String.starts_with?(actor, Pleroma.Web.Endpoint.url()) end defp note?(activity) do diff --git a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs index 8babf49e7..f25cf8b12 100644 --- a/test/web/activity_pub/mrf/activity_expiration_policy_test.exs +++ b/test/web/activity_pub/mrf/activity_expiration_policy_test.exs @@ -7,11 +7,13 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicyTest do alias Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy @id Pleroma.Web.Endpoint.url() <> "/activities/cofe" + @local_actor Pleroma.Web.Endpoint.url() <> "/users/cofe" test "adds `expires_at` property" do assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = ActivityExpirationPolicy.filter(%{ "id" => @id, + "actor" => @local_actor, "type" => "Create", "object" => %{"type" => "Note"} }) @@ -25,6 +27,7 @@ test "keeps existing `expires_at` if it less than the config setting" do assert {:ok, %{"type" => "Create", "expires_at" => ^expires_at}} = ActivityExpirationPolicy.filter(%{ "id" => @id, + "actor" => @local_actor, "type" => "Create", "expires_at" => expires_at, "object" => %{"type" => "Note"} @@ -37,6 +40,7 @@ test "overwrites existing `expires_at` if it greater than the config setting" do assert {:ok, %{"type" => "Create", "expires_at" => expires_at}} = ActivityExpirationPolicy.filter(%{ "id" => @id, + "actor" => @local_actor, "type" => "Create", "expires_at" => too_distant_future, "object" => %{"type" => "Note"} @@ -49,6 +53,7 @@ test "ignores remote activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", + "actor" => "https://example.com/users/cofe", "type" => "Create", "object" => %{"type" => "Note"} }) @@ -60,6 +65,7 @@ test "ignores non-Create/Note activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", + "actor" => "https://example.com/users/cofe", "type" => "Follow" }) @@ -68,6 +74,7 @@ test "ignores non-Create/Note activities" do assert {:ok, activity} = ActivityExpirationPolicy.filter(%{ "id" => "https://example.com/123", + "actor" => "https://example.com/users/cofe", "type" => "Create", "object" => %{"type" => "Cofe"} }) -- cgit v1.2.3 From 184742af5eed2c48ba8518f1e114cbe0655ad467 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 3 Aug 2020 22:32:51 -0500 Subject: Unique apps.client_id for new installations, fixes #2022 --- .../20200804183107_add_unique_index_to_app_client_id.exs | 7 +++++++ test/web/oauth/app_test.exs | 11 +++++++++++ 2 files changed, 18 insertions(+) create mode 100644 priv/repo/migrations/20200804183107_add_unique_index_to_app_client_id.exs diff --git a/priv/repo/migrations/20200804183107_add_unique_index_to_app_client_id.exs b/priv/repo/migrations/20200804183107_add_unique_index_to_app_client_id.exs new file mode 100644 index 000000000..83de18096 --- /dev/null +++ b/priv/repo/migrations/20200804183107_add_unique_index_to_app_client_id.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddUniqueIndexToAppClientId do + use Ecto.Migration + + def change do + create(unique_index(:apps, [:client_id])) + end +end diff --git a/test/web/oauth/app_test.exs b/test/web/oauth/app_test.exs index 899af648e..993a490e0 100644 --- a/test/web/oauth/app_test.exs +++ b/test/web/oauth/app_test.exs @@ -29,5 +29,16 @@ test "gets exist app and updates scopes" do assert exist_app.id == app.id assert exist_app.scopes == ["read", "write", "follow", "push"] end + + test "has unique client_id" do + insert(:oauth_app, client_name: "", redirect_uris: "", client_id: "boop") + + error = + catch_error(insert(:oauth_app, client_name: "", redirect_uris: "", client_id: "boop")) + + assert %Ecto.ConstraintError{} = error + assert error.constraint == "apps_client_id_index" + assert error.type == :unique + end end end -- cgit v1.2.3 From 079e410d6efcb39e72a238c13e52bd1898b442a2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 4 Aug 2020 13:12:23 -0500 Subject: Add a migration to clean up activity_expirations table --- .../20200804180322_remove_nonlocal_expirations.exs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 priv/repo/migrations/20200804180322_remove_nonlocal_expirations.exs diff --git a/priv/repo/migrations/20200804180322_remove_nonlocal_expirations.exs b/priv/repo/migrations/20200804180322_remove_nonlocal_expirations.exs new file mode 100644 index 000000000..389935f0d --- /dev/null +++ b/priv/repo/migrations/20200804180322_remove_nonlocal_expirations.exs @@ -0,0 +1,19 @@ +defmodule Pleroma.Repo.Migrations.RemoveNonlocalExpirations do + use Ecto.Migration + + def up do + statement = """ + DELETE FROM + activity_expirations A USING activities B + WHERE + A.activity_id = B.id + AND B.local = false; + """ + + execute(statement) + end + + def down do + :ok + end +end -- cgit v1.2.3 From 577b11167cb55203d30c43773f40108a87b2be6d Mon Sep 17 00:00:00 2001 From: Karol Kosek Date: Wed, 5 Aug 2020 00:01:30 +0200 Subject: templates/layout/app.html.eex: fix link color --- lib/pleroma/web/templates/layout/app.html.eex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/templates/layout/app.html.eex b/lib/pleroma/web/templates/layout/app.html.eex index 5836ec1e0..51603fe0c 100644 --- a/lib/pleroma/web/templates/layout/app.html.eex +++ b/lib/pleroma/web/templates/layout/app.html.eex @@ -37,7 +37,7 @@ } a { - color: color: #d8a070; + color: #d8a070; text-decoration: none; } -- cgit v1.2.3 From f341a8e142ad9d4c92afc4a97ef387df068e38e0 Mon Sep 17 00:00:00 2001 From: MK Fain Date: Wed, 5 Aug 2020 02:01:27 +0000 Subject: Update filter_view.ex to return whole_word actual value --- lib/pleroma/web/mastodon_api/views/filter_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/filter_view.ex b/lib/pleroma/web/mastodon_api/views/filter_view.ex index aeff646f5..c37f624e0 100644 --- a/lib/pleroma/web/mastodon_api/views/filter_view.ex +++ b/lib/pleroma/web/mastodon_api/views/filter_view.ex @@ -25,7 +25,7 @@ def render("show.json", %{filter: filter}) do context: filter.context, expires_at: expires_at, irreversible: filter.hide, - whole_word: false + whole_word: filter.whole_word } end end -- cgit v1.2.3 From 6f60ac9f41d9511afa71986f000a2fc6c637b0c5 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Wed, 5 Aug 2020 13:00:49 +0300 Subject: Refactor config --- lib/pleroma/config.ex | 61 ++++++++++++++++------------------ test/application_requirements_test.exs | 5 ++- test/config_test.exs | 16 +++++++++ 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 88d1972ba..98099ca58 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -11,33 +11,11 @@ def get(key), do: get(key, nil) def get([key], default), do: get(key, default) - def get([root_key | keys], default) do - # This is to mimic Application.get_env/3 behaviour that returns `nil` if the - # actual value is `nil`. - Enum.reduce_while(keys, Application.get_env(:pleroma, root_key), fn key, config -> - case key do - [last_key] when is_map(config) -> - {:halt, Map.get(config, last_key, default)} - - [last_key] when is_list(config) -> - {:halt, Keyword.get(config, last_key, default)} - - _ -> - case config do - %{^key => value} -> - {:cont, value} - - [_ | _] -> - case :lists.keyfind(key, 1, config) do - {_, value} -> {:cont, value} - _ -> {:halt, default} - end - - _ -> - {:halt, default} - end - end - end) + def get([_ | _] = path, default) do + case fetch(path) do + {:ok, value} -> value + :error -> default + end end def get(key, default) do @@ -54,6 +32,22 @@ def get!(key) do end end + def fetch([root_key | keys]) do + Enum.reduce_while(keys, Application.fetch_env(:pleroma, root_key), fn + key, {:ok, config} when is_map(config) or is_list(config) -> + case Access.fetch(config, key) do + :error -> + {:halt, :error} + + value -> + {:cont, value} + end + + _key, _config -> + {:halt, :error} + end) + end + def put([key], value), do: put(key, value) def put([parent_key | keys], value) do @@ -70,12 +64,15 @@ def put(key, value) do def delete([key]), do: delete(key) - def delete([parent_key | keys]) do - {_, parent} = - Application.get_env(:pleroma, parent_key) - |> get_and_update_in(keys, fn _ -> :pop end) + def delete([parent_key | keys] = path) do + with {:ok, _} <- fetch(path) do + {_, parent} = + parent_key + |> get() + |> get_and_update_in(keys, fn _ -> :pop end) - Application.put_env(:pleroma, parent_key, parent) + Application.put_env(:pleroma, parent_key, parent) + end end def delete(key) do diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index 21d24ddd0..e96295955 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -127,7 +127,10 @@ test "doesn't do anything if rum disabled" do :ok end - setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) + setup do + Pleroma.Config.get(:i_am_aware_this_may_cause_data_loss, 42) |> IO.inspect() + clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) + end test "raises if it detects unapplied migrations" do assert_raise Pleroma.ApplicationRequirements.VerifyError, diff --git a/test/config_test.exs b/test/config_test.exs index 3f3da06d0..e2c18304e 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -117,5 +117,21 @@ test "delete/2 with a list of keys" do Pleroma.Config.put([:delete_me, :delete_me], hello: "world", world: "Hello") Pleroma.Config.delete([:delete_me, :delete_me, :world]) assert Pleroma.Config.get([:delete_me, :delete_me]) == [hello: "world"] + + assert Pleroma.Config.delete([:this_key_does_not_exist]) + assert Pleroma.Config.delete([:non, :existing, :key]) + end + + test "fetch/1" do + Pleroma.Config.put([:lorem], :ipsum) + Pleroma.Config.put([:ipsum], dolor: :sit) + + assert Pleroma.Config.fetch([:lorem]) == {:ok, :ipsum} + assert Pleroma.Config.fetch([:ipsum, :dolor]) == {:ok, :sit} + assert Pleroma.Config.fetch([:lorem, :ipsum]) == :error + assert Pleroma.Config.fetch([:loremipsum]) == :error + + Pleroma.Config.delete([:lorem]) + Pleroma.Config.delete([:ipsum]) end end -- cgit v1.2.3 From 00c4c6a382d9965ea42236232094c4352c9ebae1 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 12:24:34 +0200 Subject: CommonValidations: Remove superfluous function The `is_active` functionality was integrated into the presence checker. --- .../web/activity_pub/object_validators/answer_validator.ex | 2 +- .../activity_pub/object_validators/common_validations.ex | 13 ------------- .../object_validators/create_generic_validator.ex | 2 +- .../activity_pub/object_validators/question_validator.ex | 2 +- 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index ebddd5038..323367642 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -59,7 +59,7 @@ def validate_data(data_cng) do |> validate_required([:id, :inReplyTo, :name, :attributedTo, :actor]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) - |> CommonValidations.validate_actor_is_active() + |> CommonValidations.validate_actor_presence() |> CommonValidations.validate_host_match() end end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 57d4456aa..67352f801 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -47,19 +47,6 @@ def validate_actor_presence(cng, options \\ []) do end) end - def validate_actor_is_active(cng, options \\ []) do - field_name = Keyword.get(options, :field_name, :actor) - - cng - |> validate_change(field_name, fn field_name, actor -> - if %User{deactivated: false} = User.get_cached_by_ap_id(actor) do - [] - else - [{field_name, "can't find user (or deactivated)"}] - end - end) - end - def validate_object_presence(cng, options \\ []) do field_name = Keyword.get(options, :field_name, :object) allowed_types = Keyword.get(options, :allowed_types, false) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index ff889330e..2569df7f6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -55,7 +55,7 @@ def validate_data(cng, meta \\ []) do cng |> validate_required([:actor, :type, :object]) |> validate_inclusion(:type, ["Create"]) - |> validate_actor_is_active() + |> validate_actor_presence() |> validate_any_presence([:to, :cc]) |> validate_actors_match(meta) |> validate_context_match(meta) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index d248c6aec..694cb6730 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -114,7 +114,7 @@ def validate_data(data_cng) do |> validate_required([:id, :actor, :attributedTo, :type, :context]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) - |> CommonValidations.validate_actor_is_active() + |> CommonValidations.validate_actor_presence() |> CommonValidations.validate_any_presence([:oneOf, :anyOf]) |> CommonValidations.validate_host_match() end -- cgit v1.2.3 From 70522989d9d1119e5b3d86151f633f849d92f307 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 11:14:58 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/web/mastodon_api/views/poll_view.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index ce595ae8a..1bfc99259 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -28,10 +28,10 @@ def render("show.json", %{object: object, multiple: multiple, options: options} def render("show.json", %{object: object} = params) do case object.data do - %{"anyOf" => options} when is_list(options) and options != [] -> + %{"anyOf" => [ _ | _] = options} -> render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options})) - %{"oneOf" => options} when is_list(options) and options != [] -> + %{"oneOf" => [ _ | _] = options} -> render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options})) _ -> -- cgit v1.2.3 From b5f0cef156c1d1dd0376a791d8b4be48591f2c27 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 11:33:21 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- lib/pleroma/object.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 4dd929cfd..b3e654857 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -255,9 +255,7 @@ def increase_replies_count(ap_id) do end end - defp poll_is_multiple?(%Object{data: %{"anyOf" => anyOf}}) do - !Enum.empty?(anyOf) - end + defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true defp poll_is_multiple?(_), do: false -- cgit v1.2.3 From f889400d05e86d8d9509577946a0ab3a55b3eabb Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 14:51:33 +0200 Subject: Questions: Move fixes to validators. --- .../object_validators/create_generic_validator.ex | 19 +++++++++++++++++-- .../object_validators/question_validator.ex | 10 ++++++++-- lib/pleroma/web/activity_pub/transmogrifier.ex | 11 ++--------- lib/pleroma/web/mastodon_api/views/poll_view.ex | 4 ++-- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 2569df7f6..60868eae0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -29,7 +29,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do field(:context, :string) end - def cast_data(data) do + def cast_data(data, meta \\ []) do + data = fix(data, meta) + %__MODULE__{} |> changeset(data) end @@ -42,7 +44,7 @@ def cast_and_apply(data) do def cast_and_validate(data, meta \\ []) do data - |> cast_data + |> cast_data(meta) |> validate_data(meta) end @@ -51,6 +53,19 @@ def changeset(struct, data) do |> cast(data, __schema__(:fields)) end + defp fix_context(data, meta) do + if object = meta[:object_data] do + Map.put_new(data, "context", object["context"]) + else + data + end + end + + defp fix(data, meta) do + data + |> fix_context(meta) + end + def validate_data(cng, meta \\ []) do cng |> validate_required([:actor, :type, :object]) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 694cb6730..f47acf606 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -83,17 +83,23 @@ defp fix_closed(data) do # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults defp fix_defaults(data) do - %{data: %{"id" => context}, id: context_id} = Utils.create_context(data["context"]) + %{data: %{"id" => context}, id: context_id} = + Utils.create_context(data["context"] || data["conversation"]) data - |> Map.put_new_lazy("id", &Utils.generate_object_id/0) |> Map.put_new_lazy("published", &Utils.make_date/0) |> Map.put_new("context", context) |> Map.put_new("context_id", context_id) end + defp fix_attribution(data) do + data + |> Map.put_new("actor", data["attributedTo"]) + end + defp fix(data) do data + |> fix_attribution() |> fix_closed() |> fix_defaults() end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index f85a26679..7381d4476 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -634,17 +634,10 @@ def handle_incoming( end def handle_incoming( - %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, + %{"type" => "Create", "object" => %{"type" => objtype}} = data, _options ) - when objtype in ["Question", "Answer"] do - data = - data - |> Map.put("object", fix_object(object)) - |> fix_addressing() - - data = Map.put_new(data, "context", data["object"]["context"]) - + when objtype in ["Question", "Answer", "ChatMessage"] do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 1bfc99259..1208dc9a0 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -28,10 +28,10 @@ def render("show.json", %{object: object, multiple: multiple, options: options} def render("show.json", %{object: object} = params) do case object.data do - %{"anyOf" => [ _ | _] = options} -> + %{"anyOf" => [_ | _] = options} -> render(__MODULE__, "show.json", Map.merge(params, %{multiple: true, options: options})) - %{"oneOf" => [ _ | _] = options} -> + %{"oneOf" => [_ | _] = options} -> render(__MODULE__, "show.json", Map.merge(params, %{multiple: false, options: options})) _ -> -- cgit v1.2.3 From f7146583e5f1c2d0e8a198db00dfafced79d0706 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 08:15:57 -0500 Subject: Remove LDAP mail attribute as a requirement for registering an account --- lib/pleroma/web/auth/ldap_authenticator.ex | 34 ++++++++++++------------------ test/web/oauth/ldap_authorization_test.exs | 4 +--- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index f63a66c03..f320ec746 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -105,29 +105,21 @@ defp register_user(connection, base, uid, name, password) do {:base, to_charlist(base)}, {:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))}, {:scope, :eldap.wholeSubtree()}, - {:attributes, ['mail', 'email']}, {:timeout, @search_timeout} ]) do - {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> - with {_, [mail]} <- List.keyfind(attributes, 'mail', 0) do - params = %{ - email: :erlang.list_to_binary(mail), - name: name, - nickname: name, - password: password, - password_confirmation: password - } - - changeset = User.register_changeset(%User{}, params) - - case User.register(changeset) do - {:ok, user} -> user - error -> error - end - else - _ -> - Logger.error("Could not find LDAP attribute mail: #{inspect(attributes)}") - {:error, :ldap_registration_missing_attributes} + {:ok, {:eldap_search_result, [{:eldap_entry, _, _}], _}} -> + params = %{ + name: name, + nickname: name, + password: password, + password_confirmation: password + } + + changeset = User.register_changeset(%User{}, params) + + case User.register(changeset) do + {:ok, user} -> user + error -> error end error -> diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs index 011642c08..76ae461c3 100644 --- a/test/web/oauth/ldap_authorization_test.exs +++ b/test/web/oauth/ldap_authorization_test.exs @@ -72,9 +72,7 @@ test "creates a new user after successful LDAP authorization" do equalityMatch: fn _type, _value -> :ok end, wholeSubtree: fn -> :ok end, search: fn _connection, _options -> - {:ok, - {:eldap_search_result, [{:eldap_entry, '', [{'mail', [to_charlist(user.email)]}]}], - []}} + {:ok, {:eldap_search_result, [{:eldap_entry, '', []}], []}} end, close: fn _connection -> send(self(), :close_connection) -- cgit v1.2.3 From 0f9aecbca49c828158d2cb549659a68fb21697df Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 08:18:16 -0500 Subject: Remove fallback to local database when LDAP is unavailable. In many environments this will not work as the LDAP password and the copy stored in Pleroma will stay synchronized. --- lib/pleroma/web/auth/ldap_authenticator.ex | 4 --- test/web/oauth/ldap_authorization_test.exs | 45 ------------------------------ 2 files changed, 49 deletions(-) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index f320ec746..ec47f6f91 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -28,10 +28,6 @@ def get_user(%Plug.Conn{} = conn) do %User{} = user <- ldap_user(name, password) do {:ok, user} else - {:error, {:ldap_connection_error, _}} -> - # When LDAP is unavailable, try default authenticator - @base.get_user(conn) - {:ldap, _} -> @base.get_user(conn) diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs index 76ae461c3..63b1c0eb8 100644 --- a/test/web/oauth/ldap_authorization_test.exs +++ b/test/web/oauth/ldap_authorization_test.exs @@ -7,7 +7,6 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do alias Pleroma.Repo alias Pleroma.Web.OAuth.Token import Pleroma.Factory - import ExUnit.CaptureLog import Mock @skip if !Code.ensure_loaded?(:eldap), do: :skip @@ -99,50 +98,6 @@ test "creates a new user after successful LDAP authorization" do end end - @tag @skip - test "falls back to the default authorization when LDAP is unavailable" do - password = "testpassword" - user = insert(:user, password_hash: Pbkdf2.hash_pwd_salt(password)) - app = insert(:oauth_app, scopes: ["read", "write"]) - - host = Pleroma.Config.get([:ldap, :host]) |> to_charlist - port = Pleroma.Config.get([:ldap, :port]) - - with_mocks [ - {:eldap, [], - [ - open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:error, 'connect failed'} end, - simple_bind: fn _connection, _dn, ^password -> :ok end, - close: fn _connection -> - send(self(), :close_connection) - :ok - end - ]} - ] do - log = - capture_log(fn -> - conn = - build_conn() - |> post("/oauth/token", %{ - "grant_type" => "password", - "username" => user.nickname, - "password" => password, - "client_id" => app.client_id, - "client_secret" => app.client_secret - }) - - assert %{"access_token" => token} = json_response(conn, 200) - - token = Repo.get_by(Token, token: token) - - assert token.user_id == user.id - end) - - assert log =~ "Could not open LDAP connection: 'connect failed'" - refute_received :close_connection - end - end - @tag @skip test "disallow authorization for wrong LDAP credentials" do password = "testpassword" -- cgit v1.2.3 From 5221879c358a7859d54013597c9ed9ccbb494155 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 15:40:32 +0200 Subject: Fix linting. --- lib/pleroma/object.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index b3e654857..052ad413b 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -255,7 +255,7 @@ def increase_replies_count(ap_id) do end end - defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true + defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true defp poll_is_multiple?(_), do: false -- cgit v1.2.3 From d5e4d8a6f3f7b577183809a4b371609aa29fa968 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 09:41:17 -0500 Subject: Define default authenticator in the config --- config/config.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/config.exs b/config/config.exs index 933a899ab..257b2e061 100644 --- a/config/config.exs +++ b/config/config.exs @@ -737,6 +737,8 @@ config :pleroma, :instances_favicons, enabled: false +config :pleroma, Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.PleromaAuthenticator + # 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" -- cgit v1.2.3 From 97b57014496003cabb416766457552ef854fa658 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Wed, 5 Aug 2020 17:46:14 +0300 Subject: Update clear_config macro --- test/application_requirements_test.exs | 5 +---- test/support/helpers.ex | 8 ++++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/test/application_requirements_test.exs b/test/application_requirements_test.exs index e96295955..21d24ddd0 100644 --- a/test/application_requirements_test.exs +++ b/test/application_requirements_test.exs @@ -127,10 +127,7 @@ test "doesn't do anything if rum disabled" do :ok end - setup do - Pleroma.Config.get(:i_am_aware_this_may_cause_data_loss, 42) |> IO.inspect() - clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) - end + setup do: clear_config([:i_am_aware_this_may_cause_data_loss, :disable_migration_check]) test "raises if it detects unapplied migrations" do assert_raise Pleroma.ApplicationRequirements.VerifyError, diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 7d729541d..ecd4b1e18 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -17,16 +17,16 @@ defmacro clear_config(config_path) do defmacro clear_config(config_path, do: yield) do quote do - initial_setting = Config.get(unquote(config_path), :__clear_config_absent__) + initial_setting = Config.fetch(unquote(config_path)) unquote(yield) on_exit(fn -> case initial_setting do - :__clear_config_absent__ -> + :error -> Config.delete(unquote(config_path)) - _ -> - Config.put(unquote(config_path), initial_setting) + {:ok, value} -> + Config.put(unquote(config_path), value) end end) -- cgit v1.2.3 From 2192d1e4920e2c6deffe9a205cc2ade27d4dc0b1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 10:07:31 -0500 Subject: Permit LDAP users to register without capturing their password hash We don't need it, and local auth fallback has been removed. --- lib/pleroma/user.ex | 19 +++++++++++++++++++ lib/pleroma/web/auth/ldap_authenticator.ex | 7 +++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 09e606b37..df9f34baa 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -638,6 +638,25 @@ def force_password_reset_async(user) do @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def force_password_reset(user), do: update_password_reset_pending(user, true) + # Used to auto-register LDAP accounts which don't have a password hash + def register_changeset(struct, params = %{password: password}) + when is_nil(password) do + params = Map.put_new(params, :accepts_chat_messages, true) + + struct + |> cast(params, [ + :name, + :nickname, + :accepts_chat_messages + ]) + |> unique_constraint(:nickname) + |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) + |> validate_format(:nickname, local_nickname_regex()) + |> put_ap_id() + |> unique_constraint(:ap_id) + |> put_following_and_follower_address() + end + def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Config.get([:instance, :user_bio_length], 5000) name_limit = Config.get([:instance, :user_name_length], 100) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index ec47f6f91..f667da68b 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -88,7 +88,7 @@ defp bind_user(connection, ldap, name, password) do user _ -> - register_user(connection, base, uid, name, password) + register_user(connection, base, uid, name) end error -> @@ -96,7 +96,7 @@ defp bind_user(connection, ldap, name, password) do end end - defp register_user(connection, base, uid, name, password) do + defp register_user(connection, base, uid, name) do case :eldap.search(connection, [ {:base, to_charlist(base)}, {:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))}, @@ -107,8 +107,7 @@ defp register_user(connection, base, uid, name, password) do params = %{ name: name, nickname: name, - password: password, - password_confirmation: password + password: nil } changeset = User.register_changeset(%User{}, params) -- cgit v1.2.3 From 8c57a299b463b7e5916addbbd3571b35e1742ebd Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Wed, 5 Aug 2020 18:23:12 +0300 Subject: Handle non-list keys in Config.fetch/1 --- lib/pleroma/config.ex | 2 ++ test/config_test.exs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 98099ca58..a8329cc1e 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -32,6 +32,8 @@ def get!(key) do end end + def fetch(key) when is_atom(key), do: fetch([key]) + def fetch([root_key | keys]) do Enum.reduce_while(keys, Application.fetch_env(:pleroma, root_key), fn key, {:ok, config} when is_map(config) or is_list(config) -> diff --git a/test/config_test.exs b/test/config_test.exs index e2c18304e..1556e4237 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -127,9 +127,11 @@ test "fetch/1" do Pleroma.Config.put([:ipsum], dolor: :sit) assert Pleroma.Config.fetch([:lorem]) == {:ok, :ipsum} + assert Pleroma.Config.fetch(:lorem) == {:ok, :ipsum} assert Pleroma.Config.fetch([:ipsum, :dolor]) == {:ok, :sit} assert Pleroma.Config.fetch([:lorem, :ipsum]) == :error assert Pleroma.Config.fetch([:loremipsum]) == :error + assert Pleroma.Config.fetch(:loremipsum) == :error Pleroma.Config.delete([:lorem]) Pleroma.Config.delete([:ipsum]) -- cgit v1.2.3 From 2173945f9012ec0db82a73fc7ed9423899dfd28f Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 17:26:03 +0200 Subject: MailerTest: Give it some time. --- test/emails/mailer_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/emails/mailer_test.exs b/test/emails/mailer_test.exs index e6e34cba8..3da45056b 100644 --- a/test/emails/mailer_test.exs +++ b/test/emails/mailer_test.exs @@ -19,6 +19,7 @@ defmodule Pleroma.Emails.MailerTest do test "not send email when mailer is disabled" do Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) Mailer.deliver(@email) + :timer.sleep(100) refute_email_sent( from: {"Pleroma", "noreply@example.com"}, @@ -30,6 +31,7 @@ test "not send email when mailer is disabled" do test "send email" do Mailer.deliver(@email) + :timer.sleep(100) assert_email_sent( from: {"Pleroma", "noreply@example.com"}, @@ -41,6 +43,7 @@ test "send email" do test "perform" do Mailer.perform(:deliver_async, @email, []) + :timer.sleep(100) assert_email_sent( from: {"Pleroma", "noreply@example.com"}, -- cgit v1.2.3 From 9c96fc052a89789b398794761741783eaa86d6a1 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 17:26:53 +0200 Subject: CommonValidations: Extract modification right checker --- .../object_validators/common_validations.ex | 27 +++++++++++++++++++++ .../object_validators/delete_validator.ex | 28 +--------------------- .../object_validators/delete_validation_test.exs | 2 +- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 67352f801..e4c5d9619 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -125,4 +125,31 @@ def validate_fields_match(cng, fields) do end) end end + + def same_domain?(cng, field_one \\ :actor, field_two \\ :object) do + actor_uri = + cng + |> get_field(field_one) + |> URI.parse() + + object_uri = + cng + |> get_field(field_two) + |> URI.parse() + + object_uri.host == actor_uri.host + end + + # This figures out if a user is able to create, delete or modify something + # based on the domain and superuser status + def validate_modification_rights(cng) do + actor = User.get_cached_by_ap_id(get_field(cng, :actor)) + + if User.superuser?(actor) || same_domain?(cng) do + cng + else + cng + |> add_error(:actor, "is not allowed to modify object") + end + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index 93a7b0e0b..2634e8d4d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -59,7 +58,7 @@ def validate_data(cng) do |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) |> validate_actor_presence() - |> validate_deletion_rights() + |> validate_modification_rights() |> validate_object_or_user_presence(allowed_types: @deletable_types) |> add_deleted_activity_id() end @@ -68,31 +67,6 @@ def do_not_federate?(cng) do !same_domain?(cng) end - defp same_domain?(cng) do - actor_uri = - cng - |> get_field(:actor) - |> URI.parse() - - object_uri = - cng - |> get_field(:object) - |> URI.parse() - - object_uri.host == actor_uri.host - end - - def validate_deletion_rights(cng) do - actor = User.get_cached_by_ap_id(get_field(cng, :actor)) - - if User.superuser?(actor) || same_domain?(cng) do - cng - else - cng - |> add_error(:actor, "is not allowed to delete object") - end - end - def cast_and_validate(data) do data |> cast_data diff --git a/test/web/activity_pub/object_validators/delete_validation_test.exs b/test/web/activity_pub/object_validators/delete_validation_test.exs index 42cd18298..02683b899 100644 --- a/test/web/activity_pub/object_validators/delete_validation_test.exs +++ b/test/web/activity_pub/object_validators/delete_validation_test.exs @@ -87,7 +87,7 @@ test "it's invalid if the actor of the object and the actor of delete are from d {:error, cng} = ObjectValidator.validate(invalid_other_actor, []) - assert {:actor, {"is not allowed to delete object", []}} in cng.errors + assert {:actor, {"is not allowed to modify object", []}} in cng.errors end test "it's valid if the actor of the object is a local superuser", -- cgit v1.2.3 From 3655175639a004976ef8296a0838e72642ba0b11 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 17:36:27 +0200 Subject: CommonValidations: Refactor `same_domain?` --- .../object_validators/common_validations.ex | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index e4c5d9619..82a9d39b5 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -126,18 +126,21 @@ def validate_fields_match(cng, fields) do end end - def same_domain?(cng, field_one \\ :actor, field_two \\ :object) do - actor_uri = - cng - |> get_field(field_one) - |> URI.parse() + def same_domain?(cng, fields \\ [:actor, :object]) do + unique_domains = + fields + |> Enum.map(fn field -> + %URI{host: host} = + cng + |> get_field(field) + |> URI.parse() - object_uri = - cng - |> get_field(field_two) - |> URI.parse() + host + end) + |> Enum.uniq() + |> Enum.count() - object_uri.host == actor_uri.host + unique_domains == 1 end # This figures out if a user is able to create, delete or modify something -- cgit v1.2.3 From 9d7ce1a6d014499eb4d55190b81e55da849b5ad0 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 5 Aug 2020 17:56:12 +0200 Subject: CommonValidations: More refactors. --- .../object_validators/common_validations.ex | 51 ++++++++-------------- 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 82a9d39b5..603d87b8e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -84,20 +84,7 @@ def validate_object_or_user_presence(cng, options \\ []) do end def validate_host_match(cng, fields \\ [:id, :actor]) do - unique_hosts = - fields - |> Enum.map(fn field -> - %URI{host: host} = - cng - |> get_field(field) - |> URI.parse() - - host - end) - |> Enum.uniq() - |> Enum.count() - - if unique_hosts == 1 do + if same_domain?(cng, fields) do cng else fields @@ -109,13 +96,7 @@ def validate_host_match(cng, fields \\ [:id, :actor]) do end def validate_fields_match(cng, fields) do - unique_fields = - fields - |> Enum.map(fn field -> get_field(cng, field) end) - |> Enum.uniq() - |> Enum.count() - - if unique_fields == 1 do + if map_unique?(cng, fields) do cng else fields @@ -126,21 +107,23 @@ def validate_fields_match(cng, fields) do end end - def same_domain?(cng, fields \\ [:actor, :object]) do - unique_domains = - fields - |> Enum.map(fn field -> - %URI{host: host} = - cng - |> get_field(field) - |> URI.parse() + defp map_unique?(cng, fields, func \\ & &1) do + Enum.reduce_while(fields, nil, fn field, acc -> + value = + cng + |> get_field(field) + |> func.() - host - end) - |> Enum.uniq() - |> Enum.count() + case {value, acc} do + {value, nil} -> {:cont, value} + {value, value} -> {:cont, value} + _ -> {:halt, false} + end + end) + end - unique_domains == 1 + def same_domain?(cng, fields \\ [:actor, :object]) do + map_unique?(cng, fields, fn value -> URI.parse(value).host end) end # This figures out if a user is able to create, delete or modify something -- cgit v1.2.3 From 81126b0142ec54c785952d0c84a2bdef76965fc7 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 11:36:12 -0500 Subject: Add email to user account only if it exists in LDAP --- lib/pleroma/user.ex | 9 +++++++++ lib/pleroma/web/auth/ldap_authenticator.ex | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index df9f34baa..6d39c9d1b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -643,12 +643,21 @@ def register_changeset(struct, params = %{password: password}) when is_nil(password) do params = Map.put_new(params, :accepts_chat_messages, true) + params = + if Map.has_key?(params, :email) do + Map.put_new(params, :email, params[:email]) + else + params + end + struct |> cast(params, [ :name, :nickname, + :email, :accepts_chat_messages ]) + |> validate_required([:name, :nickname]) |> unique_constraint(:nickname) |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) |> validate_format(:nickname, local_nickname_regex()) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index f667da68b..b1645a359 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -103,13 +103,19 @@ defp register_user(connection, base, uid, name) do {:scope, :eldap.wholeSubtree()}, {:timeout, @search_timeout} ]) do - {:ok, {:eldap_search_result, [{:eldap_entry, _, _}], _}} -> + {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> params = %{ name: name, nickname: name, password: nil } + params = + case List.keyfind(attributes, 'mail', 0) do + {_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail)) + _ -> params + end + changeset = User.register_changeset(%User{}, params) case User.register(changeset) do -- cgit v1.2.3 From 7569f225f1d43c6435eda6b62fd5eff3cd3408e0 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Wed, 5 Aug 2020 19:38:55 +0300 Subject: Move checks to application startup --- lib/pleroma/application.ex | 18 ++++++++++++++++++ lib/pleroma/upload/filter/exiftool.ex | 14 +++++++++----- lib/pleroma/upload/filter/mogrifun.ex | 8 +++++--- lib/pleroma/upload/filter/mogrify.ex | 12 ++++++------ 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 0ffb55358..c0b5db9f1 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -47,6 +47,7 @@ def start(_type, _args) do Pleroma.ApplicationRequirements.verify!() setup_instrumenters() load_custom_modules() + check_system_commands() Pleroma.Docs.JSON.compile() adapter = Application.get_env(:tesla, :adapter) @@ -249,4 +250,21 @@ defp http_children(Tesla.Adapter.Gun, _) do end defp http_children(_, _), do: [] + + defp check_system_commands do + filters = Config.get([Pleroma.Upload, :filters]) + + check_filter = fn filter, command_required -> + with true <- filter in filters, + false <- Pleroma.Utils.command_available?(command_required) do + Logger.error( + "#{filter} is specified in list of Pleroma.Upload filters, but the #{command_required} command is not found" + ) + end + end + + check_filter.(Pleroma.Upload.Filters.Exiftool, "exiftool") + check_filter.(Pleroma.Upload.Filters.Mogrify, "mogrify") + check_filter.(Pleroma.Upload.Filters.Mogrifun, "mogrify") + end end diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex index e1b976c98..ea8798fe3 100644 --- a/lib/pleroma/upload/filter/exiftool.ex +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -9,12 +9,16 @@ defmodule Pleroma.Upload.Filter.Exiftool do """ @behaviour Pleroma.Upload.Filter + @spec filter(Pleroma.Upload.t()) :: :ok | {:error, String.t()} def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - if Pleroma.Utils.command_available?("exiftool") do - System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) - :ok - else - {:error, "exiftool command not found"} + try do + case System.cmd("exiftool", ["-overwrite_original", "-gps:all=", file], parallelism: true) do + {_response, 0} -> :ok + {error, 1} -> {:error, error} + end + rescue + _e in ErlangError -> + {:error, "exiftool command not found"} end end diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex index 8f362333d..a8503ac24 100644 --- a/lib/pleroma/upload/filter/mogrifun.ex +++ b/lib/pleroma/upload/filter/mogrifun.ex @@ -34,12 +34,14 @@ defmodule Pleroma.Upload.Filter.Mogrifun do [{"fill", "yellow"}, {"tint", "40"}] ] + @spec filter(Pleroma.Upload.t()) :: :ok | {:error, String.t()} def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - if Pleroma.Utils.command_available?("mogrify") do + try do Filter.Mogrify.do_filter(file, [Enum.random(@filters)]) :ok - else - {:error, "mogrify command not found"} + rescue + _e in ErlangError -> + {:error, "mogrify command not found"} end end diff --git a/lib/pleroma/upload/filter/mogrify.ex b/lib/pleroma/upload/filter/mogrify.ex index 4bd0c2eb4..7a45add5a 100644 --- a/lib/pleroma/upload/filter/mogrify.ex +++ b/lib/pleroma/upload/filter/mogrify.ex @@ -8,14 +8,14 @@ defmodule Pleroma.Upload.Filter.Mogrify do @type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()} @type conversions :: conversion() | [conversion()] + @spec filter(Pleroma.Upload.t()) :: :ok | {:error, String.t()} def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - if Pleroma.Utils.command_available?("mogrify") do - filters = Pleroma.Config.get!([__MODULE__, :args]) - - do_filter(file, filters) + try do + do_filter(file, Pleroma.Config.get!([__MODULE__, :args])) :ok - else - {:error, "mogrify command not found"} + rescue + _e in ErlangError -> + {:error, "mogrify command not found"} end end -- cgit v1.2.3 From 2a4bca5bd7e33388193d252f9f956d10ce38ad77 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 11:40:09 -0500 Subject: Comments are good when they're precise... --- lib/pleroma/user.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 6d39c9d1b..69b0e1781 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -638,7 +638,7 @@ def force_password_reset_async(user) do @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def force_password_reset(user), do: update_password_reset_pending(user, true) - # Used to auto-register LDAP accounts which don't have a password hash + # Used to auto-register LDAP accounts which won't have a password hash stored locally def register_changeset(struct, params = %{password: password}) when is_nil(password) do params = Map.put_new(params, :accepts_chat_messages, true) -- cgit v1.2.3 From cb7879c7c148cfc318a176d19b1402e370c509e7 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 5 Aug 2020 11:53:57 -0500 Subject: Add note about removal of Pleroma.Web.Auth.LDAPAuthenticator fallback to Pleroma.Web.Auth.PleromaAuthenticator --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de017e30a..c0d0fe269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. - Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated. - **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated. +- **Breaking:** LDAP: Fallback to local database authentication has been removed for security reasons and lack of a mechanism to ensure the passwords are synchronized when LDAP passwords are updated.
    API Changes -- cgit v1.2.3 From d6ab9f2132cdcbed303c9ef0941bf7210e49c5d6 Mon Sep 17 00:00:00 2001 From: Mary Kate Date: Wed, 5 Aug 2020 15:36:25 -0500 Subject: update test for whole_word in filter --- .../controllers/filter_controller_test.exs | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs index f29547d13..6e94150b4 100644 --- a/test/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/web/mastodon_api/controllers/filter_controller_test.exs @@ -64,11 +64,31 @@ test "fetching a list of filters" do test "get a filter" do %{user: user, conn: conn} = oauth_access(["read:filters"]) + # check whole_word true query = %Pleroma.Filter{ user_id: user.id, filter_id: 2, phrase: "knight", - context: ["home"] + context: ["home"], + whole_word: false + } + + {:ok, filter} = Pleroma.Filter.create(query) + + conn = get(conn, "/api/v1/filters/#{filter.filter_id}") + + assert response = json_response_and_validate_schema(conn, 200) + assert response["whole_word"] == false + + # check whole_word false + %{user: user, conn: conn} = oauth_access(["read:filters"]) + + query = %Pleroma.Filter{ + user_id: user.id, + filter_id: 3, + phrase: "knight", + context: ["home"], + whole_word: true } {:ok, filter} = Pleroma.Filter.create(query) @@ -76,6 +96,7 @@ test "get a filter" do conn = get(conn, "/api/v1/filters/#{filter.filter_id}") assert response = json_response_and_validate_schema(conn, 200) + assert response["whole_word"] == true end test "update a filter" do @@ -86,7 +107,8 @@ test "update a filter" do filter_id: 2, phrase: "knight", context: ["home"], - hide: true + hide: true, + whole_word: true } {:ok, _filter} = Pleroma.Filter.create(query) @@ -108,6 +130,7 @@ test "update a filter" do assert response["phrase"] == new.phrase assert response["context"] == new.context assert response["irreversible"] == true + assert response["whole_word"] == true end test "delete a filter" do -- cgit v1.2.3 From f785dba09bc6c6624c17350356632d008f701183 Mon Sep 17 00:00:00 2001 From: Mary Kate Date: Wed, 5 Aug 2020 15:39:11 -0500 Subject: changelog for filter whole_word fix --- CHANGELOG.md | 1 + test/web/mastodon_api/controllers/filter_controller_test.exs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de017e30a..572f9e84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix CSP policy generation to include remote Captcha services - Fix edge case where MediaProxy truncates media, usually caused when Caddy is serving content for the other Federated instance. - Emoji Packs could not be listed when instance was set to `public: false` +- Fix whole_word always returning false on filter get requests ## [Unreleased (patch)] diff --git a/test/web/mastodon_api/controllers/filter_controller_test.exs b/test/web/mastodon_api/controllers/filter_controller_test.exs index 6e94150b4..0d426ec34 100644 --- a/test/web/mastodon_api/controllers/filter_controller_test.exs +++ b/test/web/mastodon_api/controllers/filter_controller_test.exs @@ -64,7 +64,7 @@ test "fetching a list of filters" do test "get a filter" do %{user: user, conn: conn} = oauth_access(["read:filters"]) - # check whole_word true + # check whole_word false query = %Pleroma.Filter{ user_id: user.id, filter_id: 2, @@ -80,7 +80,7 @@ test "get a filter" do assert response = json_response_and_validate_schema(conn, 200) assert response["whole_word"] == false - # check whole_word false + # check whole_word true %{user: user, conn: conn} = oauth_access(["read:filters"]) query = %Pleroma.Filter{ -- cgit v1.2.3 From 0c4e855663fec52f8f98fe8fa8597e5268502c97 Mon Sep 17 00:00:00 2001 From: Guy Sheffer Date: Thu, 6 Aug 2020 09:50:10 +0300 Subject: Add checksum to docker buildx, add aarch/arm64 to the list of platforms --- .gitlab-ci.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 816c05b1e..5e6245459 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -278,6 +278,8 @@ docker: IMAGE_TAG_SLUG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:latest IMAGE_TAG_LATEST_STABLE: $CI_REGISTRY_IMAGE:latest-stable + DOCKER_BUILDX_URL: https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 + DOCKER_BUILDX_HASH: 71a7d01439aa8c165a25b59c44d3f016fddbd98b before_script: &before-docker - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker pull $IMAGE_TAG_SLUG || true @@ -286,12 +288,13 @@ docker: allow_failure: true script: - mkdir -p /root/.docker/cli-plugins - - wget https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx + - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx + - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - chmod +x ~/.docker/cli-plugins/docker-buildx - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - docker buildx create --name mbuilder --driver docker-container --use - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7 --push --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 buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --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 . tags: - dind only: @@ -307,12 +310,13 @@ docker-stable: allow_failure: true script: - mkdir -p /root/.docker/cli-plugins - - wget https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx + - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx + - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - chmod +x ~/.docker/cli-plugins/docker-buildx - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - docker buildx create --name mbuilder --driver docker-container --use - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7 --push --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 buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --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 . tags: - dind only: @@ -329,12 +333,13 @@ docker-release: script: script: - mkdir -p /root/.docker/cli-plugins - - wget https://github.com/docker/buildx/releases/download/v0.4.1/buildx-v0.4.1.linux-amd64 -O ~/.docker/cli-plugins/docker-buildx + - wget "${DOCKER_BUILDX_URL}" -O ~/.docker/cli-plugins/docker-buildx + - echo "${DOCKER_BUILDX_HASH} /root/.docker/cli-plugins/docker-buildx" | sha1sum -c - chmod +x ~/.docker/cli-plugins/docker-buildx - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - docker buildx create --name mbuilder --driver docker-container --use - docker buildx inspect --bootstrap - - docker buildx build --platform linux/amd64,linux/arm/v7 --push --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 buildx build --platform linux/amd64,linux/arm/v7,linux/arm64/v8 --push --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 . tags: - dind only: -- cgit v1.2.3 From 135ae4e35a3e6a084eb611ce3a21c7a6c6bba9fc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Thu, 6 Aug 2020 16:00:00 +0300 Subject: [#2025] Defaulted OAuth login scopes choice to all scopes when user selects no scopes. --- lib/pleroma/web/oauth/oauth_controller.ex | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index f29b3cb57..dd00600ea 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -76,6 +76,13 @@ defp do_authorize(%Plug.Conn{} = conn, params) do available_scopes = (app && app.scopes) || [] scopes = Scopes.fetch_scopes(params, available_scopes) + scopes = + if scopes == [] do + available_scopes + else + scopes + end + # Note: `params` might differ from `conn.params`; use `@params` not `@conn.params` in template render(conn, Authenticator.auth_template(), %{ response_type: params["response_type"], -- cgit v1.2.3 From 03da653a123341036e0caa84c6a7b4edd129afed Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 6 Aug 2020 16:41:56 +0200 Subject: Description: Refactor. --- config/description.exs | 95 ++++++++++++++++++++------------------------------ 1 file changed, 37 insertions(+), 58 deletions(-) diff --git a/config/description.exs b/config/description.exs index b56ea3339..1f50c9952 100644 --- a/config/description.exs +++ b/config/description.exs @@ -12,6 +12,36 @@ compress: false ] +frontend_options = [ + %{ + key: "name", + type: :string, + description: "Name of the installed Admin frontend" + }, + %{ + key: "ref", + type: :string, + description: "reference of the installed Admin frontend to be used" + }, + %{ + key: "git", + type: :string, + description: "URL of the git repository of the frontend" + }, + %{ + key: "build_url", + type: :string, + description: + "Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.", + example: "https://some.url/builds/${ref}.zip" + }, + %{ + key: "build_dir", + type: :string, + description: "The directory inside the zip file " + } +] + config :pleroma, :config_description, [ %{ group: :pleroma, @@ -3552,69 +3582,18 @@ key: :primary, type: :map, description: "Primary frontend, the one that is served for all pages by default", - children: [ - %{ - key: "name", - type: :string, - description: "Name of the installed primary frontend" - }, - %{ - key: "ref", - type: :string, - description: "reference of the installed primary frontend to be used" - }, - %{ - key: "git", - type: :string, - description: "URL of the git repository of the frontend" - }, - %{ - key: "build_url", - type: :string, - description: - "Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.", - example: "https://some.url/builds/${ref}.zip" - }, - %{ - key: "build_dir", - type: :string, - description: "The directory inside the zip file " - } - ] + children: frontend_options }, %{ key: :admin, type: :map, description: "Admin frontend", - children: [ - %{ - key: "name", - type: :string, - description: "Name of the installed Admin frontend" - }, - %{ - key: "ref", - type: :string, - description: "reference of the installed Admin frontend to be used" - }, - %{ - key: "git", - type: :string, - description: "URL of the git repository of the frontend" - }, - %{ - key: "build_url", - type: :string, - description: - "Either an url to a zip file containing the frontend or a template to build it by inserting the `ref`. The string `${ref}` will be replaced by the configured `ref`.", - example: "https://some.url/builds/${ref}.zip" - }, - %{ - key: "build_dir", - type: :string, - description: "The directory inside the zip file " - } - ] + children: frontend_options + }, + %{ + key: :available, + type: :map, + description: "A map containing frontends that we have some knowledge of" } ] } -- cgit v1.2.3 From e639eee82e1e0136bf6e64e571f2b05b5b7b948c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 6 Aug 2020 18:01:29 -0500 Subject: restricted_nicknames: Add names from MastoAPI endpoints --- config/config.exs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 933a899ab..393f74372 100644 --- a/config/config.exs +++ b/config/config.exs @@ -515,7 +515,13 @@ "user-search", "user_exists", "users", - "web" + "web", + "verify_credentials", + "update_credentials", + "relationships", + "search", + "confirmation_resend", + "mfa" ], email_blacklist: [] -- cgit v1.2.3 From 6e6276b4f8a7a46c6038480f6a842339c5214d1c Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Fri, 7 Aug 2020 09:47:05 +0300 Subject: added test --- config/description.exs | 9 +-- .../controllers/config_controller_test.exs | 69 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/config/description.exs b/config/description.exs index 7da01b175..ac43bc814 100644 --- a/config/description.exs +++ b/config/description.exs @@ -982,8 +982,7 @@ %{ key: :message, type: :string, - description: - "A message that will be sent to newly registered users", + description: "A message that will be sent to newly registered users", suggestions: [ "Hi, @username! Welcome on board!" ] @@ -3552,13 +3551,15 @@ key: "name", label: "Name", type: :string, - description: "Name of the installed primary frontend. Valid config must include both `Name` and `Reference` values." + description: + "Name of the installed primary frontend. Valid config must include both `Name` and `Reference` values." }, %{ key: "ref", label: "Reference", type: :string, - description: "Reference of the installed primary frontend to be used. Valid config must include both `Name` and `Reference` values." + description: + "Reference of the installed primary frontend to be used. Valid config must include both `Name` and `Reference` values." } ] } diff --git a/test/web/admin_api/controllers/config_controller_test.exs b/test/web/admin_api/controllers/config_controller_test.exs index 61bc9fd39..4e897455f 100644 --- a/test/web/admin_api/controllers/config_controller_test.exs +++ b/test/web/admin_api/controllers/config_controller_test.exs @@ -1342,6 +1342,75 @@ test "args for Pleroma.Upload.Filter.Mogrify with custom tuples", %{conn: conn} args: ["auto-orient", "strip", {"implode", "1"}, {"resize", "3840x1080>"}] ] end + + test "enables the welcome messages", %{conn: conn} do + clear_config([:welcome]) + + params = %{ + "group" => ":pleroma", + "key" => ":welcome", + "value" => [ + %{ + "tuple" => [ + ":direct_message", + [ + %{"tuple" => [":enabled", true]}, + %{"tuple" => [":message", "Welcome to Pleroma!"]}, + %{"tuple" => [":sender_nickname", "pleroma"]} + ] + ] + }, + %{ + "tuple" => [ + ":chat_message", + [ + %{"tuple" => [":enabled", true]}, + %{"tuple" => [":message", "Welcome to Pleroma!"]}, + %{"tuple" => [":sender_nickname", "pleroma"]} + ] + ] + }, + %{ + "tuple" => [ + ":email", + [ + %{"tuple" => [":enabled", true]}, + %{"tuple" => [":sender", %{"tuple" => ["pleroma@dev.dev", "Pleroma"]}]}, + %{"tuple" => [":subject", "Welcome to <%= instance_name %>!"]}, + %{"tuple" => [":html", "Welcome to <%= instance_name %>!"]}, + %{"tuple" => [":text", "Welcome to <%= instance_name %>!"]} + ] + ] + } + ] + } + + refute Pleroma.User.WelcomeEmail.enabled?() + refute Pleroma.User.WelcomeMessage.enabled?() + refute Pleroma.User.WelcomeChatMessage.enabled?() + + res = + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => [params]}) + |> json_response_and_validate_schema(200) + + assert Pleroma.User.WelcomeEmail.enabled?() + assert Pleroma.User.WelcomeMessage.enabled?() + assert Pleroma.User.WelcomeChatMessage.enabled?() + + assert res == %{ + "configs" => [ + %{ + "db" => [":direct_message", ":chat_message", ":email"], + "group" => ":pleroma", + "key" => ":welcome", + "value" => params["value"] + } + ], + "need_reboot" => false + } + end end describe "GET /api/pleroma/admin/config/descriptions" do -- cgit v1.2.3 From 3f88366e2aebf34e81b3bec7db5c29175cdeca23 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 11:07:02 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 1f50c9952..c7c8524dd 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3593,7 +3593,7 @@ %{ key: :available, type: :map, - description: "A map containing frontends that we have some knowledge of" + description: "A map containing available frontends and parameters for their installation." } ] } -- cgit v1.2.3 From e5ab5fbe764dc8a1326fb31fb9754e5986ee53ee Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 15:01:08 +0200 Subject: Mix task frontend: Read the docs. --- lib/mix/tasks/pleroma/frontend.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/frontend.ex b/lib/mix/tasks/pleroma/frontend.ex index c385c355a..2adbf8d72 100644 --- a/lib/mix/tasks/pleroma/frontend.ex +++ b/lib/mix/tasks/pleroma/frontend.ex @@ -9,7 +9,7 @@ defmodule Mix.Tasks.Pleroma.Frontend do @shortdoc "Manages bundled Pleroma frontends" - # @moduledoc File.read!("docs/administration/CLI_tasks/frontend.md") + @moduledoc File.read!("docs/administration/CLI_tasks/frontend.md") def run(["install", "none" | _args]) do shell_info("Skipping frontend installation because none was requested") -- cgit v1.2.3 From d97b76104eabcd670aa78b8281832ab128373d36 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 15:10:34 +0200 Subject: Mix Task Frontend: Add tests. --- test/fixtures/tesla_mock/frontend.zip | Bin 0 -> 186 bytes test/fixtures/test.txt | 1 - test/tasks/frontend_test.exs | 69 ++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/tesla_mock/frontend.zip delete mode 100644 test/fixtures/test.txt create mode 100644 test/tasks/frontend_test.exs diff --git a/test/fixtures/tesla_mock/frontend.zip b/test/fixtures/tesla_mock/frontend.zip new file mode 100644 index 000000000..114d576a3 Binary files /dev/null and b/test/fixtures/tesla_mock/frontend.zip differ diff --git a/test/fixtures/test.txt b/test/fixtures/test.txt deleted file mode 100644 index e9ea42a12..000000000 --- a/test/fixtures/test.txt +++ /dev/null @@ -1 +0,0 @@ -this is a text file diff --git a/test/tasks/frontend_test.exs b/test/tasks/frontend_test.exs new file mode 100644 index 000000000..5cd4594e2 --- /dev/null +++ b/test/tasks/frontend_test.exs @@ -0,0 +1,69 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.FrontendTest do + use Pleroma.DataCase + alias Mix.Tasks.Pleroma.Frontend + + @dir "test/frontend_static_test" + + setup do + File.mkdir_p!(@dir) + clear_config([:instance, :static_dir], @dir) + + on_exit(fn -> + File.rm_rf(@dir) + end) + end + + test "it downloads and unzips a known frontend" do + clear_config([:frontends, :available], %{ + "pleroma" => %{ + "ref" => "fantasy", + "name" => "pleroma", + "build_url" => "http://gensokyo.2hu/builds/${ref}", + "build_dir" => "" + } + }) + + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} + end) + + Frontend.run(["install", "pleroma"]) + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) + end + + test "it also works given a file" do + clear_config([:frontends, :available], %{ + "pleroma" => %{ + "ref" => "fantasy", + "name" => "pleroma", + "build_dir" => "" + } + }) + + Frontend.run(["install", "pleroma", "--file", "test/fixtures/tesla_mock/frontend.zip"]) + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) + end + + test "it downloads and unzips unknown frontends" do + Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/madeup.zip"} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} + end) + + Frontend.run([ + "install", + "unknown", + "--ref", + "baka", + "--build-url", + "http://gensokyo.2hu/madeup.zip", + "--build-dir", + "" + ]) + + assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"])) + end +end -- cgit v1.2.3 From de00a4c0f1cc5d61d0a821a2d0a292f8bd95e3f1 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 15:27:41 +0200 Subject: Mix Task Frontend Test: Capture IO. --- test/tasks/frontend_test.exs | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/test/tasks/frontend_test.exs b/test/tasks/frontend_test.exs index 5cd4594e2..6a9a931eb 100644 --- a/test/tasks/frontend_test.exs +++ b/test/tasks/frontend_test.exs @@ -6,6 +6,8 @@ defmodule Pleroma.FrontendTest do use Pleroma.DataCase alias Mix.Tasks.Pleroma.Frontend + import ExUnit.CaptureIO, only: [capture_io: 1] + @dir "test/frontend_static_test" setup do @@ -31,7 +33,10 @@ test "it downloads and unzips a known frontend" do %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} end) - Frontend.run(["install", "pleroma"]) + capture_io(fn -> + Frontend.run(["install", "pleroma"]) + end) + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) end @@ -44,7 +49,10 @@ test "it also works given a file" do } }) - Frontend.run(["install", "pleroma", "--file", "test/fixtures/tesla_mock/frontend.zip"]) + capture_io(fn -> + Frontend.run(["install", "pleroma", "--file", "test/fixtures/tesla_mock/frontend.zip"]) + end) + assert File.exists?(Path.join([@dir, "frontends", "pleroma", "fantasy", "test.txt"])) end @@ -53,16 +61,18 @@ test "it downloads and unzips unknown frontends" do %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} end) - Frontend.run([ - "install", - "unknown", - "--ref", - "baka", - "--build-url", - "http://gensokyo.2hu/madeup.zip", - "--build-dir", - "" - ]) + capture_io(fn -> + Frontend.run([ + "install", + "unknown", + "--ref", + "baka", + "--build-url", + "http://gensokyo.2hu/madeup.zip", + "--build-dir", + "" + ]) + end) assert File.exists?(Path.join([@dir, "frontends", "unknown", "baka", "test.txt"])) end -- cgit v1.2.3 From 325c7c924bf05d240fcf535a37d32edf15370a0c Mon Sep 17 00:00:00 2001 From: rinpatch Date: Tue, 11 Feb 2020 00:29:25 +0300 Subject: Make Floki use fast_html --- config/config.exs | 2 ++ test/web/metadata/rel_me_test.exs | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/config.exs b/config/config.exs index 933a899ab..78f3232e6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -737,6 +737,8 @@ config :pleroma, :instances_favicons, enabled: false +config :floki, :html_parser, Floki.HTMLParser.FastHtml + # 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/test/web/metadata/rel_me_test.exs b/test/web/metadata/rel_me_test.exs index 4107a8459..ac7558014 100644 --- a/test/web/metadata/rel_me_test.exs +++ b/test/web/metadata/rel_me_test.exs @@ -9,13 +9,11 @@ defmodule Pleroma.Web.Metadata.Providers.RelMeTest do test "it renders all links with rel='me' from user bio" do bio = - ~s(https://some-link.com https://another-link.com - https://some-link.com https://another-link.com ) user = insert(:user, %{bio: bio}) assert RelMe.build_tags(%{user: user}) == [ - {:link, [rel: "me", href: "http://some3.com>"], []}, + {:link, [rel: "me", href: "http://some3.com"], []}, {:link, [rel: "me", href: "https://another-link.com"], []} ] end -- cgit v1.2.3 From 7e23a48d38da50f1653f37c65610623f585ada9e Mon Sep 17 00:00:00 2001 From: rinpatch Date: Thu, 16 Jul 2020 21:09:48 +0300 Subject: rel me test: fix HTML so broken browsers (and therefore lexbor) refuse to parse it like mochiweb does --- test/web/metadata/rel_me_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/web/metadata/rel_me_test.exs b/test/web/metadata/rel_me_test.exs index ac7558014..2293d6e13 100644 --- a/test/web/metadata/rel_me_test.exs +++ b/test/web/metadata/rel_me_test.exs @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Metadata.Providers.RelMeTest do test "it renders all links with rel='me' from user bio" do bio = ~s(https://some-link.com https://another-link.com ) + user = insert(:user, %{bio: bio}) assert RelMe.build_tags(%{user: user}) == [ -- cgit v1.2.3 From c662b09eee7f1e8a598c595f66e5b38b5dcbad45 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 7 Aug 2020 16:45:04 +0300 Subject: mix.exs: update fast_sanitize to 0.2.0 --- mix.exs | 2 +- mix.lock | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index aab833c5e..11fdb1670 100644 --- a/mix.exs +++ b/mix.exs @@ -127,7 +127,7 @@ defp deps do {:pbkdf2_elixir, "~> 1.2"}, {:bcrypt_elixir, "~> 2.2"}, {:trailing_format_plug, "~> 0.0.7"}, - {:fast_sanitize, "~> 0.1"}, + {:fast_sanitize, "~> 0.2.0"}, {:html_entities, "~> 0.5", override: true}, {:phoenix_html, "~> 2.14"}, {:calendar, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 55c3c59c6..7ec3a0b28 100644 --- a/mix.lock +++ b/mix.lock @@ -42,8 +42,8 @@ "ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [: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", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"}, - "fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"}, + "fast_html": {:hex, :fast_html, "2.0.1", "e126c74d287768ae78c48938da6711164517300d108a78f8a38993df8d588335", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "bdd6f8525c95ad391a4f10d9a1b3da4cea94078ec8638487aa8c24015ad9393a"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.2.0", "004b40d5bbecda182b6fdba762a51fffd3501e689e8eafe196e1a97eb0caf733", [:mix], [{:fast_html, "~> 2.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "11fcb37f26d272a3a2aff861872bf100be4eeacea69505908b8cdbcea5b0813a"}, "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.27.0", "6b29a14283f1e2e8fad824bc930eaa9477c462022075df6bea8f0ad811c13599", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "583b8c13697c37179f1f82443bcc7ad2f76fbc0bf4c186606eebd658f7f2631b"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, @@ -76,6 +76,7 @@ "mox": {:hex, :mox, "0.5.2", "55a0a5ba9ccc671518d068c8dddd20eeb436909ea79d1799e2209df7eaa98b6c", [:mix], [], "hexpm", "df4310628cd628ee181df93f50ddfd07be3e5ecc30232d3b6aadf30bdfe6092b"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, + "nimble_pool": {:hex, :nimble_pool, "0.1.0", "ffa9d5be27eee2b00b0c634eb649aa27f97b39186fec3c493716c2a33e784ec6", [:mix], [], "hexpm", "343a1eaa620ddcf3430a83f39f2af499fe2370390d4f785cd475b4df5acaf3f9"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "2.0.0", "e6ce70d94dd46815ec0882a1ffb7356df9a9d5b8a40a64ce5c2536617a447379", [:mix], [{:ecto_sql, ">= 3.4.3", [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", "cf574813bd048b98a698aa587c21367d2e06842d4e1b1993dcd6a696e9e633bd"}, "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, -- cgit v1.2.3 From 50d5bdfd31970c9ab5461a3eeb2316dc3203dc61 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 16:03:06 +0200 Subject: Mix Task Frontend test: Expand. --- test/fixtures/tesla_mock/dist/test.txt | 1 + test/fixtures/tesla_mock/frontend_dist.zip | Bin 0 -> 334 bytes test/fixtures/test.txt | 1 + test/tasks/frontend_test.exs | 5 ++--- 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/tesla_mock/dist/test.txt create mode 100644 test/fixtures/tesla_mock/frontend_dist.zip create mode 100644 test/fixtures/test.txt diff --git a/test/fixtures/tesla_mock/dist/test.txt b/test/fixtures/tesla_mock/dist/test.txt new file mode 100644 index 000000000..e9ea42a12 --- /dev/null +++ b/test/fixtures/tesla_mock/dist/test.txt @@ -0,0 +1 @@ +this is a text file diff --git a/test/fixtures/tesla_mock/frontend_dist.zip b/test/fixtures/tesla_mock/frontend_dist.zip new file mode 100644 index 000000000..20d7952a4 Binary files /dev/null and b/test/fixtures/tesla_mock/frontend_dist.zip differ diff --git a/test/fixtures/test.txt b/test/fixtures/test.txt new file mode 100644 index 000000000..e9ea42a12 --- /dev/null +++ b/test/fixtures/test.txt @@ -0,0 +1 @@ +this is a text file diff --git a/test/tasks/frontend_test.exs b/test/tasks/frontend_test.exs index 6a9a931eb..0ca2b9a28 100644 --- a/test/tasks/frontend_test.exs +++ b/test/tasks/frontend_test.exs @@ -24,13 +24,12 @@ test "it downloads and unzips a known frontend" do "pleroma" => %{ "ref" => "fantasy", "name" => "pleroma", - "build_url" => "http://gensokyo.2hu/builds/${ref}", - "build_dir" => "" + "build_url" => "http://gensokyo.2hu/builds/${ref}" } }) Tesla.Mock.mock(fn %{url: "http://gensokyo.2hu/builds/fantasy"} -> - %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend.zip")} + %Tesla.Env{status: 200, body: File.read!("test/fixtures/tesla_mock/frontend_dist.zip")} end) capture_io(fn -> -- cgit v1.2.3 From ebb30128af653d146091fa2317418103fd1e46a3 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 16:20:13 +0200 Subject: Changelog: Add information about the object age policy --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 572f9e84b..e2a855bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- **Breaking:** Added the ObjectAgePolicy to the default set of MRFs. This will delist and strip the follower collection of any message received that is older than 7 days. This will stop users from seeing very old messages in the timelines. The messages can still be viewed on the user's page and in conversations. They also still trigger notifications. - **Breaking:** Elixir >=1.9 is now required (was >= 1.8) - **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated. - In Conversations, return only direct messages as `last_status` -- cgit v1.2.3 From 6ddea8ebe8794a9626f3907a5d0e0db9604bf949 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 7 Aug 2020 09:42:10 -0500 Subject: Add a note about the proper value for uid --- docs/configuration/cheatsheet.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f23cf4fe4..d9115a958 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -890,6 +890,9 @@ Pleroma account will be created with the same name as the LDAP user name. * `base`: LDAP base, e.g. "dc=example,dc=com" * `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" +Note, if your LDAP server is an Active Directory server the correct value is commonly `uid: "cn"`, but if you use an +OpenLDAP server the value may be `uid: "uid"`. + ### OAuth consumer mode OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.). -- cgit v1.2.3 From 199ad47c22e5d72741f5809eb015bac9b00cca03 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 17:04:44 +0200 Subject: Docs: Add OTP commands to frontend docs. --- docs/administration/CLI_tasks/frontend.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/administration/CLI_tasks/frontend.md b/docs/administration/CLI_tasks/frontend.md index 11c1c8614..7d1c1e937 100644 --- a/docs/administration/CLI_tasks/frontend.md +++ b/docs/administration/CLI_tasks/frontend.md @@ -19,7 +19,11 @@ You can still install frontends that are not configured, see below. For a frontend configured under the `available` key, it's enough to install it by name. -```bash +```sh tab="OTP" +./bin/pleroma_ctl frontend install pleroma +``` + +```sh tab="From Source" mix pleroma.frontend install pleroma ``` @@ -27,13 +31,21 @@ This will download the latest build for the the pre-configured `ref` and install You can override any of the details. To install a pleroma build from a different url, you could do this: -```bash -mix pleroma.frontend install pleroma --ref 2huedition --build-url https://example.org/raymoo.zip +```sh tab="OPT" +./bin/pleroma_ctl frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip +``` + +```sh tab="From Source" +mix pleroma.frontend install pleroma --ref 2hu_edition --build-url https://example.org/raymoo.zip ``` Similarly, you can also install from a local zip file. -```bash +```sh tab="OTP" +./bin/pleroma_ctl frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip +``` + +```sh tab="From Source" mix pleroma.frontend install pleroma --ref mybuild --file ~/Downloads/doomfe.zip ``` @@ -45,7 +57,11 @@ Careful: This folder will be completely replaced on installation The installation process is the same, but you will have to give all the needed options on the commond line. For example: -```bash +```sh tab="OTP" +./bin/pleroma_ctl frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip +``` + +```sh tab="From Source" mix pleroma.frontend install gensokyo --ref master --build-url https://gensokyo.2hu/builds/marisa.zip ``` -- cgit v1.2.3 From cb376c4c4ca6491f2b58a9a916986998312640f5 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 7 Aug 2020 17:33:59 +0300 Subject: CI: install cmake since fast_html now requires it --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c9ab84892..66813c814 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,6 +22,7 @@ stages: - docker before_script: + - apt-get update && apt-get install -y cmake - mix local.hex --force - mix local.rebar --force -- cgit v1.2.3 From 60fe0a08f0ed4b83847995f4e0a5ff10dcf9d336 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 7 Aug 2020 17:59:55 +0200 Subject: Docs: Remove wrong / confusing auth docs. --- docs/configuration/cheatsheet.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index f23cf4fe4..89036ded0 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -858,9 +858,6 @@ Warning: it's discouraged to use this feature because of the associated security ### :auth -* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator. -* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication. - Authentication / authorization settings. * `auth_template`: authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.eex`. -- cgit v1.2.3 From 673e8e3ac154c4ce5801077234cf2bdee99e78c9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 7 Aug 2020 13:02:39 -0500 Subject: Force 204 responses to be empty, fixes #2029 --- lib/pleroma/web/controller_helper.ex | 6 ++++++ test/support/conn_case.ex | 9 ++++++++- test/web/admin_api/controllers/admin_api_controller_test.exs | 10 +++++----- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 69946fb81..6445966e0 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -18,6 +18,12 @@ def falsy_param?(value), def truthy_param?(value), do: not falsy_param?(value) + def json_response(conn, status, _) when status in [204, :no_content] do + conn + |> put_resp_header("content-type", "application/json") + |> send_resp(status, "") + end + def json_response(conn, status, json) do conn |> put_status(status) diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index b23918dd1..b50ff1bcc 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -56,6 +56,13 @@ defp request_content_type(%{conn: conn}) do [conn: conn] end + defp empty_json_response(conn) do + body = response(conn, 204) + _ = response_content_type(conn, :json) + + body + end + defp json_response_and_validate_schema( %{ private: %{ @@ -79,7 +86,7 @@ defp json_response_and_validate_schema( end schema = lookup[op_id].responses[status].content[content_type].schema - json = json_response(conn, status) + json = if status == 204, do: empty_json_response(conn), else: json_response(conn, status) case OpenApiSpex.cast_value(json, schema, spec) do {:ok, _data} -> diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index b5d5bd8c7..e63268831 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -439,7 +439,7 @@ test "it appends specified tags to users with specified nicknames", %{ user1: user1, user2: user2 } do - assert json_response(conn, :no_content) + assert empty_json_response(conn) assert User.get_cached_by_id(user1.id).tags == ["x", "foo", "bar"] assert User.get_cached_by_id(user2.id).tags == ["y", "foo", "bar"] @@ -457,7 +457,7 @@ test "it appends specified tags to users with specified nicknames", %{ end test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do - assert json_response(conn, :no_content) + assert empty_json_response(conn) assert User.get_cached_by_id(user3.id).tags == ["unchanged"] end end @@ -485,7 +485,7 @@ test "it removes specified tags from users with specified nicknames", %{ user1: user1, user2: user2 } do - assert json_response(conn, :no_content) + assert empty_json_response(conn) assert User.get_cached_by_id(user1.id).tags == [] assert User.get_cached_by_id(user2.id).tags == ["y"] @@ -503,7 +503,7 @@ test "it removes specified tags from users with specified nicknames", %{ end test "it does not modify tags of not specified users", %{conn: conn, user3: user3} do - assert json_response(conn, :no_content) + assert empty_json_response(conn) assert User.get_cached_by_id(user3.id).tags == ["unchanged"] end end @@ -1756,7 +1756,7 @@ test "sets password_reset_pending to true", %{conn: conn} do conn = patch(conn, "/api/pleroma/admin/users/force_password_reset", %{nicknames: [user.nickname]}) - assert json_response(conn, 204) == "" + assert empty_json_response(conn) == "" ObanHelpers.perform_all() -- cgit v1.2.3 From 8e1f7a3eff05a43f59f15dc6fa0483713e221fa7 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Fri, 7 Aug 2020 21:04:13 +0300 Subject: Add new `image` type to settings whose values are image urls --- config/description.exs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/config/description.exs b/config/description.exs index 7da01b175..e2f78e77d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -951,7 +951,7 @@ }, %{ key: :instance_thumbnail, - type: :string, + type: {:string, :image}, description: "The instance thumbnail can be any image that represents your instance and is used by some apps or services when they display information about your instance.", suggestions: ["/instance/thumbnail.jpeg"] @@ -1237,7 +1237,7 @@ }, %{ key: :background, - type: :string, + type: {:string, :image}, description: "URL of the background, unless viewing a user profile with a background that is set", suggestions: ["/images/city.jpg"] @@ -1294,7 +1294,7 @@ }, %{ key: :logo, - type: :string, + type: {:string, :image}, description: "URL of the logo, defaults to Pleroma's logo", suggestions: ["/static/logo.png"] }, @@ -1326,7 +1326,7 @@ %{ key: :nsfwCensorImage, label: "NSFW Censor Image", - type: :string, + type: {:string, :image}, description: "URL of the image to use for hiding NSFW media attachments in the timeline", suggestions: ["/static/img/nsfw.74818f9.png"] @@ -1452,7 +1452,7 @@ }, %{ key: :default_user_avatar, - type: :string, + type: {:string, :image}, description: "URL of the default user avatar", suggestions: ["/images/avi.png"] } @@ -2643,7 +2643,7 @@ children: [ %{ key: :logo, - type: :string, + type: {:string, :image}, description: "A path to a custom logo. Set it to `nil` to use the default Pleroma logo.", suggestions: ["some/path/logo.png"] }, @@ -3552,13 +3552,15 @@ key: "name", label: "Name", type: :string, - description: "Name of the installed primary frontend. Valid config must include both `Name` and `Reference` values." + description: + "Name of the installed primary frontend. Valid config must include both `Name` and `Reference` values." }, %{ key: "ref", label: "Reference", type: :string, - description: "Reference of the installed primary frontend to be used. Valid config must include both `Name` and `Reference` values." + description: + "Reference of the installed primary frontend to be used. Valid config must include both `Name` and `Reference` values." } ] } -- cgit v1.2.3 From 881fdb3a97740425555f4f8b9ddb75123ace73b7 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 7 Aug 2020 21:25:16 +0300 Subject: Add security policy for Pleroma backend Closes #1848 --- SECURITY.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c212a2505 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Pleroma backend security policy + +## Supported versions + +Currently, Pleroma offers bugfixes and security patches only for the latest minor release. + +| Version | Support +|---------| -------- +| 2.0 | Bugfixes and security patches + +## Reporting a vulnerability + +Please use confidential issues (tick the "This issue is confidential and should only be visible to team members with at least Reporter access." box when submitting) at our [bugtracker](https://git.pleroma.social/pleroma/pleroma/-/issues/new) for reporting vulnerabilities. +## Announcements + +New releases are announced at [pleroma.social](https://pleroma.social/announcements/). All security releases are tagged with ["Security"](https://pleroma.social/announcements/tags/security/). You can be notified of them by subscribing to an Atom feed at . -- cgit v1.2.3 From 474147a67a89f8bd92186dbda93d78d8e2045d52 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 7 Aug 2020 14:54:14 -0500 Subject: Make a new function instead of overloading register_changeset/3 --- lib/pleroma/user.ex | 2 +- lib/pleroma/web/auth/ldap_authenticator.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 69b0e1781..d1436a688 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -639,7 +639,7 @@ def force_password_reset_async(user) do def force_password_reset(user), do: update_password_reset_pending(user, true) # Used to auto-register LDAP accounts which won't have a password hash stored locally - def register_changeset(struct, params = %{password: password}) + def register_changeset_ldap(struct, params = %{password: password}) when is_nil(password) do params = Map.put_new(params, :accepts_chat_messages, true) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index b1645a359..402ab428b 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -116,7 +116,7 @@ defp register_user(connection, base, uid, name) do _ -> params end - changeset = User.register_changeset(%User{}, params) + changeset = User.register_changeset_ldap(%User{}, params) case User.register(changeset) do {:ok, user} -> user -- cgit v1.2.3 From e0dee833f2b192e07cd00cc4fd78f646a3cd21d9 Mon Sep 17 00:00:00 2001 From: Ilja Date: Sat, 8 Aug 2020 12:21:44 +0200 Subject: Improve static_dir documentation * It was still written for From Source installs. Now it's both OTP and From Source * I linked to the cheatsheet where it was about configuration * I moved the mix tasks of the robot.txt section to the CLI tasks and linked to it * i checked the code at https://git.pleroma.social/pleroma/pleroma/-/blob/develop/lib/mix/tasks/pleroma/robotstxt.ex and it doesn't seem to more than just this one command with this option * I also added the location of robot.txt and an example to dissallow everything, but allow the fediverse.network crawlers * The Thumbnail section still linked to distsn.org which doesn't exist any more. I changed it to a general statemant that it can be used by external applications. (I don't know any that actually use it.) * Both the logo and TOS need an extra `static` folder. I've seen confusion about that in #pleroma so I added an Important note. --- docs/administration/CLI_tasks/robots_txt.md | 17 +++++++++ docs/configuration/static_dir.md | 55 +++++++++++++++++++---------- 2 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 docs/administration/CLI_tasks/robots_txt.md diff --git a/docs/administration/CLI_tasks/robots_txt.md b/docs/administration/CLI_tasks/robots_txt.md new file mode 100644 index 000000000..b1de0981b --- /dev/null +++ b/docs/administration/CLI_tasks/robots_txt.md @@ -0,0 +1,17 @@ +# Managing robot.txt + +{! backend/administration/CLI_tasks/general_cli_task_info.include !} + +## Generate a new robot.txt file and add it to the static directory + +The `robots.txt` that ships by default is permissive. It allows well-behaved search engines to index all of your instance's URIs. + +If you want to generate a restrictive `robots.txt`, you can run the following mix task. The generated `robots.txt` will be written in your instance [static directory](../../../configuration/static_dir/). + +```elixir tab="OTP" +./bin/pleroma_ctl robots_txt disallow_all +``` + +```elixir tab="From Source" +mix pleroma.robots_txt disallow_all +``` diff --git a/docs/configuration/static_dir.md b/docs/configuration/static_dir.md index 5fb38c3de..8e7eea7fb 100644 --- a/docs/configuration/static_dir.md +++ b/docs/configuration/static_dir.md @@ -1,45 +1,57 @@ # Static Directory -Static frontend files are shipped in `priv/static/` and tracked by version control in this repository. If you want to overwrite or update these without the possibility of merge conflicts, you can write your custom versions to `instance/static/`. +Static frontend files are shipped with pleroma. If you want to overwrite or update these without problems during upgrades, you can write your custom versions to the static directory. +You can find the location of the static directory in the [configuration](../cheatsheet/#instance). + +```elixir tab="OTP" +config :pleroma, :instance, + static_dir: "/var/lib/pleroma/static/", ``` + +```elixir tab="From Source" config :pleroma, :instance, static_dir: "instance/static/", ``` -For example, edit `instance/static/instance/panel.html` . - Alternatively, you can overwrite this value in your configuration to use a different static instance directory. -This document is written assuming `instance/static/`. +This document is written using `$static_dir` as the value of the `config :pleroma, :instance, static_dir` setting. -Or, if you want to manage your custom file in git repository, basically remove the `instance/` entry from `.gitignore`. +If you use a From Source installation and want to manage your custom files in the git repository, you can remove the `instance/` entry from `.gitignore`. ## robots.txt -By default, the `robots.txt` that ships in `priv/static/` is permissive. It allows well-behaved search engines to index all of your instance's URIs. +There's a mix tasks to [generate a new robot.txt](../../administration/CLI_tasks/robots_txt/). + +For more complex things, you can write your own robots.txt to `$static_dir/robots.txt`. -If you want to generate a restrictive `robots.txt`, you can run the following mix task. The generated `robots.txt` will be written in your instance static directory. +E.g. if you want to block all crawerls except for [fediverse.newtork](https://fediverse.network/about) you can use ``` -mix pleroma.robots_txt disallow_all +User-Agent: * +Disallow: / + +User-Agent: crawler-us-il-1.fediverse.network +Allow: / + +User-Agent: makhnovtchina.random.sh +Allow: / ``` ## Thumbnail -Put on `instance/static/instance/thumbnail.jpeg` with your selfie or other neat picture. It will appear in [Pleroma Instances](http://distsn.org/pleroma-instances.html). +Add `$static_dir/instance/thumbnail.jpeg` with your selfie or other neat picture. It will be available on `http://your-domain.tld/instance/thumbnail.jpeg` and can be used by external applications. ## Instance-specific panel -![instance-specific panel demo](/uploads/296b19ec806b130e0b49b16bfe29ce8a/image.png) - -Create and Edit your file on `instance/static/instance/panel.html`. +Create and Edit your file on `$static_dir/instance/panel.html`. ## Background -You can change the background of your Pleroma instance by uploading it to `instance/static/`, and then changing `background` in `config/prod.secret.exs` accordingly. +You can change the background of your Pleroma instance by uploading it to `$static_dir/`, and then changing `background` in [your configuration](../cheatsheet/#frontend_configurations) accordingly. -If you put `instance/static/images/background.jpg` +E.g. if you put `$static_dir/images/background.jpg` ``` config :pleroma, :frontend_configurations, @@ -50,12 +62,14 @@ config :pleroma, :frontend_configurations, ## Logo -![logo modification demo](/uploads/c70b14de60fa74245e7f0dcfa695ebff/image.png) +!!! important + Note the extra `static` folder for the default logo.png location -If you want to give a brand to your instance, You can change the logo of your instance by uploading it to `instance/static/`. +If you want to give a brand to your instance, You can change the logo of your instance by uploading it to the static directory `$static_dir/static/logo.png`. -Alternatively, you can specify the path with config. -If you put `instance/static/static/mylogo-file.png` +Alternatively, you can specify the path to your logo in [your configuration](../cheatsheet/#frontend_configurations). + +E.g. if you put `$static_dir/static/mylogo-file.png` ``` config :pleroma, :frontend_configurations, @@ -66,4 +80,7 @@ config :pleroma, :frontend_configurations, ## Terms of Service -Terms of Service will be shown to all users on the registration page. It's the best place where to write down the rules for your instance. You can modify the rules by changing `instance/static/static/terms-of-service.html`. +!!! important + Note the extra `static` folder for the terms-of-service.html + +Terms of Service will be shown to all users on the registration page. It's the best place where to write down the rules for your instance. You can modify the rules by adding and changing `$static_dir/static/terms-of-service.html`. -- cgit v1.2.3 From e5557bf8ba6a56996ba8847a522042a748dc046b Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Sat, 8 Aug 2020 16:29:40 +0400 Subject: Add mix task to add expiration to all local statuses --- docs/administration/CLI_tasks/database.md | 12 +++++++++- lib/mix/tasks/pleroma/database.ex | 24 +++++++++++++++---- lib/pleroma/activity_expiration.ex | 12 ++++++---- test/tasks/database_test.exs | 39 +++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/docs/administration/CLI_tasks/database.md b/docs/administration/CLI_tasks/database.md index 647f6f274..64dd66c0c 100644 --- a/docs/administration/CLI_tasks/database.md +++ b/docs/administration/CLI_tasks/database.md @@ -97,4 +97,14 @@ but should only be run if necessary. **It is safe to cancel this.** ```sh tab="From Source" mix pleroma.database vacuum full -``` \ No newline at end of file +``` + +## Add expiration to all local statuses + +```sh tab="OTP" +./bin/pleroma_ctl database ensure_expiration +``` + +```sh tab="From Source" +mix pleroma.database ensure_expiration +``` diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 82e2abdcb..d57e59b11 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -10,6 +10,7 @@ defmodule Mix.Tasks.Pleroma.Database do alias Pleroma.User require Logger require Pleroma.Constants + import Ecto.Query import Mix.Pleroma use Mix.Task @@ -53,8 +54,6 @@ def run(["update_users_following_followers_counts"]) do end def run(["prune_objects" | args]) do - import Ecto.Query - {options, [], []} = OptionParser.parse( args, @@ -94,8 +93,6 @@ def run(["prune_objects" | args]) do end def run(["fix_likes_collections"]) do - import Ecto.Query - start_pleroma() from(object in Object, @@ -130,4 +127,23 @@ def run(["vacuum", args]) do Maintenance.vacuum(args) end + + def run(["ensure_expiration"]) do + start_pleroma() + days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365) + + Pleroma.Activity + |> join(:left, [a], u in assoc(a, :expiration)) + |> where(local: true) + |> where([a, u], is_nil(u)) + |> Pleroma.RepoStreamer.chunk_stream(100) + |> Stream.each(fn activities -> + Enum.each(activities, fn activity -> + expires_at = Timex.shift(activity.inserted_at, days: days) + + Pleroma.ActivityExpiration.create(activity, expires_at, false) + end) + end) + |> Stream.run() + end end diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex index db9c88d84..7cc9668b3 100644 --- a/lib/pleroma/activity_expiration.ex +++ b/lib/pleroma/activity_expiration.ex @@ -20,11 +20,11 @@ defmodule Pleroma.ActivityExpiration do field(:scheduled_at, :naive_datetime) end - def changeset(%ActivityExpiration{} = expiration, attrs) do + def changeset(%ActivityExpiration{} = expiration, attrs, validate_scheduled_at) do expiration |> cast(attrs, [:scheduled_at]) |> validate_required([:scheduled_at]) - |> validate_scheduled_at() + |> validate_scheduled_at(validate_scheduled_at) end def get_by_activity_id(activity_id) do @@ -33,9 +33,9 @@ def get_by_activity_id(activity_id) do |> Repo.one() end - def create(%Activity{} = activity, scheduled_at) do + def create(%Activity{} = activity, scheduled_at, validate_scheduled_at \\ true) do %ActivityExpiration{activity_id: activity.id} - |> changeset(%{scheduled_at: scheduled_at}) + |> changeset(%{scheduled_at: scheduled_at}, validate_scheduled_at) |> Repo.insert() end @@ -49,7 +49,9 @@ def due_expirations(offset \\ 0) do |> Repo.all() end - def validate_scheduled_at(changeset) do + def validate_scheduled_at(changeset, false), do: changeset + + def validate_scheduled_at(changeset, true) do validate_change(changeset, :scheduled_at, fn _, scheduled_at -> if not expires_late_enough?(scheduled_at) do [scheduled_at: "an ephemeral activity must live for at least one hour"] diff --git a/test/tasks/database_test.exs b/test/tasks/database_test.exs index 883828d77..3a28aa133 100644 --- a/test/tasks/database_test.exs +++ b/test/tasks/database_test.exs @@ -127,4 +127,43 @@ test "it turns OrderedCollection likes into empty arrays" do assert Enum.empty?(Object.get_by_id(object2.id).data["likes"]) end end + + describe "ensure_expiration" do + test "it adds to expiration old statuses" do + %{id: activity_id1} = insert(:note_activity) + + %{id: activity_id2} = + insert(:note_activity, %{inserted_at: NaiveDateTime.from_iso8601!("2015-01-23 23:50:07")}) + + %{id: activity_id3} = activity3 = insert(:note_activity) + + expires_at = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(60 * 61, :second) + |> NaiveDateTime.truncate(:second) + + Pleroma.ActivityExpiration.create(activity3, expires_at) + + Mix.Tasks.Pleroma.Database.run(["ensure_expiration"]) + + expirations = + Pleroma.ActivityExpiration + |> order_by(:activity_id) + |> Repo.all() + + assert [ + %Pleroma.ActivityExpiration{ + activity_id: ^activity_id1 + }, + %Pleroma.ActivityExpiration{ + activity_id: ^activity_id2, + scheduled_at: ~N[2016-01-23 23:50:07] + }, + %Pleroma.ActivityExpiration{ + activity_id: ^activity_id3, + scheduled_at: ^expires_at + } + ] = expirations + end + end end -- cgit v1.2.3 From 2e7c5fe2ded095c95f8596970d8fc3aaf0128f1b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 8 Aug 2020 12:33:37 -0500 Subject: Add migration to remove invalid activity expirations --- .../20200808173046_only_expire_creates.exs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 priv/repo/migrations/20200808173046_only_expire_creates.exs diff --git a/priv/repo/migrations/20200808173046_only_expire_creates.exs b/priv/repo/migrations/20200808173046_only_expire_creates.exs new file mode 100644 index 000000000..5a34dc7c1 --- /dev/null +++ b/priv/repo/migrations/20200808173046_only_expire_creates.exs @@ -0,0 +1,20 @@ +defmodule Pleroma.Repo.Migrations.OnlyExpireCreates do + use Ecto.Migration + + def up do + statement = """ + DELETE FROM + activity_expirations A USING activities B + WHERE + A.activity_id = B.id + AND B.local = false + AND B.data->>'type' != 'Create'; + """ + + execute(statement) + end + + def down do + :ok + end +end -- cgit v1.2.3 From cf4c97242be588e55dfccb37ab2c3d6dcac3cb0f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 8 Aug 2020 12:40:52 -0500 Subject: Ensure we only expire Create activities with the Mix task --- lib/mix/tasks/pleroma/database.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index d57e59b11..b2dc3d0f3 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -136,6 +136,7 @@ def run(["ensure_expiration"]) do |> join(:left, [a], u in assoc(a, :expiration)) |> where(local: true) |> where([a, u], is_nil(u)) + |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) |> Pleroma.RepoStreamer.chunk_stream(100) |> Stream.each(fn activities -> Enum.each(activities, fn activity -> -- cgit v1.2.3 From 761cc5b4a2b4c0ef610ae7296f614ec4c9ceccad Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 8 Aug 2020 12:44:18 -0500 Subject: Don't filter on local --- priv/repo/migrations/20200808173046_only_expire_creates.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/priv/repo/migrations/20200808173046_only_expire_creates.exs b/priv/repo/migrations/20200808173046_only_expire_creates.exs index 5a34dc7c1..42fb73375 100644 --- a/priv/repo/migrations/20200808173046_only_expire_creates.exs +++ b/priv/repo/migrations/20200808173046_only_expire_creates.exs @@ -7,7 +7,6 @@ def up do activity_expirations A USING activities B WHERE A.activity_id = B.id - AND B.local = false AND B.data->>'type' != 'Create'; """ -- cgit v1.2.3 From e08ea01d09c67a93801aa05d33bad0eb24dfca8b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 8 Aug 2020 12:49:02 -0500 Subject: Limit expirations for each cron execution to 50. This should prevent servers from being crushed. 50/min is a pretty good rate. --- lib/pleroma/activity_expiration.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex index 7cc9668b3..84edf68ef 100644 --- a/lib/pleroma/activity_expiration.ex +++ b/lib/pleroma/activity_expiration.ex @@ -46,6 +46,7 @@ def due_expirations(offset \\ 0) do ActivityExpiration |> where([exp], exp.scheduled_at < ^naive_datetime) + |> limit(50) |> Repo.all() end -- cgit v1.2.3 From 66122a11b59a3859f3eb67066148e9c75139d3ee Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 10 Aug 2020 10:33:05 +0200 Subject: AccountController: Build the correct update activity. Will fix federation issues. --- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- .../controllers/account_controller/update_credentials_test.exs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index f45678184..95d8452df 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -226,7 +226,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p with changeset <- User.update_changeset(user, user_params), {:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update), updated_object <- - Pleroma.Web.ActivityPub.UserView.render("user.json", user: user) + Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user) |> Map.delete("@context"), {:ok, update_data, []} <- Builder.update(user, updated_object), {:ok, _update, _} <- 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 b888e4c71..2e6704726 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 @@ -214,6 +214,10 @@ test "updates the user's name", %{conn: conn} do assert user_data = json_response_and_validate_schema(conn, 200) assert user_data["display_name"] == "markorepairs" + + update_activity = Repo.one(Pleroma.Activity) + assert update_activity.data["type"] == "Update" + assert update_activity.data["object"]["name"] == "markorepairs" end test "updates the user's avatar", %{user: user, conn: conn} do -- cgit v1.2.3 From a818587467feb1f5d5ad1f9499e61dcd7184e864 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 7 Aug 2020 22:05:17 +0300 Subject: 20200802170532_fix_legacy_tags: Select only fields the migration needs Selecting the full struct will break as soon as a new field is added. --- priv/repo/migrations/20200802170532_fix_legacy_tags.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200802170532_fix_legacy_tags.exs b/priv/repo/migrations/20200802170532_fix_legacy_tags.exs index f7274b44e..a84b5d0f6 100644 --- a/priv/repo/migrations/20200802170532_fix_legacy_tags.exs +++ b/priv/repo/migrations/20200802170532_fix_legacy_tags.exs @@ -18,7 +18,10 @@ defmodule Pleroma.Repo.Migrations.FixLegacyTags do def change do legacy_tags = Map.keys(@old_new_map) - from(u in User, where: fragment("? && ?", u.tags, ^legacy_tags)) + from(u in User, + where: fragment("? && ?", u.tags, ^legacy_tags), + select: struct(u, [:tags, :id]) + ) |> Repo.all() |> Enum.each(fn user -> fix_tags_changeset(user) -- cgit v1.2.3 From 15fa3b6bd8dcc65b74715c500cd23923251d7fd3 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 7 Aug 2020 22:10:09 +0300 Subject: 20200802170532_fix_legacy_tags: chunk the user query --- priv/repo/migrations/20200802170532_fix_legacy_tags.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200802170532_fix_legacy_tags.exs b/priv/repo/migrations/20200802170532_fix_legacy_tags.exs index a84b5d0f6..ca82fac42 100644 --- a/priv/repo/migrations/20200802170532_fix_legacy_tags.exs +++ b/priv/repo/migrations/20200802170532_fix_legacy_tags.exs @@ -22,7 +22,7 @@ def change do where: fragment("? && ?", u.tags, ^legacy_tags), select: struct(u, [:tags, :id]) ) - |> Repo.all() + |> Repo.chunk_stream(100) |> Enum.each(fn user -> fix_tags_changeset(user) |> Repo.update() -- cgit v1.2.3 From a4a2d3864049e03599057ab87ead4aea423c47eb Mon Sep 17 00:00:00 2001 From: Ilja Date: Mon, 10 Aug 2020 11:29:40 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- docs/configuration/static_dir.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/static_dir.md b/docs/configuration/static_dir.md index 8e7eea7fb..2c18e3d38 100644 --- a/docs/configuration/static_dir.md +++ b/docs/configuration/static_dir.md @@ -26,7 +26,7 @@ There's a mix tasks to [generate a new robot.txt](../../administration/CLI_tasks For more complex things, you can write your own robots.txt to `$static_dir/robots.txt`. -E.g. if you want to block all crawerls except for [fediverse.newtork](https://fediverse.network/about) you can use +E.g. if you want to block all crawlers except for [fediverse.network](https://fediverse.network/about) you can use ``` User-Agent: * -- cgit v1.2.3 From bd7bf6cd196ffe30652ea1f7785354a7eb1e912c Mon Sep 17 00:00:00 2001 From: Ilja Date: Mon, 10 Aug 2020 11:29:54 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- docs/configuration/static_dir.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/static_dir.md b/docs/configuration/static_dir.md index 2c18e3d38..58703e3be 100644 --- a/docs/configuration/static_dir.md +++ b/docs/configuration/static_dir.md @@ -45,7 +45,7 @@ Add `$static_dir/instance/thumbnail.jpeg` with your selfie or other neat picture ## Instance-specific panel -Create and Edit your file on `$static_dir/instance/panel.html`. +Create and Edit your file at `$static_dir/instance/panel.html`. ## Background -- cgit v1.2.3 From 5c4548d5e74e40e18d8d1ed98ad256568a063370 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 10 Aug 2020 13:05:13 +0000 Subject: Revert "Merge branch 'issue/1023' into 'develop'" This reverts merge request !2763 --- .gitignore | 2 - .../CLI_tasks/release_environments.md | 9 --- docs/installation/otp_en.md | 7 +- installation/init.d/pleroma | 1 - installation/pleroma.service | 2 - lib/mix/tasks/pleroma/release_env.ex | 76 ---------------------- test/tasks/release_env_test.exs | 30 --------- 7 files changed, 2 insertions(+), 125 deletions(-) delete mode 100644 docs/administration/CLI_tasks/release_environments.md delete mode 100644 lib/mix/tasks/pleroma/release_env.ex delete mode 100644 test/tasks/release_env_test.exs diff --git a/.gitignore b/.gitignore index 6ae21e914..599b52b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,6 @@ erl_crash.dump # variables. /config/*.secret.exs /config/generated_config.exs -/config/*.env - # Database setup file, some may forget to delete it /config/setup_db.psql diff --git a/docs/administration/CLI_tasks/release_environments.md b/docs/administration/CLI_tasks/release_environments.md deleted file mode 100644 index 36ab43864..000000000 --- a/docs/administration/CLI_tasks/release_environments.md +++ /dev/null @@ -1,9 +0,0 @@ -# Generate release environment file - -```sh tab="OTP" - ./bin/pleroma_ctl release_env gen -``` - -```sh tab="From Source" -mix pleroma.release_env gen -``` diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 338dfa7d0..e4f822d1c 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -121,9 +121,6 @@ chown -R pleroma /etc/pleroma # Run the config generator su pleroma -s $SHELL -lc "./bin/pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql" -# Run the environment file generator. -su pleroma -s $SHELL -lc "./bin/pleroma_ctl release_env gen" - # Create the postgres database su postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql" @@ -134,7 +131,7 @@ su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate" # su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" # Start the instance to verify that everything is working as expected -su pleroma -s $SHELL -lc "export $(cat /opt/pleroma/config/pleroma.env); ./bin/pleroma daemon" +su pleroma -s $SHELL -lc "./bin/pleroma daemon" # Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly sleep 20 && curl http://localhost:4000/api/v1/instance @@ -203,7 +200,6 @@ rc-update add pleroma # Copy the service into a proper directory cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service - # Start pleroma and enable it on boot systemctl start pleroma systemctl enable pleroma @@ -279,3 +275,4 @@ This will create an account withe the username of 'joeuser' with the email addre ## Questions Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. + diff --git a/installation/init.d/pleroma b/installation/init.d/pleroma index e908cda1b..384536f7e 100755 --- a/installation/init.d/pleroma +++ b/installation/init.d/pleroma @@ -8,7 +8,6 @@ pidfile="/var/run/pleroma.pid" directory=/opt/pleroma healthcheck_delay=60 healthcheck_timer=30 -export $(cat /opt/pleroma/config/pleroma.env) : ${pleroma_port:-4000} diff --git a/installation/pleroma.service b/installation/pleroma.service index ee00a3b7a..5dcbc1387 100644 --- a/installation/pleroma.service +++ b/installation/pleroma.service @@ -17,8 +17,6 @@ Environment="MIX_ENV=prod" Environment="HOME=/var/lib/pleroma" ; Path to the folder containing the Pleroma installation. WorkingDirectory=/opt/pleroma -; Path to the environment file. the file contains RELEASE_COOKIE and etc -EnvironmentFile=/opt/pleroma/config/pleroma.env ; Path to the Mix binary. ExecStart=/usr/bin/mix phx.server diff --git a/lib/mix/tasks/pleroma/release_env.ex b/lib/mix/tasks/pleroma/release_env.ex deleted file mode 100644 index 9da74ffcf..000000000 --- a/lib/mix/tasks/pleroma/release_env.ex +++ /dev/null @@ -1,76 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Mix.Tasks.Pleroma.ReleaseEnv do - use Mix.Task - import Mix.Pleroma - - @shortdoc "Generate Pleroma environment file." - @moduledoc File.read!("docs/administration/CLI_tasks/release_environments.md") - - def run(["gen" | rest]) do - {options, [], []} = - OptionParser.parse( - rest, - strict: [ - force: :boolean, - path: :string - ], - aliases: [ - p: :path, - f: :force - ] - ) - - file_path = - get_option( - options, - :path, - "Environment file path", - "./config/pleroma.env" - ) - - env_path = Path.expand(file_path) - - proceed? = - if File.exists?(env_path) do - get_option( - options, - :force, - "Environment file already exists. Do you want to overwrite the #{env_path} file? (y/n)", - "n" - ) === "y" - else - true - end - - if proceed? do - case do_generate(env_path) do - {:error, reason} -> - shell_error( - File.Error.message(%{action: "write to file", reason: reason, path: env_path}) - ) - - _ -> - shell_info("\nThe file generated: #{env_path}.\n") - - shell_info(""" - WARNING: before start pleroma app please make sure to make the file read-only and non-modifiable. - Example: - chmod 0444 #{file_path} - chattr +i #{file_path} - """) - end - else - shell_info("\nThe file is exist. #{env_path}.\n") - end - end - - def do_generate(path) do - content = "RELEASE_COOKIE=#{Base.encode32(:crypto.strong_rand_bytes(32))}" - - File.mkdir_p!(Path.dirname(path)) - File.write(path, content) - end -end diff --git a/test/tasks/release_env_test.exs b/test/tasks/release_env_test.exs deleted file mode 100644 index 519f1eba9..000000000 --- a/test/tasks/release_env_test.exs +++ /dev/null @@ -1,30 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Mix.Tasks.Pleroma.ReleaseEnvTest do - use ExUnit.Case - import ExUnit.CaptureIO, only: [capture_io: 1] - - @path "config/pleroma.test.env" - - def do_clean do - if File.exists?(@path) do - File.rm_rf(@path) - end - end - - setup do - do_clean() - on_exit(fn -> do_clean() end) - :ok - end - - test "generate pleroma.env" do - assert capture_io(fn -> - Mix.Tasks.Pleroma.ReleaseEnv.run(["gen", "--path", @path, "--force"]) - end) =~ "The file generated" - - assert File.read!(@path) =~ "RELEASE_COOKIE=" - end -end -- cgit v1.2.3 From 4d76c0ec8b24d7bc91a9e432a0cf3be17c6c10b5 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 10 Aug 2020 15:12:45 +0200 Subject: Docs: Add cmake dependency --- docs/installation/debian_based_en.md | 3 ++- docs/installation/debian_based_jp.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index 8ae5044b5..60c2f47e5 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -12,6 +12,7 @@ This guide will assume you are on Debian Stretch. This guide should also work wi * `erlang-nox` * `git` * `build-essential` +* `cmake` #### Optional packages used in this guide @@ -30,7 +31,7 @@ sudo apt full-upgrade * Install some of the above mentioned programs: ```shell -sudo apt install git build-essential postgresql postgresql-contrib +sudo apt install git build-essential postgresql postgresql-contrib cmake ``` ### Install Elixir and Erlang diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index 42e91cda7..c2dd840d3 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -16,6 +16,7 @@ - `erlang-nox` - `git` - `build-essential` +- `cmake` #### このガイドで利用している追加パッケージ @@ -32,7 +33,7 @@ sudo apt full-upgrade * 上記に挙げたパッケージをインストールしておきます。 ``` -sudo apt install git build-essential postgresql postgresql-contrib +sudo apt install git build-essential postgresql postgresql-contrib cmake ``` -- cgit v1.2.3 From a2f2ba3fbbc9788b16e7d62044756b99fa0c45e1 Mon Sep 17 00:00:00 2001 From: Alibek Omarov Date: Mon, 10 Aug 2020 16:24:45 +0300 Subject: docs: add cmake to other installation guides --- docs/installation/alpine_linux_en.md | 3 ++- docs/installation/arch_linux_en.md | 3 ++- docs/installation/gentoo_en.md | 3 ++- docs/installation/netbsd_en.md | 1 + docs/installation/openbsd_en.md | 3 ++- docs/installation/openbsd_fi.md | 2 +- 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index c726d559f..a5683f18c 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -14,6 +14,7 @@ It assumes that you have administrative rights, either as root or a user with [s * `erlang-xmerl` * `git` * Development Tools +* `cmake` #### Optional packages used in this guide @@ -39,7 +40,7 @@ sudo apk upgrade * Install some tools, which are needed later: ```shell -sudo apk add git build-base +sudo apk add git build-base cmake ``` ### Install Elixir and Erlang diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md index bf9cfb488..7fb69dd60 100644 --- a/docs/installation/arch_linux_en.md +++ b/docs/installation/arch_linux_en.md @@ -9,6 +9,7 @@ This guide will assume that you have administrative rights, either as root or a * `elixir` * `git` * `base-devel` +* `cmake` #### Optional packages used in this guide @@ -26,7 +27,7 @@ sudo pacman -Syu * Install some of the above mentioned programs: ```shell -sudo pacman -S git base-devel elixir +sudo pacman -S git base-devel elixir cmake ``` ### Install PostgreSQL diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index 32152aea7..5a676380c 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -28,6 +28,7 @@ Gentoo quite pointedly does not come with a cron daemon installed, and as such i * `dev-db/postgresql` * `dev-lang/elixir` * `dev-vcs/git` +* `dev-util/cmake` #### Optional ebuilds used in this guide @@ -46,7 +47,7 @@ Gentoo quite pointedly does not come with a cron daemon installed, and as such i * Emerge all required the required and suggested software in one go: ```shell - # emerge --ask dev-db/postgresql dev-lang/elixir dev-vcs/git www-servers/nginx app-crypt/certbot app-crypt/certbot-nginx + # emerge --ask dev-db/postgresql dev-lang/elixir dev-vcs/git www-servers/nginx app-crypt/certbot app-crypt/certbot-nginx dev-util/cmake ``` If you would not like to install the optional packages, remove them from this line. diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index 3626acc69..6ad0de2f6 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -19,6 +19,7 @@ databases/postgresql11-client databases/postgresql11-server devel/git-base devel/git-docs +devel/cmake lang/elixir security/acmesh security/sudo diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index 5dbe24f75..eee452845 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -14,11 +14,12 @@ The following packages need to be installed: * git * postgresql-server * postgresql-contrib + * cmake To install them, run the following command (with doas or as root): ``` -pkg_add elixir gmake ImageMagick git postgresql-server postgresql-contrib +pkg_add elixir gmake ImageMagick git postgresql-server postgresql-contrib cmake ``` Pleroma requires a reverse proxy, OpenBSD has relayd in base (and is used in this guide) and packages/ports are available for nginx (www/nginx) and apache (www/apache-httpd). Independently of the reverse proxy, [acme-client(1)](https://man.openbsd.org/acme-client) can be used to get a certificate from Let's Encrypt. diff --git a/docs/installation/openbsd_fi.md b/docs/installation/openbsd_fi.md index 272273cff..b5b5056a9 100644 --- a/docs/installation/openbsd_fi.md +++ b/docs/installation/openbsd_fi.md @@ -16,7 +16,7 @@ Matrix-kanava #freenode_#pleroma:matrix.org ovat hyviä paikkoja löytää apua Asenna tarvittava ohjelmisto: -`# pkg_add git elixir gmake postgresql-server-10.3 postgresql-contrib-10.3` +`# pkg_add git elixir gmake postgresql-server-10.3 postgresql-contrib-10.3 cmake` Luo postgresql-tietokanta: -- cgit v1.2.3 From 11fc90744c89097969a94d2accaa8f97cb1bbd7d Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 10 Aug 2020 15:31:36 +0200 Subject: Transmogrifier: Remove duplicate code. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 7381d4476..2f04cc6ff 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -644,16 +644,6 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "ChatMessage"}} = data, - _options - ) do - with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), - {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do - {:ok, activity} - end - end - def handle_incoming(%{"type" => type} = data, _options) when type in ~w{Like EmojiReact Announce} do with :ok <- ObjectValidator.fetch_actor_and_object(data), -- cgit v1.2.3 From 249f21dcbbbc20f401a9ea9bfc6179c858abce6f Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 10 Aug 2020 17:57:36 +0400 Subject: Admin API: Filter out unapproved users when the `active` filter is on --- lib/pleroma/user/query.ex | 1 + .../controllers/admin_api_controller_test.exs | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index 45553cb6c..d618432ff 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -130,6 +130,7 @@ defp compose_query({:external, _}, query), do: location_query(query, false) defp compose_query({:active, _}, query) do User.restrict_deactivated(query) |> where([u], not is_nil(u.nickname)) + |> where([u], u.approval_pending == false) end defp compose_query({:legacy_active, _}, query) do diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index b5d5bd8c7..7f0f02605 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -1164,6 +1164,27 @@ test "load users with tags list", %{conn: conn} do } end + test "`active` filters out users pending approval", %{token: token} do + insert(:user, approval_pending: true) + %{id: user_id} = insert(:user, approval_pending: false) + %{id: admin_id} = token.user + + conn = + build_conn() + |> assign(:user, token.user) + |> assign(:token, token) + |> get("/api/pleroma/admin/users?filters=active") + + assert %{ + "count" => 2, + "page_size" => 50, + "users" => [ + %{"id" => ^admin_id}, + %{"id" => ^user_id} + ] + } = json_response(conn, 200) + end + test "it works with multiple filters" do admin = insert(:user, nickname: "john", is_admin: true) token = insert(:oauth_admin_token, user: admin) -- cgit v1.2.3 From 345ac512e43fbb127c45552690741088d465d31d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 11 Aug 2020 10:28:35 +0300 Subject: added paginate+search for admin/MediaProxy URLs --- .../controllers/media_proxy_cache_controller.ex | 41 +++++++----- .../web/admin_api/views/media_proxy_cache_view.ex | 8 ++- .../admin/media_proxy_cache_operation.ex | 47 ++++++++------ lib/pleroma/web/media_proxy/media_proxy.ex | 13 ++-- .../media_proxy_cache_controller_test.exs | 72 ++++++++++++++-------- 5 files changed, 114 insertions(+), 67 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index e2759d59f..76d3af4ef 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -26,29 +26,38 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheController do defdelegate open_api_operation(action), to: Spec.MediaProxyCacheOperation def index(%{assigns: %{user: _}} = conn, params) do - cursor = - :banned_urls_cache - |> :ets.table([{:traverse, {:select, Cachex.Query.create(true, :key)}}]) - |> :qlc.cursor() + entries = fetch_entries(params) + urls = paginate_entries(entries, params.page, params.page_size) + + render(conn, "index.json", + urls: urls, + page_size: params.page_size, + count: length(entries) + ) + end - urls = - case params.page do - 1 -> - :qlc.next_answers(cursor, params.page_size) + defp fetch_entries(params) do + MediaProxy.cache_table() + |> Cachex.export!() + |> filter_urls(params[:query]) + end - _ -> - :qlc.next_answers(cursor, (params.page - 1) * params.page_size) - :qlc.next_answers(cursor, params.page_size) - end + defp filter_urls(entries, query) when is_binary(query) do + for {_, url, _, _, _} <- entries, String.contains?(url, query), do: url + end - :qlc.delete_cursor(cursor) + defp filter_urls(entries, _) do + Enum.map(entries, fn {_, url, _, _, _} -> url end) + end - render(conn, "index.json", urls: urls) + defp paginate_entries(entries, page, page_size) do + offset = page_size * (page - 1) + Enum.slice(entries, offset, page_size) end def delete(%{assigns: %{user: _}, body_params: %{urls: urls}} = conn, _) do MediaProxy.remove_from_banned_urls(urls) - render(conn, "index.json", urls: urls) + json(conn, %{}) end def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _) do @@ -58,6 +67,6 @@ def purge(%{assigns: %{user: _}, body_params: %{urls: urls, ban: ban}} = conn, _ MediaProxy.put_in_banned_urls(urls) end - render(conn, "index.json", urls: urls) + json(conn, %{}) end end diff --git a/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex index c97400beb..a803bda0b 100644 --- a/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex +++ b/lib/pleroma/web/admin_api/views/media_proxy_cache_view.ex @@ -5,7 +5,11 @@ defmodule Pleroma.Web.AdminAPI.MediaProxyCacheView do use Pleroma.Web, :view - def render("index.json", %{urls: urls}) do - %{urls: urls} + def render("index.json", %{urls: urls, page_size: page_size, count: count}) do + %{ + urls: urls, + count: count, + page_size: page_size + } end end diff --git a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex index 20d033f66..ab45d6633 100644 --- a/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/media_proxy_cache_operation.ex @@ -21,6 +21,12 @@ def index_operation do operationId: "AdminAPI.MediaProxyCacheController.index", security: [%{"oAuth" => ["read:media_proxy_caches"]}], parameters: [ + Operation.parameter( + :query, + :query, + %Schema{type: :string, default: nil}, + "Page" + ), Operation.parameter( :page, :query, @@ -36,7 +42,26 @@ def index_operation do | admin_api_params() ], responses: %{ - 200 => success_response() + 200 => + Operation.response( + "Array of banned MediaProxy URLs in Cachex", + "application/json", + %Schema{ + type: :object, + properties: %{ + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer}, + urls: %Schema{ + type: :array, + items: %Schema{ + type: :string, + format: :uri, + description: "MediaProxy URLs" + } + } + } + } + ) } } end @@ -61,7 +86,7 @@ def delete_operation do required: true ), responses: %{ - 200 => success_response(), + 200 => empty_object_response(), 400 => Operation.response("Error", "application/json", ApiError) } } @@ -88,25 +113,9 @@ def purge_operation do required: true ), responses: %{ - 200 => success_response(), + 200 => empty_object_response(), 400 => Operation.response("Error", "application/json", ApiError) } } end - - defp success_response do - Operation.response("Array of banned MediaProxy URLs in Cachex", "application/json", %Schema{ - type: :object, - properties: %{ - urls: %Schema{ - type: :array, - items: %Schema{ - type: :string, - format: :uri, - description: "MediaProxy URLs" - } - } - } - }) - end end diff --git a/lib/pleroma/web/media_proxy/media_proxy.ex b/lib/pleroma/web/media_proxy/media_proxy.ex index dfbfcea6b..e18dd8224 100644 --- a/lib/pleroma/web/media_proxy/media_proxy.ex +++ b/lib/pleroma/web/media_proxy/media_proxy.ex @@ -9,28 +9,31 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Web.MediaProxy.Invalidation @base64_opts [padding: false] + @cache_table :banned_urls_cache + + def cache_table, do: @cache_table @spec in_banned_urls(String.t()) :: boolean() - def in_banned_urls(url), do: elem(Cachex.exists?(:banned_urls_cache, url(url)), 1) + def in_banned_urls(url), do: elem(Cachex.exists?(@cache_table, url(url)), 1) def remove_from_banned_urls(urls) when is_list(urls) do - Cachex.execute!(:banned_urls_cache, fn cache -> + Cachex.execute!(@cache_table, fn cache -> Enum.each(Invalidation.prepare_urls(urls), &Cachex.del(cache, &1)) end) end def remove_from_banned_urls(url) when is_binary(url) do - Cachex.del(:banned_urls_cache, url(url)) + Cachex.del(@cache_table, url(url)) end def put_in_banned_urls(urls) when is_list(urls) do - Cachex.execute!(:banned_urls_cache, fn cache -> + Cachex.execute!(@cache_table, fn cache -> Enum.each(Invalidation.prepare_urls(urls), &Cachex.put(cache, &1, true)) end) end def put_in_banned_urls(url) when is_binary(url) do - Cachex.put(:banned_urls_cache, url(url), true) + Cachex.put(@cache_table, url(url), true) end def url(url) when is_nil(url) or url == "", do: nil diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 5ab6cb78a..3cf98d7c7 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -48,6 +48,9 @@ test "shows banned MediaProxy URLs", %{conn: conn} do |> get("/api/pleroma/admin/media_proxy_caches?page_size=2") |> json_response_and_validate_schema(200) + assert response["page_size"] == 2 + assert response["count"] == 5 + assert response["urls"] == [ "http://localhost:4001/media/fb1f4d.jpg", "http://localhost:4001/media/a688346.jpg" @@ -63,6 +66,9 @@ test "shows banned MediaProxy URLs", %{conn: conn} do "http://localhost:4001/media/tb13f47.jpg" ] + assert response["page_size"] == 2 + assert response["count"] == 5 + response = conn |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&page=3") @@ -70,6 +76,30 @@ test "shows banned MediaProxy URLs", %{conn: conn} do assert response["urls"] == ["http://localhost:4001/media/wb1f46.jpg"] end + + test "search banned MediaProxy URLs", %{conn: conn} do + MediaProxy.put_in_banned_urls([ + "http://localhost:4001/media/a688346.jpg", + "http://localhost:4001/media/ff44b1f4d.jpg" + ]) + + MediaProxy.put_in_banned_urls("http://localhost:4001/media/gb1f44.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/tb13f47.jpg") + MediaProxy.put_in_banned_urls("http://localhost:4001/media/wb1f46.jpg") + + response = + conn + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&query=f44") + |> json_response_and_validate_schema(200) + + assert response["urls"] == [ + "http://localhost:4001/media/gb1f44.jpg", + "http://localhost:4001/media/ff44b1f4d.jpg" + ] + + assert response["page_size"] == 2 + assert response["count"] == 2 + end end describe "POST /api/pleroma/admin/media_proxy_caches/delete" do @@ -79,15 +109,13 @@ test "deleted MediaProxy URLs from banned", %{conn: conn} do "http://localhost:4001/media/fb1f4d.jpg" ]) - response = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/media_proxy_caches/delete", %{ - urls: ["http://localhost:4001/media/a688346.jpg"] - }) - |> json_response_and_validate_schema(200) + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/delete", %{ + urls: ["http://localhost:4001/media/a688346.jpg"] + }) + |> json_response_and_validate_schema(200) - assert response["urls"] == ["http://localhost:4001/media/a688346.jpg"] refute MediaProxy.in_banned_urls("http://localhost:4001/media/a688346.jpg") assert MediaProxy.in_banned_urls("http://localhost:4001/media/fb1f4d.jpg") end @@ -106,13 +134,10 @@ test "perform invalidates cache of MediaProxy", %{conn: conn} do purge: fn _, _ -> {"ok", 0} end ]} ] do - response = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/media_proxy_caches/purge", %{urls: urls, ban: false}) - |> json_response_and_validate_schema(200) - - assert response["urls"] == urls + conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/media_proxy_caches/purge", %{urls: urls, ban: false}) + |> json_response_and_validate_schema(200) refute MediaProxy.in_banned_urls("http://example.com/media/a688346.jpg") refute MediaProxy.in_banned_urls("http://example.com/media/fb1f4d.jpg") @@ -126,16 +151,13 @@ test "perform invalidates cache of MediaProxy and adds url to banned", %{conn: c ] with_mocks [{MediaProxy.Invalidation.Script, [], [purge: fn _, _ -> {"ok", 0} end]}] do - response = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/media_proxy_caches/purge", %{ - urls: urls, - ban: true - }) - |> json_response_and_validate_schema(200) - - assert response["urls"] == urls + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/pleroma/admin/media_proxy_caches/purge", + %{urls: urls, ban: true} + ) + |> json_response_and_validate_schema(200) assert MediaProxy.in_banned_urls("http://example.com/media/a688346.jpg") assert MediaProxy.in_banned_urls("http://example.com/media/fb1f4d.jpg") -- cgit v1.2.3 From 7e4932362b7e672b08543cfd189deb3776268fe3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 10:54:38 +0200 Subject: SideEffects: Handle strange deletion case. --- lib/pleroma/web/activity_pub/side_effects.ex | 12 ++++++++++-- test/web/activity_pub/side_effects_test.exs | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5104d38ee..bff7c6629 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -22,6 +22,8 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.Streamer alias Pleroma.Workers.BackgroundWorker + require Logger + def handle(object, meta \\ []) # Tasks this handle @@ -217,13 +219,15 @@ def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do # - Stream out the activity def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do deleted_object = - Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object) + Object.normalize(deleted_object, false) || + User.get_cached_by_ap_id(deleted_object) result = case deleted_object do %Object{} -> with {:ok, deleted_object, activity} <- Object.delete(deleted_object), - %User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do + {_, actor} when is_binary(actor) <- {:actor, deleted_object.data["actor"]}, + %User{} = user <- User.get_cached_by_ap_id(actor) do User.remove_pinnned_activity(user, activity) {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) @@ -237,6 +241,10 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, ActivityPub.stream_out(object) ActivityPub.stream_out_participations(deleted_object, user) :ok + else + {:actor, _} -> + Logger.error("The object doesn't have an actor: #{inspect(deleted_object)}") + :no_object_actor end %User{} -> diff --git a/test/web/activity_pub/side_effects_test.exs b/test/web/activity_pub/side_effects_test.exs index 4a08eb7ee..9efbaad04 100644 --- a/test/web/activity_pub/side_effects_test.exs +++ b/test/web/activity_pub/side_effects_test.exs @@ -19,8 +19,9 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do alias Pleroma.Web.ActivityPub.SideEffects alias Pleroma.Web.CommonAPI - import Pleroma.Factory + import ExUnit.CaptureLog import Mock + import Pleroma.Factory describe "handle_after_transaction" do test "it streams out notifications and streams" do @@ -221,6 +222,22 @@ test "it handles user deletions", %{delete_user: delete, user: user} do assert User.get_cached_by_ap_id(user.ap_id).deactivated end + + test "it logs issues with objects deletion", %{ + delete: delete, + object: object + } do + {:ok, object} = + object + |> Object.change(%{data: Map.delete(object.data, "actor")}) + |> Repo.update() + + Object.invalid_object_cache(object) + + assert capture_log(fn -> + {:error, :no_object_actor} = SideEffects.handle(delete) + end) =~ "object doesn't have an actor" + end end describe "EmojiReact objects" do -- cgit v1.2.3 From 9a9121805ceccfa62c77e9abc81af5f7c7fd4049 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 09:08:27 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- test/support/conn_case.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index b50ff1bcc..7ef681258 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -58,7 +58,7 @@ defp request_content_type(%{conn: conn}) do defp empty_json_response(conn) do body = response(conn, 204) - _ = response_content_type(conn, :json) + response_content_type(conn, :json) body end -- cgit v1.2.3 From 54a6855ddfb4b47b91b8fe2c184bbca3dbc2884d Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 14:00:21 +0200 Subject: Transmogrifier Tests: Extract Accept handling --- .../transmogrifier/accept_handling_test.exs | 113 +++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 102 +------------------ 2 files changed, 114 insertions(+), 101 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/accept_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/accept_handling_test.exs b/test/web/activity_pub/transmogrifier/accept_handling_test.exs new file mode 100644 index 000000000..3c4e134ff --- /dev/null +++ b/test/web/activity_pub/transmogrifier/accept_handling_test.exs @@ -0,0 +1,113 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.AcceptHandlingTest do + use Pleroma.DataCase + + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "it works for incoming accepts which were pre-accepted" do + follower = insert(:user) + followed = insert(:user) + + {:ok, follower} = User.follow(follower, followed) + assert User.following?(follower, followed) == true + + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) + + accept_data = + File.read!("test/fixtures/mastodon-accept-activity.json") + |> Poison.decode!() + |> Map.put("actor", followed.ap_id) + + object = + accept_data["object"] + |> Map.put("actor", follower.ap_id) + |> Map.put("id", follow_activity.data["id"]) + + accept_data = Map.put(accept_data, "object", object) + + {:ok, activity} = Transmogrifier.handle_incoming(accept_data) + refute activity.local + + assert activity.data["object"] == follow_activity.data["id"] + + assert activity.data["id"] == accept_data["id"] + + follower = User.get_cached_by_id(follower.id) + + assert User.following?(follower, followed) == true + end + + test "it works for incoming accepts which were orphaned" do + follower = insert(:user) + followed = insert(:user, locked: true) + + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) + + accept_data = + File.read!("test/fixtures/mastodon-accept-activity.json") + |> Poison.decode!() + |> Map.put("actor", followed.ap_id) + + accept_data = + Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) + + {:ok, activity} = Transmogrifier.handle_incoming(accept_data) + assert activity.data["object"] == follow_activity.data["id"] + + follower = User.get_cached_by_id(follower.id) + + assert User.following?(follower, followed) == true + end + + test "it works for incoming accepts which are referenced by IRI only" do + follower = insert(:user) + followed = insert(:user, locked: true) + + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) + + accept_data = + File.read!("test/fixtures/mastodon-accept-activity.json") + |> Poison.decode!() + |> Map.put("actor", followed.ap_id) + |> Map.put("object", follow_activity.data["id"]) + + {:ok, activity} = Transmogrifier.handle_incoming(accept_data) + assert activity.data["object"] == follow_activity.data["id"] + + follower = User.get_cached_by_id(follower.id) + + assert User.following?(follower, followed) == true + + follower = User.get_by_id(follower.id) + assert follower.following_count == 1 + + followed = User.get_by_id(followed.id) + assert followed.follower_count == 1 + end + + test "it fails for incoming accepts which cannot be correlated" do + follower = insert(:user) + followed = insert(:user, locked: true) + + accept_data = + File.read!("test/fixtures/mastodon-accept-activity.json") + |> Poison.decode!() + |> Map.put("actor", followed.ap_id) + + accept_data = + Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) + + :error = Transmogrifier.handle_incoming(accept_data) + + follower = User.get_cached_by_id(follower.id) + + refute User.following?(follower, followed) == true + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 6dd9a3fec..52b4178bf 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -359,7 +359,7 @@ test "it strips internal reactions" do refute Map.has_key?(object_data, "reaction_count") end - test "it works for incomming unfollows with an existing follow" do + test "it works for incoming unfollows with an existing follow" do user = insert(:user) follow_data = @@ -403,106 +403,6 @@ test "it works for incoming follows to locked account" do assert [^pending_follower] = User.get_follow_requests(user) end - test "it works for incoming accepts which were pre-accepted" do - follower = insert(:user) - followed = insert(:user) - - {:ok, follower} = User.follow(follower, followed) - assert User.following?(follower, followed) == true - - {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) - - accept_data = - File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - - object = - accept_data["object"] - |> Map.put("actor", follower.ap_id) - |> Map.put("id", follow_activity.data["id"]) - - accept_data = Map.put(accept_data, "object", object) - - {:ok, activity} = Transmogrifier.handle_incoming(accept_data) - refute activity.local - - assert activity.data["object"] == follow_activity.data["id"] - - assert activity.data["id"] == accept_data["id"] - - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, followed) == true - end - - test "it works for incoming accepts which were orphaned" do - follower = insert(:user) - followed = insert(:user, locked: true) - - {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) - - accept_data = - File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - - accept_data = - Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) - - {:ok, activity} = Transmogrifier.handle_incoming(accept_data) - assert activity.data["object"] == follow_activity.data["id"] - - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, followed) == true - end - - test "it works for incoming accepts which are referenced by IRI only" do - follower = insert(:user) - followed = insert(:user, locked: true) - - {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) - - accept_data = - File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - |> Map.put("object", follow_activity.data["id"]) - - {:ok, activity} = Transmogrifier.handle_incoming(accept_data) - assert activity.data["object"] == follow_activity.data["id"] - - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, followed) == true - - follower = User.get_by_id(follower.id) - assert follower.following_count == 1 - - followed = User.get_by_id(followed.id) - assert followed.follower_count == 1 - end - - test "it fails for incoming accepts which cannot be correlated" do - follower = insert(:user) - followed = insert(:user, locked: true) - - accept_data = - File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - - accept_data = - Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) - - :error = Transmogrifier.handle_incoming(accept_data) - - follower = User.get_cached_by_id(follower.id) - - refute User.following?(follower, followed) == true - end - test "it fails for incoming rejects which cannot be correlated" do follower = insert(:user) followed = insert(:user, locked: true) -- cgit v1.2.3 From 8f9fbc86c0dcf307b87b38d218b36df2f9f35a7f Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 14:02:09 +0200 Subject: Transmogrifier: Small readability changes. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 6 ++++-- test/web/activity_pub/transmogrifier/undo_handling_test.exs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 2f04cc6ff..fe016e720 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -661,7 +661,8 @@ def handle_incoming( ) when type in ~w{Update Block Follow} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), - {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _} <- + Pipeline.common_pipeline(data, local: false) do {:ok, activity} end end @@ -670,7 +671,8 @@ def handle_incoming( %{"type" => "Delete"} = data, _options ) do - with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + with {:ok, activity, _} <- + Pipeline.common_pipeline(data, local: false) do {:ok, activity} else {:error, {:validate_object, _}} = e -> diff --git a/test/web/activity_pub/transmogrifier/undo_handling_test.exs b/test/web/activity_pub/transmogrifier/undo_handling_test.exs index 01dd6c370..8683f7135 100644 --- a/test/web/activity_pub/transmogrifier/undo_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/undo_handling_test.exs @@ -130,7 +130,7 @@ test "it works for incoming unannounces with an existing notice" do "http://mastodon.example.org/users/admin/statuses/99542391527669785/activity" end - test "it works for incomming unfollows with an existing follow" do + test "it works for incoming unfollows with an existing follow" do user = insert(:user) follow_data = -- cgit v1.2.3 From f1a0c10b17ff20a5ebbd070dc38aaedf82f8fe2e Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 15:13:07 +0200 Subject: AcceptValidator: Add basic validator with tests. --- lib/pleroma/web/activity_pub/builder.ex | 13 +++++++ lib/pleroma/web/activity_pub/object_validator.ex | 11 ++++++ .../object_validators/accept_validator.ex | 42 +++++++++++++++++++++ .../object_validators/accept_validation_test.exs | 44 ++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/object_validators/accept_validator.ex create mode 100644 test/web/activity_pub/object_validators/accept_validation_test.exs diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index 1b4c421b8..e1f88e6cc 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -14,6 +14,19 @@ defmodule Pleroma.Web.ActivityPub.Builder do require Pleroma.Constants + @spec accept(User.t(), Activity.t()) :: {:ok, map(), keyword()} + def accept(actor, accepted_activity) do + data = %{ + "id" => Utils.generate_activity_id(), + "actor" => actor.ap_id, + "type" => "Accept", + "object" => accepted_activity.data["id"], + "to" => [accepted_activity.actor] + } + + {:ok, data, []} + end + @spec follow(User.t(), User.t()) :: {:ok, map(), keyword()} def follow(follower, followed) do data = %{ diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index e1114a44d..d9dd2bc30 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator @@ -30,6 +31,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) + def validate(%{"type" => "Accept"} = object, meta) do + with {:ok, object} <- + object + |> AcceptValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Follow"} = object, meta) do with {:ok, object} <- object diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex new file mode 100644 index 000000000..b81e078e3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Accept"]) + |> validate_actor_presence() + |> validate_object_presence() + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end +end diff --git a/test/web/activity_pub/object_validators/accept_validation_test.exs b/test/web/activity_pub/object_validators/accept_validation_test.exs new file mode 100644 index 000000000..7f5dc14af --- /dev/null +++ b/test/web/activity_pub/object_validators/accept_validation_test.exs @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidationTest do + use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.Pipeline + alias Pleroma.Web.ActivityPub.ObjectValidator + + import Pleroma.Factory + + setup do + follower = insert(:user) + followed = insert(:user, local: false) + + {:ok, follow_data, _} = Builder.follow(follower, followed) + {:ok, follow_activity, _} = Pipeline.common_pipeline(follow_data, local: true) + + {:ok, accept_data, _} = Builder.accept(followed, follow_activity) + + %{accept_data: accept_data, followed: followed} + end + + test "it validates a basic 'accept'", %{accept_data: accept_data} do + assert {:ok, _, _} = ObjectValidator.validate(accept_data, []) + end + + test "it fails when the actor doesn't exist", %{accept_data: accept_data} do + accept_data = + accept_data + |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") + + assert {:error, _} = ObjectValidator.validate(accept_data, []) + end + + test "it fails when the accepted activity doesn't exist", %{accept_data: accept_data} do + accept_data = + accept_data + |> Map.put("object", "https://gensokyo.2hu/users/raymoo/follows/1") + + assert {:error, _} = ObjectValidator.validate(accept_data, []) + end +end -- cgit v1.2.3 From 304ed357436522f73036c912eaaa8d8e38e1d469 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Tue, 11 Aug 2020 17:21:17 +0400 Subject: Set `users.approval_pending` default to `false` --- ...200811125613_set_defaults_to_user_approval_pending.exs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 priv/repo/migrations/20200811125613_set_defaults_to_user_approval_pending.exs diff --git a/priv/repo/migrations/20200811125613_set_defaults_to_user_approval_pending.exs b/priv/repo/migrations/20200811125613_set_defaults_to_user_approval_pending.exs new file mode 100644 index 000000000..eec7da03f --- /dev/null +++ b/priv/repo/migrations/20200811125613_set_defaults_to_user_approval_pending.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.SetDefaultsToUserApprovalPending do + use Ecto.Migration + + def up do + execute("UPDATE users SET approval_pending = false WHERE approval_pending IS NULL") + + alter table(:users) do + modify(:approval_pending, :boolean, default: false, null: false) + end + end + + def down do + :ok + end +end -- cgit v1.2.3 From 8b1e8bec2ffcb3a73eea93015d73b44c4996baff Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 15:32:00 +0200 Subject: AcceptValidation: Codify accept rules. --- .../activity_pub/object_validators/accept_validator.ex | 16 +++++++++++++++- .../object_validators/accept_validation_test.exs | 11 +++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex index b81e078e3..6d0fa669a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Activity import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -31,7 +32,8 @@ def validate_data(cng) do |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Accept"]) |> validate_actor_presence() - |> validate_object_presence() + |> validate_object_presence(allowed_types: ["Follow"]) + |> validate_accept_rights() end def cast_and_validate(data) do @@ -39,4 +41,16 @@ def cast_and_validate(data) do |> cast_data |> validate_data end + + def validate_accept_rights(cng) do + with object_id when is_binary(object_id) <- get_field(cng, :object), + %Activity{data: %{"object" => followed_actor}} <- Activity.get_by_ap_id(object_id), + true <- followed_actor == get_field(cng, :actor) do + cng + else + _e -> + cng + |> add_error(:actor, "can't accept the given activity") + end + end end diff --git a/test/web/activity_pub/object_validators/accept_validation_test.exs b/test/web/activity_pub/object_validators/accept_validation_test.exs index 7f5dc14af..2d5d18046 100644 --- a/test/web/activity_pub/object_validators/accept_validation_test.exs +++ b/test/web/activity_pub/object_validators/accept_validation_test.exs @@ -41,4 +41,15 @@ test "it fails when the accepted activity doesn't exist", %{accept_data: accept_ assert {:error, _} = ObjectValidator.validate(accept_data, []) end + + test "for an accepted follow, it only validates if the actor of the accept is the followed actor", + %{accept_data: accept_data} do + stranger = insert(:user) + + accept_data = + accept_data + |> Map.put("actor", stranger.ap_id) + + assert {:error, _} = ObjectValidator.validate(accept_data, []) + end end -- cgit v1.2.3 From da3f9b9988d2cee4baa6018e6450b2d6027e1ce3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 15:41:19 +0200 Subject: Transmogrifier: Remove handling of orphaned accepts This was a Mastodon 2.3 issue and has been fixed for a long time. According to fediverse.networks, less than one percent of servers still run a version this old or older. --- lib/pleroma/web/activity_pub/transmogrifier.ex | 18 +----------------- .../transmogrifier/accept_handling_test.exs | 22 ---------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fe016e720..5ea97e9b7 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -391,27 +391,11 @@ defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = objec defp fix_content(object), do: object - defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do - with true <- id =~ "follows", - %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), - %Activity{} = activity <- Utils.fetch_latest_follow(follower, followed) do - {:ok, activity} - else - _ -> {:error, nil} - end - end - - defp mastodon_follow_hack(_, _), do: {:error, nil} - - defp get_follow_activity(follow_object, followed) do + defp get_follow_activity(follow_object, _followed) do with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object), {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do {:ok, activity} else - # Can't find the activity. This might a Mastodon 2.3 "Accept" - {:activity, nil} -> - mastodon_follow_hack(follow_object, followed) - _ -> {:error, nil} end diff --git a/test/web/activity_pub/transmogrifier/accept_handling_test.exs b/test/web/activity_pub/transmogrifier/accept_handling_test.exs index 3c4e134ff..bc4cc227d 100644 --- a/test/web/activity_pub/transmogrifier/accept_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/accept_handling_test.exs @@ -44,28 +44,6 @@ test "it works for incoming accepts which were pre-accepted" do assert User.following?(follower, followed) == true end - test "it works for incoming accepts which were orphaned" do - follower = insert(:user) - followed = insert(:user, locked: true) - - {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) - - accept_data = - File.read!("test/fixtures/mastodon-accept-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - - accept_data = - Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) - - {:ok, activity} = Transmogrifier.handle_incoming(accept_data) - assert activity.data["object"] == follow_activity.data["id"] - - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, followed) == true - end - test "it works for incoming accepts which are referenced by IRI only" do follower = insert(:user) followed = insert(:user, locked: true) -- cgit v1.2.3 From 3f6d50111e57a942ecc24d4aa7cdbec23b95dfec Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 16:07:42 +0200 Subject: Linter fixes. --- lib/pleroma/web/activity_pub/object_validators/accept_validator.ex | 2 +- test/web/activity_pub/object_validators/accept_validation_test.exs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex index 6d0fa669a..fd75f4b6e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex @@ -5,8 +5,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidator do use Ecto.Schema - alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations diff --git a/test/web/activity_pub/object_validators/accept_validation_test.exs b/test/web/activity_pub/object_validators/accept_validation_test.exs index 2d5d18046..d6111ba41 100644 --- a/test/web/activity_pub/object_validators/accept_validation_test.exs +++ b/test/web/activity_pub/object_validators/accept_validation_test.exs @@ -4,9 +4,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidationTest do use Pleroma.DataCase + alias Pleroma.Web.ActivityPub.Builder - alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.Pipeline import Pleroma.Factory -- cgit v1.2.3 From 9dda13bfa193be8ab5d9b2c117f7a50aaba451e1 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 16:22:15 +0200 Subject: Transmogrifier Test: Remove mastodon hack test. --- test/web/activity_pub/transmogrifier_test.exs | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 52b4178bf..13da864d1 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -422,32 +422,6 @@ test "it fails for incoming rejects which cannot be correlated" do refute User.following?(follower, followed) == true end - test "it works for incoming rejects which are orphaned" do - follower = insert(:user) - followed = insert(:user, locked: true) - - {:ok, follower} = User.follow(follower, followed) - {:ok, _, _, _follow_activity} = CommonAPI.follow(follower, followed) - - assert User.following?(follower, followed) == true - - reject_data = - File.read!("test/fixtures/mastodon-reject-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - - reject_data = - Map.put(reject_data, "object", Map.put(reject_data["object"], "actor", follower.ap_id)) - - {:ok, activity} = Transmogrifier.handle_incoming(reject_data) - refute activity.local - assert activity.data["id"] == reject_data["id"] - - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, followed) == false - end - test "it works for incoming rejects which are referenced by IRI only" do follower = insert(:user) followed = insert(:user, locked: true) -- cgit v1.2.3 From f988d82e463d2c08fa2cc22dc6ee733ee8668671 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 17:26:01 +0200 Subject: Transmogrifier: Handle accepts with the pipeline --- lib/pleroma/web/activity_pub/side_effects.ex | 28 +++++++++++++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 32 +--------------------- .../transmogrifier/accept_handling_test.exs | 2 +- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5104d38ee..3ba7eaf9e 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -21,9 +21,37 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.Push alias Pleroma.Web.Streamer alias Pleroma.Workers.BackgroundWorker + alias Pleroma.FollowingRelationship def handle(object, meta \\ []) + # Task this handles + # - Follows + # - Sends a notification + def handle( + %{ + data: %{ + "actor" => actor, + "type" => "Accept", + "object" => follow_activity_id + } + } = object, + meta + ) do + with %Activity{actor: follower_id} = follow_activity <- + Activity.get_by_ap_id(follow_activity_id), + %User{} = followed <- User.get_cached_by_ap_id(actor), + %User{} = follower <- User.get_cached_by_ap_id(follower_id), + {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), + {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do + Notification.update_notification_type(followed, follow_activity) + User.update_follower_count(followed) + User.update_following_count(follower) + end + + {:ok, object, meta} + end + # Tasks this handle # - Follows if possible # - Sends a notification diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 5ea97e9b7..24da1ef9c 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.FollowingRelationship alias Pleroma.Maps - alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo @@ -535,35 +534,6 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Accept", "object" => follow_object, "actor" => _actor, "id" => id} = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor), - {:ok, follow_activity} <- get_follow_activity(follow_object, followed), - {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), - %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do - User.update_follower_count(followed) - User.update_following_count(follower) - - Notification.update_notification_type(followed, follow_activity) - - ActivityPub.accept(%{ - to: follow_activity.data["to"], - type: "Accept", - actor: followed, - object: follow_activity.data["id"], - local: false, - activity_id: id - }) - else - _e -> - :error - end - end - def handle_incoming( %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => id} = data, _options @@ -643,7 +613,7 @@ def handle_incoming( %{"type" => type} = data, _options ) - when type in ~w{Update Block Follow} do + when type in ~w{Update Block Follow Accept} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do diff --git a/test/web/activity_pub/transmogrifier/accept_handling_test.exs b/test/web/activity_pub/transmogrifier/accept_handling_test.exs index bc4cc227d..77d468f5c 100644 --- a/test/web/activity_pub/transmogrifier/accept_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/accept_handling_test.exs @@ -82,7 +82,7 @@ test "it fails for incoming accepts which cannot be correlated" do accept_data = Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) - :error = Transmogrifier.handle_incoming(accept_data) + {:error, _} = Transmogrifier.handle_incoming(accept_data) follower = User.get_cached_by_id(follower.id) -- cgit v1.2.3 From ff4e282aad09954db5d7e234923854a21a002128 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 11 Aug 2020 09:52:28 -0500 Subject: Ensure ap_id column in users table cannot be null --- priv/repo/migrations/20200811143147_ap_id_not_null.exs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 priv/repo/migrations/20200811143147_ap_id_not_null.exs diff --git a/priv/repo/migrations/20200811143147_ap_id_not_null.exs b/priv/repo/migrations/20200811143147_ap_id_not_null.exs new file mode 100644 index 000000000..3e5d27fe1 --- /dev/null +++ b/priv/repo/migrations/20200811143147_ap_id_not_null.exs @@ -0,0 +1,13 @@ +defmodule Pleroma.Repo.Migrations.ApIdNotNull do + use Ecto.Migration + + def up do + alter table(:users) do + modify(:ap_id, :string, null: false) + end + end + + def down do + :ok + end +end -- cgit v1.2.3 From 25bfee0d12d6ee096bba169089cc57c91efd7bc3 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 17:43:16 +0200 Subject: ActivityPub: Remove ActivityPub.accept Switch to the pipeline in CommonAPI and SideEffects --- lib/pleroma/web/activity_pub/activity_pub.ex | 5 ----- lib/pleroma/web/activity_pub/side_effects.ex | 15 +++------------ lib/pleroma/web/common_api/common_api.ex | 13 ++----------- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index fe62673dc..6dd94119b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -285,11 +285,6 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d end end - @spec accept(map()) :: {:ok, Activity.t()} | {:error, any()} - def accept(params) do - accept_or_reject("Accept", params) - end - @spec reject(map()) :: {:ok, Activity.t()} | {:error, any()} def reject(params) do accept_or_reject("Reject", params) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 3ba7eaf9e..4228041e7 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Repo 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.Push @@ -72,18 +73,8 @@ def handle( {_, {:ok, _}, _, _} <- {:following, User.follow(follower, followed, :follow_pending), follower, followed} do if followed.local && !followed.locked do - Utils.update_follow_state_for_all(object, "accept") - FollowingRelationship.update(follower, followed, :follow_accept) - User.update_follower_count(followed) - User.update_following_count(follower) - - %{ - to: [following_user], - actor: followed, - object: follow_id, - local: true - } - |> ActivityPub.accept() + {:ok, accept_data, _} = Builder.accept(followed, object) + {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) end else {:following, {:error, _}, follower, followed} -> diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index c08e0ffeb..7b08c19a8 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -122,17 +122,8 @@ def unfollow(follower, unfollowed) do def accept_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), - {:ok, follower} <- User.follow(follower, followed), - {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept), - {:ok, _activity} <- - ActivityPub.accept(%{ - to: [follower.ap_id], - actor: followed, - object: follow_activity.data["id"], - type: "Accept" - }) do - Notification.update_notification_type(followed, follow_activity) + {:ok, accept_data, _} <- Builder.accept(followed, follow_activity), + {:ok, _activity, _} <- Pipeline.common_pipeline(accept_data, local: true) do {:ok, follower} end end -- cgit v1.2.3 From 724ed354f25fb83f65dff2fbadd4b5a121fb77d0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 11 Aug 2020 11:28:22 -0500 Subject: Ensure only Note objects are set to expire --- lib/mix/tasks/pleroma/database.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index b2dc3d0f3..7d8f00b08 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -134,14 +134,23 @@ def run(["ensure_expiration"]) do Pleroma.Activity |> join(:left, [a], u in assoc(a, :expiration)) + |> join(:inner, [a, _u], o in Object, + on: + fragment( + "(?->>'id') = COALESCE((?)->'object'->> 'id', (?)->>'object')", + o.data, + a.data, + a.data + ) + ) |> where(local: true) |> where([a, u], is_nil(u)) |> where([a], fragment("(? ->> 'type'::text) = 'Create'", a.data)) + |> where([_a, _u, o], fragment("?->>'type' = 'Note'", o.data)) |> Pleroma.RepoStreamer.chunk_stream(100) |> Stream.each(fn activities -> Enum.each(activities, fn activity -> expires_at = Timex.shift(activity.inserted_at, days: days) - Pleroma.ActivityExpiration.create(activity, expires_at, false) end) end) -- cgit v1.2.3 From 500576dcb623bdc29193e3b372837c581e151755 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 11 Aug 2020 19:22:14 +0200 Subject: Linting fixes. --- lib/pleroma/web/activity_pub/side_effects.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 4228041e7..e1fa75e1c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -22,7 +22,6 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.Push alias Pleroma.Web.Streamer alias Pleroma.Workers.BackgroundWorker - alias Pleroma.FollowingRelationship def handle(object, meta \\ []) -- cgit v1.2.3 From 76462efbfaa4bc01ca80cb702161b3197968c584 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Tue, 11 Aug 2020 22:06:33 +0300 Subject: fix job monitor --- lib/pleroma/job_queue_monitor.ex | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/job_queue_monitor.ex b/lib/pleroma/job_queue_monitor.ex index 2ecf261f3..c255a61ec 100644 --- a/lib/pleroma/job_queue_monitor.ex +++ b/lib/pleroma/job_queue_monitor.ex @@ -15,8 +15,8 @@ def start_link(_) do @impl true def init(state) do - :telemetry.attach("oban-monitor-failure", [:oban, :failure], &handle_event/4, nil) - :telemetry.attach("oban-monitor-success", [:oban, :success], &handle_event/4, nil) + :telemetry.attach("oban-monitor-failure", [:oban, :job, :exception], &handle_event/4, nil) + :telemetry.attach("oban-monitor-success", [:oban, :job, :stop], &handle_event/4, nil) {:ok, state} end @@ -25,8 +25,11 @@ def stats do GenServer.call(__MODULE__, :stats) end - def handle_event([:oban, status], %{duration: duration}, meta, _) do - GenServer.cast(__MODULE__, {:process_event, status, duration, meta}) + def handle_event([:oban, :job, event], %{duration: duration}, meta, _) do + GenServer.cast( + __MODULE__, + {:process_event, mapping_status(event), duration, meta} + ) end @impl true @@ -75,4 +78,7 @@ defp update_queue(queue, status, _meta, _duration) do |> Map.update!(:processed_jobs, &(&1 + 1)) |> Map.update!(status, &(&1 + 1)) end + + defp mapping_status(:stop), do: :success + defp mapping_status(:exception), do: :failure end -- cgit v1.2.3 From 644effc63b870d23830875aaca2b8caa81262662 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Wed, 12 Aug 2020 08:51:09 +0300 Subject: update docs --- docs/API/admin_api.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 4b143e4ee..05e63b528 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -1266,11 +1266,14 @@ Loads json generated from `config/descriptions.exs`. - Params: - *optional* `page`: **integer** page number - *optional* `page_size`: **integer** number of log entries per page (default is `50`) +- *optional* `query`: **string** search term - Response: ``` json { + "page_size": integer, + "count": integer, "urls": [ "http://example.com/media/a688346.jpg", "http://example.com/media/fb1f4d.jpg" @@ -1290,12 +1293,7 @@ Loads json generated from `config/descriptions.exs`. - Response: ``` json -{ - "urls": [ - "http://example.com/media/a688346.jpg", - "http://example.com/media/fb1f4d.jpg" - ] -} +{ } ``` @@ -1311,11 +1309,6 @@ Loads json generated from `config/descriptions.exs`. - Response: ``` json -{ - "urls": [ - "http://example.com/media/a688346.jpg", - "http://example.com/media/fb1f4d.jpg" - ] -} +{ } ``` -- cgit v1.2.3 From 57b455de5a378d661eb794c2c9d75a2684d74ef3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 12 Aug 2020 12:41:47 +0300 Subject: leave expirations with Create and Note types --- priv/repo/migrations/20200808173046_only_expire_creates.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/priv/repo/migrations/20200808173046_only_expire_creates.exs b/priv/repo/migrations/20200808173046_only_expire_creates.exs index 42fb73375..9df52956f 100644 --- a/priv/repo/migrations/20200808173046_only_expire_creates.exs +++ b/priv/repo/migrations/20200808173046_only_expire_creates.exs @@ -4,10 +4,10 @@ defmodule Pleroma.Repo.Migrations.OnlyExpireCreates do def up do statement = """ DELETE FROM - activity_expirations A USING activities B + activity_expirations a_exp USING activities a, objects o WHERE - A.activity_id = B.id - AND B.data->>'type' != 'Create'; + a_exp.activity_id = a.id AND (o.data->>'id') = COALESCE(a.data->'object'->>'id', a.data->>'object') + AND (a.data->>'type' != 'Create' OR o.data->>'type' != 'Note'); """ execute(statement) -- cgit v1.2.3 From 62f7cca9a1e3f6c6685094eb3618876d4b6ca3a7 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 12 Aug 2020 13:39:54 +0200 Subject: Transmogrifier Tests: Extract rejections. --- .../transmogrifier/follow_handling_test.exs | 19 ++++++ .../transmogrifier/reject_handling_test.exs | 67 ++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 72 ---------------------- 3 files changed, 86 insertions(+), 72 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/reject_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/follow_handling_test.exs b/test/web/activity_pub/transmogrifier/follow_handling_test.exs index 17e764ca1..757d90941 100644 --- a/test/web/activity_pub/transmogrifier/follow_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/follow_handling_test.exs @@ -185,5 +185,24 @@ test "it works for incoming follow requests from hubzilla" do assert data["id"] == "https://hubzilla.example.org/channel/kaniini#follows/2" assert User.following?(User.get_cached_by_ap_id(data["actor"]), user) end + + test "it works for incoming follows to locked account" do + pending_follower = insert(:user, ap_id: "http://mastodon.example.org/users/admin") + user = insert(:user, locked: true) + + data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["type"] == "Follow" + assert data["object"] == user.ap_id + assert data["state"] == "pending" + assert data["actor"] == "http://mastodon.example.org/users/admin" + + assert [^pending_follower] = User.get_follow_requests(user) + end end end diff --git a/test/web/activity_pub/transmogrifier/reject_handling_test.exs b/test/web/activity_pub/transmogrifier/reject_handling_test.exs new file mode 100644 index 000000000..5e5248641 --- /dev/null +++ b/test/web/activity_pub/transmogrifier/reject_handling_test.exs @@ -0,0 +1,67 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.RejectHandlingTest do + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "it fails for incoming rejects which cannot be correlated" do + follower = insert(:user) + followed = insert(:user, locked: true) + + accept_data = + File.read!("test/fixtures/mastodon-reject-activity.json") + |> Poison.decode!() + |> Map.put("actor", followed.ap_id) + + accept_data = + Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) + + :error = Transmogrifier.handle_incoming(accept_data) + + follower = User.get_cached_by_id(follower.id) + + refute User.following?(follower, followed) == true + end + + test "it works for incoming rejects which are referenced by IRI only" do + follower = insert(:user) + followed = insert(:user, locked: true) + + {:ok, follower} = User.follow(follower, followed) + {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) + + assert User.following?(follower, followed) == true + + reject_data = + File.read!("test/fixtures/mastodon-reject-activity.json") + |> Poison.decode!() + |> Map.put("actor", followed.ap_id) + |> Map.put("object", follow_activity.data["id"]) + + {:ok, %Activity{data: _}} = Transmogrifier.handle_incoming(reject_data) + + follower = User.get_cached_by_id(follower.id) + + assert User.following?(follower, followed) == false + end + + test "it rejects activities without a valid ID" do + user = insert(:user) + + data = + File.read!("test/fixtures/mastodon-follow-activity.json") + |> Poison.decode!() + |> Map.put("object", user.ap_id) + |> Map.put("id", "") + + :error = Transmogrifier.handle_incoming(data) + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 13da864d1..0dd4e6e47 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -384,78 +384,6 @@ test "it works for incoming unfollows with an existing follow" do refute User.following?(User.get_cached_by_ap_id(data["actor"]), user) end - test "it works for incoming follows to locked account" do - pending_follower = insert(:user, ap_id: "http://mastodon.example.org/users/admin") - user = insert(:user, locked: true) - - data = - File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() - |> Map.put("object", user.ap_id) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["type"] == "Follow" - assert data["object"] == user.ap_id - assert data["state"] == "pending" - assert data["actor"] == "http://mastodon.example.org/users/admin" - - assert [^pending_follower] = User.get_follow_requests(user) - end - - test "it fails for incoming rejects which cannot be correlated" do - follower = insert(:user) - followed = insert(:user, locked: true) - - accept_data = - File.read!("test/fixtures/mastodon-reject-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - - accept_data = - Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) - - :error = Transmogrifier.handle_incoming(accept_data) - - follower = User.get_cached_by_id(follower.id) - - refute User.following?(follower, followed) == true - end - - test "it works for incoming rejects which are referenced by IRI only" do - follower = insert(:user) - followed = insert(:user, locked: true) - - {:ok, follower} = User.follow(follower, followed) - {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed) - - assert User.following?(follower, followed) == true - - reject_data = - File.read!("test/fixtures/mastodon-reject-activity.json") - |> Poison.decode!() - |> Map.put("actor", followed.ap_id) - |> Map.put("object", follow_activity.data["id"]) - - {:ok, %Activity{data: _}} = Transmogrifier.handle_incoming(reject_data) - - follower = User.get_cached_by_id(follower.id) - - assert User.following?(follower, followed) == false - end - - test "it rejects activities without a valid ID" do - user = insert(:user) - - data = - File.read!("test/fixtures/mastodon-follow-activity.json") - |> Poison.decode!() - |> Map.put("object", user.ap_id) - |> Map.put("id", "") - - :error = Transmogrifier.handle_incoming(data) - end - test "skip converting the content when it is nil" do object_id = "https://peertube.social/videos/watch/278d2b7c-0f38-4aaa-afe6-9ecc0c4a34fe" -- cgit v1.2.3 From eec1ba232c42285fc69c26b5ccc32c504955eab5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 12 Aug 2020 15:15:17 +0300 Subject: don't expire pinned posts --- lib/mix/tasks/pleroma/database.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 7d8f00b08..0142071a8 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -150,8 +150,12 @@ def run(["ensure_expiration"]) do |> Pleroma.RepoStreamer.chunk_stream(100) |> Stream.each(fn activities -> Enum.each(activities, fn activity -> - expires_at = Timex.shift(activity.inserted_at, days: days) - Pleroma.ActivityExpiration.create(activity, expires_at, false) + user = User.get_cached_by_ap_id(activity.actor) + + if activity.id not in user.pinned_activities do + expires_at = Timex.shift(activity.inserted_at, days: days) + Pleroma.ActivityExpiration.create(activity, expires_at, false) + end end) end) |> Stream.run() -- cgit v1.2.3 From 7224bf309ef38a80898d7e560e96fbc2895737be Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 12 Aug 2020 14:48:51 +0200 Subject: Transmogrifier: Move Rejects to the Pipeline --- lib/pleroma/web/activity_pub/builder.ex | 19 ++++++-- lib/pleroma/web/activity_pub/object_validator.ex | 7 +-- .../object_validators/accept_reject_validator.ex | 56 ++++++++++++++++++++++ .../object_validators/accept_validator.ex | 56 ---------------------- lib/pleroma/web/activity_pub/side_effects.ex | 24 ++++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 38 +-------------- .../object_validators/reject_validation_test.exs | 56 ++++++++++++++++++++++ .../transmogrifier/reject_handling_test.exs | 2 +- 8 files changed, 156 insertions(+), 102 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex delete mode 100644 lib/pleroma/web/activity_pub/object_validators/accept_validator.ex create mode 100644 test/web/activity_pub/object_validators/reject_validation_test.exs diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index e1f88e6cc..f2392ce79 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -14,19 +14,28 @@ defmodule Pleroma.Web.ActivityPub.Builder do require Pleroma.Constants - @spec accept(User.t(), Activity.t()) :: {:ok, map(), keyword()} - def accept(actor, accepted_activity) do + def accept_or_reject(actor, activity, type) do data = %{ "id" => Utils.generate_activity_id(), "actor" => actor.ap_id, - "type" => "Accept", - "object" => accepted_activity.data["id"], - "to" => [accepted_activity.actor] + "type" => type, + "object" => activity.data["id"], + "to" => [activity.actor] } {:ok, data, []} end + @spec reject(User.t(), Activity.t()) :: {:ok, map(), keyword()} + def reject(actor, rejected_activity) do + accept_or_reject(actor, rejected_activity, "Reject") + end + + @spec accept(User.t(), Activity.t()) :: {:ok, map(), keyword()} + def accept(actor, accepted_activity) do + accept_or_reject(actor, accepted_activity, "Accept") + end + @spec follow(User.t(), User.t()) :: {:ok, map(), keyword()} def follow(follower, followed) do data = %{ diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index d9dd2bc30..3f1dffe2b 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -13,7 +13,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator @@ -31,10 +31,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} def validate(object, meta) - def validate(%{"type" => "Accept"} = object, meta) do + def validate(%{"type" => type} = object, meta) + when type in ~w[Accept Reject] do with {:ok, object} <- object - |> AcceptValidator.cast_and_validate() + |> AcceptRejectValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex new file mode 100644 index 000000000..179beda58 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:type, :string) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + end + + def cast_data(data) do + %__MODULE__{} + |> cast(data, __schema__(:fields)) + end + + def validate_data(cng) do + cng + |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_inclusion(:type, ["Accept", "Reject"]) + |> validate_actor_presence() + |> validate_object_presence(allowed_types: ["Follow"]) + |> validate_accept_reject_rights() + end + + def cast_and_validate(data) do + data + |> cast_data + |> validate_data + end + + def validate_accept_reject_rights(cng) do + with object_id when is_binary(object_id) <- get_field(cng, :object), + %Activity{data: %{"object" => followed_actor}} <- Activity.get_by_ap_id(object_id), + true <- followed_actor == get_field(cng, :actor) do + cng + else + _e -> + cng + |> add_error(:actor, "can't accept or reject the given activity") + end + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex deleted file mode 100644 index fd75f4b6e..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/accept_validator.ex +++ /dev/null @@ -1,56 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2020 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptValidator do - use Ecto.Schema - - alias Pleroma.Activity - alias Pleroma.EctoType.ActivityPub.ObjectValidators - - import Ecto.Changeset - import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations - - @primary_key false - - embedded_schema do - field(:id, ObjectValidators.ObjectID, primary_key: true) - field(:type, :string) - field(:object, ObjectValidators.ObjectID) - field(:actor, ObjectValidators.ObjectID) - field(:to, ObjectValidators.Recipients, default: []) - field(:cc, ObjectValidators.Recipients, default: []) - end - - def cast_data(data) do - %__MODULE__{} - |> cast(data, __schema__(:fields)) - end - - def validate_data(cng) do - cng - |> validate_required([:id, :type, :actor, :to, :cc, :object]) - |> validate_inclusion(:type, ["Accept"]) - |> validate_actor_presence() - |> validate_object_presence(allowed_types: ["Follow"]) - |> validate_accept_rights() - end - - def cast_and_validate(data) do - data - |> cast_data - |> validate_data - end - - def validate_accept_rights(cng) do - with object_id when is_binary(object_id) <- get_field(cng, :object), - %Activity{data: %{"object" => followed_actor}} <- Activity.get_by_ap_id(object_id), - true <- followed_actor == get_field(cng, :actor) do - cng - else - _e -> - cng - |> add_error(:actor, "can't accept the given activity") - end - end -end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index e1fa75e1c..a4ad12d53 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -52,6 +52,30 @@ def handle( {:ok, object, meta} end + # Task this handles + # - Rejects all existing follow activities for this person + # - Updates the follow state + def handle( + %{ + data: %{ + "actor" => actor, + "type" => "Reject", + "object" => follow_activity_id + } + } = object, + meta + ) do + with %Activity{actor: follower_id} = follow_activity <- + Activity.get_by_ap_id(follow_activity_id), + %User{} = followed <- User.get_cached_by_ap_id(actor), + %User{} = follower <- User.get_cached_by_ap_id(follower_id), + {:ok, _follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject") do + FollowingRelationship.update(follower, followed, :follow_reject) + end + + {:ok, object, meta} + end + # Tasks this handle # - Follows if possible # - Sends a notification diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 24da1ef9c..544f3f3b6 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do alias Pleroma.Activity alias Pleroma.EarmarkRenderer alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.FollowingRelationship alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment @@ -390,16 +389,6 @@ defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = objec defp fix_content(object), do: object - defp get_follow_activity(follow_object, _followed) do - with object_id when not is_nil(object_id) <- Utils.get_ap_id(follow_object), - {_, %Activity{} = activity} <- {:activity, Activity.get_by_ap_id(object_id)} do - {:ok, activity} - else - _ -> - {:error, nil} - end - end - # Reduce the object list to find the reported user. defp get_reported(objects) do Enum.reduce_while(objects, nil, fn ap_id, _ -> @@ -534,31 +523,6 @@ def handle_incoming( end end - def handle_incoming( - %{"type" => "Reject", "object" => follow_object, "actor" => _actor, "id" => id} = data, - _options - ) do - with actor <- Containment.get_actor(data), - {:ok, %User{} = followed} <- User.get_or_fetch_by_ap_id(actor), - {:ok, follow_activity} <- get_follow_activity(follow_object, followed), - {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), - %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), - {:ok, activity} <- - ActivityPub.reject(%{ - to: follow_activity.data["to"], - type: "Reject", - actor: followed, - object: follow_activity.data["id"], - local: false, - activity_id: id - }) do - {:ok, activity} - else - _e -> :error - end - end - @misskey_reactions %{ "like" => "👍", "love" => "❤️", @@ -613,7 +577,7 @@ def handle_incoming( %{"type" => type} = data, _options ) - when type in ~w{Update Block Follow Accept} do + when type in ~w{Update Block Follow Accept Reject} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do diff --git a/test/web/activity_pub/object_validators/reject_validation_test.exs b/test/web/activity_pub/object_validators/reject_validation_test.exs new file mode 100644 index 000000000..370bb6e5c --- /dev/null +++ b/test/web/activity_pub/object_validators/reject_validation_test.exs @@ -0,0 +1,56 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.RejectValidationTest do + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Builder + alias Pleroma.Web.ActivityPub.ObjectValidator + alias Pleroma.Web.ActivityPub.Pipeline + + import Pleroma.Factory + + setup do + follower = insert(:user) + followed = insert(:user, local: false) + + {:ok, follow_data, _} = Builder.follow(follower, followed) + {:ok, follow_activity, _} = Pipeline.common_pipeline(follow_data, local: true) + + {:ok, reject_data, _} = Builder.reject(followed, follow_activity) + + %{reject_data: reject_data, followed: followed} + end + + test "it validates a basic 'reject'", %{reject_data: reject_data} do + assert {:ok, _, _} = ObjectValidator.validate(reject_data, []) + end + + test "it fails when the actor doesn't exist", %{reject_data: reject_data} do + reject_data = + reject_data + |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") + + assert {:error, _} = ObjectValidator.validate(reject_data, []) + end + + test "it fails when the rejected activity doesn't exist", %{reject_data: reject_data} do + reject_data = + reject_data + |> Map.put("object", "https://gensokyo.2hu/users/raymoo/follows/1") + + assert {:error, _} = ObjectValidator.validate(reject_data, []) + end + + test "for an rejected follow, it only validates if the actor of the reject is the followed actor", + %{reject_data: reject_data} do + stranger = insert(:user) + + reject_data = + reject_data + |> Map.put("actor", stranger.ap_id) + + assert {:error, _} = ObjectValidator.validate(reject_data, []) + end +end diff --git a/test/web/activity_pub/transmogrifier/reject_handling_test.exs b/test/web/activity_pub/transmogrifier/reject_handling_test.exs index 5e5248641..7592fbe1c 100644 --- a/test/web/activity_pub/transmogrifier/reject_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/reject_handling_test.exs @@ -24,7 +24,7 @@ test "it fails for incoming rejects which cannot be correlated" do accept_data = Map.put(accept_data, "object", Map.put(accept_data["object"], "actor", follower.ap_id)) - :error = Transmogrifier.handle_incoming(accept_data) + {:error, _} = Transmogrifier.handle_incoming(accept_data) follower = User.get_cached_by_id(follower.id) -- cgit v1.2.3 From 2e347e8286fab13075e6e39e64e56cb3ba14e7e8 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 12 Aug 2020 15:07:46 +0200 Subject: ActivityPub: Remove `reject`, move everything to the Pipeline. --- lib/pleroma/web/activity_pub/activity_pub.ex | 21 --------------------- lib/pleroma/web/activity_pub/side_effects.ex | 18 +++++------------- lib/pleroma/web/common_api/common_api.ex | 14 ++------------ 3 files changed, 7 insertions(+), 46 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 6dd94119b..bde1fe708 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -285,27 +285,6 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d end end - @spec reject(map()) :: {:ok, Activity.t()} | {:error, any()} - def reject(params) do - accept_or_reject("Reject", params) - end - - @spec accept_or_reject(String.t(), map()) :: {:ok, Activity.t()} | {:error, any()} - defp accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do - local = Map.get(params, :local, true) - activity_id = Map.get(params, :activity_id, nil) - - data = - %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} - |> Maps.put_if_present("id", activity_id) - - with {:ok, activity} <- insert(data, local), - _ <- notify_and_stream(activity), - :ok <- maybe_federate(activity) do - {:ok, activity} - end - end - @spec unfollow(User.t(), User.t(), String.t() | nil, boolean()) :: {:ok, Activity.t()} | nil | {:error, any()} def unfollow(follower, followed, activity_id \\ nil, local \\ true) do diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index a4ad12d53..14a1da0c1 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -55,6 +55,7 @@ def handle( # Task this handles # - Rejects all existing follow activities for this person # - Updates the follow state + # - Dismisses notificatios def handle( %{ data: %{ @@ -71,6 +72,7 @@ def handle( %User{} = follower <- User.get_cached_by_ap_id(follower_id), {:ok, _follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject") do FollowingRelationship.update(follower, followed, :follow_reject) + Notification.dismiss(follow_activity) end {:ok, object, meta} @@ -100,19 +102,9 @@ def handle( {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) end else - {:following, {:error, _}, follower, followed} -> - Utils.update_follow_state_for_all(object, "reject") - FollowingRelationship.update(follower, followed, :follow_reject) - - if followed.local do - %{ - to: [follower.ap_id], - actor: followed, - object: follow_id, - local: true - } - |> ActivityPub.reject() - end + {:following, {:error, _}, _follower, followed} -> + {:ok, reject_data, _} = Builder.reject(followed, object) + {:ok, _activity, _} = Pipeline.common_pipeline(reject_data, local: true) _ -> nil diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 7b08c19a8..a8141b28f 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -6,9 +6,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Activity alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation - alias Pleroma.FollowingRelationship alias Pleroma.Formatter - alias Pleroma.Notification alias Pleroma.Object alias Pleroma.ThreadMute alias Pleroma.User @@ -130,16 +128,8 @@ def accept_follow_request(follower, followed) do def reject_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), - {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject"), - {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_reject), - {:ok, _notifications} <- Notification.dismiss(follow_activity), - {:ok, _activity} <- - ActivityPub.reject(%{ - to: [follower.ap_id], - actor: followed, - object: follow_activity.data["id"], - type: "Reject" - }) do + {:ok, reject_data, _} <- Builder.reject(followed, follow_activity), + {:ok, _activity, _} <- Pipeline.common_pipeline(reject_data, local: true) do {:ok, follower} end end -- cgit v1.2.3 From 05ff666f997173bda2f7d96bff237da0cf1c8ca5 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 12 Aug 2020 16:31:00 +0200 Subject: AdminApiControllerTest: Add test that `deleted` users get deactivated. --- test/web/admin_api/controllers/admin_api_controller_test.exs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index eca9272e0..66d4b1ef3 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -158,6 +158,8 @@ test "single user", %{admin: admin, conn: conn} do user = insert(:user) clear_config([:instance, :federating], true) + refute user.deactivated + with_mock Pleroma.Web.Federator, publish: fn _ -> nil end do conn = @@ -176,6 +178,9 @@ test "single user", %{admin: admin, conn: conn} do assert json_response(conn, 200) == [user.nickname] + user = Repo.get(User, user.id) + assert user.deactivated + assert called(Pleroma.Web.Federator.publish(:_)) end end -- cgit v1.2.3 From 091da10832b35ce55e5d7a5a3655d4cdb98bb27c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 12 Aug 2020 11:00:01 -0500 Subject: Add the ActivityExpirationPolicy MRF to docs and clarify post expiration criteria. --- docs/configuration/cheatsheet.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index ca587af8e..e5742bc3a 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -114,6 +114,7 @@ To add configuration to your config file, you can copy it from the base config. * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). + * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.ActivityExpiration` to be enabled for processing the scheduled delections. * `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). * `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. @@ -220,6 +221,8 @@ config :pleroma, :mrf_user_allowlist, %{ ## Pleroma.ActivityExpiration +Enables the worker which processes posts scheduled for deletion. Pinned posts are exempt from expiration. + * `enabled`: whether expired activities will be sent to the job queue to be deleted ## Frontends -- cgit v1.2.3 From b89fc1f2274424e7542a133d96373a1738eb53bb Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 12 Aug 2020 11:13:24 -0500 Subject: Add warning to the migration --- priv/repo/migrations/20200811143147_ap_id_not_null.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/priv/repo/migrations/20200811143147_ap_id_not_null.exs b/priv/repo/migrations/20200811143147_ap_id_not_null.exs index 3e5d27fe1..50f1810b2 100644 --- a/priv/repo/migrations/20200811143147_ap_id_not_null.exs +++ b/priv/repo/migrations/20200811143147_ap_id_not_null.exs @@ -2,6 +2,8 @@ defmodule Pleroma.Repo.Migrations.ApIdNotNull do use Ecto.Migration def up do + Logger.warn("If this migration fails please open an issue at https://git.pleroma.social/pleroma/pleroma/-/issues/new \n") + alter table(:users) do modify(:ap_id, :string, null: false) end -- cgit v1.2.3 From 3aa7969ff9c92ddd4b38caa91b453537ce11dcae Mon Sep 17 00:00:00 2001 From: feld Date: Wed, 12 Aug 2020 16:19:34 +0000 Subject: Update robots_txt.md --- docs/administration/CLI_tasks/robots_txt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/administration/CLI_tasks/robots_txt.md b/docs/administration/CLI_tasks/robots_txt.md index b1de0981b..844318cc8 100644 --- a/docs/administration/CLI_tasks/robots_txt.md +++ b/docs/administration/CLI_tasks/robots_txt.md @@ -1,8 +1,8 @@ -# Managing robot.txt +# Managing robots.txt {! backend/administration/CLI_tasks/general_cli_task_info.include !} -## Generate a new robot.txt file and add it to the static directory +## Generate a new robots.txt file and add it to the static directory The `robots.txt` that ships by default is permissive. It allows well-behaved search engines to index all of your instance's URIs. -- cgit v1.2.3 From 3ab83f837eb9c454b91374c6aeb14b37e8fdd3b1 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 12 Aug 2020 19:46:47 +0300 Subject: don't load pinned activities in due_expirations --- lib/mix/tasks/pleroma/database.ex | 4 +--- lib/pleroma/activity.ex | 6 ++++++ lib/pleroma/activity_expiration.ex | 4 ++++ test/activity_expiration_test.exs | 5 ++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 0142071a8..22a325b47 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -150,9 +150,7 @@ def run(["ensure_expiration"]) do |> Pleroma.RepoStreamer.chunk_stream(100) |> Stream.each(fn activities -> Enum.each(activities, fn activity -> - user = User.get_cached_by_ap_id(activity.actor) - - if activity.id not in user.pinned_activities do + if not Pleroma.Activity.pinned_by_actor?(activity) do expires_at = Timex.shift(activity.inserted_at, days: days) Pleroma.ActivityExpiration.create(activity, expires_at, false) end diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index c3cea8d2a..97feebeaa 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -340,4 +340,10 @@ def direct_conversation_id(activity, for_user) do _ -> nil end end + + @spec pinned_by_actor?(Activity.t()) :: boolean() + def pinned_by_actor?(%Activity{} = activity) do + actor = user_actor(activity) + activity.id in actor.pinned_activities + end end diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex index 84edf68ef..955f0578e 100644 --- a/lib/pleroma/activity_expiration.ex +++ b/lib/pleroma/activity_expiration.ex @@ -47,7 +47,11 @@ def due_expirations(offset \\ 0) do ActivityExpiration |> where([exp], exp.scheduled_at < ^naive_datetime) |> limit(50) + |> preload(:activity) |> Repo.all() + |> Enum.reject(fn %{activity: activity} -> + Activity.pinned_by_actor?(activity) + end) end def validate_scheduled_at(changeset, false), do: changeset diff --git a/test/activity_expiration_test.exs b/test/activity_expiration_test.exs index d75c06cc7..f86d79826 100644 --- a/test/activity_expiration_test.exs +++ b/test/activity_expiration_test.exs @@ -11,7 +11,10 @@ defmodule Pleroma.ActivityExpirationTest do test "finds activities due to be deleted only" do activity = insert(:note_activity) - expiration_due = insert(:expiration_in_the_past, %{activity_id: activity.id}) + + expiration_due = + insert(:expiration_in_the_past, %{activity_id: activity.id}) |> Repo.preload(:activity) + activity2 = insert(:note_activity) insert(:expiration_in_the_future, %{activity_id: activity2.id}) -- cgit v1.2.3 From 29a7bcd5bbb3a39fe1b31c9f5ffc0077f23fc101 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 12 Aug 2020 20:01:21 +0300 Subject: reverting pinned posts in filtering --- lib/mix/tasks/pleroma/database.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 22a325b47..7d8f00b08 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -150,10 +150,8 @@ def run(["ensure_expiration"]) do |> Pleroma.RepoStreamer.chunk_stream(100) |> Stream.each(fn activities -> Enum.each(activities, fn activity -> - if not Pleroma.Activity.pinned_by_actor?(activity) do - expires_at = Timex.shift(activity.inserted_at, days: days) - Pleroma.ActivityExpiration.create(activity, expires_at, false) - end + expires_at = Timex.shift(activity.inserted_at, days: days) + Pleroma.ActivityExpiration.create(activity, expires_at, false) end) end) |> Stream.run() -- cgit v1.2.3 From aaf7bd89a71b174ad54040eb9a6106df51ef7ad5 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 13 Aug 2020 12:32:05 +0200 Subject: Migrations: Fix Logger requirements. --- priv/repo/migrations/20200811143147_ap_id_not_null.exs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/priv/repo/migrations/20200811143147_ap_id_not_null.exs b/priv/repo/migrations/20200811143147_ap_id_not_null.exs index 50f1810b2..df649c7ca 100644 --- a/priv/repo/migrations/20200811143147_ap_id_not_null.exs +++ b/priv/repo/migrations/20200811143147_ap_id_not_null.exs @@ -1,8 +1,12 @@ defmodule Pleroma.Repo.Migrations.ApIdNotNull do use Ecto.Migration + require Logger + def up do - Logger.warn("If this migration fails please open an issue at https://git.pleroma.social/pleroma/pleroma/-/issues/new \n") + Logger.warn( + "If this migration fails please open an issue at https://git.pleroma.social/pleroma/pleroma/-/issues/new \n" + ) alter table(:users) do modify(:ap_id, :string, null: false) -- cgit v1.2.3 From a47406d577e6760ca05880f4d3c80d5e8f391e04 Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 13 Aug 2020 14:12:45 +0200 Subject: Build files: Add cmake --- .gitlab-ci.yml | 2 +- Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 66813c814..5f82e58e5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -212,7 +212,7 @@ amd64-musl: cache: *release-cache variables: *release-variables before_script: &before-release-musl - - apk add git gcc g++ musl-dev make + - apk add git gcc g++ musl-dev make cmake - echo "import Mix.Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force diff --git a/Dockerfile b/Dockerfile index 0f4fcd0bb..aa50e27ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY . . ENV MIX_ENV=prod -RUN apk add git gcc g++ musl-dev make &&\ +RUN apk add git gcc g++ musl-dev make cmake &&\ echo "import Mix.Config" > config/prod.secret.exs &&\ mix local.hex --force &&\ mix local.rebar --force &&\ -- cgit v1.2.3 From 5bcf15d5538fcd9ae0a4cba387d963ff686b921b Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 13 Aug 2020 16:04:34 +0200 Subject: Update frontend --- priv/static/index.html | 2 +- .../static/static/css/app.6dbc7dea4fc148c85860.css | Bin 5616 -> 0 bytes .../static/css/app.6dbc7dea4fc148c85860.css.map | 1 - .../static/static/css/app.77b1644622e3bae24b6b.css | Bin 0 -> 5616 bytes .../static/css/app.77b1644622e3bae24b6b.css.map | 1 + priv/static/static/font/fontello.1594823398494.eot | Bin 24524 -> 0 bytes priv/static/static/font/fontello.1594823398494.svg | 138 --------------------- priv/static/static/font/fontello.1594823398494.ttf | Bin 24356 -> 0 bytes .../static/static/font/fontello.1594823398494.woff | Bin 14912 -> 0 bytes .../static/font/fontello.1594823398494.woff2 | Bin 12584 -> 0 bytes priv/static/static/font/fontello.1597327457363.eot | Bin 0 -> 24524 bytes priv/static/static/font/fontello.1597327457363.svg | 138 +++++++++++++++++++++ priv/static/static/font/fontello.1597327457363.ttf | Bin 0 -> 24356 bytes .../static/static/font/fontello.1597327457363.woff | Bin 0 -> 14912 bytes .../static/font/fontello.1597327457363.woff2 | Bin 0 -> 12548 bytes priv/static/static/fontello.1597327457363.css | Bin 0 -> 3736 bytes priv/static/static/js/10.5ef4671883649cf93524.js | Bin 22666 -> 0 bytes .../static/js/10.5ef4671883649cf93524.js.map | Bin 113 -> 0 bytes priv/static/static/js/10.8c5b75840b696a152c7e.js | Bin 0 -> 22666 bytes .../static/js/10.8c5b75840b696a152c7e.js.map | Bin 0 -> 113 bytes priv/static/static/js/11.bfcde1c26c4d54b84ee4.js | Bin 0 -> 16124 bytes .../static/js/11.bfcde1c26c4d54b84ee4.js.map | Bin 0 -> 113 bytes priv/static/static/js/11.c5b938b4349f87567338.js | Bin 16124 -> 0 bytes .../static/js/11.c5b938b4349f87567338.js.map | Bin 113 -> 0 bytes priv/static/static/js/12.76095ee23394e0ef65bb.js | Bin 0 -> 22115 bytes .../static/js/12.76095ee23394e0ef65bb.js.map | Bin 0 -> 113 bytes priv/static/static/js/12.ab82f9512fa85e78c114.js | Bin 22115 -> 0 bytes .../static/js/12.ab82f9512fa85e78c114.js.map | Bin 113 -> 0 bytes priv/static/static/js/13.40e59c5015d3307b94ad.js | Bin 27100 -> 0 bytes .../static/js/13.40e59c5015d3307b94ad.js.map | Bin 113 -> 0 bytes priv/static/static/js/13.957b04ac11d6cde66f5b.js | Bin 0 -> 27095 bytes .../static/js/13.957b04ac11d6cde66f5b.js.map | Bin 0 -> 113 bytes priv/static/static/js/14.aae5a904931591edfaa7.js | Bin 0 -> 28304 bytes .../static/js/14.aae5a904931591edfaa7.js.map | Bin 0 -> 113 bytes priv/static/static/js/14.de791a47ee5249a526b1.js | Bin 28164 -> 0 bytes .../static/js/14.de791a47ee5249a526b1.js.map | Bin 113 -> 0 bytes priv/static/static/js/15.139f5de3950adc3b66df.js | Bin 0 -> 7789 bytes .../static/js/15.139f5de3950adc3b66df.js.map | Bin 0 -> 113 bytes priv/static/static/js/15.e24854297ad682aec45a.js | Bin 7787 -> 0 bytes .../static/js/15.e24854297ad682aec45a.js.map | Bin 113 -> 0 bytes priv/static/static/js/16.7b8466d62084c04f6671.js | Bin 0 -> 15700 bytes .../static/js/16.7b8466d62084c04f6671.js.map | Bin 0 -> 113 bytes priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js | Bin 15702 -> 0 bytes .../static/js/16.b7b0e4b8227a50fcb9bb.js.map | Bin 113 -> 0 bytes priv/static/static/js/17.c98118b6bb84ee3b5b08.js | Bin 2086 -> 0 bytes .../static/js/17.c98118b6bb84ee3b5b08.js.map | Bin 113 -> 0 bytes priv/static/static/js/17.e8ec1f5666cb4e28784a.js | Bin 0 -> 2086 bytes .../static/js/17.e8ec1f5666cb4e28784a.js.map | Bin 0 -> 113 bytes priv/static/static/js/18.89c20aa67a4dd067ea37.js | Bin 28169 -> 0 bytes .../static/js/18.89c20aa67a4dd067ea37.js.map | Bin 113 -> 0 bytes priv/static/static/js/18.d32389579b85948022b8.js | Bin 0 -> 28393 bytes .../static/js/18.d32389579b85948022b8.js.map | Bin 0 -> 113 bytes priv/static/static/js/19.6e13bad8131c4501c1c5.js | Bin 31632 -> 0 bytes .../static/js/19.6e13bad8131c4501c1c5.js.map | Bin 113 -> 0 bytes priv/static/static/js/19.d180c594b843c17c80fa.js | Bin 0 -> 31593 bytes .../static/js/19.d180c594b843c17c80fa.js.map | Bin 0 -> 113 bytes priv/static/static/js/2.5ecefab707beea40b7f0.js | Bin 0 -> 178475 bytes .../static/static/js/2.5ecefab707beea40b7f0.js.map | Bin 0 -> 458701 bytes priv/static/static/js/2.78a48aa26599b00c3b8d.js | Bin 178659 -> 0 bytes .../static/static/js/2.78a48aa26599b00c3b8d.js.map | Bin 459053 -> 0 bytes priv/static/static/js/20.27e04f2209628de3092b.js | Bin 0 -> 26374 bytes .../static/js/20.27e04f2209628de3092b.js.map | Bin 0 -> 113 bytes priv/static/static/js/20.3615c3cea2e1c2707a4f.js | Bin 26374 -> 0 bytes .../static/js/20.3615c3cea2e1c2707a4f.js.map | Bin 113 -> 0 bytes priv/static/static/js/21.641aba6f96885c381070.js | Bin 0 -> 13162 bytes .../static/js/21.641aba6f96885c381070.js.map | Bin 0 -> 113 bytes priv/static/static/js/21.64dedfc646e13e6f7915.js | Bin 13162 -> 0 bytes .../static/js/21.64dedfc646e13e6f7915.js.map | Bin 113 -> 0 bytes priv/static/static/js/22.6fa63bc6a054b7638e9e.js | Bin 19706 -> 0 bytes .../static/js/22.6fa63bc6a054b7638e9e.js.map | Bin 113 -> 0 bytes priv/static/static/js/22.cbe4790c7601004ed96f.js | Bin 0 -> 19706 bytes .../static/js/22.cbe4790c7601004ed96f.js.map | Bin 0 -> 113 bytes priv/static/static/js/23.96b5bf8d37de3bf02a17.js | Bin 0 -> 27732 bytes .../static/js/23.96b5bf8d37de3bf02a17.js.map | Bin 0 -> 113 bytes priv/static/static/js/23.e0ddea2b6e049d221ee7.js | Bin 27732 -> 0 bytes .../static/js/23.e0ddea2b6e049d221ee7.js.map | Bin 113 -> 0 bytes priv/static/static/js/24.38e3b9d44e9ee703ebf6.js | Bin 18493 -> 0 bytes .../static/js/24.38e3b9d44e9ee703ebf6.js.map | Bin 113 -> 0 bytes priv/static/static/js/24.5e5eea3542b0e17c6479.js | Bin 0 -> 18493 bytes .../static/js/24.5e5eea3542b0e17c6479.js.map | Bin 0 -> 113 bytes priv/static/static/js/25.696b41c0a8660e1f85af.js | Bin 26932 -> 0 bytes .../static/js/25.696b41c0a8660e1f85af.js.map | Bin 113 -> 0 bytes priv/static/static/js/25.dd8471a33b5a4d256564.js | Bin 0 -> 28110 bytes .../static/js/25.dd8471a33b5a4d256564.js.map | Bin 0 -> 113 bytes priv/static/static/js/26.1168f22384be75dc5492.js | Bin 14249 -> 0 bytes .../static/js/26.1168f22384be75dc5492.js.map | Bin 113 -> 0 bytes priv/static/static/js/26.91a9c2effdd1a423a79f.js | Bin 0 -> 14249 bytes .../static/js/26.91a9c2effdd1a423a79f.js.map | Bin 0 -> 113 bytes priv/static/static/js/27.3c0cfbb2a898b35486dd.js | Bin 2022 -> 0 bytes .../static/js/27.3c0cfbb2a898b35486dd.js.map | Bin 113 -> 0 bytes priv/static/static/js/27.949d608895f6e29a2fc2.js | Bin 0 -> 2022 bytes .../static/js/27.949d608895f6e29a2fc2.js.map | Bin 0 -> 113 bytes priv/static/static/js/28.1e879ccb6222c26ee837.js | Bin 0 -> 25289 bytes .../static/js/28.1e879ccb6222c26ee837.js.map | Bin 0 -> 113 bytes priv/static/static/js/28.926c71d6f1813e177271.js | Bin 25289 -> 0 bytes .../static/js/28.926c71d6f1813e177271.js.map | Bin 113 -> 0 bytes priv/static/static/js/29.187064ebed099ae45749.js | Bin 23857 -> 0 bytes .../static/js/29.187064ebed099ae45749.js.map | Bin 113 -> 0 bytes priv/static/static/js/29.a0eb0eee98462dc00d86.js | Bin 0 -> 23857 bytes .../static/js/29.a0eb0eee98462dc00d86.js.map | Bin 0 -> 113 bytes priv/static/static/js/3.44ee95fa34170fe38ef7.js | Bin 0 -> 78761 bytes .../static/static/js/3.44ee95fa34170fe38ef7.js.map | Bin 0 -> 332972 bytes priv/static/static/js/3.56898c1005d9ba1b8d4a.js | Bin 78761 -> 0 bytes .../static/static/js/3.56898c1005d9ba1b8d4a.js.map | Bin 332972 -> 0 bytes priv/static/static/js/30.73f0507f6b66caa1b632.js | Bin 0 -> 21107 bytes .../static/js/30.73f0507f6b66caa1b632.js.map | Bin 0 -> 113 bytes priv/static/static/js/30.d78855ca19bf749be905.js | Bin 21107 -> 0 bytes .../static/js/30.d78855ca19bf749be905.js.map | Bin 113 -> 0 bytes priv/static/static/js/4.2d3bef896b463484e6eb.js | Bin 2177 -> 0 bytes .../static/static/js/4.2d3bef896b463484e6eb.js.map | Bin 7940 -> 0 bytes priv/static/static/js/4.77639012e321d98c064c.js | Bin 0 -> 2177 bytes .../static/static/js/4.77639012e321d98c064c.js.map | Bin 0 -> 7940 bytes priv/static/static/js/5.84f3dce298bc720719c7.js | Bin 6994 -> 0 bytes .../static/static/js/5.84f3dce298bc720719c7.js.map | Bin 112 -> 0 bytes priv/static/static/js/5.abcc811ac6e85e621b0d.js | Bin 0 -> 6994 bytes .../static/static/js/5.abcc811ac6e85e621b0d.js.map | Bin 0 -> 112 bytes priv/static/static/js/6.389907251866808cf2c4.js | Bin 0 -> 7792 bytes .../static/static/js/6.389907251866808cf2c4.js.map | Bin 0 -> 112 bytes priv/static/static/js/6.b9497e1d403b901a664e.js | Bin 7790 -> 0 bytes .../static/static/js/6.b9497e1d403b901a664e.js.map | Bin 112 -> 0 bytes priv/static/static/js/7.33e3cc5c9abab3f21825.js | Bin 0 -> 15617 bytes .../static/static/js/7.33e3cc5c9abab3f21825.js.map | Bin 0 -> 112 bytes priv/static/static/js/7.455b574116ce3f004ffb.js | Bin 15618 -> 0 bytes .../static/static/js/7.455b574116ce3f004ffb.js.map | Bin 112 -> 0 bytes priv/static/static/js/8.5e0b07052c330e85bead.js | Bin 0 -> 21670 bytes .../static/static/js/8.5e0b07052c330e85bead.js.map | Bin 0 -> 112 bytes priv/static/static/js/8.8db9f2dcc5ed429777f7.js | Bin 21682 -> 0 bytes .../static/static/js/8.8db9f2dcc5ed429777f7.js.map | Bin 112 -> 0 bytes priv/static/static/js/9.da3973d058660aa9612f.js | Bin 13753 -> 0 bytes .../static/static/js/9.da3973d058660aa9612f.js.map | Bin 112 -> 0 bytes priv/static/static/js/9.f8e3aa590f4a66aedc3f.js | Bin 0 -> 27113 bytes .../static/static/js/9.f8e3aa590f4a66aedc3f.js.map | Bin 0 -> 112 bytes priv/static/static/js/app.032cb80dafd1f208df1c.js | Bin 0 -> 580679 bytes .../static/js/app.032cb80dafd1f208df1c.js.map | Bin 0 -> 1483327 bytes priv/static/static/js/app.31bba9f1e242ff273dcb.js | Bin 572414 -> 0 bytes .../static/js/app.31bba9f1e242ff273dcb.js.map | Bin 1465392 -> 0 bytes .../static/js/vendors~app.811c8482146cad566f7e.js | Bin 0 -> 304081 bytes .../js/vendors~app.811c8482146cad566f7e.js.map | Bin 0 -> 1274957 bytes .../static/js/vendors~app.9e24ed238da5a8538f50.js | Bin 304076 -> 0 bytes .../js/vendors~app.9e24ed238da5a8538f50.js.map | Bin 1274957 -> 0 bytes priv/static/sw-pleroma.js | Bin 181435 -> 181549 bytes priv/static/sw-pleroma.js.map | Bin 695166 -> 696193 bytes 142 files changed, 140 insertions(+), 140 deletions(-) delete mode 100644 priv/static/static/css/app.6dbc7dea4fc148c85860.css delete mode 100644 priv/static/static/css/app.6dbc7dea4fc148c85860.css.map create mode 100644 priv/static/static/css/app.77b1644622e3bae24b6b.css create mode 100644 priv/static/static/css/app.77b1644622e3bae24b6b.css.map delete mode 100644 priv/static/static/font/fontello.1594823398494.eot delete mode 100644 priv/static/static/font/fontello.1594823398494.svg delete mode 100644 priv/static/static/font/fontello.1594823398494.ttf delete mode 100644 priv/static/static/font/fontello.1594823398494.woff delete mode 100644 priv/static/static/font/fontello.1594823398494.woff2 create mode 100644 priv/static/static/font/fontello.1597327457363.eot create mode 100644 priv/static/static/font/fontello.1597327457363.svg create mode 100644 priv/static/static/font/fontello.1597327457363.ttf create mode 100644 priv/static/static/font/fontello.1597327457363.woff create mode 100644 priv/static/static/font/fontello.1597327457363.woff2 create mode 100644 priv/static/static/fontello.1597327457363.css delete mode 100644 priv/static/static/js/10.5ef4671883649cf93524.js delete mode 100644 priv/static/static/js/10.5ef4671883649cf93524.js.map create mode 100644 priv/static/static/js/10.8c5b75840b696a152c7e.js create mode 100644 priv/static/static/js/10.8c5b75840b696a152c7e.js.map create mode 100644 priv/static/static/js/11.bfcde1c26c4d54b84ee4.js create mode 100644 priv/static/static/js/11.bfcde1c26c4d54b84ee4.js.map delete mode 100644 priv/static/static/js/11.c5b938b4349f87567338.js delete mode 100644 priv/static/static/js/11.c5b938b4349f87567338.js.map create mode 100644 priv/static/static/js/12.76095ee23394e0ef65bb.js create mode 100644 priv/static/static/js/12.76095ee23394e0ef65bb.js.map delete mode 100644 priv/static/static/js/12.ab82f9512fa85e78c114.js delete mode 100644 priv/static/static/js/12.ab82f9512fa85e78c114.js.map delete mode 100644 priv/static/static/js/13.40e59c5015d3307b94ad.js delete mode 100644 priv/static/static/js/13.40e59c5015d3307b94ad.js.map create mode 100644 priv/static/static/js/13.957b04ac11d6cde66f5b.js create mode 100644 priv/static/static/js/13.957b04ac11d6cde66f5b.js.map create mode 100644 priv/static/static/js/14.aae5a904931591edfaa7.js create mode 100644 priv/static/static/js/14.aae5a904931591edfaa7.js.map delete mode 100644 priv/static/static/js/14.de791a47ee5249a526b1.js delete mode 100644 priv/static/static/js/14.de791a47ee5249a526b1.js.map create mode 100644 priv/static/static/js/15.139f5de3950adc3b66df.js create mode 100644 priv/static/static/js/15.139f5de3950adc3b66df.js.map delete mode 100644 priv/static/static/js/15.e24854297ad682aec45a.js delete mode 100644 priv/static/static/js/15.e24854297ad682aec45a.js.map create mode 100644 priv/static/static/js/16.7b8466d62084c04f6671.js create mode 100644 priv/static/static/js/16.7b8466d62084c04f6671.js.map delete mode 100644 priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js delete mode 100644 priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map delete mode 100644 priv/static/static/js/17.c98118b6bb84ee3b5b08.js delete mode 100644 priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map create mode 100644 priv/static/static/js/17.e8ec1f5666cb4e28784a.js create mode 100644 priv/static/static/js/17.e8ec1f5666cb4e28784a.js.map delete mode 100644 priv/static/static/js/18.89c20aa67a4dd067ea37.js delete mode 100644 priv/static/static/js/18.89c20aa67a4dd067ea37.js.map create mode 100644 priv/static/static/js/18.d32389579b85948022b8.js create mode 100644 priv/static/static/js/18.d32389579b85948022b8.js.map delete mode 100644 priv/static/static/js/19.6e13bad8131c4501c1c5.js delete mode 100644 priv/static/static/js/19.6e13bad8131c4501c1c5.js.map create mode 100644 priv/static/static/js/19.d180c594b843c17c80fa.js create mode 100644 priv/static/static/js/19.d180c594b843c17c80fa.js.map create mode 100644 priv/static/static/js/2.5ecefab707beea40b7f0.js create mode 100644 priv/static/static/js/2.5ecefab707beea40b7f0.js.map delete mode 100644 priv/static/static/js/2.78a48aa26599b00c3b8d.js delete mode 100644 priv/static/static/js/2.78a48aa26599b00c3b8d.js.map create mode 100644 priv/static/static/js/20.27e04f2209628de3092b.js create mode 100644 priv/static/static/js/20.27e04f2209628de3092b.js.map delete mode 100644 priv/static/static/js/20.3615c3cea2e1c2707a4f.js delete mode 100644 priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map create mode 100644 priv/static/static/js/21.641aba6f96885c381070.js create mode 100644 priv/static/static/js/21.641aba6f96885c381070.js.map delete mode 100644 priv/static/static/js/21.64dedfc646e13e6f7915.js delete mode 100644 priv/static/static/js/21.64dedfc646e13e6f7915.js.map delete mode 100644 priv/static/static/js/22.6fa63bc6a054b7638e9e.js delete mode 100644 priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map create mode 100644 priv/static/static/js/22.cbe4790c7601004ed96f.js create mode 100644 priv/static/static/js/22.cbe4790c7601004ed96f.js.map create mode 100644 priv/static/static/js/23.96b5bf8d37de3bf02a17.js create mode 100644 priv/static/static/js/23.96b5bf8d37de3bf02a17.js.map delete mode 100644 priv/static/static/js/23.e0ddea2b6e049d221ee7.js delete mode 100644 priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map delete mode 100644 priv/static/static/js/24.38e3b9d44e9ee703ebf6.js delete mode 100644 priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map create mode 100644 priv/static/static/js/24.5e5eea3542b0e17c6479.js create mode 100644 priv/static/static/js/24.5e5eea3542b0e17c6479.js.map delete mode 100644 priv/static/static/js/25.696b41c0a8660e1f85af.js delete mode 100644 priv/static/static/js/25.696b41c0a8660e1f85af.js.map create mode 100644 priv/static/static/js/25.dd8471a33b5a4d256564.js create mode 100644 priv/static/static/js/25.dd8471a33b5a4d256564.js.map delete mode 100644 priv/static/static/js/26.1168f22384be75dc5492.js delete mode 100644 priv/static/static/js/26.1168f22384be75dc5492.js.map create mode 100644 priv/static/static/js/26.91a9c2effdd1a423a79f.js create mode 100644 priv/static/static/js/26.91a9c2effdd1a423a79f.js.map delete mode 100644 priv/static/static/js/27.3c0cfbb2a898b35486dd.js delete mode 100644 priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map create mode 100644 priv/static/static/js/27.949d608895f6e29a2fc2.js create mode 100644 priv/static/static/js/27.949d608895f6e29a2fc2.js.map create mode 100644 priv/static/static/js/28.1e879ccb6222c26ee837.js create mode 100644 priv/static/static/js/28.1e879ccb6222c26ee837.js.map delete mode 100644 priv/static/static/js/28.926c71d6f1813e177271.js delete mode 100644 priv/static/static/js/28.926c71d6f1813e177271.js.map delete mode 100644 priv/static/static/js/29.187064ebed099ae45749.js delete mode 100644 priv/static/static/js/29.187064ebed099ae45749.js.map create mode 100644 priv/static/static/js/29.a0eb0eee98462dc00d86.js create mode 100644 priv/static/static/js/29.a0eb0eee98462dc00d86.js.map create mode 100644 priv/static/static/js/3.44ee95fa34170fe38ef7.js create mode 100644 priv/static/static/js/3.44ee95fa34170fe38ef7.js.map delete mode 100644 priv/static/static/js/3.56898c1005d9ba1b8d4a.js delete mode 100644 priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map create mode 100644 priv/static/static/js/30.73f0507f6b66caa1b632.js create mode 100644 priv/static/static/js/30.73f0507f6b66caa1b632.js.map delete mode 100644 priv/static/static/js/30.d78855ca19bf749be905.js delete mode 100644 priv/static/static/js/30.d78855ca19bf749be905.js.map delete mode 100644 priv/static/static/js/4.2d3bef896b463484e6eb.js delete mode 100644 priv/static/static/js/4.2d3bef896b463484e6eb.js.map create mode 100644 priv/static/static/js/4.77639012e321d98c064c.js create mode 100644 priv/static/static/js/4.77639012e321d98c064c.js.map delete mode 100644 priv/static/static/js/5.84f3dce298bc720719c7.js delete mode 100644 priv/static/static/js/5.84f3dce298bc720719c7.js.map create mode 100644 priv/static/static/js/5.abcc811ac6e85e621b0d.js create mode 100644 priv/static/static/js/5.abcc811ac6e85e621b0d.js.map create mode 100644 priv/static/static/js/6.389907251866808cf2c4.js create mode 100644 priv/static/static/js/6.389907251866808cf2c4.js.map delete mode 100644 priv/static/static/js/6.b9497e1d403b901a664e.js delete mode 100644 priv/static/static/js/6.b9497e1d403b901a664e.js.map create mode 100644 priv/static/static/js/7.33e3cc5c9abab3f21825.js create mode 100644 priv/static/static/js/7.33e3cc5c9abab3f21825.js.map delete mode 100644 priv/static/static/js/7.455b574116ce3f004ffb.js delete mode 100644 priv/static/static/js/7.455b574116ce3f004ffb.js.map create mode 100644 priv/static/static/js/8.5e0b07052c330e85bead.js create mode 100644 priv/static/static/js/8.5e0b07052c330e85bead.js.map delete mode 100644 priv/static/static/js/8.8db9f2dcc5ed429777f7.js delete mode 100644 priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map delete mode 100644 priv/static/static/js/9.da3973d058660aa9612f.js delete mode 100644 priv/static/static/js/9.da3973d058660aa9612f.js.map create mode 100644 priv/static/static/js/9.f8e3aa590f4a66aedc3f.js create mode 100644 priv/static/static/js/9.f8e3aa590f4a66aedc3f.js.map create mode 100644 priv/static/static/js/app.032cb80dafd1f208df1c.js create mode 100644 priv/static/static/js/app.032cb80dafd1f208df1c.js.map delete mode 100644 priv/static/static/js/app.31bba9f1e242ff273dcb.js delete mode 100644 priv/static/static/js/app.31bba9f1e242ff273dcb.js.map create mode 100644 priv/static/static/js/vendors~app.811c8482146cad566f7e.js create mode 100644 priv/static/static/js/vendors~app.811c8482146cad566f7e.js.map delete mode 100644 priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js delete mode 100644 priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map diff --git a/priv/static/index.html b/priv/static/index.html index 2257dec35..7dd080b2d 100644 --- a/priv/static/index.html +++ b/priv/static/index.html @@ -1 +1 @@ -Pleroma
    \ No newline at end of file +Pleroma
    \ No newline at end of file diff --git a/priv/static/static/css/app.6dbc7dea4fc148c85860.css b/priv/static/static/css/app.6dbc7dea4fc148c85860.css deleted file mode 100644 index 3927e3b77..000000000 Binary files a/priv/static/static/css/app.6dbc7dea4fc148c85860.css and /dev/null differ diff --git a/priv/static/static/css/app.6dbc7dea4fc148c85860.css.map b/priv/static/static/css/app.6dbc7dea4fc148c85860.css.map deleted file mode 100644 index 963d5b3b8..000000000 --- a/priv/static/static/css/app.6dbc7dea4fc148c85860.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.6dbc7dea4fc148c85860.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css b/priv/static/static/css/app.77b1644622e3bae24b6b.css new file mode 100644 index 000000000..8038882c0 Binary files /dev/null and b/priv/static/static/css/app.77b1644622e3bae24b6b.css differ diff --git a/priv/static/static/css/app.77b1644622e3bae24b6b.css.map b/priv/static/static/css/app.77b1644622e3bae24b6b.css.map new file mode 100644 index 000000000..4b042ef35 --- /dev/null +++ b/priv/static/static/css/app.77b1644622e3bae24b6b.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["webpack:///./src/components/tab_switcher/tab_switcher.scss","webpack:///./src/hocs/with_load_more/with_load_more.scss"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C;ACtOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,C","file":"static/css/app.77b1644622e3bae24b6b.css","sourcesContent":[".tab-switcher {\n display: -ms-flexbox;\n display: flex;\n}\n.tab-switcher .tab-icon {\n font-size: 2em;\n display: block;\n}\n.tab-switcher.top-tabs {\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.top-tabs > .tabs {\n width: 100%;\n overflow-y: hidden;\n overflow-x: auto;\n padding-top: 5px;\n -ms-flex-direction: row;\n flex-direction: row;\n}\n.tab-switcher.top-tabs > .tabs::after, .tab-switcher.top-tabs > .tabs::before {\n content: \"\";\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper {\n height: 28px;\n}\n.tab-switcher.top-tabs > .tabs .tab-wrapper:not(.active)::after {\n left: 0;\n right: 0;\n bottom: 0;\n border-bottom: 1px solid;\n border-bottom-color: #222;\n border-bottom-color: var(--border, #222);\n}\n.tab-switcher.top-tabs > .tabs .tab {\n width: 100%;\n min-width: 1px;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n padding-bottom: 99px;\n margin-bottom: -93px;\n}\n.tab-switcher.top-tabs .contents.scrollable-tabs {\n -ms-flex-preferred-size: 0;\n flex-basis: 0;\n}\n.tab-switcher.side-tabs {\n -ms-flex-direction: row;\n flex-direction: row;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs {\n overflow-x: auto;\n }\n}\n.tab-switcher.side-tabs > .contents {\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher.side-tabs > .tabs {\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n overflow-y: auto;\n overflow-x: hidden;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher.side-tabs > .tabs::after, .tab-switcher.side-tabs > .tabs::before {\n -ms-flex-negative: 0;\n flex-shrink: 0;\n -ms-flex-preferred-size: 0.5em;\n flex-basis: 0.5em;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs::after {\n -ms-flex-positive: 1;\n flex-grow: 1;\n}\n.tab-switcher.side-tabs > .tabs::before {\n -ms-flex-positive: 0;\n flex-grow: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 10em;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab-wrapper {\n min-width: 1em;\n }\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:not(.active)::after {\n top: 0;\n right: 0;\n bottom: 0;\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper::before {\n -ms-flex: 0 0 6px;\n flex: 0 0 6px;\n content: \"\";\n border-right: 1px solid;\n border-right-color: #222;\n border-right-color: var(--border, #222);\n}\n.tab-switcher.side-tabs > .tabs .tab-wrapper:last-child .tab {\n margin-bottom: 0;\n}\n.tab-switcher.side-tabs > .tabs .tab {\n -ms-flex: 1;\n flex: 1;\n box-sizing: content-box;\n min-width: 10em;\n min-width: 1px;\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n padding-left: 1em;\n padding-right: calc(1em + 200px);\n margin-right: -200px;\n margin-left: 1em;\n}\n@media all and (max-width: 800px) {\n .tab-switcher.side-tabs > .tabs .tab {\n padding-left: 0.25em;\n padding-right: calc(.25em + 200px);\n margin-right: calc(.25em - 200px);\n margin-left: 0.25em;\n }\n .tab-switcher.side-tabs > .tabs .tab .text {\n display: none;\n }\n}\n.tab-switcher .contents {\n -ms-flex: 1 0 auto;\n flex: 1 0 auto;\n min-height: 0px;\n}\n.tab-switcher .contents .hidden {\n display: none;\n}\n.tab-switcher .contents .full-height:not(.hidden) {\n height: 100%;\n display: -ms-flexbox;\n display: flex;\n -ms-flex-direction: column;\n flex-direction: column;\n}\n.tab-switcher .contents .full-height:not(.hidden) > *:not(.mobile-label) {\n -ms-flex: 1;\n flex: 1;\n}\n.tab-switcher .contents.scrollable-tabs {\n overflow-y: auto;\n}\n.tab-switcher .tab {\n position: relative;\n white-space: nowrap;\n padding: 6px 1em;\n background-color: #182230;\n background-color: var(--tab, #182230);\n}\n.tab-switcher .tab, .tab-switcher .tab:active .tab-icon {\n color: #b9b9ba;\n color: var(--tabText, #b9b9ba);\n}\n.tab-switcher .tab:not(.active) {\n z-index: 4;\n}\n.tab-switcher .tab:not(.active):hover {\n z-index: 6;\n}\n.tab-switcher .tab.active {\n background: transparent;\n z-index: 5;\n color: #b9b9ba;\n color: var(--tabActiveText, #b9b9ba);\n}\n.tab-switcher .tab img {\n max-height: 26px;\n vertical-align: top;\n margin-top: -5px;\n}\n.tab-switcher .tabs {\n display: -ms-flexbox;\n display: flex;\n position: relative;\n box-sizing: border-box;\n}\n.tab-switcher .tabs::after, .tab-switcher .tabs::before {\n display: block;\n -ms-flex: 1 1 auto;\n flex: 1 1 auto;\n}\n.tab-switcher .tab-wrapper {\n position: relative;\n display: -ms-flexbox;\n display: flex;\n -ms-flex: 0 0 auto;\n flex: 0 0 auto;\n}\n.tab-switcher .tab-wrapper:not(.active)::after {\n content: \"\";\n position: absolute;\n z-index: 7;\n}\n.tab-switcher .mobile-label {\n padding-left: 0.3em;\n padding-bottom: 0.25em;\n margin-top: 0.5em;\n margin-left: 0.2em;\n margin-bottom: 0.25em;\n border-bottom: 1px solid var(--border, #222);\n}\n@media all and (min-width: 800px) {\n .tab-switcher .mobile-label {\n display: none;\n }\n}",".with-load-more-footer {\n padding: 10px;\n text-align: center;\n border-top: 1px solid;\n border-top-color: #222;\n border-top-color: var(--border, #222);\n}\n.with-load-more-footer .error {\n font-size: 14px;\n}\n.with-load-more-footer a {\n cursor: pointer;\n}"],"sourceRoot":""} \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594823398494.eot b/priv/static/static/font/fontello.1594823398494.eot deleted file mode 100644 index 12e6beabf..000000000 Binary files a/priv/static/static/font/fontello.1594823398494.eot and /dev/null differ diff --git a/priv/static/static/font/fontello.1594823398494.svg b/priv/static/static/font/fontello.1594823398494.svg deleted file mode 100644 index 71b5d70af..000000000 --- a/priv/static/static/font/fontello.1594823398494.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - -Copyright (C) 2020 by original authors @ fontello.com - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/priv/static/static/font/fontello.1594823398494.ttf b/priv/static/static/font/fontello.1594823398494.ttf deleted file mode 100644 index 6f21845a8..000000000 Binary files a/priv/static/static/font/fontello.1594823398494.ttf and /dev/null differ diff --git a/priv/static/static/font/fontello.1594823398494.woff b/priv/static/static/font/fontello.1594823398494.woff deleted file mode 100644 index a7cd098f4..000000000 Binary files a/priv/static/static/font/fontello.1594823398494.woff and /dev/null differ diff --git a/priv/static/static/font/fontello.1594823398494.woff2 b/priv/static/static/font/fontello.1594823398494.woff2 deleted file mode 100644 index c61bf111a..000000000 Binary files a/priv/static/static/font/fontello.1594823398494.woff2 and /dev/null differ diff --git a/priv/static/static/font/fontello.1597327457363.eot b/priv/static/static/font/fontello.1597327457363.eot new file mode 100644 index 000000000..af2c39275 Binary files /dev/null and b/priv/static/static/font/fontello.1597327457363.eot differ diff --git a/priv/static/static/font/fontello.1597327457363.svg b/priv/static/static/font/fontello.1597327457363.svg new file mode 100644 index 000000000..71b5d70af --- /dev/null +++ b/priv/static/static/font/fontello.1597327457363.svg @@ -0,0 +1,138 @@ + + + +Copyright (C) 2020 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/priv/static/static/font/fontello.1597327457363.ttf b/priv/static/static/font/fontello.1597327457363.ttf new file mode 100644 index 000000000..1d5640d5d Binary files /dev/null and b/priv/static/static/font/fontello.1597327457363.ttf differ diff --git a/priv/static/static/font/fontello.1597327457363.woff b/priv/static/static/font/fontello.1597327457363.woff new file mode 100644 index 000000000..c04735bf5 Binary files /dev/null and b/priv/static/static/font/fontello.1597327457363.woff differ diff --git a/priv/static/static/font/fontello.1597327457363.woff2 b/priv/static/static/font/fontello.1597327457363.woff2 new file mode 100644 index 000000000..f53414761 Binary files /dev/null and b/priv/static/static/font/fontello.1597327457363.woff2 differ diff --git a/priv/static/static/fontello.1597327457363.css b/priv/static/static/fontello.1597327457363.css new file mode 100644 index 000000000..22d148873 Binary files /dev/null and b/priv/static/static/fontello.1597327457363.css differ diff --git a/priv/static/static/js/10.5ef4671883649cf93524.js b/priv/static/static/js/10.5ef4671883649cf93524.js deleted file mode 100644 index 6819c854b..000000000 Binary files a/priv/static/static/js/10.5ef4671883649cf93524.js and /dev/null differ diff --git a/priv/static/static/js/10.5ef4671883649cf93524.js.map b/priv/static/static/js/10.5ef4671883649cf93524.js.map deleted file mode 100644 index 95fa2207e..000000000 Binary files a/priv/static/static/js/10.5ef4671883649cf93524.js.map and /dev/null differ diff --git a/priv/static/static/js/10.8c5b75840b696a152c7e.js b/priv/static/static/js/10.8c5b75840b696a152c7e.js new file mode 100644 index 000000000..eb95d66d1 Binary files /dev/null and b/priv/static/static/js/10.8c5b75840b696a152c7e.js differ diff --git a/priv/static/static/js/10.8c5b75840b696a152c7e.js.map b/priv/static/static/js/10.8c5b75840b696a152c7e.js.map new file mode 100644 index 000000000..b390fbeaf Binary files /dev/null and b/priv/static/static/js/10.8c5b75840b696a152c7e.js.map differ diff --git a/priv/static/static/js/11.bfcde1c26c4d54b84ee4.js b/priv/static/static/js/11.bfcde1c26c4d54b84ee4.js new file mode 100644 index 000000000..0dea63f5a Binary files /dev/null and b/priv/static/static/js/11.bfcde1c26c4d54b84ee4.js differ diff --git a/priv/static/static/js/11.bfcde1c26c4d54b84ee4.js.map b/priv/static/static/js/11.bfcde1c26c4d54b84ee4.js.map new file mode 100644 index 000000000..2b2305773 Binary files /dev/null and b/priv/static/static/js/11.bfcde1c26c4d54b84ee4.js.map differ diff --git a/priv/static/static/js/11.c5b938b4349f87567338.js b/priv/static/static/js/11.c5b938b4349f87567338.js deleted file mode 100644 index b97f69bf3..000000000 Binary files a/priv/static/static/js/11.c5b938b4349f87567338.js and /dev/null differ diff --git a/priv/static/static/js/11.c5b938b4349f87567338.js.map b/priv/static/static/js/11.c5b938b4349f87567338.js.map deleted file mode 100644 index 5ccf83b1d..000000000 Binary files a/priv/static/static/js/11.c5b938b4349f87567338.js.map and /dev/null differ diff --git a/priv/static/static/js/12.76095ee23394e0ef65bb.js b/priv/static/static/js/12.76095ee23394e0ef65bb.js new file mode 100644 index 000000000..6c34e2da2 Binary files /dev/null and b/priv/static/static/js/12.76095ee23394e0ef65bb.js differ diff --git a/priv/static/static/js/12.76095ee23394e0ef65bb.js.map b/priv/static/static/js/12.76095ee23394e0ef65bb.js.map new file mode 100644 index 000000000..e00137a2b Binary files /dev/null and b/priv/static/static/js/12.76095ee23394e0ef65bb.js.map differ diff --git a/priv/static/static/js/12.ab82f9512fa85e78c114.js b/priv/static/static/js/12.ab82f9512fa85e78c114.js deleted file mode 100644 index 100d72b33..000000000 Binary files a/priv/static/static/js/12.ab82f9512fa85e78c114.js and /dev/null differ diff --git a/priv/static/static/js/12.ab82f9512fa85e78c114.js.map b/priv/static/static/js/12.ab82f9512fa85e78c114.js.map deleted file mode 100644 index 23335ae23..000000000 Binary files a/priv/static/static/js/12.ab82f9512fa85e78c114.js.map and /dev/null differ diff --git a/priv/static/static/js/13.40e59c5015d3307b94ad.js b/priv/static/static/js/13.40e59c5015d3307b94ad.js deleted file mode 100644 index 2088bb6b7..000000000 Binary files a/priv/static/static/js/13.40e59c5015d3307b94ad.js and /dev/null differ diff --git a/priv/static/static/js/13.40e59c5015d3307b94ad.js.map b/priv/static/static/js/13.40e59c5015d3307b94ad.js.map deleted file mode 100644 index 3931b5ef9..000000000 Binary files a/priv/static/static/js/13.40e59c5015d3307b94ad.js.map and /dev/null differ diff --git a/priv/static/static/js/13.957b04ac11d6cde66f5b.js b/priv/static/static/js/13.957b04ac11d6cde66f5b.js new file mode 100644 index 000000000..917b6a58b Binary files /dev/null and b/priv/static/static/js/13.957b04ac11d6cde66f5b.js differ diff --git a/priv/static/static/js/13.957b04ac11d6cde66f5b.js.map b/priv/static/static/js/13.957b04ac11d6cde66f5b.js.map new file mode 100644 index 000000000..25434f73b Binary files /dev/null and b/priv/static/static/js/13.957b04ac11d6cde66f5b.js.map differ diff --git a/priv/static/static/js/14.aae5a904931591edfaa7.js b/priv/static/static/js/14.aae5a904931591edfaa7.js new file mode 100644 index 000000000..001914ad7 Binary files /dev/null and b/priv/static/static/js/14.aae5a904931591edfaa7.js differ diff --git a/priv/static/static/js/14.aae5a904931591edfaa7.js.map b/priv/static/static/js/14.aae5a904931591edfaa7.js.map new file mode 100644 index 000000000..24719fee8 Binary files /dev/null and b/priv/static/static/js/14.aae5a904931591edfaa7.js.map differ diff --git a/priv/static/static/js/14.de791a47ee5249a526b1.js b/priv/static/static/js/14.de791a47ee5249a526b1.js deleted file mode 100644 index 0e341275e..000000000 Binary files a/priv/static/static/js/14.de791a47ee5249a526b1.js and /dev/null differ diff --git a/priv/static/static/js/14.de791a47ee5249a526b1.js.map b/priv/static/static/js/14.de791a47ee5249a526b1.js.map deleted file mode 100644 index 4bef54546..000000000 Binary files a/priv/static/static/js/14.de791a47ee5249a526b1.js.map and /dev/null differ diff --git a/priv/static/static/js/15.139f5de3950adc3b66df.js b/priv/static/static/js/15.139f5de3950adc3b66df.js new file mode 100644 index 000000000..303e00130 Binary files /dev/null and b/priv/static/static/js/15.139f5de3950adc3b66df.js differ diff --git a/priv/static/static/js/15.139f5de3950adc3b66df.js.map b/priv/static/static/js/15.139f5de3950adc3b66df.js.map new file mode 100644 index 000000000..d5a3c800d Binary files /dev/null and b/priv/static/static/js/15.139f5de3950adc3b66df.js.map differ diff --git a/priv/static/static/js/15.e24854297ad682aec45a.js b/priv/static/static/js/15.e24854297ad682aec45a.js deleted file mode 100644 index 671370192..000000000 Binary files a/priv/static/static/js/15.e24854297ad682aec45a.js and /dev/null differ diff --git a/priv/static/static/js/15.e24854297ad682aec45a.js.map b/priv/static/static/js/15.e24854297ad682aec45a.js.map deleted file mode 100644 index 89789a542..000000000 Binary files a/priv/static/static/js/15.e24854297ad682aec45a.js.map and /dev/null differ diff --git a/priv/static/static/js/16.7b8466d62084c04f6671.js b/priv/static/static/js/16.7b8466d62084c04f6671.js new file mode 100644 index 000000000..587b41dd0 Binary files /dev/null and b/priv/static/static/js/16.7b8466d62084c04f6671.js differ diff --git a/priv/static/static/js/16.7b8466d62084c04f6671.js.map b/priv/static/static/js/16.7b8466d62084c04f6671.js.map new file mode 100644 index 000000000..22818639f Binary files /dev/null and b/priv/static/static/js/16.7b8466d62084c04f6671.js.map differ diff --git a/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js b/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js deleted file mode 100644 index 6a3ea9513..000000000 Binary files a/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js and /dev/null differ diff --git a/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map b/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map deleted file mode 100644 index fec45b087..000000000 Binary files a/priv/static/static/js/16.b7b0e4b8227a50fcb9bb.js.map and /dev/null differ diff --git a/priv/static/static/js/17.c98118b6bb84ee3b5b08.js b/priv/static/static/js/17.c98118b6bb84ee3b5b08.js deleted file mode 100644 index c41f0b6b8..000000000 Binary files a/priv/static/static/js/17.c98118b6bb84ee3b5b08.js and /dev/null differ diff --git a/priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map b/priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map deleted file mode 100644 index 0c20fc89b..000000000 Binary files a/priv/static/static/js/17.c98118b6bb84ee3b5b08.js.map and /dev/null differ diff --git a/priv/static/static/js/17.e8ec1f5666cb4e28784a.js b/priv/static/static/js/17.e8ec1f5666cb4e28784a.js new file mode 100644 index 000000000..03a7d28e5 Binary files /dev/null and b/priv/static/static/js/17.e8ec1f5666cb4e28784a.js differ diff --git a/priv/static/static/js/17.e8ec1f5666cb4e28784a.js.map b/priv/static/static/js/17.e8ec1f5666cb4e28784a.js.map new file mode 100644 index 000000000..0fe92287e Binary files /dev/null and b/priv/static/static/js/17.e8ec1f5666cb4e28784a.js.map differ diff --git a/priv/static/static/js/18.89c20aa67a4dd067ea37.js b/priv/static/static/js/18.89c20aa67a4dd067ea37.js deleted file mode 100644 index db1c78a49..000000000 Binary files a/priv/static/static/js/18.89c20aa67a4dd067ea37.js and /dev/null differ diff --git a/priv/static/static/js/18.89c20aa67a4dd067ea37.js.map b/priv/static/static/js/18.89c20aa67a4dd067ea37.js.map deleted file mode 100644 index 72cdf0e0e..000000000 Binary files a/priv/static/static/js/18.89c20aa67a4dd067ea37.js.map and /dev/null differ diff --git a/priv/static/static/js/18.d32389579b85948022b8.js b/priv/static/static/js/18.d32389579b85948022b8.js new file mode 100644 index 000000000..477f6e06f Binary files /dev/null and b/priv/static/static/js/18.d32389579b85948022b8.js differ diff --git a/priv/static/static/js/18.d32389579b85948022b8.js.map b/priv/static/static/js/18.d32389579b85948022b8.js.map new file mode 100644 index 000000000..62fc5b84f Binary files /dev/null and b/priv/static/static/js/18.d32389579b85948022b8.js.map differ diff --git a/priv/static/static/js/19.6e13bad8131c4501c1c5.js b/priv/static/static/js/19.6e13bad8131c4501c1c5.js deleted file mode 100644 index 8b32827cc..000000000 Binary files a/priv/static/static/js/19.6e13bad8131c4501c1c5.js and /dev/null differ diff --git a/priv/static/static/js/19.6e13bad8131c4501c1c5.js.map b/priv/static/static/js/19.6e13bad8131c4501c1c5.js.map deleted file mode 100644 index 762d85e27..000000000 Binary files a/priv/static/static/js/19.6e13bad8131c4501c1c5.js.map and /dev/null differ diff --git a/priv/static/static/js/19.d180c594b843c17c80fa.js b/priv/static/static/js/19.d180c594b843c17c80fa.js new file mode 100644 index 000000000..c30dc75c2 Binary files /dev/null and b/priv/static/static/js/19.d180c594b843c17c80fa.js differ diff --git a/priv/static/static/js/19.d180c594b843c17c80fa.js.map b/priv/static/static/js/19.d180c594b843c17c80fa.js.map new file mode 100644 index 000000000..e90081dd9 Binary files /dev/null and b/priv/static/static/js/19.d180c594b843c17c80fa.js.map differ diff --git a/priv/static/static/js/2.5ecefab707beea40b7f0.js b/priv/static/static/js/2.5ecefab707beea40b7f0.js new file mode 100644 index 000000000..bf563c79f Binary files /dev/null and b/priv/static/static/js/2.5ecefab707beea40b7f0.js differ diff --git a/priv/static/static/js/2.5ecefab707beea40b7f0.js.map b/priv/static/static/js/2.5ecefab707beea40b7f0.js.map new file mode 100644 index 000000000..7452e1f6e Binary files /dev/null and b/priv/static/static/js/2.5ecefab707beea40b7f0.js.map differ diff --git a/priv/static/static/js/2.78a48aa26599b00c3b8d.js b/priv/static/static/js/2.78a48aa26599b00c3b8d.js deleted file mode 100644 index ecb27aa9c..000000000 Binary files a/priv/static/static/js/2.78a48aa26599b00c3b8d.js and /dev/null differ diff --git a/priv/static/static/js/2.78a48aa26599b00c3b8d.js.map b/priv/static/static/js/2.78a48aa26599b00c3b8d.js.map deleted file mode 100644 index 167cfa1c6..000000000 Binary files a/priv/static/static/js/2.78a48aa26599b00c3b8d.js.map and /dev/null differ diff --git a/priv/static/static/js/20.27e04f2209628de3092b.js b/priv/static/static/js/20.27e04f2209628de3092b.js new file mode 100644 index 000000000..e41b60066 Binary files /dev/null and b/priv/static/static/js/20.27e04f2209628de3092b.js differ diff --git a/priv/static/static/js/20.27e04f2209628de3092b.js.map b/priv/static/static/js/20.27e04f2209628de3092b.js.map new file mode 100644 index 000000000..4009ef5b9 Binary files /dev/null and b/priv/static/static/js/20.27e04f2209628de3092b.js.map differ diff --git a/priv/static/static/js/20.3615c3cea2e1c2707a4f.js b/priv/static/static/js/20.3615c3cea2e1c2707a4f.js deleted file mode 100644 index 74f89016c..000000000 Binary files a/priv/static/static/js/20.3615c3cea2e1c2707a4f.js and /dev/null differ diff --git a/priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map b/priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map deleted file mode 100644 index acddecea7..000000000 Binary files a/priv/static/static/js/20.3615c3cea2e1c2707a4f.js.map and /dev/null differ diff --git a/priv/static/static/js/21.641aba6f96885c381070.js b/priv/static/static/js/21.641aba6f96885c381070.js new file mode 100644 index 000000000..d80f64e11 Binary files /dev/null and b/priv/static/static/js/21.641aba6f96885c381070.js differ diff --git a/priv/static/static/js/21.641aba6f96885c381070.js.map b/priv/static/static/js/21.641aba6f96885c381070.js.map new file mode 100644 index 000000000..8f6253113 Binary files /dev/null and b/priv/static/static/js/21.641aba6f96885c381070.js.map differ diff --git a/priv/static/static/js/21.64dedfc646e13e6f7915.js b/priv/static/static/js/21.64dedfc646e13e6f7915.js deleted file mode 100644 index 407e6665e..000000000 Binary files a/priv/static/static/js/21.64dedfc646e13e6f7915.js and /dev/null differ diff --git a/priv/static/static/js/21.64dedfc646e13e6f7915.js.map b/priv/static/static/js/21.64dedfc646e13e6f7915.js.map deleted file mode 100644 index 8e3432668..000000000 Binary files a/priv/static/static/js/21.64dedfc646e13e6f7915.js.map and /dev/null differ diff --git a/priv/static/static/js/22.6fa63bc6a054b7638e9e.js b/priv/static/static/js/22.6fa63bc6a054b7638e9e.js deleted file mode 100644 index 4a8740c99..000000000 Binary files a/priv/static/static/js/22.6fa63bc6a054b7638e9e.js and /dev/null differ diff --git a/priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map b/priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map deleted file mode 100644 index 1c556f040..000000000 Binary files a/priv/static/static/js/22.6fa63bc6a054b7638e9e.js.map and /dev/null differ diff --git a/priv/static/static/js/22.cbe4790c7601004ed96f.js b/priv/static/static/js/22.cbe4790c7601004ed96f.js new file mode 100644 index 000000000..0e9c6ab97 Binary files /dev/null and b/priv/static/static/js/22.cbe4790c7601004ed96f.js differ diff --git a/priv/static/static/js/22.cbe4790c7601004ed96f.js.map b/priv/static/static/js/22.cbe4790c7601004ed96f.js.map new file mode 100644 index 000000000..8de20817c Binary files /dev/null and b/priv/static/static/js/22.cbe4790c7601004ed96f.js.map differ diff --git a/priv/static/static/js/23.96b5bf8d37de3bf02a17.js b/priv/static/static/js/23.96b5bf8d37de3bf02a17.js new file mode 100644 index 000000000..6a78c71fd Binary files /dev/null and b/priv/static/static/js/23.96b5bf8d37de3bf02a17.js differ diff --git a/priv/static/static/js/23.96b5bf8d37de3bf02a17.js.map b/priv/static/static/js/23.96b5bf8d37de3bf02a17.js.map new file mode 100644 index 000000000..12929720a Binary files /dev/null and b/priv/static/static/js/23.96b5bf8d37de3bf02a17.js.map differ diff --git a/priv/static/static/js/23.e0ddea2b6e049d221ee7.js b/priv/static/static/js/23.e0ddea2b6e049d221ee7.js deleted file mode 100644 index 51fe36368..000000000 Binary files a/priv/static/static/js/23.e0ddea2b6e049d221ee7.js and /dev/null differ diff --git a/priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map b/priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map deleted file mode 100644 index 36bae2bf4..000000000 Binary files a/priv/static/static/js/23.e0ddea2b6e049d221ee7.js.map and /dev/null differ diff --git a/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js b/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js deleted file mode 100644 index e5abf0af6..000000000 Binary files a/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js and /dev/null differ diff --git a/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map b/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map deleted file mode 100644 index 09f3c19d0..000000000 Binary files a/priv/static/static/js/24.38e3b9d44e9ee703ebf6.js.map and /dev/null differ diff --git a/priv/static/static/js/24.5e5eea3542b0e17c6479.js b/priv/static/static/js/24.5e5eea3542b0e17c6479.js new file mode 100644 index 000000000..45787dddd Binary files /dev/null and b/priv/static/static/js/24.5e5eea3542b0e17c6479.js differ diff --git a/priv/static/static/js/24.5e5eea3542b0e17c6479.js.map b/priv/static/static/js/24.5e5eea3542b0e17c6479.js.map new file mode 100644 index 000000000..1938ee57a Binary files /dev/null and b/priv/static/static/js/24.5e5eea3542b0e17c6479.js.map differ diff --git a/priv/static/static/js/25.696b41c0a8660e1f85af.js b/priv/static/static/js/25.696b41c0a8660e1f85af.js deleted file mode 100644 index b114890fc..000000000 Binary files a/priv/static/static/js/25.696b41c0a8660e1f85af.js and /dev/null differ diff --git a/priv/static/static/js/25.696b41c0a8660e1f85af.js.map b/priv/static/static/js/25.696b41c0a8660e1f85af.js.map deleted file mode 100644 index f6d208812..000000000 Binary files a/priv/static/static/js/25.696b41c0a8660e1f85af.js.map and /dev/null differ diff --git a/priv/static/static/js/25.dd8471a33b5a4d256564.js b/priv/static/static/js/25.dd8471a33b5a4d256564.js new file mode 100644 index 000000000..b30f01f9b Binary files /dev/null and b/priv/static/static/js/25.dd8471a33b5a4d256564.js differ diff --git a/priv/static/static/js/25.dd8471a33b5a4d256564.js.map b/priv/static/static/js/25.dd8471a33b5a4d256564.js.map new file mode 100644 index 000000000..e6a6bf3a0 Binary files /dev/null and b/priv/static/static/js/25.dd8471a33b5a4d256564.js.map differ diff --git a/priv/static/static/js/26.1168f22384be75dc5492.js b/priv/static/static/js/26.1168f22384be75dc5492.js deleted file mode 100644 index b77a4d30f..000000000 Binary files a/priv/static/static/js/26.1168f22384be75dc5492.js and /dev/null differ diff --git a/priv/static/static/js/26.1168f22384be75dc5492.js.map b/priv/static/static/js/26.1168f22384be75dc5492.js.map deleted file mode 100644 index c9b0d8495..000000000 Binary files a/priv/static/static/js/26.1168f22384be75dc5492.js.map and /dev/null differ diff --git a/priv/static/static/js/26.91a9c2effdd1a423a79f.js b/priv/static/static/js/26.91a9c2effdd1a423a79f.js new file mode 100644 index 000000000..f30ff939a Binary files /dev/null and b/priv/static/static/js/26.91a9c2effdd1a423a79f.js differ diff --git a/priv/static/static/js/26.91a9c2effdd1a423a79f.js.map b/priv/static/static/js/26.91a9c2effdd1a423a79f.js.map new file mode 100644 index 000000000..ae4781108 Binary files /dev/null and b/priv/static/static/js/26.91a9c2effdd1a423a79f.js.map differ diff --git a/priv/static/static/js/27.3c0cfbb2a898b35486dd.js b/priv/static/static/js/27.3c0cfbb2a898b35486dd.js deleted file mode 100644 index a0765356f..000000000 Binary files a/priv/static/static/js/27.3c0cfbb2a898b35486dd.js and /dev/null differ diff --git a/priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map b/priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map deleted file mode 100644 index 0cc5f46b2..000000000 Binary files a/priv/static/static/js/27.3c0cfbb2a898b35486dd.js.map and /dev/null differ diff --git a/priv/static/static/js/27.949d608895f6e29a2fc2.js b/priv/static/static/js/27.949d608895f6e29a2fc2.js new file mode 100644 index 000000000..f735c1a04 Binary files /dev/null and b/priv/static/static/js/27.949d608895f6e29a2fc2.js differ diff --git a/priv/static/static/js/27.949d608895f6e29a2fc2.js.map b/priv/static/static/js/27.949d608895f6e29a2fc2.js.map new file mode 100644 index 000000000..9f75161dd Binary files /dev/null and b/priv/static/static/js/27.949d608895f6e29a2fc2.js.map differ diff --git a/priv/static/static/js/28.1e879ccb6222c26ee837.js b/priv/static/static/js/28.1e879ccb6222c26ee837.js new file mode 100644 index 000000000..64e286799 Binary files /dev/null and b/priv/static/static/js/28.1e879ccb6222c26ee837.js differ diff --git a/priv/static/static/js/28.1e879ccb6222c26ee837.js.map b/priv/static/static/js/28.1e879ccb6222c26ee837.js.map new file mode 100644 index 000000000..123aae91b Binary files /dev/null and b/priv/static/static/js/28.1e879ccb6222c26ee837.js.map differ diff --git a/priv/static/static/js/28.926c71d6f1813e177271.js b/priv/static/static/js/28.926c71d6f1813e177271.js deleted file mode 100644 index 55cf840f2..000000000 Binary files a/priv/static/static/js/28.926c71d6f1813e177271.js and /dev/null differ diff --git a/priv/static/static/js/28.926c71d6f1813e177271.js.map b/priv/static/static/js/28.926c71d6f1813e177271.js.map deleted file mode 100644 index 1ae8f08cb..000000000 Binary files a/priv/static/static/js/28.926c71d6f1813e177271.js.map and /dev/null differ diff --git a/priv/static/static/js/29.187064ebed099ae45749.js b/priv/static/static/js/29.187064ebed099ae45749.js deleted file mode 100644 index 6eaae0226..000000000 Binary files a/priv/static/static/js/29.187064ebed099ae45749.js and /dev/null differ diff --git a/priv/static/static/js/29.187064ebed099ae45749.js.map b/priv/static/static/js/29.187064ebed099ae45749.js.map deleted file mode 100644 index b5dd63f96..000000000 Binary files a/priv/static/static/js/29.187064ebed099ae45749.js.map and /dev/null differ diff --git a/priv/static/static/js/29.a0eb0eee98462dc00d86.js b/priv/static/static/js/29.a0eb0eee98462dc00d86.js new file mode 100644 index 000000000..740e150ca Binary files /dev/null and b/priv/static/static/js/29.a0eb0eee98462dc00d86.js differ diff --git a/priv/static/static/js/29.a0eb0eee98462dc00d86.js.map b/priv/static/static/js/29.a0eb0eee98462dc00d86.js.map new file mode 100644 index 000000000..357679d53 Binary files /dev/null and b/priv/static/static/js/29.a0eb0eee98462dc00d86.js.map differ diff --git a/priv/static/static/js/3.44ee95fa34170fe38ef7.js b/priv/static/static/js/3.44ee95fa34170fe38ef7.js new file mode 100644 index 000000000..ad2b9294c Binary files /dev/null and b/priv/static/static/js/3.44ee95fa34170fe38ef7.js differ diff --git a/priv/static/static/js/3.44ee95fa34170fe38ef7.js.map b/priv/static/static/js/3.44ee95fa34170fe38ef7.js.map new file mode 100644 index 000000000..7efe5d6a5 Binary files /dev/null and b/priv/static/static/js/3.44ee95fa34170fe38ef7.js.map differ diff --git a/priv/static/static/js/3.56898c1005d9ba1b8d4a.js b/priv/static/static/js/3.56898c1005d9ba1b8d4a.js deleted file mode 100644 index 6b20ecb04..000000000 Binary files a/priv/static/static/js/3.56898c1005d9ba1b8d4a.js and /dev/null differ diff --git a/priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map b/priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map deleted file mode 100644 index 594d9047b..000000000 Binary files a/priv/static/static/js/3.56898c1005d9ba1b8d4a.js.map and /dev/null differ diff --git a/priv/static/static/js/30.73f0507f6b66caa1b632.js b/priv/static/static/js/30.73f0507f6b66caa1b632.js new file mode 100644 index 000000000..0f1beeb58 Binary files /dev/null and b/priv/static/static/js/30.73f0507f6b66caa1b632.js differ diff --git a/priv/static/static/js/30.73f0507f6b66caa1b632.js.map b/priv/static/static/js/30.73f0507f6b66caa1b632.js.map new file mode 100644 index 000000000..e73f818cd Binary files /dev/null and b/priv/static/static/js/30.73f0507f6b66caa1b632.js.map differ diff --git a/priv/static/static/js/30.d78855ca19bf749be905.js b/priv/static/static/js/30.d78855ca19bf749be905.js deleted file mode 100644 index 9202d19d3..000000000 Binary files a/priv/static/static/js/30.d78855ca19bf749be905.js and /dev/null differ diff --git a/priv/static/static/js/30.d78855ca19bf749be905.js.map b/priv/static/static/js/30.d78855ca19bf749be905.js.map deleted file mode 100644 index b9f39664d..000000000 Binary files a/priv/static/static/js/30.d78855ca19bf749be905.js.map and /dev/null differ diff --git a/priv/static/static/js/4.2d3bef896b463484e6eb.js b/priv/static/static/js/4.2d3bef896b463484e6eb.js deleted file mode 100644 index 4a611feb4..000000000 Binary files a/priv/static/static/js/4.2d3bef896b463484e6eb.js and /dev/null differ diff --git a/priv/static/static/js/4.2d3bef896b463484e6eb.js.map b/priv/static/static/js/4.2d3bef896b463484e6eb.js.map deleted file mode 100644 index ebcc883e5..000000000 Binary files a/priv/static/static/js/4.2d3bef896b463484e6eb.js.map and /dev/null differ diff --git a/priv/static/static/js/4.77639012e321d98c064c.js b/priv/static/static/js/4.77639012e321d98c064c.js new file mode 100644 index 000000000..e8d35a81d Binary files /dev/null and b/priv/static/static/js/4.77639012e321d98c064c.js differ diff --git a/priv/static/static/js/4.77639012e321d98c064c.js.map b/priv/static/static/js/4.77639012e321d98c064c.js.map new file mode 100644 index 000000000..1a0373e08 Binary files /dev/null and b/priv/static/static/js/4.77639012e321d98c064c.js.map differ diff --git a/priv/static/static/js/5.84f3dce298bc720719c7.js b/priv/static/static/js/5.84f3dce298bc720719c7.js deleted file mode 100644 index 242b2a525..000000000 Binary files a/priv/static/static/js/5.84f3dce298bc720719c7.js and /dev/null differ diff --git a/priv/static/static/js/5.84f3dce298bc720719c7.js.map b/priv/static/static/js/5.84f3dce298bc720719c7.js.map deleted file mode 100644 index 4fcc32982..000000000 Binary files a/priv/static/static/js/5.84f3dce298bc720719c7.js.map and /dev/null differ diff --git a/priv/static/static/js/5.abcc811ac6e85e621b0d.js b/priv/static/static/js/5.abcc811ac6e85e621b0d.js new file mode 100644 index 000000000..1575d2a95 Binary files /dev/null and b/priv/static/static/js/5.abcc811ac6e85e621b0d.js differ diff --git a/priv/static/static/js/5.abcc811ac6e85e621b0d.js.map b/priv/static/static/js/5.abcc811ac6e85e621b0d.js.map new file mode 100644 index 000000000..03251d1d8 Binary files /dev/null and b/priv/static/static/js/5.abcc811ac6e85e621b0d.js.map differ diff --git a/priv/static/static/js/6.389907251866808cf2c4.js b/priv/static/static/js/6.389907251866808cf2c4.js new file mode 100644 index 000000000..def098eda Binary files /dev/null and b/priv/static/static/js/6.389907251866808cf2c4.js differ diff --git a/priv/static/static/js/6.389907251866808cf2c4.js.map b/priv/static/static/js/6.389907251866808cf2c4.js.map new file mode 100644 index 000000000..7b96d2998 Binary files /dev/null and b/priv/static/static/js/6.389907251866808cf2c4.js.map differ diff --git a/priv/static/static/js/6.b9497e1d403b901a664e.js b/priv/static/static/js/6.b9497e1d403b901a664e.js deleted file mode 100644 index 8c66e9062..000000000 Binary files a/priv/static/static/js/6.b9497e1d403b901a664e.js and /dev/null differ diff --git a/priv/static/static/js/6.b9497e1d403b901a664e.js.map b/priv/static/static/js/6.b9497e1d403b901a664e.js.map deleted file mode 100644 index 5af0576c7..000000000 Binary files a/priv/static/static/js/6.b9497e1d403b901a664e.js.map and /dev/null differ diff --git a/priv/static/static/js/7.33e3cc5c9abab3f21825.js b/priv/static/static/js/7.33e3cc5c9abab3f21825.js new file mode 100644 index 000000000..6a4e332e9 Binary files /dev/null and b/priv/static/static/js/7.33e3cc5c9abab3f21825.js differ diff --git a/priv/static/static/js/7.33e3cc5c9abab3f21825.js.map b/priv/static/static/js/7.33e3cc5c9abab3f21825.js.map new file mode 100644 index 000000000..a04c36f4c Binary files /dev/null and b/priv/static/static/js/7.33e3cc5c9abab3f21825.js.map differ diff --git a/priv/static/static/js/7.455b574116ce3f004ffb.js b/priv/static/static/js/7.455b574116ce3f004ffb.js deleted file mode 100644 index 0e35f6904..000000000 Binary files a/priv/static/static/js/7.455b574116ce3f004ffb.js and /dev/null differ diff --git a/priv/static/static/js/7.455b574116ce3f004ffb.js.map b/priv/static/static/js/7.455b574116ce3f004ffb.js.map deleted file mode 100644 index 971b570e1..000000000 Binary files a/priv/static/static/js/7.455b574116ce3f004ffb.js.map and /dev/null differ diff --git a/priv/static/static/js/8.5e0b07052c330e85bead.js b/priv/static/static/js/8.5e0b07052c330e85bead.js new file mode 100644 index 000000000..7fd0ec5a1 Binary files /dev/null and b/priv/static/static/js/8.5e0b07052c330e85bead.js differ diff --git a/priv/static/static/js/8.5e0b07052c330e85bead.js.map b/priv/static/static/js/8.5e0b07052c330e85bead.js.map new file mode 100644 index 000000000..d324ed4b0 Binary files /dev/null and b/priv/static/static/js/8.5e0b07052c330e85bead.js.map differ diff --git a/priv/static/static/js/8.8db9f2dcc5ed429777f7.js b/priv/static/static/js/8.8db9f2dcc5ed429777f7.js deleted file mode 100644 index 79082fc7c..000000000 Binary files a/priv/static/static/js/8.8db9f2dcc5ed429777f7.js and /dev/null differ diff --git a/priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map b/priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map deleted file mode 100644 index e193375de..000000000 Binary files a/priv/static/static/js/8.8db9f2dcc5ed429777f7.js.map and /dev/null differ diff --git a/priv/static/static/js/9.da3973d058660aa9612f.js b/priv/static/static/js/9.da3973d058660aa9612f.js deleted file mode 100644 index 50d977f67..000000000 Binary files a/priv/static/static/js/9.da3973d058660aa9612f.js and /dev/null differ diff --git a/priv/static/static/js/9.da3973d058660aa9612f.js.map b/priv/static/static/js/9.da3973d058660aa9612f.js.map deleted file mode 100644 index 4c8f70599..000000000 Binary files a/priv/static/static/js/9.da3973d058660aa9612f.js.map and /dev/null differ diff --git a/priv/static/static/js/9.f8e3aa590f4a66aedc3f.js b/priv/static/static/js/9.f8e3aa590f4a66aedc3f.js new file mode 100644 index 000000000..353737ab0 Binary files /dev/null and b/priv/static/static/js/9.f8e3aa590f4a66aedc3f.js differ diff --git a/priv/static/static/js/9.f8e3aa590f4a66aedc3f.js.map b/priv/static/static/js/9.f8e3aa590f4a66aedc3f.js.map new file mode 100644 index 000000000..452afcc41 Binary files /dev/null and b/priv/static/static/js/9.f8e3aa590f4a66aedc3f.js.map differ diff --git a/priv/static/static/js/app.032cb80dafd1f208df1c.js b/priv/static/static/js/app.032cb80dafd1f208df1c.js new file mode 100644 index 000000000..c4b099811 Binary files /dev/null and b/priv/static/static/js/app.032cb80dafd1f208df1c.js differ diff --git a/priv/static/static/js/app.032cb80dafd1f208df1c.js.map b/priv/static/static/js/app.032cb80dafd1f208df1c.js.map new file mode 100644 index 000000000..397fbfbe8 Binary files /dev/null and b/priv/static/static/js/app.032cb80dafd1f208df1c.js.map differ diff --git a/priv/static/static/js/app.31bba9f1e242ff273dcb.js b/priv/static/static/js/app.31bba9f1e242ff273dcb.js deleted file mode 100644 index 22413689c..000000000 Binary files a/priv/static/static/js/app.31bba9f1e242ff273dcb.js and /dev/null differ diff --git a/priv/static/static/js/app.31bba9f1e242ff273dcb.js.map b/priv/static/static/js/app.31bba9f1e242ff273dcb.js.map deleted file mode 100644 index 8ff7a00c6..000000000 Binary files a/priv/static/static/js/app.31bba9f1e242ff273dcb.js.map and /dev/null differ diff --git a/priv/static/static/js/vendors~app.811c8482146cad566f7e.js b/priv/static/static/js/vendors~app.811c8482146cad566f7e.js new file mode 100644 index 000000000..c2114925d Binary files /dev/null and b/priv/static/static/js/vendors~app.811c8482146cad566f7e.js differ diff --git a/priv/static/static/js/vendors~app.811c8482146cad566f7e.js.map b/priv/static/static/js/vendors~app.811c8482146cad566f7e.js.map new file mode 100644 index 000000000..858078059 Binary files /dev/null and b/priv/static/static/js/vendors~app.811c8482146cad566f7e.js.map differ diff --git a/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js b/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js deleted file mode 100644 index 76c8a8dc1..000000000 Binary files a/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js and /dev/null differ diff --git a/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map b/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map deleted file mode 100644 index f3c067c15..000000000 Binary files a/priv/static/static/js/vendors~app.9e24ed238da5a8538f50.js.map and /dev/null differ diff --git a/priv/static/sw-pleroma.js b/priv/static/sw-pleroma.js index 9b7d127fd..5aabeb744 100644 Binary files a/priv/static/sw-pleroma.js and b/priv/static/sw-pleroma.js differ diff --git a/priv/static/sw-pleroma.js.map b/priv/static/sw-pleroma.js.map index 5749809d5..20dac11d0 100644 Binary files a/priv/static/sw-pleroma.js.map and b/priv/static/sw-pleroma.js.map differ -- cgit v1.2.3 From 035c44dd7bc34dedc184ec0434ed994cc7a190dd Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 13 Aug 2020 16:38:04 +0200 Subject: CI: Add cmake to build --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5f82e58e5..9e9107ce3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -194,6 +194,7 @@ amd64: variables: &release-variables MIX_ENV: prod before_script: &before-release + - apt install cmake -y - echo "import Mix.Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force -- cgit v1.2.3 From 4f3c955f264c9a6bfcc302b4644ea9da6e7ad38b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 13 Aug 2020 18:10:43 +0200 Subject: side_effects: Fix typo on notification --- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 14a1da0c1..5a02f1d69 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -55,7 +55,7 @@ def handle( # Task this handles # - Rejects all existing follow activities for this person # - Updates the follow state - # - Dismisses notificatios + # - Dismisses notification def handle( %{ data: %{ -- cgit v1.2.3 From 3515cb5c3ac138c3e19eacc8fd78bb1480e3a98c Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 13 Aug 2020 21:01:21 +0300 Subject: fix Cron.PurgeExpiredActivitiesWorker --- lib/pleroma/workers/cron/purge_expired_activities_worker.ex | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex index e926c5dc8..0de8edd24 100644 --- a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex +++ b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex @@ -21,11 +21,12 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker do @impl Oban.Worker def perform(_job) do - if Config.get([ActivityExpiration, :enabled]) do - Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) - else - :ok - end + if Config.get([ActivityExpiration, :enabled]) do + Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) + end + + after + :ok end def delete_activity(%ActivityExpiration{activity_id: activity_id}) do @@ -41,7 +42,7 @@ def delete_activity(%ActivityExpiration{activity_id: activity_id}) do {:user, _} -> Logger.error( - "#{__MODULE__} Couldn't delete expired activity: not found actorof ##{activity_id}" + "#{__MODULE__} Couldn't delete expired activity: not found actor of ##{activity_id}" ) end end -- cgit v1.2.3 From 9b055f72119b3c4b51f026b88814fd17e87eba26 Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Thu, 13 Aug 2020 21:01:54 +0300 Subject: fix cron wroker --- lib/pleroma/workers/cron/clear_oauth_token_worker.ex | 4 ++-- lib/pleroma/workers/cron/digest_emails_worker.ex | 4 ++-- lib/pleroma/workers/cron/new_users_digest_worker.ex | 6 ++---- lib/pleroma/workers/cron/purge_expired_activities_worker.ex | 7 +++---- lib/pleroma/workers/cron/stats_worker.ex | 1 + 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex index d41be4e87..276f47efc 100644 --- a/lib/pleroma/workers/cron/clear_oauth_token_worker.ex +++ b/lib/pleroma/workers/cron/clear_oauth_token_worker.ex @@ -16,8 +16,8 @@ defmodule Pleroma.Workers.Cron.ClearOauthTokenWorker do def perform(_job) do if Config.get([:oauth2, :clean_expired_tokens], false) do Token.delete_expired_tokens() - else - :ok end + + :ok end end diff --git a/lib/pleroma/workers/cron/digest_emails_worker.ex b/lib/pleroma/workers/cron/digest_emails_worker.ex index ee646229f..0c56f00fb 100644 --- a/lib/pleroma/workers/cron/digest_emails_worker.ex +++ b/lib/pleroma/workers/cron/digest_emails_worker.ex @@ -37,9 +37,9 @@ def perform(_job) do ) |> Repo.all() |> send_emails - else - :ok end + + :ok end def send_emails(users) do diff --git a/lib/pleroma/workers/cron/new_users_digest_worker.ex b/lib/pleroma/workers/cron/new_users_digest_worker.ex index abc8a5e95..8bbaed83d 100644 --- a/lib/pleroma/workers/cron/new_users_digest_worker.ex +++ b/lib/pleroma/workers/cron/new_users_digest_worker.ex @@ -55,11 +55,9 @@ def perform(_job) do |> Repo.all() |> Enum.map(&Pleroma.Emails.NewUsersDigestEmail.new_users(&1, users_and_statuses)) |> Enum.each(&Pleroma.Emails.Mailer.deliver/1) - else - :ok end - else - :ok end + + :ok end end diff --git a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex index 0de8edd24..6549207fc 100644 --- a/lib/pleroma/workers/cron/purge_expired_activities_worker.ex +++ b/lib/pleroma/workers/cron/purge_expired_activities_worker.ex @@ -21,10 +21,9 @@ defmodule Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker do @impl Oban.Worker def perform(_job) do - if Config.get([ActivityExpiration, :enabled]) do - Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) - end - + if Config.get([ActivityExpiration, :enabled]) do + Enum.each(ActivityExpiration.due_expirations(@interval), &delete_activity/1) + end after :ok end diff --git a/lib/pleroma/workers/cron/stats_worker.ex b/lib/pleroma/workers/cron/stats_worker.ex index e54bd9a7f..6a79540bc 100644 --- a/lib/pleroma/workers/cron/stats_worker.ex +++ b/lib/pleroma/workers/cron/stats_worker.ex @@ -12,5 +12,6 @@ defmodule Pleroma.Workers.Cron.StatsWorker do @impl Oban.Worker def perform(_job) do Pleroma.Stats.do_collect() + :ok end end -- cgit v1.2.3 From 07376bd21ae732a00c61ce55be920ddf8ba603ee Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 6 Aug 2020 00:01:57 -0400 Subject: Adding installation documentation for FreeBSD + rc.d script --- docs/installation/freebsd_en.md | 201 ++++++++++++++++++++++++++++++++++++++ installation/freebsd/rc.d/pleroma | 28 ++++++ 2 files changed, 229 insertions(+) create mode 100644 docs/installation/freebsd_en.md create mode 100755 installation/freebsd/rc.d/pleroma diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md new file mode 100644 index 000000000..51990c5e4 --- /dev/null +++ b/docs/installation/freebsd_en.md @@ -0,0 +1,201 @@ +# Installing on FreeBSD + +This document was written for FreeBSD 12.1, but should be trivially trailerable to future releases. +Additionally, this guide document can be modified to + +## Required software + +This assumes the target system has `pkg(8)`. + +`# pkg install elixir postgresql12-server postgresql12-client postgresql12-contrib git-lite sudo nginx gmake acme.sh` + +Copy the rc.d scripts to the right directory: + +Setup the required services to automatically start at boot, using `sysrc(8)`. + +``` +# sysrc nginx_enable=YES +# sysrc postgresql_enable=YES +``` + +## Initialize postgres + +``` +# service postgresql initdb +# service postgresql start +``` + +## Configuring Pleroma + +Create a user for Pleroma: + +``` +# pw add user pleroma -m +# echo 'export LC_ALL="en_US.UTF-8"' >> /home/pleroma/.profile +# su -l pleroma +``` + +Clone the repository: + +``` +$ cd $HOME # Should be the same as /home/pleroma +$ git clone -b stable https://git.pleroma.social/pleroma/pleroma.git +``` + +Configure Pleroma. Note that you need a domain name at this point: + +``` +$ cd /home/pleroma/pleroma +$ mix deps.get +$ mix pleroma.instance gen # You will be asked a few questions here. +$ cp config/generated_config.exs config/prod.secret.exs # The default values should be sufficient but you should edit it and check that everything seems OK. +``` + +Since Postgres is configured, we can now initialize the database. There should +now be a file in `config/setup_db.psql` that makes this easier. Edit it, and +*change the password* to a password of your choice. Make sure it is secure, since +it'll be protecting your database. As root, you can now initialize the database: + +``` +# cd /home/pleroma/pleroma +# sudo -Hu postgres -g postgres psql -f config/setup_db.psql +``` + +Postgres allows connections from all users without a password by default. To +fix this, edit `/var/db/postgres/data12/pg_hba.conf`. Change every `trust` to +`password`. + +Once this is done, restart Postgres with `# service postgresql restart`. + +Run the database migrations. + +Back as the pleroma user, you will need to do this whenever you update with `git pull`: + +``` +# su -l pleroma +$ cd /home/pleroma/pleroma +$ MIX_ENV=prod mix ecto.migrate +``` + +## Configuring nginx + +Install the example configuration file +`/home/pleroma/pleroma/installation/pleroma.nginx` to +`/usr/local/etc/nginx/nginx.conf`. + +Note that it will need to be wrapped in a `http {}` block. You should add +settings for the nginx daemon outside of the http block, for example: + +``` +user nginx nginx; +error_log /var/log/nginx/error.log; +worker_processes 4; + +events { +} +``` + +Edit the defaults: + +* Change `ssl_certificate` and `ssl_trusted_certificate` to +`/etc/ssl/example.tld/fullchain`. +* Change `ssl_certificate_key` to `/etc/ssl/example.tld/key`. +* Change `example.tld` to your instance's domain name. + +## Configuring acme.sh + +We'll be using acme.sh in Stateless Mode for TLS certificate renewal. + +First, get your account fingerprint: + +``` +$ sudo -Hu nginx -g nginx acme.sh --register-account +``` + +You need to add the following to your nginx configuration for the server +running on port 80: + +``` + location ~ ^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$ { + default_type text/plain; + return 200 "$1.6fXAG9VyG0IahirPEU2ZerUtItW2DHzDzD9wZaEKpqd"; + } +``` + +Replace the string after after `$1.` with your fingerprint. + +Start nginx: + +``` +# service nginx start +``` + +It should now be possible to issue a cert (replace `example.com` +with your domain name): + +``` +$ sudo -Hu nginx -g nginx acme.sh --issue -d example.com --stateless +$ acme.sh --install-cert -d example.com \ + --key-file /path/to/keyfile/in/nginx/key.pem \ + --fullchain-file /path/to/fullchain/nginx/cert.pem \ +``` + +Let's add auto-renewal to `/etc/daily.local` +(replace `example.com` with your domain): + +``` +/usr/pkg/bin/sudo -Hu nginx -g nginx \ + /usr/pkg/sbin/acme.sh -r \ + -d example.com \ + --cert-file /etc/nginx/tls/cert \ + --key-file /etc/nginx/tls/key \ + --ca-file /etc/nginx/tls/ca \ + --fullchain-file /etc/nginx/tls/fullchain \ + --stateless +``` + +## Creating a startup script for Pleroma + +Pleroma will need to compile when it initially starts, which typically takes a longer +period of time. Therefore, it is good practice to initially run pleroma from the +command-line before utilizing the rc.d script. That is done as follows: + +``` +# su -l pleroma +$ cd $HOME/pleroma +$ MIX_ENV=prod mix phx.server +``` + +Copy the startup script to the correct location and make sure it's executable: + +``` +# cp /home/pleroma/pleroma/installation/freebsd/rc.d/pleroma /usr/local/etc/rc.d/pleroma +# chmod +x /etc/rc.d/pleroma +``` + +Add the following to `/etc/rc.conf`: + +``` +pleroma=YES +pleroma_home="/home/pleroma" +pleroma_user="pleroma" +``` + +Run `# /etc/rc.d/pleroma start` to start Pleroma. + +## Conclusion + +Restart nginx with `# /etc/rc.d/nginx restart` and you should be up and running. + +If you need further help, contact niaa on freenode. + +Make sure your time is in sync, or other instances will receive your posts with +incorrect timestamps. You should have ntpd running. + +#### Further reading + +{! backend/installation/further_reading.include !} + +## Questions + +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. diff --git a/installation/freebsd/rc.d/pleroma b/installation/freebsd/rc.d/pleroma new file mode 100755 index 000000000..1e41e57e6 --- /dev/null +++ b/installation/freebsd/rc.d/pleroma @@ -0,0 +1,28 @@ +#!/bin/sh +# REQUIRE: DAEMON postgresql +# PROVIDE: pleroma + +# sudo -u pleroma MIX_ENV=prod elixir --erl \"-detached\" -S mix phx.server + +. /etc/rc.subr + +name="pleroma" +desc="Pleroma Social Media Platform" +rcvar=${name}_enable +command="/usr/local/bin/elixir" +command_args="--erl \"-detached\" -S /usr/local/bin/mix phx.server" +pidfile="/dev/null" + +pleroma_user="pleroma" +pleroma_home="/home/pleroma" +pleroma_chdir="${pleroma_home}/pleroma" +pleroma_env="HOME=${pleroma_home} MIX_ENV=prod" + +check_pidfile() +{ + pid=$(pgrep beam.smp$) + echo -n "${pid}" +} + +load_rc_config ${name} +run_rc_command "$1" -- cgit v1.2.3 From da5aca27a8c79edcb4577c3a9f05cfa5d0463e83 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 6 Aug 2020 23:24:12 +0000 Subject: Minor reorganization --- docs/installation/freebsd_en.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 51990c5e4..c98992fe5 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -69,7 +69,7 @@ Once this is done, restart Postgres with `# service postgresql restart`. Run the database migrations. -Back as the pleroma user, you will need to do this whenever you update with `git pull`: +Back as the pleroma user, run the following to implement any database migrations. ``` # su -l pleroma @@ -77,9 +77,11 @@ $ cd /home/pleroma/pleroma $ MIX_ENV=prod mix ecto.migrate ``` +You will need to do this whenever you update with `git pull`: + ## Configuring nginx -Install the example configuration file +As root, install the example configuration file `/home/pleroma/pleroma/installation/pleroma.nginx` to `/usr/local/etc/nginx/nginx.conf`. -- cgit v1.2.3 From f6686a64afceb775d775e623c847d413fecf65f8 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 6 Aug 2020 23:35:33 +0000 Subject: Updated ssl and domain name updates Removed the reference to niaa --- docs/installation/freebsd_en.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index c98992fe5..9c5caa4d3 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -97,12 +97,12 @@ events { } ``` -Edit the defaults: +Edit the defaults of `/usr/local/etc/nginx/nginx.conf`: -* Change `ssl_certificate` and `ssl_trusted_certificate` to -`/etc/ssl/example.tld/fullchain`. -* Change `ssl_certificate_key` to `/etc/ssl/example.tld/key`. -* Change `example.tld` to your instance's domain name. +* Change `ssl_trusted_certificate` to `/etc/ssl/example.tld/chain.pem`. +* Change `ssl_certificate` to `/etc/ssl/example.tld/fullchain.pem`. +* Change `ssl_certificate_key` to `/etc/ssl/example.tld/privkey.pem`. +* Change all references of `example.tld` to your instance's domain name. ## Configuring acme.sh @@ -189,8 +189,6 @@ Run `# /etc/rc.d/pleroma start` to start Pleroma. Restart nginx with `# /etc/rc.d/nginx restart` and you should be up and running. -If you need further help, contact niaa on freenode. - Make sure your time is in sync, or other instances will receive your posts with incorrect timestamps. You should have ntpd running. -- cgit v1.2.3 From 53c4215ef1d65300ffbf8d47cdb5a713558df528 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Fri, 7 Aug 2020 01:04:33 +0000 Subject: Updated some more instruction specifics. --- docs/installation/freebsd_en.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 9c5caa4d3..ee42b9427 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -172,18 +172,16 @@ Copy the startup script to the correct location and make sure it's executable: ``` # cp /home/pleroma/pleroma/installation/freebsd/rc.d/pleroma /usr/local/etc/rc.d/pleroma -# chmod +x /etc/rc.d/pleroma +# chmod +x /usr/local/etc/rc.d/pleroma ``` -Add the following to `/etc/rc.conf`: +Update the `/etc/rc.conf` file with the following command: ``` -pleroma=YES -pleroma_home="/home/pleroma" -pleroma_user="pleroma" +# sysrc pleroma_enable=YES ``` -Run `# /etc/rc.d/pleroma start` to start Pleroma. +Now you can start pleroma with `# service pleroma start`. ## Conclusion -- cgit v1.2.3 From 33ea430f3b026f4e9b353b74bcc60846c67a5a69 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Fri, 7 Aug 2020 01:52:39 +0000 Subject: acme.sh and netbsd to freebsd updates --- docs/installation/freebsd_en.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index ee42b9427..b5c62bee6 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -146,8 +146,8 @@ Let's add auto-renewal to `/etc/daily.local` (replace `example.com` with your domain): ``` -/usr/pkg/bin/sudo -Hu nginx -g nginx \ - /usr/pkg/sbin/acme.sh -r \ +/usr/pkg/bin/sudo -Hu www -g www \ + /usr/local/sbin/acme.sh -r \ -d example.com \ --cert-file /etc/nginx/tls/cert \ --key-file /etc/nginx/tls/key \ @@ -175,25 +175,22 @@ Copy the startup script to the correct location and make sure it's executable: # chmod +x /usr/local/etc/rc.d/pleroma ``` -Update the `/etc/rc.conf` file with the following command: +Update the `/etc/rc.conf` and start pleroma with the following commands: ``` # sysrc pleroma_enable=YES +# service pleroma start ``` Now you can start pleroma with `# service pleroma start`. ## Conclusion -Restart nginx with `# /etc/rc.d/nginx restart` and you should be up and running. +Restart nginx with `# service nginx restart` and you should be up and running. Make sure your time is in sync, or other instances will receive your posts with incorrect timestamps. You should have ntpd running. -#### Further reading - -{! backend/installation/further_reading.include !} - ## Questions Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. -- cgit v1.2.3 From b5f48275c5a0802ac5e7da0caf3d3af0bfbb7c6c Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 19:08:13 -0400 Subject: Minor patch update --- docs/installation/freebsd_en.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index b5c62bee6..12c870322 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -108,10 +108,10 @@ Edit the defaults of `/usr/local/etc/nginx/nginx.conf`: We'll be using acme.sh in Stateless Mode for TLS certificate renewal. -First, get your account fingerprint: +First, as root, get your account fingerprint: ``` -$ sudo -Hu nginx -g nginx acme.sh --register-account +# sudo -Hu acme -g acme acme.sh --register-account ``` You need to add the following to your nginx configuration for the server @@ -136,7 +136,7 @@ It should now be possible to issue a cert (replace `example.com` with your domain name): ``` -$ sudo -Hu nginx -g nginx acme.sh --issue -d example.com --stateless +$ sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless $ acme.sh --install-cert -d example.com \ --key-file /path/to/keyfile/in/nginx/key.pem \ --fullchain-file /path/to/fullchain/nginx/cert.pem \ @@ -146,7 +146,7 @@ Let's add auto-renewal to `/etc/daily.local` (replace `example.com` with your domain): ``` -/usr/pkg/bin/sudo -Hu www -g www \ +/usr/local/bin/sudo -Hu acme -g acme \ /usr/local/sbin/acme.sh -r \ -d example.com \ --cert-file /etc/nginx/tls/cert \ -- cgit v1.2.3 From cba9f368af13768f7c0161074ab3f25deae5b5a6 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 19:34:04 -0400 Subject: Added comment --- docs/installation/freebsd_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 12c870322..38afd76e4 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -46,7 +46,7 @@ Configure Pleroma. Note that you need a domain name at this point: ``` $ cd /home/pleroma/pleroma -$ mix deps.get +$ mix deps.get # Enter "y" when asked to install Hex $ mix pleroma.instance gen # You will be asked a few questions here. $ cp config/generated_config.exs config/prod.secret.exs # The default values should be sufficient but you should edit it and check that everything seems OK. ``` -- cgit v1.2.3 From 24eb917dbc752a81716699ebd23ad9ff9cbd6a24 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 20:58:46 -0400 Subject: Rearranging acme --- docs/installation/freebsd_en.md | 63 +++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 38afd76e4..a8741e565 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -79,36 +79,19 @@ $ MIX_ENV=prod mix ecto.migrate You will need to do this whenever you update with `git pull`: -## Configuring nginx +## Configuring acme.sh -As root, install the example configuration file -`/home/pleroma/pleroma/installation/pleroma.nginx` to -`/usr/local/etc/nginx/nginx.conf`. +We'll be using acme.sh in Stateless Mode for TLS certificate renewal. -Note that it will need to be wrapped in a `http {}` block. You should add -settings for the nginx daemon outside of the http block, for example: +First, as root, allow the user `acme` to have access to the acme log file, as follows: ``` -user nginx nginx; -error_log /var/log/nginx/error.log; -worker_processes 4; - -events { -} +# touch /var/log/acme.sh.log +# chown acme:acme /var/log/acme.sh.log +# chmod 600 /var/log/acme.sh.log ``` -Edit the defaults of `/usr/local/etc/nginx/nginx.conf`: - -* Change `ssl_trusted_certificate` to `/etc/ssl/example.tld/chain.pem`. -* Change `ssl_certificate` to `/etc/ssl/example.tld/fullchain.pem`. -* Change `ssl_certificate_key` to `/etc/ssl/example.tld/privkey.pem`. -* Change all references of `example.tld` to your instance's domain name. - -## Configuring acme.sh - -We'll be using acme.sh in Stateless Mode for TLS certificate renewal. - -First, as root, get your account fingerprint: +Next, obtain your account fingerprint: ``` # sudo -Hu acme -g acme acme.sh --register-account @@ -156,6 +139,38 @@ Let's add auto-renewal to `/etc/daily.local` --stateless ``` +### Configuring nginx + +FreeBSD's default nginx configuration does not contain an include directive, which is +typically used for multiple sites. Therefore, you will need to first create the required +directory as follows: + + +``` +# mkdir -p /usr/local/etc/nginx/sites-available +``` + +Next, add an `include` directive to `/usr/local/etc/nginx/nginx.conf`, within the `http {}` +block, as follows: + + +``` +http { +... + include /usr/local/etc/nginx/sites-available/*.conf; +} +``` + +As root, copy `/home/pleroma/pleroma/installation/pleroma.nginx` to +`/usr/local/etc/nginx/sites-available/pleroma.conf`. + +Edit the defaults of `/usr/local/etc/nginx/sites-available/pleroma.conf`: + +* Change `ssl_trusted_certificate` to `/etc/ssl/example.tld/chain.pem`. +* Change `ssl_certificate` to `/etc/ssl/example.tld/fullchain.pem`. +* Change `ssl_certificate_key` to `/etc/ssl/example.tld/privkey.pem`. +* Change all references of `example.tld` to your instance's domain name. + ## Creating a startup script for Pleroma Pleroma will need to compile when it initially starts, which typically takes a longer -- cgit v1.2.3 From f2665547f59a7043cf8bac9d39c56a9b717d5099 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 21:24:08 -0400 Subject: acme updates --- docs/installation/freebsd_en.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index a8741e565..386a0ae10 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -119,10 +119,11 @@ It should now be possible to issue a cert (replace `example.com` with your domain name): ``` -$ sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless -$ acme.sh --install-cert -d example.com \ - --key-file /path/to/keyfile/in/nginx/key.pem \ - --fullchain-file /path/to/fullchain/nginx/cert.pem \ +# mkdir -p /etc/ssl/example.com +# sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless +# acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ + --key-file /etc/ssl/example.com/key.pem + --fullchain-file /etc/ssl/example.com/fullchain.pem ``` Let's add auto-renewal to `/etc/daily.local` -- cgit v1.2.3 From b0c456d18d3b4e20233a7dbaef3c55d0586a1946 Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 22:18:33 -0400 Subject: more acme.sh updates --- docs/installation/freebsd_en.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 386a0ae10..458b8032d 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -122,22 +122,22 @@ with your domain name): # mkdir -p /etc/ssl/example.com # sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless # acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ - --key-file /etc/ssl/example.com/key.pem + --ca-file /etc/ssl/example.com/ca.pem \ + --key-file /etc/ssl/example.com/key.pem \ + --cert-file /etc/ssl/example.com/cert.pem \ --fullchain-file /etc/ssl/example.com/fullchain.pem ``` -Let's add auto-renewal to `/etc/daily.local` +Let's add auto-renewal to `/etc/crontab` (replace `example.com` with your domain): ``` -/usr/local/bin/sudo -Hu acme -g acme \ - /usr/local/sbin/acme.sh -r \ - -d example.com \ - --cert-file /etc/nginx/tls/cert \ - --key-file /etc/nginx/tls/key \ - --ca-file /etc/nginx/tls/ca \ - --fullchain-file /etc/nginx/tls/fullchain \ - --stateless +/usr/local/bin/sudo -Hu acme -g acme /usr/local/sbin/acme.sh -r -d example.com --stateless +/usr/local/sbin/acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ + --ca-file /etc/ssl/example.com/ca.pem \ + --key-file /etc/ssl/example.com/key.pem \ + --cert-file /etc/ssl/test-app.mailchar.com/cert.pem \ + --fullchain-file /etc/ssl/example.com/fullchain.pem ``` ### Configuring nginx -- cgit v1.2.3 From 816c04abdc2e8045f3fa52071b953c5ac608d0bd Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 22:38:23 -0400 Subject: Updates --- docs/installation/freebsd_en.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 458b8032d..f1e06892c 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -123,8 +123,8 @@ with your domain name): # sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless # acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ --ca-file /etc/ssl/example.com/ca.pem \ - --key-file /etc/ssl/example.com/key.pem \ - --cert-file /etc/ssl/example.com/cert.pem \ + --key-file /etc/ssl/example.com/privkey.pem \ + --cert-file /etc/ssl/example.com/chain.pem \ --fullchain-file /etc/ssl/example.com/fullchain.pem ``` @@ -135,8 +135,8 @@ Let's add auto-renewal to `/etc/crontab` /usr/local/bin/sudo -Hu acme -g acme /usr/local/sbin/acme.sh -r -d example.com --stateless /usr/local/sbin/acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ --ca-file /etc/ssl/example.com/ca.pem \ - --key-file /etc/ssl/example.com/key.pem \ - --cert-file /etc/ssl/test-app.mailchar.com/cert.pem \ + --key-file /etc/ssl/example.com/privkey.pem \ + --cert-file /etc/ssl/example.com/chain.pem \ --fullchain-file /etc/ssl/example.com/fullchain.pem ``` @@ -158,7 +158,7 @@ block, as follows: ``` http { ... - include /usr/local/etc/nginx/sites-available/*.conf; + include /usr/local/etc/nginx/sites-available/*; } ``` -- cgit v1.2.3 From a5144f05c2245c5043f2469955e8960b5d80b48e Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Thu, 13 Aug 2020 22:49:50 -0400 Subject: Removed a trailing comment --- docs/installation/freebsd_en.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index f1e06892c..ce0cdead6 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -198,8 +198,6 @@ Update the `/etc/rc.conf` and start pleroma with the following commands: # service pleroma start ``` -Now you can start pleroma with `# service pleroma start`. - ## Conclusion Restart nginx with `# service nginx restart` and you should be up and running. -- cgit v1.2.3 From e8c20c42cd02cc4dcbcb420cec98f68951a1609d Mon Sep 17 00:00:00 2001 From: Farhan Khan Date: Fri, 14 Aug 2020 00:21:42 -0400 Subject: minor changes --- docs/installation/freebsd_en.md | 42 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index ce0cdead6..130d68766 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -1,13 +1,14 @@ # Installing on FreeBSD -This document was written for FreeBSD 12.1, but should be trivially trailerable to future releases. -Additionally, this guide document can be modified to +This document was written for FreeBSD 12.1, but should be work on future releases. ## Required software This assumes the target system has `pkg(8)`. -`# pkg install elixir postgresql12-server postgresql12-client postgresql12-contrib git-lite sudo nginx gmake acme.sh` +``` +# pkg install elixir postgresql12-server postgresql12-client postgresql12-contrib git-lite sudo nginx gmake acme.sh +``` Copy the rc.d scripts to the right directory: @@ -48,7 +49,7 @@ Configure Pleroma. Note that you need a domain name at this point: $ cd /home/pleroma/pleroma $ mix deps.get # Enter "y" when asked to install Hex $ mix pleroma.instance gen # You will be asked a few questions here. -$ cp config/generated_config.exs config/prod.secret.exs # The default values should be sufficient but you should edit it and check that everything seems OK. +$ cp config/generated_config.exs config/prod.secret.exs ``` Since Postgres is configured, we can now initialize the database. There should @@ -65,7 +66,10 @@ Postgres allows connections from all users without a password by default. To fix this, edit `/var/db/postgres/data12/pg_hba.conf`. Change every `trust` to `password`. -Once this is done, restart Postgres with `# service postgresql restart`. +Once this is done, restart Postgres with: +``` +# service postgresql restart +``` Run the database migrations. @@ -119,13 +123,7 @@ It should now be possible to issue a cert (replace `example.com` with your domain name): ``` -# mkdir -p /etc/ssl/example.com # sudo -Hu acme -g acme acme.sh --issue -d example.com --stateless -# acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ - --ca-file /etc/ssl/example.com/ca.pem \ - --key-file /etc/ssl/example.com/privkey.pem \ - --cert-file /etc/ssl/example.com/chain.pem \ - --fullchain-file /etc/ssl/example.com/fullchain.pem ``` Let's add auto-renewal to `/etc/crontab` @@ -133,11 +131,6 @@ Let's add auto-renewal to `/etc/crontab` ``` /usr/local/bin/sudo -Hu acme -g acme /usr/local/sbin/acme.sh -r -d example.com --stateless -/usr/local/sbin/acme.sh --home /var/db/acme/.acme.sh/ --install-cert -d example.com \ - --ca-file /etc/ssl/example.com/ca.pem \ - --key-file /etc/ssl/example.com/privkey.pem \ - --cert-file /etc/ssl/example.com/chain.pem \ - --fullchain-file /etc/ssl/example.com/fullchain.pem ``` ### Configuring nginx @@ -163,13 +156,13 @@ http { ``` As root, copy `/home/pleroma/pleroma/installation/pleroma.nginx` to -`/usr/local/etc/nginx/sites-available/pleroma.conf`. +`/usr/local/etc/nginx/sites-available/pleroma.nginx`. -Edit the defaults of `/usr/local/etc/nginx/sites-available/pleroma.conf`: +Edit the defaults of `/usr/local/etc/nginx/sites-available/pleroma.nginx`: -* Change `ssl_trusted_certificate` to `/etc/ssl/example.tld/chain.pem`. -* Change `ssl_certificate` to `/etc/ssl/example.tld/fullchain.pem`. -* Change `ssl_certificate_key` to `/etc/ssl/example.tld/privkey.pem`. +* Change `ssl_trusted_certificate` to `/var/db/acme/certs/example.tld/example.tld.cer`. +* Change `ssl_certificate` to `/var/db/acme/certs/example.tld/fullchain.cer`. +* Change `ssl_certificate_key` to `/var/db/acme/certs/example.tld/example.tld.key`. * Change all references of `example.tld` to your instance's domain name. ## Creating a startup script for Pleroma @@ -198,6 +191,13 @@ Update the `/etc/rc.conf` and start pleroma with the following commands: # service pleroma start ``` +#### Create your first user + +If your instance is up and running, you can create your first user with administrative rights with the following task: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new --admin +``` ## Conclusion Restart nginx with `# service nginx restart` and you should be up and running. -- cgit v1.2.3 From 76ce3a1c9e36181dac47dde013e8dad98f606387 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 14 Aug 2020 18:27:18 +0200 Subject: Mogrifun: Add a line about the purpose of the module. --- lib/pleroma/upload/filter/mogrifun.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex index a8503ac24..c8fa7b190 100644 --- a/lib/pleroma/upload/filter/mogrifun.ex +++ b/lib/pleroma/upload/filter/mogrifun.ex @@ -6,6 +6,10 @@ defmodule Pleroma.Upload.Filter.Mogrifun do @behaviour Pleroma.Upload.Filter alias Pleroma.Upload.Filter + @moduledoc """ + This module is just an example of an Upload filter. It's not supposed to be used in production. + """ + @filters [ {"implode", "1"}, {"-raise", "20"}, -- cgit v1.2.3 From f510dc30b4d47fe1af14b91bcc42f0438f2367eb Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 14 Aug 2020 12:48:49 -0500 Subject: Update AdminFE bundle for 2.1.0 release --- priv/static/adminfe/app.01bdb34a.css | Bin 12837 -> 0 bytes priv/static/adminfe/app.61bb0915.css | Bin 0 -> 12895 bytes priv/static/adminfe/chunk-0171.8dc0d9da.css | Bin 0 -> 3545 bytes priv/static/adminfe/chunk-070d.d2dd6533.css | Bin 4748 -> 0 bytes priv/static/adminfe/chunk-0cbc.60bba79b.css | Bin 3385 -> 0 bytes priv/static/adminfe/chunk-143c.43ada4fc.css | Bin 692 -> 0 bytes priv/static/adminfe/chunk-1609.408dae86.css | Bin 1381 -> 0 bytes priv/static/adminfe/chunk-176e.4d21033f.css | Bin 0 -> 2163 bytes priv/static/adminfe/chunk-176e.5d7d957b.css | Bin 2163 -> 0 bytes priv/static/adminfe/chunk-2d97.7053ff89.css | Bin 0 -> 692 bytes priv/static/adminfe/chunk-40a4.2fe71f6c.css | Bin 0 -> 5006 bytes priv/static/adminfe/chunk-43ca.af749c6c.css | Bin 24279 -> 0 bytes priv/static/adminfe/chunk-4e7e.5afe1978.css | Bin 2044 -> 0 bytes priv/static/adminfe/chunk-565e.33809ac8.css | Bin 0 -> 26965 bytes priv/static/adminfe/chunk-5882.f65db7f2.css | Bin 4401 -> 0 bytes priv/static/adminfe/chunk-60a9.a80ec218.css | Bin 0 -> 1139 bytes priv/static/adminfe/chunk-654e.e105ec9c.css | Bin 0 -> 1381 bytes priv/static/adminfe/chunk-68ea.be16aa5f.css | Bin 0 -> 4748 bytes priv/static/adminfe/chunk-6e81.7f126ac7.css | Bin 0 -> 745 bytes priv/static/adminfe/chunk-6e81.ca3b222f.css | Bin 745 -> 0 bytes priv/static/adminfe/chunk-6e8c.f7407fd4.css | Bin 0 -> 4474 bytes priv/static/adminfe/chunk-7503.c75b68df.css | Bin 0 -> 3290 bytes priv/static/adminfe/chunk-7506.f01f6c2a.css | Bin 3290 -> 0 bytes priv/static/adminfe/chunk-7c6b.4c8fa90a.css | Bin 0 -> 2340 bytes priv/static/adminfe/chunk-7c6b.d9e7180a.css | Bin 2340 -> 0 bytes priv/static/adminfe/chunk-97e2.b21a8915.css | Bin 0 -> 5842 bytes priv/static/adminfe/chunk-9a72.3e577534.css | Bin 0 -> 2044 bytes priv/static/adminfe/chunk-c5f4.b1112f18.css | Bin 5842 -> 0 bytes priv/static/adminfe/chunk-commons.67f053f7.css | Bin 0 -> 2495 bytes priv/static/adminfe/chunk-commons.7f6d2d11.css | Bin 2495 -> 0 bytes priv/static/adminfe/chunk-e404.a56021ae.css | Bin 5063 -> 0 bytes priv/static/adminfe/chunk-libs.5cf7f50a.css | Bin 0 -> 3577 bytes priv/static/adminfe/chunk-libs.686b5876.css | Bin 3577 -> 0 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/ZhIB.861df339.js | Bin 11328 -> 0 bytes priv/static/adminfe/static/js/ZhIB.861df339.js.map | Bin 49483 -> 0 bytes priv/static/adminfe/static/js/app.86bfcdf3.js | Bin 0 -> 203611 bytes priv/static/adminfe/static/js/app.86bfcdf3.js.map | Bin 0 -> 449775 bytes priv/static/adminfe/static/js/app.f220ac13.js | Bin 194930 -> 0 bytes priv/static/adminfe/static/js/app.f220ac13.js.map | Bin 430912 -> 0 bytes priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js | Bin 0 -> 24601 bytes .../adminfe/static/js/chunk-0171.9ad03c0e.js.map | Bin 0 -> 94865 bytes priv/static/adminfe/static/js/chunk-070d.7e10a520.js | Bin 7919 -> 0 bytes .../adminfe/static/js/chunk-070d.7e10a520.js.map | Bin 17438 -> 0 bytes priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js | Bin 21596 -> 0 bytes .../adminfe/static/js/chunk-0cbc.2b0f8802.js.map | Bin 86354 -> 0 bytes priv/static/adminfe/static/js/chunk-143c.fc1825bf.js | Bin 13814 -> 0 bytes .../adminfe/static/js/chunk-143c.fc1825bf.js.map | Bin 37014 -> 0 bytes priv/static/adminfe/static/js/chunk-1609.98da6b01.js | Bin 10740 -> 0 bytes .../adminfe/static/js/chunk-1609.98da6b01.js.map | Bin 46790 -> 0 bytes priv/static/adminfe/static/js/chunk-176e.c4995511.js | Bin 10092 -> 0 bytes .../adminfe/static/js/chunk-176e.c4995511.js.map | Bin 32132 -> 0 bytes priv/static/adminfe/static/js/chunk-176e.fe016b36.js | Bin 0 -> 10092 bytes .../adminfe/static/js/chunk-176e.fe016b36.js.map | Bin 0 -> 32132 bytes priv/static/adminfe/static/js/chunk-2d97.931fa130.js | Bin 0 -> 15034 bytes .../adminfe/static/js/chunk-2d97.931fa130.js.map | Bin 0 -> 39781 bytes priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js | Bin 0 -> 19901 bytes .../adminfe/static/js/chunk-40a4.e7e37fc4.js.map | Bin 0 -> 75861 bytes priv/static/adminfe/static/js/chunk-43ca.aceb457c.js | Bin 112966 -> 0 bytes .../adminfe/static/js/chunk-43ca.aceb457c.js.map | Bin 386132 -> 0 bytes priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js | Bin 5112 -> 0 bytes .../adminfe/static/js/chunk-4e7e.91b5e73a.js.map | Bin 19744 -> 0 bytes priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js | Bin 0 -> 126482 bytes .../adminfe/static/js/chunk-565e.32b3b7b0.js.map | Bin 0 -> 426950 bytes priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js | Bin 24347 -> 0 bytes .../adminfe/static/js/chunk-5882.7cbc4c1b.js.map | Bin 81471 -> 0 bytes priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js | Bin 0 -> 6125 bytes .../adminfe/static/js/chunk-60a9.15f68a0f.js.map | Bin 0 -> 29926 bytes priv/static/adminfe/static/js/chunk-654e.d523dfc3.js | Bin 0 -> 10740 bytes .../adminfe/static/js/chunk-654e.d523dfc3.js.map | Bin 0 -> 46790 bytes priv/static/adminfe/static/js/chunk-68ea.a283cad8.js | Bin 0 -> 7919 bytes .../adminfe/static/js/chunk-68ea.a283cad8.js.map | Bin 0 -> 17438 bytes priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js | Bin 2080 -> 0 bytes .../adminfe/static/js/chunk-6e81.6efb01f4.js.map | Bin 9090 -> 0 bytes priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js | Bin 0 -> 2080 bytes .../adminfe/static/js/chunk-6e81.b4ee7cf5.js.map | Bin 0 -> 9090 bytes priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js | Bin 0 -> 26275 bytes .../adminfe/static/js/chunk-6e8c.46fda72d.js.map | Bin 0 -> 86864 bytes priv/static/adminfe/static/js/chunk-7503.ee7af549.js | Bin 0 -> 18559 bytes .../adminfe/static/js/chunk-7503.ee7af549.js.map | Bin 0 -> 62271 bytes priv/static/adminfe/static/js/chunk-7506.a3364e53.js | Bin 17041 -> 0 bytes .../adminfe/static/js/chunk-7506.a3364e53.js.map | Bin 58197 -> 0 bytes priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js | Bin 0 -> 8606 bytes .../adminfe/static/js/chunk-7c6b.7c4844a9.js.map | Bin 0 -> 28838 bytes priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js | Bin 8606 -> 0 bytes .../adminfe/static/js/chunk-7c6b.e63ae1da.js.map | Bin 28838 -> 0 bytes priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js | Bin 0 -> 26121 bytes .../adminfe/static/js/chunk-97e2.5baa6e73.js.map | Bin 0 -> 89970 bytes priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js | Bin 0 -> 5112 bytes .../adminfe/static/js/chunk-9a72.7b2fc06e.js.map | Bin 0 -> 19744 bytes priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js | Bin 26121 -> 0 bytes .../adminfe/static/js/chunk-c5f4.cf269f9b.js.map | Bin 89970 -> 0 bytes .../adminfe/static/js/chunk-commons.38728553.js | Bin 0 -> 9443 bytes .../adminfe/static/js/chunk-commons.38728553.js.map | Bin 0 -> 33718 bytes .../adminfe/static/js/chunk-commons.5a106955.js | Bin 9443 -> 0 bytes .../adminfe/static/js/chunk-commons.5a106955.js.map | Bin 33718 -> 0 bytes priv/static/adminfe/static/js/chunk-e404.554bc2e3.js | Bin 19723 -> 0 bytes .../adminfe/static/js/chunk-e404.554bc2e3.js.map | Bin 75596 -> 0 bytes .../adminfe/static/js/chunk-elementUI.2de79b84.js | Bin 0 -> 663883 bytes .../adminfe/static/js/chunk-elementUI.2de79b84.js.map | Bin 0 -> 2404935 bytes .../adminfe/static/js/chunk-elementUI.fba0efec.js | Bin 663883 -> 0 bytes .../adminfe/static/js/chunk-elementUI.fba0efec.js.map | Bin 2404935 -> 0 bytes priv/static/adminfe/static/js/chunk-libs.76802be9.js | Bin 0 -> 287147 bytes .../adminfe/static/js/chunk-libs.76802be9.js.map | Bin 0 -> 1691901 bytes priv/static/adminfe/static/js/chunk-libs.b8c453ab.js | Bin 275926 -> 0 bytes .../adminfe/static/js/chunk-libs.b8c453ab.js.map | Bin 1642077 -> 0 bytes priv/static/adminfe/static/js/runtime.0a70a9f5.js | Bin 4229 -> 0 bytes priv/static/adminfe/static/js/runtime.0a70a9f5.js.map | Bin 17240 -> 0 bytes priv/static/adminfe/static/js/runtime.ba9393f3.js | Bin 0 -> 4260 bytes priv/static/adminfe/static/js/runtime.ba9393f3.js.map | Bin 0 -> 17283 bytes 110 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 priv/static/adminfe/app.01bdb34a.css create mode 100644 priv/static/adminfe/app.61bb0915.css create mode 100644 priv/static/adminfe/chunk-0171.8dc0d9da.css delete mode 100644 priv/static/adminfe/chunk-070d.d2dd6533.css delete mode 100644 priv/static/adminfe/chunk-0cbc.60bba79b.css delete mode 100644 priv/static/adminfe/chunk-143c.43ada4fc.css delete mode 100644 priv/static/adminfe/chunk-1609.408dae86.css create mode 100644 priv/static/adminfe/chunk-176e.4d21033f.css delete mode 100644 priv/static/adminfe/chunk-176e.5d7d957b.css create mode 100644 priv/static/adminfe/chunk-2d97.7053ff89.css create mode 100644 priv/static/adminfe/chunk-40a4.2fe71f6c.css delete mode 100644 priv/static/adminfe/chunk-43ca.af749c6c.css delete mode 100644 priv/static/adminfe/chunk-4e7e.5afe1978.css create mode 100644 priv/static/adminfe/chunk-565e.33809ac8.css delete mode 100644 priv/static/adminfe/chunk-5882.f65db7f2.css create mode 100644 priv/static/adminfe/chunk-60a9.a80ec218.css create mode 100644 priv/static/adminfe/chunk-654e.e105ec9c.css create mode 100644 priv/static/adminfe/chunk-68ea.be16aa5f.css create mode 100644 priv/static/adminfe/chunk-6e81.7f126ac7.css delete mode 100644 priv/static/adminfe/chunk-6e81.ca3b222f.css create mode 100644 priv/static/adminfe/chunk-6e8c.f7407fd4.css create mode 100644 priv/static/adminfe/chunk-7503.c75b68df.css delete mode 100644 priv/static/adminfe/chunk-7506.f01f6c2a.css create mode 100644 priv/static/adminfe/chunk-7c6b.4c8fa90a.css delete mode 100644 priv/static/adminfe/chunk-7c6b.d9e7180a.css create mode 100644 priv/static/adminfe/chunk-97e2.b21a8915.css create mode 100644 priv/static/adminfe/chunk-9a72.3e577534.css delete mode 100644 priv/static/adminfe/chunk-c5f4.b1112f18.css create mode 100644 priv/static/adminfe/chunk-commons.67f053f7.css delete mode 100644 priv/static/adminfe/chunk-commons.7f6d2d11.css delete mode 100644 priv/static/adminfe/chunk-e404.a56021ae.css create mode 100644 priv/static/adminfe/chunk-libs.5cf7f50a.css delete mode 100644 priv/static/adminfe/chunk-libs.686b5876.css delete mode 100644 priv/static/adminfe/static/js/ZhIB.861df339.js delete mode 100644 priv/static/adminfe/static/js/ZhIB.861df339.js.map create mode 100644 priv/static/adminfe/static/js/app.86bfcdf3.js create mode 100644 priv/static/adminfe/static/js/app.86bfcdf3.js.map delete mode 100644 priv/static/adminfe/static/js/app.f220ac13.js delete mode 100644 priv/static/adminfe/static/js/app.f220ac13.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js create mode 100644 priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-070d.7e10a520.js delete mode 100644 priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js delete mode 100644 priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-143c.fc1825bf.js delete mode 100644 priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-1609.98da6b01.js delete mode 100644 priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-176e.c4995511.js delete mode 100644 priv/static/adminfe/static/js/chunk-176e.c4995511.js.map create mode 100644 priv/static/adminfe/static/js/chunk-176e.fe016b36.js create mode 100644 priv/static/adminfe/static/js/chunk-176e.fe016b36.js.map create mode 100644 priv/static/adminfe/static/js/chunk-2d97.931fa130.js create mode 100644 priv/static/adminfe/static/js/chunk-2d97.931fa130.js.map create mode 100644 priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js create mode 100644 priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-43ca.aceb457c.js delete mode 100644 priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js delete mode 100644 priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map create mode 100644 priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js create mode 100644 priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js delete mode 100644 priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js create mode 100644 priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js.map create mode 100644 priv/static/adminfe/static/js/chunk-654e.d523dfc3.js create mode 100644 priv/static/adminfe/static/js/chunk-654e.d523dfc3.js.map create mode 100644 priv/static/adminfe/static/js/chunk-68ea.a283cad8.js create mode 100644 priv/static/adminfe/static/js/chunk-68ea.a283cad8.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js delete mode 100644 priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map create mode 100644 priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js create mode 100644 priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js.map create mode 100644 priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js create mode 100644 priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7503.ee7af549.js create mode 100644 priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7506.a3364e53.js delete mode 100644 priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js create mode 100644 priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js delete mode 100644 priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map create mode 100644 priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js create mode 100644 priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js.map create mode 100644 priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js create mode 100644 priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js delete mode 100644 priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-commons.38728553.js create mode 100644 priv/static/adminfe/static/js/chunk-commons.38728553.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-commons.5a106955.js delete mode 100644 priv/static/adminfe/static/js/chunk-commons.5a106955.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-e404.554bc2e3.js delete mode 100644 priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js delete mode 100644 priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js.map create mode 100644 priv/static/adminfe/static/js/chunk-libs.76802be9.js create mode 100644 priv/static/adminfe/static/js/chunk-libs.76802be9.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-libs.b8c453ab.js delete mode 100644 priv/static/adminfe/static/js/chunk-libs.b8c453ab.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.0a70a9f5.js delete mode 100644 priv/static/adminfe/static/js/runtime.0a70a9f5.js.map create mode 100644 priv/static/adminfe/static/js/runtime.ba9393f3.js create mode 100644 priv/static/adminfe/static/js/runtime.ba9393f3.js.map diff --git a/priv/static/adminfe/app.01bdb34a.css b/priv/static/adminfe/app.01bdb34a.css deleted file mode 100644 index 1b83a8a39..000000000 Binary files a/priv/static/adminfe/app.01bdb34a.css and /dev/null differ diff --git a/priv/static/adminfe/app.61bb0915.css b/priv/static/adminfe/app.61bb0915.css new file mode 100644 index 000000000..9d74d13dc Binary files /dev/null and b/priv/static/adminfe/app.61bb0915.css differ diff --git a/priv/static/adminfe/chunk-0171.8dc0d9da.css b/priv/static/adminfe/chunk-0171.8dc0d9da.css new file mode 100644 index 000000000..824bddc85 Binary files /dev/null and b/priv/static/adminfe/chunk-0171.8dc0d9da.css differ diff --git a/priv/static/adminfe/chunk-070d.d2dd6533.css b/priv/static/adminfe/chunk-070d.d2dd6533.css deleted file mode 100644 index 30bf7de23..000000000 Binary files a/priv/static/adminfe/chunk-070d.d2dd6533.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-0cbc.60bba79b.css b/priv/static/adminfe/chunk-0cbc.60bba79b.css deleted file mode 100644 index c6280f7ef..000000000 Binary files a/priv/static/adminfe/chunk-0cbc.60bba79b.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-143c.43ada4fc.css b/priv/static/adminfe/chunk-143c.43ada4fc.css deleted file mode 100644 index b580e0699..000000000 Binary files a/priv/static/adminfe/chunk-143c.43ada4fc.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-1609.408dae86.css b/priv/static/adminfe/chunk-1609.408dae86.css deleted file mode 100644 index 483d88545..000000000 Binary files a/priv/static/adminfe/chunk-1609.408dae86.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-176e.4d21033f.css b/priv/static/adminfe/chunk-176e.4d21033f.css new file mode 100644 index 000000000..0bedf3773 Binary files /dev/null and b/priv/static/adminfe/chunk-176e.4d21033f.css differ diff --git a/priv/static/adminfe/chunk-176e.5d7d957b.css b/priv/static/adminfe/chunk-176e.5d7d957b.css deleted file mode 100644 index 0bedf3773..000000000 Binary files a/priv/static/adminfe/chunk-176e.5d7d957b.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-2d97.7053ff89.css b/priv/static/adminfe/chunk-2d97.7053ff89.css new file mode 100644 index 000000000..f6e28e1fb Binary files /dev/null and b/priv/static/adminfe/chunk-2d97.7053ff89.css differ diff --git a/priv/static/adminfe/chunk-40a4.2fe71f6c.css b/priv/static/adminfe/chunk-40a4.2fe71f6c.css new file mode 100644 index 000000000..83fefcb55 Binary files /dev/null and b/priv/static/adminfe/chunk-40a4.2fe71f6c.css differ diff --git a/priv/static/adminfe/chunk-43ca.af749c6c.css b/priv/static/adminfe/chunk-43ca.af749c6c.css deleted file mode 100644 index 504affb93..000000000 Binary files a/priv/static/adminfe/chunk-43ca.af749c6c.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-4e7e.5afe1978.css b/priv/static/adminfe/chunk-4e7e.5afe1978.css deleted file mode 100644 index c0074e6f7..000000000 Binary files a/priv/static/adminfe/chunk-4e7e.5afe1978.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-565e.33809ac8.css b/priv/static/adminfe/chunk-565e.33809ac8.css new file mode 100644 index 000000000..063b0b35d Binary files /dev/null and b/priv/static/adminfe/chunk-565e.33809ac8.css differ diff --git a/priv/static/adminfe/chunk-5882.f65db7f2.css b/priv/static/adminfe/chunk-5882.f65db7f2.css deleted file mode 100644 index b5e2a00b0..000000000 Binary files a/priv/static/adminfe/chunk-5882.f65db7f2.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-60a9.a80ec218.css b/priv/static/adminfe/chunk-60a9.a80ec218.css new file mode 100644 index 000000000..d45d79f4c Binary files /dev/null and b/priv/static/adminfe/chunk-60a9.a80ec218.css differ diff --git a/priv/static/adminfe/chunk-654e.e105ec9c.css b/priv/static/adminfe/chunk-654e.e105ec9c.css new file mode 100644 index 000000000..483d88545 Binary files /dev/null and b/priv/static/adminfe/chunk-654e.e105ec9c.css differ diff --git a/priv/static/adminfe/chunk-68ea.be16aa5f.css b/priv/static/adminfe/chunk-68ea.be16aa5f.css new file mode 100644 index 000000000..30bf7de23 Binary files /dev/null and b/priv/static/adminfe/chunk-68ea.be16aa5f.css differ diff --git a/priv/static/adminfe/chunk-6e81.7f126ac7.css b/priv/static/adminfe/chunk-6e81.7f126ac7.css new file mode 100644 index 000000000..da819ca09 Binary files /dev/null and b/priv/static/adminfe/chunk-6e81.7f126ac7.css differ diff --git a/priv/static/adminfe/chunk-6e81.ca3b222f.css b/priv/static/adminfe/chunk-6e81.ca3b222f.css deleted file mode 100644 index da819ca09..000000000 Binary files a/priv/static/adminfe/chunk-6e81.ca3b222f.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-6e8c.f7407fd4.css b/priv/static/adminfe/chunk-6e8c.f7407fd4.css new file mode 100644 index 000000000..6936755b9 Binary files /dev/null and b/priv/static/adminfe/chunk-6e8c.f7407fd4.css differ diff --git a/priv/static/adminfe/chunk-7503.c75b68df.css b/priv/static/adminfe/chunk-7503.c75b68df.css new file mode 100644 index 000000000..93d3eac84 Binary files /dev/null and b/priv/static/adminfe/chunk-7503.c75b68df.css differ diff --git a/priv/static/adminfe/chunk-7506.f01f6c2a.css b/priv/static/adminfe/chunk-7506.f01f6c2a.css deleted file mode 100644 index 93d3eac84..000000000 Binary files a/priv/static/adminfe/chunk-7506.f01f6c2a.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-7c6b.4c8fa90a.css b/priv/static/adminfe/chunk-7c6b.4c8fa90a.css new file mode 100644 index 000000000..9d730019a Binary files /dev/null and b/priv/static/adminfe/chunk-7c6b.4c8fa90a.css differ diff --git a/priv/static/adminfe/chunk-7c6b.d9e7180a.css b/priv/static/adminfe/chunk-7c6b.d9e7180a.css deleted file mode 100644 index 9d730019a..000000000 Binary files a/priv/static/adminfe/chunk-7c6b.d9e7180a.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-97e2.b21a8915.css b/priv/static/adminfe/chunk-97e2.b21a8915.css new file mode 100644 index 000000000..d3b7604aa Binary files /dev/null and b/priv/static/adminfe/chunk-97e2.b21a8915.css differ diff --git a/priv/static/adminfe/chunk-9a72.3e577534.css b/priv/static/adminfe/chunk-9a72.3e577534.css new file mode 100644 index 000000000..c0074e6f7 Binary files /dev/null and b/priv/static/adminfe/chunk-9a72.3e577534.css differ diff --git a/priv/static/adminfe/chunk-c5f4.b1112f18.css b/priv/static/adminfe/chunk-c5f4.b1112f18.css deleted file mode 100644 index d3b7604aa..000000000 Binary files a/priv/static/adminfe/chunk-c5f4.b1112f18.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-commons.67f053f7.css b/priv/static/adminfe/chunk-commons.67f053f7.css new file mode 100644 index 000000000..42f5e0ee9 Binary files /dev/null and b/priv/static/adminfe/chunk-commons.67f053f7.css differ diff --git a/priv/static/adminfe/chunk-commons.7f6d2d11.css b/priv/static/adminfe/chunk-commons.7f6d2d11.css deleted file mode 100644 index 42f5e0ee9..000000000 Binary files a/priv/static/adminfe/chunk-commons.7f6d2d11.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-e404.a56021ae.css b/priv/static/adminfe/chunk-e404.a56021ae.css deleted file mode 100644 index 7d8596ef6..000000000 Binary files a/priv/static/adminfe/chunk-e404.a56021ae.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-libs.5cf7f50a.css b/priv/static/adminfe/chunk-libs.5cf7f50a.css new file mode 100644 index 000000000..3a7a99679 Binary files /dev/null and b/priv/static/adminfe/chunk-libs.5cf7f50a.css differ diff --git a/priv/static/adminfe/chunk-libs.686b5876.css b/priv/static/adminfe/chunk-libs.686b5876.css deleted file mode 100644 index 3a7a99679..000000000 Binary files a/priv/static/adminfe/chunk-libs.686b5876.css and /dev/null differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 22b3143d2..5214cc94f 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/ZhIB.861df339.js b/priv/static/adminfe/static/js/ZhIB.861df339.js deleted file mode 100644 index aeec873c8..000000000 Binary files a/priv/static/adminfe/static/js/ZhIB.861df339.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/ZhIB.861df339.js.map b/priv/static/adminfe/static/js/ZhIB.861df339.js.map deleted file mode 100644 index ff11a2e71..000000000 Binary files a/priv/static/adminfe/static/js/ZhIB.861df339.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.86bfcdf3.js b/priv/static/adminfe/static/js/app.86bfcdf3.js new file mode 100644 index 000000000..083555948 Binary files /dev/null and b/priv/static/adminfe/static/js/app.86bfcdf3.js differ diff --git a/priv/static/adminfe/static/js/app.86bfcdf3.js.map b/priv/static/adminfe/static/js/app.86bfcdf3.js.map new file mode 100644 index 000000000..1e35d9d3d Binary files /dev/null and b/priv/static/adminfe/static/js/app.86bfcdf3.js.map differ diff --git a/priv/static/adminfe/static/js/app.f220ac13.js b/priv/static/adminfe/static/js/app.f220ac13.js deleted file mode 100644 index e5e1eda91..000000000 Binary files a/priv/static/adminfe/static/js/app.f220ac13.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.f220ac13.js.map b/priv/static/adminfe/static/js/app.f220ac13.js.map deleted file mode 100644 index 90c22121e..000000000 Binary files a/priv/static/adminfe/static/js/app.f220ac13.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js b/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js new file mode 100644 index 000000000..070fe2201 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js differ diff --git a/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map b/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map new file mode 100644 index 000000000..4696152ee Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-070d.7e10a520.js b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js deleted file mode 100644 index 8726dbcd3..000000000 Binary files a/priv/static/adminfe/static/js/chunk-070d.7e10a520.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map b/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map deleted file mode 100644 index 6b75a215e..000000000 Binary files a/priv/static/adminfe/static/js/chunk-070d.7e10a520.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js b/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js deleted file mode 100644 index d29070b62..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map b/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map deleted file mode 100644 index 7c99d9d48..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0cbc.2b0f8802.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js deleted file mode 100644 index 6fbc5b1ed..000000000 Binary files a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map b/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map deleted file mode 100644 index 425a7427a..000000000 Binary files a/priv/static/adminfe/static/js/chunk-143c.fc1825bf.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-1609.98da6b01.js b/priv/static/adminfe/static/js/chunk-1609.98da6b01.js deleted file mode 100644 index 29dbad261..000000000 Binary files a/priv/static/adminfe/static/js/chunk-1609.98da6b01.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map b/priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map deleted file mode 100644 index f287a503a..000000000 Binary files a/priv/static/adminfe/static/js/chunk-1609.98da6b01.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-176e.c4995511.js b/priv/static/adminfe/static/js/chunk-176e.c4995511.js deleted file mode 100644 index 80474b904..000000000 Binary files a/priv/static/adminfe/static/js/chunk-176e.c4995511.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map b/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map deleted file mode 100644 index f0caa5f62..000000000 Binary files a/priv/static/adminfe/static/js/chunk-176e.c4995511.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-176e.fe016b36.js b/priv/static/adminfe/static/js/chunk-176e.fe016b36.js new file mode 100644 index 000000000..eb57c5863 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-176e.fe016b36.js differ diff --git a/priv/static/adminfe/static/js/chunk-176e.fe016b36.js.map b/priv/static/adminfe/static/js/chunk-176e.fe016b36.js.map new file mode 100644 index 000000000..b3d84706b Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-176e.fe016b36.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-2d97.931fa130.js b/priv/static/adminfe/static/js/chunk-2d97.931fa130.js new file mode 100644 index 000000000..d5ba28881 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-2d97.931fa130.js differ diff --git a/priv/static/adminfe/static/js/chunk-2d97.931fa130.js.map b/priv/static/adminfe/static/js/chunk-2d97.931fa130.js.map new file mode 100644 index 000000000..69c447abc Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-2d97.931fa130.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js b/priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js new file mode 100644 index 000000000..7e3de73d2 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js differ diff --git a/priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js.map b/priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js.map new file mode 100644 index 000000000..935c150cc Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-40a4.e7e37fc4.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js b/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js deleted file mode 100644 index f9fcbc288..000000000 Binary files a/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map b/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map deleted file mode 100644 index 3c71ad178..000000000 Binary files a/priv/static/adminfe/static/js/chunk-43ca.aceb457c.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js deleted file mode 100644 index 0fdf0de50..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map b/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map deleted file mode 100644 index 7a6751cf8..000000000 Binary files a/priv/static/adminfe/static/js/chunk-4e7e.91b5e73a.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js b/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js new file mode 100644 index 000000000..b72017611 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js differ diff --git a/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map b/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map new file mode 100644 index 000000000..a2bc8a3cd Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js deleted file mode 100644 index a29b6daab..000000000 Binary files a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map b/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map deleted file mode 100644 index d1aa2037f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-5882.7cbc4c1b.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js b/priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js new file mode 100644 index 000000000..7b3e2e46c Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js differ diff --git a/priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js.map b/priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js.map new file mode 100644 index 000000000..a1bd1aa43 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-60a9.15f68a0f.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-654e.d523dfc3.js b/priv/static/adminfe/static/js/chunk-654e.d523dfc3.js new file mode 100644 index 000000000..44c2c61c8 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-654e.d523dfc3.js differ diff --git a/priv/static/adminfe/static/js/chunk-654e.d523dfc3.js.map b/priv/static/adminfe/static/js/chunk-654e.d523dfc3.js.map new file mode 100644 index 000000000..00f04d1d4 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-654e.d523dfc3.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-68ea.a283cad8.js b/priv/static/adminfe/static/js/chunk-68ea.a283cad8.js new file mode 100644 index 000000000..bb7cbff96 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-68ea.a283cad8.js differ diff --git a/priv/static/adminfe/static/js/chunk-68ea.a283cad8.js.map b/priv/static/adminfe/static/js/chunk-68ea.a283cad8.js.map new file mode 100644 index 000000000..201d8eaa9 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-68ea.a283cad8.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js deleted file mode 100644 index f40d31879..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map b/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map deleted file mode 100644 index 0390c3309..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6e81.6efb01f4.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js b/priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js new file mode 100644 index 000000000..32ede5eff Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js differ diff --git a/priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js.map b/priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js.map new file mode 100644 index 000000000..7301b6957 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e81.b4ee7cf5.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js b/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js new file mode 100644 index 000000000..f6175a4b5 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js differ diff --git a/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map b/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map new file mode 100644 index 000000000..159876ea9 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7503.ee7af549.js b/priv/static/adminfe/static/js/chunk-7503.ee7af549.js new file mode 100644 index 000000000..6126d904d Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7503.ee7af549.js differ diff --git a/priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map b/priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map new file mode 100644 index 000000000..cf893c61f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js deleted file mode 100644 index d4eaa356a..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map b/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map deleted file mode 100644 index c8e9db8e0..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7506.a3364e53.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js b/priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js new file mode 100644 index 000000000..a349860a8 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js.map b/priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js.map new file mode 100644 index 000000000..632e5750e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7c6b.7c4844a9.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js deleted file mode 100644 index 27478ddb1..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map b/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map deleted file mode 100644 index 2114a3c52..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7c6b.e63ae1da.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js b/priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js new file mode 100644 index 000000000..a3b706d5d Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js differ diff --git a/priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js.map b/priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js.map new file mode 100644 index 000000000..b7a392337 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-97e2.5baa6e73.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js b/priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js new file mode 100644 index 000000000..0dc8e9b68 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js differ diff --git a/priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js.map b/priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js.map new file mode 100644 index 000000000..c351b689e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-9a72.7b2fc06e.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js b/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js deleted file mode 100644 index 2d5308031..000000000 Binary files a/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map b/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map deleted file mode 100644 index d5fc047ee..000000000 Binary files a/priv/static/adminfe/static/js/chunk-c5f4.cf269f9b.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-commons.38728553.js b/priv/static/adminfe/static/js/chunk-commons.38728553.js new file mode 100644 index 000000000..0f2ffce9f Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-commons.38728553.js differ diff --git a/priv/static/adminfe/static/js/chunk-commons.38728553.js.map b/priv/static/adminfe/static/js/chunk-commons.38728553.js.map new file mode 100644 index 000000000..048f21e43 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-commons.38728553.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-commons.5a106955.js b/priv/static/adminfe/static/js/chunk-commons.5a106955.js deleted file mode 100644 index a6cf2ce52..000000000 Binary files a/priv/static/adminfe/static/js/chunk-commons.5a106955.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map b/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map deleted file mode 100644 index d924490e5..000000000 Binary files a/priv/static/adminfe/static/js/chunk-commons.5a106955.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js deleted file mode 100644 index 769e9f4f9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map b/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map deleted file mode 100644 index e8214adbb..000000000 Binary files a/priv/static/adminfe/static/js/chunk-e404.554bc2e3.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js b/priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js new file mode 100644 index 000000000..c76b0430b Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js differ diff --git a/priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js.map b/priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js.map new file mode 100644 index 000000000..fa9dc12f0 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-elementUI.2de79b84.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js b/priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js deleted file mode 100644 index f106931f5..000000000 Binary files a/priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js.map b/priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js.map deleted file mode 100644 index a1f726fde..000000000 Binary files a/priv/static/adminfe/static/js/chunk-elementUI.fba0efec.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-libs.76802be9.js b/priv/static/adminfe/static/js/chunk-libs.76802be9.js new file mode 100644 index 000000000..984b5ad40 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-libs.76802be9.js differ diff --git a/priv/static/adminfe/static/js/chunk-libs.76802be9.js.map b/priv/static/adminfe/static/js/chunk-libs.76802be9.js.map new file mode 100644 index 000000000..d4680796a Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-libs.76802be9.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-libs.b8c453ab.js b/priv/static/adminfe/static/js/chunk-libs.b8c453ab.js deleted file mode 100644 index 2af35eb62..000000000 Binary files a/priv/static/adminfe/static/js/chunk-libs.b8c453ab.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-libs.b8c453ab.js.map b/priv/static/adminfe/static/js/chunk-libs.b8c453ab.js.map deleted file mode 100644 index 9c9d9acde..000000000 Binary files a/priv/static/adminfe/static/js/chunk-libs.b8c453ab.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.0a70a9f5.js b/priv/static/adminfe/static/js/runtime.0a70a9f5.js deleted file mode 100644 index a99d1d369..000000000 Binary files a/priv/static/adminfe/static/js/runtime.0a70a9f5.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.0a70a9f5.js.map b/priv/static/adminfe/static/js/runtime.0a70a9f5.js.map deleted file mode 100644 index 62e726b22..000000000 Binary files a/priv/static/adminfe/static/js/runtime.0a70a9f5.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.ba9393f3.js b/priv/static/adminfe/static/js/runtime.ba9393f3.js new file mode 100644 index 000000000..c66462ab6 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.ba9393f3.js differ diff --git a/priv/static/adminfe/static/js/runtime.ba9393f3.js.map b/priv/static/adminfe/static/js/runtime.ba9393f3.js.map new file mode 100644 index 000000000..c167edf90 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.ba9393f3.js.map differ -- cgit v1.2.3 From 95529ab709b14acbf0b4ef2c17a76e0540e1e84e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Fri, 14 Aug 2020 20:55:45 +0300 Subject: [#2046] Defaulted pleroma/restrict_unauthenticated basing on instance privacy setting (i.e. restrict on private instances only by default). --- config/config.exs | 8 ++++--- lib/pleroma/config.ex | 10 ++++++++ lib/pleroma/user.ex | 8 ++++--- lib/pleroma/web/activity_pub/visibility.ex | 7 ++---- .../controllers/timeline_controller.ex | 5 ++-- lib/pleroma/web/preload/timelines.ex | 2 +- test/web/preload/timeline_test.exs | 28 ++++------------------ 7 files changed, 31 insertions(+), 37 deletions(-) diff --git a/config/config.exs b/config/config.exs index eb85a6ed4..a7c9e54b1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -725,10 +725,12 @@ timeout: 300_000 ] +private_instance? = :if_instance_is_private + config :pleroma, :restrict_unauthenticated, - timelines: %{local: false, federated: false}, - profiles: %{local: false, remote: false}, - activities: %{local: false, remote: false} + timelines: %{local: private_instance?, federated: private_instance?}, + profiles: %{local: private_instance?, remote: private_instance?}, + activities: %{local: private_instance?, remote: private_instance?} config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index a8329cc1e..97f877595 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -81,6 +81,16 @@ def delete(key) do Application.delete_env(:pleroma, key) end + def restrict_unauthenticated_access?(resource, kind) do + setting = get([:restrict_unauthenticated, resource, kind]) + + if setting in [nil, :if_instance_is_private] do + !get!([:instance, :public]) + else + setting + end + end + def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index d1436a688..ac065e9dc 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -311,10 +311,12 @@ def visible_for(%User{} = user, for_user) do def visible_for(_, _), do: :invisible - defp restrict_unauthenticated?(%User{local: local}) do - config_key = if local, do: :local, else: :remote + defp restrict_unauthenticated?(%User{local: true}) do + Config.restrict_unauthenticated_access?(:profiles, :local) + end - Config.get([:restrict_unauthenticated, :profiles, config_key], false) + defp restrict_unauthenticated?(%User{local: _}) do + Config.restrict_unauthenticated_access?(:profiles, :remote) end defp visible_account_status(user) do diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 343f41caa..5c349bb7a 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -59,12 +59,9 @@ def visible_for_user?(%{data: %{"listMessage" => list_ap_id}} = activity, %User{ end def visible_for_user?(%{local: local} = activity, nil) do - cfg_key = - if local, - do: :local, - else: :remote + cfg_key = if local, do: :local, else: :remote - if Pleroma.Config.get([:restrict_unauthenticated, :activities, cfg_key]), + if Pleroma.Config.restrict_unauthenticated_access?(:activities, cfg_key), do: false, else: is_public?(activity) end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index ab7b1d6aa..9244316ed 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, add_link_headers: 3] + alias Pleroma.Config alias Pleroma.Pagination alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.OAuthScopesPlug @@ -89,11 +90,11 @@ def direct(%{assigns: %{user: user}} = conn, params) do end defp restrict_unauthenticated?(true = _local_only) do - Pleroma.Config.get([:restrict_unauthenticated, :timelines, :local]) + Config.restrict_unauthenticated_access?(:timelines, :local) end defp restrict_unauthenticated?(_) do - Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated]) + Config.restrict_unauthenticated_access?(:timelines, :federated) end # GET /api/v1/timelines/public diff --git a/lib/pleroma/web/preload/timelines.ex b/lib/pleroma/web/preload/timelines.ex index 57de04051..b279a865d 100644 --- a/lib/pleroma/web/preload/timelines.ex +++ b/lib/pleroma/web/preload/timelines.ex @@ -16,7 +16,7 @@ def generate_terms(params) do end def build_public_tag(acc, params) do - if Pleroma.Config.get([:restrict_unauthenticated, :timelines, :federated], true) do + if Pleroma.Config.restrict_unauthenticated_access?(:timelines, :federated) do acc else Map.put(acc, @public_url, public_timeline(params)) diff --git a/test/web/preload/timeline_test.exs b/test/web/preload/timeline_test.exs index fea95a6a4..3b1f2f1aa 100644 --- a/test/web/preload/timeline_test.exs +++ b/test/web/preload/timeline_test.exs @@ -12,16 +12,8 @@ defmodule Pleroma.Web.Preload.Providers.TimelineTest do @public_url "/api/v1/timelines/public" describe "unauthenticated timeliness when restricted" do - setup do - svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) - Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{local: true, federated: true}) - - on_exit(fn -> - Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) - end) - - :ok - end + setup do: clear_config([:restrict_unauthenticated, :timelines, :local], true) + setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], true) test "return nothing" do tl_data = Timelines.generate_terms(%{}) @@ -31,20 +23,10 @@ test "return nothing" do end describe "unauthenticated timeliness when unrestricted" do - setup do - svd_config = Pleroma.Config.get([:restrict_unauthenticated, :timelines]) + setup do: clear_config([:restrict_unauthenticated, :timelines, :local], false) + setup do: clear_config([:restrict_unauthenticated, :timelines, :federated], false) - Pleroma.Config.put([:restrict_unauthenticated, :timelines], %{ - local: false, - federated: false - }) - - on_exit(fn -> - Pleroma.Config.put([:restrict_unauthenticated, :timelines], svd_config) - end) - - {:ok, user: insert(:user)} - end + setup do: {:ok, user: insert(:user)} test "returns the timeline when not restricted" do assert Timelines.generate_terms(%{}) -- cgit v1.2.3 From 6c3130ef47562496e9b2605bc05cae90cd6af03b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 14 Aug 2020 13:07:58 -0500 Subject: Improve description for mediaproxy cache invalidation settings --- config/description.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index 7734ff7a1..e27abf40f 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1810,12 +1810,12 @@ %{ key: :enabled, type: :boolean, - description: "Enables invalidate media cache" + description: "Enables media cache object invalidation." }, %{ key: :provider, type: :module, - description: "Module which will be used to cache purge.", + description: "Module which will be used to purge objects from the cache.", suggestions: [ Pleroma.Web.MediaProxy.Invalidation.Script, Pleroma.Web.MediaProxy.Invalidation.Http -- cgit v1.2.3 From 4fcf272717bf2d8f582720de69fa9e50cab1b66a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 15 Aug 2020 09:49:12 +0300 Subject: Docs: Fix the way tabs are declared Since python doesn't have a way to lock deps for a particlar project by default, I didn't bother with it. This resulted in mkdocs updating at some point, bringing a breaking change to how tabs are declared and broken tabs on docs-develop.pleroma.social. I've learned my lesson and locked deps with pipenv in pleroma/docs!5. This MR updates Pleroma docs to use the new tab style, fortunately my editor did most of it. Closes #2045 --- docs/administration/CLI_tasks/config.md | 29 ++- docs/administration/CLI_tasks/database.md | 129 ++++++---- docs/administration/CLI_tasks/digest.md | 32 ++- docs/administration/CLI_tasks/email.md | 32 ++- docs/administration/CLI_tasks/emoji.md | 49 ++-- docs/administration/CLI_tasks/instance.md | 16 +- docs/administration/CLI_tasks/oauth_app.md | 16 +- docs/administration/CLI_tasks/relay.md | 48 ++-- docs/administration/CLI_tasks/robots_txt.md | 16 +- docs/administration/CLI_tasks/uploads.md | 16 +- docs/administration/CLI_tasks/user.md | 288 ++++++++++++++-------- docs/configuration/static_dir.md | 20 +- docs/installation/migrating_from_source_otp_en.md | 48 ++-- docs/installation/otp_en.md | 200 ++++++++------- 14 files changed, 567 insertions(+), 372 deletions(-) diff --git a/docs/administration/CLI_tasks/config.md b/docs/administration/CLI_tasks/config.md index cc32bf859..0923004b5 100644 --- a/docs/administration/CLI_tasks/config.md +++ b/docs/administration/CLI_tasks/config.md @@ -11,14 +11,17 @@ config :pleroma, configurable_from_database: true ``` -```sh tab="OTP" - ./bin/pleroma_ctl config migrate_to_db -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.config migrate_to_db -``` + ```sh + ./bin/pleroma_ctl config migrate_to_db + ``` + +=== "From Source" + ```sh + mix pleroma.config migrate_to_db + ``` ## Transfer config from DB to `config/env.exported_from_db.secret.exs` @@ -31,10 +34,12 @@ mix pleroma.config migrate_to_db To delete transfered settings from database optional flag `-d` can be used. `` is `prod` by default. -```sh tab="OTP" - ./bin/pleroma_ctl config migrate_from_db [--env=] [-d] -``` +=== "OTP" + ```sh + ./bin/pleroma_ctl config migrate_from_db [--env=] [-d] + ``` -```sh tab="From Source" -mix pleroma.config migrate_from_db [--env=] [-d] -``` +=== "From Source" + ```sh + mix pleroma.config migrate_from_db [--env=] [-d] + ``` diff --git a/docs/administration/CLI_tasks/database.md b/docs/administration/CLI_tasks/database.md index 64dd66c0c..6dca83167 100644 --- a/docs/administration/CLI_tasks/database.md +++ b/docs/administration/CLI_tasks/database.md @@ -9,13 +9,18 @@ Replaces embedded objects with references to them in the `objects` table. Only needs to be ran once if the instance was created before Pleroma 1.0.5. The reason why this is not a migration is because it could significantly increase the database size after being ran, however after this `VACUUM FULL` will be able to reclaim about 20% (really depends on what is in the database, your mileage may vary) of the db size before the migration. -```sh tab="OTP" -./bin/pleroma_ctl database remove_embedded_objects [option ...] -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl database remove_embedded_objects [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.database remove_embedded_objects [option ...] + ``` -```sh tab="From Source" -mix pleroma.database remove_embedded_objects [option ...] -``` ### Options - `--vacuum` - run `VACUUM FULL` after the embedded objects are replaced with their references @@ -27,13 +32,17 @@ This will prune remote posts older than 90 days (configurable with [`config :ple !!! danger The disk space will only be reclaimed after `VACUUM FULL`. You may run out of disk space during the execution of the task or vacuuming if you don't have about 1/3rds of the database size free. -```sh tab="OTP" -./bin/pleroma_ctl database prune_objects [option ...] -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl database prune_objects [option ...] + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.database prune_objects [option ...] -``` + ```sh + mix pleroma.database prune_objects [option ...] + ``` ### Options - `--vacuum` - run `VACUUM FULL` after the objects are pruned @@ -42,33 +51,45 @@ mix pleroma.database prune_objects [option ...] Can be safely re-run -```sh tab="OTP" -./bin/pleroma_ctl database bump_all_conversations -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.database bump_all_conversations -``` + ```sh + ./bin/pleroma_ctl database bump_all_conversations + ``` + +=== "From Source" + + ```sh + mix pleroma.database bump_all_conversations + ``` ## Remove duplicated items from following and update followers count for all users -```sh tab="OTP" -./bin/pleroma_ctl database update_users_following_followers_counts -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl database update_users_following_followers_counts + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.database update_users_following_followers_counts -``` + ```sh + mix pleroma.database update_users_following_followers_counts + ``` ## Fix the pre-existing "likes" collections for all objects -```sh tab="OTP" -./bin/pleroma_ctl database fix_likes_collections -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.database fix_likes_collections -``` + ```sh + ./bin/pleroma_ctl database fix_likes_collections + ``` + +=== "From Source" + + ```sh + mix pleroma.database fix_likes_collections + ``` ## Vacuum the database @@ -76,13 +97,17 @@ mix pleroma.database fix_likes_collections Running an `analyze` vacuum job can improve performance by updating statistics used by the query planner. **It is safe to cancel this.** -```sh tab="OTP" -./bin/pleroma_ctl database vacuum analyze -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl database vacuum analyze + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.database vacuum analyze -``` + ```sh + mix pleroma.database vacuum analyze + ``` ### Full @@ -91,20 +116,28 @@ and more compact files with an optimized layout. This process will take a long t it builds the files side-by-side the existing database files. It can make your database faster and use less disk space, but should only be run if necessary. **It is safe to cancel this.** -```sh tab="OTP" -./bin/pleroma_ctl database vacuum full -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.database vacuum full -``` + ```sh + ./bin/pleroma_ctl database vacuum full + ``` + +=== "From Source" + + ```sh + mix pleroma.database vacuum full + ``` ## Add expiration to all local statuses -```sh tab="OTP" -./bin/pleroma_ctl database ensure_expiration -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl database ensure_expiration + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.database ensure_expiration -``` + ```sh + mix pleroma.database ensure_expiration + ``` diff --git a/docs/administration/CLI_tasks/digest.md b/docs/administration/CLI_tasks/digest.md index 2eb31379e..a590581e3 100644 --- a/docs/administration/CLI_tasks/digest.md +++ b/docs/administration/CLI_tasks/digest.md @@ -4,22 +4,30 @@ ## Send digest email since given date (user registration date by default) ignoring user activity status. -```sh tab="OTP" - ./bin/pleroma_ctl digest test [since_date] -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.digest test [since_date] -``` + ```sh + ./bin/pleroma_ctl digest test [since_date] + ``` + +=== "From Source" + + ```sh + mix pleroma.digest test [since_date] + ``` Example: -```sh tab="OTP" -./bin/pleroma_ctl digest test donaldtheduck 2019-05-20 -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl digest test donaldtheduck 2019-05-20 + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.digest test donaldtheduck 2019-05-20 -``` + ```sh + mix pleroma.digest test donaldtheduck 2019-05-20 + ``` diff --git a/docs/administration/CLI_tasks/email.md b/docs/administration/CLI_tasks/email.md index 7b7a8457a..00d2e74f8 100644 --- a/docs/administration/CLI_tasks/email.md +++ b/docs/administration/CLI_tasks/email.md @@ -4,21 +4,29 @@ ## Send test email (instance email by default) -```sh tab="OTP" - ./bin/pleroma_ctl email test [--to ] -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.email test [--to ] -``` + ```sh + ./bin/pleroma_ctl email test [--to ] + ``` + +=== "From Source" + + ```sh + mix pleroma.email test [--to ] + ``` Example: -```sh tab="OTP" -./bin/pleroma_ctl email test --to root@example.org -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl email test --to root@example.org + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.email test --to root@example.org -``` + ```sh + mix pleroma.email test --to root@example.org + ``` diff --git a/docs/administration/CLI_tasks/emoji.md b/docs/administration/CLI_tasks/emoji.md index ddcb7e62c..e3d1b210e 100644 --- a/docs/administration/CLI_tasks/emoji.md +++ b/docs/administration/CLI_tasks/emoji.md @@ -4,13 +4,15 @@ ## Lists emoji packs and metadata specified in the manifest -```sh tab="OTP" -./bin/pleroma_ctl emoji ls-packs [option ...] -``` +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji ls-packs [option ...] + ``` -```sh tab="From Source" -mix pleroma.emoji ls-packs [option ...] -``` +=== "From Source" + ```sh + mix pleroma.emoji ls-packs [option ...] + ``` ### Options @@ -18,26 +20,30 @@ mix pleroma.emoji ls-packs [option ...] ## Fetch, verify and install the specified packs from the manifest into `STATIC-DIR/emoji/PACK-NAME` -```sh tab="OTP" -./bin/pleroma_ctl emoji get-packs [option ...] -``` +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji get-packs [option ...] + ``` -```sh tab="From Source" -mix pleroma.emoji get-packs [option ...] -``` +=== "From Source" + ```sh + mix pleroma.emoji get-packs [option ...] + ``` ### Options - `-m, --manifest PATH/URL` - same as [`ls-packs`](#ls-packs) ## Create a new manifest entry and a file list from the specified remote pack file -```sh tab="OTP" -./bin/pleroma_ctl emoji gen-pack PACK-URL -``` +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji gen-pack PACK-URL + ``` -```sh tab="From Source" -mix pleroma.emoji gen-pack PACK-URL -``` +=== "From Source" + ```sh + 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. @@ -47,8 +53,9 @@ Currently, only .zip archives are recognized as remote pack files and packs are ## Reload emoji packs -```sh tab="OTP" -./bin/pleroma_ctl emoji reload -``` +=== "OTP" + ```sh + ./bin/pleroma_ctl emoji reload + ``` This command only works with OTP releases. diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md index 52e264bb1..989ecc55d 100644 --- a/docs/administration/CLI_tasks/instance.md +++ b/docs/administration/CLI_tasks/instance.md @@ -3,13 +3,17 @@ {! backend/administration/CLI_tasks/general_cli_task_info.include !} ## Generate a new configuration file -```sh tab="OTP" - ./bin/pleroma_ctl instance gen [option ...] -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.instance gen [option ...] -``` + ```sh + ./bin/pleroma_ctl instance gen [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.instance gen [option ...] + ``` If any of the options are left unspecified, you will be prompted interactively. diff --git a/docs/administration/CLI_tasks/oauth_app.md b/docs/administration/CLI_tasks/oauth_app.md index 4d6bfc25a..f0568491e 100644 --- a/docs/administration/CLI_tasks/oauth_app.md +++ b/docs/administration/CLI_tasks/oauth_app.md @@ -7,10 +7,14 @@ Optional params: * `-s SCOPES` - scopes for app, e.g. `read,write,follow,push`. -```sh tab="OTP" - ./bin/pleroma_ctl app create -n APP_NAME -r REDIRECT_URI -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.app create -n APP_NAME -r REDIRECT_URI -``` \ No newline at end of file + ```sh + ./bin/pleroma_ctl app create -n APP_NAME -r REDIRECT_URI + ``` + +=== "From Source" + + ```sh + mix pleroma.app create -n APP_NAME -r REDIRECT_URI + ``` \ No newline at end of file diff --git a/docs/administration/CLI_tasks/relay.md b/docs/administration/CLI_tasks/relay.md index c4f078f4d..bdd7e8be4 100644 --- a/docs/administration/CLI_tasks/relay.md +++ b/docs/administration/CLI_tasks/relay.md @@ -4,30 +4,42 @@ ## Follow a relay -```sh tab="OTP" -./bin/pleroma_ctl relay follow -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.relay follow -``` + ```sh + ./bin/pleroma_ctl relay follow + ``` + +=== "From Source" + + ```sh + mix pleroma.relay follow + ``` ## Unfollow a remote relay -```sh tab="OTP" -./bin/pleroma_ctl relay unfollow -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl relay unfollow + ``` -```sh tab="From Source" -mix pleroma.relay unfollow -``` +=== "From Source" + + ```sh + mix pleroma.relay unfollow + ``` ## List relay subscriptions -```sh tab="OTP" -./bin/pleroma_ctl relay list -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl relay list + ``` + +=== "From Source" -```sh tab="From Source" -mix pleroma.relay list -``` + ```sh + mix pleroma.relay list + ``` diff --git a/docs/administration/CLI_tasks/robots_txt.md b/docs/administration/CLI_tasks/robots_txt.md index 844318cc8..7eeedf571 100644 --- a/docs/administration/CLI_tasks/robots_txt.md +++ b/docs/administration/CLI_tasks/robots_txt.md @@ -8,10 +8,14 @@ The `robots.txt` that ships by default is permissive. It allows well-behaved sea If you want to generate a restrictive `robots.txt`, you can run the following mix task. The generated `robots.txt` will be written in your instance [static directory](../../../configuration/static_dir/). -```elixir tab="OTP" -./bin/pleroma_ctl robots_txt disallow_all -``` +=== "OTP" -```elixir tab="From Source" -mix pleroma.robots_txt disallow_all -``` + ```sh + ./bin/pleroma_ctl robots_txt disallow_all + ``` + +=== "From Source" + + ```sh + mix pleroma.robots_txt disallow_all + ``` diff --git a/docs/administration/CLI_tasks/uploads.md b/docs/administration/CLI_tasks/uploads.md index 6a15d22f6..8585ec76b 100644 --- a/docs/administration/CLI_tasks/uploads.md +++ b/docs/administration/CLI_tasks/uploads.md @@ -3,13 +3,17 @@ {! backend/administration/CLI_tasks/general_cli_task_info.include !} ## Migrate uploads from local to remote storage -```sh tab="OTP" - ./bin/pleroma_ctl uploads migrate_local [option ...] -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.uploads migrate_local [option ...] -``` + ```sh + ./bin/pleroma_ctl uploads migrate_local [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.uploads migrate_local [option ...] + ``` ### Options - `--delete` - delete local uploads after migrating them to the target uploader diff --git a/docs/administration/CLI_tasks/user.md b/docs/administration/CLI_tasks/user.md index 3b4c421a7..3e7f028ba 100644 --- a/docs/administration/CLI_tasks/user.md +++ b/docs/administration/CLI_tasks/user.md @@ -4,13 +4,17 @@ ## Create a user -```sh tab="OTP" -./bin/pleroma_ctl user new [option ...] -``` +=== "OTP" -```sh tab="From Source" -mix pleroma.user new [option ...] -``` + ```sh + ./bin/pleroma_ctl user new [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user new [option ...] + ``` ### Options @@ -22,23 +26,33 @@ mix pleroma.user new [option ...] - `-y`, `--assume-yes`/`--no-assume-yes` - whether to assume yes to all questions ## List local users -```sh tab="OTP" - ./bin/pleroma_ctl user list -``` -```sh tab="From Source" -mix pleroma.user list -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user list + ``` + +=== "From Source" + + ```sh + mix pleroma.user list + ``` ## Generate an invite link -```sh tab="OTP" - ./bin/pleroma_ctl user invite [option ...] -``` -```sh tab="From Source" -mix pleroma.user invite [option ...] -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user invite [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user invite [option ...] + ``` ### Options @@ -46,113 +60,168 @@ mix pleroma.user invite [option ...] - `--max-use NUMBER` - maximum numbers of token uses ## List generated invites -```sh tab="OTP" - ./bin/pleroma_ctl user invites -``` -```sh tab="From Source" -mix pleroma.user invites -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user invites + ``` + +=== "From Source" + + ```sh + mix pleroma.user invites + ``` ## Revoke invite -```sh tab="OTP" - ./bin/pleroma_ctl user revoke_invite -``` -```sh tab="From Source" -mix pleroma.user revoke_invite -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user revoke_invite + ``` + +=== "From Source" + + ```sh + mix pleroma.user revoke_invite + ``` ## Delete a user -```sh tab="OTP" - ./bin/pleroma_ctl user rm -``` -```sh tab="From Source" -mix pleroma.user rm -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user rm + ``` + +=== "From Source" + + ```sh + mix pleroma.user rm + ``` ## Delete user's posts and interactions -```sh tab="OTP" - ./bin/pleroma_ctl user delete_activities -``` -```sh tab="From Source" -mix pleroma.user delete_activities -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user delete_activities + ``` + +=== "From Source" + + ```sh + mix pleroma.user delete_activities + ``` ## Sign user out from all applications (delete user's OAuth tokens and authorizations) -```sh tab="OTP" - ./bin/pleroma_ctl user sign_out -``` -```sh tab="From Source" -mix pleroma.user sign_out -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user sign_out + ``` + +=== "From Source" + + ```sh + mix pleroma.user sign_out + ``` ## Deactivate or activate a user -```sh tab="OTP" - ./bin/pleroma_ctl user toggle_activated -``` -```sh tab="From Source" -mix pleroma.user toggle_activated -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user toggle_activated + ``` + +=== "From Source" + + ```sh + mix pleroma.user toggle_activated + ``` ## Deactivate a user and unsubscribes local users from the user -```sh tab="OTP" - ./bin/pleroma_ctl user deactivate NICKNAME -``` -```sh tab="From Source" -mix pleroma.user deactivate NICKNAME -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user deactivate NICKNAME + ``` + +=== "From Source" + + ```sh + mix pleroma.user deactivate NICKNAME + ``` ## Deactivate all accounts from an instance and unsubscribe local users on it -```sh tab="OTP" - ./bin/pleroma_ctl user deactivate_all_from_instance -``` -```sh tab="From Source" -mix pleroma.user deactivate_all_from_instance -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user deactivate_all_from_instance + ``` + +=== "From Source" + + ```sh + mix pleroma.user deactivate_all_from_instance + ``` ## Create a password reset link for user -```sh tab="OTP" - ./bin/pleroma_ctl user reset_password -``` -```sh tab="From Source" -mix pleroma.user reset_password -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user reset_password + ``` + +=== "From Source" + + ```sh + mix pleroma.user reset_password + ``` ## Disable Multi Factor Authentication (MFA/2FA) for a user -```sh tab="OTP" - ./bin/pleroma_ctl user reset_mfa -``` -```sh tab="From Source" -mix pleroma.user reset_mfa -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user reset_mfa + ``` + +=== "From Source" + + ```sh + mix pleroma.user reset_mfa + ``` ## Set the value of the given user's settings -```sh tab="OTP" - ./bin/pleroma_ctl user set [option ...] -``` -```sh tab="From Source" -mix pleroma.user set [option ...] -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user set [option ...] + ``` + +=== "From Source" + + ```sh + mix pleroma.user set [option ...] + ``` ### Options - `--locked`/`--no-locked` - whether the user should be locked @@ -160,30 +229,45 @@ mix pleroma.user set [option ...] - `--admin`/`--no-admin` - whether the user should be an admin ## Add tags to a user -```sh tab="OTP" - ./bin/pleroma_ctl user tag -``` -```sh tab="From Source" -mix pleroma.user tag -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user tag + ``` + +=== "From Source" + + ```sh + mix pleroma.user tag + ``` ## Delete tags from a user -```sh tab="OTP" - ./bin/pleroma_ctl user untag -``` -```sh tab="From Source" -mix pleroma.user untag -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user untag + ``` + +=== "From Source" + + ```sh + mix pleroma.user untag + ``` ## Toggle confirmation status of the user -```sh tab="OTP" - ./bin/pleroma_ctl user toggle_confirmed -``` -```sh tab="From Source" -mix pleroma.user toggle_confirmed -``` +=== "OTP" + + ```sh + ./bin/pleroma_ctl user toggle_confirmed + ``` + +=== "From Source" + + ```sh + mix pleroma.user toggle_confirmed + ``` diff --git a/docs/configuration/static_dir.md b/docs/configuration/static_dir.md index 58703e3be..8ac07b725 100644 --- a/docs/configuration/static_dir.md +++ b/docs/configuration/static_dir.md @@ -4,15 +4,19 @@ Static frontend files are shipped with pleroma. If you want to overwrite or upda You can find the location of the static directory in the [configuration](../cheatsheet/#instance). -```elixir tab="OTP" -config :pleroma, :instance, - static_dir: "/var/lib/pleroma/static/", -``` +=== "OTP" -```elixir tab="From Source" -config :pleroma, :instance, - static_dir: "instance/static/", -``` + ```elixir + config :pleroma, :instance, + static_dir: "/var/lib/pleroma/static/" + ``` + +=== "From Source" + + ```elixir + config :pleroma, :instance, + static_dir: "instance/static/" + ``` Alternatively, you can overwrite this value in your configuration to use a different static instance directory. diff --git a/docs/installation/migrating_from_source_otp_en.md b/docs/installation/migrating_from_source_otp_en.md index 31c2f1294..d303a6daf 100644 --- a/docs/installation/migrating_from_source_otp_en.md +++ b/docs/installation/migrating_from_source_otp_en.md @@ -8,13 +8,15 @@ You will be running commands as root. If you aren't root already, please elevate The system needs to have `curl` and `unzip` installed for downloading and unpacking release builds. -```sh tab="Alpine" -apk add curl unzip -``` +=== "Alpine" + ```sh + apk add curl unzip + ``` -```sh tab="Debian/Ubuntu" -apt install curl unzip -``` +=== "Debian/Ubuntu" + ```sh + apt install curl unzip + ``` ## Moving content out of the application directory When using OTP releases the application directory changes with every version so it would be a bother to keep content there (and also dangerous unless `--no-rm` option is used when updating). Fortunately almost all paths in Pleroma are configurable, so it is possible to move them out of there. @@ -110,27 +112,29 @@ OTP releases have different service files than from-source installs so they need **Warning:** The service files assume pleroma user's home directory is `/opt/pleroma`, please make sure all paths fit your installation. -```sh tab="Alpine" -# Copy the service into a proper directory -cp -f ~pleroma/installation/init.d/pleroma /etc/init.d/pleroma +=== "Alpine" + ```sh + # Copy the service into a proper directory + cp -f ~pleroma/installation/init.d/pleroma /etc/init.d/pleroma -# Start pleroma -rc-service pleroma start -``` + # Start pleroma + rc-service pleroma start + ``` -```sh tab="Debian/Ubuntu" -# Copy the service into a proper directory -cp ~pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service +=== "Debian/Ubuntu" + ```sh + # Copy the service into a proper directory + cp ~pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service -# Reload service files -systemctl daemon-reload + # Reload service files + systemctl daemon-reload -# Reenable pleroma to start on boot -systemctl reenable pleroma + # Reenable pleroma to start on boot + systemctl reenable pleroma -# Start pleroma -systemctl start pleroma -``` + # Start pleroma + systemctl start pleroma + ``` ## Running mix tasks Refer to [Running mix tasks](otp_en.md#running-mix-tasks) section from OTP release installation guide. diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index e4f822d1c..b7e3bb2ac 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -28,15 +28,17 @@ Other than things bundled in the OTP release Pleroma depends on: * nginx (could be swapped with another reverse proxy but this guide covers only it) * certbot (for Let's Encrypt certificates, could be swapped with another ACME client, but this guide covers only it) -```sh tab="Alpine" -echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories -apk update -apk add curl unzip ncurses postgresql postgresql-contrib nginx certbot -``` - -```sh tab="Debian/Ubuntu" -apt install curl unzip libncurses5 postgresql postgresql-contrib nginx certbot -``` +=== "Alpine" + ``` + echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories + apk update + apk add curl unzip ncurses postgresql postgresql-contrib nginx certbot + ``` + +=== "Debian/Ubuntu" + ``` + apt install curl unzip libncurses5 postgresql postgresql-contrib nginx certbot + ``` ## Setup ### Configuring PostgreSQL @@ -47,31 +49,35 @@ apt install curl unzip libncurses5 postgresql postgresql-contrib nginx certbot RUM indexes are an alternative indexing scheme that is not included in PostgreSQL by default. You can read more about them on the [Configuration page](../configuration/cheatsheet.md#rum-indexing-for-full-text-search). They are completely optional and most of the time are not worth it, especially if you are running a single user instance (unless you absolutely need ordered search results). -```sh tab="Alpine" -apk add git build-base postgresql-dev -git clone https://github.com/postgrespro/rum /tmp/rum -cd /tmp/rum -make USE_PGXS=1 -make USE_PGXS=1 install -cd -rm -r /tmp/rum -``` - -```sh tab="Debian/Ubuntu" -# Available only on Buster/19.04 -apt install postgresql-11-rum -``` +=== "Alpine" + ``` + apk add git build-base postgresql-dev + git clone https://github.com/postgrespro/rum /tmp/rum + cd /tmp/rum + make USE_PGXS=1 + make USE_PGXS=1 install + cd + rm -r /tmp/rum + ``` + +=== "Debian/Ubuntu" + ``` + # Available only on Buster/19.04 + apt install postgresql-11-rum + ``` #### (Optional) Performance configuration It is encouraged to check [Optimizing your PostgreSQL performance](../configuration/postgresql.md) document, for tips on PostgreSQL tuning. -```sh tab="Alpine" -rc-service postgresql restart -``` +=== "Alpine" + ``` + rc-service postgresql restart + ``` -```sh tab="Debian/Ubuntu" -systemctl restart postgresql -``` +=== "Debian/Ubuntu" + ``` + systemctl restart postgresql + ``` If you are using PostgreSQL 12 or higher, add this to your Ecto database configuration @@ -151,14 +157,16 @@ certbot certonly --standalone --preferred-challenges http -d yourinstance.tld The location of nginx configs is dependent on the distro -```sh tab="Alpine" -cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/conf.d/pleroma.conf -``` +=== "Alpine" + ``` + cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/conf.d/pleroma.conf + ``` -```sh tab="Debian/Ubuntu" -cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.conf -ln -s /etc/nginx/sites-available/pleroma.conf /etc/nginx/sites-enabled/pleroma.conf -``` +=== "Debian/Ubuntu" + ``` + cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.conf + ln -s /etc/nginx/sites-available/pleroma.conf /etc/nginx/sites-enabled/pleroma.conf + ``` If your distro does not have either of those you can append `include /etc/nginx/pleroma.conf` to the end of the http section in /etc/nginx/nginx.conf and ```sh @@ -175,35 +183,39 @@ nginx -t ``` #### Start nginx -```sh tab="Alpine" -rc-service nginx start -``` +=== "Alpine" + ``` + rc-service nginx start + ``` -```sh tab="Debian/Ubuntu" -systemctl start nginx -``` +=== "Debian/Ubuntu" + ``` + systemctl start nginx + ``` At this point if you open your (sub)domain in a browser you should see a 502 error, that's because Pleroma is not started yet. ### Setting up a system service -```sh tab="Alpine" -# Copy the service into a proper directory -cp /opt/pleroma/installation/init.d/pleroma /etc/init.d/pleroma +=== "Alpine" + ``` + # Copy the service into a proper directory + cp /opt/pleroma/installation/init.d/pleroma /etc/init.d/pleroma -# Start pleroma and enable it on boot -rc-service pleroma start -rc-update add pleroma -``` + # Start pleroma and enable it on boot + rc-service pleroma start + rc-update add pleroma + ``` -```sh tab="Debian/Ubuntu" -# Copy the service into a proper directory -cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service +=== "Debian/Ubuntu" + ``` + # Copy the service into a proper directory + cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service -# Start pleroma and enable it on boot -systemctl start pleroma -systemctl enable pleroma -``` + # Start pleroma and enable it on boot + systemctl start pleroma + systemctl enable pleroma + ``` If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errrors. @@ -223,43 +235,45 @@ $EDITOR path-to-nginx-config nginx -t ``` -```sh tab="Alpine" -# Restart nginx -rc-service nginx restart - -# Start the cron daemon and make it start on boot -rc-service crond start -rc-update add crond - -# Ensure the webroot menthod and post hook is working -certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --dry-run --post-hook 'rc-service nginx reload' - -# Add it to the daily cron -echo '#!/bin/sh -certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --post-hook "rc-service nginx reload" -' > /etc/periodic/daily/renew-pleroma-cert -chmod +x /etc/periodic/daily/renew-pleroma-cert - -# If everything worked the output should contain /etc/cron.daily/renew-pleroma-cert -run-parts --test /etc/periodic/daily -``` - -```sh tab="Debian/Ubuntu" -# Restart nginx -systemctl restart nginx - -# Ensure the webroot menthod and post hook is working -certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --dry-run --post-hook 'systemctl reload nginx' - -# Add it to the daily cron -echo '#!/bin/sh -certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --post-hook "systemctl reload nginx" -' > /etc/cron.daily/renew-pleroma-cert -chmod +x /etc/cron.daily/renew-pleroma-cert - -# If everything worked the output should contain /etc/cron.daily/renew-pleroma-cert -run-parts --test /etc/cron.daily -``` +=== "Alpine" + ``` + # Restart nginx + rc-service nginx restart + + # Start the cron daemon and make it start on boot + rc-service crond start + rc-update add crond + + # Ensure the webroot menthod and post hook is working + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --dry-run --post-hook 'rc-service nginx reload' + + # Add it to the daily cron + echo '#!/bin/sh + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --post-hook "rc-service nginx reload" + ' > /etc/periodic/daily/renew-pleroma-cert + chmod +x /etc/periodic/daily/renew-pleroma-cert + + # If everything worked the output should contain /etc/cron.daily/renew-pleroma-cert + run-parts --test /etc/periodic/daily + ``` + +=== "Debian/Ubuntu" + ``` + # Restart nginx + systemctl restart nginx + + # Ensure the webroot menthod and post hook is working + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --dry-run --post-hook 'systemctl reload nginx' + + # Add it to the daily cron + echo '#!/bin/sh + certbot renew --cert-name yourinstance.tld --webroot -w /var/lib/letsencrypt/ --post-hook "systemctl reload nginx" + ' > /etc/cron.daily/renew-pleroma-cert + chmod +x /etc/cron.daily/renew-pleroma-cert + + # If everything worked the output should contain /etc/cron.daily/renew-pleroma-cert + run-parts --test /etc/cron.daily + ``` ## Create your first user and set as admin ```sh -- cgit v1.2.3 From 0865f36965f1583085af3a424dbbc89de724fd33 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Sat, 15 Aug 2020 15:27:41 +0200 Subject: Mark notifications about statuses from muted users as read automatically --- lib/pleroma/notification.ex | 6 ++++++ test/notification_test.exs | 6 +++++- test/web/mastodon_api/views/notification_view_test.exs | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 0b171563b..b4719896e 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -441,6 +441,7 @@ def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) |> Multi.insert(:notification, %Notification{ user_id: user.id, activity: activity, + seen: mark_as_read?(activity, user), type: type_from_activity(activity) }) |> Marker.multi_set_last_read_id(user, "notifications") @@ -634,6 +635,11 @@ def skip?(:filtered, activity, user) do def skip?(_, _, _), do: false + def mark_as_read?(activity, target_user) do + user = Activity.user_actor(activity) + User.mutes_user?(target_user, user) + end + def for_user_and_activity(user, activity) do from(n in __MODULE__, where: n.user_id == ^user.id, diff --git a/test/notification_test.exs b/test/notification_test.exs index 8243cfd34..93f4761da 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -217,7 +217,10 @@ test "it creates a notification for the user if the user mutes the activity auth muter = Repo.get(User, muter.id) {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"}) - assert Notification.create_notification(activity, muter) + notification = Notification.create_notification(activity, muter) + + assert notification.id + assert notification.seen end test "notification created if user is muted without notifications" do @@ -1012,6 +1015,7 @@ test "it returns notifications for muted user without notifications", %{user: us [notification] = Notification.for_user(user) assert notification.activity.object + assert notification.seen end test "it doesn't return notifications for muted user with notifications", %{user: user} do diff --git a/test/web/mastodon_api/views/notification_view_test.exs b/test/web/mastodon_api/views/notification_view_test.exs index 8e0e58538..2f6a808f1 100644 --- a/test/web/mastodon_api/views/notification_view_test.exs +++ b/test/web/mastodon_api/views/notification_view_test.exs @@ -219,7 +219,7 @@ test "muted notification" do expected = %{ id: to_string(notification.id), - pleroma: %{is_seen: false, is_muted: true}, + pleroma: %{is_seen: true, is_muted: true}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), status: StatusView.render("show.json", %{activity: create_activity, for: user}), -- cgit v1.2.3 From 60ac83a4c196233ed13c3da9ca296b0a4224e9a3 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 15 Aug 2020 18:30:20 +0300 Subject: [#2046] Added test for pleroma/restrict_unauthenticated defaults on private instance. Updated docs and changelog. --- CHANGELOG.md | 1 + docs/configuration/cheatsheet.md | 6 ++++-- .../controllers/timeline_controller_test.exs | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e80eb3c..d0fa138df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated. - **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated. - **Breaking:** LDAP: Fallback to local database authentication has been removed for security reasons and lack of a mechanism to ensure the passwords are synchronized when LDAP passwords are updated. +- **Breaking** Changed defaults for `:restrict_unauthenticated` so that when `:instance, :public` is set to `false` then all `:restrict_unauthenticated` items be effectively set to `true`. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`.
    API Changes diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index e5742bc3a..e68b6c6dc 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -38,8 +38,8 @@ To add configuration to your config file, you can copy it from the base config. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. * `allow_relay`: Enable Pleroma’s Relay, which makes it possible to follow a whole instance. -* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. See also: `restrict_unauthenticated`. -* `quarantined_instances`: List of ActivityPub instances where private(DMs, followers-only) activities will not be send. +* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. Note that there is a dependent setting restricting or allowing unauthenticated access to specific resources, see `restrict_unauthenticated` for more details. +* `quarantined_instances`: List of ActivityPub instances where private (DMs, followers-only) activities will not be send. * `managed_config`: Whenether the config for pleroma-fe is configured in [:frontend_configurations](#frontend_configurations) or in ``static/config.json``. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). * `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with @@ -1051,6 +1051,8 @@ Restrict access for unauthenticated users to timelines (public and federated), u * `local` * `remote` +Note: when `:instance, :public` is set to `false`, all `:restrict_unauthenticated` items be effectively set to `true` by default. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`. + Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). ## Pleroma.Web.ApiSpec.CastAndValidate diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 50e0d783d..71bac99f7 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -445,6 +445,23 @@ defp ensure_authenticated_access(base_uri) do assert length(json_response(res_conn, 200)) == 2 end + test "with default settings on private instances, returns 403 for unauthenticated users", %{ + conn: conn, + base_uri: base_uri, + error_response: error_response + } do + clear_config([:instance, :public], false) + clear_config([:restrict_unauthenticated, :timelines]) + + for local <- [true, false] do + res_conn = get(conn, "#{base_uri}?local=#{local}") + + assert json_response(res_conn, :unauthorized) == error_response + end + + ensure_authenticated_access(base_uri) + end + test "with `%{local: true, federated: true}`, returns 403 for unauthenticated users", %{ conn: conn, base_uri: base_uri, -- cgit v1.2.3 From f6da12f45d98707ad5e106e56cf36c055c3e105d Mon Sep 17 00:00:00 2001 From: Maksim Pechnikov Date: Sun, 16 Aug 2020 06:54:48 +0300 Subject: fix search media proxy urls --- .../controllers/media_proxy_cache_controller.ex | 16 +++++++++------- .../controllers/media_proxy_cache_controller_test.exs | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex index 76d3af4ef..131e22d78 100644 --- a/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex @@ -38,18 +38,20 @@ def index(%{assigns: %{user: _}} = conn, params) do defp fetch_entries(params) do MediaProxy.cache_table() - |> Cachex.export!() - |> filter_urls(params[:query]) + |> Cachex.stream!(Cachex.Query.create(true, :key)) + |> filter_entries(params[:query]) end - defp filter_urls(entries, query) when is_binary(query) do - for {_, url, _, _, _} <- entries, String.contains?(url, query), do: url - end + defp filter_entries(stream, query) when is_binary(query) do + regex = ~r/#{query}/i - defp filter_urls(entries, _) do - Enum.map(entries, fn {_, url, _, _, _} -> url end) + stream + |> Enum.filter(fn url -> String.match?(url, regex) end) + |> Enum.to_list() end + defp filter_entries(stream, _), do: Enum.to_list(stream) + defp paginate_entries(entries, page, page_size) do offset = page_size * (page - 1) Enum.slice(entries, offset, page_size) diff --git a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs index 3cf98d7c7..f243d1fb2 100644 --- a/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs +++ b/test/web/admin_api/controllers/media_proxy_cache_controller_test.exs @@ -89,7 +89,7 @@ test "search banned MediaProxy URLs", %{conn: conn} do response = conn - |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&query=f44") + |> get("/api/pleroma/admin/media_proxy_caches?page_size=2&query=F44") |> json_response_and_validate_schema(200) assert response["urls"] == [ -- cgit v1.2.3 From 25c69e271a3ea6687805e0bd0d4b902cda06e364 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Mon, 17 Aug 2020 00:07:23 +0200 Subject: Make notifications about new statuses from muted threads read --- CHANGELOG.md | 1 + lib/pleroma/notification.ex | 3 ++- lib/pleroma/web/common_api/common_api.ex | 2 +- test/notification_test.exs | 7 ++++++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e80eb3c..c462833d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). contents has been renamed to `hide_notification_contents` - Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance - Mastodon API (legacy): Allow query parameters for `/api/v1/domain_blocks`, e.g. `/api/v1/domain_blocks?domain=badposters.zone` +- Mastodon API: Make notifications about statuses from muted users and threads read automatically - Pleroma API: `/api/pleroma/captcha` responses now include `seconds_valid` with an integer value.
    diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index b4719896e..c1825f810 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Notification do alias Pleroma.Repo alias Pleroma.ThreadMute alias Pleroma.User + alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.Push alias Pleroma.Web.Streamer @@ -637,7 +638,7 @@ def skip?(_, _, _), do: false def mark_as_read?(activity, target_user) do user = Activity.user_actor(activity) - User.mutes_user?(target_user, user) + User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(target_user, activity) end def for_user_and_activity(user, activity) do diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index a8141b28f..5ad2b91c2 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -465,7 +465,7 @@ def remove_mute(user, activity) do end def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) - when is_binary("context") do + when is_binary(context) do ThreadMute.exists?(user_id, context) end diff --git a/test/notification_test.exs b/test/notification_test.exs index 93f4761da..a09b08675 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -246,7 +246,10 @@ test "it creates a notification for an activity from a muted thread" do in_reply_to_status_id: activity.id }) - assert Notification.create_notification(activity, muter) + notification = Notification.create_notification(activity, muter) + + assert notification.id + assert notification.seen end test "it disables notifications from strangers" do @@ -320,6 +323,7 @@ test "it creates notifications if content matches with a not irreversible filter {:ok, [notification]} = Notification.create_notifications(status) assert notification + refute notification.seen end test "it creates notifications when someone likes user's status with a filtered word" do @@ -333,6 +337,7 @@ test "it creates notifications when someone likes user's status with a filtered {:ok, [notification]} = Notification.create_notifications(activity_two) assert notification + refute notification.seen end end -- cgit v1.2.3 From 317b6c6c526d14dda928abeb411a76dac53849db Mon Sep 17 00:00:00 2001 From: Hugo Müller-Downing Date: Sun, 16 Aug 2020 14:02:33 +1000 Subject: Start :ssl if not started when running migration or rollback --- CHANGELOG.md | 1 + lib/mix/tasks/pleroma/ecto/migrate.ex | 4 ++++ lib/mix/tasks/pleroma/ecto/rollback.ex | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e80eb3c..eecdd78e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix edge case where MediaProxy truncates media, usually caused when Caddy is serving content for the other Federated instance. - Emoji Packs could not be listed when instance was set to `public: false` - Fix whole_word always returning false on filter get requests +- Fix SSL not being started for database migrations in OTP release ## [Unreleased (patch)] diff --git a/lib/mix/tasks/pleroma/ecto/migrate.ex b/lib/mix/tasks/pleroma/ecto/migrate.ex index bc8ed29fb..e903bd171 100644 --- a/lib/mix/tasks/pleroma/ecto/migrate.ex +++ b/lib/mix/tasks/pleroma/ecto/migrate.ex @@ -41,6 +41,10 @@ def run(args \\ []) do load_pleroma() {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + if Application.get_env(:pleroma, Pleroma.Repo)[:ssl] do + Application.ensure_all_started(:ssl) + end + opts = if opts[:to] || opts[:step] || opts[:all], do: opts, diff --git a/lib/mix/tasks/pleroma/ecto/rollback.ex b/lib/mix/tasks/pleroma/ecto/rollback.ex index f43bd0b98..3dba952cb 100644 --- a/lib/mix/tasks/pleroma/ecto/rollback.ex +++ b/lib/mix/tasks/pleroma/ecto/rollback.ex @@ -40,6 +40,10 @@ def run(args \\ []) do load_pleroma() {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + if Application.get_env(:pleroma, Pleroma.Repo)[:ssl] do + Application.ensure_all_started(:ssl) + end + opts = if opts[:to] || opts[:step] || opts[:all], do: opts, -- cgit v1.2.3 From b2d3b26511476bc2786520130a37847f1d560333 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 17 Aug 2020 07:58:24 +0000 Subject: Apply 1 suggestion(s) to 1 file(s) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eecdd78e0..83697beaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,7 +105,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix edge case where MediaProxy truncates media, usually caused when Caddy is serving content for the other Federated instance. - Emoji Packs could not be listed when instance was set to `public: false` - Fix whole_word always returning false on filter get requests -- Fix SSL not being started for database migrations in OTP release +- Migrations not working on OTP releases if the database was connected over ssl ## [Unreleased (patch)] -- cgit v1.2.3 From 5ea752dab2c5b0aab7efff67e2d007273d534da6 Mon Sep 17 00:00:00 2001 From: lain Date: Mon, 17 Aug 2020 14:11:36 +0200 Subject: Migrations: Add an index on the `invisible` field on users. --- .../migrations/20200817120935_add_invisible_index_to_users.exs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 priv/repo/migrations/20200817120935_add_invisible_index_to_users.exs diff --git a/priv/repo/migrations/20200817120935_add_invisible_index_to_users.exs b/priv/repo/migrations/20200817120935_add_invisible_index_to_users.exs new file mode 100644 index 000000000..2417d366e --- /dev/null +++ b/priv/repo/migrations/20200817120935_add_invisible_index_to_users.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddInvisibleIndexToUsers do + use Ecto.Migration + + def change do + create(index(:users, [:invisible])) + end +end -- cgit v1.2.3 From 7a273087ed7b49dedd821ca69a6e09d5f893c913 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 17 Aug 2020 23:46:42 +0200 Subject: object_validators: Use ecto_types where available --- .../web/activity_pub/object_validators/answer_validator.ex | 13 +++++-------- .../activity_pub/object_validators/create_note_validator.ex | 9 ++++----- .../activity_pub/object_validators/emoji_react_validator.ex | 4 ++-- .../web/activity_pub/object_validators/note_validator.ex | 10 +++++----- .../activity_pub/object_validators/question_validator.ex | 12 ++++++------ .../web/activity_pub/object_validators/undo_validator.ex | 4 ++-- 6 files changed, 24 insertions(+), 28 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index 323367642..b9fbaf4f6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -15,16 +15,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do embedded_schema do field(:id, ObjectValidators.ObjectID, primary_key: true) - field(:to, {:array, :string}, default: []) - field(:cc, {:array, :string}, default: []) - - # is this actually needed? - field(:bto, {:array, :string}, default: []) - field(:bcc, {:array, :string}, default: []) - + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) field(:type, :string) field(:name, :string) - field(:inReplyTo, :string) + field(:inReplyTo, ObjectValidators.ObjectID) field(:attributedTo, ObjectValidators.ObjectID) # TODO: Remove actor on objects diff --git a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex index 316bd0c07..9b9743c4a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex @@ -16,11 +16,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do field(:id, ObjectValidators.ObjectID, primary_key: true) field(:actor, ObjectValidators.ObjectID) field(:type, :string) - field(:to, {:array, :string}) - field(:cc, {:array, :string}) - field(:bto, {:array, :string}, default: []) - field(:bcc, {:array, :string}, default: []) - + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) embeds_one(:object, NoteValidator) end diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index a543af1f8..336c92d35 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -20,8 +20,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do field(:actor, ObjectValidators.ObjectID) field(:context, :string) field(:content, :string) - field(:to, {:array, :string}, default: []) - field(:cc, {:array, :string}, default: []) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) end def cast_and_validate(data) 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 a65fe2354..14ae29cb6 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -13,10 +13,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do embedded_schema do field(:id, ObjectValidators.ObjectID, primary_key: true) - field(:to, {:array, :string}, default: []) - field(:cc, {:array, :string}, default: []) - field(:bto, {:array, :string}, default: []) - field(:bcc, {:array, :string}, default: []) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) # TODO: Write type field(:tag, {:array, :map}, default: []) field(:type, :string) @@ -34,7 +34,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:replies_count, :integer, default: 0) field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) - field(:inReplyTo, :string) + field(:inReplyTo, ObjectValidators.ObjectID) field(:uri, ObjectValidators.Uri) field(:likes, {:array, :string}, default: []) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index f47acf606..220065fd4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -19,10 +19,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do # Extends from NoteValidator embedded_schema do field(:id, ObjectValidators.ObjectID, primary_key: true) - field(:to, {:array, :string}, default: []) - field(:cc, {:array, :string}, default: []) - field(:bto, {:array, :string}, default: []) - field(:bcc, {:array, :string}, default: []) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) # TODO: Write type field(:tag, {:array, :map}, default: []) field(:type, :string) @@ -42,7 +42,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:replies_count, :integer, default: 0) field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) - field(:inReplyTo, :string) + field(:inReplyTo, ObjectValidators.ObjectID) field(:uri, ObjectValidators.Uri) # short identifier for PleromaFE to group statuses by context field(:context_id, :integer) @@ -117,7 +117,7 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Question"]) - |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) |> CommonValidations.validate_actor_presence() diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex index e8d2d39c1..8cae94467 100644 --- a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -18,8 +18,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do field(:type, :string) field(:object, ObjectValidators.ObjectID) field(:actor, ObjectValidators.ObjectID) - field(:to, {:array, :string}, default: []) - field(:cc, {:array, :string}, default: []) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) end def cast_and_validate(data) do -- cgit v1.2.3 From b1fc4fe0ca6abab97be69e0b1bf138e8b5c1c303 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 18 Aug 2020 02:01:40 +0200 Subject: fetcher: fallback to [] when to/cc is nil Related: https://git.pleroma.social/pleroma/pleroma/-/issues/2063 --- lib/pleroma/object/fetcher.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 3ff25118d..6fdbc8efd 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -125,8 +125,8 @@ def fetch_object_from_id(id, options \\ []) do defp prepare_activity_params(data) do %{ "type" => "Create", - "to" => data["to"], - "cc" => data["cc"], + "to" => data["to"] || [], + "cc" => data["cc"] || [], # Should we seriously keep this attributedTo thing? "actor" => data["actor"] || data["attributedTo"], "object" => data -- cgit v1.2.3 From 2bc08d5573782ba491c36450b817aa352264fb27 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 18 Aug 2020 01:48:42 +0200 Subject: Pipeline Ingestion: Audio --- lib/pleroma/web/activity_pub/object_validator.ex | 17 +++- .../object_validators/audio_validator.ex | 109 +++++++++++++++++++++ lib/pleroma/web/activity_pub/side_effects.ex | 3 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 4 +- 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/audio_validator.ex diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 3f1dffe2b..d770ce1be 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -16,6 +16,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AudioValidator alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CreateChatMessageValidator @@ -137,6 +138,16 @@ def validate(%{"type" => "Question"} = object, meta) do end end + def validate(%{"type" => "Audio"} = object, meta) do + with {:ok, object} <- + object + |> AudioValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Answer"} = object, meta) do with {:ok, object} <- object @@ -176,7 +187,7 @@ def validate( %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, meta ) - when objtype in ["Question", "Answer"] do + when objtype in ~w[Question Answer Audio] do with {:ok, object_data} <- cast_and_apply(object), meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- @@ -210,6 +221,10 @@ def cast_and_apply(%{"type" => "Answer"} = object) do AnswerValidator.cast_and_apply(object) end + def cast_and_apply(%{"type" => "Audio"} = object) do + AudioValidator.cast_and_apply(object) + end + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} # is_struct/1 isn't present in Elixir 1.8.x diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex new file mode 100644 index 000000000..5ff9e3832 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.Utils + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + # Extends from NoteValidator + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) + # TODO: Write type + field(:tag, {:array, :map}, default: []) + field(:type, :string) + field(:content, :string) + field(:context, :string) + + # TODO: Remove actor on objects + field(:actor, ObjectValidators.ObjectID) + + field(:attributedTo, ObjectValidators.ObjectID) + field(:summary, :string) + field(:published, ObjectValidators.DateTime) + # TODO: Write type + field(:emoji, :map, default: %{}) + field(:sensitive, :boolean, default: false) + embeds_many(:attachment, AttachmentValidator) + field(:replies_count, :integer, default: 0) + field(:like_count, :integer, default: 0) + field(:announcement_count, :integer, default: 0) + field(:inReplyTo, :string) + field(:uri, ObjectValidators.Uri) + # short identifier for PleromaFE to group statuses by context + field(:context_id, :integer) + + field(:likes, {:array, :string}, default: []) + field(:announcements, {:array, :string}, default: []) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults + defp fix_defaults(data) do + %{data: %{"id" => context}, id: context_id} = + Utils.create_context(data["context"] || data["conversation"]) + + data + |> Map.put_new_lazy("published", &Utils.make_date/0) + |> Map.put_new("context", context) + |> Map.put_new("context_id", context_id) + end + + defp fix_attribution(data) do + data + |> Map.put_new("actor", data["attributedTo"]) + end + + defp fix(data) do + data + |> fix_defaults() + |> fix_attribution() + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields) -- [:attachment]) + |> cast_embed(:attachment) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Audio"]) + |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_host_match() + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index bcd6fd2fb..3dc66c60b 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -340,7 +340,8 @@ def handle_object_creation(%{"type" => "Answer"} = object_map, meta) do end end - def handle_object_creation(%{"type" => "Question"} = object, meta) do + def handle_object_creation(%{"type" => objtype} = object, meta) + when objtype in ~w[Audio Question] do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do {:ok, object, meta} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 544f3f3b6..6be17e0ed 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -461,7 +461,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ["Article", "Event", "Note", "Video", "Page", "Audio"] do + when objtype in ~w{Article Event Note Video Page} do actor = Containment.get_actor(data) with nil <- Activity.get_create_by_object_ap_id(object["id"]), @@ -555,7 +555,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype}} = data, _options ) - when objtype in ["Question", "Answer", "ChatMessage"] do + when objtype in ~w{Question Answer ChatMessage Audio} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} -- cgit v1.2.3 From c9d6638461e62a5b9e357f55a6d6d4e468b6bc92 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 18 Aug 2020 02:11:38 +0200 Subject: common_fixes: Get fixes common from Audio and Question --- .../object_validators/audio_validator.ex | 23 +++------------------- .../activity_pub/object_validators/common_fixes.ex | 23 ++++++++++++++++++++++ .../object_validators/question_validator.ex | 22 +++------------------ 3 files changed, 29 insertions(+), 39 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/common_fixes.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex index 5ff9e3832..5d9bf345f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex @@ -7,15 +7,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations - alias Pleroma.Web.ActivityPub.Utils import Ecto.Changeset @primary_key false @derive Jason.Encoder - # Extends from NoteValidator embedded_schema do field(:id, ObjectValidators.ObjectID, primary_key: true) field(:to, ObjectValidators.Recipients, default: []) @@ -67,26 +66,10 @@ def cast_data(data) do |> changeset(data) end - # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults - defp fix_defaults(data) do - %{data: %{"id" => context}, id: context_id} = - Utils.create_context(data["context"] || data["conversation"]) - - data - |> Map.put_new_lazy("published", &Utils.make_date/0) - |> Map.put_new("context", context) - |> Map.put_new("context_id", context_id) - end - - defp fix_attribution(data) do - data - |> Map.put_new("actor", data["attributedTo"]) - end - defp fix(data) do data - |> fix_defaults() - |> fix_attribution() + |> CommonFixes.fix_defaults() + |> CommonFixes.fix_attribution() end def changeset(struct, data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex new file mode 100644 index 000000000..f13c16eca --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -0,0 +1,23 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do + alias Pleroma.Web.ActivityPub.Utils + + # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults + def fix_defaults(data) do + %{data: %{"id" => context}, id: context_id} = + Utils.create_context(data["context"] || data["conversation"]) + + data + |> Map.put_new_lazy("published", &Utils.make_date/0) + |> Map.put_new("context", context) + |> Map.put_new("context_id", context_id) + end + + def fix_attribution(data) do + data + |> Map.put_new("actor", data["attributedTo"]) + end +end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index f47acf606..0aa70ee30 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -7,9 +7,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator - alias Pleroma.Web.ActivityPub.Utils import Ecto.Changeset @@ -81,27 +81,11 @@ defp fix_closed(data) do end end - # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults - defp fix_defaults(data) do - %{data: %{"id" => context}, id: context_id} = - Utils.create_context(data["context"] || data["conversation"]) - - data - |> Map.put_new_lazy("published", &Utils.make_date/0) - |> Map.put_new("context", context) - |> Map.put_new("context_id", context_id) - end - - defp fix_attribution(data) do - data - |> Map.put_new("actor", data["attributedTo"]) - end - defp fix(data) do data - |> fix_attribution() + |> CommonFixes.fix_defaults() + |> CommonFixes.fix_attribution() |> fix_closed() - |> fix_defaults() end def changeset(struct, data) do -- cgit v1.2.3 From 2f8c3c842dd48c26009e1272a28220175d0b1f06 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 18 Aug 2020 02:12:13 +0200 Subject: common_fixes: Remove Utils.make_date call --- lib/pleroma/web/activity_pub/object_validators/common_fixes.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index f13c16eca..721749de0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -11,7 +11,6 @@ def fix_defaults(data) do Utils.create_context(data["context"] || data["conversation"]) data - |> Map.put_new_lazy("published", &Utils.make_date/0) |> Map.put_new("context", context) |> Map.put_new("context_id", context_id) end -- cgit v1.2.3 From d55faa2f8fc3d613a3fa44b521fed27f8231c558 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 17 Aug 2020 21:52:28 -0500 Subject: Purge a local user upon deletion, fixes #2062 --- lib/pleroma/user.ex | 14 +++++++++++- .../controllers/admin_api_controller_test.exs | 25 ++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index ac065e9dc..a8bdcdad7 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1583,6 +1583,18 @@ def update_notification_settings(%User{} = user, settings) do |> update_and_set_cache() end + @spec purge_user_changeset(User.t()) :: Changeset.t() + def purge_user_changeset(user) do + change(user, %{ + deactivated: true, + email: nil, + avatar: %{}, + banner: %{}, + background: %{}, + fields: [] + }) + end + def delete(users) when is_list(users) do for user <- users, do: delete(user) end @@ -1610,7 +1622,7 @@ defp delete_or_deactivate(%User{local: true} = user) do _ -> user - |> change(%{deactivated: true, email: nil}) + |> purge_user_changeset() |> update_and_set_cache() end end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 66d4b1ef3..f23d23e05 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -155,13 +155,28 @@ test "GET /api/pleroma/admin/users/:nickname requires " <> describe "DELETE /api/pleroma/admin/users" do test "single user", %{admin: admin, conn: conn} do - user = insert(:user) clear_config([:instance, :federating], true) + user = + insert(:user, + avatar: %{"url" => [%{"href" => "https://someurl"}]}, + banner: %{"url" => [%{"href" => "https://somebanner"}]} + ) + + # Create some activities to check they got deleted later + follower = insert(:user) + {:ok, _} = CommonAPI.post(user, %{status: "test"}) + {:ok, _, _, _} = CommonAPI.follow(user, follower) + {:ok, _, _, _} = CommonAPI.follow(follower, user) + user = Repo.get(User, user.id) + assert user.note_count == 1 + assert user.follower_count == 1 + assert user.following_count == 1 refute user.deactivated with_mock Pleroma.Web.Federator, - publish: fn _ -> nil end do + publish: fn _ -> nil end, + perform: fn _, _ -> nil end do conn = conn |> put_req_header("accept", "application/json") @@ -181,6 +196,12 @@ test "single user", %{admin: admin, conn: conn} do user = Repo.get(User, user.id) assert user.deactivated + assert user.avatar == %{} + assert user.banner == %{} + assert user.note_count == 0 + assert user.follower_count == 0 + assert user.following_count == 0 + assert called(Pleroma.Web.Federator.publish(:_)) end end -- cgit v1.2.3 From c12c576ee28016444b89c426d67c960f156e831e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 17 Aug 2020 22:08:08 -0500 Subject: Also purge bio and display name --- lib/pleroma/user.ex | 4 +++- test/web/admin_api/controllers/admin_api_controller_test.exs | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a8bdcdad7..1a7d25801 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1591,7 +1591,9 @@ def purge_user_changeset(user) do avatar: %{}, banner: %{}, background: %{}, - fields: [] + fields: [], + bio: nil, + name: nil }) end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index f23d23e05..2eb698807 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -160,7 +160,9 @@ test "single user", %{admin: admin, conn: conn} do user = insert(:user, avatar: %{"url" => [%{"href" => "https://someurl"}]}, - banner: %{"url" => [%{"href" => "https://somebanner"}]} + banner: %{"url" => [%{"href" => "https://somebanner"}]}, + bio: "Hello world!", + name: "A guy" ) # Create some activities to check they got deleted later @@ -201,6 +203,8 @@ test "single user", %{admin: admin, conn: conn} do assert user.note_count == 0 assert user.follower_count == 0 assert user.following_count == 0 + assert user.bio == nil + assert user.name == nil assert called(Pleroma.Web.Federator.publish(:_)) end -- cgit v1.2.3 From 72cbe20a5887cf2457895b0559e7eb97cc1bc871 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 17 Aug 2020 23:44:44 -0500 Subject: Purge most user fields upon deletion, "right to be forgotten" #859 --- lib/pleroma/user.ex | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1a7d25801..a9820affa 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1585,15 +1585,44 @@ def update_notification_settings(%User{} = user, settings) do @spec purge_user_changeset(User.t()) :: Changeset.t() def purge_user_changeset(user) do + # "Right to be forgotten" + # https://gdpr.eu/right-to-be-forgotten/ change(user, %{ - deactivated: true, + bio: nil, + raw_bio: nil, email: nil, + name: nil, + password_hash: nil, + keys: nil, + public_key: nil, avatar: %{}, + tags: [], + last_refreshed_at: nil, + last_digest_emailed_at: nil, banner: %{}, background: %{}, + note_count: 0, + follower_count: 0, + following_count: 0, + locked: false, + confirmation_pending: false, + password_reset_pending: false, + approval_pending: false, + registration_reason: nil, + confirmation_token: nil, + domain_blocks: [], + deactivated: true, + ap_enabled: false, + is_moderator: false, + is_admin: false, + mastofe_settings: nil, + mascot: nil, + emoji: %{}, + pleroma_settings_store: %{}, fields: [], - bio: nil, - name: nil + raw_fields: [], + discoverable: false, + also_known_as: [] }) end -- cgit v1.2.3 From dcc8926ff1bb7206295dcfe9ad9388cb3c05be2a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 18 Aug 2020 00:10:09 -0500 Subject: Test purging a user with User.delete/1 --- test/user_test.exs | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/test/user_test.exs b/test/user_test.exs index b47405895..3cf248659 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1417,7 +1417,6 @@ test "deactivates user when activation is not required", %{user: user} do test "delete/1 when approval is pending deletes the user" do user = insert(:user, approval_pending: true) - {:ok, user: user} {:ok, job} = User.delete(user) {:ok, _} = ObanHelpers.perform(job) @@ -1426,6 +1425,85 @@ test "delete/1 when approval is pending deletes the user" do refute User.get_by_id(user.id) end + test "delete/1 purges a user when they wouldn't be fully deleted" do + user = + insert(:user, %{ + bio: "eyy lmao", + name: "qqqqqqq", + password_hash: "pdfk2$1b3n159001", + keys: "RSA begin buplic key", + public_key: "--PRIVATE KEYE--", + avatar: %{"a" => "b"}, + tags: ["qqqqq"], + banner: %{"a" => "b"}, + background: %{"a" => "b"}, + note_count: 9, + follower_count: 9, + following_count: 9001, + locked: true, + confirmation_pending: true, + password_reset_pending: true, + approval_pending: true, + registration_reason: "ahhhhh", + confirmation_token: "qqqq", + domain_blocks: ["lain.com"], + deactivated: true, + ap_enabled: true, + is_moderator: true, + is_admin: true, + mastofe_settings: %{"a" => "b"}, + mascot: %{"a" => "b"}, + emoji: %{"a" => "b"}, + pleroma_settings_store: %{"q" => "x"}, + fields: [%{"gg" => "qq"}], + raw_fields: [%{"gg" => "qq"}], + discoverable: true, + also_known_as: ["https://lol.olo/users/loll"] + }) + + {:ok, job} = User.delete(user) + {:ok, _} = ObanHelpers.perform(job) + user = User.get_by_id(user.id) + + assert %User{ + bio: nil, + raw_bio: nil, + email: nil, + name: nil, + password_hash: nil, + keys: nil, + public_key: nil, + avatar: %{}, + tags: [], + last_refreshed_at: nil, + last_digest_emailed_at: nil, + banner: %{}, + background: %{}, + note_count: 0, + follower_count: 0, + following_count: 0, + locked: false, + confirmation_pending: false, + password_reset_pending: false, + approval_pending: false, + registration_reason: nil, + confirmation_token: nil, + domain_blocks: [], + deactivated: true, + ap_enabled: false, + is_moderator: false, + is_admin: false, + mastofe_settings: nil, + mascot: nil, + emoji: %{}, + pleroma_settings_store: %{}, + fields: [], + raw_fields: [], + discoverable: false, + also_known_as: [] + } = user + end + test "get_public_key_for_ap_id fetches a user that's not in the db" do assert {:ok, _key} = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end -- cgit v1.2.3 From a0f5eb1a552cf161f0efb746d74c4c590de4f02f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 18 Aug 2020 00:24:28 -0500 Subject: Test that `POST /api/pleroma/delete_account` purges the user --- test/web/twitter_api/util_controller_test.exs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 109c1e637..354d77b56 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -586,10 +586,16 @@ test "with proper permissions and wrong or missing password", %{conn: conn} do end end - test "with proper permissions and valid password", %{conn: conn} do + test "with proper permissions and valid password", %{conn: conn, user: user} do conn = post(conn, "/api/pleroma/delete_account", %{"password" => "test"}) - + ObanHelpers.perform_all() assert json_response(conn, 200) == %{"status" => "success"} + + user = User.get_by_id(user.id) + assert user.deactivated == true + assert user.name == nil + assert user.bio == nil + assert user.password_hash == nil end end end -- cgit v1.2.3 From aabc26a57327b15c1aa5ee9980b7542c9e2f4899 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 18 Aug 2020 13:21:30 +0200 Subject: Pleroma.Upload: Set default upload name / description based on config. --- config/config.exs | 3 ++- lib/pleroma/upload.ex | 11 ++++++++++- test/web/activity_pub/activity_pub_test.exs | 30 +++++++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/config/config.exs b/config/config.exs index a7c9e54b1..1ed3157c3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -72,7 +72,8 @@ pool: :upload ] ], - filename_display_max_length: 30 + filename_display_max_length: 30, + default_description: nil config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads" diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 0fa6b89dc..015c87593 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -56,6 +56,15 @@ defmodule Pleroma.Upload do } defstruct [:id, :name, :tempfile, :content_type, :path] + defp get_description(opts, upload) do + case {opts[:description], Pleroma.Config.get([Pleroma.Upload, :default_description])} do + {description, _} when is_binary(description) -> description + {_, :filename} -> upload.name + {_, str} when is_binary(str) -> str + _ -> "" + end + end + @spec store(source, options :: [option()]) :: {:ok, Map.t()} | {:error, any()} def store(upload, opts \\ []) do opts = get_opts(opts) @@ -63,7 +72,7 @@ def store(upload, opts \\ []) do with {:ok, upload} <- prepare_upload(upload, opts), upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"}, {:ok, upload} <- Pleroma.Upload.Filter.filter(opts.filters, upload), - description = Map.get(opts, :description) || upload.name, + description = get_description(opts, upload), {_, true} <- {:description_limit, String.length(description) <= Pleroma.Config.get([:instance, :description_limit])}, diff --git a/test/web/activity_pub/activity_pub_test.exs b/test/web/activity_pub/activity_pub_test.exs index d6eab7337..03f968aaf 100644 --- a/test/web/activity_pub/activity_pub_test.exs +++ b/test/web/activity_pub/activity_pub_test.exs @@ -990,13 +990,39 @@ test "returns reblogs for users for whom reblogs have not been muted" do end describe "uploading files" do - test "copies the file to the configured folder" do - file = %Plug.Upload{ + setup do + test_file = %Plug.Upload{ content_type: "image/jpg", path: Path.absname("test/fixtures/image.jpg"), filename: "an_image.jpg" } + %{test_file: test_file} + end + + test "sets a description if given", %{test_file: file} do + {:ok, %Object{} = object} = ActivityPub.upload(file, description: "a cool file") + assert object.data["name"] == "a cool file" + end + + test "it sets the default description depending on the configuration", %{test_file: file} do + clear_config([Pleroma.Upload, :default_description]) + + Pleroma.Config.put([Pleroma.Upload, :default_description], nil) + {:ok, %Object{} = object} = ActivityPub.upload(file) + assert object.data["name"] == "" + + Pleroma.Config.put([Pleroma.Upload, :default_description], :filename) + {:ok, %Object{} = object} = ActivityPub.upload(file) + assert object.data["name"] == "an_image.jpg" + + Pleroma.Config.put([Pleroma.Upload, :default_description], "unnamed attachment") + {:ok, %Object{} = object} = ActivityPub.upload(file) + assert object.data["name"] == "unnamed attachment" + end + + test "copies the file to the configured folder", %{test_file: file} do + clear_config([Pleroma.Upload, :default_description], :filename) {:ok, %Object{} = object} = ActivityPub.upload(file) assert object.data["name"] == "an_image.jpg" end -- cgit v1.2.3 From 368fd04b47834a49391424e3ce2073bfc80d7b7a Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 18 Aug 2020 13:22:00 +0200 Subject: Cheatsheet: Add information about filename descriptions --- docs/configuration/cheatsheet.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index e68b6c6dc..4758fca66 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -552,6 +552,7 @@ the source code is here: [kocaptcha](https://github.com/koto-bank/kocaptcha). Th * `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. * `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. * `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30. +* `default_description`: Sets which default description an image has if none is set explicitly. Options: nil (default) - Don't set a default, :filename - use the filename of the file, a string (e.g. "attachment") - Use this string !!! warning `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. -- cgit v1.2.3 From 757410a17758600514107edd4ed946e4f67fd9a6 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 18 Aug 2020 13:24:39 +0200 Subject: Changelog: Add info about upload description changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ae2981c..cdc0cd8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed +- **Breaking:** The default descriptions on uploads are now empty. The old behavior (filename as default) can be configured, see the cheat sheet. - **Breaking:** Added the ObjectAgePolicy to the default set of MRFs. This will delist and strip the follower collection of any message received that is older than 7 days. This will stop users from seeing very old messages in the timelines. The messages can still be viewed on the user's page and in conversations. They also still trigger notifications. - **Breaking:** Elixir >=1.9 is now required (was >= 1.8) - **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated. -- cgit v1.2.3 From f0a8d723bb4c4ec31dd2ab5ce7a1606aa280efbb Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 18 Aug 2020 13:37:28 +0200 Subject: Transmogrifier Test: Extract audio tests. --- .../transmogrifier/audio_handling_test.exs | 45 ++++++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 29 -------------- 2 files changed, 45 insertions(+), 29 deletions(-) create mode 100644 test/web/activity_pub/transmogrifier/audio_handling_test.exs diff --git a/test/web/activity_pub/transmogrifier/audio_handling_test.exs b/test/web/activity_pub/transmogrifier/audio_handling_test.exs new file mode 100644 index 000000000..c74a9c45d --- /dev/null +++ b/test/web/activity_pub/transmogrifier/audio_handling_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.ActivityPub.Transmogrifier.AudioHandlingTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + alias Pleroma.Activity + alias Pleroma.Object + alias Pleroma.Web.ActivityPub.Transmogrifier + + import Pleroma.Factory + + test "it works for incoming listens" do + _user = insert(:user, ap_id: "http://mastodon.example.org/users/admin") + + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], + "type" => "Listen", + "id" => "http://mastodon.example.org/users/admin/listens/1234/activity", + "actor" => "http://mastodon.example.org/users/admin", + "object" => %{ + "type" => "Audio", + "id" => "http://mastodon.example.org/users/admin/listens/1234", + "attributedTo" => "http://mastodon.example.org/users/admin", + "title" => "lain radio episode 1", + "artist" => "lain", + "album" => "lain radio", + "length" => 180_000 + } + } + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + + object = Object.normalize(activity) + + assert object.data["title"] == "lain radio episode 1" + assert object.data["artist"] == "lain" + assert object.data["album"] == "lain radio" + assert object.data["length"] == 180_000 + end +end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 0dd4e6e47..3fa41b0c7 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -225,35 +225,6 @@ test "it works for incoming notices with hashtags" do assert Enum.at(object.data["tag"], 2) == "moo" end - test "it works for incoming listens" do - data = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "cc" => [], - "type" => "Listen", - "id" => "http://mastodon.example.org/users/admin/listens/1234/activity", - "actor" => "http://mastodon.example.org/users/admin", - "object" => %{ - "type" => "Audio", - "id" => "http://mastodon.example.org/users/admin/listens/1234", - "attributedTo" => "http://mastodon.example.org/users/admin", - "title" => "lain radio episode 1", - "artist" => "lain", - "album" => "lain radio", - "length" => 180_000 - } - } - - {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) - - object = Object.normalize(activity) - - assert object.data["title"] == "lain radio episode 1" - assert object.data["artist"] == "lain" - assert object.data["album"] == "lain radio" - assert object.data["length"] == 180_000 - end - test "it works for incoming notices with contentMap" do data = File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() -- cgit v1.2.3 From 52a79506c786a1388eeab24892c7b36ee9682977 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 18 Aug 2020 14:37:35 +0200 Subject: Test config: Default to filename for descriptions --- config/test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/test.exs b/config/test.exs index 413c7f0b9..f0358e384 100644 --- a/config/test.exs +++ b/config/test.exs @@ -21,7 +21,10 @@ config :pleroma, :auth, oauth_consumer_strategies: [] -config :pleroma, Pleroma.Upload, filters: [], link_name: false +config :pleroma, Pleroma.Upload, + filters: [], + link_name: false, + default_description: :filename config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads" -- cgit v1.2.3 From dfcb1401c701edb6e963d40772f4d26662c40793 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 18 Aug 2020 10:24:34 -0500 Subject: Improve FreeBSD rc script Passes rclint now, $HOME is dynamic, and properly matches process name for signalling shutdown. --- installation/freebsd/rc.d/pleroma | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/installation/freebsd/rc.d/pleroma b/installation/freebsd/rc.d/pleroma index 1e41e57e6..f62aef18d 100755 --- a/installation/freebsd/rc.d/pleroma +++ b/installation/freebsd/rc.d/pleroma @@ -1,28 +1,27 @@ #!/bin/sh -# REQUIRE: DAEMON postgresql +# $FreeBSD$ # PROVIDE: pleroma +# REQUIRE: DAEMON postgresql +# KEYWORD: shutdown # sudo -u pleroma MIX_ENV=prod elixir --erl \"-detached\" -S mix phx.server . /etc/rc.subr -name="pleroma" +name=pleroma +rcvar=pleroma_enable + desc="Pleroma Social Media Platform" -rcvar=${name}_enable -command="/usr/local/bin/elixir" -command_args="--erl \"-detached\" -S /usr/local/bin/mix phx.server" -pidfile="/dev/null" -pleroma_user="pleroma" -pleroma_home="/home/pleroma" -pleroma_chdir="${pleroma_home}/pleroma" -pleroma_env="HOME=${pleroma_home} MIX_ENV=prod" +load_rc_config ${name} -check_pidfile() -{ - pid=$(pgrep beam.smp$) - echo -n "${pid}" -} +: ${pleroma_user:=pleroma} +: ${pleroma_home:=$(getent passwd ${pleroma_user} | awk -F: '{print $6}')} +: ${pleroma_chdir:="${pleroma_home}/pleroma"} +: ${pleroma_env:="HOME=${pleroma_home} MIX_ENV=prod"} + +command=/usr/local/bin/elixir +command_args="--erl \"-detached\" -S /usr/local/bin/mix phx.server" +procname="*beam.smp" -load_rc_config ${name} run_rc_command "$1" -- cgit v1.2.3 From 5316e231b0b007ce05bc1bffdf6ce0244749fb9e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 19 Aug 2020 00:05:48 +0200 Subject: Pipeline Ingestion: Audio (Part 2) --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../object_validators/attachment_validator.ex | 42 ++++++++-------- .../object_validators/audio_validator.ex | 18 ++++++- .../object_validators/create_generic_validator.ex | 11 ++++ .../object_validators/note_validator.ex | 2 +- .../object_validators/question_validator.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 5 +- .../tesla_mock/funkwhale_create_audio.json | 58 ++++++++++++++++++++++ .../transmogrifier/audio_handling_test.exs | 35 +++++++++++++ .../transmogrifier/question_handling_test.exs | 2 + 10 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 test/fixtures/tesla_mock/funkwhale_create_audio.json diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index bde1fe708..db1867494 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -85,7 +85,7 @@ defp increase_replies_count_if_reply(%{ defp increase_replies_count_if_reply(_create_data), do: :noop - @object_types ["ChatMessage", "Question", "Answer"] + @object_types ~w[ChatMessage Question Answer Audio] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index f53bb02be..c8b148280 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -41,34 +41,34 @@ def changeset(struct, data) do end def fix_media_type(data) do - data = - data - |> Map.put_new("mediaType", data["mimeType"]) + data = Map.put_new(data, "mediaType", data["mimeType"]) if MIME.valid?(data["mediaType"]) do data else - data - |> Map.put("mediaType", "application/octet-stream") + Map.put(data, "mediaType", "application/octet-stream") end end - def fix_url(data) do - case data["url"] do - url when is_binary(url) -> - data - |> Map.put( - "url", - [ - %{ - "href" => url, - "type" => "Link", - "mediaType" => data["mediaType"] - } - ] - ) - - _ -> + defp handle_href(href, mediaType) do + [ + %{ + "href" => href, + "type" => "Link", + "mediaType" => mediaType + } + ] + end + + defp fix_url(data) do + cond do + is_binary(data["url"]) -> + Map.put(data, "url", handle_href(data["url"], data["mediaType"])) + + is_binary(data["href"]) and data["url"] == nil -> + Map.put(data, "url", handle_href(data["href"], data["mediaType"])) + + true -> data end end diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex index 5d9bf345f..d1869f188 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_validator.ex @@ -41,7 +41,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inReplyTo, :string) - field(:uri, ObjectValidators.Uri) + field(:url, ObjectValidators.Uri) # short identifier for PleromaFE to group statuses by context field(:context_id, :integer) @@ -66,10 +66,24 @@ def cast_data(data) do |> changeset(data) end + defp fix_url(%{"url" => url} = data) when is_list(url) do + attachment = + Enum.find(url, fn x -> is_map(x) and String.starts_with?(x["mimeType"], "audio/") end) + + link_element = Enum.find(url, fn x -> is_map(x) and x["mimeType"] == "text/html" end) + + data + |> Map.put("attachment", [attachment]) + |> Map.put("url", link_element["href"]) + end + + defp fix_url(data), do: data + defp fix(data) do data |> CommonFixes.fix_defaults() |> CommonFixes.fix_attribution() + |> fix_url() end def changeset(struct, data) do @@ -83,7 +97,7 @@ def changeset(struct, data) do def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Audio"]) - |> validate_required([:id, :actor, :attributedTo, :type, :context]) + |> validate_required([:id, :actor, :attributedTo, :type, :context, :attachment]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) |> CommonValidations.validate_actor_presence() diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 60868eae0..b3dbeea57 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -61,9 +61,20 @@ defp fix_context(data, meta) do end end + defp fix_addressing(data, meta) do + if object = meta[:object_data] do + data + |> Map.put_new("to", object["to"] || []) + |> Map.put_new("cc", object["cc"] || []) + else + data + end + end + defp fix(data, meta) do data |> fix_context(meta) + |> fix_addressing(meta) end def validate_data(cng, meta \\ []) 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 14ae29cb6..3e1f13a88 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -35,7 +35,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inReplyTo, ObjectValidators.ObjectID) - field(:uri, ObjectValidators.Uri) + field(:url, ObjectValidators.Uri) field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index a7ca42b2f..712047424 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -43,7 +43,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:like_count, :integer, default: 0) field(:announcement_count, :integer, default: 0) field(:inReplyTo, ObjectValidators.ObjectID) - field(:uri, ObjectValidators.Uri) + field(:url, ObjectValidators.Uri) # short identifier for PleromaFE to group statuses by context field(:context_id, :integer) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 6be17e0ed..7c860af9f 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -276,13 +276,12 @@ def fix_url(%{"url" => url} = object) when is_map(url) do Map.put(object, "url", url["href"]) end - def fix_url(%{"type" => object_type, "url" => url} = object) - when object_type in ["Video", "Audio"] and is_list(url) do + def fix_url(%{"type" => "Video", "url" => url} = object) when is_list(url) do attachment = Enum.find(url, fn x -> media_type = x["mediaType"] || x["mimeType"] || "" - is_map(x) and String.starts_with?(media_type, ["audio/", "video/"]) + is_map(x) and String.starts_with?(media_type, "video/") end) link_element = diff --git a/test/fixtures/tesla_mock/funkwhale_create_audio.json b/test/fixtures/tesla_mock/funkwhale_create_audio.json new file mode 100644 index 000000000..fe6059cbf --- /dev/null +++ b/test/fixtures/tesla_mock/funkwhale_create_audio.json @@ -0,0 +1,58 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + "https://funkwhale.audio/ns", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "Hashtag": "as:Hashtag" + } + ], + "type": "Create", + "id": "https://channels.tests.funkwhale.audio/federation/music/uploads/42342395-0208-4fee-a38d-259a6dae0871/activity", + "actor": "https://channels.tests.funkwhale.audio/federation/actors/compositions", + "object": { + "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/web/activity_pub/transmogrifier/audio_handling_test.exs b/test/web/activity_pub/transmogrifier/audio_handling_test.exs index c74a9c45d..9cb53c48b 100644 --- a/test/web/activity_pub/transmogrifier/audio_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/audio_handling_test.exs @@ -12,6 +12,11 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.AudioHandlingTest do import Pleroma.Factory + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + test "it works for incoming listens" do _user = insert(:user, ap_id: "http://mastodon.example.org/users/admin") @@ -42,4 +47,34 @@ test "it works for incoming listens" do assert object.data["album"] == "lain radio" assert object.data["length"] == 180_000 end + + test "Funkwhale Audio object" do + data = File.read!("test/fixtures/tesla_mock/funkwhale_create_audio.json") |> Poison.decode!() + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + + assert object = Object.normalize(activity, false) + + assert object.data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + + assert object.data["cc"] == [] + + assert object.data["url"] == "https://channels.tests.funkwhale.audio/library/tracks/74" + + assert object.data["attachment"] == [ + %{ + "mediaType" => "audio/ogg", + "type" => "Link", + "name" => nil, + "url" => [ + %{ + "href" => + "https://channels.tests.funkwhale.audio/api/v1/listen/3901e5d8-0445-49d5-9711-e096cf32e515/?upload=42342395-0208-4fee-a38d-259a6dae0871&download=false", + "mediaType" => "audio/ogg", + "type" => "Link" + } + ] + } + ] + end end diff --git a/test/web/activity_pub/transmogrifier/question_handling_test.exs b/test/web/activity_pub/transmogrifier/question_handling_test.exs index 9fb965d7f..c82361828 100644 --- a/test/web/activity_pub/transmogrifier/question_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/question_handling_test.exs @@ -24,6 +24,8 @@ test "Mastodon Question activity" do object = Object.normalize(activity, false) + assert object.data["url"] == "https://mastodon.sdf.org/@rinpatch/102070944809637304" + assert object.data["closed"] == "2019-05-11T09:03:36Z" assert object.data["context"] == activity.data["context"] -- cgit v1.2.3 From 7dc275b69bbd50e7a6944c76c5541c0a9c41a051 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Tue, 18 Aug 2020 18:21:34 +0300 Subject: relay fix for admin-fe --- docs/API/admin_api.md | 48 ++++++++++++++----- lib/mix/tasks/pleroma/relay.ex | 10 +++- lib/pleroma/following_relationship.ex | 8 ++++ lib/pleroma/user.ex | 17 +++---- lib/pleroma/web/activity_pub/activity_pub.ex | 7 ++- lib/pleroma/web/activity_pub/builder.ex | 2 +- lib/pleroma/web/activity_pub/relay.ex | 56 +++++++++++----------- .../web/admin_api/controllers/relay_controller.ex | 2 +- .../api_spec/operations/admin/relay_operation.ex | 50 +++++++++++-------- test/tasks/relay_test.exs | 10 ++-- .../activity_pub/activity_pub_controller_test.exs | 2 +- .../controllers/relay_controller_test.exs | 15 ++++-- 12 files changed, 138 insertions(+), 89 deletions(-) diff --git a/docs/API/admin_api.md b/docs/API/admin_api.md index 05e63b528..c0ea074f0 100644 --- a/docs/API/admin_api.md +++ b/docs/API/admin_api.md @@ -313,31 +313,53 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret - On failure: `Not found` - On success: JSON array of user's latest statuses +## `GET /api/pleroma/admin/relay` + +### List Relays + +Params: none +Response: + +* On success: JSON array of relays + +```json +[ + {"actor": "https://example.com/relay", "followed_back": true}, + {"actor": "https://example2.com/relay", "followed_back": false} +] +``` + ## `POST /api/pleroma/admin/relay` ### Follow a Relay -- Params: - - `relay_url` -- Response: - - On success: URL of the followed relay +Params: + +* `relay_url` + +Response: + +* On success: relay json object + +```json +{"actor": "https://example.com/relay", "followed_back": true} +``` ## `DELETE /api/pleroma/admin/relay` ### Unfollow a Relay -- Params: - - `relay_url` -- Response: - - On success: URL of the unfollowed relay +Params: -## `GET /api/pleroma/admin/relay` +* `relay_url` -### List Relays +Response: -- Params: none -- Response: - - On success: JSON array of relays +* On success: URL of the unfollowed relay + +```json +{"https://example.com/relay"} +``` ## `POST /api/pleroma/admin/users/invite_token` diff --git a/lib/mix/tasks/pleroma/relay.ex b/lib/mix/tasks/pleroma/relay.ex index c3312507e..a6d8d6c1c 100644 --- a/lib/mix/tasks/pleroma/relay.ex +++ b/lib/mix/tasks/pleroma/relay.ex @@ -35,10 +35,16 @@ def run(["unfollow", target]) do def run(["list"]) do start_pleroma() - with {:ok, list} <- Relay.list(true) do - list |> Enum.each(&shell_info(&1)) + with {:ok, list} <- Relay.list() do + Enum.each(list, &print_relay_url/1) else {:error, e} -> shell_error("Error while fetching relay subscription list: #{inspect(e)}") end end + + defp print_relay_url(%{followed_back: false} = relay) do + shell_info("#{relay.actor} - no Accept received (relay didn't follow back)") + end + + defp print_relay_url(relay), do: shell_info(relay.actor) end diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 83b366dd4..2039a259d 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -264,4 +264,12 @@ defp validate_following_id_follower_id_inequality(%Changeset{} = changeset) do end end) end + + @spec following_ap_ids(User.t()) :: [String.t()] + def following_ap_ids(%User{} = user) do + user + |> following_query() + |> select([r, u], u.ap_id) + |> Repo.all() + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index a9820affa..d2ad9516f 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -247,6 +247,13 @@ def unquote(:"#{outgoing_relation_target}_ap_ids")(user, restrict_deactivated? \ end end + defdelegate following_count(user), to: FollowingRelationship + defdelegate following(user), to: FollowingRelationship + defdelegate following?(follower, followed), to: FollowingRelationship + defdelegate following_ap_ids(user), to: FollowingRelationship + defdelegate get_follow_requests(user), to: FollowingRelationship + defdelegate search(query, opts \\ []), to: User.Search + @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>> @@ -372,8 +379,6 @@ def restrict_deactivated(query) do from(u in query, where: u.deactivated != ^true) end - defdelegate following_count(user), to: FollowingRelationship - defp truncate_fields_param(params) do if Map.has_key?(params, :fields) do Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) @@ -868,8 +873,6 @@ def follow_all(follower, followeds) do set_cache(follower) end - defdelegate following(user), to: FollowingRelationship - def follow(%User{} = follower, %User{} = followed, state \\ :follow_accept) do deny_follow_blocked = Config.get([:user, :deny_follow_blocked]) @@ -923,8 +926,6 @@ defp do_unfollow(%User{} = follower, %User{} = followed) do end end - defdelegate following?(follower, followed), to: FollowingRelationship - @doc "Returns follow state as Pleroma.FollowingRelationship.State value" def get_follow_state(%User{} = follower, %User{} = following) do following_relationship = FollowingRelationship.get(follower, following) @@ -1189,8 +1190,6 @@ def get_friends_ids(user, page \\ nil) do |> Repo.all() end - defdelegate get_follow_requests(user), to: FollowingRelationship - def increase_note_count(%User{} = user) do User |> where(id: ^user.id) @@ -2163,8 +2162,6 @@ def get_ap_ids_by_nicknames(nicknames) do |> Repo.all() end - defdelegate search(query, opts \\ []), to: User.Search - defp put_password_hash( %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset ) do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index bde1fe708..04478bc33 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1344,9 +1344,8 @@ def fetch_and_prepare_user_from_ap_id(ap_id) do end def maybe_handle_clashing_nickname(data) do - nickname = data[:nickname] - - with %User{} = old_user <- User.get_by_nickname(nickname), + with nickname when is_binary(nickname) <- data[:nickname], + %User{} = old_user <- User.get_by_nickname(nickname), {_, false} <- {:ap_id_comparison, data[:ap_id] == old_user.ap_id} do Logger.info( "Found an old user for #{nickname}, the old ap id is #{old_user.ap_id}, new one is #{ @@ -1360,7 +1359,7 @@ def maybe_handle_clashing_nickname(data) do else {:ap_id_comparison, true} -> Logger.info( - "Found an old user for #{nickname}, but the ap id #{data[:ap_id]} is the same as the new user. Race condition? Not changing anything." + "Found an old user for #{data[:nickname]}, but the ap id #{data[:ap_id]} is the same as the new user. Race condition? Not changing anything." ) _ -> diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f2392ce79..9a7b7d9de 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -215,7 +215,7 @@ def announce(actor, object, options \\ []) do to = cond do - actor.ap_id == Relay.relay_ap_id() -> + actor.ap_id == Relay.ap_id() -> [actor.follower_address] public? -> diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index b09764d2b..b65710a94 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -10,19 +10,13 @@ defmodule Pleroma.Web.ActivityPub.Relay do alias Pleroma.Web.CommonAPI require Logger - @relay_nickname "relay" + @nickname "relay" - def get_actor do - actor = - relay_ap_id() - |> User.get_or_create_service_actor_by_ap_id(@relay_nickname) + @spec ap_id() :: String.t() + def ap_id, do: "#{Pleroma.Web.Endpoint.url()}/#{@nickname}" - actor - end - - def relay_ap_id do - "#{Pleroma.Web.Endpoint.url()}/relay" - end + @spec get_actor() :: User.t() | nil + def get_actor, do: User.get_or_create_service_actor_by_ap_id(ap_id(), @nickname) @spec follow(String.t()) :: {:ok, Activity.t()} | {:error, any()} def follow(target_instance) do @@ -61,34 +55,38 @@ def publish(%Activity{data: %{"type" => "Create"}} = activity) do def publish(_), do: {:error, "Not implemented"} - @spec list(boolean()) :: {:ok, [String.t()]} | {:error, any()} - def list(with_not_accepted \\ false) do + @spec list() :: {:ok, [%{actor: String.t(), followed_back: boolean()}]} | {:error, any()} + def list do with %User{} = user <- get_actor() do accepted = user - |> User.following() - |> Enum.map(fn entry -> URI.parse(entry).host end) - |> Enum.uniq() - - list = - if with_not_accepted do - without_accept = - user - |> Pleroma.Activity.following_requests_for_actor() - |> Enum.map(fn a -> URI.parse(a.data["object"]).host <> " (no Accept received)" end) - |> Enum.uniq() + |> following() + |> Enum.map(fn actor -> %{actor: actor, followed_back: true} end) - accepted ++ without_accept - else - accepted - end + without_accept = + user + |> Pleroma.Activity.following_requests_for_actor() + |> Enum.map(fn activity -> %{actor: activity.data["object"], followed_back: false} end) + |> Enum.uniq() - {:ok, list} + {:ok, accepted ++ without_accept} else error -> format_error(error) end end + @spec following() :: [String.t()] + def following do + get_actor() + |> following() + end + + defp following(user) do + user + |> User.following_ap_ids() + |> Enum.uniq() + end + defp format_error({:error, error}), do: format_error(error) defp format_error(error) do diff --git a/lib/pleroma/web/admin_api/controllers/relay_controller.ex b/lib/pleroma/web/admin_api/controllers/relay_controller.ex index cf9f3a14b..95d06dde7 100644 --- a/lib/pleroma/web/admin_api/controllers/relay_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/relay_controller.ex @@ -39,7 +39,7 @@ def follow(%{assigns: %{user: admin}, body_params: %{relay_url: target}} = conn, target: target }) - json(conn, target) + json(conn, %{actor: target, followed_back: target in Relay.following()}) else _ -> conn diff --git a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex index 67ee5eee0..e06b2d164 100644 --- a/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/relay_operation.ex @@ -27,8 +27,7 @@ def index_operation do properties: %{ relays: %Schema{ type: :array, - items: %Schema{type: :string}, - example: ["lain.com", "mstdn.io"] + items: relay() } } }) @@ -43,19 +42,9 @@ def follow_operation do operationId: "AdminAPI.RelayController.follow", security: [%{"oAuth" => ["write:follows"]}], parameters: admin_api_params(), - requestBody: - request_body("Parameters", %Schema{ - type: :object, - properties: %{ - relay_url: %Schema{type: :string, format: :uri} - } - }), + requestBody: request_body("Parameters", relay_url()), responses: %{ - 200 => - Operation.response("Status", "application/json", %Schema{ - type: :string, - example: "http://mastodon.example.org/users/admin" - }) + 200 => Operation.response("Status", "application/json", relay()) } } end @@ -67,13 +56,7 @@ def unfollow_operation do operationId: "AdminAPI.RelayController.unfollow", security: [%{"oAuth" => ["write:follows"]}], parameters: admin_api_params(), - requestBody: - request_body("Parameters", %Schema{ - type: :object, - properties: %{ - relay_url: %Schema{type: :string, format: :uri} - } - }), + requestBody: request_body("Parameters", relay_url()), responses: %{ 200 => Operation.response("Status", "application/json", %Schema{ @@ -83,4 +66,29 @@ def unfollow_operation do } } end + + defp relay do + %Schema{ + type: :object, + properties: %{ + actor: %Schema{ + type: :string, + example: "https://example.com/relay" + }, + followed_back: %Schema{ + type: :boolean, + description: "Is relay followed back by this actor?" + } + } + } + end + + defp relay_url do + %Schema{ + type: :object, + properties: %{ + relay_url: %Schema{type: :string, format: :uri} + } + } + end end diff --git a/test/tasks/relay_test.exs b/test/tasks/relay_test.exs index 79ab72002..e5225b64c 100644 --- a/test/tasks/relay_test.exs +++ b/test/tasks/relay_test.exs @@ -42,7 +42,11 @@ test "relay is followed" do assert activity.data["object"] == target_user.ap_id :ok = Mix.Tasks.Pleroma.Relay.run(["list"]) - assert_receive {:mix_shell, :info, ["mastodon.example.org (no Accept received)"]} + + assert_receive {:mix_shell, :info, + [ + "http://mastodon.example.org/users/admin - no Accept received (relay didn't follow back)" + ]} end end @@ -95,8 +99,8 @@ test "Prints relay subscription list" do :ok = Mix.Tasks.Pleroma.Relay.run(["list"]) - assert_receive {:mix_shell, :info, ["mstdn.io"]} - assert_receive {:mix_shell, :info, ["mastodon.example.org"]} + assert_receive {:mix_shell, :info, ["https://mstdn.io/users/mayuutann"]} + assert_receive {:mix_shell, :info, ["http://mastodon.example.org/users/admin"]} end end end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index ed900d8f8..57988dc1e 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -533,7 +533,7 @@ test "accept follow activity", %{conn: conn} do end) :ok = Mix.Tasks.Pleroma.Relay.run(["list"]) - assert_receive {:mix_shell, :info, ["relay.mastodon.host"]} + assert_receive {:mix_shell, :info, ["https://relay.mastodon.host/actor"]} end @tag capture_log: true diff --git a/test/web/admin_api/controllers/relay_controller_test.exs b/test/web/admin_api/controllers/relay_controller_test.exs index 64086adc5..adadf2b5c 100644 --- a/test/web/admin_api/controllers/relay_controller_test.exs +++ b/test/web/admin_api/controllers/relay_controller_test.exs @@ -39,8 +39,10 @@ test "POST /relay", %{conn: conn, admin: admin} do relay_url: "http://mastodon.example.org/users/admin" }) - assert json_response_and_validate_schema(conn, 200) == - "http://mastodon.example.org/users/admin" + assert json_response_and_validate_schema(conn, 200) == %{ + "actor" => "http://mastodon.example.org/users/admin", + "followed_back" => false + } log_entry = Repo.one(ModerationLog) @@ -59,8 +61,13 @@ test "GET /relay", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/relay") - assert json_response_and_validate_schema(conn, 200)["relays"] -- - ["mastodon.example.org", "mstdn.io"] == [] + assert json_response_and_validate_schema(conn, 200)["relays"] == [ + %{ + "actor" => "http://mastodon.example.org/users/admin", + "followed_back" => true + }, + %{"actor" => "https://mstdn.io/users/mayuutann", "followed_back" => true} + ] end test "DELETE /relay", %{conn: conn, admin: admin} do -- cgit v1.2.3 From fa23d5d3d3dddc5dc8fea893c14d06193a8d997b Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 19 Aug 2020 08:40:26 +0300 Subject: changelog entry --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc0cd8ae..e2210dbec 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/). - Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated. - **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated. - **Breaking:** LDAP: Fallback to local database authentication has been removed for security reasons and lack of a mechanism to ensure the passwords are synchronized when LDAP passwords are updated. -- **Breaking** Changed defaults for `:restrict_unauthenticated` so that when `:instance, :public` is set to `false` then all `:restrict_unauthenticated` items be effectively set to `true`. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`. +- **Breaking** Changed defaults for `:restrict_unauthenticated` so that when `:instance, :public` is set to `false` then all `:restrict_unauthenticated` items be effectively set to `true`. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`.
    API Changes @@ -108,6 +108,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Emoji Packs could not be listed when instance was set to `public: false` - Fix whole_word always returning false on filter get requests - Migrations not working on OTP releases if the database was connected over ssl +- Fix relay following ## [Unreleased (patch)] -- cgit v1.2.3 From 13f6029b4b7aad145c68fe804d34fbff9a371d36 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 19 Aug 2020 08:55:03 +0300 Subject: additional changelog entry --- CHANGELOG.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2210dbec..1ed8445b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,19 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Changed + - **Breaking:** The default descriptions on uploads are now empty. The old behavior (filename as default) can be configured, see the cheat sheet. - **Breaking:** Added the ObjectAgePolicy to the default set of MRFs. This will delist and strip the follower collection of any message received that is older than 7 days. This will stop users from seeing very old messages in the timelines. The messages can still be viewed on the user's page and in conversations. They also still trigger notifications. - **Breaking:** Elixir >=1.9 is now required (was >= 1.8) - **Breaking:** Configuration: `:auto_linker, :opts` moved to `:pleroma, Pleroma.Formatter`. Old config namespace is deprecated. +- **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated. +- **Breaking:** LDAP: Fallback to local database authentication has been removed for security reasons and lack of a mechanism to ensure the passwords are synchronized when LDAP passwords are updated. +- **Breaking** Changed defaults for `:restrict_unauthenticated` so that when `:instance, :public` is set to `false` then all `:restrict_unauthenticated` items be effectively set to `true`. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`. - In Conversations, return only direct messages as `last_status` - Using the `only_media` filter on timelines will now exclude reblog media - MFR policy to set global expiration for all local Create activities - OGP rich media parser merged with TwitterCard - Configuration: `:instance, rewrite_policy` moved to `:mrf, policies`, `:instance, :mrf_transparency` moved to `:mrf, :transparency`, `:instance, :mrf_transparency_exclusions` moved to `:mrf, :transparency_exclusions`. Old config namespace is deprecated. - Configuration: `:media_proxy, whitelist` format changed to host with scheme (e.g. `http://example.com` instead of `example.com`). Domain format is deprecated. -- **Breaking:** Configuration: `:instance, welcome_user_nickname` moved to `:welcome, :direct_message, :sender_nickname`, `:instance, :welcome_message` moved to `:welcome, :direct_message, :message`. Old config namespace is deprecated. -- **Breaking:** LDAP: Fallback to local database authentication has been removed for security reasons and lack of a mechanism to ensure the passwords are synchronized when LDAP passwords are updated. -- **Breaking** Changed defaults for `:restrict_unauthenticated` so that when `:instance, :public` is set to `false` then all `:restrict_unauthenticated` items be effectively set to `true`. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`.
    API Changes @@ -26,29 +27,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** Pleroma API: The routes to update avatar, banner and background have been removed. - **Breaking:** Image description length is limited now. - **Breaking:** Emoji API: changed methods and renamed routes. +- **Breaking:** Notification Settings API for suppressing notifications has been simplified down to `block_from_strangers`. +- **Breaking:** Notification Settings API option for hiding push notification contents has been renamed to `hide_notification_contents`. - MastodonAPI: Allow removal of avatar, banner and background. - Streaming: Repeats of a user's posts will no longer be pushed to the user's stream. - Mastodon API: Added `pleroma.metadata.fields_limits` to /api/v1/instance - Mastodon API: On deletion, returns the original post text. - Mastodon API: Add `pleroma.unread_count` to the Marker entity. -- **Breaking:** Notification Settings API for suppressing notifications - has been simplified down to `block_from_strangers`. -- **Breaking:** Notification Settings API option for hiding push notification - contents has been renamed to `hide_notification_contents` - Mastodon API: Added `pleroma.metadata.post_formats` to /api/v1/instance - Mastodon API (legacy): Allow query parameters for `/api/v1/domain_blocks`, e.g. `/api/v1/domain_blocks?domain=badposters.zone` - Pleroma API: `/api/pleroma/captcha` responses now include `seconds_valid` with an integer value. +
    Admin API Changes +- **Breaking** Changed relay `/api/pleroma/admin/relay` endpoints response format. - Status visibility stats: now can return stats per instance. - - Mix task to refresh counter cache (`mix pleroma.refresh_counter_cache`) +
    ### Removed + - **Breaking:** removed `with_move` parameter from notifications timeline. ### Added -- cgit v1.2.3 From 4727030f59a5d879a579c4bccd0f1612c5221670 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 19 Aug 2020 11:06:03 +0300 Subject: fixes for mix tasks - fix for `mix pleroma.database update_users_following_followers_counts` - raise error, if fetch was unsuccessful in emoji tasks - fix for `pleroma.digest test` task --- lib/mix/pleroma.ex | 2 +- lib/mix/tasks/pleroma/emoji.ex | 10 ++++++---- lib/pleroma/emails/user_email.ex | 31 ++++++++++++++++++++----------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index 074492a46..fe9b0d16c 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -14,7 +14,7 @@ defmodule Mix.Pleroma do :swoosh, :timex ] - @cachex_children ["object", "user"] + @cachex_children ["object", "user", "scrubber"] @doc "Common functions to be reused in mix tasks" def start_pleroma do Pleroma.Config.Holder.save_default() diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index f4eaeac98..8f52ee98d 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -15,7 +15,7 @@ def run(["ls-packs" | args]) do {options, [], []} = parse_global_opts(args) url_or_path = options[:manifest] || default_manifest() - manifest = fetch_and_decode(url_or_path) + manifest = fetch_and_decode!(url_or_path) Enum.each(manifest, fn {name, info} -> to_print = [ @@ -42,7 +42,7 @@ def run(["get-packs" | args]) do url_or_path = options[:manifest] || default_manifest() - manifest = fetch_and_decode(url_or_path) + manifest = fetch_and_decode!(url_or_path) for pack_name <- pack_names do if Map.has_key?(manifest, pack_name) do @@ -92,7 +92,7 @@ def run(["get-packs" | args]) do ]) ) - files = fetch_and_decode(files_loc) + files = fetch_and_decode!(files_loc) IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name])) @@ -243,9 +243,11 @@ def run(["reload"]) do IO.puts("Emoji packs have been reloaded.") end - defp fetch_and_decode(from) do + defp fetch_and_decode!(from) do with {:ok, json} <- fetch(from) do Jason.decode!(json) + else + {:error, error} -> raise "#{from} cannot be fetched. Error: #{error} occur." end end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 313533859..1d8c72ae9 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -107,25 +107,34 @@ def digest_email(user) do |> Enum.filter(&(&1.activity.data["type"] == "Create")) |> Enum.map(fn notification -> object = Pleroma.Object.normalize(notification.activity) - object = update_in(object.data["content"], &format_links/1) - %{ - data: notification, - object: object, - from: User.get_by_ap_id(notification.activity.actor) - } + if not is_nil(object) do + object = update_in(object.data["content"], &format_links/1) + + %{ + data: notification, + object: object, + from: User.get_by_ap_id(notification.activity.actor) + } + end end) + |> Enum.filter(& &1) followers = notifications |> Enum.filter(&(&1.activity.data["type"] == "Follow")) |> Enum.map(fn notification -> - %{ - data: notification, - object: Pleroma.Object.normalize(notification.activity), - from: User.get_by_ap_id(notification.activity.actor) - } + from = User.get_by_ap_id(notification.activity.actor) + + if not is_nil(from) do + %{ + data: notification, + object: Pleroma.Object.normalize(notification.activity), + from: User.get_by_ap_id(notification.activity.actor) + } + end end) + |> Enum.filter(& &1) unless Enum.empty?(mentions) do styling = Config.get([__MODULE__, :styling]) -- cgit v1.2.3 From c68bcae362921a16dfb0995539a97ca521036dd7 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Wed, 19 Aug 2020 12:57:29 +0300 Subject: fix for sometimes failing tests --- test/emails/mailer_test.exs | 4 ++-- test/tasks/digest_test.exs | 2 ++ test/tasks/email_test.exs | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/emails/mailer_test.exs b/test/emails/mailer_test.exs index 3da45056b..9e232d2a0 100644 --- a/test/emails/mailer_test.exs +++ b/test/emails/mailer_test.exs @@ -14,10 +14,10 @@ defmodule Pleroma.Emails.MailerTest do subject: "Pleroma test email", to: [{"Test User", "user1@example.com"}] } - setup do: clear_config([Pleroma.Emails.Mailer, :enabled]) + setup do: clear_config([Pleroma.Emails.Mailer, :enabled], true) test "not send email when mailer is disabled" do - Pleroma.Config.put([Pleroma.Emails.Mailer, :enabled], false) + clear_config([Pleroma.Emails.Mailer, :enabled], false) Mailer.deliver(@email) :timer.sleep(100) diff --git a/test/tasks/digest_test.exs b/test/tasks/digest_test.exs index eefbc8936..0b444c86d 100644 --- a/test/tasks/digest_test.exs +++ b/test/tasks/digest_test.exs @@ -17,6 +17,8 @@ defmodule Mix.Tasks.Pleroma.DigestTest do :ok end + setup do: clear_config([Pleroma.Emails.Mailer, :enabled], true) + describe "pleroma.digest test" do test "Sends digest to the given user" do user1 = insert(:user) diff --git a/test/tasks/email_test.exs b/test/tasks/email_test.exs index 944c07064..c3af7ef68 100644 --- a/test/tasks/email_test.exs +++ b/test/tasks/email_test.exs @@ -16,6 +16,8 @@ defmodule Mix.Tasks.Pleroma.EmailTest do :ok end + setup do: clear_config([Pleroma.Emails.Mailer, :enabled], true) + describe "pleroma.email test" do test "Sends test email with no given address" do mail_to = Config.get([:instance, :email]) -- cgit v1.2.3 From d833d2c1fd3861caa055c2d5c87d549b8f246d30 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 19 Aug 2020 13:37:33 +0200 Subject: CI: Fix release builds once more. --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9e9107ce3..be0dd4773 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -194,7 +194,7 @@ amd64: variables: &release-variables MIX_ENV: prod before_script: &before-release - - apt install cmake -y + - apt-get update && apt-get install -y cmake - echo "import Mix.Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force -- cgit v1.2.3 From c1277be041167be255e966eebf95a5137f49e34b Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 19 Aug 2020 14:36:42 +0200 Subject: AudioHandlingTest: Make mock explicit --- .../web/activity_pub/transmogrifier/audio_handling_test.exs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/web/activity_pub/transmogrifier/audio_handling_test.exs b/test/web/activity_pub/transmogrifier/audio_handling_test.exs index 9cb53c48b..0636d00c5 100644 --- a/test/web/activity_pub/transmogrifier/audio_handling_test.exs +++ b/test/web/activity_pub/transmogrifier/audio_handling_test.exs @@ -12,11 +12,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.AudioHandlingTest do import Pleroma.Factory - setup_all do - Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) - :ok - end - test "it works for incoming listens" do _user = insert(:user, ap_id: "http://mastodon.example.org/users/admin") @@ -49,6 +44,14 @@ test "it works for incoming listens" do end test "Funkwhale Audio object" do + Tesla.Mock.mock(fn + %{url: "https://channels.tests.funkwhale.audio/federation/actors/compositions"} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/funkwhale_channel.json") + } + end) + data = File.read!("test/fixtures/tesla_mock/funkwhale_create_audio.json") |> Poison.decode!() {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) -- cgit v1.2.3 From 36c125a071e1fe5a3c0bb1f33a18ba60965437ab Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Thu, 20 Aug 2020 18:41:42 +0200 Subject: Pipeline Ingestion: Event --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- lib/pleroma/web/activity_pub/object_validator.ex | 17 +++- .../object_validators/event_validator.ex | 96 ++++++++++++++++++++++ .../object_validators/note_validator.ex | 11 ++- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 4 +- lib/pleroma/web/mastodon_api/views/status_view.ex | 19 +---- .../transmogrifier/event_handling_test.exs | 40 +++++++++ test/web/mastodon_api/views/status_view_test.exs | 6 ++ 9 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/event_validator.ex create mode 100644 test/web/activity_pub/transmogrifier/event_handling_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index db1867494..8c5b7dac2 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -85,7 +85,7 @@ defp increase_replies_count_if_reply(%{ defp increase_replies_count_if_reply(_create_data), do: :noop - @object_types ~w[ChatMessage Question Answer Audio] + @object_types ~w[ChatMessage Question Answer Audio Event] @spec persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index d770ce1be..b77c06395 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -23,6 +23,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.EventValidator alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator @@ -43,6 +44,16 @@ def validate(%{"type" => type} = object, meta) end end + def validate(%{"type" => "Event"} = object, meta) do + with {:ok, object} <- + object + |> EventValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def validate(%{"type" => "Follow"} = object, meta) do with {:ok, object} <- object @@ -187,7 +198,7 @@ def validate( %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, meta ) - when objtype in ~w[Question Answer Audio] do + when objtype in ~w[Question Answer Audio Event] do with {:ok, object_data} <- cast_and_apply(object), meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- @@ -225,6 +236,10 @@ def cast_and_apply(%{"type" => "Audio"} = object) do AudioValidator.cast_and_apply(object) end + def cast_and_apply(%{"type" => "Event"} = object) do + EventValidator.cast_and_apply(object) + end + def cast_and_apply(o), do: {:error, {:validator_not_set, o}} # is_struct/1 isn't present in Elixir 1.8.x diff --git a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex new file mode 100644 index 000000000..07e4821a4 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex @@ -0,0 +1,96 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.EventValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + import Ecto.Changeset + + @primary_key false + @derive Jason.Encoder + + # Extends from NoteValidator + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) + # TODO: Write type + field(:tag, {:array, :map}, default: []) + field(:type, :string) + + field(:name, :string) + field(:summary, :string) + field(:content, :string) + + field(:context, :string) + # short identifier for PleromaFE to group statuses by context + field(:context_id, :integer) + + # TODO: Remove actor on objects + field(:actor, ObjectValidators.ObjectID) + + field(:attributedTo, ObjectValidators.ObjectID) + field(:published, ObjectValidators.DateTime) + # TODO: Write type + field(:emoji, :map, default: %{}) + field(:sensitive, :boolean, default: false) + embeds_many(:attachment, AttachmentValidator) + field(:replies_count, :integer, default: 0) + field(:like_count, :integer, default: 0) + field(:announcement_count, :integer, default: 0) + field(:inReplyTo, ObjectValidators.ObjectID) + field(:url, ObjectValidators.Uri) + + field(:likes, {:array, ObjectValidators.ObjectID}, default: []) + field(:announcements, {:array, ObjectValidators.ObjectID}, default: []) + end + + def cast_and_apply(data) do + data + |> cast_data + |> apply_action(:insert) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + defp fix(data) do + data + |> CommonFixes.fix_defaults() + |> CommonFixes.fix_attribution() + end + + def changeset(struct, data) do + data = fix(data) + + struct + |> cast(data, __schema__(:fields) -- [:attachment]) + |> cast_embed(:attachment) + end + + def validate_data(data_cng) do + data_cng + |> validate_inclusion(:type, ["Event"]) + |> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id]) + |> CommonValidations.validate_any_presence([:cc, :to]) + |> CommonValidations.validate_fields_match([:actor, :attributedTo]) + |> CommonValidations.validate_actor_presence() + |> CommonValidations.validate_host_match() + end +end 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 3e1f13a88..20e735619 100644 --- a/lib/pleroma/web/activity_pub/object_validators/note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/note_validator.ex @@ -20,11 +20,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do # TODO: Write type field(:tag, {:array, :map}, default: []) field(:type, :string) + + field(:name, :string) + field(:summary, :string) field(:content, :string) + field(:context, :string) + # short identifier for PleromaFE to group statuses by context + field(:context_id, :integer) + field(:actor, ObjectValidators.ObjectID) field(:attributedTo, ObjectValidators.ObjectID) - field(:summary, :string) field(:published, ObjectValidators.DateTime) # TODO: Write type field(:emoji, :map, default: %{}) @@ -39,9 +45,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator do field(:likes, {:array, :string}, default: []) field(:announcements, {:array, :string}, default: []) - - # see if needed - field(:context_id, :string) end def cast_and_validate(data) do diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 3dc66c60b..a5e2323bd 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -341,7 +341,7 @@ def handle_object_creation(%{"type" => "Answer"} = object_map, meta) do end def handle_object_creation(%{"type" => objtype} = object, meta) - when objtype in ~w[Audio Question] do + when objtype in ~w[Audio Question Event] do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do {:ok, object, meta} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 7c860af9f..76298c4a0 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -460,7 +460,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, options ) - when objtype in ~w{Article Event Note Video Page} do + when objtype in ~w{Article Note Video Page} do actor = Containment.get_actor(data) with nil <- Activity.get_create_by_object_ap_id(object["id"]), @@ -554,7 +554,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype}} = data, _options ) - when objtype in ~w{Question Answer ChatMessage Audio} do + when objtype in ~w{Question Answer ChatMessage Audio Event} do with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 91b41ef59..01b8bb6bb 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -473,23 +473,10 @@ def get_reply_to(%{data: %{"object" => _object}} = activity, _) do end end - def render_content(%{data: %{"type" => object_type}} = object) - 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 - _ -> object.data["content"] || "" - end - end + def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do + url = object.data["url"] || object.data["id"] - def render_content(%{data: %{"type" => object_type}} = object) - when object_type in ["Article", "Page"] do - with summary when not is_nil(summary) and summary != "" <- object.data["name"], - url when is_bitstring(url) <- object.data["url"] do - "

    #{summary}

    #{object.data["content"]}" - else - _ -> object.data["content"] || "" - end + "

    #{name}

    #{object.data["content"]}" end def render_content(object), do: object.data["content"] || "" diff --git a/test/web/activity_pub/transmogrifier/event_handling_test.exs b/test/web/activity_pub/transmogrifier/event_handling_test.exs new file mode 100644 index 000000000..7f1ef2cbd --- /dev/null +++ b/test/web/activity_pub/transmogrifier/event_handling_test.exs @@ -0,0 +1,40 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.EventHandlingTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + alias Pleroma.Object.Fetcher + + test "Mobilizon Event object" do + Tesla.Mock.mock(fn + %{url: "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39"} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/mobilizon.org-event.json") + } + + %{url: "https://mobilizon.org/@tcit"} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/mobilizon.org-user.json") + } + end) + + assert {:ok, object} = + Fetcher.fetch_object_from_id( + "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39" + ) + + assert object.data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] + assert object.data["cc"] == [] + + assert object.data["url"] == + "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39" + + assert object.data["published"] == "2019-12-17T11:33:56Z" + assert object.data["name"] == "Mobilizon Launching Party" + 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 8703d5ba7..70d829979 100644 --- a/test/web/mastodon_api/views/status_view_test.exs +++ b/test/web/mastodon_api/views/status_view_test.exs @@ -517,6 +517,12 @@ test "a Mobilizon event" do represented = StatusView.render("show.json", %{for: user, activity: activity}) assert represented[:id] == to_string(activity.id) + + assert represented[:url] == + "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39" + + assert represented[:content] == + "

    Mobilizon Launching Party

    Mobilizon is now federated! 🎉

    You can view this event from other instances if they are subscribed to mobilizon.org, and soon directly from Mastodon and Pleroma. It is possible that you may see some comments from other instances, including Mastodon ones, just below.

    With a Mobilizon account on an instance, you may participate at events from other instances and add comments on events.

    Of course, it's still a work in progress: if reports made from an instance on events and comments can be federated, you can't block people right now, and moderators actions are rather limited, but this will definitely get fixed over time until first stable version next year.

    Anyway, if you want to come up with some feedback, head over to our forum or - if you feel you have technical skills and are familiar with it - on our Gitlab repository.

    Also, to people that want to set Mobilizon themselves even though we really don't advise to do that for now, we have a little documentation but it's quite the early days and you'll probably need some help. No worries, you can chat with us on our Forum or though our Matrix channel.

    Check our website for more informations and follow us on Twitter or Mastodon.

    " end describe "build_tags/1" do -- cgit v1.2.3 From 1f8c32b773b56e950285ce96cb9a62f045f2a225 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Fri, 21 Aug 2020 10:38:56 +0300 Subject: adding actor type in user show --- lib/pleroma/web/admin_api/views/account_view.ex | 3 +- .../controllers/admin_api_controller_test.exs | 72 ++++++++++++++-------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 333e72e42..9c477feab 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -79,7 +79,8 @@ def render("show.json", %{user: user}) do "confirmation_pending" => user.confirmation_pending, "approval_pending" => user.approval_pending, "url" => user.uri || user.ap_id, - "registration_reason" => user.registration_reason + "registration_reason" => user.registration_reason, + "actor_type" => user.actor_type } end diff --git a/test/web/admin_api/controllers/admin_api_controller_test.exs b/test/web/admin_api/controllers/admin_api_controller_test.exs index 2eb698807..dbf478edf 100644 --- a/test/web/admin_api/controllers/admin_api_controller_test.exs +++ b/test/web/admin_api/controllers/admin_api_controller_test.exs @@ -381,7 +381,8 @@ test "Show", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } assert expected == json_response(conn, 200) @@ -663,7 +664,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => admin.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" }, %{ "deactivated" => user.deactivated, @@ -677,7 +679,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" }, %{ "deactivated" => user2.deactivated, @@ -691,7 +694,8 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do "confirmation_pending" => false, "approval_pending" => true, "url" => user2.ap_id, - "registration_reason" => "I'm a chill dude" + "registration_reason" => "I'm a chill dude", + "actor_type" => "Person" } ] |> Enum.sort_by(& &1["nickname"]) @@ -766,7 +770,8 @@ test "regular search", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -794,7 +799,8 @@ test "search by domain", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -822,7 +828,8 @@ test "search by full nickname", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -850,7 +857,8 @@ test "search by display name", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -878,7 +886,8 @@ test "search by email", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -906,7 +915,8 @@ test "regular search with page size", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -929,7 +939,8 @@ test "regular search with page size", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user2.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -964,7 +975,8 @@ test "only local users" do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -992,7 +1004,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" }, %{ "deactivated" => admin.deactivated, @@ -1006,7 +1019,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => admin.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" }, %{ "deactivated" => false, @@ -1020,7 +1034,8 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => old_admin.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] |> Enum.sort_by(& &1["nickname"]) @@ -1058,7 +1073,8 @@ test "only unapproved users", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => true, "url" => user.ap_id, - "registration_reason" => "Plz let me in!" + "registration_reason" => "Plz let me in!", + "actor_type" => "Person" } ] |> Enum.sort_by(& &1["nickname"]) @@ -1091,7 +1107,8 @@ test "load only admins", %{conn: conn, admin: admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => admin.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" }, %{ "deactivated" => false, @@ -1105,7 +1122,8 @@ test "load only admins", %{conn: conn, admin: admin} do "confirmation_pending" => false, "approval_pending" => false, "url" => second_admin.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] |> Enum.sort_by(& &1["nickname"]) @@ -1140,7 +1158,8 @@ test "load only moderators", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => moderator.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -1168,7 +1187,8 @@ test "load users with tags list", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user1.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" }, %{ "deactivated" => false, @@ -1182,7 +1202,8 @@ test "load users with tags list", %{conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => user2.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] |> Enum.sort_by(& &1["nickname"]) @@ -1245,7 +1266,8 @@ test "it works with multiple filters" do "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -1272,7 +1294,8 @@ test "it omits relay user", %{admin: admin, conn: conn} do "confirmation_pending" => false, "approval_pending" => false, "url" => admin.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } ] } @@ -1357,7 +1380,8 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi "confirmation_pending" => false, "approval_pending" => false, "url" => user.ap_id, - "registration_reason" => nil + "registration_reason" => nil, + "actor_type" => "Person" } log_entry = Repo.one(ModerationLog) -- cgit v1.2.3 From 6e5678b5af62231bf8c18a15b91fed5ecb30e625 Mon Sep 17 00:00:00 2001 From: Angelina Filippova Date: Mon, 24 Aug 2020 22:43:37 +0300 Subject: Add Pleroma.Web.Preload to description.exs --- config/description.exs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/config/description.exs b/config/description.exs index e27abf40f..ebe1f11c4 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3571,5 +3571,24 @@ ] } ] + }, + %{ + group: :pleroma, + key: Pleroma.Web.Preload, + type: :group, + description: "Preload-related settings", + children: [ + %{ + key: :providers, + type: {:list, :module}, + description: "List of preload providers to enable", + suggestions: [ + Pleroma.Web.Preload.Providers.Instance, + Pleroma.Web.Preload.Providers.User, + Pleroma.Web.Preload.Providers.Timelines, + Pleroma.Web.Preload.Providers.StatusNet + ] + } + ] } ] -- cgit v1.2.3 From 16f777a7b2f6c80f6239834707a41b7cf8064e9a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Mon, 24 Aug 2020 23:13:35 +0200 Subject: clients.md: Remove Nekonium, Twidere-iOS, Feather; Add DashFE and BloatFE --- docs/clients.md | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/docs/clients.md b/docs/clients.md index 2a42c659f..f84295b1f 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -6,11 +6,11 @@ Feel free to contact us to be added to this list! ### Roma for Desktop - Homepage: - Source Code: -- Platforms: Windows, Mac, (Linux?) +- Platforms: Windows, Mac, Linux - Features: Streaming Ready ### Social -- Source Code: +- Source Code: - Contact: [@brainblasted@social.libre.fi](https://social.libre.fi/users/brainblasted) - Platforms: Linux (GNOME) - Note(2019-01-28): Not at a pre-alpha stage yet @@ -35,7 +35,7 @@ Feel free to contact us to be added to this list! - Source Code: - Contact: [@fedilab@framapiaf.org](https://framapiaf.org/users/fedilab) - Platforms: Android -- Features: Streaming Ready, Moderation, Text Formatting +- Features: Streaming Ready, Moderation, Text Formatting ### Kyclos - Source Code: @@ -48,16 +48,9 @@ Feel free to contact us to be added to this list! - Platforms: Android - Features: No Streaming, Emoji Reactions, Text Formatting, FE Stickers -### Nekonium -- Homepage: [F-Droid Repository](https://repo.gdgd.jp.net/), [Google Play](https://play.google.com/store/apps/details?id=com.apps.nekonium), [Amazon](https://www.amazon.co.jp/dp/B076FXPRBC/) -- Source: -- Contact: [@lin@pleroma.gdgd.jp.net](https://pleroma.gdgd.jp.net/users/lin) -- Platforms: Android -- Features: Streaming Ready - ### Fedi - Homepage: -- Source Code: Proprietary, but free +- Source Code: Proprietary, but gratis - Platforms: iOS, Android - Features: Pleroma-specific features like Reactions @@ -70,9 +63,9 @@ Feel free to contact us to be added to this list! ### Twidere - Homepage: -- Source Code: , +- Source Code: - Contact: -- Platform: Android, iOS +- Platform: Android - Features: No Streaming ### Indigenous @@ -89,11 +82,6 @@ Feel free to contact us to be added to this list! - Contact: [@gcupc@glitch.social](https://glitch.social/users/gcupc) - Features: No Streaming -### Feather -- Source Code: -- Contact: [@kaniini@pleroma.site](https://pleroma.site/kaniini) -- Features: No Streaming - ### Halcyon - Source Code: - Contact: [@halcyon@social.csswg.org](https://social.csswg.org/users/halcyon) @@ -107,6 +95,15 @@ Feel free to contact us to be added to this list! - Features: No Streaming ### Sengi +- Homepage: - Source Code: - Contact: [@sengi_app@mastodon.social](https://mastodon.social/users/sengi_app) -- Note(2019-01-28): The development is currently in a early stage. + +### DashFE +- Source Code: +- Contact: [@dashfe@stereophonic.space](https://stereophonic.space/users/dashfe) + +### BloatFE +- Source Code: +- Contact: [@r@freesoftwareextremist.com](https://freesoftwareextremist.com/users/r) +- Features: Does not requires JavaScript -- cgit v1.2.3 From 6d6e43fd09a66740d447e77e9878d9d35bc07414 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 25 Aug 2020 11:49:44 +0200 Subject: Description: Update description. --- config/description.exs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/config/description.exs b/config/description.exs index c50910911..29a657333 100644 --- a/config/description.exs +++ b/config/description.exs @@ -12,20 +12,35 @@ compress: false ] -frontend_options = [ +installed_frontend_options = [ %{ key: "name", label: "Name", type: :string, description: - "Name of the frontend. Valid config must include both `Name` and `Reference` values." + "Name of the installed frontend. Valid config must include both `Name` and `Reference` values." }, %{ key: "ref", label: "Reference", type: :string, description: - "Reference of the frontend to be used. Valid config must include both `Name` and `Reference` values." + "Reference of the installed frontend to be used. Valid config must include both `Name` and `Reference` values." + } +] + +frontend_options = [ + %{ + key: "name", + label: "Name", + type: :string, + description: "Name of the frontend." + }, + %{ + key: "ref", + label: "Reference", + type: :string, + description: "Reference of the frontend to be used." }, %{ key: "git", @@ -3587,13 +3602,13 @@ key: :primary, type: :map, description: "Primary frontend, the one that is served for all pages by default", - children: frontend_options + children: installed_frontend_options }, %{ key: :admin, type: :map, description: "Admin frontend", - children: frontend_options + children: installed_frontend_options }, %{ key: :available, -- cgit v1.2.3 From 49a436ad3e174e1b0b6442f7a777498cc551449c Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 25 Aug 2020 16:07:07 +0200 Subject: Mix: Make rc version explicit. --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index d7c408972..77cf517cb 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.1.0"), + version: version("2.1.0-rc0"), elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), -- cgit v1.2.3 From 112bec52252108de57e708ed47cf43abd9f3b2f1 Mon Sep 17 00:00:00 2001 From: lain Date: Tue, 25 Aug 2020 17:35:59 +0200 Subject: Webfinger: Handle bogus ids better. --- lib/pleroma/web/web_finger/web_finger.ex | 24 ++++++++++++++---------- test/web/web_finger/web_finger_test.exs | 5 +++++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/web_finger/web_finger.ex b/lib/pleroma/web/web_finger/web_finger.ex index 71ccf251a..c4051e63e 100644 --- a/lib/pleroma/web/web_finger/web_finger.ex +++ b/lib/pleroma/web/web_finger/web_finger.ex @@ -149,6 +149,18 @@ def find_lrdd_template(domain) do end end + defp get_address_from_domain(domain, encoded_account) when is_binary(domain) do + case find_lrdd_template(domain) do + {:ok, template} -> + String.replace(template, "{uri}", encoded_account) + + _ -> + "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}" + end + end + + defp get_address_from_domain(_, _), do: nil + @spec finger(String.t()) :: {:ok, map()} | {:error, any()} def finger(account) do account = String.trim_leading(account, "@") @@ -163,16 +175,8 @@ def finger(account) do encoded_account = URI.encode("acct:#{account}") - address = - case find_lrdd_template(domain) do - {:ok, template} -> - String.replace(template, "{uri}", encoded_account) - - _ -> - "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}" - end - - with response <- + with address when is_binary(address) <- get_address_from_domain(domain, encoded_account), + response <- HTTP.get( address, [{"accept", "application/xrd+xml,application/jrd+json"}] diff --git a/test/web/web_finger/web_finger_test.exs b/test/web/web_finger/web_finger_test.exs index f4884e0a2..96fc0bbaa 100644 --- a/test/web/web_finger/web_finger_test.exs +++ b/test/web/web_finger/web_finger_test.exs @@ -40,6 +40,11 @@ test "works for ap_ids" do end describe "fingering" do + test "returns error for nonsensical input" do + assert {:error, _} = WebFinger.finger("bliblablu") + assert {:error, _} = WebFinger.finger("pleroma.social") + end + test "returns error when fails parse xml or json" do user = "invalid_content@social.heldscal.la" assert {:error, %Jason.DecodeError{}} = WebFinger.finger(user) -- cgit v1.2.3 From dcd06488e02408f7e9b459d1da4be2669db0969d Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 26 Aug 2020 07:08:02 +0300 Subject: release MR template: add a note about merging stable changes back to develop --- .gitlab/merge_request_templates/Release.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md index 237f74e00..b2c772696 100644 --- a/.gitlab/merge_request_templates/Release.md +++ b/.gitlab/merge_request_templates/Release.md @@ -3,3 +3,4 @@ * [ ] Compile a changelog * [ ] Create an MR with an announcement to pleroma.social * [ ] Tag the release +* [ ] Merge `stable` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs) -- cgit v1.2.3 From b76839e0087a92aa138b4c0dd57ec26f0c539156 Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 26 Aug 2020 13:53:16 +0200 Subject: Cheatsheet: Remove misleading example --- 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 a09d6b6b2..2f440adf4 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -87,7 +87,7 @@ To add configuration to your config file, you can copy it from the base config. direct_message: [ enabled: true, sender_nickname: "lain", - message: "Hi, @username! Welcome on board!" + message: "Hi! Welcome on board!" ], email: [ enabled: true, -- cgit v1.2.3 From 6da17f88d641b20540ff5b686bfa40f15b7ac0a3 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Wed, 26 Aug 2020 16:31:02 +0300 Subject: fix fast_html build errors with gcc 10 by bumping fast_sanitize Closes #2058 --- mix.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.lock b/mix.lock index 7ec3a0b28..86d0a75d7 100644 --- a/mix.lock +++ b/mix.lock @@ -42,8 +42,8 @@ "ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [: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", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.13.1", "b9f1697f7c9e0cfe15d1a1d737fb169c398803ffcbc57e672aa007e9fd42864c", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4bb550e045def1b4d531a37fb766cbbe1307f7628bf8f0414168b3f52021cce"}, - "fast_html": {:hex, :fast_html, "2.0.1", "e126c74d287768ae78c48938da6711164517300d108a78f8a38993df8d588335", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "bdd6f8525c95ad391a4f10d9a1b3da4cea94078ec8638487aa8c24015ad9393a"}, - "fast_sanitize": {:hex, :fast_sanitize, "0.2.0", "004b40d5bbecda182b6fdba762a51fffd3501e689e8eafe196e1a97eb0caf733", [:mix], [{:fast_html, "~> 2.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "11fcb37f26d272a3a2aff861872bf100be4eeacea69505908b8cdbcea5b0813a"}, + "fast_html": {:hex, :fast_html, "2.0.2", "1fabc408b2baa965cf6399a48796326f2721b21b397a3c667bb3bb88fb9559a4", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "f077e2c1597a6e2678e6cacc64f456a6c6024eb4240092c46d4212496dc59aba"}, + "fast_sanitize": {:hex, :fast_sanitize, "0.2.1", "3302421a988992b6cae08e68f77069e167ff116444183f3302e3c36017a50558", [:mix], [{:fast_html, "~> 2.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bcd2c54e328128515edd1a8fb032fdea7e5581672ba161fc5962d21ecee92502"}, "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.27.0", "6b29a14283f1e2e8fad824bc930eaa9477c462022075df6bea8f0ad811c13599", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "583b8c13697c37179f1f82443bcc7ad2f76fbc0bf4c186606eebd658f7f2631b"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, @@ -68,7 +68,7 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, + "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, @@ -87,7 +87,7 @@ "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.0", "2acfa0db038a7649e0a4614eee970e6ed9a39d191ccd79a03583b51d0da98165", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "b8bbae4b59a676de6b8bd8675eda37bc8b4424812ae429d6fdcb2b039e00003b"}, - "plug": {:hex, :plug, "1.10.3", "c9cebe917637d8db0e759039cc106adca069874e1a9034fd6e3fdd427fd3c283", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "01f9037a2a1de1d633b5a881101e6a444bcabb1d386ca1e00bb273a1f1d9d939"}, + "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, "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"}, -- cgit v1.2.3 From cfc99fe05c31d5e2140c35f3a2d223635dc07a2f Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 26 Aug 2020 15:37:30 +0200 Subject: TimelineController: Keys are atoms now. Closes #2078 Closes #2070 --- .../controllers/timeline_controller.ex | 9 +++-- .../controllers/timeline_controller_test.exs | 40 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 9244316ed..5272790d3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -182,11 +182,10 @@ def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) do with %Pleroma.List{title: _title, following: following} <- Pleroma.List.get(id, user) do params = params - |> Map.new(fn {key, value} -> {to_string(key), value} end) - |> Map.put("type", "Create") - |> Map.put("blocking_user", user) - |> Map.put("user", user) - |> Map.put("muting_user", user) + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:user, user) + |> Map.put(:muting_user, user) # we must filter the following list for the user to avoid leaking statuses the user # does not actually have permission to see (for more info, peruse security issue #270). diff --git a/test/web/mastodon_api/controllers/timeline_controller_test.exs b/test/web/mastodon_api/controllers/timeline_controller_test.exs index 71bac99f7..517cabcff 100644 --- a/test/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/web/mastodon_api/controllers/timeline_controller_test.exs @@ -333,6 +333,46 @@ test "doesn't include DMs from blocked users" do describe "list" do setup do: oauth_access(["read:lists"]) + test "does not contain retoots", %{user: user, conn: conn} do + other_user = insert(:user) + {:ok, activity_one} = CommonAPI.post(user, %{status: "Marisa is cute."}) + {:ok, activity_two} = CommonAPI.post(other_user, %{status: "Marisa is stupid."}) + {:ok, _} = CommonAPI.repeat(activity_one.id, other_user) + + {:ok, list} = Pleroma.List.create("name", user) + {:ok, list} = Pleroma.List.follow(list, other_user) + + conn = get(conn, "/api/v1/timelines/list/#{list.id}") + + assert [%{"id" => id}] = json_response_and_validate_schema(conn, :ok) + + assert id == to_string(activity_two.id) + end + + test "works with pagination", %{user: user, conn: conn} do + other_user = insert(:user) + {:ok, list} = Pleroma.List.create("name", user) + {:ok, list} = Pleroma.List.follow(list, other_user) + + Enum.each(1..30, fn i -> + CommonAPI.post(other_user, %{status: "post number #{i}"}) + end) + + res = + get(conn, "/api/v1/timelines/list/#{list.id}?limit=1") + |> json_response_and_validate_schema(:ok) + + assert length(res) == 1 + + [first] = res + + res = + get(conn, "/api/v1/timelines/list/#{list.id}?max_id=#{first["id"]}&limit=30") + |> json_response_and_validate_schema(:ok) + + assert length(res) == 29 + end + test "list timeline", %{user: user, conn: conn} do other_user = insert(:user) {:ok, _activity_one} = CommonAPI.post(user, %{status: "Marisa is cute."}) -- cgit v1.2.3 From 5ffd20f3b5ea9781d5b17a4a72ba7312cf04936c Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 26 Aug 2020 15:40:00 +0200 Subject: Changelog: Add info about list fixes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0eba0f79..9b616ee57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
    ### Fixed +- Fix list pagination and other list issues. - Support pagination in conversations API - **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again - Fix follower/blocks import when nicknames starts with @ -- cgit v1.2.3 From f1b9e3595df3fed48527feabc116c5122a2019ca Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 26 Aug 2020 15:43:34 -0500 Subject: Update AdminFE build in preparation for 2.1.0 release --- priv/static/adminfe/chunk-0171.82f5a48b.css | Bin 0 -> 4270 bytes priv/static/adminfe/chunk-0171.8dc0d9da.css | Bin 3545 -> 0 bytes priv/static/adminfe/chunk-565e.33809ac8.css | Bin 26965 -> 0 bytes priv/static/adminfe/chunk-565e.aed36fe0.css | Bin 0 -> 26991 bytes priv/static/adminfe/chunk-6e8c.5832dc0a.css | Bin 0 -> 5199 bytes priv/static/adminfe/chunk-6e8c.f7407fd4.css | Bin 4474 -> 0 bytes priv/static/adminfe/chunk-7503.37b33ad8.css | Bin 0 -> 4015 bytes priv/static/adminfe/chunk-7503.c75b68df.css | Bin 3290 -> 0 bytes priv/static/adminfe/index.html | 2 +- priv/static/adminfe/static/js/app.86bfcdf3.js | Bin 203611 -> 0 bytes priv/static/adminfe/static/js/app.86bfcdf3.js.map | Bin 449775 -> 0 bytes priv/static/adminfe/static/js/app.ad6a566b.js | Bin 0 -> 204431 bytes priv/static/adminfe/static/js/app.ad6a566b.js.map | Bin 0 -> 451525 bytes priv/static/adminfe/static/js/chunk-0171.006185b1.js | Bin 0 -> 25208 bytes .../adminfe/static/js/chunk-0171.006185b1.js.map | Bin 0 -> 97447 bytes priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js | Bin 24601 -> 0 bytes .../adminfe/static/js/chunk-0171.9ad03c0e.js.map | Bin 94865 -> 0 bytes priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js | Bin 126482 -> 0 bytes .../adminfe/static/js/chunk-565e.32b3b7b0.js.map | Bin 426950 -> 0 bytes priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js | Bin 0 -> 127324 bytes .../adminfe/static/js/chunk-565e.61a8d6f8.js.map | Bin 0 -> 429257 bytes priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js | Bin 26275 -> 0 bytes .../adminfe/static/js/chunk-6e8c.46fda72d.js.map | Bin 86864 -> 0 bytes priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js | Bin 0 -> 27154 bytes .../adminfe/static/js/chunk-6e8c.ca3f6a22.js.map | Bin 0 -> 90354 bytes priv/static/adminfe/static/js/chunk-7503.4ad05fac.js | Bin 0 -> 19166 bytes .../adminfe/static/js/chunk-7503.4ad05fac.js.map | Bin 0 -> 64883 bytes priv/static/adminfe/static/js/chunk-7503.ee7af549.js | Bin 18559 -> 0 bytes .../adminfe/static/js/chunk-7503.ee7af549.js.map | Bin 62271 -> 0 bytes priv/static/adminfe/static/js/runtime.aaeb14f8.js | Bin 0 -> 4260 bytes priv/static/adminfe/static/js/runtime.aaeb14f8.js.map | Bin 0 -> 17283 bytes priv/static/adminfe/static/js/runtime.ba9393f3.js | Bin 4260 -> 0 bytes priv/static/adminfe/static/js/runtime.ba9393f3.js.map | Bin 17283 -> 0 bytes 33 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/chunk-0171.82f5a48b.css delete mode 100644 priv/static/adminfe/chunk-0171.8dc0d9da.css delete mode 100644 priv/static/adminfe/chunk-565e.33809ac8.css create mode 100644 priv/static/adminfe/chunk-565e.aed36fe0.css create mode 100644 priv/static/adminfe/chunk-6e8c.5832dc0a.css delete mode 100644 priv/static/adminfe/chunk-6e8c.f7407fd4.css create mode 100644 priv/static/adminfe/chunk-7503.37b33ad8.css delete mode 100644 priv/static/adminfe/chunk-7503.c75b68df.css delete mode 100644 priv/static/adminfe/static/js/app.86bfcdf3.js delete mode 100644 priv/static/adminfe/static/js/app.86bfcdf3.js.map create mode 100644 priv/static/adminfe/static/js/app.ad6a566b.js create mode 100644 priv/static/adminfe/static/js/app.ad6a566b.js.map create mode 100644 priv/static/adminfe/static/js/chunk-0171.006185b1.js create mode 100644 priv/static/adminfe/static/js/chunk-0171.006185b1.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js delete mode 100644 priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js delete mode 100644 priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map create mode 100644 priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js create mode 100644 priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js delete mode 100644 priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map create mode 100644 priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js create mode 100644 priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js.map create mode 100644 priv/static/adminfe/static/js/chunk-7503.4ad05fac.js create mode 100644 priv/static/adminfe/static/js/chunk-7503.4ad05fac.js.map delete mode 100644 priv/static/adminfe/static/js/chunk-7503.ee7af549.js delete mode 100644 priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map create mode 100644 priv/static/adminfe/static/js/runtime.aaeb14f8.js create mode 100644 priv/static/adminfe/static/js/runtime.aaeb14f8.js.map delete mode 100644 priv/static/adminfe/static/js/runtime.ba9393f3.js delete mode 100644 priv/static/adminfe/static/js/runtime.ba9393f3.js.map diff --git a/priv/static/adminfe/chunk-0171.82f5a48b.css b/priv/static/adminfe/chunk-0171.82f5a48b.css new file mode 100644 index 000000000..45340d06b Binary files /dev/null and b/priv/static/adminfe/chunk-0171.82f5a48b.css differ diff --git a/priv/static/adminfe/chunk-0171.8dc0d9da.css b/priv/static/adminfe/chunk-0171.8dc0d9da.css deleted file mode 100644 index 824bddc85..000000000 Binary files a/priv/static/adminfe/chunk-0171.8dc0d9da.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-565e.33809ac8.css b/priv/static/adminfe/chunk-565e.33809ac8.css deleted file mode 100644 index 063b0b35d..000000000 Binary files a/priv/static/adminfe/chunk-565e.33809ac8.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-565e.aed36fe0.css b/priv/static/adminfe/chunk-565e.aed36fe0.css new file mode 100644 index 000000000..c126f246e Binary files /dev/null and b/priv/static/adminfe/chunk-565e.aed36fe0.css differ diff --git a/priv/static/adminfe/chunk-6e8c.5832dc0a.css b/priv/static/adminfe/chunk-6e8c.5832dc0a.css new file mode 100644 index 000000000..76f698880 Binary files /dev/null and b/priv/static/adminfe/chunk-6e8c.5832dc0a.css differ diff --git a/priv/static/adminfe/chunk-6e8c.f7407fd4.css b/priv/static/adminfe/chunk-6e8c.f7407fd4.css deleted file mode 100644 index 6936755b9..000000000 Binary files a/priv/static/adminfe/chunk-6e8c.f7407fd4.css and /dev/null differ diff --git a/priv/static/adminfe/chunk-7503.37b33ad8.css b/priv/static/adminfe/chunk-7503.37b33ad8.css new file mode 100644 index 000000000..cc1e824b8 Binary files /dev/null and b/priv/static/adminfe/chunk-7503.37b33ad8.css differ diff --git a/priv/static/adminfe/chunk-7503.c75b68df.css b/priv/static/adminfe/chunk-7503.c75b68df.css deleted file mode 100644 index 93d3eac84..000000000 Binary files a/priv/static/adminfe/chunk-7503.c75b68df.css and /dev/null differ diff --git a/priv/static/adminfe/index.html b/priv/static/adminfe/index.html index 5214cc94f..5e1ace11d 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.86bfcdf3.js b/priv/static/adminfe/static/js/app.86bfcdf3.js deleted file mode 100644 index 083555948..000000000 Binary files a/priv/static/adminfe/static/js/app.86bfcdf3.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.86bfcdf3.js.map b/priv/static/adminfe/static/js/app.86bfcdf3.js.map deleted file mode 100644 index 1e35d9d3d..000000000 Binary files a/priv/static/adminfe/static/js/app.86bfcdf3.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/app.ad6a566b.js b/priv/static/adminfe/static/js/app.ad6a566b.js new file mode 100644 index 000000000..339ba292b Binary files /dev/null and b/priv/static/adminfe/static/js/app.ad6a566b.js differ diff --git a/priv/static/adminfe/static/js/app.ad6a566b.js.map b/priv/static/adminfe/static/js/app.ad6a566b.js.map new file mode 100644 index 000000000..678fc37c2 Binary files /dev/null and b/priv/static/adminfe/static/js/app.ad6a566b.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0171.006185b1.js b/priv/static/adminfe/static/js/chunk-0171.006185b1.js new file mode 100644 index 000000000..5eb66a811 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0171.006185b1.js differ diff --git a/priv/static/adminfe/static/js/chunk-0171.006185b1.js.map b/priv/static/adminfe/static/js/chunk-0171.006185b1.js.map new file mode 100644 index 000000000..1b3aa9574 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-0171.006185b1.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js b/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js deleted file mode 100644 index 070fe2201..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map b/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map deleted file mode 100644 index 4696152ee..000000000 Binary files a/priv/static/adminfe/static/js/chunk-0171.9ad03c0e.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js b/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js deleted file mode 100644 index b72017611..000000000 Binary files a/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map b/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map deleted file mode 100644 index a2bc8a3cd..000000000 Binary files a/priv/static/adminfe/static/js/chunk-565e.32b3b7b0.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js b/priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js new file mode 100644 index 000000000..92bda657e Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js differ diff --git a/priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js.map b/priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js.map new file mode 100644 index 000000000..606d52b31 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-565e.61a8d6f8.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js b/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js deleted file mode 100644 index f6175a4b5..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map b/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map deleted file mode 100644 index 159876ea9..000000000 Binary files a/priv/static/adminfe/static/js/chunk-6e8c.46fda72d.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js b/priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js new file mode 100644 index 000000000..fea70e93c Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js differ diff --git a/priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js.map b/priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js.map new file mode 100644 index 000000000..d25390a78 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-6e8c.ca3f6a22.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7503.4ad05fac.js b/priv/static/adminfe/static/js/chunk-7503.4ad05fac.js new file mode 100644 index 000000000..d82aface2 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7503.4ad05fac.js differ diff --git a/priv/static/adminfe/static/js/chunk-7503.4ad05fac.js.map b/priv/static/adminfe/static/js/chunk-7503.4ad05fac.js.map new file mode 100644 index 000000000..28070c0b4 Binary files /dev/null and b/priv/static/adminfe/static/js/chunk-7503.4ad05fac.js.map differ diff --git a/priv/static/adminfe/static/js/chunk-7503.ee7af549.js b/priv/static/adminfe/static/js/chunk-7503.ee7af549.js deleted file mode 100644 index 6126d904d..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7503.ee7af549.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map b/priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map deleted file mode 100644 index cf893c61f..000000000 Binary files a/priv/static/adminfe/static/js/chunk-7503.ee7af549.js.map and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.aaeb14f8.js b/priv/static/adminfe/static/js/runtime.aaeb14f8.js new file mode 100644 index 000000000..b300473ed Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.aaeb14f8.js differ diff --git a/priv/static/adminfe/static/js/runtime.aaeb14f8.js.map b/priv/static/adminfe/static/js/runtime.aaeb14f8.js.map new file mode 100644 index 000000000..e783189c8 Binary files /dev/null and b/priv/static/adminfe/static/js/runtime.aaeb14f8.js.map differ diff --git a/priv/static/adminfe/static/js/runtime.ba9393f3.js b/priv/static/adminfe/static/js/runtime.ba9393f3.js deleted file mode 100644 index c66462ab6..000000000 Binary files a/priv/static/adminfe/static/js/runtime.ba9393f3.js and /dev/null differ diff --git a/priv/static/adminfe/static/js/runtime.ba9393f3.js.map b/priv/static/adminfe/static/js/runtime.ba9393f3.js.map deleted file mode 100644 index c167edf90..000000000 Binary files a/priv/static/adminfe/static/js/runtime.ba9393f3.js.map and /dev/null differ -- cgit v1.2.3 From 78939c1d161f09ac38348fc02e8f4a83d8d82d2d Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 27 Aug 2020 12:13:18 +0200 Subject: ChatController: Don't die if the recipient is gone. --- .../web/pleroma_api/controllers/chat_controller.ex | 4 +++- test/web/pleroma_api/controllers/chat_controller_test.exs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index e8a1746d4..1f2e953f7 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -149,7 +149,9 @@ def index(%{assigns: %{user: %{id: user_id} = user}} = conn, _params) do from(c in Chat, where: c.user_id == ^user_id, where: c.recipient not in ^blocked_ap_ids, - order_by: [desc: c.updated_at] + order_by: [desc: c.updated_at], + inner_join: u in User, + on: u.ap_id == c.recipient ) |> Repo.all() diff --git a/test/web/pleroma_api/controllers/chat_controller_test.exs b/test/web/pleroma_api/controllers/chat_controller_test.exs index d71e80d03..7be5fe09c 100644 --- a/test/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/web/pleroma_api/controllers/chat_controller_test.exs @@ -267,6 +267,21 @@ test "it returns a chat", %{conn: conn, user: user} do describe "GET /api/v1/pleroma/chats" do setup do: oauth_access(["read:chats"]) + test "it does not return chats with deleted users", %{conn: conn, user: user} do + recipient = insert(:user) + {:ok, _} = Chat.get_or_create(user.id, recipient.ap_id) + + Pleroma.Repo.delete(recipient) + User.invalidate_cache(recipient) + + result = + conn + |> get("/api/v1/pleroma/chats") + |> json_response_and_validate_schema(200) + + assert length(result) == 0 + end + test "it does not return chats with users you blocked", %{conn: conn, user: user} do recipient = insert(:user) -- cgit v1.2.3 From b141e35d641e733dffe7bd6a45a5bbcafe586c56 Mon Sep 17 00:00:00 2001 From: lain Date: Fri, 28 Aug 2020 11:34:48 +0200 Subject: Mix: Update version --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 77cf517cb..d7c408972 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.1.0-rc0"), + version: version("2.1.0"), elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), -- cgit v1.2.3